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
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { basename, resolve } from 'node:path';
23
23
 
24
- import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
24
+ import { EXIT, fail, note, present } from '../output/index.mjs';
25
25
  import { provenanceDigest } from '../projection/project.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
27
  import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
@@ -78,50 +78,50 @@ export async function compareCommand(positional, flags) {
78
78
  directories: found.directories.length,
79
79
  });
80
80
 
81
- if (isJson(flags)) {
82
- emitJson({
83
- ok: !rows.some((r) => r.problems.length > 0),
84
- command: 'compare',
85
- root: summary.root,
86
- shape: summary.shape,
87
- source: summary.source,
88
- expectedSha: summary.expectedSha,
89
- // Spread, not a hand-listed copy. A copy is a second place to forget: a field
90
- // added to `summarise` renders in the text output and silently vanishes from
91
- // `--json`, which is the combination automation trusts most. That is not
92
- // hypothetical — `withOwnHooks` and `staleRuleFiles` both did it, and both
93
- // were caught only because a test asserts the two shapes agree.
94
- summary: {
95
- repos: summary.repos,
96
- adopted: summary.adopted,
97
- unmanaged: summary.unmanaged,
98
- broken: summary.broken,
99
- drifted: summary.drifted,
100
- current: summary.current,
101
- noRegion: summary.noRegion,
102
- malformed: summary.malformed,
103
- staleRuleFiles: summary.staleRuleFiles,
104
- packageRegions: summary.packageRegions,
105
- stalePackages: summary.stalePackages,
106
- hooksRouted: summary.hooksRouted,
107
- withProblems: summary.withProblems,
108
- withOwnHooks: summary.withOwnHooks,
109
- shas: summary.shas,
110
- },
111
- rows,
112
- learning,
113
- worktrees: found.worktrees.map((w) => ({
114
- name: w.name,
115
- path: w.path,
116
- owner: w.ownerName,
117
- ownerMatchesDirName: w.ownerMatches,
118
- })),
119
- directories: found.directories.map((d) => basename(d)),
120
- });
81
+ const payload = {
82
+ ok: !rows.some((r) => r.problems.length > 0),
83
+ command: 'compare',
84
+ root: summary.root,
85
+ shape: summary.shape,
86
+ source: summary.source,
87
+ expectedSha: summary.expectedSha,
88
+ // Spread, not a hand-listed copy. A copy is a second place to forget: a field
89
+ // added to `summarise` renders in the text output and silently vanishes from
90
+ // `--json`, which is the combination automation trusts most. That is not
91
+ // hypothetical — `withOwnHooks` and `staleRuleFiles` both did it, and both
92
+ // were caught only because a test asserts the two shapes agree.
93
+ summary: {
94
+ repos: summary.repos,
95
+ adopted: summary.adopted,
96
+ unmanaged: summary.unmanaged,
97
+ broken: summary.broken,
98
+ drifted: summary.drifted,
99
+ current: summary.current,
100
+ noRegion: summary.noRegion,
101
+ malformed: summary.malformed,
102
+ staleRuleFiles: summary.staleRuleFiles,
103
+ packageRegions: summary.packageRegions,
104
+ stalePackages: summary.stalePackages,
105
+ hooksRouted: summary.hooksRouted,
106
+ withProblems: summary.withProblems,
107
+ withOwnHooks: summary.withOwnHooks,
108
+ shas: summary.shas,
109
+ },
110
+ rows,
111
+ learning,
112
+ worktrees: found.worktrees.map((w) => ({
113
+ name: w.name,
114
+ path: w.path,
115
+ owner: w.ownerName,
116
+ ownerMatchesDirName: w.ownerMatches,
117
+ })),
118
+ directories: found.directories.map((d) => basename(d)),
119
+ };
120
+
121
+ if (present(payload, () => render(rows, summary, found, learning), flags) === 'json') {
121
122
  return rows.some((r) => r.problems.length > 0) ? EXIT.FAIL : EXIT.OK;
122
123
  }
123
124
 
124
- emit(render(rows, summary, found, learning), { maxBytes: flags.maxBytes ?? 0 });
125
125
  if (summary.shape === 'repo') {
126
126
  note('note one repository compared; pass --root at a workspace directory to compare its siblings');
127
127
  } else if (target.source === 'none') {
@@ -5,7 +5,7 @@
5
5
  // `$fpath`. A report line above it would be a report line in the user's shell
6
6
  // config.
7
7
 
8
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
+ import { EXIT, fail, present } from '../output/index.mjs';
9
9
  import { SHELLS, renderCompletions } from '../completions.mjs';
10
10
 
11
11
  export async function completionsCommand(args, flags, commands) {
@@ -18,10 +18,6 @@ export async function completionsCommand(args, flags, commands) {
18
18
  }
19
19
 
20
20
  const script = renderCompletions(shell, commands);
21
- if (isJson(flags)) {
22
- emitJson({ ok: true, command: 'completions', shell, script });
23
- return EXIT.OK;
24
- }
25
- emit(script, { maxBytes: flags.maxBytes ?? 0 });
21
+ present({ ok: true, command: 'completions', shell, script }, () => script, flags);
26
22
  return EXIT.OK;
27
23
  }
@@ -10,9 +10,10 @@ import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
10
  import { commitJournal } from '../journal.mjs';
11
11
  import { diffField, emitDiffs } from '../diff.mjs';
12
12
  import { parsePathList } from '../argv.mjs';
13
+ import { commandRoot } from '../repo-root.mjs';
13
14
 
14
15
  export async function decideCommand(args, flags) {
15
- const root = flags.root ?? process.cwd();
16
+ const root = commandRoot(flags);
16
17
 
17
18
  if (flags.check === true) {
18
19
  // Q55: one resolution — the manifest key, then the detected directory —
@@ -31,7 +32,7 @@ export async function decideCommand(args, flags) {
31
32
  }
32
33
 
33
34
  if (res.findings.length === 0) {
34
- emit('decisions ok', { maxBytes: flags.maxBytes ?? 0 });
35
+ emit('decisions ok');
35
36
  return EXIT.OK;
36
37
  }
37
38
 
@@ -42,7 +43,6 @@ export async function decideCommand(args, flags) {
42
43
  ...(f.remedy ? [` remedy: ${f.remedy}`] : []),
43
44
  ])
44
45
  .join('\n'),
45
- { maxBytes: flags.maxBytes ?? 0 },
46
46
  );
47
47
  return EXIT.FAIL;
48
48
  }
@@ -123,7 +123,7 @@ export async function decideCommand(args, flags) {
123
123
  : `would write ${res.relPath}; apply with: ${CLI} decide ${JSON.stringify(res.title)} --yes`,
124
124
  ];
125
125
 
126
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
126
+ emit(lines.join('\n'));
127
127
  // After the report: the report says what, the diff shows it (R50).
128
128
  emitDiffs(diffs, flags);
129
129
  return EXIT.OK;
@@ -16,7 +16,7 @@ export async function distillCommand(args, flags) {
16
16
  if (typeof result === 'number') return result;
17
17
 
18
18
  if (isJson(flags)) {
19
- emitJson(result, { maxBytes: flags.maxBytes ?? 0 });
19
+ emitJson(result);
20
20
  return EXIT.OK;
21
21
  }
22
22
 
@@ -27,10 +27,11 @@ export async function distillCommand(args, flags) {
27
27
  `engine ${result.engine}`,
28
28
  `budget ${result.budget} bytes`,
29
29
  `citations ${result.citations}`,
30
- ...(result.verified === true ? [] : result.faults.map((f) => `unverified ${f}`)),
30
+ // A cached proposal predates this field; `?? []` keeps the report from crashing on
31
+ // the file the command wrote one release ago (review 0003 Q52).
32
+ ...(result.verified === true ? [] : (result.faults ?? []).map((f) => `unverified ${f}`)),
31
33
  `proposal ${result.proposal.path} (${result.proposal.state}, ${result.proposal.bytes} bytes)`,
32
34
  ].join('\n'),
33
- { maxBytes: flags.maxBytes ?? 0 },
34
35
  );
35
36
  return EXIT.OK;
36
37
  }
