simt-dev 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/LICENSE +202 -0
  3. package/README.md +92 -2
  4. package/bin/simt-dev.mjs +28 -0
  5. package/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  6. package/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  7. package/docs/decisions/ADR-003-test-breadth.md +46 -0
  8. package/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  9. package/docs/decisions/ADR-005-file-length.md +42 -0
  10. package/docs/decisions/ADR-006-coverage-bar.md +59 -0
  11. package/docs/decisions/ADR-007-section-skeleton.md +54 -0
  12. package/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  13. package/docs/decisions/ADR-009-skill-layout.md +48 -0
  14. package/docs/decisions/ADR-010-runner-invocation.md +56 -0
  15. package/docs/decisions/ADR-011-windows-support.md +52 -0
  16. package/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  17. package/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  18. package/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  19. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  20. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  21. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  22. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +173 -0
  23. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  24. package/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  25. package/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  26. package/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  27. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  28. package/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  29. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  30. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  31. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  32. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  33. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  34. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  35. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  36. package/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  37. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  38. package/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  39. package/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  40. package/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  41. package/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  42. package/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  43. package/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  44. package/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  45. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  46. package/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  47. package/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  48. package/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  49. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  50. package/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  51. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  52. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  53. package/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  54. package/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  55. package/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  56. package/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  57. package/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  58. package/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  59. package/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  60. package/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  61. package/docs/decisions/README.md +528 -0
  62. package/graph/skills.graph.json +1622 -0
  63. package/package.json +42 -3
  64. package/rules/core.md +76 -0
  65. package/rules/mdc/intake-questions.mdc +50 -0
  66. package/rules/mdc/preview-postflight.mdc +61 -0
  67. package/rules/mdc/slice-commit.mdc +52 -0
  68. package/rules/mdc/test-gate.mdc +54 -0
  69. package/rules/stacks/android.md +63 -0
  70. package/rules/stacks/cloudflare-workers.md +65 -0
  71. package/rules/stacks/connectiq.md +62 -0
  72. package/rules/stacks/container.md +31 -0
  73. package/rules/stacks/jvm.md +54 -0
  74. package/rules/stacks/node.md +45 -0
  75. package/rules/stacks/python.md +48 -0
  76. package/rules/stacks/react-native.md +58 -0
  77. package/rules/stacks/react-web.md +61 -0
  78. package/rules/stacks/typescript.md +21 -0
  79. package/rules/stacks/uv.md +32 -0
  80. package/src/argv.mjs +116 -0
  81. package/src/ci/dependabot.mjs +268 -0
  82. package/src/ci/workflow.mjs +360 -0
  83. package/src/commands/ci.mjs +285 -0
  84. package/src/commands/compare.mjs +195 -0
  85. package/src/commands/graph.mjs +73 -0
  86. package/src/commands/help.mjs +108 -0
  87. package/src/commands/hooks.mjs +457 -0
  88. package/src/commands/index.mjs +241 -0
  89. package/src/commands/init.mjs +566 -0
  90. package/src/commands/mcp.mjs +63 -0
  91. package/src/commands/rules.mjs +374 -0
  92. package/src/commands/skills.mjs +647 -0
  93. package/src/commands/skillsinstall.mjs +515 -0
  94. package/src/commands/sync.mjs +331 -0
  95. package/src/commands/upgrade.mjs +202 -0
  96. package/src/compare/discover.mjs +212 -0
  97. package/src/compare/hooksstate.mjs +159 -0
  98. package/src/compare/row.mjs +299 -0
  99. package/src/decisions/index.mjs +512 -0
  100. package/src/graph/load.mjs +541 -0
  101. package/src/hooks/capability.mjs +129 -0
  102. package/src/hooks/dispatcher.mjs +268 -0
  103. package/src/hooks/gates.mjs +144 -0
  104. package/src/hooks/resolve.mjs +194 -0
  105. package/src/hooks/shim.mjs +140 -0
  106. package/src/hooks/where.mjs +118 -0
  107. package/src/locate.mjs +75 -0
  108. package/src/mcp/server.mjs +167 -0
  109. package/src/mcp/tools.mjs +410 -0
  110. package/src/name.mjs +95 -0
  111. package/src/output/index.mjs +96 -0
  112. package/src/projection/baselines.mjs +209 -0
  113. package/src/projection/conflicts.mjs +205 -0
  114. package/src/projection/manifest.mjs +478 -0
  115. package/src/projection/markers.mjs +349 -0
  116. package/src/projection/mdc.mjs +327 -0
  117. package/src/projection/project.mjs +504 -0
  118. package/src/projection/rules.mjs +640 -0
  119. package/src/projection/surfaces.mjs +398 -0
  120. package/src/projection/targets.mjs +221 -0
  121. package/src/resolve/fingerprint.mjs +553 -0
  122. package/src/resolve/near.mjs +61 -0
  123. package/src/resolve/resolve.mjs +457 -0
  124. package/src/resolve/source.mjs +105 -0
  125. package/src/toolchain.mjs +202 -0
  126. package/src/tsconfig.mjs +314 -0
  127. package/src/upgrade/plan.mjs +203 -0
  128. package/src/workspace.mjs +289 -0
