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
package/src/mcp/tools.mjs CHANGED
@@ -5,34 +5,36 @@
5
5
  // a diff by a human before they touch anything (ADR-001, ADR-004). A model can call a
6
6
  // tool; a model cannot review a diff it never showed you.
7
7
  //
8
- // The tools build their own payloads from the same modules the commands use rather than
9
- // calling the command functions. That is not duplication for its own sake: the commands
10
- // render for terminals, these return JSON for a model, and forcing one renderer through a
11
- // transport it was not written for is how a "clean" abstraction starts lying about bytes.
12
- // What is shared is the LOGIC - fingerprint, the registry, the resolver, the region
13
- // planner - and that is what has to be shared, because two resolvers would be two
14
- // answers to the same question.
8
+ // The tools return the same facts the commands render, by calling the command
9
+ // modules' core functions rather than rebuilding the answers. The commands render
10
+ // for terminals, these return JSON for a model, and the two copies had already
11
+ // drifted: MCP `check` reported 3 findings where the command reports 77, the MCP
12
+ // `doctor` was a fingerprint with none of the findings, `rules_list` missed the
13
+ // per-surface rules, and the skills payloads disagreed on field names (Q15). The
14
+ // CLI keeps its field names; the tools adopt them. What is shared is the whole
15
+ // answer, because two answers to one question is what ADR-035 rules out.
15
16
  //
16
17
  // Bodies are never inlined by `skills_brief`. Tier 1 is a set of pointers and reasons;
17
18
  // the body is a separate call. A 384 KB reference inlined into a tool result is the exact
18
19
  // failure progressive disclosure exists to prevent, and a transport makes it easier to
19
20
  // cause by accident because the caller cannot see the size until it has paid for it.
20
21
 
21
- import { fingerprint } from '../resolve/fingerprint.mjs';
22
22
  import { loadRegistry } from '../graph/load.mjs';
