simt-dev 0.2.0 → 0.2.2

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 (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -11,7 +11,7 @@
11
11
  // 2. scan for conflicts does it contradict a ruling
12
12
  // 3. block on conflicts unless --accept-conflicts
13
13
  // 4. plan everything manifest + region, dry
14
- // 5. branch and tag a named escape hatch, before the first write
14
+ // 5. branch and tag an opt-in escape hatch (--backup), before the first write
15
15
  // 6. write only with --yes
16
16
  // Never commits, never pushes (ADR-001).
17
17
 
@@ -20,7 +20,7 @@ import { promisify } from 'node:util';
20
20
  import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises';
21
21
  import { dirname, join } from 'node:path';
22
22
 
23
- import { EXIT, emit, fail, isJson, note } from '../output/index.mjs';
23
+ import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
24
24
  import { fingerprint } from '../resolve/fingerprint.mjs';
25
25
  import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
26
26
  import { MANIFEST, buildManifest, manifestPath, readManifest, writeManifest, planGates } from '../projection/manifest.mjs';
@@ -44,6 +44,7 @@ import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../pr
44
44
  import { parse as parseRegion } from '../projection/markers.mjs';
45
45
  import { CLI } from '../name.mjs';
46
46
  import { MANIFEST_FILE } from '../name.mjs';
47
+ import { packageVersion, hasNpm } from '../package-version.mjs';
47
48
 
48
49
  const run = promisify(execFile);
49
50
 
@@ -59,6 +60,11 @@ export async function initCommand(positional, flags) {
59
60
  }
60
61
 
61
62
  const sha = await provenanceDigest();
63
+ // The release this adoption records, and the kind of repository it is adopting: the
64
+ // version goes into the manifest pin and the rendered gate, and the kind decides how
65
+ // a repository without node_modules obtains the binary in CI.
66
+ const version = packageVersion();
67
+ const npm = await hasNpm(root);
62
68
  const existing = await readManifest(root);
63
69
  const { manifest: current, exists, error } = existing;
64
70
  if (error) {
@@ -70,8 +76,12 @@ export async function initCommand(positional, flags) {
70
76
  // a repository declared five and could run two, which would have blocked every
71
77
  // commit once hooks were installed.
72
78
  const capability = await readCapability(root);
73
- const { dropped } = planGates(detected.stack, capability);
74
- const manifest = buildManifest({ root, stack: detected.stack, sha, existing: current, capability });
79
+ // `facts` carries the ecosystem rows (has-cargo-toml, ...) — the same signal set that
80
+ // selects this repo's profiles, so one marker file reaches both the region the reader
81
+ // gets and the gates its commits run. Without it the ecosystem rows never fire here
82
+ // and a fresh Rust repo is adopted with gates it cannot run.
83
+ const { dropped, commands } = planGates(detected.stack, capability, detected.facts);
84
+ const manifest = buildManifest({ root, stack: detected.stack, sha, version, existing: current, capability, facts: detected.facts });
75
85
  const findings = await scanRepo(root);
76
86
  const summary = summarise(findings);
77
87
  const projected = await plan(root, flags);
@@ -141,6 +151,12 @@ export async function initCommand(positional, flags) {
141
151
  // adoption: an existing manifest keeps its gates on purpose, so its drops would
142
152
  // describe proposals that were never made.
143
153
  droppedGates: exists ? [] : dropped,
154
+ // Commands a FRESH manifest declares under gates.commands. The plan must show them:
155
+ // a command that lands in the manifest without appearing here is a silent write,
156
+ // and the reader would approve it by reading the diff afterwards — the review step
157
+ // this tool exists to make unnecessary. On an existing manifest nothing is seeded
158
+ // (the repo's declaration owns that field), so the empty list is deliberate.
159
+ seededCommands: exists ? [] : Object.entries(commands).map(([gate, command]) => ({ gate, command })),
144
160
  surfaces: projected.surfaces,
145
161
  surfaceSource: projected.surfaceSource,
146
162
  pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
@@ -193,6 +209,24 @@ export async function initCommand(positional, flags) {
193
209
  git: null,
194
210
  };
195
211
 
212
+ // A surface this build does not know cannot be written to. Checked before the rule
213
+ // branch, and named for what it is: `init --surfaces` accepts a list, a reader
214
+ // mistypes one, and the misspelling used to be reported as an unprojectable rule with
215
+ // a remedy about substitution keys — the one problem on this path that had no
216
+ // substitution keys anywhere near it.
217
+ const surfaceProblems = projected.surfaceProblems ?? [];
218
+ if (surfaceProblems.length > 0) {
219
+ payload.ok = false;
220
+ payload.blockedBy = 'surface-problem';
221
+ for (const p of surfaceProblems) note(`PROBLEM ${p}`);
222
+ if (isJson(flags)) emitJson(payload);
223
+ return fail(
224
+ `${surfaceProblems.length} surface(s) cannot be resolved (${surfaceProblems[0]})`,
225
+ EXIT.FAIL,
226
+ `pass a known surface id to --surfaces, or omit it to take the default`,
227
+ );
228
+ }
229
+
196
230
  // A `.mdc` rule that cannot render is a defect in this build. Adopting a repo
197
231
  // without it would leave the family half-retired, and the user would have no way
198
232
  // to tell which half from the diff.
@@ -200,7 +234,7 @@ export async function initCommand(positional, flags) {
200
234
  payload.ok = false;
201
235
  payload.blockedBy = 'mdc-problem';
202
236
  for (const p of mdcProblems) note(`PROBLEM ${p}`);
203
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
237
+ if (isJson(flags)) emitJson(payload);
204
238
  return fail(
205
239
  `${mdcProblems.length} canonical .mdc rule(s) cannot be projected (${mdcProblems[0]})`,
206
240
  EXIT.FAIL,
@@ -215,9 +249,9 @@ export async function initCommand(positional, flags) {
215
249
  payload.ok = false;
216
250
  payload.blockedBy = 'conflict';
217
251
  if (isJson(flags)) {
218
- emit(`${JSON.stringify(payload, null, 2)}\n`);
252
+ emitJson(payload);
219
253
  } else {
220
- emit(render(payload, manifest));
254
+ emit(render(payload, manifest, { npm, version }));
221
255
  note('');
222
256
  note(
223
257
  `blocked ${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}; nothing written`,
@@ -237,8 +271,8 @@ export async function initCommand(positional, flags) {
237
271
  if (malformed.length > 0) {
238
272
  payload.ok = false;
239
273
  payload.blockedBy = 'malformed-region';
240
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
241
- else emit(render(payload, manifest));
274
+ if (isJson(flags)) emitJson(payload);
275
+ else emit(render(payload, manifest, { npm, version }));
242
276
  return fail(
243
277
  `${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
244
278
  EXIT.FAIL,
@@ -248,9 +282,9 @@ export async function initCommand(positional, flags) {
248
282
 
249
283
  // Step 4/5: report everything, then make changes only when told to.
250
284
  if (isJson(flags)) {
251
- if (!apply) emit(`${JSON.stringify(payload, null, 2)}\n`);
285
+ if (!apply) emitJson(payload);
252
286
  } else {
253
- emit(render(payload, manifest));
287
+ emit(render(payload, manifest, { npm, version }));
254
288
  }
255
289
  if (!apply) {
256
290
  note('');
@@ -262,9 +296,40 @@ export async function initCommand(positional, flags) {
262
296
  return EXIT.OK;
263
297
  }
264
298
 
265
- const git = await establishEscapeHatch(root);
299
+ // The escape hatch is opt-in (ADR-063): plan 0002 §8.2 item 3 said "always", and the
300
+ // tool complied, but consumers kept reporting the branch switch as a surprise. The
301
+ // default now writes in place on the branch the reader already has — `git diff` still
302
+ // shows the full adoption, and nothing is tagged or branched. `--backup` restores the
303
+ // tag and the branch for readers who want the named undo path.
304
+ const git = flags.backup
305
+ ? await establishEscapeHatch(root)
306
+ : { branch: null, tag: null, from: null, notes: [] };
266
307
  payload.git = git;
267
308
  for (const message of git.notes) note(`note ${message}`);
309
+ if (!flags.backup) {
310
+ note(
311
+ 'in place no backup tag or adopt branch created; the adoption is written on your current branch - git diff shows it and git checkout -- . discards it; re-run with --backup for the tag and the branch',
312
+ );
313
+ }
314
+ // The operations that succeeded, not just the ones that failed.
315
+ //
316
+ // With --backup, `init` moves the reader onto a different branch and writes a tag
317
+ // into their repository. Both were once reported only under `--json`, so the run —
318
+ // the one almost everybody reads — did not say that the branch under them had
319
+ // changed. A reader who ran the documented escape hatch and then committed would be
320
+ // committing to a branch they had not chosen, and nothing in the output told them
321
+ // that had happened.
322
+ //
323
+ // The plan's heading is "Nothing is silent", so whatever the run does to git state —
324
+ // or deliberately does not — the output names it and the command that undoes it.
325
+ // `notes` carried the failures because a failure is the kind of thing an operator
326
+ // notices; a success is not.
327
+ if (git.tag !== null) {
328
+ note(`tagged ${git.tag} at the pre-adoption commit; \`git reset --hard ${git.tag}\` returns this repository to what it was`);
329
+ }
330
+ if (git.branch !== null && git.from !== null) {
331
+ note(`branch switched to ${git.branch}; ${git.from} is untouched and \`git checkout ${git.from}\` returns to it`);
332
+ }
268
333
 
269
334
  await writeManifest(root, manifest);
270
335
  note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
@@ -304,7 +369,7 @@ export async function initCommand(positional, flags) {
304
369
  // guaranteed to be misled.
305
370
  if (payload.ci.action === 'create') {
306
371
  await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
307
- await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own }));
372
+ await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own, npm, version }));
308
373
  note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
309
374
  }
310
375
  if (payload.dependabot.action === 'create') {
@@ -378,7 +443,7 @@ export async function initCommand(positional, flags) {
378
443
  }
379
444
  payload.written = true;
380
445
 
381
- if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
446
+ if (isJson(flags)) emitJson(payload);
382
447
 
383
448
  note('');
384
449
  note(`next ${CLI} sync --yes, then ${CLI} check in CI. Commit when you are happy with the diff.`);
@@ -423,13 +488,14 @@ async function scanRepo(root) {
423
488
  }
424
489
 
425
490
  /**
426
- * A named escape hatch before the first write, per plan 0001. Both operations are
427
- * best-effort: a repo that is not a git repo, or has no commits yet, must still be
491
+ * The opt-in escape hatch before the first write, taken when --backup is passed
492
+ * (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
493
+ * are best-effort: a repo that is not a git repo, or has no commits yet, must still be
428
494
  * adoptable. Failure is reported as a note rather than blocking, because refusing
429
495
  * to initialise an unborn repo would be worse than proceeding without a backup.
430
496
  */
431
497
  async function establishEscapeHatch(root) {
432
- const result = { branch: null, tag: null, notes: [] };
498
+ const result = { branch: null, tag: null, from: null, notes: [] };
433
499
  const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
434
500
  if (!isRepo.ok) {
435
501
  result.notes.push('not a git work tree; no backup branch or tag created');
@@ -457,6 +523,10 @@ async function establishEscapeHatch(root) {
457
523
 
458
524
  const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
459
525
  if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
526
+ // Recorded rather than re-read by the caller: only this function knows what the
527
+ // branch was before it moved it, and a second `rev-parse` would answer with the new
528
+ // value and make every "you were on X" line read as "you were on Y".
529
+ result.from = current.stdout.trim();
460
530
  const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
461
531
  if (branch.ok) result.branch = ADOPT_BRANCH;
462
532
  else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
@@ -477,7 +547,7 @@ async function git(cwd, args) {
477
547
  }
478
548
 
479
549
  /** Every line is a decision with a reason. Nothing is silent. */
480
- function render(payload, manifest) {
550
+ function render(payload, manifest, { npm = true, version } = {}) {
481
551
  const lines = [
482
552
  `repo ${payload.root}`,
483
553
  `stack ${payload.stack.length > 0 ? payload.stack.join(', ') : '(undetected)'}`,
@@ -518,6 +588,12 @@ function render(payload, manifest) {
518
588
  lines.push(` ${'dropped'.padEnd(9)} gate ${d.gate}: ${d.why}`);
519
589
  lines.push(` remedy: ${d.remedy}`);
520
590
  }
591
+ // A seed is a write, and the same rule as for dropped gates applies: the reader must
592
+ // see the line or they approve it from the diff afterwards. Naming the manifest field
593
+ // keeps the line actionable — this is where the reader edits it if the default is wrong.
594
+ for (const s of payload.seededCommands ?? []) {
595
+ lines.push(` ${'seeded'.padEnd(9)} gates.commands["${s.gate}"] = ${s.command}`);
596
+ }
521
597
  lines.push(
522
598
  ` ${'profiles'.padEnd(9)} ${LOCAL_PROFILE_DIR}/*.md ` +
523
599
  `repo-local profiles are selected by the same matcher (ADR-013)`,
@@ -532,8 +608,12 @@ function render(payload, manifest) {
532
608
  // on it would understate the edit the reader is approving. A baseline is the
533
609
  // opposite case to AGENTS.md in the one way that matters here — it is placed FIRST,
534
610
  // so the repo's own settings below it keep winning (ADR-025).
611
+ // A nested `apps/web/AGENTS.md` is the same kind of target as the root's, so the
612
+ // test is the suffix and not the exact path: a monorepo's package region holds
613
+ // hand-written content too, and describing it as a generated rule file would be
614
+ // the wrong contract for a reader who has prose above the markers.
535
615
  const caption =
536
- target.file === PRIMARY_TARGET
616
+ target.file.endsWith(PRIMARY_TARGET)
537
617
  ? 'hand-written content outside the markers is never modified'
538
618
  : target.kind === 'baseline'
539
619
  ? 'a region placed first, so your own settings below it still win (ADR-025)'
@@ -560,7 +640,7 @@ function render(payload, manifest) {
560
640
  // a reader can tell "this repo does not use GitHub Actions" from "this repo was
561
641
  // not offered the gate".
562
642
  if (payload.ci.action === 'create') {
563
- lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own }).length} bytes`);
643
+ lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own, npm, version }).length} bytes`);
564
644
  lines.push(
565
645
  payload.ci.own
566
646
  ? ` this repository is simt-dev's own; ${CLI} ci --yes refreshes this copy when the gate changes`
@@ -13,9 +13,9 @@ import { readFile } from 'node:fs/promises';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { dirname, join } from 'node:path';
15
15
 
16
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
16
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
17
17
  import { TOOLS } from '../mcp/tools.mjs';
18
- import { PROTOCOL_VERSION, serve } from '../mcp/server.mjs';
18
+ import { PROTOCOL_VERSION, SUPPORTED_VERSIONS, serve } from '../mcp/server.mjs';
19
19
  import { CLI } from '../name.mjs';
20
20
 
21
21
  export async function mcpCommand(positional, flags) {
@@ -23,12 +23,27 @@ export async function mcpCommand(positional, flags) {
23
23
 
24
24
  if (flags.list === true) {
25
25
  if (isJson(flags)) {
26
- emit(`${JSON.stringify({ ok: true, protocolVersion: PROTOCOL_VERSION, version, tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }, null, 2)}\n`);
26
+ emitJson({
27
+ ok: true,
28
+ protocolVersion: PROTOCOL_VERSION,
29
+ protocolVersions: SUPPORTED_VERSIONS,
30
+ version,
31
+ // The output schema and the read-only annotation travel with the tool here too:
32
+ // a reader listing the surface should see the same contract a call returns,
33
+ // rather than discovering the shape by making one.
34
+ tools: TOOLS.map(({ name, description, inputSchema, outputSchema, annotations }) => ({
35
+ name,
36
+ description,
37
+ inputSchema,
38
+ ...(outputSchema ? { outputSchema } : {}),
39
+ annotations,
40
+ })),
41
+ });
27
42
  return EXIT.OK;
28
43
  }
29
44
  emit(
30
45
  [
31
- `simt-dev ${version} - MCP surface over stdio (protocol ${PROTOCOL_VERSION})`,
46
+ `simt-dev ${version} - MCP surface over stdio (protocols ${SUPPORTED_VERSIONS.join(', ')})`,
32
47
  '',
33
48
  `${TOOLS.length} read-only tools:`,
34
49
  ...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
@@ -44,7 +59,7 @@ export async function mcpCommand(positional, flags) {
44
59
  }
45
60
 
46
61
  try {
47
- return await serve(TOOLS, { version });
62
+ return await serve(TOOLS, { packageVersion: version });
48
63
  } catch (error) {
49
64
  return fail(`mcp transport failed: ${error.message}`, EXIT.FAIL, `run: ${CLI} mcp --list to check the surface loads`);
50
65
  }
@@ -14,8 +14,9 @@
14
14
  // one, because "this rule has no decision behind it" is exactly the fact a
15
15
  // reviewer needs and exactly the fact an empty output would hide.
16
16
 
17
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
17
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
18
18
  import { loadRules, select } from '../projection/project.mjs';
19
+ import { loadMdcRules } from '../projection/mdc.mjs';
19
20
  import { fingerprint } from '../resolve/fingerprint.mjs';
20
21
  import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
21
22
  import { nearMatches } from '../resolve/near.mjs';
@@ -23,9 +24,18 @@ import { CLI } from '../name.mjs';
23
24
 
24
25
  const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
25
26
 
26
- /** `simt-applies` is the string "always" for core and an array for profiles. */
27
+ /**
28
+ * `simt-applies` is the string "always" for core and an array for profiles.
29
+ *
30
+ * An opt-in rule has an EMPTY array, and `-` is a true reading of that which answers
31
+ * none of the questions a reader has. `simt-opt-in` is why it is empty — detection cannot
32
+ * distinguish a repository with the problem from one without it, so the repository
33
+ * declares it — and that is the fact worth printing. It only became visible when the
34
+ * per-surface rules entered this catalogue; before that the rule was not listed at all.
35
+ */
27
36
  function appliesLabel(rule) {
28
37
  if (rule.applies === 'always') return 'always';
38
+ if (rule.optIn === true) return 'opt-in (the repo declares it)';
29
39
  if (Array.isArray(rule.applies)) return rule.applies.join(',') || '-';
30
40
  return '-';
31
41
  }
@@ -38,7 +48,37 @@ function sorted(rules) {
38
48
  async function load(root) {
39
49
  const rules = await loadRules(root ?? null);
40
50
  const broken = rules.filter((r) => r.error);
41
- return { rules, broken };
51
+ return { rules: [...rules, ...(await loadPerSurfaceRules())], broken };
52
+ }
53
+
54
+ /**
55
+ * The `.mdc` rules, in the shape this file's three commands print.
56
+ *
57
+ * `loadRules` reads `rules/*.md` and `rules/stacks/*.md`, which is every rule that is a
58
+ * *stack profile* — and the four per-surface rules are not that. They are projected by
59
+ * `ruleBodies` inside `desiredRegion` rather than by `select`, so they were invisible to
60
+ * `rules list`, `rules show` and `rules why`: a reader could be told by an agent to run
61
+ * `simt rules why test-gate` and be met with "no rule with that id", for a rule that was
62
+ * in their own `AGENTS.md` at the time.
63
+ *
64
+ * Deliberately NOT added to `loadRules`. That function's result is `select`ed and
65
+ * `render`ed into the region, so putting these there would project every one of them
66
+ * twice — once as a profile section and once as `## Process: <id>` — which is the
67
+ * duplication ADR-046 exists to prevent. The catalogue is the right layer for this: it is
68
+ * a view, and a view may be wider than what one projection writes.
69
+ */
70
+ async function loadPerSurfaceRules() {
71
+ const mdc = await loadMdcRules();
72
+ return mdc.map((r) => ({
73
+ ...r,
74
+ // These have no `simt-title`; their `description` is what a reader sees in the
75
+ // generated file, so it is the title here rather than a column of blanks.
76
+ title: r.title ?? r.description ?? null,
77
+ // `mdc` rather than `central`: the column exists so a reader can tell where a rule
78
+ // came from, and "central" is true of all of them and useless for this distinction.
79
+ origin: 'per-surface',
80
+ fallback: false,
81
+ }));
42
82
  }
43
83
 
44
84
  // --- list ---------------------------------------------------------------------
@@ -52,30 +92,25 @@ export async function listCommand(args, flags) {
52
92
  const selection = root ? await selectionFor(rules, root) : null;
53
93
 
54
94
  if (isJson(flags)) {
55
- emit(
56
- `${JSON.stringify(
57
- {
58
- ok: broken.length === 0,
59
- command: 'rules list',
60
- root,
61
- rules: sorted(rules).map((r) => ({
62
- id: r.id,
63
- title: r.title,
64
- origin: r.origin,
65
- source: r.source,
66
- applies: r.applies,
67
- fallback: r.fallback,
68
- bytes: r.bytes,
69
- error: r.error,
70
- adrs: citations(r.body),
71
- selected: selection ? selection.selected.has(r.id) : null,
72
- })),
73
- notes: selection?.notes ?? [],
74
- },
75
- null,
76
- 2,
77
- )}\n`,
78
- { maxBytes: flags.maxBytes ?? 0 },
95
+ emitJson(
96
+ {
97
+ ok: broken.length === 0,
98
+ command: 'rules list',
99
+ root,
100
+ rules: sorted(rules).map((r) => ({
101
+ id: r.id,
102
+ title: r.title,
103
+ origin: r.origin,
104
+ source: r.source,
105
+ applies: r.applies,
106
+ fallback: r.fallback,
107
+ bytes: r.bytes,
108
+ error: r.error,
109
+ adrs: citations(r.body),
110
+ selected: selection ? selection.selected.has(r.id) : null,
111
+ })),
112
+ notes: selection?.notes ?? [],
113
+ },
79
114
  );
80
115
  return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
81
116
  }
@@ -148,7 +183,7 @@ export async function showCommand(args, flags) {
148
183
 
149
184
  const adrs = citations(rule.body);
150
185
  if (isJson(flags)) {
151
- emit(`${JSON.stringify({ ok: !rule.error, ...rule, adrs }, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
186
+ emitJson({ ok: !rule.error, ...rule, adrs }, { maxBytes: flags.maxBytes ?? 0 });
152
187
  return rule.error ? EXIT.FAIL : EXIT.OK;
153
188
  }
154
189
 
@@ -182,7 +217,7 @@ export async function whyCommand(args, flags) {
182
217
  if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
183
218
 
184
219
  const { rules, broken } = await load(flags.root ?? null);
185
- const { ok, decisions, errors } = await loadDecisions();
220
+ const { ok, decisions, errors } = await loadDecisions({ all: flags.all === true });
186
221
  if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
187
222
 
188
223
  return what.match(/^ADR-\d{3}$/i)
@@ -198,21 +233,16 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
198
233
  const index = byId(decisions);
199
234
 
200
235
  if (isJson(flags)) {
201
- emit(
202
- `${JSON.stringify(
203
- {
204
- ok: true,
205
- command: 'rules why',
206
- rule: rule.id,
207
- source: rule.source,
208
- origin: rule.origin,
209
- adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
210
- uncited: cited.length === 0,
211
- },
212
- null,
213
- 2,
214
- )}\n`,
215
- { maxBytes: flags.maxBytes ?? 0 },
236
+ emitJson(
237
+ {
238
+ ok: true,
239
+ command: 'rules why',
240
+ rule: rule.id,
241
+ source: rule.source,
242
+ origin: rule.origin,
243
+ adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
244
+ uncited: cited.length === 0,
245
+ },
216
246
  );
217
247
  return EXIT.OK;
218
248
  }
@@ -273,22 +303,17 @@ async function adrDirection(adr, rules, decisions, flags) {
273
303
  // effective ruling is carried alongside rather than instead: the record asked about
274
304
  // is still named, and what is in force is named separately.
275
305
  const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
276
- emit(
277
- `${JSON.stringify(
278
- {
279
- ok: true,
280
- command: 'rules why',
281
- adr: d,
282
- supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
283
- inForce: replacement
284
- ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
285
- : { id: d.id, status: d.status, summary: d.summary },
286
- rules: restates.map((r) => r.id),
287
- },
288
- null,
289
- 2,
290
- )}\n`,
291
- { maxBytes: flags.maxBytes ?? 0 },
306
+ emitJson(
307
+ {
308
+ ok: true,
309
+ command: 'rules why',
310
+ adr: d,
311
+ supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
312
+ inForce: replacement
313
+ ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
314
+ : { id: d.id, status: d.status, summary: d.summary },
315
+ rules: restates.map((r) => r.id),
316
+ },
292
317
  );
293
318
  return EXIT.OK;
294
319
  }
@@ -342,6 +367,17 @@ async function adrDirection(adr, rules, decisions, flags) {
342
367
  if (effective.summary.ruled.length > 0) {
343
368
  lines.push('', replacement ? 'ruled now' : 'ruled');
344
369
  for (const r of effective.summary.ruled) lines.push(` - ${redactEstate(r)}`);
370
+ // The count, because the excerpt above is two rulings and a record may carry eight.
371
+ // Without it the header reads as a claim about everything the record settled, which
372
+ // is how a reader goes looking for a ruling that was never printed and concludes it
373
+ // does not exist.
374
+ const shown = effective.summary.ruled.length;
375
+ const total = effective.summary.rulingCount ?? shown;
376
+ if (total > shown) {
377
+ lines.push(
378
+ ` (${shown} of ${total} rulings shown; the rest: ${CLI} rules why ${effective.id} --all)`,
379
+ );
380
+ }
345
381
  }
346
382
  if (effective.summary.rejected.length > 0) {
347
383
  // The rejected alternatives are the part a reader cannot reconstruct. A ruling is