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
@@ -22,15 +22,18 @@
22
22
  // tool call. Failures of this command exit 1, which a harness reports as a failed hook,
23
23
  // and that is the right blast radius for a hook that is broken rather than refusing.
24
24
 
25
+ import { existsSync } from 'node:fs';
25
26
  import { mkdir, readFile } from 'node:fs/promises';
26
- import { dirname, join } from 'node:path';
27
+ import { dirname, isAbsolute, join } from 'node:path';
27
28
  import { commitJournal, journaledWriteFile } from '../journal.mjs';
29
+ import { AGENT_HOOK_SHIM, renderAgentHookShim } from '../agenthooks/shim.mjs';
28
30
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
29
31
 
30
32
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
31
- import { CLI } from '../name.mjs';
32
- import { ALLOW_ENV, EVENTS, POLICY_IDS } from '../agenthooks/policy.mjs';
33
+ import { CLI, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
34
+ import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV } from '../agenthooks/policy.mjs';
33
35
  import { decide, factsFor } from '../agenthooks/decide.mjs';
36
+ import { repoRoot } from '../repo-root.mjs';
34
37
  import {
35
38
  adapter,
36
39
  adapters,
@@ -97,10 +100,33 @@ export async function agentHookCommand(args, flags) {
97
100
  // `--root` outranks the payload: a packaged hook runs from the plugin directory and the
98
101
  // harness's environment variable names the project, so a payload cwd that is the plugin's
99
102
  // own working directory must not win. The repository-install path passes no flag, and the
100
- // payload's cwd is what the harness reports.
101
- const { root, command, writes } = factsFrom(row, payload, { root: flags.root });
102
- const facts = await factsFor(root, { command, writes });
103
- const decision = decide({ event, facts, env: process.env });
103
+ // payload's cwd is what the harness reports. The answer is then resolved to the
104
+ // repository's top level: a harness reports the directory it happens to be in, and an
105
+ // agent works from subdirectories, so every repository-relative fact (the lease file,
106
+ // `.gitmodules`) must resolve against the root git itself uses, not the cwd (review
107
+ // 0003 Q16).
108
+ const { root: payloadRoot, command, writes, session: payloadSession } = factsFrom(row, payload, { root: flags.root });
109
+ const root = repoRoot(payloadRoot);
110
+ // A relative write path is relative to the directory the harness is in, not to the
111
+ // repository root the facts resolve against: an agent editing `keep.mjs` from `src/`
112
+ // means `src/keep.mjs`, and a path joined to the wrong base is a path the lease file
113
+ // will never meet (review 0003 Q16).
114
+ // Writes are `{path, ...edit}` rows (Q17): the path resolves against the harness
115
+ // cwd, the edit detail travels untouched so the region check sees the old_string.
116
+ const writesAbsolute = writes.map((w) => {
117
+ const path = typeof w === 'string' ? w : w?.path;
118
+ const abs = typeof path === 'string' && isAbsolute(path) ? path : join(payloadRoot, path);
119
+ return typeof w === 'string' ? abs : { ...w, path: abs };
120
+ });
121
+ const facts = await factsFor(root, { command, writes: writesAbsolute });
122
+ // Harness hook processes rarely carry SIMT_SESSION, so the payload's own
123
+ // session id is the fallback: without it an agent is blocked by its own
124
+ // lease (review 0003 Q20). The environment still wins when it names one.
125
+ const session =
126
+ typeof process.env[SESSION_ENV] === 'string' && process.env[SESSION_ENV] !== ''
127
+ ? process.env[SESSION_ENV]
128
+ : (payloadSession ?? '');
129
+ const decision = decide({ event, facts, env: { ...process.env, [SESSION_ENV]: session } });
104
130
 
105
131
  // Both notes are about a conclusion the author is about to draw: a lift that did
106
132
  // nothing because of a typo, and a lift that is the reason the command they expected to
@@ -115,7 +141,11 @@ export async function agentHookCommand(args, flags) {
115
141
  const json = decisionJson(row, decision);
116
142
  if (json === null) return EXIT.OK;
117
143
 
118
- if (root) {
144
+ // A denial is recorded only for a repository that adopted the tool: hook
145
+ // denials in a never-adopted repo are noise about someone else's policy, and
146
+ // that is how an unadopted checkout accumulated 23 candidates nobody would
147
+ // ever promote (review 0003 Q36). The manifest is the adoption mark.
148
+ if (root && (existsSync(join(root, MANIFEST_FILE)) || existsSync(join(root, MANIFEST_LEGACY)))) {
119
149
  try {
120
150
  const { recordHookDenial } = await import('../memory/lessons.mjs');
121
151
  await recordHookDenial(root, {
@@ -143,8 +173,24 @@ export async function agentHooksCommand(sub, args, flags) {
143
173
  return fail(`unknown agent-hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agent-hooks status`);
144
174
  }
145
175
 
176
+ // The label for a reader the run did not write, by the state that names why:
177
+ // `none` documents no hooks file, `other` is a reader this tool does not write,
178
+ // `skipped` was an adapter the caller did not ask for (ADR-107).
179
+ const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
180
+
146
181
  async function installHooks(root, flags) {
147
182
  const mode = writeMode(flags);
183
+ // `--harness` narrows the install to one reader (ADR-107). The id is validated
184
+ // against the same table the loop reads, so a typo fails before anything is
185
+ // planned; the remedy names the ids the way `agent-hook` does.
186
+ const selected = flags.harness;
187
+ if (selected !== undefined && adapter(selected) === null) {
188
+ return fail(
189
+ `unknown harness \`${selected}\``,
190
+ EXIT.USAGE,
191
+ `--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
192
+ );
193
+ }
148
194
  // The delivery is a second entry in the same config, and a repository asks for it by
149
195
  // declaring a `brief` key rather than by having the entry. Read through a dynamic
150
196
  // import for the same reason the hook delivers through one.
@@ -155,8 +201,9 @@ async function installHooks(root, flags) {
155
201
  listPromotedLessons(root).catch(() => []),
156
202
  ]);
157
203
  const hasLessons = promotedLessons.length > 0;
204
+ const rows = selected === undefined ? adapters() : adapters().filter((r) => r.id === selected);
158
205
  const plan = [];
159
- for (const row of adapters()) {
206
+ for (const row of rows) {
160
207
  const path = hookPath(row.id);
161
208
  const existing = await readIfExists(join(root, path));
162
209
  const result = rendered(row, existing, { prompt: optIn.optedIn, post: hasLessons });
@@ -178,15 +225,28 @@ async function installHooks(root, flags) {
178
225
 
179
226
  // The readers this tool does not write an entry for, reported rather than skipped in
180
227
  // silence: a command that writes four files and says nothing about the fifth harness
181
- // in the repository reads as "that one is covered too".
228
+ // in the repository reads as "that one is covered too". A narrowed install adds a
229
+ // third state: an adapter that is this tool's but was not requested (ADR-107).
182
230
  const others = [];
183
231
  for (const r of hookRows()) {
184
232
  if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
185
233
  else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
234
+ else if (selected !== undefined && r.id !== selected) {
235
+ others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
236
+ }
186
237
  }
187
238
 
188
239
  const refused = plan.filter((p) => p.error);
189
240
 
241
+ // The shim every harness config points at: without it the entries above name a
242
+ // binary no harness has on PATH (review 0003 Q21). Its remedy installs the
243
+ // dependency the same way the git shims' does — a devDependency where the
244
+ // repository has a package.json, the release binary where it does not.
245
+ const shimText = renderAgentHookShim({ npm: (await readIfExists(join(root, 'package.json'))) !== null });
246
+ const shimBefore = await readBefore(join(root, AGENT_HOOK_SHIM));
247
+ const shimAction = shimBefore === null ? 'create' : shimBefore === shimText ? 'unchanged' : 'update';
248
+ const shimRow = { id: 'shim', path: AGENT_HOOK_SHIM, action: shimAction };
249
+
190
250
  // The `--diff` pairs, read before anything is written so the before side is
191
251
  // true in preview and in apply alike (R50).
192
252
  const diffs =
@@ -194,7 +254,8 @@ async function installHooks(root, flags) {
194
254
  ? await Promise.all(
195
255
  plan
196
256
  .filter((p) => p.action !== 'unchanged')
197
- .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text })),
257
+ .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text }))
258
+ .concat(shimAction === 'unchanged' ? [] : [{ path: AGENT_HOOK_SHIM, before: shimBefore, after: shimText }]),
198
259
  )
199
260
  : [];
200
261
 
@@ -206,6 +267,7 @@ async function installHooks(root, flags) {
206
267
  root,
207
268
  dryRun: true,
208
269
  plan: plan.map(({ text, ...rest }) => rest),
270
+ shim: shimRow,
209
271
  notWritten: others,
210
272
  filesWritten: false,
211
273
  ...diffField(diffs, flags),
@@ -216,7 +278,8 @@ async function installHooks(root, flags) {
216
278
  [
217
279
  `repo ${root}`,
218
280
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
219
- ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
281
+ `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
282
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
220
283
  ...promptLine,
221
284
  '',
222
285
  refused.length > 0
@@ -245,6 +308,14 @@ async function installHooks(root, flags) {
245
308
  await journaledWriteFile(root, journal, join(root, p.path), p.text);
246
309
  written.push(p);
247
310
  }
311
+ // The shim is a file like the configs: unchanged installs leave it alone, and a
312
+ // changed one is journaled so `undo` removes or restores it with the configs.
313
+ let shimWritten = false;
314
+ if (shimAction !== 'unchanged') {
315
+ await mkdir(join(root, dirname(AGENT_HOOK_SHIM)), { recursive: true });
316
+ await journaledWriteFile(root, journal, join(root, AGENT_HOOK_SHIM), shimText, { mode: 0o755 });
317
+ shimWritten = true;
318
+ }
248
319
  await commitJournal(root, 'agent-hooks install', journal);
249
320
 
250
321
  const notes = [];
@@ -260,6 +331,7 @@ async function installHooks(root, flags) {
260
331
  command: 'agent-hooks install',
261
332
  root,
262
333
  plan: plan.map(({ text, ...rest }) => rest),
334
+ shim: { ...shimRow, written: shimWritten },
263
335
  notWritten: others,
264
336
  written: written.map((p) => p.path),
265
337
  filesWritten: true,
@@ -272,10 +344,11 @@ async function installHooks(root, flags) {
272
344
  [
273
345
  `repo ${root}`,
274
346
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
275
- ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
347
+ `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
348
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
276
349
  ...promptLine,
277
350
  '',
278
- `WROTE ${written.length} hooks file(s); each one runs ${CLI} agent-hook before the tool call`,
351
+ `WROTE ${written.length} hooks file(s)${shimWritten ? ' and the shim' : ''}; each one runs the ${AGENT_HOOK_SHIM} shim before the tool call`,
279
352
  ].join('\n'),
280
353
  { maxBytes: flags.maxBytes ?? 0 },
281
354
  );
@@ -290,10 +363,31 @@ async function installHooks(root, flags) {
290
363
  async function hooksStatus(root, flags) {
291
364
  const rows = [];
292
365
  const problems = [];
366
+ // The plugin file this tool owns whole is compared against what `install` would write
367
+ // right now, so status reads the same opt-in install reads: a file that matches the
368
+ // preview is present, anything else is drift an install will rewrite.
369
+ const { promptOptIn } = await import('../agenthooks/prompt.mjs');
370
+ const optIn = await promptOptIn(root);
293
371
 
294
372
  for (const row of adapters()) {
295
373
  const path = hookPath(row.id);
296
374
  const existing = await readIfExists(join(root, path));
375
+ if (row.file) {
376
+ if (existing === null) {
377
+ rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
378
+ } else {
379
+ const present = rendered(row, existing, { prompt: optIn.optedIn }).action === 'unchanged';
380
+ rows.push({
381
+ id: row.id,
382
+ path,
383
+ state: present ? 'present' : 'drift',
384
+ entries: present ? 1 : 0,
385
+ prompt: row.prompt ? (present && optIn.optedIn ? 'present' : 'absent') : null,
386
+ note: present ? null : 'differs from what install would write; run install to restore it',
387
+ });
388
+ }
389
+ continue;
390
+ }
297
391
  if (existing === null) {
298
392
  rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
299
393
  continue;
@@ -341,7 +435,7 @@ async function hooksStatus(root, flags) {
341
435
 
342
436
  const lines = [`repo ${root}`];
343
437
  for (const r of rows) {
344
- const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
438
+ const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', drift: 'DRIFT ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
345
439
  lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no hooks file is documented)'}`);
346
440
  if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agent-hooks install --yes`);
347
441
  // The second event is a separate answer to a separate question: a harness can carry
@@ -217,6 +217,7 @@ export async function benchReportCommand(positional, flags) {
217
217
  harness: flags.harness,
218
218
  model: flags.model,
219
219
  compare: compareItems.length === 2 ? compareItems : null,
220
+ crossGroup: Boolean(flags.crossGroup),
220
221
  });
221
222
  } catch (error) {
222
223
  return fail(
@@ -405,7 +406,7 @@ export async function benchVariantsCommand(positional, flags) {
405
406
  // and until a bench here shows that, every report calls the critic advisory.
406
407
  export async function benchCriticCommand(positional, flags, commands = []) {
407
408
  const root = resolve(flags.root ?? positional[0] ?? process.cwd());
408
- const labelsPath = flags.labels ?? '.simt/critic-labels.jsonl';
409
+ const labelsPath = flags.labels ?? '.simt/local/critic-labels.jsonl';
409
410
  const result = await criticBench(root, { labelsPath, flags, commands });
410
411
  if (typeof result === 'number') return result;
411
412
 
@@ -24,7 +24,7 @@ import { basename, resolve } from 'node:path';
24
24
  import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
25
25
  import { provenanceDigest } from '../projection/project.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
- import { buildRow, summarise } from '../compare/row.mjs';
27
+ import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
28
28
  import { discover } from '../compare/discover.mjs';
29
29
  import { learnAcrossEstate, renderLearningLines } from '../compare/learn.mjs';
30
30
  import { targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
@@ -60,12 +60,13 @@ export async function compareCommand(positional, flags) {
60
60
  const expectedSha = await provenanceDigest();
61
61
  const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
62
62
 
63
- // Sequential on purpose. 16 fingerprint+region computations each spawn a git
64
- // process; running them concurrently produces a burst of subprocesses and no
65
- // measurable speedup, because the work is process-spawn bound rather than CPU
66
- // bound. Determinism of the output matters more than wall clock on a dashboard.
67
- const rows = [];
68
- for (const repo of found.repos) rows.push(await buildRow(repo, context));
63
+ // Parallel with a cap, in discovery order. 16 fingerprint+region computations
64
+ // each spawn a git process; unbounded parallelism is a burst of subprocesses
65
+ // with no speedup, and sequential builds are what took 48 s over MCP for 17
66
+ // repos (review 0003 Q38). The cap keeps the burst bounded while overlapping
67
+ // the spawn wait; the rows still come back in input order, so two runs diff
68
+ // cleanly and determinism of the output is untouched.
69
+ const rows = await buildRows(found.repos, context, { concurrency: resolveConcurrency(flags) });
69
70
  const learning = await learnAcrossEstate(found.repos);
70
71
 
71
72
  const summary = summarise(rows, {
@@ -2,10 +2,10 @@
2
2
  // a rulings skeleton, and structured `simt-affects` + `simt-supersedes` frontmatter
3
3
  // (plan 0003 R34).
4
4
 
5
- import { join, resolve } from 'node:path';
5
+ import { join } from 'node:path';
6
6
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
7
  import { CLI } from '../name.mjs';
8
- import { DECISIONS_DIR } from '../decisions/index.mjs';
8
+ import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
9
9
  import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
10
  import { commitJournal } from '../journal.mjs';
11
11
  import { diffField, emitDiffs } from '../diff.mjs';
@@ -15,9 +15,10 @@ export async function decideCommand(args, flags) {
15
15
  const root = flags.root ?? process.cwd();
16
16
 
17
17
  if (flags.check === true) {
18
- const decisionsDir = join(root, 'docs', 'decisions');
19
- const allowHistorical = resolve(decisionsDir) === DECISIONS_DIR;
20
- const res = await lintDecisions(decisionsDir, { allowHistorical });
18
+ // Q55: one resolution — the manifest key, then the detected directory —
19
+ // and the cutoff from the same config, never a hard-coded number or path.
20
+ const relDir = await decisionsDir(root);
21
+ const res = await lintDecisions(join(root, relDir), { historicalCutoff: await decisionsCutoff(root) });
21
22
 
22
23
  if (isJson(flags)) {
23
24
  emitJson({
@@ -9,7 +9,14 @@ import { readFile } from 'node:fs/promises';
9
9
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
10
10
  import { readManifest } from '../projection/manifest.mjs';
11
11
  import { CLI, MANIFEST_FILE } from '../name.mjs';
12
- import { claimsVerification, evidenceMode, readLedger, treeId } from '../ledger/index.mjs';
12
+ import {
13
+ claimsVerification,
14
+ evidenceMode,
15
+ hasTestEvidence,
16
+ indexTreeId,
17
+ readLedger,
18
+ treeId,
19
+ } from '../ledger/index.mjs';
13
20
 
14
21
  export async function evidenceCommand(args, flags) {
15
22
  const root = flags.root ?? process.cwd();
@@ -72,14 +79,17 @@ async function checkClaim(root, file, flags) {
72
79
  if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
73
80
  return EXIT.OK;
74
81
  }
82
+ // A commit records the index, not the worktree: `git add -p` can commit bytes
83
+ // the worktree tree never had (review 0003 Q18). Match on the staged tree, with
84
+ // the worktree tree as the fallback when git cannot name the index.
75
85
  let tree;
76
86
  try {
77
- tree = await treeId(root);
87
+ tree = (await indexTreeId(root)) ?? (await treeId(root));
78
88
  } catch (error) {
79
89
  return fail(`could not name this tree (${error.message})`, EXIT.UNSUPPORTED, 'run this inside a git repository');
80
90
  }
81
91
  const { entries } = await readLedger(root);
82
- const supported = entries.some((e) => e.tree === tree && e.exit === 0);
92
+ const supported = hasTestEvidence(entries, tree);
83
93
  const payload = { ok: supported || mode === 'warn', command: 'evidence check', claim: true, supported, mode, tree };
84
94
  if (supported) {
85
95
  if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
@@ -6,8 +6,9 @@
6
6
  // this tool knows how to read is compacted; the raw log is kept.
7
7
 
8
8
  import { execFile } from 'node:child_process';
9
+ import { existsSync } from 'node:fs';
9
10
  import { mkdir, writeFile } from 'node:fs/promises';
10
- import { join } from 'node:path';
11
+ import { dirname, join } from 'node:path';
11
12
  import { promisify } from 'node:util';
12
13
 
13
14
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
@@ -15,7 +16,8 @@ import { CLI } from '../name.mjs';
15
16
  import { detectTestRunner } from '../bench/mine.mjs';
16
17
  import { compactOutput } from '../index/compact.mjs';
17
18
  import { readImportEdges, selectTests } from '../index/gate.mjs';
18
- import { packageOf } from '../index/store.mjs';
19
+ import { langOf, packageOf } from '../index/store.mjs';
20
+ import { localRel } from '../state.mjs';
19
21
 
20
22
  const exec = promisify(execFile);
21
23
 
@@ -31,31 +33,81 @@ export async function gateCommand(args, flags) {
31
33
  }
32
34
  if (changed.length === 0) {
33
35
  if (flags.changed === true) {
36
+ // No change: nothing is unmatched and no language is implicated, so the
37
+ // graph is vacuously resolved and the verdict is still said out loud.
34
38
  if (isJson(flags)) {
35
- emitJson({ ok: true, command: 'gate', root, selected: [], code: 0 }, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
39
+ emitJson(
40
+ { ok: true, command: 'gate', root, selected: [], unmatched: [], graph: 'resolved', verdict: 'nothing-selected', code: 0 },
41
+ { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
42
+ );
36
43
  } else {
37
- emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
44
+ emit('selected 0\nverdict nothing-selected', { maxBytes: flags.maxBytes ?? 0 });
38
45
  }
39
46
  return EXIT.OK;
40
47
  }
41
48
  return fail(`nothing changed`, EXIT.USAGE, `pass a path, or ${CLI} gate --changed`);
42
49
  }
50
+ // Q40: an explicit path that is neither tracked nor on disk names nothing the
51
+ // gate could select from. That is a usage error, not an empty selection: the
52
+ // old answer (`ok: true`, no tests, exit 0) was indistinguishable from "this
53
+ // change is safe" and trained the reader to trust it.
54
+ const unknown = changed.filter((p) => !listed.files.includes(p) && !existsSync(join(root, p)));
55
+ if (unknown.length > 0) {
56
+ return fail(
57
+ `no such file: ${unknown.join(', ')}`,
58
+ EXIT.USAGE,
59
+ `pass a path git tracks, or ${CLI} gate --changed`,
60
+ );
61
+ }
43
62
  const files = new Set(listed.files);
44
63
  for (const file of changed) files.add(file);
45
- const selected = selectTests(changed, await readImportEdges(root, files));
64
+ const edges = await readImportEdges(root, files);
65
+ const selected = selectTests(changed, edges);
66
+ // Q40: the selection explains itself. `unmatched` names explicit paths the
67
+ // tracked tree does not carry (untracked but present, so the run proceeds);
68
+ // `graph` is `unresolved` when a language has edges but none of them reaches
69
+ // a tracked file (the Kotlin estate: thousands of specs, zero resolutions);
70
+ // `verdict` says the empty set out loud instead of leaving `selected: []` to
71
+ // be read as either safe or blind. `ok` stays as is until W27's opt-in.
72
+ const unmatched = changed.filter((p) => !listed.files.includes(p));
73
+ const graph = graphState(edges, files);
46
74
  if (selected.length === 0) {
47
- const payload = { ok: true, command: 'gate', root, selected, code: 0 };
75
+ const payload = { ok: true, command: 'gate', root, selected, unmatched, graph, verdict: 'nothing-selected', code: 0 };
48
76
  if (isJson(flags)) {
49
77
  emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
50
78
  return EXIT.OK;
51
79
  }
52
- emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
80
+ const lines = ['selected 0', 'verdict nothing-selected'];
81
+ if (unmatched.length > 0) lines.push(`unmatched ${unmatched.join(', ')}`);
82
+ if (graph === 'unresolved') lines.push('graph unresolved');
83
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
53
84
  return EXIT.OK;
54
85
  }
55
- return runSelected(root, flags, selected);
86
+ return runSelected(root, flags, selected, { unmatched, graph });
87
+ }
88
+
89
+ /**
90
+ * `resolved` unless some language present in the edges has total edges but
91
+ * zero reaching a tracked file. A language with no edges at all is not
92
+ * evidence of anything - an unimported file needs no resolution.
93
+ */
94
+ function graphState(edges, files) {
95
+ const byLang = new Map();
96
+ for (const edge of edges) {
97
+ const lang = langOf(edge.src);
98
+ if (!lang) continue;
99
+ const stat = byLang.get(lang) ?? { total: 0, resolved: 0 };
100
+ stat.total += 1;
101
+ if (files.has(edge.dst)) stat.resolved += 1;
102
+ byLang.set(lang, stat);
103
+ }
104
+ for (const stat of byLang.values()) {
105
+ if (stat.total > 0 && stat.resolved === 0) return 'unresolved';
106
+ }
107
+ return 'resolved';
56
108
  }
57
109
 
58
- async function runSelected(root, flags, selected) {
110
+ async function runSelected(root, flags, selected, { unmatched = [], graph = 'resolved' } = {}) {
59
111
  const cache = new Map();
60
112
  const groups = new Map();
61
113
  for (const file of selected) {
@@ -106,7 +158,18 @@ async function runSelected(root, flags, selected) {
106
158
  }
107
159
  const raw = await writeRaw(root, rawParts.join('\n'));
108
160
  if (raw) text.push('', `raw ${raw}`);
109
- const payload = { ok: code === 0, command: 'gate', root, selected, code, raw, runs };
161
+ const payload = {
162
+ ok: code === 0,
163
+ command: 'gate',
164
+ root,
165
+ selected,
166
+ unmatched,
167
+ graph,
168
+ verdict: 'selected',
169
+ code,
170
+ raw,
171
+ runs,
172
+ };
110
173
  if (isJson(flags)) {
111
174
  emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
112
175
  return code === 0 ? EXIT.OK : EXIT.FAIL;
@@ -116,12 +179,13 @@ async function runSelected(root, flags, selected) {
116
179
  }
117
180
 
118
181
  async function writeRaw(root, body) {
182
+ // Raw gate output is machine-local state (review 0003 Q19).
119
183
  try {
120
- const dir = join(root, '.simt', 'gate');
184
+ const rel = localRel('gate', `gate-${Date.now()}.log`);
185
+ const dir = join(root, dirname(rel));
121
186
  await mkdir(dir, { recursive: true });
122
- const name = `gate-${Date.now()}.log`;
123
- await writeFile(join(dir, name), body);
124
- return `.simt/gate/${name}`;
187
+ await writeFile(join(root, rel), body);
188
+ return rel;
125
189
  } catch {
126
190
  return '';
127
191
  }
@@ -129,8 +193,10 @@ async function writeRaw(root, body) {
129
193
 
130
194
  async function tracked(root) {
131
195
  try {
132
- const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
133
- return { files: stdout.split('\n').filter(Boolean) };
196
+ // `-z`: without it git C-quotes a non-ASCII path (`src/\303\274n...`) and
197
+ // the reader's real filename never matches it (review 0003 Q39).
198
+ const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
199
+ return { files: stdout.split('\0').filter(Boolean) };
134
200
  } catch (error) {
135
201
  return {
136
202
  error: fail(
@@ -144,9 +210,9 @@ async function tracked(root) {
144
210
 
145
211
  async function changedFiles(root) {
146
212
  try {
147
- const { stdout } = await exec('git', ['diff', '--name-only', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
148
- const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, maxBuffer: 8 << 20 });
149
- return { files: [...new Set([...stdout.split('\n'), ...untracked.stdout.split('\n')].filter(Boolean))].sort() };
213
+ const { stdout } = await exec('git', ['diff', '--name-only', '-z', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
214
+ const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 8 << 20 });
215
+ return { files: [...new Set([...stdout.split('\0'), ...untracked.stdout.split('\0')].filter(Boolean))].sort() };
150
216
  } catch (error) {
151
217
  return {
152
218
  error: fail(
@@ -31,16 +31,29 @@ export async function lintCommand(positional, flags) {
31
31
  // client accept these files", and the second question needs no registry at all. A
32
32
  // reader with no registry can still lint a skill directory, so the two are additive
33
33
  // rather than one gating the other.
34
- const specReport = registry && workspace?.root
35
- ? await lintSpec(registry, workspace.root)
36
- : { errors: [], warnings: [], checked: 0 };
34
+ //
35
+ // The pass runs only when asked for (`--spec`) and only against a verified
36
+ // registry: without it the default lint reported 37 warnings for a pass the
37
+ // payload claimed never ran (`spec: null`), and `ok` stayed true over errors
38
+ // the spec pass had just found (review 0003 Q35).
39
+ let specReport = { errors: [], warnings: [], checked: 0 };
40
+ if (spec !== null) {
41
+ if (registry && verified && workspace?.root) {
42
+ specReport = await lintSpec(registry, workspace.root);
43
+ } else {
44
+ warnings.push(
45
+ 'spec pass skipped: it needs --spec against a registry verified on disk (see `verified` above)',
46
+ );
47
+ }
48
+ }
37
49
  errors.push(...specReport.errors);
38
50
  warnings.push(...specReport.warnings);
51
+ const finalOk = ok && errors.length === 0;
39
52
 
40
53
  if (isJson(flags)) {
41
54
  emitJson(
42
55
  {
43
- ok,
56
+ ok: finalOk,
44
57
  registry: path,
45
58
  capabilities: registry?.capabilities?.length ?? 0,
46
59
  skills: registry?.skills?.length ?? 0,
@@ -60,7 +73,7 @@ export async function lintCommand(positional, flags) {
60
73
  },
61
74
  { maxBytes: flags.maxBytes ?? 0 },
62
75
  );
63
- return ok ? EXIT.OK : EXIT.REGISTRY;
76
+ return finalOk ? EXIT.OK : EXIT.REGISTRY;
64
77
  }
65
78
 
66
79
  if (!registry) {