simt-dev 0.6.2 → 0.6.3

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 (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
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
25
  import { buildRow, 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'],
@@ -355,6 +426,53 @@ const output = {
355
426
  },
356
427
  required: ['root', 'kind', 'basis', 'task', 'scope', 'budget', 'bytes', 'before', 'floorHeld', 'cut', 'sections', 'text'],
357
428
  },
429
+ pr_body: {
430
+ type: 'object',
431
+ properties: {
432
+ root: { type: 'string' },
433
+ base: { type: ['string', 'null'] },
434
+ tree: { type: ['string', 'null'] },
435
+ verdict: { type: 'string' },
436
+ unreviewed: { type: 'array', items: { type: 'string' } },
437
+ paths: { type: 'array', items: { type: 'string' } },
438
+ decisions: {
439
+ type: 'array',
440
+ items: {
441
+ type: 'object',
442
+ properties: {
443
+ path: { type: 'string' },
444
+ id: { type: 'string' },
445
+ title: { type: 'string' },
446
+ status: { type: 'string' },
447
+ },
448
+ required: ['path', 'id', 'title', 'status'],
449
+ },
450
+ },
451
+ gates: {
452
+ type: 'object',
453
+ properties: {
454
+ declared: { type: 'array', items: { type: 'string' } },
455
+ evidenceMode: { type: ['string', 'null'] },
456
+ run: { type: ['object', 'null'] },
457
+ rulesOk: { type: ['boolean', 'null'] },
458
+ hooksOk: { type: ['boolean', 'null'] },
459
+ blocking: { type: 'number' },
460
+ warnings: { type: 'number' },
461
+ cached: { type: 'boolean' },
462
+ },
463
+ required: ['declared', 'blocking', 'warnings', 'cached'],
464
+ },
465
+ evidence: {
466
+ type: 'object',
467
+ properties: {
468
+ entries: { type: 'array', items: { type: 'object' } },
469
+ errors: { type: 'array', items: { type: 'string' } },
470
+ },
471
+ required: ['entries', 'errors'],
472
+ },
473
+ },
474
+ required: ['root', 'base', 'tree', 'verdict', 'unreviewed', 'decisions', 'gates', 'evidence', 'paths'],
475
+ },
358
476
  federation_query: {
359
477
  type: 'object',
360
478
  properties: {
@@ -411,17 +529,16 @@ export const TOOLS = [
411
529
  inputSchema: rootArg,
412
530
  async run(args) {
413
531
  const root = rootOf(args);
414
- const ws = await resolveWorkspace(args ?? {});
415
- const result = await fingerprint(root);
532
+ const { result, ws, shared, findings, failure } = await diagnose(root, args ?? {});
416
533
  return {
417
534
  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,
535
+ ...result,
536
+ workspace: { root: ws.root ?? null, source: ws.source, detail: ws.detail, error: ws.error ?? null },
537
+ shared,
538
+ findings: serialisedFindings(findings),
539
+ // Read-only here: the command applies fixes behind `--fix`, the tool only reports.
540
+ fix: { requested: false, applied: false, fixed: [], failed: [] },
541
+ ...(failure ?? {}),
425
542
  };
426
543
  },
427
544
  },
@@ -466,51 +583,20 @@ export const TOOLS = [
466
583
  },
467
584
  async run(args) {
468
585
  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
- //
586
+ const facts = await skillsBriefFacts(root, args ?? {});
486
587
  // No body is ever returned by this tool: a caller that wants one calls
487
588
  // 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
- };
589
+ if (facts.kind === 'all') {
590
+ return {
591
+ all: true,
592
+ repoStack: facts.repoStack,
593
+ inline: facts.inline.map((s) => s.name),
594
+ deferred: facts.deferred.map((s) => s.name),
595
+ ...(facts.warning ? { warning: facts.warning } : {}),
596
+ };
597
+ }
598
+ const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspaceRoot, kind, ...doc } = facts;
599
+ return doc;
514
600
  },
515
601
  },
516
602
 
@@ -528,30 +614,14 @@ export const TOOLS = [
528
614
  required: ['name'],
529
615
  },
530
616
  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
617
  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;
618
+ const facts = await skillsShowFacts(root, args?.name, args ?? {});
542
619
  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,
620
+ ...facts.skill,
621
+ workspace: facts.workspace,
622
+ body: facts.body,
623
+ bodyFrom: facts.bodyFrom,
624
+ bodyText: facts.bodyText,
555
625
  };
556
626
  },
557
627
  },
@@ -566,18 +636,7 @@ export const TOOLS = [
566
636
  required: ['query'],
567
637
  },
568
638
  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
- };
639
+ return skillsFindFacts(typeof args?.query === 'string' ? args.query : '', args ?? {});
581
640
  },
582
641
  },
583
642
 
@@ -592,26 +651,8 @@ export const TOOLS = [
592
651
  required: ['name'],
593
652
  },
