simt-dev 0.7.0 → 0.7.1

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 (112) hide show
  1. package/CHANGELOG.md +158 -872
  2. package/README.md +19 -133
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  6. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  7. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  8. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  9. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  10. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  11. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  12. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  13. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  14. package/package.json +10 -2
  15. package/rules/core.md +7 -0
  16. package/rules/mdc/slice-commit.mdc +57 -4
  17. package/src/agenthooks/prompt.mjs +5 -2
  18. package/src/agenthooks/shim.mjs +1 -1
  19. package/src/api.mjs +29 -0
  20. package/src/argv.mjs +29 -3
  21. package/src/bench/adapters.json +16 -0
  22. package/src/bench/corpus.mjs +150 -0
  23. package/src/bench/grade.mjs +23 -49
  24. package/src/bench/mine.mjs +199 -35
  25. package/src/bench/probes.mjs +99 -62
  26. package/src/bench/process.mjs +128 -0
  27. package/src/bench/report.mjs +17 -4
  28. package/src/bench/run.mjs +242 -142
  29. package/src/brief/assemble.mjs +38 -3
  30. package/src/ci/workflow.mjs +30 -7
  31. package/src/cli.mjs +45 -0
  32. package/src/commands/agent.mjs +3 -4
  33. package/src/commands/agenthook.mjs +4 -5
  34. package/src/commands/bench.mjs +190 -17
  35. package/src/commands/brief.mjs +5 -4
  36. package/src/commands/ci.mjs +2 -1
  37. package/src/commands/compare.mjs +42 -42
  38. package/src/commands/completions.mjs +2 -6
  39. package/src/commands/decide.mjs +4 -4
  40. package/src/commands/distill.mjs +4 -3
  41. package/src/commands/doctor.mjs +11 -7
  42. package/src/commands/evidence.mjs +6 -9
  43. package/src/commands/explain.mjs +0 -1
  44. package/src/commands/gate.mjs +16 -12
  45. package/src/commands/graph.mjs +2 -3
  46. package/src/commands/harness.mjs +3 -2
  47. package/src/commands/help.mjs +125 -17
  48. package/src/commands/hooks.mjs +100 -30
  49. package/src/commands/ignore.mjs +2 -1
  50. package/src/commands/impact.mjs +4 -7
  51. package/src/commands/index.mjs +273 -188
  52. package/src/commands/init.mjs +2 -1
  53. package/src/commands/lease.mjs +6 -7
  54. package/src/commands/lessons.mjs +10 -9
  55. package/src/commands/local.mjs +3 -3
  56. package/src/commands/map.mjs +14 -9
  57. package/src/commands/mcp.mjs +0 -1
  58. package/src/commands/pack.mjs +9 -6
  59. package/src/commands/plan.mjs +6 -5
  60. package/src/commands/plugin.mjs +2 -2
  61. package/src/commands/prbody.mjs +3 -3
  62. package/src/commands/repoindex.mjs +4 -3
  63. package/src/commands/review.mjs +2 -2
  64. package/src/commands/rules.mjs +33 -16
  65. package/src/commands/shared.mjs +2 -3
  66. package/src/commands/skills.mjs +28 -26
  67. package/src/commands/skillscompile.mjs +4 -5
  68. package/src/commands/skillsinstall.mjs +2 -2
  69. package/src/commands/slice.mjs +21 -10
  70. package/src/commands/spec.mjs +151 -0
  71. package/src/commands/status.mjs +4 -7
  72. package/src/commands/sync.mjs +13 -11
  73. package/src/commands/tests.mjs +6 -9
  74. package/src/commands/tokens.mjs +2 -2
  75. package/src/commands/undo.mjs +5 -15
  76. package/src/commands/upgrade.mjs +7 -3
  77. package/src/commands/why.mjs +9 -8
  78. package/src/compare/row.mjs +0 -2
  79. package/src/context.mjs +64 -0
  80. package/src/decisions/index-doc.mjs +46 -0
  81. package/src/diff.mjs +1 -1
  82. package/src/distill/distill.mjs +14 -1
  83. package/src/distill/engine.mjs +5 -1
  84. package/src/graph/load.mjs +4 -1
  85. package/src/hooks/capability.mjs +3 -3
  86. package/src/hooks/dispatcher.mjs +198 -43
  87. package/src/hooks/gates.mjs +0 -43
  88. package/src/hooks/resolve.mjs +2 -7
  89. package/src/hooks/shim.mjs +5 -2
  90. package/src/identity.mjs +29 -1
  91. package/src/journal.mjs +78 -9
  92. package/src/ledger/index.mjs +30 -1
  93. package/src/local/client.mjs +4 -6
  94. package/src/locate.mjs +33 -9
  95. package/src/manifest/schema.mjs +213 -0
  96. package/src/mcp/resources.mjs +7 -10
  97. package/src/mcp/server.mjs +70 -16
  98. package/src/mcp/tools.mjs +45 -7
  99. package/src/measure/session.mjs +3 -2
  100. package/src/node-version.mjs +37 -0
  101. package/src/output/index.mjs +111 -3
  102. package/src/packs/manage.mjs +17 -3
  103. package/src/packs.mjs +20 -2
  104. package/src/projection/lock.mjs +11 -1
  105. package/src/projection/manifest.mjs +20 -34
  106. package/src/projection/rules.mjs +4 -3
  107. package/src/repo-root.mjs +19 -0
  108. package/src/resolve/fingerprint.mjs +19 -15
  109. package/src/review/critic.mjs +2 -1
  110. package/src/toolwords.mjs +3 -0
  111. package/src/workspace.mjs +55 -10
  112. package/docs/decisions/README.md +0 -600
