simt-dev 0.6.2 → 0.7.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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -0,0 +1,270 @@
1
+ // The finding-code table behind `simt explain <CODE>` (plan 0003 R46).
2
+ //
3
+ // Every status `simt check` can emit, one row each: what the reader is looking
4
+ // at, which decision settled that it is a finding (the `why` an agent needs
5
+ // before it "fixes" something that is working as designed), how to fix it, and
6
+ // a concrete trigger. The acceptance is coverage, not prose: `test/explain.test.mjs`
7
+ // scans the check pipeline for `status:` literals and fails when one has no row
8
+ // here, so a new finding that ships without documentation is a red gate.
9
+ //
10
+ // Keys are the canonical lowercase codes. `check` prints them uppercased; the
11
+ // command folds case before lookup, so DRIFT and drift are the same question.
12
+
13
+ /**
14
+ * @typedef {{what: string, why: string, fix: string, example: string}} CodeRow
15
+ * @type {Record<string, CodeRow>}
16
+ */
17
+ export const EXPLAIN_CODES = {
18
+ absent: {
19
+ what: 'The file the gate reads does not exist: no AGENTS.md at the repository root, so there is no region to compare.',
20
+ why: 'ADR-025: the region is the baseline in a file the repository owns. Absence is the pre-adoption state — there is nothing to verify against yet, which is why the gate reports it instead of passing a repository it never inspected.',
21
+ fix: 'Run: simt sync --yes to adopt the repository and write the region.',
22
+ example: 'A fresh clone before `simt init`: `check` reports ABSENT AGENTS.md.',
23
+ },
24
+ 'beyond-read-limit': {
25
+ what: 'The generated region ends past a reader\u2019s documented read limit, so that reader stops mid-region and never sees the rules below the cut.',
26
+ why: 'ADR-020: rules are projected per surface, and each surface declares what its reader actually loads. A region longer than the limit is a rule the reader was promised and cannot see.',
27
+ fix: 'Set projection.place to "top" in simt.json and run: simt sync --yes, or raise the reader\u2019s project_doc_max_bytes.',
28
+ example: 'A 40 KB region with a Cursor-side 20 KB read: BEYOND-READ-LIMIT names the reader, the limit and the overrun in bytes.',
29
+ },
30
+ budget: {
31
+ what: 'The always-on projection carries more bytes than this repository agreed to carry in budget.bytes — and nothing was cut.',
32
+ why: 'ADR-072: the budget is a number the repository declares, and the gate reports the overrun without enforcing it by deletion. Nothing is cut in full mode, so a reader told only "over budget" must not go looking for a removed rule.',
33
+ fix: 'Raise budget.bytes if the projection is meant to be this size, switch mode to kernel to defer convention and style rules, or narrow the rules that carry it.',
34
+ example: 'budget.bytes 12000 with a 18400-byte projection: BUDGET names the bytes and the ceiling.',
35
+ },
36
+ 'budget-cut': {
37
+ what: 'The projection was cut to fit budget.bytes: kernel mode packed least-valuable-first and named every cut rule under "Deferred rules".',
38
+ why: 'ADR-083: the cut is the tool doing what the repository was told — a repository that declared a budget asked for exactly this. Failing the gate for it would fail the tool for obeying.',
39
+ fix: 'No fix needed: this is reported, not failed. Read the Deferred rules section (simt rules show <id> restores any cut rule), or raise budget.bytes.',
40
+ example: 'A kernel-mode repo 3 KB over budget: BUDGET-CUT lists the deferred rule ids.',
41
+ },
42
+ 'check-unbuilt': {
43
+ what: 'A selected rule declares a checker this tool does not know, so the gate has no direction to enforce and refuses to pretend otherwise.',
44
+ why: 'ADR-088: declared checkers are compiled from rule prose, and a name that resolves to nothing is not a check — it is a sentence shaped like one. Reported, never blocking: a warning, and a checker with no direction to enforce, is not a red gate.',
45
+ fix: 'Correct the checker name in the rule, or implement it; until then the gate reports and does not fail.',
46
+ example: 'A rule naming `max-paragraphs` when no such checker exists.',
47
+ },
48
+ current: {
49
+ what: 'Not a defect: the file on disk is byte-for-byte what the tool would write. This is the gate\u2019s passing state for that file.',
50
+ why: 'ADR-036: a check that finds no drift exits 0, and the per-file CURRENT lines are the receipt — the answer to "which files did the gate actually look at".',
51
+ fix: 'No fix needed. If every file is current, the gate is green.',
52
+ example: 'AGENTS.md after `simt sync --yes`: CURRENT, with no remedy line.',
53
+ },
54
+ drift: {
55
+ what: 'The file on disk differs from what the tool would write: someone edited the generated region, or the rules changed underneath it.',
56
+ why: 'ADR-036: a bare sync is a check that prints the plan, and a check that finds drift exits non-zero. The region is generated text; hand edits to it are overwritten by the next sync, so the gate fails rather than silently blessing them.',
57
+ fix: 'Run: simt sync --yes to regenerate, or move the hand edit outside the region markers.',
58
+ example: 'A hand-added paragraph inside the simt region of AGENTS.md.',
59
+ },
60
+ 'file-lines': {
61
+ what: 'A file exceeds the 1000-line hard ceiling: it fails the gate.',
62
+ why: 'ADR-005: 400 lines is a review trigger, 1000 is a hard ceiling, and excluded files are exempt. The ceiling is a default a repository can narrow, never a number an agent negotiates at write time.',
63
+ fix: 'Split the file, or name it in limits.fileLines.exclude — not by editing the generated line.',
64
+ example: 'A 1240-line module with no exemption: FILE-LINES names the file and the count.',
65
+ },
66
+ 'file-lines-warn': {
67
+ what: 'A file passed the 400-line review trigger but is under the ceiling: reported, and the gate stays green.',
68
+ why: 'ADR-005: the trigger exists to start a review, not to fail a gate. A warning that failed would turn every growing file into a red repository months before the ceiling mattered.',
69
+ fix: 'No fix needed unless the growth continues: review the file, split it early, or ignore the warning.',
70
+ example: 'A 460-line file: FILE-LINES-WARN, no remedy, exit still 0.',
71
+ },
72
+ 'forbid-regex': {
73
+ what: 'A rule forbids a pattern and the pattern occurs: the file contains text the repository\u2019s own rules refuse.',
74
+ why: 'ADR-088: declared checkers read the rule source, not the folded region, so a line that left the prompt is still the line that runs. The rule is the decision; this finding is its enforcement.',
75
+ fix: 'Remove the matching text, or amend the rule if the pattern is now legitimate.',
76
+ example: 'A rule forbidding `console.log` in a file that logs to the console.',
77
+ },
78
+ 'forbidden-path': {
79
+ what: 'A path the repository forbids was touched: the change reaches a file the rules say is out of bounds.',
80
+ why: 'ADR-088: path checkers are declared beside the rules they protect, and a gate that watched content but not location would guard the words while the layout rotted.',
81
+ fix: 'Revert the touch, move the work to an allowed path, or amend the rule.',
82
+ example: 'A commit editing generated output the rules declare read-only.',
83
+ },
84
+ 'gate-cwd-missing': {
85
+ what: 'gates.cwd names a directory that is not a directory in this repository: the gate would run somewhere that does not exist.',
86
+ why: 'ADR-017: a declared gate must be runnable, and a gate whose working directory is missing fails closed on every commit — after the commit was written, which is the wrong order. Reported here so the reader finds out before committing.',
87
+ fix: 'Point gates.cwd at a directory in this repository, or remove it to run the gate at the root.',
88
+ example: 'gates.cwd.test set to "frontend" after the directory was renamed to "web".',
89
+ },
90
+ 'gate-cwd-outside': {
91
+ what: 'gates.cwd resolves outside the repository: the gate would run outside the tree it is supposed to guard.',
92
+ why: 'ADR-017: the dispatcher fails closed on a cwd outside the repository, because a gate running elsewhere enforces nothing here while reporting green. The finding names the gate and the path, since "no such directory" and "outside the repository" are repaired by different edits.',
93
+ fix: 'Point gates.cwd at a directory inside this repository, or remove it.',
94
+ example: 'gates.cwd.test set to "../shared" in a monorepo member.',
95
+ },
96
+ inconsistent: {
97
+ what: 'The project\u2019s tsconfig spells `moduleResolution` in a form the TypeScript parser accepts but the documentation does not, so it compiles and quietly disagrees with every repository that spells it the documented way.',
98
+ why: 'The parser is case-insensitive, which is the whole reason a workspace drifts on this key: both spellings work, so nothing ever said the other one did not. Consistency with the documentation is the only spelling checkable in one repository at a time \u2014 consistency with the other repositories would need a workspace, and `check` runs in a repository on its own.',
99
+ fix: 'Use the documented spelling the finding names; it compiles identically.',
100
+ example: '"moduleResolution": "Bundler" where the documentation writes "bundler".',
101
+ },
102
+ inert: {
103
+ what: 'The TypeScript base config is present but inert: nothing in the repository reads it, so it constrains no file.',
104
+ why: 'ADR-026: a config nobody reads is decoration that misdescribes the artefact — the same defect as a rule no surface projects. Reported so the dead file is either wired up or deleted.',
105
+ fix: 'Point the project tsconfig at the base, or delete the base file.',
106
+ example: 'A tsconfig.base.json no project tsconfig extends.',
107
+ },
108
+ invalid: {
109
+ what: 'A file the tool reads declares something it cannot use: a rule with unparseable frontmatter, a manifest surface no editor documents, or a checker error the compiler reports.',
110
+ why: 'The tool reads strictly and fails loudly (ADR-088 for rules and checkers, ADR-020 for surfaces): a profile that silently skipped the broken half would project a region nobody can account for, and `rules why` could not resolve it.',
111
+ fix: 'Read the detail: it names the file and the reason. Repair the frontmatter key, the surface id, or the checker declaration it cites.',
112
+ example: 'A rule whose frontmatter has `simt-applies: [node` — the detail names the line.',
113
+ },
114
+ 'invalid-decision-frontmatter': {
115
+ what: 'An ADR\u2019s structured frontmatter does not parse: simt-supersedes names a record or ruling that does not exist, or the shape is not [{ adr, ruling }].',
116
+ why: 'Plan 0003 R34: supersession is data the tooling reads (`rules why`, `why`), and frontmatter that almost parses is a claim no reader can verify. Failing here is what keeps a typo from rewriting history silently.',
117
+ fix: 'Fix the simt-supersedes entry to [{ adr: "ADR-NNN", ruling: N }], with a real record and ruling number.',
118
+ example: 'simt-supersedes: [{ adr: "ADR-999", ruling: 1 }] when no ADR-999 exists.',
119
+ },
120
+ 'duplicate-decision-id': {
121
+ what: 'Two decision records claim one number: the index only sees the first, so the second is a record no provenance answer can reach.',
122
+ why: 'Review 0003 Q55: byId keeps one silently, and a reader of the other gets a decision the tooling cannot resolve. Failing names both files while the collision is still a rename, not a rewrite of history.',
123
+ fix: 'Renumber the newer record to a free number, then rerun the check.',
124
+ example: 'ADR-002-first.md and ADR-002-second.md: DUPLICATE-DECISION-ID names both.',
125
+ },
126
+ 'lock-absent': {
127
+ what: 'No lock is recorded: the projection was never locked, so there is nothing `--frozen` can compare against.',
128
+ why: 'ADR-086: the lock is the byte-identical contract across machines, and `--frozen` without one would compare the projection against nothing and call it current. Reported only under --frozen, because a repository that never locked is not drift.',
129
+ fix: 'Run: simt sync --yes to write the lock.',
130
+ example: '`simt check --frozen` on a repo adopted before the lock existed.',
131
+ },
132
+ 'lock-stale': {
133
+ what: 'The inputs changed and the lock was not rewritten: something the projection is computed from moved without re-locking.',
134
+ why: 'ADR-086: a stale lock is a lock describing another tree. Failing here is what makes the lock a contract rather than a souvenir — the wording says which of the two failures it was.',
135
+ fix: 'Run: simt sync --yes to rewrite the lock from the current inputs.',
136
+ example: 'A rule edited after the last sync, then `check --frozen`.',
137
+ },
138
+ 'lock-unexplained': {
139
+ what: 'The projection changed and the inputs did not: the outputs moved with no input change to explain them.',
140
+ why: 'ADR-086: this is the failure the lock exists to catch — an output no input explains is either a tool bug or a hand edit inside the region, and both must be loud rather than absorbed.',
141
+ fix: 'Run: simt sync --yes if the change is intended; if it recurs with no edits, file it as a tool defect with the lock diff.',
142
+ example: 'Hand-edited generated text with untouched rules, then `check --frozen`.',
143
+ },
144
+ 'lock-unreadable': {
145
+ what: 'The lock file cannot be read or parsed: --frozen has a lock it cannot verify.',
146
+ why: 'ADR-086: an unreadable lock is not an absent one — deleting the distinction would let a corrupted lock pass as "not yet locked". The error text is the detail, because the remedy depends on what broke.',
147
+ fix: 'Read the error: repair the file, or delete it and run: simt sync --yes to rewrite it.',
148
+ example: 'A lock file truncated by a crashed write: LOCK-UNREADABLE names the parse error.',
149
+ },
150
+ malformed: {
151
+ what: 'The generated region\u2019s markers do not parse: the begin/end lines are damaged, duplicated, or in the wrong order.',
152
+ why: 'The markers are the machine-readable boundary between owned prose and generated text. A region the parser cannot bound cannot be diffed, so the gate reports the damage (with the line) instead of guessing which half is yours.',
153
+ fix: 'Restore the marker lines, or delete the whole region and run: simt sync --yes to rewrite it cleanly.',
154
+ example: 'Two BEGIN lines after a bad merge: MALFORMED names the line.',
155
+ },
156
+ misplaced: {
157
+ what: 'The region is intact but not at the top of the file, so it overrides the settings below it — and in this format the last matching definition wins, so the repository\u2019s own settings silently stop applying.',
158
+ why: 'ADR-025: the baseline belongs where it cannot change meaning by position. The bytes are right and the meaning is wrong, which is the hardest kind of drift to notice, so it gets its own finding rather than a note.',
159
+ fix: 'Move the region markers above your own settings, or delete the region and set what you need.',
160
+ example: 'A project header prepended above the generated region in AGENTS.md.',
161
+ },
162
+ orphaned: {
163
+ what: 'A managed rule file this run would not write: narrowing a surface\u2019s output left a file the tool once generated and now ignores.',
164
+ why: 'ADR-021 ruling 6: sync does not delete a file it once wrote, and the gate must not fail on one either — a finding sync cannot repair and would never write again is a note with a remedy, not a problem (ADR-051 draws the same line for pointers).',
165
+ fix: 'Delete it by hand if it is truly stale, or leave it: the gate reports and passes.',
166
+ example: 'A .mcc rule dropped from the surface set, still on disk.',
167
+ },
168
+ 'pack-declared-twice': {
169
+ what: 'The manifest declares the same pack twice: the second declaration is read as the first, so one of them is dead text.',
170
+ why: 'ADR-065: a pack declaration is a pin, and two pins for one name cannot both be true. The resolver reads the first and reports the second rather than merging them and guessing.',
171
+ fix: 'Remove the duplicate entry, then: simt sync --yes.',
172
+ example: 'Two { name: "house" } entries after a bad merge of simt.json.',
173
+ },
174
+ 'pack-digest-mismatch': {
175
+ what: 'The pack\u2019s bytes no longer match the digest the manifest pins: someone edited the vendored copy, or vendored different bytes than the pin records.',
176
+ why: 'ADR-065: the digest is the per-repo answer to "which bytes were these". The pack still projects — dropping it would let the remedy delete the rules it carries, which is the opposite of what the reader meant — but the gate stays red until the pin is re-recorded, and both digests are printed.',
177
+ fix: 'Restore the pinned bytes with: git checkout -- .simt/packs/<name> (or record the new digest, then: simt sync --yes).',
178
+ example: 'A hand edit inside .simt/packs/house/rules/: PACK-DIGEST-MISMATCH prints both digests.',
179
+ },
180
+ 'pack-missing': {
181
+ what: 'The manifest declares a pack with no directory: every rule the pack carries is missing from this repository\u2019s projection.',
182
+ why: 'ADR-065: a region rendered without half its rules tells the reader something false, and the next sync would overwrite the truth on disk with the shorter region. The remedy restores the bytes git has — sync cannot help, because it would render the region without the pack and call it current.',
183
+ fix: 'Restore it with: git checkout -- .simt/packs/<name> (or remove the declaration, then: simt sync --yes).',
184
+ example: 'A fresh clone with .simt/packs untracked: PACK-MISSING names the directory.',
185
+ },
186
+ 'pack-name-invalid': {
187
+ what: 'The manifest names a pack that cannot be a directory name: it escapes the pack directory or uses characters a path cannot hold.',
188
+ why: 'ADR-065: a pack name is built into a path the renderer reads, so a hand-edited manifest pointing outside the repository would make the region depend on bytes the reader does not have — the one failure this tool exists to prevent.',
189
+ fix: 'Correct or remove the entry, then: simt sync --yes.',
190
+ example: '{ name: "../escape" }: PACK-NAME-INVALID, and the entry is not resolved as a pack.',
191
+ },
192
+ 'pack-unpinned': {
193
+ what: 'The pack has no digest: nothing records which bytes it was declared at. Its rules still render, and the gate stays green.',
194
+ why: 'ADR-065: an absent pin is a missing record, not a mismatch — refusing to project would punish a repository for a field the tool treats as optional. Reported with the digest to record, so pinning is one copy-paste.',
195
+ fix: 'Record the printed digest as its digest, then: simt sync --yes.',
196
+ example: 'A freshly vendored pack before the first pin: PACK-UNPINNED prints the digest.',
197
+ },
198
+ 'pointer-blocked': {
199
+ what: 'The pointer file cannot be written where it belongs: something the tool does not own blocks the path.',
200
+ why: 'ADR-051: the pointer is the reader\u2019s only route to the rules, so a blocked pointer is not a convenience failing — it is the rules going unread. The gate fails rather than leaving a reader with a pointer at a file that is not the rules.',
201
+ fix: 'Remove or relocate the blocking file, then: simt sync --yes.',
202
+ example: 'A hand-written CLAUDE.md where the pointer belongs: POINTER-BLOCKED.',
203
+ },
204
+ 'pointer-unread': {
205
+ what: 'The pointer aims at a file the reader will not read: it names a path no adopted surface documents.',
206
+ why: 'ADR-051: a pointer the reader never follows is a rule nobody is held to. Sync refuses to write it (a gate whose remedy is the command being run is worse than a noisy gate); check is where it blocks.',
207
+ fix: 'Point the surface at a path the reader loads, or drop the surface.',
208
+ example: 'A surface aimed at docs/notes.md: POINTER-UNREAD names the path.',
209
+ },
210
+ 'prose-partial-supersession': {
211
+ what: 'An ADR states a partial supersession only in prose: "ruling N superseded by ADR-MMM" with no matching simt-supersedes frontmatter entry, so tooling cannot see the move.',
212
+ why: 'Plan 0003 R34 with ADR-049: a supersession the parser cannot see is a ruling that `rules why` reports as still standing. Stating it twice — prose for the reader, frontmatter for the tool — is what keeps history from being rewritten by a typo.',
213
+ fix: 'Declare simt-supersedes: [{ adr: "ADR-MMM", ruling: N }] in the record\u2019s frontmatter.',
214
+ example: 'A status line "ruling 2 narrowed by ADR-024" with empty frontmatter: the lint names the raw clause.',
215
+ },
216
+ reformatted: {
217
+ what: 'The manifest\u2019s content is what simt-dev would write, but the bytes differ: another formatter owns this file\u2019s shape.',
218
+ why: 'ADR-050: a formatter that rewrites the file on every run and a tool that rewrites it back would fight forever, with init as the only repair — which re-runs adoption to change one file\u2019s formatting. Comparing content-to-content isolates the real question: does another tool own this file.',
219
+ fix: 'Add simt.json to .prettierignore (a generated file is not prose), then: simt sync --yes.',
220
+ example: 'Prettier collapsing the manifest to one line: REFORMATTED, content identical.',
221
+ },
222
+ 'required-file': {
223
+ what: 'A rule requires a file the repository does not have: the project is missing something its own rules demand.',
224
+ why: 'ADR-088: required-file is a declared checker, and a declaration nobody verifies is a wish. The gate fails because the rule\u2019s author already decided this file is load-bearing.',
225
+ fix: 'Add the file, or amend the rule if the requirement moved on.',
226
+ example: 'A rule requiring SECURITY.md in a repo without one.',
227
+ },
228
+ 'stale-generated-field': {
229
+ what: 'Generated manifest fields do not match what simt-dev writes: the projection SHA, the stack, or the version pin drifted without a re-run.',
230
+ why: 'ADR-050: sync repairs exactly this field set and nothing else, so init\u2019s re-adoption is never the repair for a stale field. check names the fields; sync rewrites them.',
231
+ fix: 'Run: simt sync --yes.',
232
+ example: 'A hand-bumped version pin with the old projection SHA still recorded.',
233
+ },
234
+ unmanaged: {
235
+ what: 'The file exists but carries no generated region: the repository was never adopted, or the region was deleted by hand.',
236
+ why: 'ADR-025: with no region there is no baseline to verify against. Unmanaged is the normal mid-migration state, and the gate says so with the command that moves it forward rather than failing mysteriously.',
237
+ fix: 'Run: simt sync --yes to write the region (or simt init for a first adoption).',
238
+ example: 'AGENTS.md present, hand-written, no simt region: UNMANAGED.',
239
+ },
240
+ 'unknown-gate-cwd': {
241
+ what: 'gates.cwd names a gate the manifest never declares: the working-directory override points at nothing.',
242
+ why: 'ADR-017: an override for a gate that does not exist cannot run, and a gate that cannot run fails closed on every commit. Reported here so the typo surfaces before the first commit, not after.',
243
+ fix: 'Correct the gate name in gates.cwd, or declare the gate it was meant for.',
244
+ example: 'gates.cwd.test set while the manifest declares no test gate.',
245
+ },
246
+ unreadable: {
247
+ what: 'The gate could not read a file it must verify: permissions, a broken symlink, or an I/O error — anything but a clean absence.',
248
+ why: 'A check that is wrong about existence is worse than one that crashes: only ENOENT is absence, and every other failure is reported with its own code rather than folded into "missing". The gate refuses rather than passing a repository it never inspected.',
249
+ fix: 'Read the detail (it carries the OS error): fix permissions, remove the broken link, then re-run.',
250
+ example: 'AGENTS.md unreadable (EACCES): UNREADABLE with the errno, not ABSENT.',
251
+ },
252
+ unresolvable: {
253
+ what: 'The checked-in gate workflow cannot resolve simt-dev by any path its locator tries: no pinned devDependency, no release binary, no simt on PATH.',
254
+ why: 'Review 0003 Q31: a green tick from a check that never ran is the one outcome worse than a red one. The workflow installs whatever the repo pins, so a repo that pins nothing gates nothing — reported here instead of failing open in CI.',
255
+ fix: 'Run the install the finding names (the repo manager\u2019s add-dev command) so CI restores the pinned dependency.',
256
+ example: 'A pnpm repo whose workflow says npm ci with no simt-dev dependency: UNRESOLVABLE names pnpm add -D.',
257
+ },
258
+ unrunnable: {
259
+ what: 'A declared gate cannot run here: the command it needs is not on PATH, or its runner is missing.',
260
+ why: 'ADR-017: a gate that cannot run fails closed on every commit once hooks are installed — after the reader committed, which is the wrong order. check reports it before anyone installs anything, with the probe result that proved it.',
261
+ fix: 'Install the missing program, declare the gate\u2019s command in gates.commands, or drop the gate from the manifest.',
262
+ example: 'A lint gate naming eslint with no eslint on PATH: UNRUNNABLE names the probe.',
263
+ },
264
+ 'version-mismatch': {
265
+ what: 'The binary running the gate is not the version the manifest pins: the gate that runs is not the gate CI runs.',
266
+ why: 'ADR-059: the version a repository runs is the one it pinned, and a stale binary would run a different gate than the one the pin promises. The mismatch fails the gate rather than warning, because a warning is a gate that passes while disagreeing with CI.',
267
+ fix: 'Run: simt upgrade --yes to move the pin (and the binary) forward.',
268
+ example: 'Pin simt-dev 0.5.0 with 0.6.2 installed: VERSION-MISMATCH names both.',
269
+ },
270
+ };
@@ -35,6 +35,8 @@
35
35
  // runs. A malformed block costs the reader one line, which is exactly what they need to fix
