Migration plan — one repo to five
- The governing principle
- Phase 0 — unblock, before anything moves
- Phase I — move code to the target shape, still one repo
- Phase II — separate repos, incrementally
- Phase III — build on the harness
- Risks
- What this plan does not decide
Status: proposed, nothing started. This plan is the deliverable of issue #223. It is written to be argued with before any code moves.
The governing principle
Move wiring and script together. Every boundary this migration creates is an
opportunity to reproduce the defect AGENTS.md records from qou commit
39fc90f6: a script deleted while its hook reference survived, or a path
computed from a script’s own location that was correct before a split and wrong
after.
Bean dh4f is the measured cost of that defect at the last split: 30 scripts
triaged, 23 broken, and three of them —
including a CI gate
— silently passing over a corpus they could no longer read. A green check is
the symptom, not the reassurance.
Hence every phase gate below is a positive assertion (this tool read this corpus and found this) rather than a negative one (nothing went red).
Phase 0 — unblock, before anything moves
Phase I is described in the issue as “move code around to prepare for change”. Three things have to be true first, and none of them is a move. Skipping them means discovering in Phase II that the target shape is unreachable.
0.1 — Make the dependency model able to carry the split · decided and built
Decision (2026-09-18, maintainer): load-time registration.
A dependency names a module in its <name>.config.json:
{ "contributes": "./contributions.ts" }
Its default export is called as the root walks the dependency tree, and returns
the block kinds, adapter and MCP tools it adds. schemas/contributions.ts holds
the registry; loadContributions() in schemas/harness-config.ts walks and loads.
Gate met. A synthetic two-repo fixture in which the dependency contributes
one block kind, one adapter and one MCP tool, and the root resolves all three —
plus a test that a kind claimed by a second contributor is refused.
13 tests, schemas/contributions.test.ts.
What the shape costs, and what was done about it
Load-time registration was chosen over a manifest validated before loading. It is cheaper and more familiar, and it makes load order semantically significant — the same five repos in a different listed order are a different program. Two mitigations are built in, and neither changes the chosen shape:
Collisions do not resolve by order. A kind claimed by two contributors
throws, naming both. Last-writer-wins would make adapterForKind ambiguous —
the one property schemas/block-kinds.ts states it must keep — and would make
that ambiguity depend on listing order, which is the hardest kind of bug to
reproduce from a report. A dependency also may not redefine a kind or adapter
the platform already owns: shadowing theorem from a config file two repos away
would change what every existing folio validates against.
A diamond is not a collision. The proposed graph is a diamond —
<dak-layer> → smart-kg → core and <science-layer> → core — so a depth-first
walk reaches core twice. Re-registering identical content from the same
contributor is a no-op. Without that rule every realistic dependency tree throws
a false collision on first load, and the obvious fix (dropping the collision
check) is precisely the one that must not be made.
The dependency entry’s name is authoritative over whatever the contributed module says about itself. A contributor able to rename itself could claim another’s namespace and turn a collision into a silent merge.
A declared-but-missing contributes module is a hard error, not a silent
zero contribution — the AGENTS.md “move wiring and script together” failure
mode, caught at the point it occurs.
Where it lives, and why that mattered
schemas/contributions.ts, not src/core/. The registry is about the content
model — which kinds exist, which adapter owns them — so it belongs with the
model, and putting it under src/ would have added another
cat-harness → <content-core> import, already the largest
wrong-direction group. A mechanism built to enable the split must not deepen
what the split has to undo. Verified: check:partition reports 45 edges before
and after. MCP tool contributions are carried as opaque registrar callbacks for
the same reason, so the MCP SDK type never reaches the content model.
Still open under 0.1
resolveSkillDirs still has no caller, and there is no content-directory
resolver at all. The docstring table in schemas/harness-config.ts has been
corrected to say so rather than claiming both work. Wiring them is finishing
work, not a design question, and does not block Phase I.
0.2 — Replace the filename heuristic with a real dependency scan · done
bun run cat check:partition (scripts/repo-partition.ts) builds the import graph,
partitions it across the five proposed repos, and reports the edges crossing a
boundary in the wrong direction. Gate met: 341 modules, 670 edges, 46
wrong-direction edges named, 0 modules unassigned (the 27 platform
meta-scripts were triaged by hand, and are reported with their own provenance so
a judgement stays visible as a judgement). Full results in
current state.
Scope, which this paragraph did not state until 2026-09-30 (bean p11x).
That scan’s root is one instance, so its edge count is over modules under
that instance only and says nothing about imports between the instances already
sitting side by side in the checkout. The cross-instance axis is a separate
question with a separate tool — see I.1b below.
Two results change the plan below rather than merely confirming it:
smart-kgpartitions to zero modules. There is no L1 code to move, so that repo is new construction like the Test repos, not an extraction. It is re-sequenced accordingly.- 21 of the 46 edges are
cat-harness→ the content core — the harness importing the content-object model, which is its defining constraint failing in practice. Extracting the harness is therefore harder than extracting sci, not easier, and Phase II’s order reflects that.
The count moved 33 → 41 → 45 as classification improved, and stopped only when the unassigned column reached zero. When re-running this, read the unassigned column before the edge count — a low edge count over an unclassified corpus is the same false comfort as a green check over an empty one.
It then reached 46 when ten commits from main merged in: five new harness
modules, one of which imports the content model. The harness → core violation
grows as ordinary work lands, because nothing stops a new src/ module
importing schemas/types.ts. Add check:partition --strict as a CI gate once
the count is driven down — the flag exists for it.
0.3 — Decide what folio is
From the issue comment:
top-level content/ becomes folio/; folio becomes its own schema which
can contain zero or more Content instances; the left-hand navbar gets a
section per node in the folio instance.
The rename is mechanical and wide — 2,408 literal content/ occurrences
across 429 files. The schema is not mechanical and is the part that matters:
today <name>.config.json has a single contentType and a single adapter, so
“a folio” and “a content instance” are the same object. Making a folio a
container of zero-or-more instances is a model change that touches
adapterForKind, the profile check, the render path and the viewer.
Do the schema first and the rename last. A rename lands cleanly on a settled model; a settled model does not land cleanly on a corpus mid-rename.
Phase I — move code to the target shape, still one repo
Everything here happens inside litlfred/folio-assistant. Nothing is
extracted. The output is a repo whose directory structure is the future state,
so Phase II can be git filter-repo plus a <name>.config.json rather than
archaeology.
| # | work | gate |
|---|---|---|
| I.1 | Resolve each wrong-direction cross-edge from 0.2 — invert the dependency, move the module, or record why it is legitimate | both axes below are empty, or every survivor has a written reason |
| I.1a | The within-instance axis: modules under this instance’s root, bucketed into the five proposed repos by path rule — bun run cat check:partition, enforcing in CI | a new wrong-direction edge among this instance’s own modules fails the PR that introduces it |
| I.1b | The cross-instance axis: edges between instances that already exist side by side, checked against each one’s declared needs — bun run cat kg:detangle:direction, blocking in CI | a new edge from an instance into one that declares a dependency on it fails the PR that introduces it |
I.1 takes two gates, and it took one until 2026-09-30. The row above read
“Gate the result: bun run cat check:partition --strict in CI”, which was the
whole gate — and check:partition resolves its ROOT to one instance, so
every 0 wrong-direction edges it prints is scoped to that instance’s own
modules and is silent about edges between the already-extracted siblings.
I.1 was therefore discharged on one instance’s evidence and reported as done.
Bean bf5l measured the gap rather than arguing it. With a real
wrong-direction import in place (cat-harness/schemas/intake.ts importing
folio-assistant-core): check:instance-graph ✓ no cycle, check:partition 0, kg:detangle 1. Its own sentence — “three green checks did not mean
the layering held.”
Bean p11x, owner’s ruling of 2026-09-30 (Option 2): check:partition
stays instance-scoped and kg:detangle owns the cross-instance axis, since it
already sees it. The alternative — widening check:partition’s root to the
checkout — was considered and rejected: its path rules are written against
instance-relative paths, so it would have meant reclassifying ~17 instances’
modules to reach a fact another tool already reports.
The two are not each other’s second opinion. check:partition partitions a
future layout by path rule; kg:detangle:direction checks the present
layout against declared needs. Either can be green while the other is red, and
a survivor recorded against one says nothing about the other. Read
check:partition’s count with the scope it now prints beside it.
| I.2 | Introduce the folio schema (0.3); a folio holds 0..n content instances | content_validate passes on a zero-instance folio and a two-instance folio |
| I.3 | Rename content/ → folio/ | no content/ path literal survives outside intentional content-instance paths; full test suite green and a synthetic-folio run proves each moved tool still reads its corpus |
| I.4 | LHS navbar renders a section per node in the folio instance | a two-instance folio shows two sections; a zero-instance folio renders without error |
| I.5 | Group modules into the five target trees | each tree builds with only its declared dependencies on the path |
| I.6 | Split QA infrastructure (harness) from QA criteria (downstream) | the criterion registry loads zero criteria without erroring; criteria come from a dependency |
| I.7 | Reconcile the three todo-manager.md copies (AGENTS.md records a 188-line divergence and one copy with no CI guard) | one canonical copy; every other is generated and checked |
| I.8 | Every repo declares stub + canonicalUrl and publishes <stub>.jsonld / <stub>.schema.json | bun run cat kg:export in each repo emits artefacts named after that repo, with absolute @ids under its own canonicalUrl |
I.8 is what keeps five repos from becoming five vocabularies. Each split
repo publishes its own knowledge graph, and the graphs are only mergeable if
their node IRIs do not collide. Naming every artefact after its repository and
minting @ids under that repository’s canonicalUrl guarantees that by
construction: cat-harness.jsonld and <content-core>.jsonld cannot
assert the same node IRI, because the document IRI is part of every node’s.
The corollary is the rule the skill states and a test enforces: artefacts are
stub-named, the declaration file is not. The first half still holds; the
second was re-decided on 2026-09-21, and the declaration is now <name>.json
— named for the instance’s name rather than its stub, which is what keeps
it out of the stub-named set.
The concern recorded here was discovery, and it was real: a consumer must
be able to open a repository it has never seen without first knowing what it
is called. It was answered rather than dropped. The failure this paragraph
warned of — a resolver computing the filename from the directory finds
nothing when a repo is cloned under a different name, and reports “no config”
rather than an error — is the failure findDeclarationFile() is built to
avoid: it never computes the name from the directory, but scans for a *.json
whose stem equals the name inside it, and throws when a directory holds
two rather than picking one.
See directory-conventions
§Naming and kg-export.
I.3 is the one to stage carefully. 2,408 occurrences is a scale at which a single sweep is unreviewable. Split it by consumer — pipeline, scripts, docs, workflows, CI — with the wiring for each moving in the same commit as its targets.
Phase II — separate repos, incrementally
One repo at a time, each becoming a folio-assistant instance in its own right.
Order, cheapest-first, each proving something the next needs:
| order | repo | proves | why here |
|---|---|---|---|
| 1 | the science layer | a dependency can contribute schemas, an adapter and MCP tools | hardest mechanism, cleanest boundary — 101 files that already cluster |
| 2 | cat-harness | the harness genuinely does nothing on its own | extracting it from underneath core is how you find out what core assumed |
| 3 | the DAK layer | a dependency-of-a-dependency resolves | the depth-first walk is exercised for real |
| 4 | smart-kg | the L1/L2 line holds | needs WHO context; least-built today |
| 5 | smart-kg-tools, smart-base-tools | the Tool/Content kind split | only worth doing once the parents are stable |
The content core is never extracted: it is what remains.
Per-repo gate — all five required:
git filter-repopreserves history for every moved file.- The extracted repo builds and tests standalone, with its declared dependencies and nothing else on the path.
- The parent, with the extraction declared as a dependency, is green.
- Every moved tool is run against a synthetic folio and asserted to have read
a non-empty corpus — the
dh4fgate. An empty corpus exits non-zero. - No wiring left behind: every hook, workflow and doc reference to a moved script is repointed in the same change.
Rollback: until gate 3 passes, the extracted repo is additive — the parent keeps its copy. The cutover commit is the one that deletes the parent’s copy, and it is the only commit in Phase II worth reverting as a unit.
Phase III — build on the harness
New construction, not migration. L4/L5 Content, Tool and Test repos built off
cat-harness, and healthworker- and individual-facing workflows
(immunizations is the issue’s example).
The Test repos are the substantive new thing, because no Test repo exists today: test data, generation templates, FHIR Test Plans, Gherkin dialects, ITB configuration. Their defining requirement is the taxonomy’s — test data assets are themselves folio content types in the KG — so that SME review of test data is the same workflow, with the same QA sidecars and the same translation path, as review of anything else.
Phase III is the test of whether the split worked. If a healthworker-facing folio has to depend on the science layer to get a working install, the boundary is in the wrong place and Phase II is not finished.
Risks
| risk | why it bites here | mitigation |
|---|---|---|
| Silent-success scripts | proven at the last split: 30 scripts, 23 broken, a CI gate passing over 0 blocks | every gate is a positive assertion over a synthetic folio |
| The dependency model cannot carry it | schemas and MCP tools are ruled out by design today | Phase 0.1 is a hard blocker, sequenced before any move |
| The rename swamps review | 2,408 occurrences across 429 files | staged by consumer; schema before rename |
| Folio specifics leak into a split repo | already the most-repeated defect in AGENTS.md | platform-boundary-guard subagent on every extraction; a TRAP entry per incident |
| Boundaries drawn without domain context | the L1/L2 line needs WHO knowledge this repo does not encode | smart-kg sequenced 4th; the line is drawn by a person, not inferred |
| Five changelogs, one change | a cross-cutting fix now needs five PRs | the ordering test: split only where consumers or cadences differ |
What this plan does not decide
Stated plainly so the gaps are not mistaken for omissions:
- Whether a dependency may contribute adapters and MCP tools (0.1). The largest open question; the science layer is unbuildable until it is answered.
- Where the L1/L2 boundary falls between
smart-kgand the DAK layer. - Whether the viewer splits by content type or exposes a registration point (future state).
- Repo ownership, naming and hosting — org, visibility, release cadence.
- Whether the science layer depends on core only, as the issue states, or also needs harness surface that core does not re-export.