@@ -19,7 +19,8 @@
19
19
  import { mkdir, readFile, stat, symlink, unlink } from 'node:fs/promises';
20
20
  import { dirname, join, resolve, sep } from 'node:path';
21
21
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
22
- import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
22
+ import { repoContext } from '../context.mjs';
23
+ import { plan, checkTargets, checkTarget, select, pointerTargets, pointerState } from '../projection/project.mjs';
23
24
  import { fingerprint } from '../resolve/fingerprint.mjs';
24
25
  import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText, manifestPath } from '../projection/manifest.mjs';
25
26
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
@@ -34,6 +35,7 @@ import { computeLock, readLock } from '../projection/lock.mjs';
34
35
  import { runDeclaredChecks } from '../kernel/checks.mjs';
35
36
  import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
36
37
  import { lintDecisions } from '../memory/decide.mjs';
38
+ import { commandRoot } from '../repo-root.mjs';
37
39
 
38
40
  /** Findings `sync` reports and does not fail on.
39
41
  *
@@ -123,7 +125,7 @@ export const CHECK_NON_BLOCKING = new Set([
123
125
  ]);
124
126
 
125
127
  export async function syncCommand(positional, flags) {
126
- const root = flags.root ?? positional[0] ?? process.cwd();
128
+ const root = commandRoot(flags, positional);
127
129
  const checkOnly = flags.check === true;
128
130
  const mode = writeMode(flags);
129
131
 
@@ -409,7 +411,7 @@ export async function syncCommand(positional, flags) {
409
411
  : `would write ${changed.length + pointersAdded.length + (manifestChanged ? 1 : 0)} file(s); apply with: ${CLI} sync --yes`,
410
412
  );
411
413
  }
412
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
414
+ emit(lines.join('\n'));
413
415
  // After the report: the report says what, the diff shows it (R50).
414
416
  emitDiffs(diffs, flags);
415
417
  }
@@ -544,8 +546,9 @@ async function gateCwdFindings(root, manifest) {
544
546
  // reports 77. The command below renders these facts for terminals; the tool
545
547
  // returns them as JSON. The CLI keeps its field names; the tool adopts them.
546
548
  export async function checkRepo(root, opts = {}) {
547
- const findings = await checkTargets(root, opts);
548
- const rules = await loadRules(root);
549
+ const ctx = opts.ctx ?? repoContext(root);
550
+ const findings = await checkTargets(root, { ...opts, ctx });
551
+ const rules = await ctx.rules();
549
552
 
550
553
  // A rule file with no id would be projected without a traceable source, so it
551
554
  // is a gate failure rather than a warning: `rules why` could not resolve it.
@@ -568,7 +571,7 @@ export async function checkRepo(root, opts = {}) {
568
571
  // A declared gate this repo cannot run fails closed on every commit once hooks are
569
572
  // installed. Reported here rather than left to `hooks status`, so the repo is red
570
573
  // before anyone installs anything rather than after.
571
- const { manifest, exists } = await readManifest(root);
574
+ const { manifest, exists } = await ctx.manifest();
572
575
  // The gates slot, counted — not just reported — so the line can print run/total.
573
576
  // A cwd finding names a declared gate that will not run, so it counts with the
574
577
  // unrunnable; an unknown-gate-cwd names no declared gate, so it does not.
@@ -603,14 +606,14 @@ export async function checkRepo(root, opts = {}) {
603
606
  // hand either: the next `init` puts simt-dev's own bytes back and the formatter
604
607
  // takes them away again. Reporting it here is what stops that being a surprise
605
608
  // discovered at upgrade time.
606
- const { stack } = await fingerprint(root);
607
- const drift = await manifestDrift(root, stack, await provenanceDigest());
609
+ const { stack } = await ctx.fingerprint();
610
+ const drift = await manifestDrift(root, stack, await ctx.digest());
608
611
  if (drift) findings.push(drift);
609
612
  }
610
613
  if (opts.frozen) findings.push(...(await frozenFindings(root, opts)));
611
614
  // The checkers a selected rule declared (ADR-088). They read the rule source,
612
615
  // not the folded region, so a line that left the prompt is still the line that runs.
613
- const fp = await fingerprint(root);
616
+ const fp = await ctx.fingerprint();
614
617
  const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
615
618
  findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
616
619
 
@@ -652,7 +655,7 @@ export async function checkRepo(root, opts = {}) {
652
655
  }
653
656
 
654
657
  export async function checkCommand(positional, flags) {
655
- const root = flags.root ?? positional[0] ?? process.cwd();
658
+ const root = commandRoot(flags, positional);
656
659
  const { ok, findings } = await checkRepo(root, flags);
657
660
  const payload = {
658
661
  ok,
@@ -678,7 +681,6 @@ export async function checkCommand(positional, flags) {
678
681
  return f.remedy ? [head, `${' '.repeat(11)}remedy: ${f.remedy}`] : [head];
679
682
  })
680
683
  .join('\n'),
681
- { maxBytes: flags.maxBytes ?? 0 },
682
684
  );
683
685
  return ok ? EXIT.OK : EXIT.FAIL;
684
686
  }
@@ -10,11 +10,12 @@ import { readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { promisify } from 'node:util';
12
12
 
13
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
14
14
  import { CLI } from '../name.mjs';
15
15
  import { isTestPath } from '../bench/mine.mjs';
16
16
  import { blobTexts, isIndexStale, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
17
17
  import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
18
+ import { commandRoot } from '../repo-root.mjs';
18
19
 
19
20
  const exec = promisify(execFile);
20
21
 
@@ -31,7 +32,7 @@ const COVERAGE_PATHS = [
31
32
 
32
33
  export async function testsCommand(args, flags) {
33
34
  if (args.length > 1) return fail(`unknown tests argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} tests [path]`);
34
- const root = flags.root ?? process.cwd();
35
+ const root = commandRoot(flags);
35
36
  const loaded = await loadFiles(root);
36
37
  if (loaded.error) return loaded.error;
37
38
  const { files } = loaded;
@@ -58,11 +59,11 @@ export async function testsCommand(args, flags) {
58
59
  const tests = testsFor(target, edges, { cochange: flags.cochange === true });
59
60
  const payload = { ok: true, command: 'tests', root, path: target, tests };
60
61
  if (isJson(flags)) {
61
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
62
+ emitJson(payload);
62
63
  return EXIT.OK;
63
64
  }
64
65
  const lines = [`tests ${tests.length}`, ...tests];
65
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
66
+ emit(lines.join('\n'));
66
67
  return EXIT.OK;
67
68
  }
68
69
 
@@ -74,11 +75,7 @@ function summarise(root, flags, edges) {
74
75
  else if (edge.kind === 'cochange') cochange += 1;
75
76
  }
76
77
  const payload = { ok: true, command: 'tests', root, test, cochange };
77
- if (isJson(flags)) {
78
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
79
- return EXIT.OK;
80
- }
81
- emit(`test ${test}\ncochange ${cochange}`, { maxBytes: flags.maxBytes ?? 0 });
78
+ present(payload, () => `test ${test}\ncochange ${cochange}`, flags);
82
79
  return EXIT.OK;
83
80
  }
84
81
 
@@ -73,7 +73,7 @@ export async function tokensCommand(positional, flags) {
73
73
  `tool output ${plain(session.toolOutput.bytes)} B ~${plain(session.toolOutput.tokens)} tokens (${plain(session.toolOutput.share)}%)`,
74
74
  `total ${plain(session.total.bytes)} B ~${plain(session.total.tokens)} tokens`,
75
75
  ];
76
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
76
+ emit(lines.join('\n'));
77
77
  return EXIT.OK;
78
78
  }
79
79
 
@@ -193,6 +193,6 @@ export async function tokensCommand(positional, flags) {
193
193
  `mcp tools/list ${plain(mcp.bytes)} B of ${plain(mcp.tools)} tools (the same for every repository, so it is not in any total)`,
194
194
  );
195
195
 
196
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
196
+ emit(lines.join('\n'));
197
197
  return EXIT.OK;
198
198
  }
@@ -11,24 +11,21 @@
11
11
  // the rest around a hand edit would silently merge two authors' intentions.
12
12
  // Like every other writer this command previews without `--yes`.
13
13
 
14
- import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
14
+ import { EXIT, emit, emitJson, isJson, writeMode, present } from '../output/index.mjs';
15
15
  import { CLI } from '../name.mjs';
16
16
  import { beforeText, entryLabel, listJournalSets, readGitValue, readJournalSet, undoLastJournalSet } from '../journal.mjs';
17
17
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
18
18
  import { join } from 'node:path';
19
19
  import { invalidateCheckCache } from '../status.mjs';
20
+ import { commandRoot } from '../repo-root.mjs';
20
21
 
21
22
  export async function undoCommand(positional, flags) {
22
- const root = flags.root ?? positional[0] ?? process.cwd();
23
+ const root = commandRoot(flags, positional);
23
24
  const mode = writeMode(flags);
24
25
 
25
26
  const [id] = await listJournalSets(root);
26
27
  if (!id) {
27
- if (isJson(flags)) {
28
- emitJson({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply });
29
- return EXIT.OK;
30
- }
31
- emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
28
+ present({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply }, () => 'nothing to undo: no journaled writes', flags);
32
29
  return EXIT.OK;
33
30
  }
34
31
 
@@ -64,7 +61,6 @@ export async function undoCommand(positional, flags) {
64
61
  '',
65
62
  `would restore ${files} file(s)${keys > 0 ? ` and ${keys} git config key(s)` : ''}; apply with: ${CLI} undo --yes`,
66
63
  ].join('\n'),
67
- { maxBytes: flags.maxBytes ?? 0 },
68
64
  );
69
65
  // After the report: the report says what, the diff shows it (R50).
70
66
  emitDiffs(diffs, flags);
@@ -73,11 +69,7 @@ export async function undoCommand(positional, flags) {
73
69
 
74
70
  const result = await undoLastJournalSet(root);
75
71
  if (result.empty) {
76
- if (isJson(flags)) {
77
- emitJson({ ok: true, command: 'undo', root, set: null, restored: [] });
78
- return EXIT.OK;
79
- }
80
- emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
72
+ present({ ok: true, command: 'undo', root, set: null, restored: [] }, () => 'nothing to undo: no journaled writes', flags);
81
73
  return EXIT.OK;
82
74
  }
83
75
  if (result.refused) {
@@ -92,7 +84,6 @@ export async function undoCommand(positional, flags) {
92
84
  '',
93
85
  'nothing was restored; re-run the writing command or merge by hand, then undo again',
94
86
  ].join('\n'),
95
- { maxBytes: flags.maxBytes ?? 0 },
96
87
  );
97
88
  return EXIT.FAIL;
98
89
  }
@@ -106,7 +97,6 @@ export async function undoCommand(positional, flags) {
106
97
  }
107
98
  emit(
108
99
  [`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
109
- { maxBytes: flags.maxBytes ?? 0 },
110
100
  );
111
101
  // After the report: the report says what, the diff shows it (R50).
112
102
  emitDiffs(diffs, flags);
@@ -216,8 +216,12 @@ export async function upgradeCommand(positional, flags) {
216
216
  }
217
217
  }
218
218
 
219
+ // One verdict, stated once. `ok` used to be `skipped === 0`, so a workspace with a
220
+ // repo that never adopted answered ok:false and exited 0: the document and the process
221
+ // disagreed, and a caller had no way to tell which one was the answer (review 0003 Q65).
222
+ const code = exitFor(plans, failed);
219
223
  const payload = {
220
- ok: summary.skipped === 0 && failed.length === 0,
224
+ ok: code === EXIT.OK,
221
225
  command: 'upgrade',
222
226
  root,
223
227
  shape: found.shape,
@@ -232,7 +236,7 @@ export async function upgradeCommand(positional, flags) {
232
236
 
233
237
  if (isJson(flags)) {
234
238
  emitJson(payload);
235
- return exitFor(plans, failed);
239
+ return code;
236
240
  }
237
241
 
238
242
  emit(render(plans, summary, { root, shape: found.shape, source: target.source, sha, applied, failed }));
@@ -256,7 +260,7 @@ export async function upgradeCommand(positional, flags) {
256
260
  } else {
257
261
  note('note every consumer already runs this simt-dev');
258
262
  }
259
- return exitFor(plans, failed);
263
+ return code;
260
264
  }
261
265
 
262
266
  /**
@@ -11,8 +11,9 @@ import { join } from 'node:path';
11
11
  import { promisify } from 'node:util';
12
12
 
13
13
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
- import { CLI, MANIFEST_FILE } from '../name.mjs';
14
+ import { CLI } from '../name.mjs';
15
15
  import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
16
+ import { loadManifest } from '../manifest/schema.mjs';
16
17
  import { decisionsDir } from '../decisions/dir.mjs';
17
18
  import {
18
19
  decisionHits,
@@ -24,6 +25,7 @@ import {
24
25
  } from '../index/govern.mjs';
25
26
  import { splitFrontmatter } from '../memory/decide.mjs';
26
27
  import { listPromotedLessons } from '../memory/lessons.mjs';
28
+ import { commandRoot } from '../repo-root.mjs';
27
29
 
28
30
  const exec = promisify(execFile);
29
31
 
@@ -31,7 +33,7 @@ const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt/profiles'];
31
33
 
32
34
  export async function whyCommand(args, flags, commands = []) {
33
35
  if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
34
- const root = flags.root ?? process.cwd();
36
+ const root = commandRoot(flags);
35
37
  const listed = await tracked(root);
36
38
  if (listed.error) return listed.error;
37
39
  const files = new Set(listed.files);
@@ -45,22 +47,22 @@ export async function whyCommand(args, flags, commands = []) {
45
47
  const items = sortHits([...hits, ...scopeHits(target, corpus)]);
46
48
  const payload = { ok: true, command: 'why', root, path: target, items };
47
49
  if (isJson(flags)) {
48
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
50
+ emitJson(payload);
49
51
  return EXIT.OK;
50
52
  }
51
53
  const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
52
- emit(lines.length > 0 ? lines.join('\n') : 'none', { maxBytes: flags.maxBytes ?? 0 });
54
+ emit(lines.length > 0 ? lines.join('\n') : 'none');
53
55
  return EXIT.OK;
54
56
  }
55
57
 
56
58
  function reportUnresolved(root, flags, unresolved) {
57
59
  const payload = { ok: true, command: 'why', root, unresolved };
58
60
  if (isJson(flags)) {
59
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
61
+ emitJson(payload);
60
62
  return EXIT.OK;
61
63
  }
62
64
  const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
63
- emit([`unresolved ${unresolved.length}`, ...lines].join('\n'), { maxBytes: flags.maxBytes ?? 0 });
65
+ emit([`unresolved ${unresolved.length}`, ...lines].join('\n'));
64
66
  return EXIT.OK;
65
67
  }
66
68
 
@@ -172,8 +174,7 @@ export async function lessonScopes(root) {
172
174
 
173
175
  async function gateCommands(root) {
174
176
  try {
175
- const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), 'utf8'));
176
- const commands = parsed?.gates?.commands;
177
+ const commands = (await loadManifest(root)).manifest?.gates?.commands;
177
178
  return commands && typeof commands === 'object' ? commands : {};
178
179
  } catch {
179
180
  return {};
@@ -21,7 +21,6 @@ import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
21
21
  import { manifestDrift, readManifest } from '../projection/manifest.mjs';
22
22
  import { loadRules, select } from '../projection/rules.mjs';
23
23
  import { resolvePacks } from '../packs.mjs';
24
- import { knownGates } from '../hooks/resolve.mjs';
25
24
  import { fingerprint } from '../resolve/fingerprint.mjs';
26
25
  import { readHooksState, wantsShim } from './hooksstate.mjs';
27
26
  import { CLI } from '../name.mjs';
@@ -421,4 +420,3 @@ export function summarise(rows, extra = {}) {
421
420
  };
422
421
  }
423
422
 
424
- export { knownGates };
@@ -0,0 +1,64 @@
1
+ // The per-run memo a command reads its repository through (review 0003 Q67).
2
+ //
3
+ // `check` did the same work twice in one run: `fingerprint(root)` at two sites, a
4
+ // manifest read that `manifestDrift` repeated, and a rules load that every consumer
5
+ // paid for again. Each fingerprint spawns `git ls-files` and walks the tree once per
6
+ // glob fact, so the second call was never cheap - it was invisible.
7
+ //
8
+ // The cache lives for one command, not for the process. `bench` writes manifests and
9
+ // rewrites trees mid-run; a process-lifetime memo would hand a benchmark the answer
10
+ // for a repository it had just changed, which is the one way a cache can be worse
11
+ // than no cache. A caller that wants a fresh answer makes a fresh context.
12
+ //
13
+ // A rejected computation is not cached: a transient failure (a git lock, a permission
14
+ // error) should not become the answer for the rest of the run, and every one of these
15
+ // readers is safe to retry.
16
+
17
+ import { fingerprint } from './resolve/fingerprint.mjs';
18
+ import { readManifest } from './projection/manifest.mjs';
19
+ import { loadRules, provenanceDigest } from './projection/rules.mjs';
20
+
21
+ /**
22
+ * One repository, read once per question.
23
+ *
24
+ * Each accessor is a promise, and two calls with the same key share it rather than
25
+ * racing: the first caller to ask starts the work, and everyone else awaits it.
26
+ *
27
+ * @param {string} root the repository this context answers for
28
+ */
29
+ export function repoContext(root) {
30
+ const cache = new Map();
31
+ const counts = { hits: 0, misses: 0 };
32
+
33
+ const once = (key, compute) => {
34
+ if (cache.has(key)) {
35
+ counts.hits += 1;
36
+ return cache.get(key);
37
+ }
38
+ counts.misses += 1;
39
+ const pending = Promise.resolve()
40
+ .then(compute)
41
+ .catch((error) => {
42
+ // Not cached, or one failed read would answer every later caller.
43
+ cache.delete(key);
44
+ throw error;
45
+ });
46
+ cache.set(key, pending);
47
+ return pending;
48
+ };
49
+
50
+ return {
51
+ root,
52
+ /** What the cache did, for a test or a measurement - never printed by a command. */
53
+ get stats() {
54
+ return { ...counts, entries: cache.size };
55
+ },
56
+ fingerprint: () => once('fingerprint', () => fingerprint(root)),
57
+ // The legacy shape, because that is what the commands print: `error` is the
58
+ // unreadable-file case and every caller already renders it. Memoising the loader's
59
+ // `problems` array here instead gave doctor `undefined` where it tests for null.
60
+ manifest: () => once('manifest', () => readManifest(root)),
61
+ rules: () => once('rules', () => loadRules(root)),
62
+ digest: () => once('digest', () => provenanceDigest()),
63
+ };
64
+ }
@@ -0,0 +1,46 @@
1
+ // The decisions index document, rendered from the records (review 0003 Q91).
2
+ //
3
+ // The file was 590 hand-written lines: a table of every ADR kept in step by whoever
4
+ // remembered. It agreed with the records until it did not, and nothing read it, so
5
+ // nothing noticed - the failure the generated rule regions exist to prevent, one
6
+ // directory over. The table is output now: this module renders it, `simt rules
7
+ // decisions --yes` writes it, and test/decisions-index.test.mjs fails on drift.
8
+ //
9
+ // The narrative that trailed the table moved to docs/architecture/history.md: prose a
10
+ // reader consults is not prose a command rewrites.
11
+
12
+ const HEADER = `# Decisions
13
+
14
+ Architecture decision records for the SIMT development ecosystem. One canonical home per
15
+ decision; nothing here is duplicated into a repo's \`AGENTS.md\`.
16
+
17
+ A decision is superseded by writing a new ADR with a higher number and editing the old
18
+ record's status line — never by editing the ruling in place.
19
+
20
+ This table is generated from the records: \`simt rules decisions --yes\` writes it, and
21
+ \`test/decisions-index.test.mjs\` fails when it drifts. The history behind the numbers is
22
+ in [architecture/history.md](architecture/history.md).
23
+
24
+ | ADR | Decision | Status |
25
+ |---|---|---|
26
+ `;
27
+
28
+ /**
29
+ * The index document, from the records.
30
+ *
31
+ * Ordered by number, not by filename: the number IS the order, and a directory listing
32
+ * is not (`ADR-100` sorts before `ADR-99` as text).
33
+ *
34
+ * @param {{id: string, title: string, status: string, file: string}[]} rows
35
+ * @returns {string}
36
+ */
37
+ export function renderDecisionsIndex(rows) {
38
+ const ordered = [...rows].sort((a, b) => number(a.id) - number(b.id));
39
+ const lines = ordered.map((row) => `| [${number(row.id)}](decisions/${row.file}) | ${row.title} | ${row.status} |`);
40
+ return `${HEADER}${lines.join('\n')}\n`;
41
+ }
42
+
43
+ /** `ADR-007` -> `007`, the number the table and the ordering both use. */
44
+ function number(id) {
45
+ return id.replace(/^ADR-/i, '').padStart(3, '0');
46
+ }
package/src/diff.mjs CHANGED
@@ -49,7 +49,7 @@ export async function readBefore(path) {
49
49
  export function emitDiffs(pairs, flags) {
50
50
  if (isJson(flags)) return;
51
51
  const text = renderDiffs(pairs);
52
- if (text !== '') emit(text, { maxBytes: flags.maxBytes ?? 0 });
52
+ if (text !== '') emit(text);
53
53
  }
54
54
 
55
55
  /** The `--diff` bytes for a JSON payload: absent entirely without the flag, so
@@ -35,6 +35,7 @@ import {
35
35
 
36
36
  export const DEFAULT_BUDGET = 2048;
37
37
  import { LOCAL_DIR } from '../state.mjs';
38
+ import { commandRoot } from '../repo-root.mjs';
38
39
 
39
40
  const DISTILL_DIR = join(LOCAL_DIR, 'distilled');
40
41
 
@@ -175,7 +176,7 @@ export async function distillTarget(args, flags) {
175
176
  );
176
177
  }
177
178
 
178
- const root = resolve(flags.root ?? process.cwd());
179
+ const root = resolve(commandRoot(flags));
179
180
  const source = await resolveTarget(target, root, flags);
180
181
  if (source.exit !== undefined) return source.exit;
181
182
 
@@ -192,6 +193,16 @@ export async function distillTarget(args, flags) {
192
193
  const cached = flags.refresh === true ? null : await readFile(absProposal, 'utf8').catch(() => null);
193
194
  if (cached !== null) {
194
195
  const fields = parseExistingFrontmatter(cached);
196
+ let faults = [];
197
+ if (typeof fields.faults === 'string') {
198
+ try {
199
+ const parsedFaults = JSON.parse(fields.faults);
200
+ if (Array.isArray(parsedFaults)) faults = parsedFaults.map(String);
201
+ } catch {
202
+ // A malformed faults line is not a reason to refuse the proposal; the proposal
203
+ // still says it is unverified, and `--refresh` re-derives the list.
204
+ }
205
+ }
195
206
  return {
196
207
  ok: true,
197
208
  command: 'distill',
@@ -201,6 +212,7 @@ export async function distillTarget(args, flags) {
201
212
  budget: Number(fields.budget ?? budget),
202
213
  citations: countCitations(cached),
203
214
  verified: fields.verified !== 'false',
215
+ faults,
204
216
  metric: { note: METRIC_NOTE },
205
217
  proposal: { path: relProposal, state: 'cached', bytes: Buffer.byteLength(cached, 'utf8') },
206
218
  };
@@ -227,6 +239,7 @@ export async function distillTarget(args, flags) {
227
239
  engine: provenance,
228
240
  budget,
229
241
  verified: faults.length === 0,
242
+ faults,
230
243
  }) + `\n${outcome.body}\n`;
231
244
 
232
245
  await mkdir(join(root, DISTILL_DIR), { recursive: true });
@@ -167,7 +167,7 @@ export function verifyCitations(body, sourceLineCount) {
167
167
  }
168
168
 
169
169
  /** The proposal file's frontmatter. No wall-clock value here: byte-stable (P4). */
170
- export function proposalFrontmatter({ source, digest, engine, budget, prompt, verified, verdict, advisory }) {
170
+ export function proposalFrontmatter({ source, digest, engine, budget, prompt, verified, verdict, advisory, faults = [] }) {
171
171
  return [
172
172
  '---',
173
173
  `source: ${source}`,
@@ -178,6 +178,10 @@ export function proposalFrontmatter({ source, digest, engine, budget, prompt, ve
178
178
  verdict ? `verdict: ${verdict}` : null,
179
179
  advisory ? 'advisory: true' : null,
180
180
  verified === false ? 'verified: false' : 'verified: true',
181
+ // The faults belong to the proposal, not only to the report that wrote it: a cached
182
+ // proposal re-read without them reported `verified:false` and nothing to fix
183
+ // (review 0003 Q52).
184
+ Array.isArray(faults) && faults.length > 0 ? `faults: ${JSON.stringify(faults)}` : null,
181
185
  'status: proposal',
182
186
  '---',
183
187
  '',
@@ -117,7 +117,9 @@ async function defaultRegistry(flags) {
117
117
  * failure to resolve is a WARNING, not 38 `ENOENT` lines (ADR-014).
118
118
  *
119
119
  * Memoised per the inputs that decide the answer: the `--workspace` flag, the
120
- * `SIMT_DEV_WORKSPACE` environment and the user-config location. A single
120
+ * `SIMT_DEV_WORKSPACE` environment, the user-config location, and the current
121
+ * directory, whose manifest declares the workspace the variable may point into
122
+ * (ADR-119). A single
121
123
  * first-call-wins promise served one caller's workspace to every later caller with
122
124
  * different flags (Q14) — `skills_index({workspace: ws})` followed by
123
125
  * `skills_index({})` answered `ws` twice. Callers with the same inputs still share
@@ -129,6 +131,7 @@ export function workspace(flags = {}) {
129
131
  flags?.workspace ?? null,
130
132
  process.env.SIMT_DEV_WORKSPACE ?? null,
131
133
  process.env.XDG_CONFIG_HOME ?? null,
134
+ process.cwd(),
132
135
  ]);
133
136
  if (!workspacePromises.has(key)) workspacePromises.set(key, resolveWorkspace(flags));
134
137
  return workspacePromises.get(key);
@@ -21,7 +21,8 @@
21
21
  import { constants as fsConstants } from 'node:fs';
22
22
  import { access, readdir, readFile, stat } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
- import { ECOSYSTEMS, manifestPath } from '../projection/manifest.mjs';
24
+ import { ECOSYSTEMS } from '../projection/manifest.mjs';
25
+ import { loadManifest } from '../manifest/schema.mjs';
25
26
  import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
26
27
 
27
28
  /** Binaries the shared dispatcher shells out to directly, rather than via a script. */
@@ -170,8 +171,7 @@ export async function readCapability(root) {
170
171
  // so the only evidence that can prove it is the declaration itself. A repo with no
171
172
  // manifest simply has no declared commands.
172
173
  try {
173
- const manifest = JSON.parse(await readFile(await manifestPath(root), 'utf8'));
174
- capability.commands = declaredCommands(manifest);
174
+ capability.commands = declaredCommands((await loadManifest(root)).manifest);
175
175
  } catch {
176
176
  capability.commands = {};
177
177
  }