36
36
  // it — and a silent skip is the failure that line exists to prevent.
37
37
 
38
+ import { isAbsolute, relative, resolve } from 'node:path';
39
+
38
40
  import { readManifest } from '../projection/manifest.mjs';
39
41
  import { DEFAULT_TIMEOUT_MS } from './client.mjs';
40
42
 
@@ -120,6 +122,48 @@ export function federationServers(manifest) {
120
122
  return { servers, findings };
121
123
  }
122
124
 
125
+ /**
126
+ * Whether the MCP server may ask repo-declared servers on this call.
127
+ *
128
+ * Pure: everything it reads comes in through the arguments, so the same call always
129
+ * gives the same answer. Two gates, both closed by default (Q13):
130
+ *
131
+ * 1. The `root` a model supplies must sit inside the server's own ground — its cwd or
132
+ * the resolved workspace root. A root anywhere else is a directory with a crafted
133
+ * manifest whose `command` this tool would otherwise spawn.
134
+ * 2. Each server asked must be named in the *user-level* config, not only in the
135
+ * repo manifest. A repository the model points at does not get to authorise
136
+ * process spawns on the operator's machine; the operator does, in their own file.
137
+ * `federation.allow: true` names every repo-declared server at once.
138
+ *
139
+ * @returns {null | {message: string, remedy: string}} null when the call may proceed.
140
+ */
141
+ export function federationAccess({ root, cwd, workspaceRoot, serverNames, userConfig, configPath }) {
142
+ const requested = resolve(root);
143
+ const grounds = [cwd, workspaceRoot].filter((base) => typeof base === 'string' && base.length > 0).map((base) => resolve(base));
144
+ const inside = grounds.some((base) => {
145
+ const rel = relative(base, requested);
146
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
147
+ });
148
+ if (!inside) {
149
+ return {
150
+ message: `federation_query refuses a root outside the server workspace: ${requested}`,
151
+ remedy: 'point root at a repository under the workspace this server was started in',
152
+ };
153
+ }
154
+ const federation = userConfig?.federation;
155
+ const allowAll = federation?.allow === true;
156
+ const allowed = Array.isArray(federation?.servers) ? federation.servers.filter((name) => typeof name === 'string') : [];
157
+ const refused = (serverNames ?? []).filter((name) => !allowAll && !allowed.includes(name));
158
+ if (refused.length > 0) {
159
+ return {
160
+ message: `federation server \`${refused[0]}\` is not named in the user config`,
161
+ remedy: `name it under \`federation.servers\` in ${configPath} (or set \`federation.allow: true\` to allow every repo-declared server)`,
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+
123
167
  /**
124
168
  * The same two lists, read from the repository's manifest.
125
169
  *
@@ -116,13 +116,22 @@ async function defaultRegistry(flags) {
116
116
  * environment, user config file, then a sibling heuristic that must verify - and a
117
117
  * failure to resolve is a WARNING, not 38 `ENOENT` lines (ADR-014).
118
118
  *
119
- * Resolved once per process and memoised, because every caller needs the same answer
120
- * and the answer cannot change while the process runs.
119
+ * Memoised per the inputs that decide the answer: the `--workspace` flag, the
120
+ * `SIMT_DEV_WORKSPACE` environment and the user-config location. A single
121
+ * first-call-wins promise served one caller's workspace to every later caller with
122
+ * different flags (Q14) — `skills_index({workspace: ws})` followed by
123
+ * `skills_index({})` answered `ws` twice. Callers with the same inputs still share
124
+ * the one resolution, which is where the cost was.
121
125
  */
122
- let workspacePromise = null;
126
+ const workspacePromises = new Map();
123
127
  export function workspace(flags = {}) {
124
- if (!workspacePromise) workspacePromise = resolveWorkspace(flags);
125
- return workspacePromise;
128
+ const key = JSON.stringify([
129
+ flags?.workspace ?? null,
130
+ process.env.SIMT_DEV_WORKSPACE ?? null,
131
+ process.env.XDG_CONFIG_HOME ?? null,
132
+ ]);
133
+ if (!workspacePromises.has(key)) workspacePromises.set(key, resolveWorkspace(flags));
134
+ return workspacePromises.get(key);
126
135
  }
127
136
 
128
137
  const VALID_ROLES = new Set(['canonical', 'variant', 'stub', 'duplicate', 'deprecated']);
@@ -145,10 +154,15 @@ export async function loadRegistry(path = null, flags = {}) {
145
154
  // errors are facts about ABSENCE - no registry here, or more than one and the reader must
146
155
  // pick - and are reported as such. A named path that is missing is a different fact: the
147
156
  // reader pointed at a file that is not there, and it names that path.
148
- let where = path;
157
+ //
158
+ // Resolution order for the registry itself (review 0003 Q37): positional path,
159
+ // then `--registry`, then `SIMT_REGISTRY`, then the default tiers above.
160
+ const explicit =
161
+ path ?? flags?.registry ?? process.env.SIMT_REGISTRY ?? process.env.SIMT_DEV_REGISTRY ?? null;
162
+ let where = explicit;
149
163
  let resolveErrors = null;
150
164
  let resolvedWorkspace = null;
151
- if (path === null) {
165
+ if (explicit === null) {
152
166
  const found = await defaultRegistry(flags);
153
167
  where = found.where;
154
168
  resolveErrors = found.errors;
@@ -172,7 +186,7 @@ export async function loadRegistry(path = null, flags = {}) {
172
186
  registry = JSON.parse(await readFile(where, 'utf8'));
173
187
  } catch (error) {
174
188
  const message =
175
- path === null && error.code === 'ENOENT'
189
+ explicit === null && error.code === 'ENOENT'
176
190
  ? `no skill registry at ${where}; create one there, or name it explicitly`
177
191
  : `${where}: unreadable or invalid JSON (${error.message})`;
178
192
  return {
@@ -251,6 +265,11 @@ export async function loadRegistry(path = null, flags = {}) {
251
265
  * broken registry rather than a wrong pointer, which is exactly backwards.
252
266
  */
253
267
  function holdsAnyRepo(registry, root) {
268
+ // Every repo the registry names, vendored or not: the question is whether this
269
+ // directory is a workspace at all. Filtering out vendored sources made the check
270
+ // unsatisfiable the moment the last non-vendored skill left the registry - an
271
+ // all-vendored registry names repos just the same, and a home folder holds none
272
+ // of them.
254
273
  const named = new Set(
255
274
  (registry.skills ?? []).flatMap((s) => (s.sources ?? []).map((x) => x.repo)).filter(Boolean),
256
275
  );
@@ -273,14 +292,20 @@ export function lintRegistry(registry) {
273
292
  const threshold = registry.deferredThreshold ?? 20000;
274
293
 
275
294
  // --- shape -----------------------------------------------------------------
295
+ // Every check below assumes arrays of objects. A registry that fails the shape
296
+ // is already fully described by the shape error; continuing past it only
297
+ // trades one true error for a crash (`for..of` over `undefined`) or a pile of
298
+ // secondary noise, so the lint stops here (review 0003 Q35).
276
299
  if (!Array.isArray(registry.capabilities) || capabilities.size === 0) {
277
300
  err('capabilities: missing or empty');
278
301
  }
279
302
  if (!Array.isArray(registry.skills) || skills.length === 0) {
280
303
  err('skills: missing or empty');
304
+ }
305
+ if (errors.length > 0) {
281
306
  return { sync: errors, warnings, index };
282
307
  }
283
- for (const c of registry.capabilities) {
308
+ for (const c of registry.capabilities ?? []) {
284
309
  if (!c.id || !c.summary) err(`capability ${c.id ?? '<no id>'}: needs id and summary`);
285
310
  }
286
311
 
@@ -440,22 +465,22 @@ function checkRequires(at, skill, index, err) {
440
465
  if (!index.has(dep)) err(`${at}: requires unknown skill "${dep}"`);
441
466
  if (dep === skill.name) err(`${at}: requires itself`);
442
467
  }
443
- // Cycle detection over `requires`. Depth is bounded by node count, so an
444
- // accidental cycle terminates rather than hanging the lint.
445
- const seen = new Set([skill.name]);
446
- let frontier = skill.requires ?? [];
447
- while (frontier.length > 0) {
448
- const next = [];
449
- for (const dep of frontier) {
450
- if (seen.has(dep)) {
451
- err(`requires cycle: ${[...seen].join(' -> ')} -> ${dep}`);
468
+ // Cycle detection over `requires`, depth-first with a recursion stack. The old
469
+ // breadth-first walk shared one `seen` set across branches, so a diamond
470
+ // (a requires b and c; both require d) reported d's second visit as a cycle.
471
+ // Only a dependency already on the current path is a cycle; a node reached
472
+ // through two branches is sharing, not looping (review 0003 Q35).
473
+ const visit = (name, path) => {
474
+ const stack = [...path, name];
475
+ for (const dep of index.get(name)?.requires ?? []) {
476
+ if (stack.includes(dep)) {
477
+ err(`requires cycle: ${[...stack, dep].join(' -> ')}`);
452
478
  return;
453
479
  }
454
- seen.add(dep);
455
- next.push(...(index.get(dep)?.requires ?? []));
480
+ visit(dep, stack);
456
481
  }
457
- frontier = next;
458
- }
482
+ };
483
+ visit(skill.name, []);
459
484
  }
460
485
 
461
486
  function checkAlias(at, skill, index, err) {
@@ -561,6 +586,10 @@ async function lintSourcesOnDisk(registry, workspaceRoot) {
561
586
  const warnings = [];
562
587
 
563
588
  for (const skill of registry.skills ?? []) {
589
+ // A vendored skill ships with the package; its source is the package root,
590
+ // not a workspace member. Verifying it against the workspace filesystem is
591
+ // the wrong check (the path will not exist there by design).
592
+ if (skill.vendored) continue;
564
593
  for (const source of skill.sources ?? []) {
565
594
  const at = `${skill.name}: ${source.repo}/${source.path}`;
566
595
  if (!source.repo || !source.path) continue;
@@ -635,8 +664,7 @@ export async function measureSkill(full) {
635
664
  hasEntry = false;
636
665
  }
637
666
  if (!hasEntry) {
638
- const md = files === 1 ? '' : '';
639
- throw Object.assign(new Error(`${full} has no SKILL.md entry point${md}`), { code: 'NO_SKILL_MD' });
667
+ throw Object.assign(new Error(`${full} has no SKILL.md entry point`), { code: 'NO_SKILL_MD' });
640
668
  }
641
669
 
642
670
  return { bytes, files, flat: false };
@@ -22,6 +22,7 @@ import { constants as fsConstants } from 'node:fs';
22
22
  import { access, readdir, readFile, stat } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
24
  import { ECOSYSTEMS, manifestPath } from '../projection/manifest.mjs';
25
+ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
25
26
 
26
27
  /** Binaries the shared dispatcher shells out to directly, rather than via a script. */
27
28
  const BIN_DIR = join('node_modules', '.bin');
@@ -104,10 +105,22 @@ async function probeTool(root, eco) {
104
105
  export async function readCapability(root) {
105
106
  const capability = { scripts: {}, binaries: [], commands: {}, tools: [], runner: null, error: null };
106
107
 
108
+ // Q31: a package.json names a Node repo, but not which manager it installs
109
+ // with. The lockfile on disk wins over the `packageManager` field (the field
110
+ // can disagree; `checkToolchain` reports that), and the field wins over the
111
+ // npm default — so a `packageManager: pnpm@9` repo resolves pnpm before its
112
+ // first install, and every consumer below reads the same answer.
113
+ let packageManager = null;
107
114
  try {
108
115
  const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
109
116
  capability.scripts = manifest.scripts ?? {};
110
- capability.runner = 'npm';
117
+ packageManager = typeof manifest.packageManager === 'string' ? manifest.packageManager : null;
118
+ const lockfiles = [];
119
+ for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
120
+ if (await fileExists(join(root, file))) lockfiles.push(file);
121
+ }
122
+ capability.runner = resolveNodeRunner({ lockfiles, packageManager }).id;
123
+ capability.packageManager = packageManager;
111
124
  } catch (error) {
112
125
  // A repo with no manifest is a normal shape — a Python or Gradle repo has no
113
126
  // package.json at all — so this is reported, never thrown. The gate filter treats