@@ -22,18 +22,20 @@
22
22
  // `--yes` previews: it names what it would apply and lists the rest.
23
23
 
24
24
  import { join } from 'node:path';
25
+ import { repoContext } from '../context.mjs';
25
26
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
26
27
  import { CLI, MANIFEST_FILE } from '../name.mjs';
27
28
  import { fingerprint } from '../resolve/fingerprint.mjs';
28
29
  import { resolveWorkspace } from '../workspace.mjs';
29
30
  import { sharedWarning } from './shared.mjs';
30
31
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
31
- import { manifestPath, readManifest, writeManifest } from '../projection/manifest.mjs';
32
+ import { manifestPath, writeManifest } from '../projection/manifest.mjs';
32
33
  import { planManifestRepair } from './sync.mjs';
33
34
  import { provenanceDigest } from '../projection/project.mjs';
34
35
  import { applyHooksInstall, plannedDiffs, planHooksInstall } from './hooks.mjs';
35
36
  import { fileFor } from '../hooks/shim.mjs';
36
37
  import { commitJournal, ignoreJournal, journalHygiene, journalIgnoreNext } from '../journal.mjs';
38
+ import { commandRoot } from '../repo-root.mjs';
37
39
 
38
40
  /**
39
41
  * One config-health finding: what is wrong, how to fix it, and whether
@@ -47,7 +49,7 @@ import { commitJournal, ignoreJournal, journalHygiene, journalIgnoreNext } from
47
49
  // finding creation, before any fix is applied, so the before side is true in
48
50
  // preview and in apply alike. Only safe findings carry pairs: an unsafe finding
49
51
  // is never applied, so there is no write to preview.
50
- async function collectFindings(root, { result, ws, shared, withDiffs = false }) {
52
+ async function collectFindings(root, { result, ws, shared, withDiffs = false, ctx }) {
51
53
  const findings = [];
52
54
  if (result.error) {
53
55
  // Nothing else can be asked: classification failed, so every check built on
@@ -63,7 +65,7 @@ async function collectFindings(root, { result, ws, shared, withDiffs = false })
63
65
  return findings;
64
66
  }
65
67
 
66
- const { manifest, exists, error: manifestError } = await readManifest(root);
68
+ const { manifest, exists, error: manifestError } = await ctx.manifest();
67
69
  if (manifestError !== null) {
68
70
  // An unreadable manifest is a hand-edit, not a repair: re-rendering it
69
71
  // would destroy whatever the other tool was saying.
@@ -232,7 +234,10 @@ function findingLines(findings, { fixMode, applying }) {
232
234
  //
233
235
  // See the module header: classification first, because everything else is a guess without it.
234
236
  export async function diagnose(root, args = {}) {
235
- const result = await fingerprint(root);
237
+ // One context for the whole diagnosis, so a caller that already holds one (a UI, a
238
+ // harness, an MCP request) does not pay for a second fingerprint (review 0003 Q67).
239
+ const ctx = args.ctx ?? repoContext(root);
240
+ const result = await ctx.fingerprint();
236
241
  // Which source answered the workspace question, and where it looked. A tool
237
242
  // that silently fell back to the wrong directory would make every downstream
238
243
  // answer confidently wrong, so the answer is reported rather than inferred
@@ -246,7 +251,7 @@ export async function diagnose(root, args = {}) {
246
251
  const shared = await sharedWarning(root, result.manifest ?? null, args);
247
252
 
248
253
  const withDiffs = args.fix === true && args.diff === true;
249
- const findings = await collectFindings(root, { result, ws, shared, withDiffs });
254
+ const findings = await collectFindings(root, { result, ws, shared, withDiffs, ctx });
250
255
  // Under `--json` the document is the whole report, so it has to carry both
251
256
  // failures the text path reports AND the workspace answer the text prints.
252
257
  const failure = result.error
@@ -276,7 +281,7 @@ export function serialisedFindings(findings) {
276
281
  }
277
282
 
278
283
  export async function runDoctor(positional, flags) {
279
- const root = flags.root ?? positional[0] ?? process.cwd();
284
+ const root = commandRoot(flags, positional);
280
285
  const { result, ws, shared, findings, failure, code: diagnosisCode } = await diagnose(root, flags);
281
286
 
282
287
  const fixMode = flags.fix === true;
@@ -344,7 +349,6 @@ export async function runDoctor(positional, flags) {
344
349
  ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
345
350
  ...findingLines(findings, { fixMode, applying: mode.apply }),
346
351
  ].join('\n'),
347
- { maxBytes: flags.maxBytes ?? 0 },
348
352
  );
349
353
  // After the report: the report says what, the diff shows it (R50). Only the
350
354
  // fix plan is diffed, so without --fix there is nothing to show.
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { readFile } from 'node:fs/promises';
8
8
 
9
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
10
10
  import { readManifest } from '../projection/manifest.mjs';
11
11
  import { CLI, MANIFEST_FILE } from '../name.mjs';
12
12
  import {
@@ -17,9 +17,10 @@ import {
17
17
  readLedger,
18
18
  treeId,
19
19
  } from '../ledger/index.mjs';
20
+ import { commandRoot } from '../repo-root.mjs';
20
21
 
21
22
  export async function evidenceCommand(args, flags) {
22
- const root = flags.root ?? process.cwd();
23
+ const root = commandRoot(flags);
23
24
  if (args[0] === 'check') return checkClaim(root, args[1], flags);
24
25
  if (args[0] !== undefined) {
25
26
  return fail(`unknown evidence subcommand ${args[0]}`, EXIT.USAGE, `run: ${CLI} evidence`);
@@ -42,7 +43,7 @@ async function showEvidence(root, flags) {
42
43
  const mine = entries.filter((e) => e.tree === tree);
43
44
  const payload = { ok: errors.length === 0, command: 'evidence', root, tree, entries: mine, errors };
44
45
  if (isJson(flags)) {
45
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
46
+ emitJson(payload);
46
47
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
47
48
  }
48
49
  const lines = [`tree ${tree}`];
@@ -51,7 +52,7 @@ async function showEvidence(root, flags) {
51
52
  lines.push(`${e.exit === 0 ? 'pass' : 'fail'} ${e.cmd} exit ${e.exit} ${e.summary}`);
52
53
  }
53
54
  for (const error of errors) lines.push(`error ${error}`);
54
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
55
+ emit(lines.join('\n'));
55
56
  return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
56
57
  }
57
58
 
@@ -97,10 +98,6 @@ async function checkClaim(root, file, flags) {
97
98
  }
98
99
  const detail = 'the message claims a run this tree does not have';
99
100
  const remedy = `run the command, then commit, or take the claim out of the message`;
100
- if (isJson(flags)) {
101
- emitJson({ ...payload, detail, remedy }, { pretty: flags.pretty === true });
102
- return mode === 'block' ? EXIT.FAIL : EXIT.OK;
103
- }
104
- emit(`${mode === 'block' ? 'BLOCKED' : 'WARNING'} ${detail}\n remedy: ${remedy}`, { maxBytes: flags.maxBytes ?? 0 });
101
+ present({ ...payload, detail, remedy }, () => `${mode === 'block' ? 'BLOCKED' : 'WARNING'} ${detail}\n remedy: ${remedy}`, flags);
105
102
  return mode === 'block' ? EXIT.FAIL : EXIT.OK;
106
103
  }
@@ -46,7 +46,6 @@ export async function explainCommand(positional, flags) {
46
46
  }
47
47
  emit(
48
48
  [`code ${code}`, '', row.what, '', `Why: ${row.why}`, '', `Fix: ${row.fix}`, '', `Example: ${row.example}`].join('\n'),
49
- { maxBytes: flags.maxBytes ?? 0 },
50
49
  );
51
50
  return EXIT.OK;
52
51
  }
@@ -7,22 +7,26 @@
7
7
 
8
8
  import { execFile } from 'node:child_process';
9
9
  import { existsSync } from 'node:fs';
10
- import { mkdir, writeFile } from 'node:fs/promises';
10
+ import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { promisify } from 'node:util';
13
13
 
14
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
15
15
  import { CLI } from '../name.mjs';
16
16
  import { detectTestRunner } from '../bench/mine.mjs';
17
17
  import { compactOutput } from '../index/compact.mjs';
18
18
  import { readImportEdges, selectTests } from '../index/gate.mjs';
19
19
  import { langOf, packageOf } from '../index/store.mjs';
20
20
  import { localRel } from '../state.mjs';
21
+ import { commandRoot } from '../repo-root.mjs';
21
22
 
22
23
  const exec = promisify(execFile);
23
24
 
25
+ /** How many raw gate logs the state directory keeps. */
26
+ const RAW_LOG_KEEP = 5;
27
+
24
28
  export async function gateCommand(args, flags) {
25
- const root = flags.root ?? process.cwd();
29
+ const root = commandRoot(flags);
26
30
  const listed = await tracked(root);
27
31
  if (listed.error) return listed.error;
28
32
  let changed = args.filter(Boolean);
@@ -38,10 +42,9 @@ export async function gateCommand(args, flags) {
38
42
  if (isJson(flags)) {
39
43
  emitJson(
40
44
  { ok: true, command: 'gate', root, selected: [], unmatched: [], graph: 'resolved', verdict: 'nothing-selected', code: 0 },
41
- { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
42
45
  );
43
46
  } else {
44
- emit('selected 0\nverdict nothing-selected', { maxBytes: flags.maxBytes ?? 0 });
47
+ emit('selected 0\nverdict nothing-selected');
45
48
  }
46
49
  return EXIT.OK;
47
50
  }
@@ -74,13 +77,13 @@ export async function gateCommand(args, flags) {
74
77
  if (selected.length === 0) {
75
78
  const payload = { ok: true, command: 'gate', root, selected, unmatched, graph, verdict: 'nothing-selected', code: 0 };
76
79
  if (isJson(flags)) {
77
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
80
+ emitJson(payload);
78
81
  return EXIT.OK;
79
82
  }
80
83
  const lines = ['selected 0', 'verdict nothing-selected'];
81
84
  if (unmatched.length > 0) lines.push(`unmatched ${unmatched.join(', ')}`);
82
85
  if (graph === 'unresolved') lines.push('graph unresolved');
83
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
86
+ emit(lines.join('\n'));
84
87
  return EXIT.OK;
85
88
  }
86
89
  return runSelected(root, flags, selected, { unmatched, graph });
@@ -170,11 +173,7 @@ async function runSelected(root, flags, selected, { unmatched = [], graph = 'res
170
173
  raw,
171
174
  runs,
172
175
  };
173
- if (isJson(flags)) {
174
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
175
- return code === 0 ? EXIT.OK : EXIT.FAIL;
176
- }
177
- emit(text.filter((line) => line !== undefined).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
176
+ present(payload, () => text.filter((line) => line !== undefined).join('\n'), flags);
178
177
  return code === 0 ? EXIT.OK : EXIT.FAIL;
179
178
  }
180
179
 
@@ -185,6 +184,11 @@ async function writeRaw(root, body) {
185
184
  const dir = join(root, dirname(rel));
186
185
  await mkdir(dir, { recursive: true });
187
186
  await writeFile(join(root, rel), body);
187
+ // Keep the newest few: raw gate output is machine-local state (Q19), and a file per
188
+ // run with no bound is a directory that grows on every command (Q83).
189
+ const kept = await readdir(dir).catch(() => []);
190
+ const logs = kept.filter((name) => /^gate-\d+\.log$/.test(name)).sort().reverse();
191
+ for (const name of logs.slice(RAW_LOG_KEEP)) await unlink(join(dir, name)).catch(() => {});
188
192
  return rel;
189
193
  } catch {
190
194
  return '';
@@ -71,7 +71,6 @@ export async function lintCommand(positional, flags) {
71
71
  errors,
72
72
  warnings,
73
73
  },
74
- { maxBytes: flags.maxBytes ?? 0 },
75
74
  );
76
75
  return finalOk ? EXIT.OK : EXIT.REGISTRY;
77
76
  }
@@ -109,11 +108,11 @@ export async function lintCommand(positional, flags) {
109
108
  if (errors.length > 0) {
110
109
  lines.push('', `FAIL ${errors.length}`);
111
110
  for (const error of errors) lines.push(` ${error}`);
112
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
111
+ emit(lines.join('\n'));
113
112
  return EXIT.REGISTRY;
114
113
  }
115
114
 
116
115
  lines.push('', verified ? 'OK no findings' : 'OK structural checks only');
117
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
116
+ emit(lines.join('\n'));
118
117
  return EXIT.OK;
119
118
  }
@@ -27,6 +27,7 @@ import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
27
27
  import { SURFACES, CANDIDATE_DIRS } from '../projection/surfaces.mjs';
28
28
  import { LOADERS, LOADER_IDS, caps, loader } from '../projection/loaders.mjs';
29
29
  import { viewAll } from '../measure/loaderView.mjs';
30
+ import { commandRoot } from '../repo-root.mjs';
30
31
 
31
32
  /**
32
33
  * `simt harness detect`. The subcommand is validated by dispatch, which rejects a
@@ -39,7 +40,7 @@ import { viewAll } from '../measure/loaderView.mjs';
39
40
  * @returns {Promise<number>} process exit code
40
41
  */
41
42
  export async function harnessCommand(positional, flags) {
42
- const root = flags.root ?? process.cwd();
43
+ const root = commandRoot(flags);
43
44
 
44
45
  // MCP evidence is project-scope by construction: the loader table carries repo-root
45
46
  // paths (.mcp.json, .codex/config.toml, …), and the same path means different scopes
@@ -256,7 +257,7 @@ function block(scan, pad) {
256
257
  * do" in a repository where the answer is not about bytes.
257
258
  */
258
259
  export async function harnessLoadersCommand(positional, flags) {
259
- const root = flags.root ?? process.cwd();
260
+ const root = commandRoot(flags);
260
261
  const wanted = typeof flags.reader === 'string' ? flags.reader : null;
261
262
  const rows = wanted === null ? LOADERS : [loader(wanted)].filter(Boolean);
262
263
 
@@ -1,4 +1,5 @@
1
- import { emit, fail, jsonFailMode } from '../output/index.mjs';
1
+ import { emit, emitJson, fail, jsonFailMode } from '../output/index.mjs';
2
+ import { SPEC, SPEC_PATHS, tierOf } from './spec.mjs';
2
3
  import { parse, rawJsonFlag } from '../argv.mjs';
3
4
  import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
4
5
  import { SESSION_ENV } from '../agenthooks/policy.mjs';
@@ -145,7 +146,7 @@ ${CLI} lessons list deterministic lesson candidates in .simt/local/le
145
146
  and promoted lessons in docs/lessons/*.md (reads nothing else)
146
147
  ${CLI} lessons capture scan the ledger and git history for new candidates (the write half of list)
147
148
  ${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
148
- (dry-run unless --yes; writes Appendix A frontmatter)
149
+ (dry-run unless --yes; writes the lesson frontmatter)
149
150
  ${CLI} explain <CODE> what a check finding means: the decision behind it, how
150
151
  to fix it, and a concrete trigger (DRIFT, UNMANAGED,
151
152
  PACK-DIGEST-MISMATCH, ...; case-insensitive)
@@ -168,7 +169,7 @@ ${CLI} distill <skill|doc> a budgeted proposal summary of a skill body or do
168
169
  from the configured chat model instead; the cache is
169
170
  keyed by source digest and budget, --refresh regenerates)
170
171
  ${CLI} review [--local] an ADR-008 verdict skeleton for the staged diff,
171
- the brief listing everything that governs it (R25)
172
+ the brief listing everything that governs it
172
173
  (deterministic without --local: the brief and its
173
174
  governing ids only; --local proposes from the
174
175
  configured chat model and writes
@@ -219,6 +220,16 @@ ${CLI} bench critic precision and recall of the local critic over a lab
219
220
  overrides --labels; labels are human-written verdicts
220
221
  under ADR-008; the critic is advisory until precision
221
222
  reaches 0.8 on 30 labelled diffs)
223
+ ${CLI} bench export <file> [--yes]
224
+ sealed export of the mined corpus: every task
225
+ descriptor and both hidden patches in one signed
226
+ JSON file, for import on another machine
227
+ (dry-run unless --yes; --root selects the repository)
228
+ ${CLI} bench import <file> [--yes]
229
+ import a sealed corpus: verify the seal, then write
230
+ the task descriptors and hidden patches into the
231
+ repository's vault (dry-run unless --yes; --root
232
+ selects the repository)
222
233
  ${CLI} pack new <name> --source <spec>
223
234
  scaffold a versioned pack: pack.json, an unmeasured
224
235
  bench.json, and one stack rule (dry-run unless --yes;
@@ -322,8 +333,14 @@ const FLAGS = `--json structured output (compact; one document,
322
333
  --embed index / map / brief: compute or query optional local vector embeddings
323
334
  (off by default unless recall@10 gains at least 10 points)
324
335
  --labels <path> bench critic: the labelled set of historical diffs to score
336
+ --cross-group bench ablate: compare across harness, model and experiment
337
+ groups instead of refusing the comparison
338
+ --expires <when> lessons capture: how long a captured candidate stays eligible
325
339
  (default .simt/critic-labels.jsonl; one human-written
326
340
  {sha, verdict} pair per line)
341
+ --replace bench mine: delete task files the current mine does not
342
+ reproduce (off by default: mining is append-only; a
343
+ re-mine adds, it does not replace the corpus)
327
344
  --refresh distill / review: regenerate a proposal that is already cached
328
345
  --local distill / review: propose the answer from the configured
329
346
  local chat model (local.chat); off by default, which
@@ -338,17 +355,69 @@ const PRINCIPLES = `design rules:
338
355
  zero dependencies a DX tool that needs an install step fails on a fresh machine
339
356
  bounded output one fact per line, fixed field order, explicit TRUNCATED lines`;
340
357
 
341
- export async function help(argv) {
342
- const parsed = parse(argv);
358
+ export async function help(argv, dispatched) {
359
+ // `dispatched` is what `dispatch` already parsed. Called from a test or another
360
+ // command with only argv, help parses it itself.
361
+ const parsed =
362
+ dispatched === undefined
363
+ ? parse(argv)
364
+ : dispatched.parseError === undefined
365
+ ? { ok: true, value: { flags: dispatched.flags, positional: dispatched.positional } }
366
+ : { ok: false, error: dispatched.parseError };
343
367
  if (!parsed.ok) {
344
368
  jsonFailMode(rawJsonFlag(argv));
345
369
  return fail(parsed.error.message, parsed.error.code);
346
370
  }
347
- const { flags } = parsed.value;
371
+ const { flags, positional } = parsed.value;
348
372
  if (flags.version) {
349
373
  emit((await version()).trim());
350
374
  return 0;
351
375
  }
376
+ // A reader who names one command gets that command, not the whole surface (review
377
+ // 0003 Q61): `simt help gate` and `simt gate --help` both printed 23 KB, so the one
378
+ // fact being asked for was 40 screens down.
379
+ const asked = commandFromHelp(positional);
380
+ if (asked !== null) {
381
+ emit(commandPage(asked));
382
+ return 0;
383
+ }
384
+ if (flags.json === true) {
385
+ emitJson(SPEC.map(specRow));
386
+ return 0;
387
+ }
388
+ // `--all` prints every command and every flag; the default page lists the core tier and
389
+ // says where the rest is. A reader asking "what can this do" needs the shape, not 23 KB
390
+ // of flag reference (review 0003 Q61).
391
+ if (flags.all === true) {
392
+ emit(
393
+ [
394
+ 'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
395
+ '',
396
+ `usage: ${CLI} <command> [args] [flags]`,
397
+ '',
398
+ 'commands:',
399
+ COMMANDS,
400
+ '',
401
+ 'flags:',
402
+ FLAGS,
403
+ '',
404
+ PRINCIPLES,
405
+ '',
406
+ `rulings ${CLI} rules list the decisions behind the rules you just read`,
407
+ ].join('\n'),
408
+ );
409
+ return 0;
410
+ }
411
+ // One line per group and one per bare command: listing every subcommand with its
412
+ // summary does not fit in the 2 KB a reader will actually read (review 0003 Q61).
413
+ const groups = new Map();
414
+ const bare = [];
415
+ for (const row of SPEC) {
416
+ if (tierOf(row.path) !== 'core') continue;
417
+ const [head, sub] = row.path.split(' ');
418
+ if (sub === undefined) bare.push(row);
419
+ else groups.set(head, [...(groups.get(head) ?? []), sub]);
420
+ }
352
421
  emit(
353
422
  [
354
423
  'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
@@ -356,19 +425,12 @@ export async function help(argv) {
356
425
  `usage: ${CLI} <command> [args] [flags]`,
357
426
  '',
358
427
  'commands:',
359
- COMMANDS,
360
- '',
361
- 'flags:',
362
- FLAGS,
428
+ ...[...groups].map(([group, subs]) => ` ${CLI} ${group} <${subs.join('|')}>`),
429
+ ...bare.map((row) => ` ${CLI} ${row.path.padEnd(8)} ${row.summary.length > 46 ? `${row.summary.slice(0, 43)}...` : row.summary}`),
363
430
  '',
364
- PRINCIPLES,
365
- '',
366
- // The plans ship inside the installed package but have no command that surfaces
367
- // them, so naming the path would again be advice the reader cannot take. They
368
- // are an implementation record; the rulings are the part a reader can act on.
369
- `rulings ${CLI} rules list the decisions behind the rules you just read`,
431
+ `more ${CLI} help --all (every command and flag), ${CLI} help <command>`,
432
+ `rulings ${CLI} rules list (the decisions behind the rules)`,
370
433
  ].join('\n'),
371
- { maxBytes: flags.maxBytes ?? 0 },
372
434
  );
373
435
  return 0;
374
436
  }
@@ -377,3 +439,49 @@ async function version() {
377
439
  const { describeVersion } = await import('../package-version.mjs');
378
440
  return `${describeVersion()}\n`;
379
441
  }
442
+
443
+ /** The command a help request names, or null when it names none. */
444
+ function commandFromHelp(positional) {
445
+ const words = positional[0] === 'help' ? positional.slice(1) : positional;
446
+ const two = words.slice(0, 2).join(' ');
447
+ if (SPEC_PATHS.includes(two)) return two;
448
+ return SPEC_PATHS.includes(words[0]) ? words[0] : null;
449
+ }
450
+
451
+ /** One spec row as the JSON a UI or a harness reads. */
452
+ function specRow(row) {
453
+ return { path: row.path, summary: row.summary, ...(row.defaultFor === undefined ? {} : { defaultFor: row.defaultFor }) };
454
+ }
455
+
456
+ const PAGE_WIDTH = 78;
457
+
458
+ /** The summary broken to the page width, so a narrow terminal keeps it readable. */
459
+ function wrapped(text) {
460
+ const lines = [];
461
+ let line = '';
462
+ for (const word of text.split(' ')) {
463
+ if (line !== '' && line.length + 1 + word.length > PAGE_WIDTH) {
464
+ lines.push(line);
465
+ line = word;
466
+ } else {
467
+ line = line === '' ? word : `${line} ${word}`;
468
+ }
469
+ }
470
+ if (line !== '') lines.push(line);
471
+ return lines;
472
+ }
473
+
474
+ /**
475
+ * One command's page: what it is, and where the rest of the answers are. The flags a
476
+ * single command declares arrive with the spec's `flags` field (Q62); until then the
477
+ * page points at the global list rather than inventing one per command.
478
+ */
479
+ export function commandPage(path) {
480
+ const row = SPEC.find((r) => r.path === path);
481
+ if (row === undefined) return '';
482
+ const lines = [`${CLI} ${path} [args] [flags]`, '', ...wrapped(row.summary), ''];
483
+ if (row.defaultFor !== undefined) lines.push(`default ${CLI} ${row.defaultFor} runs this command`);
484
+ lines.push(`flags --json --pretty --root --max-bytes, and every global flag`);
485
+ lines.push(`all ${CLI} --help every command, and every flag it takes`);
486
+ return `${lines.join('\n')}\n`;
487
+ }