simt-dev 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -13,7 +13,7 @@ import { promisify } from 'node:util';
13
13
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
14
  import { CLI } from '../name.mjs';
15
15
  import { isTestPath } from '../bench/mine.mjs';
16
- import { blobTexts, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
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
18
 
19
19
  const exec = promisify(execFile);
@@ -84,7 +84,10 @@ function summarise(root, flags, edges) {
84
84
 
85
85
  async function loadFiles(root) {
86
86
  let files = await readFiles(root);
87
- if (files.length === 0) {
87
+ // An empty index has nothing to answer from; a stale one has yesterday's
88
+ // tree (review 0003 Q42). Either way the refresh is incremental, so a
89
+ // current repository pays for one `git ls-files -s`, not a rebuild.
90
+ if (files.length === 0 || (await isIndexStale(root))) {
88
91
  try {
89
92
  await reindex(root);
90
93
  } catch (error) {
@@ -1,9 +1,10 @@
1
1
  // `simt undo [--yes] [--json]`
2
2
  //
3
3
  // Restore the last journaled set of writes (plan 0003 R49): every file the last
4
- // writing run changed goes back to its pre-run bytes, and files the run created
5
- // are deleted. A run that wrote nothing leaves no set, so there is nothing to
6
- // restore and the command says so.
4
+ // writing run changed goes back to its pre-run bytes, files the run created
5
+ // are deleted, and git config keys the run set go back to their pre-run
6
+ // values (review 0003 Q22). A run that wrote nothing leaves no set, so there
7
+ // is nothing to restore and the command says so.
7
8
  //
8
9
  // The safety is the journal's, not the flag's: when any file in the set no
9
10
  // longer matches the bytes the run left, the whole undo is refused — restoring
@@ -12,7 +13,7 @@
12
13
 
13
14
  import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
14
15
  import { CLI } from '../name.mjs';
15
- import { listJournalSets, readJournalSet, undoLastJournalSet } from '../journal.mjs';
16
+ import { beforeText, entryLabel, listJournalSets, readGitValue, readJournalSet, undoLastJournalSet } from '../journal.mjs';
16
17
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
17
18
  import { join } from 'node:path';
18
19
  import { invalidateCheckCache } from '../status.mjs';
@@ -32,30 +33,37 @@ export async function undoCommand(positional, flags) {
32
33
  }
33
34
 
34
35
  const set = await readJournalSet(root, id);
35
- const paths = set.entries.map((e) => e.path);
36
+ const labels = set.entries.map(entryLabel);
36
37
  // The `--diff` pairs: what the tree holds now against what the restore
37
- // would put back. A null beforeBytes is a file the run created, so the
38
- // restore deletes it — the after side is empty (R50).
38
+ // would put back. A null before is a file the run created (or a key it
39
+ // set from unset), so the restore deletes it — the after side is empty
40
+ // (R50).
39
41
  const diffs =
40
42
  flags.diff === true
41
43
  ? await Promise.all(
42
- set.entries.map(async (e) => ({
43
- path: e.path,
44
- before: await readBefore(join(root, e.path)),
45
- after: e.beforeBytes ?? null,
46
- })),
44
+ set.entries.map(async (e) => {
45
+ if (e.kind === 'git-config') {
46
+ return { path: entryLabel(e), before: await readGitValue(root, e.key), after: e.before ?? null };
47
+ }
48
+ return { path: e.path, before: await readBefore(join(root, e.path)), after: beforeText(e) };
49
+ }),
47
50
  )
48
51
  : [];
49
52
 
50
53
  if (!mode.apply) {
51
54
  if (isJson(flags)) {
52
- emitJson({ ok: true, command: 'undo', root, set: id, restores: paths, dryRun: true, ...diffField(diffs, flags) });
55
+ emitJson({ ok: true, command: 'undo', root, set: id, restores: labels, dryRun: true, ...diffField(diffs, flags) });
53
56
  return EXIT.OK;
54
57
  }
58
+ const files = set.entries.filter((e) => e.kind !== 'git-config').length;
59
+ const keys = set.entries.length - files;
55
60
  emit(
56
- [`last write ${set.command} (${id})`, ...paths.map((p) => `restore ${p}`), '', `would restore ${paths.length} file(s); apply with: ${CLI} undo --yes`].join(
57
- '\n',
58
- ),
61
+ [
62
+ `last write ${set.command} (${id})`,
63
+ ...labels.map((p) => `restore ${p}`),
64
+ '',
65
+ `would restore ${files} file(s)${keys > 0 ? ` and ${keys} git config key(s)` : ''}; apply with: ${CLI} undo --yes`,
66
+ ].join('\n'),
59
67
  { maxBytes: flags.maxBytes ?? 0 },
60
68
  );
61
69
  // After the report: the report says what, the diff shows it (R50).
@@ -21,6 +21,9 @@
21
21
  // 4. A repo it could not upgrade is reported loudly and fails the run. A repo left
22
22
  // silently behind is the failure `compare` exists to surface, and this command must
23
23
  // not be the thing that creates it.
24
+ // 5. With no root named, it upgrades THIS repository only. The workspace is a claim
25
+ // about scope, so it needs `--all` or an explicit root (review 0003 Q33): the old
26
+ // default resolved the workspace and a dry run inside one repo planned all sixteen.
24
27
 
25
28
  import { mkdir } from 'node:fs/promises';
26
29
  import { commitJournal, journaledWriteFile } from '../journal.mjs';
@@ -55,12 +58,41 @@ export async function upgradeCommand(positional, flags) {
55
58
  }
56
59
  }
57
60
 
58
- const target = await targetRoot(flags, positional);
61
+ // `--root`, a positional, and `--workspace` are all explicit claims about scope.
62
+ // Anything else is the enclosing repository, below.
63
+ const target = explicit || flags.workspace
64
+ ? await targetRoot(flags, positional)
65
+ : await (
66
+ // No root named: the scope is THIS repository, not the workspace. The old
67
+ // default resolved the workspace and swept every sibling - 16 repos planned
68
+ // from a dry run inside one of them. A workspace is a claim about scope, so
69
+ // it now needs `--all` or an explicit root, the way `--all` always did.
70
+ //
71
+ // The note fires only when the old default would have planned a different
72
+ // directory: a configured workspace that is not this repo. No workspace, or
73
+ // one equal to the cwd, changes nothing - and a note about an unchanged
74
+ // behaviour is noise.
75
+ (async () => {
76
+ const ws = await resolveWorkspace(flags);
77
+ if (ws.root && resolve(ws.root) !== process.cwd()) {
78
+ note(`note the default scope is this repository; the workspace at ${ws.root} is not upgraded. Pass --root ${ws.root} (or --all) to upgrade it`);
79
+ }
80
+ return { root: process.cwd(), source: 'default', detail: 'the enclosing repository', ws };
81
+ })()
82
+ );
59
83
  const root = resolve(target.root);
60
84
  const found = await discover(root);
61
85
  if (found.error) {
62
86
  return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
63
87
  }
88
+ if (found.repos.length === 0 && found.worktrees.length === 1) {
89
+ // A worktree passed directly is not counted as a repo by discover (its files are
90
+ // its parent's HEAD checked out elsewhere, so drift reporting would double-count
91
+ // it). Upgrading is different: the worktree carries the same rule content as its
92
+ // parent, so it is a valid single target. This is the shape the pre-push gate
93
+ // materializes - a detached worktree at HEAD that the suite runs inside.
94
+ found.repos = [found.worktrees[0].path];
95
+ }
64
96
  if (found.repos.length === 0) {
65
97
  return fail(
66
98
  `${root} contains no git repositories`,
@@ -13,6 +13,7 @@ import { promisify } from 'node:util';
13
13
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
14
  import { CLI, MANIFEST_FILE } from '../name.mjs';
15
15
  import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
16
+ import { decisionsDir } from '../decisions/dir.mjs';
16
17
  import {
17
18
  decisionHits,
18
19
  scopeHits,
@@ -67,8 +68,10 @@ function reportUnresolved(root, flags, unresolved) {
67
68
  // the critic's brief resolves governance the same way without a second copy.
68
69
  export async function tracked(root) {
69
70
  try {
70
- const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
71
- return { files: stdout.split('\n').filter(Boolean) };
71
+ // `-z`: without it git C-quotes a non-ASCII path and the reader's real
72
+ // filename never matches it (review 0003 Q39).
73
+ const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
74
+ return { files: stdout.split('\0').filter(Boolean) };
72
75
  } catch (error) {
73
76
  return {
74
77
  error: fail(
@@ -81,10 +84,13 @@ export async function tracked(root) {
81
84
  }
82
85
 
83
86
  export async function decisionRecords(root) {
84
- const dir = join(root, 'docs', 'decisions');
87
+ // Q55: the repository's own records, wherever it keeps them — not a hard-coded path.
88
+ const dir = join(root, await decisionsDir(root));
85
89
  let names = [];
86
90
  try {
87
- names = (await readdir(dir)).filter((name) => /^ADR-\d{3,}-.*\.md$/i.test(name)).sort();
91
+ names = (await readdir(dir))
92
+ .filter((name) => /^ADR-\d{3,}-.*\.md$/i.test(name))
93
+ .sort((a, b) => Number(/^ADR-(\d{3,})/i.exec(a)[1]) - Number(/^ADR-(\d{3,})/i.exec(b)[1]));
88
94
  } catch {
89
95
  return [];
90
96
  }
@@ -14,6 +14,25 @@ import { promisify } from 'node:util';
14
14
  import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
15
15
  import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
16
16
  import { packageVersion, hasNpm } from '../package-version.mjs';
17
+ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
18
+ import { fingerprint } from '../resolve/fingerprint.mjs';
19
+
20
+ /**
21
+ * The Node manager id for this repository, or null without a package.json.
22
+ * Lockfile first, `packageManager` next — the one resolver (review 0003 Q31).
23
+ */
24
+ async function nodeManagerFor(root) {
25
+ if (!(await hasNpm(root))) return null;
26
+ const detected = await fingerprint(root);
27
+ let packageManager = null;
28
+ try {
29
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
30
+ if (typeof pkg.packageManager === 'string') packageManager = pkg.packageManager;
31
+ } catch {
32
+ // Unreadable here leaves the npm default; the manifest path reports it.
33
+ }
34
+ return resolveNodeRunner({ facts: detected.facts, packageManager }).id;
35
+ }
17
36
 
18
37
  const exec = promisify(execFile);
19
38
 
@@ -149,7 +168,10 @@ async function describeShim(path, root) {
149
168
  const chain = await readChainedShim(path);
150
169
  stale = chain === null || content !== renderChainedShim(chain.event, chain.target);
151
170
  } else {
152
- stale = content !== renderShim(basename(path), { npm: await hasNpm(root), version: packageVersion() });
171
+ // Q31: the comparison renders with the repo's own manager, or a pnpm repo
172
+ // reads as stale the moment this tool ships — the same one-answer rule as
173
+ // the install path above.
174
+ stale = content !== renderShim(basename(path), { npm: await hasNpm(root), manager: await nodeManagerFor(root), version: packageVersion() });
153
175
  }
154
176
  } catch {
155
177
  stale = true;
@@ -13,17 +13,24 @@
13
13
  // every ADR draft are byte-identical (no wall-clock timestamps, fixed sorting and
14
14
  // rounding).
15
15
 
16
- import { readFile } from 'node:fs/promises';
16
+ import { createHash } from 'node:crypto';
17
17
  import { basename, join } from 'node:path';
18
+ import { mapCapped, DEFAULT_CONCURRENCY } from './parallel.mjs';
18
19
 
19
20
  import { policiesFor } from '../agenthooks/policy.mjs';
21
+ import { DEL_R_TOLERANCE, parseAblationArmName } from '../bench/ablate.mjs';
20
22
  import { compareArms, evaluateRunRecord, loadPrices, loadRunRecords } from '../bench/report.mjs';
21
23
  import { GATES } from '../hooks/gates.mjs';
22
24
  import { readLedger } from '../ledger/index.mjs';
23
25
  import { readLessonCandidates } from '../memory/lessons.mjs';
26
+ import { STATE_DIR } from '../name.mjs';
24
27
  import { readManifest } from '../projection/manifest.mjs';
28
+ import { localRel, readStateFile } from '../state.mjs';
25
29
 
26
- const DEL_R_TOLERANCE = 0.01;
30
+ const ABLATION_REL = localRel('bench', 'ablation.json');
31
+ // Where pre-Q19 checkouts keep the file: the writer's path before the move to
32
+ // machine-local state (review 0003 Q50).
33
+ const ABLATION_REL_LEGACY = join(STATE_DIR, 'bench', 'ablation.json');
27
34
 
28
35
  const POLICY_BY_ID = new Map(policiesFor('pre-tool-use').map((p) => [p.id, p]));
29
36
  const POLICY_BY_ADR = new Map(policiesFor('pre-tool-use').map((p) => [p.adr, p]));
@@ -78,7 +85,7 @@ export function classifyGateCommand(cmd, manifest = null) {
78
85
  for (const gate of GATE_ENTRIES) {
79
86
  if (gate.kind === 'script' && gate.script) {
80
87
  const scriptRe = new RegExp(`(?:^|\\s)npm\\s+run(?:\\s+--silent)?\\s+${gate.script}(?:\\s|$)`);
81
- if (scriptRe.test(raw) || (gate.script === 'test' && /\b(?:npm\s+test|node\s+--test|pytest|go\s+test)\b/.test(raw))) {
88
+ if (scriptRe.test(raw) || (gate.script === 'test' && /\b(?:npm\s+test|node\s+--test|pytest|go\s+test|cargo\s+test)\b/.test(raw))) {
82
89
  return { gate: gate.name, adr: gate.adr };
83
90
  }
84
91
  }
@@ -162,21 +169,17 @@ export function classifyRuleViolations(candidate) {
162
169
  return [...found.values()];
163
170
  }
164
171
 
165
- function parseAblatedSectionArm(arm) {
166
- const raw = String(arm ?? '');
167
- if (raw.startsWith('no-core-')) {
168
- const slug = raw.slice('no-core-'.length);
169
- return { sectionId: `core:${slug}`, arm: raw, kind: 'core', targetId: slug, affects: ['rules/core.md'] };
170
- }
171
- if (raw.startsWith('no-profile-')) {
172
- const id = raw.slice('no-profile-'.length);
173
- return { sectionId: `profile:${id}`, arm: raw, kind: 'profile', targetId: id, affects: [`rules/stacks/${id}.md`] };
174
- }
175
- if (raw.startsWith('no-process-')) {
176
- const id = raw.slice('no-process-'.length);
177
- return { sectionId: `process:${id}`, arm: raw, kind: 'process', targetId: id, affects: [`rules/mdc/${id}.mdc`] };
172
+ async function readExplicitNoValueSections(repoPath) {
173
+ const { text } = await readStateFile(repoPath, ABLATION_REL, [ABLATION_REL_LEGACY]).catch(() => ({
174
+ text: null,
175
+ }));
176
+ if (text === null) return [];
177
+ try {
178
+ const raw = JSON.parse(text);
179
+ return Array.isArray(raw?.sections) ? raw.sections : Array.isArray(raw) ? raw : [];
180
+ } catch {
181
+ return [];
178
182
  }
179
- return null;
180
183
  }
181
184
 
182
185
  async function collectRepoAblationRecords(repoPath) {
@@ -188,17 +191,6 @@ async function collectRepoAblationRecords(repoPath) {
188
191
  return records.map((rec) => evaluateRunRecord(rec, prices));
189
192
  }
190
193
 
191
- async function readExplicitNoValueSections(repoPath) {
192
- const file = join(repoPath, '.simt', 'ablation.json');
193
- try {
194
- const raw = JSON.parse(await readFile(file, 'utf8'));
195
- const list = Array.isArray(raw?.sections) ? raw.sections : Array.isArray(raw) ? raw : [];
196
- return list;
197
- } catch {
198
- return [];
199
- }
200
- }
201
-
202
194
  function formatAffectsList(affects) {
203
195
  const sorted = [...new Set(affects.map(String).filter(Boolean))].sort();
204
196
  return sorted.length > 0 ? `[${sorted.map((a) => JSON.stringify(a)).join(', ')}]` : '[]';
@@ -270,9 +262,55 @@ export function renderAdrDraft({
270
262
  * adrDrafts: Array<{id: string, category: string, target: string, title: string, affects: string[], supersedes: Array<{adr: string, ruling: number|null}>, markdown: string}>
271
263
  * }>}
272
264
  */
265
+ /**
266
+ * Digest-keyed memo of estate learning (review 0003 Q38).
267
+ *
268
+ * The bootstrap comparisons below are pure over the records on disk, so a
269
+ * second `compare` with unchanged inputs recomputes nothing. Keyed by digest,
270
+ * not by time, so a changed record can never read a stale answer. In-process
271
+ * only: the MCP server is long-lived and repeats the call per session, while a
272
+ * CLI run pays one computation anyway. Bounded, because estates differ.
273
+ */
274
+ const learningCache = new Map();
275
+ const LEARNING_CACHE_MAX = 8;
276
+
277
+ export function clearLearningCache() {
278
+ learningCache.clear();
279
+ }
280
+
273
281
  export async function learnAcrossEstate(repos) {
274
282
  const sortedRepos = [...(repos ?? [])].sort();
275
283
 
284
+ // Collection first, capped and parallel: one repo's five reads overlap the
285
+ // next repo's, and the aggregation below stays sequential over sorted input
286
+ // so the output cannot depend on completion order.
287
+ const collected = await mapCapped(
288
+ sortedRepos,
289
+ async (repoPath) => {
290
+ const [manifestRes, ledgerRes, lessonsRes, benchEvaluated, explicitAblation] = await Promise.all([
291
+ readManifest(repoPath).catch(() => ({ manifest: null })),
292
+ readLedger(repoPath).catch(() => ({ entries: [] })),
293
+ readLessonCandidates(repoPath).catch(() => ({ candidates: [] })),
294
+ collectRepoAblationRecords(repoPath),
295
+ readExplicitNoValueSections(repoPath),
296
+ ]);
297
+ return {
298
+ repoPath,
299
+ repoName: basename(repoPath),
300
+ manifest: manifestRes?.manifest ?? null,
301
+ ledgerEntries: ledgerRes?.entries ?? [],
302
+ candidates: lessonsRes?.candidates ?? [],
303
+ benchEvaluated,
304
+ explicitAblation,
305
+ };
306
+ },
307
+ { concurrency: DEFAULT_CONCURRENCY },
308
+ );
309
+
310
+ const digest = createHash('sha1').update(JSON.stringify(collected)).digest('hex');
311
+ const cached = learningCache.get(digest);
312
+ if (cached) return cached;
313
+
276
314
  const ruleViolations = new Map();
277
315
  const gateStats = new Map();
278
316
  const ablationByArm = new Map();
@@ -284,19 +322,7 @@ export async function learnAcrossEstate(repos) {
284
322
  let totalLedgerRuns = 0;
285
323
  let totalHookDenials = 0;
286
324
 
287
- for (const repoPath of sortedRepos) {
288
- const repoName = basename(repoPath);
289
- const [manifestRes, ledgerRes, lessonsRes, benchEvaluated, explicitAblation] = await Promise.all([
290
- readManifest(repoPath).catch(() => ({ manifest: null })),
291
- readLedger(repoPath).catch(() => ({ entries: [] })),
292
- readLessonCandidates(repoPath).catch(() => ({ candidates: [] })),
293
- collectRepoAblationRecords(repoPath),
294
- readExplicitNoValueSections(repoPath),
295
- ]);
296
-
297
- const manifest = manifestRes?.manifest ?? null;
298
- const ledgerEntries = ledgerRes?.entries ?? [];
299
- const candidates = lessonsRes?.candidates ?? [];
325
+ for (const { repoName, manifest, ledgerEntries, candidates, benchEvaluated, explicitAblation } of collected) {
300
326
 
301
327
  if (ledgerEntries.length > 0) reposWithLedgers += 1;
302
328
  totalLedgerRuns += ledgerEntries.length;
@@ -357,7 +383,7 @@ export async function learnAcrossEstate(repos) {
357
383
  const tagged = { ...row, task: `${repoName}:${row.task}` };
358
384
  if (row.arm === 'current') {
359
385
  currentRunsAll.push(tagged);
360
- } else if (parseAblatedSectionArm(row.arm)) {
386
+ } else if (parseAblationArmName(row.arm)) {
361
387
  const list = ablationByArm.get(row.arm) ?? [];
362
388
  list.push({ row: tagged, repoName });
363
389
  ablationByArm.set(row.arm, list);
@@ -415,7 +441,7 @@ export async function learnAcrossEstate(repos) {
415
441
  const noValueMap = new Map();
416
442
 
417
443
  for (const [arm, entries] of [...ablationByArm.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
418
- const parsed = parseAblatedSectionArm(arm);
444
+ const parsed = parseAblationArmName(arm);
419
445
  if (!parsed || currentRunsAll.length === 0) continue;
420
446
  const ablatedRuns = entries.map((e) => e.row);
421
447
  const cmp = compareArms(arm, ablatedRuns, 'current', currentRunsAll);
@@ -423,11 +449,11 @@ export async function learnAcrossEstate(repos) {
423
449
  const dR = round4(cmp.deltas.R);
424
450
  const dC = round4(cmp.deltas.C);
425
451
  if (dR <= DEL_R_TOLERANCE && dC > 0) {
426
- noValueMap.set(parsed.sectionId, {
427
- sectionId: parsed.sectionId,
428
- kind: parsed.kind,
429
- targetId: parsed.targetId,
430
- affects: parsed.affects,
452
+ noValueMap.set(parsed.ablate.sectionId, {
453
+ sectionId: parsed.ablate.sectionId,
454
+ kind: parsed.ablate.kind,
455
+ targetId: parsed.ablate.targetId,
456
+ affects: [parsed.ablate.sectionId],
431
457
  dR,
432
458
  dC,
433
459
  tasks: cmp.pairedTasks,
@@ -444,12 +470,6 @@ export async function learnAcrossEstate(repos) {
444
470
  const isCandidate = sec.deletionCandidate === true || (sec.measured !== false && dR <= DEL_R_TOLERANCE && dC > 0);
445
471
  if (!isCandidate) continue;
446
472
  const [kind = 'core', targetId = sectionId] = sectionId.split(':');
447
- const affects =
448
- kind === 'profile'
449
- ? [`rules/stacks/${targetId}.md`]
450
- : kind === 'process'
451
- ? [`rules/mdc/${targetId}.mdc`]
452
- : ['rules/core.md'];
453
473
  const existing = noValueMap.get(sectionId);
454
474
  if (existing) {
455
475
  existing.repos = [...new Set([...existing.repos, repoName])].sort();
@@ -458,7 +478,7 @@ export async function learnAcrossEstate(repos) {
458
478
  sectionId,
459
479
  kind,
460
480
  targetId,
461
- affects,
481
+ affects: [sectionId],
462
482
  dR,
463
483
  dC,
464
484
  tasks: Number(sec.tasks ?? 1),
@@ -584,7 +604,7 @@ export async function learnAcrossEstate(repos) {
584
604
 
585
605
  for (const s of noValueRules) {
586
606
  const rawEntry = noValueMap.get(s.sectionId);
587
- const affects = rawEntry?.affects ?? ['rules/core.md'];
607
+ const affects = rawEntry?.affects ?? [s.sectionId];
588
608
  const draftId = nextDraftId();
589
609
  const title = `Prune or demote zero-value section "${s.sectionId}" (dR=${s.dR}, dC=+$${s.dC})`;
590
610
  const context =
@@ -616,7 +636,7 @@ export async function learnAcrossEstate(repos) {
616
636
  });
617
637
  }
618
638
 
619
- return {
639
+ const result = {
620
640
  reposWithLedgers,
621
641
  reposWithDenials,
622
642
  totalLedgerRuns,
@@ -627,6 +647,11 @@ export async function learnAcrossEstate(repos) {
627
647
  noValueRules,
628
648
  adrDrafts,
629
649
  };
650
+ if (learningCache.size >= LEARNING_CACHE_MAX) {
651
+ learningCache.delete(learningCache.keys().next().value);
652
+ }
653
+ learningCache.set(digest, result);
654
+ return result;
630
655
  }
631
656
 
632
657
  /**
@@ -0,0 +1,50 @@
1
+ // Capped parallelism for estate-wide work (review 0003 Q38).
2
+ //
3
+ // Two callers need it - row building and estate learning - and a third copy of
4
+ // the same worker pool would be the defect ADR-002 names, so it lives here.
5
+ // Order-preserving: results land at their input index, because both callers
6
+ // diff their output between runs and completion order would scramble it.
7
+
8
+ /**
9
+ * How many estate items to work on at once.
10
+ *
11
+ * The work is process-spawn bound (git, file reads), so unbounded parallelism
12
+ * is a burst of subprocesses with no speedup, and sequential work is what took
13
+ * `compare` 48 s over MCP for 17 repos. Four overlaps the wait while keeping
14
+ * the burst bounded.
15
+ */
16
+ export const DEFAULT_CONCURRENCY = 4;
17
+
18
+ export function resolveConcurrency(flags = {}) {
19
+ const raw = flags?.concurrency ?? process.env.SIMT_CONCURRENCY ?? DEFAULT_CONCURRENCY;
20
+ const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
21
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_CONCURRENCY;
22
+ }
23
+
24
+ /**
25
+ * Map `items` through async `fn` with at most `concurrency` in flight.
26
+ *
27
+ * @param {unknown[]} items
28
+ * @param {(item: unknown, index: number) => Promise<unknown>} fn
29
+ * @param {{concurrency?: number, signal?: AbortSignal|null, onItem?: ((done: number, total: number) => void)|null}} options
30
+ * @returns {Promise<unknown[]>} results in input order
31
+ * @throws the first worker failure, or a CANCELLED error when `signal` aborts
32
+ */
33
+ export async function mapCapped(items, fn, { concurrency = DEFAULT_CONCURRENCY, signal = null, onItem = null } = {}) {
34
+ const list = [...(items ?? [])];
35
+ const out = new Array(list.length);
36
+ const cap = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : DEFAULT_CONCURRENCY;
37
+ let next = 0;
38
+ let done = 0;
39
+ async function worker() {
40
+ while (next < list.length) {
41
+ if (signal?.aborted) throw Object.assign(new Error('cancelled by client'), { code: 'CANCELLED' });
42
+ const i = next++;
43
+ out[i] = await fn(list[i], i);
44
+ done += 1;
45
+ if (onItem) onItem(done, list.length);
46
+ }
47
+ }
48
+ await Promise.all(Array.from({ length: Math.min(cap, list.length) }, () => worker()));
49
+ return out;
50
+ }
@@ -16,6 +16,7 @@
16
16
  // fact per line in a stable field order so two runs diff cleanly; a row that
17
17
  // reordered its columns would make every comparison between two runs unreadable.
18
18
 
19
+ import { mapCapped, DEFAULT_CONCURRENCY, resolveConcurrency } from './parallel.mjs';
19
20
  import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
20
21
  import { manifestDrift, readManifest } from '../projection/manifest.mjs';
21
22
  import { loadRules, select } from '../projection/rules.mjs';
@@ -74,7 +75,8 @@ function packageEntry(f) {
74
75
  return { path: f.file, profiles: f.profiles ?? [], status: f.status, detail: f.detail ?? null };
75
76
  }
76
77
 
77
- export async function buildRow(path, context) { const name = path.split('/').filter(Boolean).pop() ?? path;
78
+ export async function buildRow(path, context) {
79
+ const name = path.split('/').filter(Boolean).pop() ?? path;
78
80
  const problems = [];
79
81
  const notes = [];
80
82
 
@@ -354,6 +356,29 @@ export async function buildRow(path, context) { const name = path.split('/').fi
354
356
  return row;
355
357
  }
356
358
 
359
+ export { DEFAULT_CONCURRENCY, resolveConcurrency } from './parallel.mjs';
360
+
361
+ /**
362
+ * Every row, in input order, built with at most `concurrency` in flight.
363
+ *
364
+ * A thrown row is impossible - `buildRow` never rejects - so the first error
365
+ * aborts, because a pool that swallows the one failure mode its worker cannot
366
+ * produce is decoration. `onBatch(done, total)` fires after each row for MCP
367
+ * `notifications/progress`; `signal` aborts between rows for
368
+ * `notifications/cancelled`.
369
+ */
370
+ export async function buildRows(
371
+ paths,
372
+ context,
373
+ { concurrency = DEFAULT_CONCURRENCY, signal = null, onBatch = null } = {},
374
+ ) {
375
+ return mapCapped(paths, (p) => buildRow(p, context), {
376
+ concurrency,
377
+ signal,
378
+ onItem: onBatch,
379
+ });
380
+ }
381
+
357
382
  function declaredGates(manifest) {
358
383
  if (!manifest) return [];
359
384
  return [...(manifest.gates?.preCommit ?? []), ...(manifest.gates?.prePush ?? [])];
@@ -125,6 +125,11 @@ function renderZsh({ top, groups }) {
125
125
  '# source this after compinit: `source <(simt completions zsh)`.',
126
126
  '_simt() {',
127
127
  ' local cur prev',
128
+ // compsys sets `words` and `CURRENT` before the function runs; without the
129
+ // assignment `cur` and `prev` stay empty, so the flag and value-flag cases
130
+ // below never match and zsh completes only positionals (review 0003 Q51).
131
+ ' cur=${words[CURRENT]}',
132
+ ' prev=${words[CURRENT-1]}',
128
133
  ' local -a top flags subs',
129
134
  ` top=( ${top.join(' ')} )`,
130
135
  ` flags=( ${FLAGS.join(' ')} )`,
@@ -0,0 +1,84 @@
1
+ // The one answer to "where are this repository's decision records" (review 0003 Q55).
2
+ //
3
+ // Before this, every reader hard-coded `docs/decisions` while detection already
4
+ // knew four spellings (`docs/decisions`, `docs/adr`, `docs/adrs`, `adr`), so a
5
+ // repository that kept its records under `docs/adr/` was fingerprinted correctly
6
+ // and then unread by every command. One resolution, every reader.
7
+ //
8
+ // Precedence is declaration first. A manifest `decisions.dir` names the directory
9
+ // relative to the repository root; without one, the first existing candidate
10
+ // wins; without any, the default is `docs/decisions`. The cutoff below answers
11
+ // the companion question — which records predate structured frontmatter — and it
12
+ // lives in the same config (`decisions.historicalCutoff`), never in code.
13
+
14
+ import { stat } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+
17
+ import { readManifest } from '../projection/manifest.mjs';
18
+
19
+ /** Directory spellings a repository's decision records live under, in priority order. */
20
+ const DECISION_DIR_CANDIDATES = ['docs/decisions', 'docs/adr', 'docs/adrs', 'adr'];
21
+
22
+ /** The default when nothing is declared and nothing exists yet. */
23
+ const DEFAULT_DECISIONS_DIR = 'docs/decisions';
24
+
25
+ /**
26
+ * The repository's own `decisions` manifest key, or null.
27
+ *
28
+ * Read with the manifest loader (so the legacy name resolves the same way for
29
+ * this as for every other reader) and returned as-is: validation belongs to
30
+ * the caller that knows what it needs.
31
+ */
32
+ async function manifestDecisions(root) {
33
+ try {
34
+ const { manifest } = await readManifest(root);
35
+ const decisions = manifest?.decisions;
36
+ return decisions !== null && typeof decisions === 'object' && !Array.isArray(decisions) ? decisions : null;
37
+ } catch {
38
+ // An unreadable manifest is not evidence either way; detection decides.
39
+ return null;
40
+ }
41
+ }
42
+
43
+ async function isDir(path) {
44
+ try {
45
+ return (await stat(path)).isDirectory();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The relative decisions directory for this repository.
53
+ *
54
+ * @param {string} root repository root
55
+ * @returns {Promise<string>} e.g. `docs/decisions`
56
+ */
57
+ export async function decisionsDir(root) {
58
+ const declared = await manifestDecisions(root);
59
+ if (typeof declared?.dir === 'string' && declared.dir.trim() !== '') {
60
+ return declared.dir.trim().replace(/\\/g, '/').replace(/\/+$/, '');
61
+ }
62
+ for (const candidate of DECISION_DIR_CANDIDATES) {
63
+ if (await isDir(join(root, candidate))) return candidate;
64
+ }
65
+ return DEFAULT_DECISIONS_DIR;
66
+ }
67
+
68
+ /**
69
+ * How many leading records predate structured frontmatter and are exempt from it.
70
+ *
71
+ * The package's own history is the only corpus with such records, and even it
72
+ * declares the number in its own manifest rather than in code: a number in code
73
+ * is a second answer the next record silently moves. Any other repository gets
74
+ * zero unless it declares otherwise.
75
+ *
76
+ * @param {string} root repository root
77
+ * @returns {Promise<number>}
78
+ */
79
+ export async function decisionsCutoff(root) {
80
+ const declared = await manifestDecisions(root);
81
+ const value = declared?.historicalCutoff;
82
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value);
83
+ return 0;
84
+ }