Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
7.2 KiB
How to resolve an unreachable-workflow finding
Goal: Turn a shipped workflow that no loader references into either a wired workflow or a fully swept deletion — so the instruction layer stays a single source of truth, instead of accumulating files that install to every runtime and are never read.
Prerequisites: A failing npm run lint:ci (or a direct node scripts/lint-command-contract.cjs) reporting one or more unreachable workflows. The check runs automatically as part of lint:ci — you do not invoke it separately.
For what counts as a reference and why the check is shaped this way, see ADR-0002. This guide covers only how to act on a finding.
Read a finding
ERROR lint-command-contract: 1 unreachable workflow file(s)
msd-core/workflows/scan.md
ships to every runtime install tree, but no command, agent, or skill references it
The check walks the transitive closure from every commands/**, agents/**, and skills/** file, following three reference shapes:
| Shape | Example | When it is used |
|---|---|---|
| Eager include | @~/.claude/msd-core/workflows/x.md |
Inlined on every invocation. Reserve for workflows the command always needs. |
| Lazy path | `~/.claude/msd-core/workflows/x.md` |
Read on demand at the point of use. The default for flag-gated or conditional workflows. |
| Parent-relative | execute-phase/steps/x.md |
A sub-file under an existing workflow directory. |
A finding means none of those reaches the file. There are exactly two correct resolutions.
Wire it — the workflow is live and the reference is missing
Choose this when a command, flag, or agent is documented as using the workflow. The file is not dead; the reference is.
Add a reference in the loader that dispatches to it. Prefer a lazy path unless the workflow is needed on every invocation of that command:
- If it is `--fast`: strip the flag, then read and execute
`~/.claude/msd-core/workflows/scan.md` (passing remaining args).
An eager @-include is inlined into context on every invocation of that command, including the paths that never use the workflow. The progressive-disclosure split (#717) exists specifically to keep that cost off the common path, so reach for the lazy form first.
If the loader is a commands/msd/*.md file, regenerate the skill surface afterward:
npm run gen:plugin-skills
This was the right answer for scan.md — /msd-map-codebase --fast was a shipped, documented flag whose routing line named no resolvable path. Deleting the file would have removed the only implementation of a live feature (#3561).
Delete it — the workflow is genuinely dead
Choose this when nothing is supposed to reach it — typically because its command was removed and the workflow was left behind.
Before deleting, confirm the fence is not load-bearing: check the file's own header for a claimed caller and verify that caller exists. The discovery-phase workflow's header claimed it was "called from plan-phase.md's mandatory_discovery step"; that step did not exist. docs/INVENTORY.md claimed it was an alternate /msd-new-project entry; new-project.md never referenced it. A claimed caller is not a caller. (The discovery-phase workflow was deleted in #3560.)
Deletion is a five-step sweep, and skipping any step leaves the tree inconsistent:
git rm msd-core/workflows/<name>.md
-
Remove its
docs/INVENTORY.mdrow — in all five locales.docs/INVENTORY.mdplusdocs/ja-JP/,docs/ko-KR/,docs/zh-CN/,docs/pt-BR/. Check the explanatory note near the bottom of each file too; a workflow is sometimes named there as well as in the table. -
Regenerate the inventory manifest. Build first — regenerating before
build:libsilently drops modules:npm run build:lib && node scripts/gen-inventory-manifest.cjs --write -
Regenerate the golden install-tree fixtures. All 19 runtimes list every shipped workflow:
npm run gen:install-tree -
Sweep tests that name the file — allowlists and content assertions. Two distinct traps live here, and #3560 hit both:
- An allowlist keyed on the bare basename will not match a full-path search.
tests/planner-language-regression.test.cjscarried a stale entry for thediscovery-phaseworkflow that a path-based sweep missed. - A test that asserts the file's existence or content pins it.
tests/phase.test.cjsrequired theplan-milestone-gapsworkflow to exist and checked itsmkdirpatterns; deleting the file turned that into four red tests on the remote runner. Cut the coupling in the same change — but keep any sibling assertions that guard other files, since these blocks are often shared across several workflows.
Note that
scripts/lint-removed-but-needed.cjswill not catch either: it scans.github/workflows/,msd-core/,docs/, andpackage.json— nottests/. Searchtests/yourself. - An allowlist keyed on the bare basename will not match a full-path search.
-
Add a
Removedchangeset fragment, and remember that aRemovedtype requires adocs/change — which step 1 already satisfies.
Then confirm the tree is consistent:
npm run lint:ci
What does not count as a reference
The check deliberately scans only commands/, agents/, skills/, and msd-core/**. A mention anywhere else confers no reachability:
| Location | Counts? | Why |
|---|---|---|
docs/** |
No | Documentation is a claim about the system, not a loader. scan.md was documented in docs/INVENTORY.md while being entirely unreached. |
tests/fixtures/install-tree/*.json |
No | A shipping manifest proves the file ships, which is the problem being reported, not a refutation of it. |
| A changeset fragment | No | Historical record, not a load path. |
If you are tempted to satisfy the check by adding a mention somewhere convenient, that is the failure mode this scoping exists to prevent — the file stays dead and the gate goes green.
When the check passes but something still feels wrong
The rule proves structural reachability: some loader names the file. It cannot prove the file is read on any executed path.
| You see | It means |
|---|---|
151 workflow files, 151 reachable, 0 unreachable |
Every shipped workflow is named by at least one loader. Not that every one is used. |
| A workflow reachable only from another unreachable workflow | Reported correctly — reachability is seeded only from loaders, so an unreachable file cannot confer reachability on anything else. |
| A pair of workflows that reference only each other | Both reported. A mutual-reference island satisfies nothing. |
| A workflow that references itself | Reported. A self-reference is not a loader. |
| A path named only inside a fenced code block | Counted as a reference. This is a deliberate over-count: the check fails builds, and a false positive on a correct tree is worse than a missed orphan, so ambiguous references resolve toward "reachable". |
A workflow that is named by a loader but never actually executed is not caught here. That is a semantic question this structural check does not answer.