594
653
  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
654
  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
- };
655
+ return skillsWhyFacts(root, args?.name, args ?? {});
615
656
  },
616
657
  },
617
658
 
@@ -626,29 +667,12 @@ export const TOOLS = [
626
667
  required: [],
627
668
  },
628
669
  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));
670
+ const root = typeof args?.root === 'string' && args.root ? args.root : null;
671
+ const { rules, notes } = await rulesCatalogue(root, { selectFor: root });
636
672
  return {
637
673
  sha: await provenanceDigest(),
638
674
  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
- })),
675
+ rules,
652
676
  };
653
677
  },
654
678
  },
@@ -665,47 +689,7 @@ export const TOOLS = [
665
689
  },
666
690
  async run(args) {
667
691
  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
- };
692
+ return rulesWhyFacts(null, what, {});
709
693
  },
710
694
  },
711
695
 
@@ -717,12 +701,8 @@ export const TOOLS = [
717
701
  inputSchema: rootArg,
718
702
  async run(args) {
719
703
  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 };
704
+ const { ok, findings } = await checkRepo(root, {});
705
+ return { root, ok, findings };
726
706
  },
727
707
  },
728
708
 
@@ -817,11 +797,53 @@ export const TOOLS = [
817
797
  };
818
798
  },
819
799
  },
800
+ // The PR-flow entry point over this surface: the same engine the `pr-body`
801
+ // command renders, returning JSON for a model. Read-only like everything
802
+ // here — it names the tree, reads the ledger and the check cache, and writes
803
+ // nothing, so the exit-code-never-carries-the-verdict contract stays intact.
804
+ {
805
+ outputSchema: output.pr_body,
806
+ name: 'pr_body',
807
+ // R41's 2 KB description budget is shared, not per tool: this sentence was
808
+ // paid for by shortening `brief` and `federation_query`, and the test on the
809
+ // total holds every future tool to the same trade.
810
+ description:
811
+ '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.',
812
+ inputSchema: {
813
+ type: 'object',
814
+ properties: {
815
+ root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
816
+ base: { type: 'string', description: 'the ref the body diffs against; defaults to origin/main, then main' },
817
+ },
818
+ required: [],
819
+ },
820
+ async run(args) {
821
+ const root = rootOf(args);
822
+ const base = typeof args?.base === 'string' && args.base.length > 0 ? args.base : null;
823
+ const report = await buildPrBody(root, { base });
824
+ if (report.error !== null) {
825
+ throw Object.assign(new Error(report.error), {
826
+ remedy: `run: ${CLI} pr-body --base <ref> with a ref git knows`,
827
+ });
828
+ }
829
+ return {
830
+ root,
831
+ base: report.base,
832
+ tree: report.tree,
833
+ verdict: report.verdict,
834
+ unreviewed: report.unreviewed,
835
+ paths: report.paths,
836
+ decisions: report.decisions,
837
+ gates: report.gates,
838
+ evidence: report.evidence,
839
+ };
840
+ },
841
+ },
820
842
  {
821
843
  outputSchema: output.brief,
822
844
  name: 'brief',
823
845
  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.',
846
+ 'The packet for one task in prompt-sized bytes: kind, commands, files, tests, decisions, skills. With no task it describes the repository.',
825
847
  inputSchema: {
826
848
  type: 'object',
827
849
  properties: {
@@ -868,7 +890,10 @@ export const TOOLS = [
868
890
  outputSchema: output.federation_query,
869
891
  name: 'federation_query',
870
892
  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.',
893
+ '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.',
894
+ // This tool spawns the repo manifest's `command`, so it is not read-only: a client
895
+ // that cached its answer as one would replay a process spawn as a pure read (Q13).
896
+ annotations: { readOnlyHint: false, openWorldHint: true },
872
897
  inputSchema: {
873
898
  type: 'object',
874
899
  properties: {
@@ -899,6 +924,21 @@ export const TOOLS = [
899
924
  remedy: `declared servers: ${servers.map((server) => server.name).join(', ')}`,
900
925
  });
901
926
  }
927
+ // A model-supplied root can point at a directory with a crafted manifest, and a
928
+ // repo manifest alone must not authorise a spawn on the operator's machine (Q13).
929
+ const ws = await resolveWorkspace(args ?? {});
930
+ const user = await readUserConfig();
931
+ const denied = federationAccess({
932
+ root,
933
+ cwd: process.cwd(),
934
+ workspaceRoot: ws.root,
935
+ serverNames: chosen.map((server) => server.name),
936
+ userConfig: user.config,
937
+ configPath: user.path ?? configPaths()[0] ?? '~/.config/simt-dev/config.json',
938
+ });
939
+ if (denied !== null) {
940
+ throw Object.assign(new Error(denied.message), { remedy: denied.remedy });
941
+ }
902
942
  const { answers, findings: failures } = await consult({ root, query, servers: chosen });
903
943
  return {
904
944
  root,