@@ -0,0 +1,101 @@
1
+ # ADR-039 — A gate a CI step runs must have a form that can fail, and a refusal is a failure
2
+
3
+ Status: **accepted** · 2026-10-08 · affects `ci`, its own workflow copy, and any command a workflow runs
4
+
5
+ ## Context
6
+
7
+ Review 0002's S23 found three things about `simt-dev ci` and the step this repository's own
8
+ workflow runs. All three reproduced against the shipped binary, and the first is the reason
9
+ this record exists.
10
+
11
+ **The own gate's own copy is checked by a step that cannot fail.** `.github/workflows/simt-dev-check.yml`
12
+ carries a step named "the gate copy in this repo is current", with the comment *"the drift
13
+ this exception exists to prevent, checked where it would be caught"*, running
14
+ `node bin/simt-dev.mjs ci`. With a hand edit appended to the copy, `ci` printed:
15
+
16
+ ```
17
+ file .github/workflows/simt-dev-check.yml
18
+ action refresh
19
+ why this repository is simt-dev's own, and the gate template has changed since this copy was written
20
+ ```
21
+
22
+ and exited **0**. The step read the drift it was written to catch, printed it, and passed.
23
+ That is the shape this repository's own rules keep warning about — a control that appears to
24
+ hold and does not — except that here the control was the one enforcing the control.
25
+
26
+ **`ci --json --dry-run` printed nothing.** The JSON branch was guarded by
27
+ `flags.yes !== true && flags.dryRun !== true`, so the one combination a script reaches for
28
+ was the one combination with no document on stdout at all.
29
+
30
+ **A refusal reported success.** Run in a repository that adopted simt-dev, `ci` explains at
31
+ length that the `.github` file is the repository's own, names `simt-dev init --yes` as the
32
+ remedy, and then emits `{"ok": true, …, "own": false}` and exits **0**. A caller reading `ok`
33
+ is told the request succeeded, one line above a paragraph saying it was refused.
34
+
35
+ ## Ruling
36
+
37
+ **1. A command whose output a CI step consumes has a form that can fail, and that form is
38
+ what the step runs.** Previewing by default is right for a human at a terminal and wrong for
39
+ a gate: a step has no reader to hand the plan to. `ci --check` exists for that and never
40
+ writes, whatever else it is given — a check that repairs is a second writer, which ADR-036
41
+ is about.
42
+
43
+ **2. A check that names drift shows the drift.** `--check` prints a unified diff of both
44
+ files. The reader is the author of the template change that made the copy stale, and the
45
+ question they have is "what did I change". The plan now carries both sides of the
46
+ comparison — `source: { text, desired }` — so the verdict and the diff cannot disagree, and
47
+ those two bodies are stripped from the JSON payload, because a document is not the place
48
+ for two copies of a workflow file.
49
+
50
+ **3. A refusal is `EXIT.UNSUPPORTED` with `ok: false`.** Not because nothing is wrong, but
51
+ because the request cannot be satisfied *in this repository*, which is what the code is
52
+ documented to mean (`output/index.mjs:16`). The prior behaviour also contradicted ADR-035
53
+ directly: it emitted `ok: true` and exited 0 while the text beside it said the opposite, and
54
+ ADR-035 requires the two to agree. This is the first place in the tool where they
55
+ disagreed by design rather than by oversight.
56
+
57
+ **4. `--json` emits one document in every mode, and `ok` agrees with the exit code.** The
58
+ mode guard was the bug; the invariant is ADR-035's. The test walks the modes rather than
59
+ trusting the guard.
60
+
61
+ ## Consequences
62
+
63
+ `test/ci.test.mjs` gains a `run()` helper that reports the exit code instead of rejecting.
64
+ `promisify(execFile)` rejects on non-zero, which is right for a helper asserting success and
65
+ useless for every test about a command that must fail — and the code a refusal test needs to
66
+ check is the code inside the rejection.
67
+
68
+ The existing refusal test asserted exit 0 and carried the reasoning *"the refusal must not
69
+ be an error, because nothing is wrong"*. That reasoning is this record's third ruling
70
+ overturned, so the comment now says why the opposite holds.
71
+
72
+ The `--check` and `--json` tests run **in this repository on a stale copy**, with a `finally`
73
+ that restores the file, because `isOwnRepo` compares against the package root and no temp
74
+ repo can reach this code. The first version of the JSON test used a fixture and passed with
75
+ the guard restored: the fixture was not the own repository, so every mode took the refusal
76
+ path and printed a perfectly good document — for the wrong reason. A mutation found it.
77
+
78
+ Four mutations were applied and reverted, each caught by its own test: `--check` returning
79
+ `EXIT.OK`, the refusal returning `EXIT.OK`, the mode guard restored, and the workflow
80
+ template reverted to the bare `ci`.
81
+
82
+ ## Rejected
83
+
84
+ - **Leave the own gate's step running `ci` and rely on the reader.** The reader is a person
85
+ who did not make the template change and will not know that the step passed. A gate whose
86
+ failure is visible only to someone reading logs is a note.
87
+ - **Make `ci` itself exit non-zero on drift, without a `--check`.** That is what the review
88
+ asked for and it would break ADR-036: previewing by default is the rule every writer
89
+ follows, and one writer that reports drift is the exception nobody remembers.
90
+ - **Keep the refusal at exit 0 and change only `ok` to false.** Two answers to one question
91
+ is the defect ADR-035 exists to prevent, and the more surprising half of the two: the
92
+ shell reads the code, and it was reading success.
93
+ - **Exit `EXIT.FAIL` (1) for the refusal.** It is not a gate that failed; nothing ran. `1`
94
+ means "a gate failed: drift, policy violation, missing evidence", and a caller deciding
95
+ whether to re-run on `1` would retry a request that can never succeed in this repository.
96
+ - **Ship the diff as a dependency rather than writing it.** ADR-012 rules the dependency, and
97
+ the diff here has one reader and one shape: two whole files this tool wrote, differing by
98
+ lines. A line-level LCS table is a screen and a risk, not a dependency.
99
+ - **Print the whole file rather than eliding context.** The output is the only reason the
100
+ step is worth having over a bare exit code, and fifty lines of context around one changed
101
+ step buries it.
@@ -0,0 +1,158 @@
1
+ # ADR-040 — The last writer that wrote by default was not the one named
2
+
3
+ Status: **accepted** · 2026-10-08 · affects `simt-dev hooks install`, every remedy naming it, and the shim it installs
4
+
5
+ ## Context
6
+
7
+ ADR-035 ruled that `writeMode(flags)` decides whether a run may write, "and every mutating
8
+ command reads it. A command that consults `flags.yes` directly is a defect whether or not its
9
+ arithmetic happens to agree today". ADR-036 then took `sync` out of the set of writers that write
10
+ by default. Review 0002's S24 opened by calling `sync` "the only writer that writes by default"
11
+ and named its five siblings as the ones that "all preview first": `init`, `upgrade`, `ci`,
12
+ `hooks install` and `skills install`.
13
+
14
+ Four of those five were true. One was not.
15
+
16
+ Measured against the shipped binary, in a fresh repository that declares `lint` and `test`:
17
+
18
+ ```
19
+ $ simt-dev hooks install --root .
20
+ repo .
21
+ core.hooksPath created (unset) -> .simt-hooks
22
+ CREATED .simt-hooks/pre-commit
23
+ CREATED .simt-hooks/pre-push
24
+ WROTE shims and set core.hooksPath
25
+ $ echo $?
26
+ 0
27
+ ```
28
+
29
+ `hooks.mjs` read `flags.yes` at line 146 and `flags.dryRun` at line 169 and imported neither
30
+ `writeMode` nor any other write decision. In the ordinary case — no `core.hooksPath`, no foreign
31
+ hook — neither flag was consulted at all and the shims were written.
32
+
33
+ Six independent sources agree this is a defect rather than an exemption:
34
+
35
+ 1. ADR-035's ruling names the exact pattern as a defect.
36
+ 2. The code is the pattern.
37
+ 3. S24's premise, which is the finding the rest of Wave 0 was ordered against, was wrong on one
38
+ of six.
39
+ 4. Review 0002's own migration script instructs: "**Route hooks.** `simt-dev hooks install`
40
+ (plan), then `--yes`." An operator following it would read a plan and get a write, and the
41
+ `--yes` on the next line would be spent on nothing.
42
+ 5. Three **shipped** strings assumed the preview: `init`'s remedy, two `compare` remedies, and
43
+ the `Reinstall with:` comment inside the shim itself, which is written into every adopting
44
+ repository.
45
+ 6. `contract.test.mjs`'s `WRITERS` table covered `upgrade`, `sync` and `skills install`. It never
46
+ covered `hooks install`, which is why 586 tests did not notice.
47
+
48
+ Six is not an accident and it is not a subtle shape. It is what happens when a rule about a set of
49
+ commands is checked by reading the commands one at a time.
50
+
51
+ The comment `sync.mjs` gained in ADR-036 is part of the evidence. It reads "the odd one out among
52
+ six: `init`, `upgrade`, `ci`, `hooks install` and `skills install` all preview unless told
53
+ otherwise" — a claim about code a different file owns, one commit out of date, which this record
54
+ corrects.
55
+
56
+ ## Ruling
57
+
58
+ **1. `hooks install` reads `writeMode(flags)`, like every other writer.** `apply` gates the write
59
+ and the `core.hooksPath` assignment together, so there is no half-installed state reachable by a
60
+ flag combination.
61
+
62
+ **2. The preview exits `EXIT.OK` and names the command that applies it.** A rehearsal is not a
63
+ failure: `upgrade`, `ci` and `skills install` previews all exit 0, and a preview that exits
64
+ non-zero would make `--dry-run` unusable in a script for no gain. This is deliberately **not**
65
+ `sync`'s rule, where a preview that found work exits 1 because plain `sync` is also a check
66
+ (ADR-036). `hooks install` is not a check; `hooks status` is, and it says so through its own exit
67
+ code.
68
+
69
+ **3. `--yes` is one flag doing two jobs here, and that is not a reason to keep two.** It
70
+ authorises the write *and* it is the consent ADR-018 requires to take over routing git is already
71
+ using. A bare command is refused in the takeover case and merely previews in the ordinary one, so
72
+ the consent is required exactly where it was before and the write is now gated by the same word.
73
+ The BLOCKED text needed no change: it already ended "then re-run with `--yes` to accept".
74
+
75
+ **4. Every string that names the command moves with it.** `init`'s remedy, both `compare`
76
+ remedies, the shim's own `Reinstall with:` comment, the help synopsis, and the ADR-036 comment
77
+ that misdescribed it. This is ADR-036's own rule — "a writer that writes says so, and the
78
+ instructions naming it move with it" — applied to the second writer rather than the first.
79
+
80
+ **5. A remedy is a guard, and the guard covers both writers.** `shipped.test.mjs`'s "every remedy
81
+ names a command that does what the remedy claims" now scans `simt-dev hooks install` as well as
82
+ `simt-dev sync`. The assertion had to be rewritten to be worth having: the original required the
83
+ authorising flag immediately after the command, which would have reported `simt-dev hooks install
84
+ --root <path> --yes` — the shape `init` prints — as an offender. It searches the rest of the line
85
+ instead, stopping at the next `simt-dev` so a correct remedy cannot vouch for a bare one later on
86
+ the same line, and it skips comment lines because prose about the tool is not an instruction to
87
+ run it.
88
+
89
+ **6. An unauthorised run that finds work is either a rehearsal or a refusal, and the exit code is
90
+ the only thing that says which.** This is a change to the `WRITERS` table, not to `hooks install`.
91
+ Asserting only the bytes passes on a command that **refuses** rather than previews, because a
92
+ refusal changes nothing. `hooks install` on a repo with no runnable gate is refused exactly that
93
+ way, and the refusal is correct: a shim with an empty gate list enforces nothing yet merely
94
+ existing replaces whatever git was running. The row therefore carries `stack: 'node'` and the
95
+ table asserts the exit code of every unauthorised run — 0 for a rehearsal, non-zero only for the
96
+ row (`sync`) that is also a check. The old `if (writer.name === 'sync')` special case became a
97
+ `previewFindsWork` column, because a table that encodes one row's name in the loop is a table that
98
+ cannot get a new row right.
99
+
100
+ The assertion is scoped to the unauthorised form. A rehearsal is a different question:
101
+ `sync --yes --dry-run` reports the work it found exactly as the unauthorised form does (ADR-036),
102
+ and requiring 0 there would fail on behaviour that record deliberately settled. **That was caught
103
+ by a mutation, not by reading it** — the first version asserted the exit code in both modes and
104
+ failed the whole table.
105
+
106
+ ## Consequences
107
+
108
+ `hooks install` becomes the fourth row in `contract.test.mjs`'s `WRITERS` table, and its
109
+ `verify` fingerprints **both** things it writes: the shim under `.simt-hooks/` and
110
+ `core.hooksPath` inside `.git/config`. The second is not decoration. A preview that set the config
111
+ without writing the shims would leave a repository whose commits are gated by a hook git does not
112
+ run, which is the same silent downgrade ADR-018 exists to prevent — and a shim-only fingerprint
113
+ does not notice it. Removing the config probe makes that mutation pass; that was measured rather
114
+ than assumed.
115
+
116
+ Six mutations were applied and reverted, each caught by its own test:
117
+
118
+ | Mutation | Caught by |
119
+ | --- | --- |
120
+ | the `!mode.apply` gate removed — always write | the `WRITERS` row |
121
+ | the preview drops the "apply with:" clause | `hooks install: without --yes it prints the plan and the command that applies it` |
122
+ | `init`'s remedy loses `--yes` | `every remedy names a command that does what the remedy claims` |
123
+ | the shim's `Reinstall with:` loses `--yes` | the same guard, and `the installed shim tells the reader how to reinstall it` |
124
+ | the `WRITERS` row pointed at the python fixture | the new exit assertion — *"hooks install exited 1 instead of previewing"* |
125
+ | the preview half-writes `core.hooksPath` | the `WRITERS` row, and **not** without the config probe |
126
+
127
+ A seventh mutation is worth recording because it was **not** caught by the new row: `hooks
128
+ install --yes` that never sets `core.hooksPath` passes `contract.test.mjs` entirely, because the
129
+ row only exercises unauthorised and rehearsed runs. Six tests in `hooks.test.mjs` catch it. The
130
+ row does not carry that claim and this record does not claim it does.
131
+
132
+ Two of the new assertions were wrong before they were right. The exit-code assertion was, as
133
+ above. The `sync` row's own preview also exits 1 in `--dry-run`, which is the same finding seen
134
+ from the other row. Two of two wrong first is not a coincidence worth explaining: a table that
135
+ grows a new column needs each cell written down, and neither cell was.
136
+
137
+ ## Rejected
138
+
139
+ - **Treat `hooks install` as exempt because ADR-018 already asks for consent.** ADR-018's consent
140
+ is about *taking over* routing git already uses. In the ordinary case there is nothing to take
141
+ over, so the consent requirement never fired — and the write happened anyway. An exemption
142
+ invented to fit a defect is indistinguishable from a decision, which is why this one would have
143
+ needed a reason and there is not one.
144
+ - **Leave `--yes` as takeover-only and add a second flag for the write.** Two flags for one
145
+ decision is ADR-035's defect restated: a command reading `flags.a` for one thing and
146
+ `flags.b` for another is how `upgrade` read `yes` and ignored `dryRun`. The word "yes" is also
147
+ what the reader expects, and every remedy already says `--yes`.
148
+ - **Make the preview exit 1 when it finds work, matching `sync`.** Defensible, and it would break
149
+ `hooks install` in the one script shape that uses it: read the plan, then decide. `hooks status`
150
+ is the command for "is this repo wired up", and it already reports drift through its exit code.
151
+ - **Only add the `WRITERS` row and leave the shipped strings.** The strings are the defect's
152
+ widest blast radius. A remedy that prints a command which no longer reinstalls is ADR-036's
153
+ exact failure, one writer later, and `Reinstall with:` lives inside the file that gets installed
154
+ into every adopting repository.
155
+ - **Rewrite `sync.mjs`'s ADR-036 comment by deleting the sentence.** The sentence is the record of
156
+ why that change was made. It is amended instead, with the correction stated, because a comment
157
+ that enumerates which commands follow a rule is a claim about code this file does not own and
158
+ the next reader has no way to check it cheaply.
@@ -0,0 +1,92 @@
1
+ # ADR-041 — The suite installs the package in order to test it
2
+
3
+ Status: **accepted** · 2026-10-08
4
+ **Affects:** `test/pack.test.mjs` and what the suite may assume about `npm`.
5
+
6
+ ## Context
7
+
8
+ Every other file in `test/` imports from `../src`. That is what they are for: they are tests of
9
+ units, and a unit test cannot see a file that `package.json` forgot to ship. S01 shipped a
10
+ package whose `files` list and whose runtime reads disagreed, and the suite was green — because
11
+ every test in it was reading the checkout rather than the artefact.
12
+
13
+ Review 0001 §S20 named five missing test families. Four have since landed: the JSON and
14
+ write-mode contract table (S10), the git-state hook tests (S08), the resolver tests (Wave 1) and
15
+ the MCP parity work in progress. The first family on the list, pack-and-install end to end, was
16
+ still open, and review 0002 §5 says plainly that it "is the one that would have caught S01, S27
17
+ and S35 before they shipped; land it first, with S01". S01 had already been closed. So the row
18
+ that was supposed to be carried by it was never carried at all.
19
+
20
+ Two facts about this repository make that gap harder to notice than it looks. `test/` may not
21
+ reach outside the checkout (ADR-031), and this package has no dependencies (ADR-012). Together
22
+ those mean the suite has no installed copy of itself to test, and the natural workaround —
23
+ borrowing a formatter from a neighbouring checkout — is the one ADR-031 rules out. Prettier 3.8.1
24
+ is sitting in `../simt-blog/node_modules/.bin` and is not available here.
25
+
26
+ ## Ruling
27
+
28
+ **One.** `test/pack.test.mjs` drives `node_modules/.bin/simt-dev`, installed from a tarball that
29
+ `npm pack` produced from the working tree. Anything a reader of this repository only learns from
30
+ the published package — that the registry resolves out of `node_modules`, that the decisions are
31
+ present to read, that a full adoption survives a commit and a hook — is asserted here and nowhere
32
+ else.
33
+
34
+ **Two.** The install is `npm install --offline`. That is a constraint on this repository, not a
35
+ setting: the package must install with no network and no warm cache, so `dependencies` and
36
+ `devDependencies` stay empty and adding a build-time or test-time tool to either of them is a
37
+ change to a guarantee rather than a convenience. A suite that fails on a cold runner gets
38
+ skipped on one, and a skipped test and a passing test are the same line of output.
39
+
40
+ **Three.** The pack test runs `npm pack` against the working tree rather than against a staged
41
+ or committed tree. A mutation test has to be able to change a byte in `src/` and watch this file
42
+ notice, and packing a commit would hide the change it is supposed to catch.
43
+
44
+ **Four.** This file does not assert that the projected output is formatted, because there is no
45
+ formatter to assert it with. S29's second acceptance half — a packed install whose output passes
46
+ `format:check` — therefore stays open, and the seam it would have caught is asserted
47
+ structurally instead, in `test/markers.test.mjs`. The record is here so that the open half is a
48
+ decision rather than an oversight, and so that whoever makes Prettier reachable does not have to
49
+ rediscover why this file stops where it stops.
50
+
51
+ ## Consequences
52
+
53
+ Four mutations were made against the four rulings, each reverted immediately, and each was caught
54
+ by the test that claims the behaviour:
55
+
56
+ | Mutation | Caught by |
57
+ | --- | --- |
58
+ | `"graph"` removed from `package.json` `files` | `the registry the installed tool reads comes out of node_modules`, plus three others |
59
+ | `"docs/decisions"` removed from `files` | `the decisions the tool explains itself with are in the tarball` |
60
+ | `planGates` stops filtering by capability | `a repo that cannot run a gate is not told it has one` |
61
+ | `upgrade` and `hooks install` write without `--yes` | `a packed install survives the whole adoption, and no writer writes unasked` |
62
+
63
+ The first two are the S01 class and they shipped green for a long time. The third is S27. The
64
+ fourth is ADR-035 and ADR-036, and it is in the list because `hooks install` was found by hand the
65
+ day before, not by any of these tests.
66
+
67
+ One thing this file deliberately does not assert, having been measured while writing it: the
68
+ `files` list in `package.json` names four directories that do not exist in this repository —
69
+ `skills`, `agents`, `tooling` and `hooks`. npm ignores them without complaint and the tarball is
70
+ complete. An assertion that each entry exists would pass the moment the directories are created
71
+ and say nothing in the meantime, so the fact is recorded here rather than encoded as a check.
72
+
73
+ ## Rejected
74
+
75
+ **Running the installed binary out of a staged copy of `src/`.** It reproduces the checkout's
76
+ bytes under a different path, which is precisely the state S01 shipped in. A test that cannot
77
+ distinguish the two is worse than no test.
78
+
79
+ **Adding Prettier as a devDependency so the formatting assertion can be written.** It would
80
+ make the assertion possible and the suite unreliable: `npm install` would need the network on a
81
+ cold runner, and ruling two exists because that is not a cost this repository pays. The
82
+ correct fix is a formatter the suite can already reach, which is a decision somebody should make
83
+ deliberately rather than a side effect of this one.
84
+
85
+ **Skipping the pack test when `npm` is unavailable.** Every other test in the suite already needs
86
+ node, git and a POSIX shell; npm is in the same class. A conditional skip converts a green run
87
+ into an unverified one, which is the failure this file was written to stop.
88
+
89
+ **Copying Prettier out of a sibling checkout.** It exists, it works, and ADR-031 rules it out for
90
+ the same reason it rules out reading skill bodies from `../simt-social`: a suite that passes on
91
+ the author's machine and fails on a runner with only this checkout is evidence about the author's
92
+ directory.
@@ -0,0 +1,94 @@
1
+ # ADR-042 — A declared glob must match a path built from it
2
+
3
+ Status: **accepted** · 2026-10-08 · ruling 1 superseded by ADR-053
4
+ **Affects:** `src/resolve/resolve.mjs` and `graph lint`.
5
+
6
+ ## Context
7
+
8
+ `matchesGlob` rewrote one shape of double star and left the rest to the JavaScript regex engine:
9
+
10
+ ```js
11
+ .replace(/\/\*\*/g, '(?:/.*)?')
12
+ ```
13
+
14
+ A glob that **started** with a double star compiled to an anchored pattern beginning `**`, which is
15
+ not a valid regex. A `try`/`catch` returned `false`, so the failure was indistinguishable from the
16
+ glob not matching — which is what a glob is for. 13 of the registry's 65 globs start that way
17
+ (`**/*.test.ts`, `**/*.kt`, `**/build/**`, and ten more), and path is the heaviest signal the
18
+ resolver has at weight 100. Every one of those skills was unreachable by a diff, and the only
19
+ evidence was a staged `.spec.ts` file producing a selection that did not include the skill for
20
+ writing tests.
21
+
22
+ Two facts made this durable rather than a slip. The registry is hand-authored, so nothing compiles
23
+ a glob until a real diff happens to contain a matching path. And `graph lint` — the check that
24
+ exists to keep the registry describing reality — asked only whether a path array was **present**.
25
+ `checkStack` had already established the principle one field over: a tag `fingerprint()` cannot emit
26
+ matches nothing, forever, with no error at resolution time, so lint rejects it. A glob that matches
27
+ nothing is the same failure wearing a different field name, and no check asked.
28
+
29
+ Review 0001 §S02 diagnosed both halves and proposed the segment-by-segment translation. Review 0002
30
+ §4.1 noted that with a raised Node floor this becomes `path.matchesGlob`, which is one line instead
31
+ of twenty. That floor is proposal P8 and S47, still queued; `engines.node` reads `>=20` today and
32
+ using a built-in that needs `>=22.20` would have quietly broken the declared range. So the translation
33
+ is hand-written here and becomes a one-liner when S47 lands.
34
+
35
+ ## Ruling
36
+
37
+ **One.** A glob is translated segment by segment. A leading double star is any number of leading
38
+ directories, a middle one is any number of middle ones, a trailing one is anything beneath, and a
39
+ bare one is everything. `*` remains confined to a single segment, because that is what makes the
40
+ double star worth translating rather than treating every star alike.
41
+
42
+ **Two.** A glob that matches nothing is a lint error, not a zero. `graph lint` now builds paths from
43
+ each declared glob and requires the glob to match every one of them. This is the same question the
44
+ resolver asks, asked at authoring time by the only tool in this repository that sees the whole
45
+ registry at once.
46
+
47
+ **Three.** A check that reads the same generator it is validating cannot catch that generator
48
+ misbehaving. The synthetic expansion is asserted in its own right — a glob claiming any depth must
49
+ be shown a path at depth and a path not at depth — because the registry-wide test otherwise just
50
+ checks fewer paths when the expansion is narrowed, and checks fewer without saying so.
51
+
52
+ **Four.** `?` is escaped, not read as a single-character wildcard. Nothing in the registry uses it,
53
+ and reading it as a quantifier made `src/a?.ts` match `.ts`. A glob the matcher cannot honour now
54
+ fails ruling two loudly instead of matching the wrong paths.
55
+
56
+ ## Consequences
57
+
58
+ | Mutation | Caught by |
59
+ | --- | --- |
60
+ | a leading double star translated as a trailing one | `** in each position is anchored the way a path segment is` |
61
+ | the synthetic expansion narrowed to the empty case only | `a double star is expanded both ways, or the check above is a mirror` |
62
+ | `checkGlobs` deleted | `lint: detects a path glob that matches nothing (S02)` |
63
+ | the path signal dropped from `scoreSkill` | `a path signal now reaches the skills that declare a leading **` |
64
+
65
+ The second mutation is the one worth reading twice. The registry-wide test passed with it — it reads
66
+ the generator it is checking, so narrowing the generator narrows the test in step. Only the lint
67
+ rule noticed, and only because a bare double star then expanded to nothing at all. Ruling three is
68
+ the fix, and it exists because the obvious assertion was not enough.
69
+
70
+ Measured end to end through the shipped binary: in a Node repo with one staged `src/other.spec.ts`,
71
+ `complete-testing` now resolves with the reason `path:**/*.spec.ts`. Before this record it did not
72
+ resolve at all. `graph lint` over the real registry reports 49 unique globs and 0 unreachable.
73
+
74
+ ## Rejected
75
+
76
+ **Waiting for S47 and using `path.matchesGlob`.** It is the better implementation and it is one line.
77
+ It is also unavailable under the `engines.node` this package currently declares, and a resolver fix
78
+ that breaks a stated runtime range is a worse defect than the one it removes. When the floor moves,
79
+ this file's ruling one collapses into that call and the rest stands unchanged.
80
+
81
+ **Catching the bad glob at match time instead of lint time.** A `throw` in `matchesGlob` converts one
82
+ silent zero into a loud crash for whoever happened to stage the right file first — a user, in a
83
+ repository, with no pointer to the hand-authored registry field that caused it. The same fact is
84
+ available to `graph lint`, where the remedy is a file and a line number.
85
+
86
+ **Extending the glob language to cover brace and character-class patterns.** `matchesGlob` is called
87
+ with `skill.paths` and nothing else; the registry declares no braces and no character classes, and
88
+ every one added is a rule that must hold for every future glob with no second caller to check it
89
+ against. Ruling four turns the gap into a lint error instead, which is the honest description of a
90
+ field the matcher does not implement.
91
+
92
+ **Testing only the translation, as review 0001's acceptance described.** Unit cases for leading,
93
+ middle, trailing and bare double stars all pass while a declared glob nobody staged still matches
94
+ nothing. The unit tests prove the function; the lint rule is what proves the data.
@@ -0,0 +1,90 @@
1
+ # ADR-043 — A keyword is a phrase, and two letters is a word
2
+
3
+ Status: **accepted** · 2026-10-08
4
+ **Affects:** `src/resolve/resolve.mjs` and what `graph lint` rejects.
5
+
6
+ ## Context
7
+
8
+ Review 0001 §S03, marked P1, reproduced exactly. Two independent filters in `scoreSkill` were
9
+ between a task description and every skill it named.
10
+
11
+ `tokenize` dropped tokens of length 2 or less. In this domain those are not noise: `d1`, `r2`, `kv`,
12
+ `ci` and `ui` are the vocabulary, and the capability aliases `ui`, `r2`, `kv` and `d1` were dead for
13
+ that reason alone. `tokenize("fix d1 migration")` returned `fix, migration` — the one word that
14
+ identifies the change was removed from the sentence before anything read it.
15
+
16
+ Keywords were then compared one task token at a time. A multi-word keyword could therefore never
17
+ match, because no single token equals `"durable object"`. Thirteen of the registry's 232 unique
18
+ keywords were unreachable, nine of them multi-word, and `durable object` — declared on
19
+ `durable-objects`, `cloudflare` and `api-security-review` — was dead on all three. Review 0001 §4
20
+ records the symptom: `skills find "durable object"` returned four matches and not one `keyword:`
21
+ reason.
22
+
23
+ `graph lint` reported the registry clean. It checked that `keywords` was non-empty, which a list of
24
+ words the tokenizer discards satisfies.
25
+
26
+ ## Ruling
27
+
28
+ **One.** The tokenizer keeps words of length 2. The floor was 3 and it was chosen as a noise filter
29
+ in a domain where the two-letter words are the signal. Lowering it is only half a fix; the noise it
30
+ admits is the subject of S04 and is measured below rather than absorbed here.
31
+
32
+ **Two.** A declared keyword is matched as a contiguous phrase, not token by token. Adjacency is the
33
+ whole of the rule, so there is no similarity and no model: `"durable storage object"` does not match
34
+ `"durable object"` and `"a durable object"` does.
35
+
36
+ **Three.** The `keyword:` reason quotes the declaration as written. `keyword:durable object` is a
37
+ string a reader can look up in the registry; `keyword:durable` is a keyword nobody declared.
38
+
39
+ **Four.** A keyword whose words the tokenizer all discards is a lint error. This is the same
40
+ question `checkGlobs` asks of a path (ADR-042), added for the same reason: a hand-authored field
41
+ costs nothing to declare, so only a check that speaks the resolver's language catches one nobody
42
+ executed.
43
+
44
+ ## Consequences
45
+
46
+ Both acceptance cases named in review 0001 §S03 are now asserted against the shipped binary rather
47
+ than against a function's return value, because both are claims about what a reader sees:
48
+ `skills find "durable object"` prints `keyword:durable object`, and `skills brief --task "d1
49
+ migration"` prints `keyword:d1`.
50
+
51
+ Five mutations, each reverted, each caught by the test that claims it:
52
+
53
+ | Mutation | Caught by |
54
+ | --- | --- |
55
+ | the tokenizer floor put back to 3 | five tests, including both acceptance cases |
56
+ | phrase matching reverted to token equality | `a multi-word keyword matches as a phrase`, plus `skills find` |
57
+ | `hasPhrase` becomes a bag of words | `a phrase needs its words adjacent` |
58
+ | `checkKeywords` deleted | `lint: detects a keyword no task can produce` |
59
+ | the reason names a token rather than the declaration | both phrase tests, plus `skills find` |
60
+
61
+ ### The noise this admits, measured
62
+
63
+ Keeping two-letter words lets six of them reach the summary signal: `an`, `in`, `on`, `of`, `so`, and
64
+ `or`, which selects eleven skills on its own. That is real and it is S04's finding, which was filed
65
+ against the old floor and is still open.
66
+
67
+ The measurement that keeps this record honest is the other side of it: `the` selected eleven skills
68
+ and `and` selected twenty-nine **before** this change. A `summary:` hit is weight 8, and any score
69
+ above 0 selects, so the threshold was already low before a single stopword was admitted. S03 adds
70
+ six offenders to a pre-existing problem rather than creating it, and the remedy is the one review
71
+ 0001 named — a stopword list and a minimum score — not a longer tokenizer floor. Restoring the floor
72
+ to 3 would hide the problem by making `d1` disappear again.
73
+
74
+ ## Rejected
75
+
76
+ **Restoring the length-3 floor and declaring the two-letter aliases dead instead.** It would leave the
77
+ suite quieter and `d1` unselectable, which is the defect this record exists to end. The word is in
78
+ the domain; the filter is what was wrong.
79
+
80
+ **Matching multi-word keywords as an unordered bag of words.** Cheaper, and it matches
81
+ `"durable storage object"`. It also makes `google-services.json` a keyword reachable by any task
82
+ merely mentioning Google, which is not what the declaration means.
83
+
84
+ **Tokenizing the task and then searching the joined string for the keyword.** Same adjacency, but it
85
+ loses the `start` offset a phrase needs and re-tokenizes a task the caller already tokenized. The
86
+ keyword is a phrase over the same token stream, so the check belongs on the stream.
87
+
88
+ **Widening `tokenize` to keep single letters as well.** `c`, `x` and `r` appear in skill names as
89
+ initialisms, and every one of them is a stopword or a typo in a task description. Two is the point
90
+ below which a word stops being a word.
@@ -0,0 +1,116 @@
1
+ # ADR-044 — Compatibility is not evidence, and a resolver nobody measured is merely felt
2
+
3
+ Status: **accepted** · 2026-10-08 · affects what `skills brief` and `skills find` return
4
+
5
+ ## Context
6
+
7
+ Review 0001 §S04, marked P1, reproduced exactly through the shipped binary. Two facts, both
8
+ measured in a Workers fixture before anything was changed:
9
+
10
+ - `skills brief --task "fix d1 migration"` resolved 29 914 bytes against a 20 480-byte budget and
11
+ reported `(budget capped)`. The tail was `complete-testing` and `slice-commit`, selected on
12
+ `stack:node+cloudflare-workers` alone.
13
+ - `skills brief --task "fix a typo in the README"` resolved four skills, every one of them on
14
+ `summary:the` or `summary:in`, and filled the budget with bodies about launch galaxies.
15
+
16
+ The mechanism is one line. Stack overlap is 15 a tag, any score above zero selects, and the stack
17
+ gate already ran. Compatibility is a **precondition**, so a score made of nothing but compatibility is
18
+ not a weak match — it is no match, reported as one. Meanwhile `summary` is weight 8 and clears a
19
+ threshold of zero, so a word of shared English was enough to select anything.
20
+
21
+ The review asked for three things: make stack a tiebreaker, add a stopword list with a minimum score
22
+ of 25, and add a gold corpus. It also noted that plan 0002 §11 step 5 claims an `eval` corpus
23
+ "passes" and is marked **done**. There is no `skills eval` command and there was no corpus. Every
24
+ ranking change in this repository had been argued from examples.
25
+
26
+ ## Ruling
27
+
28
+ **One.** A score may select only with evidence. `scoreSkill` returns `evidence` — the score without
29
+ its stack contribution — and the threshold is applied to that, never to the total. Both halves matter:
30
+ stack is 15 a tag, so a repo matching three of a skill's tags is 45 on its own, and a threshold that
31
+ counted stack would wave it through.
32
+
33
+ **Two.** `MIN_SCORE` is `W.capability`, 25. Below it the only available signals are `summary` at 8 and
34
+ `stack` at 15, and neither is a statement about the task. "Only a capability-strength signal or better
35
+ selects" is the rule the review states and the number it gives.
36
+
37
+ **Three.** Stopwords are suppressed in the summary signal only. A keyword is an explicit declaration
38
+ and a name is an identity, so hiding those would suppress something a person wrote on purpose; a
39
+ summary is prose, and prose carries words that are there for grammar. `do not push` is a declared
40
+ keyword and stays reachable.
41
+
42
+ **Four.** The rejection says why. `NOT SELECTED` is the block an agent uses to override, so a
43
+ rejection that cannot be acted on is the same defect as a bad selection.
44
+
45
+ **Five.** One place decides, and three commands call it: `rankSkills`, `skills find`, and the MCP
46
+ `skills_find` tool. They each ranked on `score > 0` independently, and an assertion that one of them
47
+ kept that rule would have passed the entire suite — see the fourth mutation below.
48
+
49
+ ## Consequences
50
+
51
+ The two acceptance cases in review 0001 §S04 now hold, and both are asserted against the shipped
52
+ binary: `fix d1 migration` selects `d1-safe-change` and not `api-contract-sync`, and a task of
53
+ stopwords resolves nothing and says why. The budget no longer caps on either.
54
+
55
+ The gold corpus is `test/fixtures/eval-corpus.json`, ten cases across four ecosystems, run by
56
+ `test/eval.test.mjs`. It states `select` (must return) and `reject` (must not), and asserts that
57
+ every skill it names still exists in the registry — a corpus citing a renamed skill otherwise passes
58
+ by comparing two lists that no longer overlap.
59
+
60
+ Five mutations, each reverted, each caught by the test that claims it:
61
+
62
+ | Mutation | Caught by |
63
+ | --- | --- |
64
+ | the minimum score dropped | two corpus cases, the weak-signal test, and the shipped-binary test |
65
+ | stack counted toward the threshold again | two corpus cases and the weak-signal test |
66
+ | stopwords not applied to the summary signal | both stopword tests |
67
+ | `skills find` reverted to `score > 0` | **nothing** — see below |
68
+ | the stopword list emptied | both stopword tests |
69
+
70
+ The fourth is the one worth keeping. Reverting `skills find` to its own weaker rule passed all 621
71
+ tests at the time, because it was the third command to be moved and no test covered the third. It is
72
+ ADR-035's lesson exactly: a promise about a set of commands is only true while something checks the
73
+ set. The test that now catches it queries `secrets`, chosen because that word lands strictly between
74
+ 0 and `MIN_SCORE` for exactly one skill (`wrangler`, at 8 via its summary), so the two rules give
75
+ visibly different answers — 3 matches against 4.
76
+
77
+ The first version of the precision metric was also wrong and was caught by the corpus itself. It
78
+ scored precision against `select + reject`, so a case where the resolver selected a skill neither
79
+ list named scored a perfect 1.0. That is not precision, it is agreement with whatever was written
80
+ down. Precision is now measured two ways and both must hold: every named negative stays unselected,
81
+ and every case whose answer is "nothing" returns nothing at all.
82
+
83
+ ### What the corpus found that the review did not predict
84
+
85
+ The `regenerate the openapi contract` case failed on the first run, against my own expectation: I had
86
+ written that `web-feature-slice` and `api-feature-slice` should not be selected. The resolver was
87
+ right and the corpus was wrong. `web-feature-slice` and `add-country-galaxy` both declare capability
88
+ `contract`, and `api-feature-slice` declares keyword `openapi` — so each carries a capability-strength
89
+ signal, which is exactly what ruling two admits. A hand-authored gold set is an opinion written down,
90
+ and this one had been written without checking the registry.
91
+
92
+ That leaves a real question this record does not answer: whether `contract` is the right capability
93
+ for a skill that registers a country. Changing `graph/skills.graph.json` changes
94
+ `provenanceDigest()`, which stamps every consumer repository, so the answer is not a question this
95
+ slice may decide by editing one line of data.
96
+
97
+ ## Rejected
98
+
99
+ **Making the minimum score a `--min-score` flag.** It would let a user recover the old behaviour and
100
+ it would put the default in a place nobody reads. The old behaviour is not a preference some
101
+ repositories want; it is the reason `NOT SELECTED` was empty and the budget was full of bodies.
102
+
103
+ **Thresholding on the total score instead of on evidence.** Simpler, and wrong: it fails for exactly
104
+ the repos this tool serves. A repo matching three tags is the common case, not the edge.
105
+
106
+ **Removing the stack signal from the score entirely.** It orders candidates, which is a tiebreaker's
107
+ job, and removing it would make two equally-evidenced skills order by name alone.
108
+
109
+ **Putting the corpus under `graph/`, beside the registry.** `provenanceDigest()` walks `rules/` and
110
+ `graph/`, so a corpus file there changes the content digest and forces every consumer repository to
111
+ be restamped. A test corpus is not worth a migration, and the digest would move invisibly.
112
+
113
+ **Building the `skills eval` command this record could have built.** Plan 0002 §9 lists it. It is a
114
+ new product surface with its own flags, JSON contract and help text, and its absence is a defect in
115
+ the plan rather than in the tool. The corpus and the runner are what S04 asked for; the command is
116
+ recorded below as still owed.