23
- import { tokenize, rankSkills, closure, selectWithinBudget, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
24
- import { loadRules, select, checkTargets, provenanceDigest } from '../projection/project.mjs';
25
- import { loadDecisions, byId, citations } from '../decisions/index.mjs';
23
+ import { provenanceDigest } from '../projection/project.mjs';
26
24
  import { discover } from '../compare/discover.mjs';
27
- import { buildRow, summarise } from '../compare/row.mjs';
25
+ import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
28
26
  import { GATES } from '../hooks/gates.mjs';
29
- import { dirtyPaths, parsePathList, readBody } from '../commands/skills.mjs';
27
+ import { checkRepo } from '../commands/sync.mjs';
28
+ import { diagnose, serialisedFindings } from '../commands/doctor.mjs';
29
+ import { rulesCatalogue, rulesWhyFacts } from '../commands/rules.mjs';
30
+ import { skillsBriefFacts, skillsFindFacts, skillsShowFacts, skillsWhyFacts } from '../commands/skills.mjs';
30
31
  import { assembleBrief } from '../brief/assemble.mjs';
31
32
  import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
32
- import { readFederation } from '../federation/config.mjs';
33
+ import { federationAccess, readFederation } from '../federation/config.mjs';
33
34
  import { consult } from '../federation/query.mjs';
34
35
  import { planNext, planDone } from '../plan/index.mjs';
35
- import { resolveWorkspace } from '../workspace.mjs';
36
+ import { buildPrBody } from '../prbody.mjs';
37
+ import { configPaths, readUserConfig, resolveWorkspace } from '../workspace.mjs';
36
38
  import { measureSkill } from '../graph/load.mjs';
37
39
  import { CLI } from '../name.mjs';
38
40
 
@@ -70,9 +72,20 @@ export const ERROR_SHAPE = {
70
72
 
71
73
  const output = {
72
74
  doctor: {
75
+ // The `doctor --json` document minus its envelope: the same findings, the
76
+ // same workspace answer, the same fingerprint. (Q15)
73
77
  type: 'object',
74
78
  properties: {
75
79
  root: { type: 'string' },
80
+ stack: { type: 'array', items: { type: 'string' } },
81
+ facts: { type: 'array', items: { type: 'string' } },
82
+ factEvidence: { type: 'object', additionalProperties: { type: ['string', 'null'] } },
83
+ signals: { type: 'array', items: { type: 'string' } },
84
+ // One entry per signal, naming the file that proved it. The payload is a list of
85
+ // paths, not of records - `fingerprint` returns evidence as the paths themselves.
86
+ evidence: { type: 'array', items: { type: 'string' } },
87
+ manifest: { type: ['object', 'null'] },
88
+ error: { type: ['string', 'null'] },
76
89
  workspace: {
77
90
  type: 'object',
78
91
  properties: {
@@ -82,19 +95,40 @@ const output = {
82
95
  root: { type: ['string', 'null'] },
83
96
  source: { type: ['string', 'null'] },
84
97
  detail: { type: ['string', 'null'] },
98
+ error: { type: ['string', 'null'] },
85
99
  },
86
100
  required: ['root', 'source'],
87
101
  },
88
- stack: { type: 'array', items: { type: 'string' } },
89
- facts: { type: 'object', additionalProperties: { type: ['string', 'null'] } },
90
- signals: { type: 'array', items: { type: 'string' } },
91
- // One entry per signal, naming the file that proved it. The payload is a list of
92
- // paths, not of records - `fingerprint` returns evidence as the paths themselves.
93
- evidence: { type: 'array', items: { type: 'string' } },
94
- manifest: { type: ['object', 'null'] },
95
- error: { type: ['string', 'null'] },
102
+ shared: { type: ['object', 'null'] },
103
+ findings: {
104
+ type: 'array',
105
+ items: {
106
+ type: 'object',
107
+ properties: {
108
+ id: { type: 'string' },
109
+ severity: { type: 'string' },
110
+ detail: { type: 'string' },
111
+ remedy: { type: ['string', 'null'] },
112
+ why: { type: ['string', 'null'] },
113
+ safe: { type: 'boolean' },
114
+ fixed: { type: 'boolean' },
115
+ },
116
+ required: ['id', 'severity', 'detail', 'safe', 'fixed'],
117
+ },
118
+ },
119
+ fix: {
120
+ type: 'object',
121
+ properties: {
122
+ requested: { type: 'boolean' },
123
+ applied: { type: 'boolean' },
124
+ fixed: { type: 'array', items: { type: 'string' } },
125
+ failed: { type: 'array', items: { type: 'object' } },
126
+ },
127
+ required: ['requested', 'applied', 'fixed', 'failed'],
128
+ },
129
+ remedy: { type: ['string', 'null'] },
96
130
  },
97
- required: ['root', 'workspace', 'stack', 'facts', 'signals', 'evidence'],
131
+ required: ['root', 'workspace', 'stack', 'facts', 'signals', 'evidence', 'findings'],
98
132
  },
99
133
  skills_index: {
100
134
  type: 'object',
@@ -121,53 +155,74 @@ const output = {
121
155
  },
122
156
  required: ['skills', 'totalBytes', 'entries'],
123
157
  },
158
+ // The `skills brief --json` document minus its envelope, or the `--all` one.
159
+ // The CLI keeps its field names; this adopts them. (Q15)
124
160
  skills_brief: {
125
- type: 'object',
126
- properties: {
127
- repo: {
161
+ anyOf: [
162
+ {
128
163
  type: 'object',
129
164
  properties: {
130
- root: { type: 'string' },
131
- stack: { type: 'array', items: { type: 'string' } },
132
- facts: { type: 'array', items: { type: 'string' } },
133
- },
134
- required: ['root', 'stack', 'facts'],
135
- },
136
- task: { type: ['string', 'null'] },
137
- budget: { type: 'number' },
138
- inlineBytes: { type: 'number' },
139
- selected: {
140
- type: 'array',
141
- items: {
142
- type: 'object',
143
- properties: {
144
- name: { type: 'string' },
145
- bytes: { type: 'number' },
146
- summary: { type: 'string' },
147
- why: { type: 'array', items: { type: 'string' } },
165
+ repo: {
166
+ type: 'object',
167
+ properties: {
168
+ root: { type: 'string' },
169
+ stack: { type: 'array', items: { type: 'string' } },
170
+ facts: { type: 'array', items: { type: 'string' } },
171
+ },
172
+ required: ['root', 'stack', 'facts'],
148
173
  },
149
- required: ['name', 'bytes', 'why'],
150
- },
151
- },
152
- deferred: {
153
- type: 'array',
154
- items: {
155
- type: 'object',
156
- properties: { name: { type: 'string' }, bytes: { type: 'number' }, reason: { type: 'string' } },
157
- required: ['name', 'reason'],
174
+ workspace: { type: 'string' },
175
+ task: { type: ['string', 'null'] },
176
+ budget: { type: 'number' },
177
+ inlineBytes: { type: 'number' },
178
+ resolved: {
179
+ type: 'array',
180
+ items: {
181
+ type: 'object',
182
+ properties: {
183
+ name: { type: 'string' },
184
+ bytes: { type: 'number' },
185
+ summary: { type: 'string' },
186
+ why: { type: 'array', items: { type: 'string' } },
187
+ },
188
+ required: ['name', 'bytes', 'why'],
189
+ },
190
+ },
191
+ deferred: {
192
+ type: 'array',
193
+ items: {
194
+ type: 'object',
195
+ properties: { name: { type: 'string' }, bytes: { type: 'number' }, reason: { type: 'string' } },
196
+ required: ['name', 'bytes', 'reason'],
197
+ },
198
+ },
199
+ excluded: {
200
+ type: 'array',
201
+ items: {
202
+ type: 'object',
203
+ properties: { name: { type: 'string' }, reason: { type: 'string' } },
204
+ required: ['name', 'reason'],
205
+ },
206
+ },
207
+ warning: { type: 'string' },
158
208
  },
209
+ required: ['repo', 'budget', 'inlineBytes', 'resolved', 'deferred', 'excluded'],
159
210
  },
160
- notSelected: {
161
- type: 'array',
162
- items: {
163
- type: 'object',
164
- properties: { name: { type: 'string' }, reason: { type: 'string' } },
165
- required: ['name', 'reason'],
211
+ {
212
+ type: 'object',
213
+ properties: {
214
+ all: { type: 'boolean', enum: [true] },
215
+ repoStack: { type: 'array', items: { type: 'string' } },
216
+ inline: { type: 'array', items: { type: 'string' } },
217
+ deferred: { type: 'array', items: { type: 'string' } },
218
+ warning: { type: 'string' },
166
219
  },
220
+ required: ['all', 'repoStack', 'inline', 'deferred'],
167
221
  },
168
- },
169
- required: ['repo', 'budget', 'inlineBytes', 'selected', 'deferred', 'notSelected'],
222
+ ],
170
223
  },
224
+ // The `skills show --json` document minus its envelope, plus the body text the
225
+ // command prints below the metadata. (Q15)
171
226
  skills_show: {
172
227
  type: 'object',
173
228
  properties: {
@@ -176,9 +231,8 @@ const output = {
176
231
  bytes: { type: 'number' },
177
232
  capabilities: { type: 'array', items: { type: 'string' } },
178
233
  stack: { type: 'array', items: { type: 'string' } },
234
+ keywords: { type: 'array', items: { type: 'string' } },
179
235
  deferred: { type: 'boolean' },
180
- source: { type: ['string', 'null'] },
181
- readFrom: { type: 'string', enum: ['ok', 'empty', 'unavailable'] },
182
236
  requires: { type: 'array', items: { type: 'string' } },
183
237
  variants: {
184
238
  type: 'array',
@@ -188,10 +242,15 @@ const output = {
188
242
  required: ['id'],
189
243
  },
190
244
  },
191
- maxBytes: { type: 'number' },
245
+ sources: { type: 'array', items: { type: 'object' } },
246
+ aliasOf: { type: ['string', 'null'] },
247
+ variantOf: { type: ['string', 'null'] },
248
+ workspace: { type: 'string' },
192
249
  body: { type: ['string', 'null'] },
250
+ bodyFrom: { type: 'string' },
251
+ bodyText: { type: 'string' },
193
252
  },
194
- required: ['name', 'bytes', 'readFrom', 'requires', 'variants'],
253
+ required: ['name', 'bytes', 'bodyFrom', 'requires', 'variants'],
195
254
  },
196
255
  skills_find: {
197
256
  type: 'object',
@@ -205,29 +264,41 @@ const output = {
205
264
  name: { type: 'string' },
206
265
  score: { type: 'number' },
207
266
  summary: { type: 'string' },
208
- why: { type: 'array', items: { type: 'string' } },
267
+ reasons: { type: 'array', items: { type: 'string' } },
209
268
  },
210
- required: ['name', 'score', 'why'],
269
+ required: ['name', 'score', 'reasons'],
211
270
  },
212
271
  },
213
272
  },
214
273
  required: ['query', 'results'],
215
274
  },
275
+ // The `skills why --json` document minus its envelope. (Q15)
216
276
  skills_why: {
217
277
  type: 'object',
218
278
  properties: {
219
- name: { type: 'string' },
279
+ skill: { type: 'string' },
220
280
  score: { type: 'number' },
221
281
  compatible: { type: 'boolean' },
222
- reasons: { type: 'array', items: { type: 'string' } },
282
+ gate: { type: 'string' },
223
283
  repo: {
224
284
  type: 'object',
225
- properties: { root: { type: 'string' }, signals: { type: 'array', items: { type: 'string' } } },
285
+ properties: {
286
+ root: { type: 'string' },
287
+ stack: { type: 'array', items: { type: 'string' } },
288
+ facts: { type: 'array', items: { type: 'string' } },
289
+ evidence: { type: 'array', items: { type: 'string' } },
290
+ },
226
291
  },
227
- task: { type: ['string', 'null'] },
228
- pathsSeen: { type: 'array', items: { type: 'string' } },
292
+ workspace: { type: 'object' },
293
+ paths: { type: 'array', items: { type: 'string' } },
294
+ taskTokens: { type: 'array', items: { type: 'string' } },
295
+ signals: { type: 'array', items: { type: 'string' } },
296
+ variants: { type: 'array', items: { type: 'object' } },
297
+ aliasOf: { type: ['string', 'null'] },
298
+ variantOf: { type: ['string', 'null'] },
299
+ requires: { type: 'array', items: { type: 'string' } },
229
300
  },
230
- required: ['name', 'score', 'compatible', 'reasons'],
301
+ required: ['skill', 'score', 'compatible', 'signals'],
231
302
  },
232
303
  rules_list: {
233
304
  type: 'object',
@@ -250,7 +321,8 @@ const output = {
250
321
  bytes: { type: 'number' },
251
322
  adrs: { type: 'array', items: { type: 'string' } },
252
323
  error: { type: ['string', 'null'] },
253
- selected: { type: 'boolean' },
324
+ fallback: { type: 'boolean' },
325
+ selected: { type: ['boolean', 'null'] },
254
326
  },
255
327
  required: ['source', 'bytes', 'selected'],
256
328
  },
@@ -258,23 +330,22 @@ const output = {
258
330
  },
259
331
  required: ['sha', 'rules'],
260
332
  },
333
+ // The `rules why --json` document minus its envelope, either direction. (Q15)
261
334
  rules_why: {
262
335
  type: 'object',
263
336
  properties: {
264
337
  direction: { type: 'string', enum: ['rule', 'decision'] },
265
338
  id: { type: 'string' },
339
+ rule: { type: 'string' },
266
340
  title: { type: ['string', 'null'] },
267
- status: { type: 'string' },
268
341
  source: { type: 'string' },
269
342
  origin: { type: 'string' },
343
+ adrs: { type: 'array', items: { type: 'object' } },
270
344
  uncited: { type: 'boolean' },
271
- readWith: { type: 'string' },
272
- supersededBy: { type: ['string', 'null'] },
273
- supersedes: { type: 'array', items: { type: 'string' } },
274
- // A decision record may be partially superseded; the record carries the list of
275
- // what it replaced, so this is a list and not a flag.
276
- partial: { type: ['boolean', 'array'] },
277
- decisions: { type: 'array', items: { type: 'object' } },
345
+ others: { type: 'array', items: { type: 'string' } },
346
+ adr: { type: 'object' },
347
+ supersededBy: { type: ['object', 'null'] },
348
+ inForce: { type: 'object' },
278
349
  rules: { type: 'array', items: { type: 'string' } },
279
350
  },
280
351
  required: ['direction', 'id'],
@@ -310,6 +381,9 @@ const output = {
310
381
  repos: { type: 'array', items: { type: 'object' } },
311
382
  worktrees: { type: 'array', items: { type: 'object' } },
312
383
  directories: { type: 'array', items: { type: 'string' } },
384
+ partial: { type: 'boolean' },
385
+ cursor: { type: ['string', 'null'] },
386
+ total: { type: 'number' },
313
387
  },
314
388
  required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
315
389
  },
@@ -355,6 +429,53 @@ const output = {
355
429
  },
356
430
  required: ['root', 'kind', 'basis', 'task', 'scope', 'budget', 'bytes', 'before', 'floorHeld', 'cut', 'sections', 'text'],
357
431
  },
432
+ pr_body: {
433
+ type: 'object',
434
+ properties: {
435
+ root: { type: 'string' },
436
+ base: { type: ['string', 'null'] },
437
+ tree: { type: ['string', 'null'] },
438
+ verdict: { type: 'string' },
439
+ unreviewed: { type: 'array', items: { type: 'string' } },
440
+ paths: { type: 'array', items: { type: 'string' } },
441
+ decisions: {
442
+ type: 'array',
443
+ items: {
444
+ type: 'object',
445
+ properties: {
446
+ path: { type: 'string' },
447
+ id: { type: 'string' },
448
+ title: { type: 'string' },
449
+ status: { type: 'string' },
450
+ },
451
+ required: ['path', 'id', 'title', 'status'],
452
+ },
453
+ },
454
+ gates: {
455
+ type: 'object',
456
+ properties: {
457
+ declared: { type: 'array', items: { type: 'string' } },
458
+ evidenceMode: { type: ['string', 'null'] },
459
+ run: { type: ['object', 'null'] },
460
+ rulesOk: { type: ['boolean', 'null'] },
461
+ hooksOk: { type: ['boolean', 'null'] },
462
+ blocking: { type: 'number' },
463
+ warnings: { type: 'number' },
464
+ cached: { type: 'boolean' },
465
+ },
466
+ required: ['declared', 'blocking', 'warnings', 'cached'],
467
+ },
468
+ evidence: {
469
+ type: 'object',
470
+ properties: {
471
+ entries: { type: 'array', items: { type: 'object' } },
472
+ errors: { type: 'array', items: { type: 'string' } },
473
+ },
474
+ required: ['entries', 'errors'],
475
+ },
476
+ },
477
+ required: ['root', 'base', 'tree', 'verdict', 'unreviewed', 'decisions', 'gates', 'evidence', 'paths'],
478
+ },
358
479
  federation_query: {
359
480
  type: 'object',
360
481
  properties: {
@@ -411,17 +532,16 @@ export const TOOLS = [
411
532
  inputSchema: rootArg,
412
533
  async run(args) {
413
534
  const root = rootOf(args);
414
- const ws = await resolveWorkspace(args ?? {});
415
- const result = await fingerprint(root);
535
+ const { result, ws, shared, findings, failure } = await diagnose(root, args ?? {});
416
536
  return {
417
537
  root,
418
- workspace: { root: ws.root, source: ws.source, detail: ws.detail },
419
- stack: result.stack,
420
- facts: Object.fromEntries(result.facts.map((f) => [f, result.factEvidence[f] ?? null])),
421
- signals: result.signals,
422
- evidence: result.evidence,
423
- manifest: result.manifest,
424
- error: result.error ?? null,
538
+ ...result,
539
+ workspace: { root: ws.root ?? null, source: ws.source, detail: ws.detail, error: ws.error ?? null },
540
+ shared,
541
+ findings: serialisedFindings(findings),
542
+ // Read-only here: the command applies fixes behind `--fix`, the tool only reports.
543
+ fix: { requested: false, applied: false, fixed: [], failed: [] },
544
+ ...(failure ?? {}),
425
545
  };
426
546
  },
427
547
  },
@@ -431,7 +551,13 @@ export const TOOLS = [
431
551
  name: 'skills_index',
432
552
  description:
433
553
  'Full skill catalogue: every skill with summary, size, stack tags and deferred flag. The fallback when brief returns nothing useful.',
434
- inputSchema: noArgs,
554
+ inputSchema: {
555
+ type: 'object',
556
+ properties: {
557
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
558
+ },
559
+ required: [],
560
+ },
435
561
  async run(args) {
436
562
  const { registry: reg, verified, workspace } = await registry(args);
437
563
  return {
@@ -461,56 +587,26 @@ export const TOOLS = [
461
587
  budget: { type: 'number', description: 'context budget in bytes (default 20480)' },
462
588
  skill: { type: 'string', description: 'force a skill in, comma-separated' },
463
589
  all: { type: 'boolean', description: 'ignore the budget' },
590
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
464
591
  },
465
592
  required: [],
466
593
  },
467
594
  async run(args) {
468
595
  const root = rootOf(args);
469
- const { registry: reg } = await registry(args);
470
- const repo = await fingerprint(root);
471
- const paths = args?.paths ? parsePathList(args.paths) : await dirtyPaths(root);
472
- const forced = parsePathList(args?.skill);
473
- const budget = Number.isInteger(args?.budget) ? args.budget : 20480;
474
-
475
- const context = { repoStack: repo.signals, taskTokens: tokenize(args?.task ?? ''), paths };
476
- const { selected, excluded } = rankSkills(reg.skills, context, { forced });
477
- const expanded = closure(selected, reg.skills);
478
-
479
- // The budget decides what is INLINED, and it is the same function the CLI
480
- // uses — not a second copy of the same loop. The two copies had drifted: this one
481
- // checked `deferred` *after* the budget and inlined a deferred dependency, while
482
- // `skills brief` checked it first and did not. A caller reaching for the MCP tool
483
- // and a caller reaching for the command got different answers to one question, and
484
- // neither could see the other (ADR-035's shape, one level down).
485
- //
596
+ const facts = await skillsBriefFacts(root, args ?? {});
486
597
  // No body is ever returned by this tool: a caller that wants one calls
487
598
  // skills_show, which makes the cost visible before it is paid.
488
- const { picked, deferred, used } = selectWithinBudget(expanded, {
489
- budget,
490
- all: args?.all === true,
491
- });
492
-
493
- const closureNames = new Set(expanded.map((e) => e.skill.name));
494
- return {
495
- repo: { root, stack: repo.stack, facts: repo.facts },
496
- task: args?.task ?? null,
497
- budget,
498
- inlineBytes: used,
499
- selected: picked.map((e) => ({
500
- name: e.skill.name,
501
- bytes: e.skill.bytes,
502
- summary: e.skill.summary,
503
- why: [...e.reasons, ...[...e.via].map((v) => `requires:${v}`)],
504
- })),
505
- deferred: deferred.map((e) => ({
506
- name: e.skill.name,
507
- bytes: e.skill.bytes,
508
- reason: e.skill.deferred ? 'marked deferred' : 'over budget',
509
- })),
510
- notSelected: excluded
511
- .filter((e) => !closureNames.has(e.skill.name))
512
- .map((e) => ({ name: e.skill.name, reason: e.reason })),
513
- };
599
+ if (facts.kind === 'all') {
600
+ return {
601
+ all: true,
602
+ repoStack: facts.repoStack,
603
+ inline: facts.inline.map((s) => s.name),
604
+ deferred: facts.deferred.map((s) => s.name),
605
+ ...(facts.warning ? { warning: facts.warning } : {}),
606
+ };
607
+ }
608
+ const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspaceRoot, kind, ...doc } = facts;
609
+ return doc;
514
610
  },
515
611
  },
516
612
 
@@ -524,34 +620,19 @@ export const TOOLS = [
524
620
  name: { type: 'string' },
525
621
  root: { type: 'string' },
526
622
  maxBytes: { type: 'number', description: 'hard ceiling on the returned text' },
623
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
527
624
  },
528
625
  required: ['name'],
529
626
  },
530
627
  async run(args) {
531
- const name = args?.name;
532
- if (typeof name !== 'string' || name.length === 0) throw new Error('skills_show needs a skill name');
533
- const { registry: reg } = await registry(args);
534
- const skill = reg.skills.find((s) => s.name === name);
535
- if (!skill) throw new Error(`no skill named "${name}"; call skills_index or skills_find`);
536
-
537
628
  const root = rootOf(args);
538
- const ws = await resolveWorkspace(args ?? {});
539
- const body = await readBody(skill, ws.root, root);
540
- const source = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
541
- const located = body.startsWith('(body unavailable') ? null : body;
629
+ const facts = await skillsShowFacts(root, args?.name, args ?? {});
542
630
  return {
543
- name,
544
- summary: skill.summary,
545
- bytes: skill.bytes,
546
- capabilities: skill.capabilities,
547
- stack: skill.stack,
548
- deferred: Boolean(skill.deferred),
549
- source: source ? `${source.repo}/${source.path}` : null,
550
- readFrom: located === null ? 'unavailable' : body.length === 0 ? 'empty' : 'ok',
551
- requires: skill.requires ?? [],
552
- variants: (skill.variants ?? []).map((v) => ({ id: v.id, stack: v.stack, bytes: v.bytes })),
553
- maxBytes: Number.isInteger(args?.maxBytes) ? args.maxBytes : 0,
554
- body: body || null,
631
+ ...facts.skill,
632
+ workspace: facts.workspace,
633
+ body: facts.body,
634
+ bodyFrom: facts.bodyFrom,
635
+ bodyText: facts.bodyText,
555
636
  };
556
637
  },
557
638
  },
@@ -562,22 +643,16 @@ export const TOOLS = [
562
643
  description: 'Raw search across capability, keyword and summary. Use when skills_brief has nothing and you suspect a skill exists.',
563
644
  inputSchema: {
564
645
  type: 'object',
565
- properties: { query: { type: 'string' } },
646
+ properties: {
647
+ query: { type: 'string' },
648
+ root: { type: 'string' },
649
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
650
+ },
566
651
  required: ['query'],
567
652
  },
568
653
  async run(args) {
569
- const query = typeof args?.query === 'string' ? args.query : '';
570
- if (query.trim() === '') throw new Error('skills_find needs a query');
571
- const { registry: reg } = await registry(args);
572
- const tokens = tokenize(query);
573
- return {
574
- query,
575
- results: reg.skills
576
- .map((skill) => ({ skill, ...scoreSkill(skill, { repoStack: [], taskTokens: tokens, paths: [] }) }))
577
- .filter((r) => selectionVerdict(r.evidence, r.reasons) === null)
578
- .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
579
- .map((r) => ({ name: r.skill.name, score: r.score, summary: r.skill.summary, why: r.reasons })),
580
- };
654
+ const root = rootOf(args);
655
+ return skillsFindFacts(root, typeof args?.query === 'string' ? args.query : '', args ?? {});
581
656
  },
582
657
  },
583
658
 
@@ -588,30 +663,17 @@ export const TOOLS = [
588
663
  'The signal chain for one skill in one repository: its score, the reasons, whether the stack gate admits it, and what would select it.',
589
664
  inputSchema: {
590
665
  type: 'object',
591
- properties: { name: { type: 'string' }, root: { type: 'string' }, task: { type: 'string' } },
666
+ properties: {
667
+ name: { type: 'string' },
668
+ root: { type: 'string' },
669
+ task: { type: 'string' },
670
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
671
+ },
592
672
  required: ['name'],
593
673
  },
594
674
  async run(args) {
595
- const { registry: reg } = await registry(args);
596
- const skill = reg.skills.find((s) => s.name === args?.name);
597
- if (!skill) throw new Error(`no skill named "${args?.name}"; call skills_find`);
598
675
  const root = rootOf(args);
599
- const repo = await fingerprint(root);
600
- const paths = await dirtyPaths(root);
601
- const { score, reasons } = scoreSkill(skill, {
602
- repoStack: repo.signals,
603
- taskTokens: tokenize(args?.task ?? ''),
604
- paths,
605
- });
606
- return {
607
- name: skill.name,
608
- score,
609
- compatible: stackCompatible(skill, repo.signals),
610
- reasons,
611
- repo: { root, signals: repo.signals, evidence: repo.evidence },
612
- task: args?.task ?? null,
613
- pathsSeen: paths.slice(0, 20),
614
- };
676
+ return skillsWhyFacts(root, args?.name, args ?? {});
615
677
  },
616
678
  },
617
679
 
@@ -626,29 +688,12 @@ export const TOOLS = [
626
688
  required: [],
627
689
  },
628
690
  async run(args) {
629
- const root = args?.root;
630
- const rules = await loadRules(typeof root === 'string' && root ? root : null);
631
- const repo = typeof root === 'string' && root ? await fingerprint(root) : null;
632
- const { selected, notes } = repo
633
- ? select(rules, repo.stack, repo.facts)
634
- : { selected: [], notes: [] };
635
- const chosen = new Set(selected.map((r) => r.id));
691
+ const root = typeof args?.root === 'string' && args.root ? args.root : null;
692
+ const { rules, notes } = await rulesCatalogue(root, { selectFor: root });
636
693
  return {
637
694
  sha: await provenanceDigest(),
638
695
  notes,
639
- rules: [...rules]
640
- .sort((a, b) => String(a.id).localeCompare(String(b.id)))
641
- .map((r) => ({
642
- id: r.id,
643
- title: r.title,
644
- applies: r.applies,
645
- origin: r.origin,
646
- source: r.source,
647
- bytes: r.bytes,
648
- adrs: citations(r.body),
649
- error: r.error,
650
- selected: chosen.has(r.id),
651
- })),
696
+ rules,
652
697
  };
653
698
  },
654
699
  },
@@ -665,47 +710,7 @@ export const TOOLS = [
665
710
  },
666
711
  async run(args) {
667
712
  const what = typeof args?.what === 'string' ? args.what : '';
668
- const rules = await loadRules(null);
669
- const { decisions } = await loadDecisions();
670
- const index = byId(decisions);
671
-
672
- if (/^ADR-\d{3,}$/i.test(what)) {
673
- const id = what.toUpperCase();
674
- const d = decisions.find((x) => x.id === id);
675
- if (!d) throw new Error(`no decision record ${id}`);
676
- return {
677
- direction: 'decision',
678
- id: d.id,
679
- title: d.title,
680
- status: d.status,
681
- // The record travels inside the installed package. A relative path would resolve
682
- // against the consuming repo and name a file that is not there, which is
683
- // worse than giving no path at all.
684
- readWith: `${CLI} rules why ${d.id}`,
685
- supersededBy: d.supersededBy,
686
- supersedes: d.supersedes,
687
- partial: d.partial,
688
- rules: rules.filter((r) => citations(r.body).includes(id)).map((r) => r.id),
689
- };
690
- }
691
-
692
- const rule = rules.find((r) => r.id === what);
693
- if (!rule) throw new Error(`no rule with id "${what}"; call rules_list`);
694
- const cited = citations(rule.body);
695
- return {
696
- direction: 'rule',
697
- id: rule.id,
698
- title: rule.title,
699
- source: rule.source,
700
- origin: rule.origin,
701
- uncited: cited.length === 0,
702
- decisions: cited.map((c) => {
703
- const d = index.get(c);
704
- return d
705
- ? { id: d.id, title: d.title, status: d.status, supersededBy: d.supersededBy }
706
- : { id: c, missing: true };
707
- }),
708
- };
713
+ return rulesWhyFacts(null, what, {});
709
714
  },
710
715
  },
711
716
 
@@ -717,12 +722,8 @@ export const TOOLS = [
717
722
  inputSchema: rootArg,
718
723
  async run(args) {
719
724
  const root = rootOf(args);
720
- const findings = await checkTargets(root);
721
- for (const r of await loadRules()) {
722
- if (r.error) findings.push({ file: r.id ?? r.file, status: 'invalid', detail: r.error });
723
- }
724
- const bad = findings.filter((f) => f.status !== 'current');
725
- return { root, ok: bad.length === 0, findings };
725
+ const { ok, findings } = await checkRepo(root, {});
726
+ return { root, ok, findings };
726
727
  },
727
728
  },
728
729
 
@@ -730,23 +731,37 @@ export const TOOLS = [
730
731
  outputSchema: output.compare,
731
732
  name: 'compare',
732
733
  description:
733
- 'The workspace matrix: every repository with its adoption, region state, pinned SHA, hook routing and gates, plus the problems found.',
734
+ 'The workspace matrix: adoption, region, SHA, hooks and gates per repo, plus problems. Pages with limit/cursor; honors progressToken and cancellation.',
734
735
  inputSchema: {
735
736
  type: 'object',
736
737
  properties: {
737
738
  root: { type: 'string', description: 'a workspace directory, or one repository' },
738
739
  workspace: { type: 'string', description: 'workspace root when root is not given' },
740
+ limit: { type: 'number', description: 'page size; without it every row is built' },
741
+ cursor: { type: 'string', description: 'opaque page start from a previous partial answer' },
742
+ concurrency: { type: 'number', description: 'rows built at once (default 4)' },
739
743
  },
740
744
  required: [],
741
745
  },
742
- async run(args) {
746
+ async run(args, ctx = {}) {
743
747
  const root = args?.root ?? args?.workspace ?? (await resolveWorkspace(args ?? {})).root ?? process.cwd();
744
748
  const found = await discover(root);
745
749
  if (found.error) throw Object.assign(new Error(`${root}: ${found.error}`), { remedy: 'pass root' });
746
750
  const expectedSha = await provenanceDigest();
747
751
  const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
748
- const rows = [];
749
- for (const repo of found.repos) rows.push(await buildRow(repo, context));
752
+ // A page names its start as an index into discovery order, which is
753
+ // stable between calls on an unchanged estate; garbage parses to 0
754
+ // rather than erroring, because a dashboard that refuses to run over a
755
+ // stale cursor is worse than one that restarts the listing.
756
+ const start = Math.max(0, parseInt(typeof args?.cursor === 'string' ? args.cursor : '0', 10) || 0);
757
+ const limit = typeof args?.limit === 'number' && args.limit >= 1 ? Math.floor(args.limit) : null;
758
+ const page = limit === null ? found.repos : found.repos.slice(start, start + limit);
759
+ const rows = await buildRows(page, context, {
760
+ concurrency: resolveConcurrency(args ?? {}),
761
+ signal: ctx?.signal ?? null,
762
+ onBatch: ctx?.onProgress ? (done, total) => ctx.onProgress(start + done, found.repos.length) : null,
763
+ });
764
+ const next = limit !== null && start + page.length < found.repos.length ? String(start + page.length) : null;
750
765
  return {
751
766
  root,
752
767
  shape: found.shape,
@@ -755,6 +770,7 @@ export const TOOLS = [
755
770
  repos: rows,
756
771
  worktrees: found.worktrees.map((w) => ({ name: w.name, owner: w.ownerName })),
757
772
  directories: found.directories,
773
+ ...(limit === null ? {} : { partial: next !== null, cursor: next, total: found.repos.length }),
758
774
  };
759
775
  },
760
776
  },
@@ -817,11 +833,53 @@ export const TOOLS = [
817
833
  };
818
834
  },
819
835
  },
836
+ // The PR-flow entry point over this surface: the same engine the `pr-body`
837
+ // command renders, returning JSON for a model. Read-only like everything
838
+ // here — it names the tree, reads the ledger and the check cache, and writes
839
+ // nothing, so the exit-code-never-carries-the-verdict contract stays intact.
840
+ {
841
+ outputSchema: output.pr_body,
842
+ name: 'pr_body',
843
+ // R41's 2 KB description budget is shared, not per tool: this sentence was
844
+ // paid for by shortening `brief` and `federation_query`, and the test on the
845
+ // total holds every future tool to the same trade.
846
+ description:
847
+ 'The pull request body: ledger evidence for this tree, the decisions the diff touches, gate state, and the ADR-008 verdict with what was not reviewed.',
848
+ inputSchema: {
849
+ type: 'object',
850
+ properties: {
851
+ root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
852
+ base: { type: 'string', description: 'the ref the body diffs against; defaults to origin/main, then main' },
853
+ },
854
+ required: [],
855
+ },
856
+ async run(args) {
857
+ const root = rootOf(args);
858
+ const base = typeof args?.base === 'string' && args.base.length > 0 ? args.base : null;
859
+ const report = await buildPrBody(root, { base });
860
+ if (report.error !== null) {
861
+ throw Object.assign(new Error(report.error), {
862
+ remedy: `run: ${CLI} pr-body --base <ref> with a ref git knows`,
863
+ });
864
+ }
865
+ return {
866
+ root,
867
+ base: report.base,
868
+ tree: report.tree,
869
+ verdict: report.verdict,
870
+ unreviewed: report.unreviewed,
871
+ paths: report.paths,
872
+ decisions: report.decisions,
873
+ gates: report.gates,
874
+ evidence: report.evidence,
875
+ };
876
+ },
877
+ },
820
878
  {
821
879
  outputSchema: output.brief,
822
880
  name: 'brief',
823
881
  description:
824
- 'The packet for one task in prompt-sized bytes: task kind, needed commands, ranked files, covering tests, governing decisions, applicable skills. With no task it describes the repository.',
882
+ 'The packet for one task in prompt-sized bytes: kind, commands, files, tests, decisions, skills. With no task it describes the repository.',
825
883
  inputSchema: {
826
884
  type: 'object',
827
885
  properties: {
@@ -868,7 +926,10 @@ export const TOOLS = [
868
926
  outputSchema: output.federation_query,
869
927
  name: 'federation_query',
870
928
  description:
871
- 'Ask a declared code-graph server one structural question; the answer names the server that gave it. Starts only servers simt.json names; a repository declaring none is told so.',
929
+ 'Ask a declared code-graph server one structural question; the answer names the server. Starts only servers simt.json names; none declared is an error.',
930
+ // This tool spawns the repo manifest's `command`, so it is not read-only: a client
931
+ // that cached its answer as one would replay a process spawn as a pure read (Q13).
932
+ annotations: { readOnlyHint: false, openWorldHint: true },
872
933
  inputSchema: {
873
934
  type: 'object',
874
935
  properties: {
@@ -899,6 +960,21 @@ export const TOOLS = [
899
960
  remedy: `declared servers: ${servers.map((server) => server.name).join(', ')}`,
900
961
  });
901
962
  }
963
+ // A model-supplied root can point at a directory with a crafted manifest, and a
964
+ // repo manifest alone must not authorise a spawn on the operator's machine (Q13).
965
+ const ws = await resolveWorkspace(args ?? {});
966
+ const user = await readUserConfig();
967
+ const denied = federationAccess({
968
+ root,
969
+ cwd: process.cwd(),
970
+ workspaceRoot: ws.root,
971
+ serverNames: chosen.map((server) => server.name),
972
+ userConfig: user.config,
973
+ configPath: user.path ?? configPaths()[0] ?? '~/.config/simt-dev/config.json',
974
+ });
975
+ if (denied !== null) {
976
+ throw Object.assign(new Error(denied.message), { remedy: denied.remedy });
977
+ }
902
978
  const { answers, findings: failures } = await consult({ root, query, servers: chosen });
903
979
  return {
904
980
  root,