Migrating an MII KDS module IG onto the module template
This skill supports and partly automates the migration. It never publishes, and four human review gates are mandatory. The full procedure, with acceptance criteria per step, is in the migration specification; this file is the operating summary.
Preconditions
Discover the context: assume none of it, create nothing that is missing.
The source guide. Two inputs come from the human and cannot be derived: the URL of the rendered Simplifier IG and the URL of its source GitHub repository. If either is absent, ask for it and stop. Everything else about the module's identity is read, not asked (step 2).
The module repository — classify the source shape. Two shapes are in scope and take different routes to the same place; a third is not. Decide first, and record it (spec §5.1b).
- Shape A — a SUSHI / IG-Publisher project. A
sushi-config.yamlor anig.iniat the repository root plus aninput/fsh/— the FHIR IG ecosystem conventions that make this skill portable. The FSH exists; step 4 transfers it.input/fsh/present but empty → report it; a migration with no artefacts to move is a configuration error, not a no-op. - Shape B — a raw FHIR resource repository. No scaffolding — checked to depth 3 (spec
§5.1b): a SUSHI project nested below root (strukturdaten) is shape A (nested), re-rooted
with a
decisionline — goFSH-ing it discards the author's FSH with the ratio green. This is the normal state of a module authored in Forge and published on Simplifier, and it is fully in scope — the most authentic case this skill exists for. Step 2b derives the FSH with goFSH; from step 3 the two paths are identical. Detect it by content, not by folder name (spec §5.1b.1): a file is a FHIR resource if it parses and carries aresourceType; folder names are hand-chosen and often German, so no conventional-name glob finds them. The rendered guide's narrative is on Simplifier, not in the repository — noimplementation-guides/**tree, so step 1 takes the page structure from the rendered IG andfql-scan.shrightly exits 2 on an empty target set before migration. Not in git is not unobtainable: step 2c harvests it, and skipping that ships the template's starter pages under the module's name. Nor is it "no narrative in the repository": the reference module ships a 43-line GermanREADME.mdand a 126-line CodeSystem mirror. Inventory every narrative-bearing text file, with a disposition each. - Neither — no scaffolding and no FHIR resources: not a FHIR IG project. Say so and stop. Do not scaffold one. Path B is no exception: it creates no artefacts, deriving FSH from resources that already exist, each tracing to a source file (guardrail 3).
- Shape A — a SUSHI / IG-Publisher project. A
The target template. Determine which state the module is in — discovery only here, the skeleton comes in step 3 of the procedure. In every state, read
medizininformatik-initiative/mii-kds-module-templateat the ref you intend to use rather than relying on this skill's description of it.- Already on the module template — an
ig.initemplateentry naming the MII template (the repository URL, the published package id, or the legacy#ig-template); a vendoredig-template/folder marks a pre-2026-08-28 scaffold. A re-migration: report what is in place before changing anything. - Plain Simplifier project — Simplifier files only (
.simplifier/,project.yaml,implementation-guides/), no IG-Publisher scaffolding. The normal starting state. - Hybrid, or on another template — IG-Publisher files beside the Simplifier ones: an
ig.ininaming another template,_genonce.sh& co., a committedfsh-generated/or rendered output (possibly live GitHub Pages). Still a migration. Inventory them and record which the template replaces (ig.ini,_gen*/_update*), which carry content to transfer (input/,fsh-generated/), and which retire only after Gate D. List any unrecognized top-level entry too (e.g.validator/) with a retain/retire proposal — list, do not remove.
- Already on the module template — an
Unreplaced placeholders. The template does not build until every
{{...}}placeholder is replaced, and an unreplaced one ships a bogus artefact rather than failing loudly. Before and after migrating, grep the tree for{{and account for every hit — excluding.github/**(Actions${{ … }}matches the pattern) and counting Simplifier directives in narrative sources as accounted (step-5 material). Census by EXCLUSION (grep -rIl '{{' . --exclude-dir=.git), not an--includelist: it missesig.ini, whose slug killed a publisher run (PROs try-run).The toolchain — invoke SUSHI and goFSH only as a version-pinned
npx. Neither is normally installed (which gofshfinds nothing on the reference machine), so a baresushi/gofshis unrunnable and appears nowhere in this skill: writenpx --yes fsh-sushi@3.20.0andnpx --yes gofsh@2.6.1— the npm package for SUSHI isfsh-sushi, notsushi. What the "no fetching a toolchain" rule protects is an exact, recorded version, which the pin supplies and an unpinnednpxdoes not; let the pin be the record, carried in the log'scmd=token. (allowed-toolsgrantsBash(npx:*);Bash(gofsh:*)never matches annpxcommand line.) goFSH is required for shape B, for shape A only where the source ships JSON/XML; the IG Publisher is needed from step 7. Missing node/npx → say which and stop after step 2. A parent package without snapshots additionally needs java and a pinnedvalidator_cli.jar— fetched only when that condition is actually detected (spec §5.1b.5), never hand-substituted.
Procedure
Written in English; the artefacts operated on are German-language KDS documents, and German terms
of art are kept as such. Output language follows the target template: English is the default
language and German is the translation. This reverses the older convention — see Language
below, and verify it against the target's sushi-config.yaml rather than trusting this sentence.
Resolve the script path first. The commands below name tools relative to this skill's own directory, not to your working directory — which is the project being migrated. Set
SKILL_DIR=<the directory containing this SKILL.md>(e.g..claude/skills/mii-ig-migration) andML="$SKILL_DIR/scripts/migration-log.sh", and use them in every invocation. A barescripts/...from the project root does not merely fail: if the project has its ownscripts/with a same-named file, it silently runs that instead.
Inventory the source — Gate 0 FIRST (spec §9c): run the sibling
fhir-ig-analysison the unmigrated source (bash "$ML" run 1 preflight-analysis …→migration-log/preflight-analysis.json); its artifact counts drive the measured M9 decisions and the §9b CS-absence path,special_url_predictionpre-builds the special-url list, andqa_baseline: Nonemeans: obtain the source QA proof now. Then, from the rendered IG and the source repository, extract every artefact (profiles, extensions, value sets, code systems, capability statements, examples) and the narrative structure, each with its source path →migration-log/source-inventory.json. Whenimplementation-guides/holds several guide trees (versions × languages + shared assets — a real module ships six), apply spec §5.1a: pick the authoritative tree, mark parallel-language trees as harvest seeds, retain the rest. No rendered-IG URL? Discover it — spec §5.1c.bash "$SKILL_DIR/scripts/simplifier-discover.sh" --org ORG --module SLUGwalks org project list → package → project →/<project>/filterprojectguides(no tilde; the~variants return 200 and yield nothing) →/published-guide/<key>/versions→ the server-rendered guide root, WARNing at any hop that yields nothing. Pin a PUBLISHED, read-only version and record it like the source commit SHA — never?version=current, the live editable project. Keys and page slugs are discovered, never constructed (a constructed key 404s; the renderer de-punctuates slugs). The PROJECT page is client-rendered and yields nothing while the GUIDE pages yield everything — generalizing that one measurement to the platform is what once cost this skill the procedure. One instance is not the class, in BOTH directions (spec §5.1c.2a): measure a negative on the exact artefact you claim it about, and before encoding a shape as normative measure it on more than one instance, choosing the one most likely to differ — the same day'sdata-url="/guide/<key>"reading was taken on the one module where every key is bare and silently dropped keys on two others (consent 3 of 3, mikrobiologie 2 of 3, person 0 of 3, all at exit 0).Read the module's identity — do not ask for it, and do not invent it. From the source's
sushi-config.yamlandpackage.json(absent asushi-config.yaml:package.jsonplus theImplementationGuideresource) readtitle,packageId,canonical,status,releaseLabel,license,dependenciesandpublisher, and carry them over unchanged. On disagreementsushi-config.yamlwins — it is what the build reads; record it. A field in neither file comes from the generatedImplementationGuide; absent everywhere it takes the template default, at Gate A. Resolve floating pins (1.5.x) per spec §2.1, recording the pick and its evidence. Shape B often has none of the three files — and a repository carrying no identity is not an identity-less module. Identity is then RECOVERED from several sources in this order, each field recorded with the source it came from (spec §2.1; the scripts write the ledger themselves):- P — the published package.
bash "$SKILL_DIR/scripts/package-identity.sh" --package ID --version VlogspackageId,version,description,fhirVersions,jurisdictionand the dependency pins — source evidence, outranking anydist-tags.latest— plus thecanonicalderived from the packaged resources' own urls by common prefix, unanimous or a WARN, never a majority vote. No manifest carriestitle,licenseorpublisher;authoris a registry account, not a publisher. - R — the source repository.
bash "$SKILL_DIR/scripts/repo-identity.sh" --dir DIR --repo OWNER/NAME --rendered URLreads the LICENSE text's SPDX id — real licence evidence, the field that must never default (§2.2) — the README's first heading as atitlecandidate, the repo description, and the release tags, whose match with P's version ties that release to the commit. An unrecognized licence text yields nothing (license-text-unrecognized:); the GitHub owner is not apublisher. - H — Simplifier. TWO URL SPACES, TWO ANSWERS — do not carry one to the other (spec §2.1.3). The project page (
simplifier.net/<Project>/) is measured client-rendered (HTTP 200, ~56 KB, 52 script markers, no identity metadata in the DOM): a human reference at Gate A for what no machine source carries, not a scrape target, and the script measures that and extracts nothing. The guide pages (simplifier.net/guide/<key>/<Root>[/<Page>]?version=<v>) are a different URL space and are SERVER-RENDERED — measured on Consent 2026.0.0: root 24509 bytes carrying the whole page tree (18 page links), leaf 20481 bytes carrying<h1 id="page-title">and the real German narrative. They yield the narrative, not identity: harvest them per step 2c.
Whatever no tier yields stays Gate A — measured on the reference module,
publisheralone. Every value is claimed with its evidence (bash "$ML" claim 2.1 ACTION FIELD VALUE TIER SOURCE), and a second source with a different value raisesidentity-contradiction:— reported, never resolved (measured: goFSH'sversion: 1.0.8against the package's2026.0.0; a source pin2.0.2againstdist-tags.latest2.0.3).bash "$ML" claims --markdownis the report's identity table. NEVER ALTER EXISTING METADATA from a recovered value, even where the recovery shows it to be inconsistent: recovery is evidence for Gate A. Spec §2.1.2–§2.1.4.Log each value read, and each divergence as a WARN. The target version is the only identity value that is a human decision: MII CalVer
YYYY.n.n, not SemVer, defaulting to the source's. When the source and the template disagree, the source wins — the template'scanonicalandpackageIdpatterns are what a new module gets, and changing a published canonical breaks every consumer. Report each divergence and let a human decide; never normalize silently. That covers every value the template pre-fills as a literal rather than a placeholder,licenseabove all: the template shipsCC-BY-4.0, no placeholder check flags it, and MII modules commonly declareCC0-1.0. Relicensing is a human decision, never a default. Spec §2.2.- P — the published package.
2b. Source shape B only — derive the FSH from the raw resources. Runs before the skeleton,
which step 3 merges into FSH that must already exist. Work in a scratch directory outside the
module repository; skip for shape A. Spec §5.1b is normative here (measured with goFSH
2.6.1 and SUSHI 3.20.0 on medizininformatik-initiative/kerndatensatzmodul-consent).
mkdir -p migration-log
ML="$SKILL_DIR/scripts/migration-log.sh" # run-log helper — see *Run log* below
SRC=<source-repo-root>; OUT=<scratch-dir>; GLOG=migration-log/gofsh.log
SUSHI="npx --yes fsh-sushi@3.20.0"
E() { grep -oE '[0-9]+ Errors' "$1" | tail -1 | cut -d' ' -f1 | grep . || echo n/a; }
bash "$ML" begin "step 2b — Path B on $SRC" # run boundary: re-runs stay separable
rm -rf "$OUT" # goFSH refuses a non-empty -o dir; $OUT is derived, so clearing is safe
N_IN=$(find "$SRC" -type f \( -name '*.json' -o -name '*.xml' \) \
-exec grep -lE '"resourceType"[[:space:]]*:|xmlns="http://hl7\.org/fhir"' {} + \
| wc -l | tr -d ' ') # inputs, BY CONTENT
bash "$ML" info 5.1b.2 gofsh-input "inputs=$N_IN src=$SRC"
bash "$ML" run 5.1b.2 gofsh-convert --raw-log "$GLOG" -- \
npx --yes gofsh@2.6.1 "$SRC" -o "$OUT" -s file-per-definition -t json-and-xml \
-d <parent-ig-package>@<version> -d hl7.fhir.r4.core@4.0.1
GOFSH_EXIT=$?
bash "$SKILL_DIR/scripts/gofsh-results.sh" --log "$GLOG" --inputs "$N_IN" \
--exit $GOFSH_EXIT # <- the mandatory WARN fires here
# A failed conversion is a stop: everything below measures $OUT (spec §5.1b.2).
[ "$GOFSH_EXIT" -eq 0 ] || { bash "$ML" error 5.1b.2 gofsh-convert \
"conversion failed — not measuring \$OUT; fix the cause and re-run"; exit 1; }
bash "$ML" run 5.1b.3 sushi-before --raw-log migration-log/sushi-before.log -- \
bash -c "cd '$OUT' && $SUSHI ." # the 41 of "41 -> 5"
B=$(E migration-log/sushi-before.log); bash "$ML" info 5.1b.3 sushi-before "errors=$B"
bash "$ML" run 5.1b.3 postprocess-gofsh --emits-runlog -- \
python3 "$SKILL_DIR/scripts/postprocess-gofsh.py" "$OUT/input/fsh" --gofsh-log "$GLOG"
bash "$ML" run 5.1b.3 sushi-after --raw-log migration-log/sushi-after.log \
--expected-nonzero 'shape B: unresolvable parents are a Gate-A escalation (§5.1b.4)' -- \
bash -c "cd '$OUT' && $SUSHI ." # the 5
A=$(E migration-log/sushi-after.log) # n/a when a run printed no count (crash/kill)
case "$B$A" in *n/a*) R="resolved=not-measured";; *) R="resolved=$(( B - A ))";; esac
bash "$ML" info 5.1b.3 sushi-after "errors=$A $R before=$B"
Run verbatim, in that order, from the same directory: the post-processor reads $GLOG, and the
E helper reads each SUSHI error count back out of its raw log into an INFO line — that pair
is the 41 → 5 evidence, and it is in the block, not only in the spec. gofsh-results.sh
reads goFSH's own RESULTS table back, labels every cell, counts converted resources only
(never Invariants/Mappings/Aliases) and reconciles them against $N_IN. That reconciliation is
the point of the whole block — goFSH's exit code is not the signal, its counts are — and run
keeps each real exit status, truncating each raw log per invocation so a re-run measures itself
and not the sum of both.
- Point goFSH at the repository ROOT, not at one resource folder. The reference module keeps its 20 resources in five hand-named directories and needs no staging: measured, goFSH walks the tree recursively and the FSH from the root is byte-identical to that from a staged flat directory. Stage only for a recorded reason (spec §5.1b.2).
-t json-and-xmlis mandatory; its absence fails SILENTLY. goFSH defaults tojson-only: on Consent (19 XML + 1 JSON) the flagless run exited 0, reported "0 Errors" and converted exactly ONE resource, warning only that "13 XML definition(s)" lacked a JSON counterpart — goFSH's own count, not the input's 19 files (the difference is the sixSearchParameters); say which you mean. The deciding number is neither: it isconverted 1 of 20, whichgofsh-results.shemits and WARNs on. Reconcile against step 1's inventory, never the exit code.- Declare every foreign parent IG with
-d <package>@<version>, found in the resources' ownbaseDefinitioncanonicals; goFSH's "cannot find a definition for its parent … declaring that IG as a dependency" is the signal. Re-run rather than patch the dependency-less output — the two differ (12 mappings / 14 aliases without, 0 / 8 with). A Forge repo names neither package nor version: resolve it against the FHIR package registry (spec §5.1b.2); no hit is a Gate-A stop. - goFSH writes the
sushi-config.yamlitself but it is a STARTING POINT, NOT IDENTITY: noid/name/title/publisher/packageId/license, an untrustedversion(measured1.0.8— one profile's — against the module's published2026.0.0), anddependenciesthat are only whatever-dset you passed. Recover identity per step 2 instead. - The script's two passes are mechanical:
fhir_commentsrules and unquoted code-reference systems whose name carries whitespace, repaired with the name goFSH itself reports. It classifies before writing, writes nothing on a shape it does not model, and is idempotent. Give it the whole FSH tree — a narrowed path cannot see the declarations it checks against, so it refuses (exit 1, whichrunreturns and ateewould have hidden). - Then SUSHI must compile clean apart from genuinely unresolvable parents — measured
41 errors before, 5 after, both logged by the block above. A parse error stops SUSHI reading
the rest of a file while it still exports the instance, silently truncated ("exported" is not
"converted"): the three Consent examples carried 1 nested provision each before, 27 / 6 / 3
after. SUSHI's exit status is its error count, so
sushi-afterexits 5 — the anticipated shape-B outcome, which--expected-nonzerologs as an escalation rather than as a failure. - A parent package that ships no snapshots blocks import — SUSHI cannot read such a parent at
all, blocking those profiles and every instance declaring
InstanceOfthem. Detect it, then generate the snapshots with a real generator; never hand-roll one (spec §5.1b.5):bash "$SKILL_DIR/scripts/parent-snapshots.sh" detect --package ID --version Vcounts them (measured: 21 SDs, 0 snapshots, in both candidate versions — another version does not fix it), and… build … --validator validator_cli.jar --install --require <parent-url>…drives the official HL7 generator (java -jar validator_cli.jar snapshot, ProfileUtilities), verifies every result (a snapshot whose element count matches only the differential is WRONG and is refused), and installs a new cache entry<id>#<version>-snapshots— upstream is never overwritten. A generator refusal is an upstream defect to escalate, not to hand-finish; what the rebuild costs CI (it is local-only) is a Gate-A decision. Approximating a merge — slicing, cardinalities, element order — fabricates a parent (guardrails 1 and 3). Then re-pin, re-run SUSHI and log both error counts: measured on Consent, 5 → 0. goFSH-invented ids and GUID-named files go to the ② queue: minted ids become the module's, so Gate A confirms them. - Acceptance: counts match the inventory; the script exits 0; every remaining SUSHI error is a
named unresolvable-parent escalation; all of it is in
run.log. Path B does not by itself produce a clean build, so every "clean build" criterion below (steps 3 and 7, Verification) is read for shape B through the shape-B qualifier, spec §5.1b.4: no mechanical error left, every residual in the ① queue with a Gate-A decision, no parent fabricated. A tolerated error count is not a pass.
2c. Harvest the narrative when it is not in the repository — shape B always, and any shape A whose pages live on the platform. Spec §5.1d is normative (§5.1c found the guide). Order of sources, most trustworthy first, and "nothing" is not the third one:
① The authenticated project download — preferred whenever credentials exist.
https://simplifier.net/<project-slug>/$actions/downloadingyields the project including the narrative markdown as the author wrote it, behind a Simplifier login: measured, anonymous access returns the login page (/login?ReturnUrl=…) and every anonymous alternative probed 404s or returns HTML, so no verified anonymous project download exists. A human signs in in their own browser, downloads the archive and names a path outside the repository; the agent reads it and logsnarrative-source=project-download. Never invent a credential mechanism, ask for a password or store a token — the gate is the point. Nobody available: logproject-download-unavailable:and fall through to ②.② The guide harvest — anonymous, verified, and a rendering (directives already expanded; a rendered artefact view is not the resource it renders). Call it directly, it emits its own run-log lines:
bash "$SKILL_DIR/scripts/guide-harvest.sh" \ --guide-url "https://simplifier.net/guide/<key>/<Root>?version=<version>" \ --out migration-log/guide-harvest/pagecontent --keep-html migration-log/guide-harvest/htmlIt discovers the page tree from the root's own
hrefvalues — slugs are read, never constructed ("Anwendungsfälle / Informationsmodell" →AnwendungsflleInformationsmodell), pins every page to?version=, isolates each content region<div id="preview-content">by depth-scanning<div>/</div>(a regex to the next</div>truncates at the first nested one; a page without the region is skipped, never converted whole), writes Markdown with a provenance header +TODO:REVIEW, and classifies each pagenarrativeorartefact-view.--outhas no default — this is step 5's input, not the template's page set. Verification is the point: every discovered page is inmigration-log/guide-harvest.tsvharvested-with-counts or skipped-with-a-reason;ratioraises the mandatorysilent-partial-success:WARN when harvested < discovered; per page the source text runs that did not survive the conversion are counted (missing_runs=) and WARNed — under a separategenerated-view-lossy:token on artefact views, so generated tables cannot bury real losses. Measured on Consent 2026.0.0: 18 discovered, 18 harvested, 0 skipped, 0 narrative pages short, 14 narrative + 4 artefact-view, 3 assets. Exit 0 = clean; 1 = something skipped or short.③ Nothing is not the third option. An unreachable source is reported, escalated to Gate B and named.
The registry package has not changed role: resources and identity (step 2), no narrative —
it is what the harvested set is verified against. The harvested pages enter the SAME contract as every other source shape: the 5.4c page-routing advice run reads the manifest (--harvest-tsv, auto-discovered from the target's migration-log/) and GENERATES their map rows — a page the
harvest skipped blocks the coverage validation until the harvest is clean or a human retires it in the reviewed map — and step 5 consumes only the map
(spec §9/§9f): never one target page per harvested page, and place the language per Language.
Create the skeleton (spec §5.2). The migration happens in place: on a working branch of the module's existing repository, scaffold from the template checked out in Preconditions 3 and run its first-run bootstrap — do not mint a new repository; history, issues and consumers stay where they are (a new repository is a human decision, recorded in the report, never a default). No vendored template in the migrated module (spec §5.2, decision 2026-08-28): keep the scaffold ig.ini's repository-URL
template =line, deleteig-template/+ the sync machinery (sync-ig-template.yml,sync-ig-template.sh,resolve-ig-template-source.sh) if the scaffold ref still ships them, and log both provenance lines —5.2 skeleton-vendored ref=<module-template tag> commit=<sha>and5.2 template-reference url=<repo url> release=<latest ig-template release>(P2 and P1 read these). Replace every{{...}}placeholder from the identity read in step 2. Align the LICENSE file with the SOURCE's licence —license-align(invocation under Verification): the source's own LICENSE file verbatim, else the vendored official legal code of the declared id (references/licenses/); every replacement is ANNOUNCED from → to (license-replaced:log line + a FIX row in the report), and the cases no tool may decide — no licence anywhere, an id without a vendored text, a source-internal contradiction — exit 1 for Gate A: the template's CC-BY-4.0 text never silently relicenses a module. The template's CRMImeta.profileclaims require thehl7.fhir.uv.crmidependency — add it to the carried source dependencies, recorded at Gate A (template machinery, not source identity). Scaffold filenames that embed the module's name derive from the module ID, never the repository slug (ig.ini'sig=path,ImplementationGuide-<id>references, the step-6.po): SUSHI writesImplementationGuide-<id>.jsonfrom theidand nothing else, so a slug-derived name builds green until the publisher finds no IG resource — the id-vs-slug class check P5 catches. Then delete the template's example artefacts (input/fsh/profiles/example-patient.fsh,input/fsh/instances/example-patient-instance.fsh— verify the paths against the template you actually checked out) so they cannot collide with the module's real examples. Before copying the template's FSH scaffold (input/fsh/aliases.fsh,input/fsh/rulesets/*), diff itsRuleSet:/Alias:names against the module's FSH — module definitions win, the module's FSH is never changed: a shared alias file (aliases.fsh) is resolved per DEFINITION (append only the template definitions the module lacks), every other colliding scaffold file per file (skipped whole). Log both lists — overwriting a module'saliases.fshbroke a real migration with 234 SUSHI errors. Acceptance:bash "$ML" run 5.2 sushi-skeleton -- npx --yes fsh-sushi@3.20.0 .runs clean (shape B: as qualified in step 2b), and the skip/append lists are in the log.Transfer the artefacts — structure-preserving. Move the FSH sources across keeping the source's directory layout; convert JSON/XML with a pinned
npx --yes gofsh@2.6.1where that is all the source has — for shape B that happened in step 2b, so what moves here is its post-processed output. IDs and URLs unchanged. Acceptance is path-level, not count-level:comm -3over the sorted repo-relative FSH path lists of source and target is empty apart from scaffold additions named in the log — counts alone prove nothing about structure.Migrate the narrative. Its source is step 2c; this step maps it. Move the Manteldokument content into
input/pagecontent/*.mdand translate Simplifier and FQL directives into IG Publisher equivalents:bash "$ML" run 5.4 fql-scan --emits-runlog -- bash "$SKILL_DIR/scripts/fql-scan.sh" --strictThe scan is recursive and pre-migration includes
implementation-guides/**; it logs files scanned per target, WARNs when a named directory contributed none, and exits 2 on an empty target set — never read "nothing scanned" as "nothing found".--strictexits 1 on any finding;runkeeps both statuses where ateereports 0. Apply each printed recommendation; mapping in the FQL crosswalk, rules inreferences/fql-rules.tsv. In doubt, writeTODO:REVIEW.Route every source page BEFORE writing — spec §9e, first match wins: one artefact →
input/intro-notes/<Type>-<id>-intro.md(German mirror, same filename, renders atop the artifact page); a family overview → a section on an index page that exists; an agreed page owns it → merge; only cross-cutting narrative becomes a page — a hub (≤250 words, one line per child) at ≥3 children, with a menu entry only inside the budget (≤33 entries, ≤10 per dropdown, top level ≤8, depth ≤2), elsepages:-nested and linked. Size gate: >2500 words, >4 merged sources or ANY repeated heading re-runs it (PROs shipped a 6214-word host, 13 colliding anchors). The routing is GENERATED, not judged — the5.4c page-routingrun ISpage-structure-advice.py--map(invocation under Verification): it WRITESmigration-log/page-map.tsv(v2:source_page⇥target⇥reason⇥branch⇥measure) from these branches plus the semantic routing tablereferences/routing-table.tsv, VALIDATES coverage of the full source page universe (authoritative guide tree ∪input/pagecontent∪ on-disk pages no toc lists ∪ the step-2c harvest manifest; exit 1 until every page has a target and every RETIRED row a reason — dangling toc entries and harvest-skipped pages are findings) and proposes the M9/other-bucket decisions from Gate 0. The map is THE contract (spec §9f): a human reviews/edits it BEFORE anything is written; this step then consumes ONLY the map — a page not in the map is not written, a map row left unhandled is a defect; step 7b checks against the same map. Text you WRITE (overviews, hub one-liners, bridges) is DERIVED — mark it per §9d so it renders as a review box; run5.4d derived-scan(writesmigration-log/derived-content.tsv, which C7 reads and ② is generated from); moved or split content is never marked. §9 records the reference module's use-case gap: report it, never fill it.Template ≥ v0.8 targets: spec §9a is normative for the page set — split pages, link-only Conformance cluster +
datasets-and-descriptions(content re-routes per §9a), no re-added title headings (M10), two REQUIRED run-logged decisions:5.4a optional-page-decisions(M9 — measured: package artifact count 0 → remove, > 0 → keep, artifacts never deleted; §9a),5.4b security-privacy-decision(M11). Index style parity (authors/contacts as lists, disclaimer as prose), the(de)translation-marker wording and the one-commit revertible-fix protocol are §9a; an absent CapabilityStatement is detected, SUGGESTED from the module's profiles and rendered INLINE on its page per spec §9b.Set up the bilingual pages. English is the default; German is the translation, a same-named file under
input/translations/de/pagecontent/. These do render. The menu isinput/includes/menu.xmlwith a per-language mirror atinput/translations/de/includes/menu.xml— never amenu:property insushi-config.yaml, which competes with it. Resource translations are.posupplements underinput/translations/de/; check the target's recipe for which resource types actually render before investing in one. A German-only source inverts the direction — see Language below. Page titles (breadcrumbs, table of contents,<title>) — full recipe in spec §5.5. The publisher does localize them, through one IG-level catalogueinput/translations/<lang>/ImplementationGuide-<ig-id>.po(imported into the IG resource at load time — not a resource supplement, so their type restriction does not apply). Generate it after the step-3 SUSHI run from the SUSHI-generated ImplementationGuide resource, the authoritative title set — the menus serve only as a translation seed:bash "$ML" run 5.5 gen-page-title-po --emits-runlog -- \ python3 "$SKILL_DIR/scripts/gen-page-title-po.py" \ fsh-generated/resources/ImplementationGuide-<ig-id>.json \ migration-log/menu-titles-de.txt \ de input/translations/de/ImplementationGuide-<ig-id>.pomigration-log/menu-titles-de.txtis a required argument with no default (oneEnglish Title => Deutscher Titelper line; build it, and the-"no seed" option, per spec §5.5 — an unresolvable path is a setup error, never a silent empty seed). Regenerating is non-destructive; an emptymsgstrmeans untranslated and goes to the ② queue. Footgun: the language must appear intranslation-sources, not onlyi18n-lang, or every.pois silently ignored. Modules from template v0.5.0 also drop its breadcrumb override.Build and QA. SUSHI, then the IG Publisher — both through
bash "$ML" run 5.6 …, so the two numbers this step exists to produce end up in the log: SUSHI's error count, andqa.txt's summary line copied into an INFO (spec §5.6 has the block). The target pins its toolchain in the build workflow'senv:block — read the pins from there rather than from this file. Acceptance:qa.txtreportsErrors: 0and every example validates — shape B: as qualified in step 2b, the named escalations excepted and every other error still a stop. Then run the same-module verification withfhir-ig-analysis(measure the unmigrated source, then the migrated tree — the POSTFLIGHT measurement, kept asmigration-log/postflight-analysis.json; an equalpackageIdtriggers the comparison, the SOURCE is the first input): identity, published artifact set and canonical URLs must all read IDENTISCH and a DIVERGIERT is a stop; the narrative per-language table goes into the report's QA triage. Then DIFF the two measurements withscripts/prepost-delta.py(invocation under Verification; spec §5.6): one verdict per compared property (unchanged · improved · REGRESSION · expected-change), and exit 1 = a property got WORSE (an artefact count dropped, licence turned contradictory, dependency-injection risk appeared, an identity field changed) — a stop to fix, never a delta to file; when the two measurements' censusmodefields differ (reducedraw-resource vsstaticFSH-declaration census — the harvested shape), count differences report as expected-change with the modes named, never as regressions (spec §5.6). That sibling skill is a checked precondition, not an assumption —bash "$SKILL_DIR/scripts/sibling-skill-check.sh" --skill-dir "$SKILL_DIR"finds it or WARNssibling-skill-unavailable:with the exact pinnednpx skills addcommand, and never installs it: a tool grant is permission, not a dependency, and an unrelated run must not write into the operator's skills directory (spec §5.6a).
7b. Verify — mechanically, and it exits non-zero. Spec §11 is normative. It replaces the prose checklist a human used to perform: four real migrations passed that checklist on a green build while shipping unreachable artefacts, a stale rendered provenance, a broken page header, a silently truncated file and a wrong dependency pin.
bash "$ML" run 11 verify-migration --emits-runlog \
--expected-nonzero 'findings are this step OUTPUT (1 = DIVERGIERT, 3 = NICHT PRÜFBAR)' -- \
python3 "$SKILL_DIR/scripts/verify-migration.py" --target . --source <unmigrated-source> \
--rendered output --source-lang de --template-latest <latest module-template release>
Four layers plus the log. Conservation: every source artefact present and reachable from artifacts.html — present is a different property, and
step 7's set comparison proves only the first; every page of the UNION source set (authoritative guide tree ∪ input/pagecontent ∪ the step-2c
harvest — never a fallback chain: one pagecontent stub must not suppress a 149-page guide tree) migrated/retired/MISSING per migration-log/page-map.tsv,
the reviewed contract the 5.4c page-routing advice run generated; every source text run present somewhere. Fidelity: identity, dependency pins identical to the source's,
license asserted from a tier and never defaulted — and reconciled against the LICENSE file's own text (mismatch DIVERGIERT, unrecognized text NICHT
PRÜFBAR). Provenance: the template package+version read out of the rendered qa.html, the publisher version, the pinned guide version —
comparing like with like, because the ig-template PACKAGE version (vendored copy in legacy modules, the run log's template-reference release under the URL form) and the module-template REPO release are different numbers (measured: repo v0.6.0 vendors package 0.5.1) — plus P5: ig.ini's ig= names the IG resource SUSHI actually writes (ImplementationGuide-<id>.json, derived from the
id, never from the repo slug). Rendering integrity: empty tables/tabs, missing images, header markers like Unknown region code, and language
parity — a translated page byte-identical to the default is a fallback, not a translation. The run log is the SECOND ORACLE (§11.6): it records
what each step intended and measured where the tree records the outcome, so it alone catches an unactioned silent-partial-success: WARN, a step
that emitted no line at all (references/expected-steps.tsv is the manifest), an open identity-contradiction:, and log-versus-artefact count
mismatches. Three verdicts, not two: IDENTISCH, DIVERGIERT (named, with evidence) and NICHT PRÜFBAR, so an unmechanisable check is never
silently written as a pass. Exit 0 clean · 1 divergence · 3 incomplete, which is not a pass.
- Report. Write
migration-log/migration-report.mdfrom the report template — three reviewer queues (① decide, ② review, ③ triage): every open decision,TODO:REVIEWand QA finding lands in exactly one, and QA provenance requires proof (build the unmigrated source to claim "pre-existing"). Its reader has never seen this skill, spec or repo: every labelled line the template lists is mandatory (incl. if nobody acts, who decides, effort, reversibility), codes are glossed where used, and ids tie the queues to Sign-off. Two GENERATED aids are pasted, never retyped (invocations in the template's How to re-run):qa-checklist.pyrenders every open obligation from the
…(truncated)