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
@@ -10,6 +10,7 @@
10
10
  // black box.
11
11
 
12
12
  import { readFile, realpath } from 'node:fs/promises';
13
+ import { repoContext } from '../context.mjs';
13
14
  import { join } from 'node:path';
14
15
  import { existsSync } from 'node:fs';
15
16
 
@@ -24,6 +25,7 @@ import { localSkillNodes } from '../resolve/local.mjs';
24
25
  import { nearMatches } from '../resolve/near.mjs';
25
26
  import { CLI } from '../name.mjs';
26
27
  import { parsePathList } from '../argv.mjs';
28
+ import { commandRoot } from '../repo-root.mjs';
27
29
 
28
30
  export { parsePathList };
29
31
 
@@ -82,11 +84,11 @@ async function skillsRegistry(args) {
82
84
  * costs a fingerprint plus a directory scan, so it runs only when the registry did
83
85
  * not answer.
84
86
  */
85
- async function findSkill(registry, root, name, verb) {
87
+ async function findSkill(registry, root, name, verb, ctx) {
86
88
  if (!name) skillsFailure(`${verb} needs a skill name`, `usage: ${CLI} skills ${verb} <name>`, EXIT.USAGE);
87
89
  const skill = registry.skills.find((s) => s.name === name);
88
90
  if (skill) return skill;
89
- const repoStack = (await fingerprint(root)).signals;
91
+ const repoStack = (await ctx.fingerprint()).signals;
90
92
  const local = (await localSkillNodes(root, registry, repoStack)).find((s) => s.name === name);
91
93
  if (local) return local;
92
94
  const near = nearMatches(name, registry.skills.map((s) => s.name));
@@ -114,7 +116,10 @@ function skillFacts(skill) {
114
116
  /** Tier 1: the selection the task resolves to, with the text packet's display sets. */
115
117
  export async function skillsBriefFacts(root, args = {}) {
116
118
  const registry = await skillsRegistry(args);
117
- const repo = await fingerprint(root);
119
+ // One context per call: a second question about the same repository inside this
120
+ // command costs nothing (review 0003 Q67).
121
+ const ctx = args.ctx ?? repoContext(root);
122
+ const repo = await ctx.fingerprint();
118
123
  const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
119
124
  const repoStack = repo.signals;
120
125
  const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
@@ -246,7 +251,7 @@ export async function skillsFindFacts(root, query, args = {}) {
246
251
  ...registry.skills
247
252
  .map((skill) => ({ skill, ...scoreSkill(skill, context) }))
248
253
  .filter((r) => selectionVerdict(r.evidence, r.reasons) === null),
249
- ...(await localSkillNodes(root, registry, (await fingerprint(root)).signals))
254
+ ...(await localSkillNodes(root, registry, (await (args.ctx ?? repoContext(root)).fingerprint()).signals))
250
255
  .map((skill) => ({ skill, ...scoreSkill(skill, context) }))
251
256
  .filter((r) => r.score > 0),
252
257
  ].sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
@@ -259,7 +264,7 @@ export async function skillsFindFacts(root, query, args = {}) {
259
264
  /** Tier 2: one skill body and the metadata needed to judge it. */
260
265
  export async function skillsShowFacts(root, name, args = {}) {
261
266
  const registry = await skillsRegistry(args);
262
- const skill = await findSkill(registry, root, name, 'show');
267
+ const skill = await findSkill(registry, root, name, 'show', args.ctx ?? repoContext(root));
263
268
  const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
264
269
  // The repo is a location for a declared source too, so `show` reads a body the
265
270
  // caller already has instead of reporting the body missing.
@@ -287,8 +292,9 @@ export async function skillsShowFacts(root, name, args = {}) {
287
292
  /** The signal chain for one skill in one repository. */
288
293
  export async function skillsWhyFacts(root, name, args = {}) {
289
294
  const registry = await skillsRegistry(args);
290
- const skill = await findSkill(registry, root, name, 'why');
291
- const repo = await fingerprint(root);
295
+ const ctx = args.ctx ?? repoContext(root);
296
+ const skill = await findSkill(registry, root, name, 'why', ctx);
297
+ const repo = await ctx.fingerprint();
292
298
  const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
293
299
  const tokens = tokenize(args.task ?? '');
294
300
  const { score, reasons } = scoreSkill(skill, { repoStack: repo.signals, taskTokens: tokens, paths });
@@ -386,7 +392,6 @@ export async function indexCommand(args, flags) {
386
392
  '',
387
393
  `deferred entries return a pointer, not a body. Fetch one with: ${CLI} skills show <name>`,
388
394
  ].join('\n'),
389
- { maxBytes: flags.maxBytes ?? 0 },
390
395
  );
391
396
  return EXIT.OK;
392
397
  }
@@ -429,14 +434,14 @@ async function emitAllFacts(facts, flags) {
429
434
  if (deferred.length > 0) {
430
435
  lines.push('', `DEFERRED ${deferred.length}: ${deferred.map((s) => s.name).join(', ')}`);
431
436
  }
432
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
437
+ emit(lines.join('\n'));
433
438
  return EXIT.OK;
434
439
  }
435
440
 
436
441
  // --- find ---------------------------------------------------------------------
437
442
 
438
443
  export async function findCommand(args, flags) {
439
- const root = flags.root ?? process.cwd();
444
+ const root = commandRoot(flags);
440
445
  let facts;
441
446
  try {
442
447
  facts = await skillsFindFacts(root, args.join(' '), flags);
@@ -449,7 +454,6 @@ export async function findCommand(args, flags) {
449
454
  if (isJson(flags)) {
450
455
  emitJson(
451
456
  { ok: true, query, workspace, results },
452
- { maxBytes: flags.maxBytes ?? 0 },
453
457
  );
454
458
  return EXIT.OK;
455
459
  }
@@ -469,17 +473,17 @@ export async function findCommand(args, flags) {
469
473
  `${r.name.padEnd(26)} ${kb(r.bytes).padStart(7)} ${r.reasons.join(' ')}`,
470
474
  ),
471
475
  ].join('\n'),
472
- { maxBytes: flags.maxBytes ?? 0 },
473
476
  );
474
477
  return EXIT.OK;
475
478
  }
476
479
 
477
480
  // --- brief: tier 1 ------------------------------------------------------------
478
481
  export async function briefCommand(args, flags) {
479
- const root = flags.root ?? process.cwd();
482
+ const root = commandRoot(flags);
483
+ const ctx = repoContext(root);
480
484
  let facts;
481
485
  try {
482
- facts = await skillsBriefFacts(root, flags);
486
+ facts = await skillsBriefFacts(root, { ...flags, ctx });
483
487
  } catch (error) {
484
488
  return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
485
489
  }
@@ -498,21 +502,20 @@ export async function briefCommand(args, flags) {
498
502
  }
499
503
 
500
504
  if (facts.warning) {
501
- const repo = await fingerprint(root);
505
+ const repo = await ctx.fingerprint();
502
506
  emit(
503
507
  [
504
508
  `repo ${root.split('/').pop()} (${repoLine(repo)})`,
505
509
  '',
506
510
  facts.warning,
507
511
  ].join('\n'),
508
- { maxBytes: flags.maxBytes ?? 0 },
509
512
  );
510
513
  return EXIT.OK;
511
514
  }
512
515
 
513
516
  const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspace, workspaceRoot, budget } = facts;
514
517
  const lines = [];
515
- const repo = await fingerprint(root);
518
+ const repo = await ctx.fingerprint();
516
519
 
517
520
  lines.push(
518
521
  `RESOLVED ${shown.length} skills, ${kb(shownUsed)}${flags.all || shownUsed <= budget ? '' : ' (budget capped)'} - repo ${root.split('/').pop()} (${repoLine(repo)})`,
@@ -533,7 +536,7 @@ export async function briefCommand(args, flags) {
533
536
  lines.push('', `NOT SELECTED ${hidden.length}`, '', ` notes: ${CLI} skills why <name> | ${CLI} skills show <name>`);
534
537
  }
535
538
  lines.push('', 'OVERRIDE --skill <name> | --budget <bytes> | --all | --paths <globs>');
536
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
539
+ emit(lines.join('\n'));
537
540
  return EXIT.OK;
538
541
  }
539
542
 
@@ -578,14 +581,14 @@ export async function briefCommand(args, flags) {
578
581
  }
579
582
 
580
583
  lines.push('', 'OVERRIDE --skill <name> | --budget <bytes> | --all | --paths <globs>');
581
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
584
+ emit(lines.join('\n'));
582
585
  return EXIT.OK;
583
586
  }
584
587
 
585
588
  // --- show: tier 2 -------------------------------------------------------------
586
589
 
587
590
  export async function showCommand(args, flags) {
588
- const root = flags.root ?? process.cwd();
591
+ const root = commandRoot(flags);
589
592
  let facts;
590
593
  try {
591
594
  facts = await skillsShowFacts(root, args[0], flags);
@@ -595,7 +598,7 @@ export async function showCommand(args, flags) {
595
598
  const { skill, workspace, body, bodyFrom, bodyText } = facts;
596
599
  const source = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
597
600
  if (isJson(flags)) {
598
- emitJson({ ok: true, ...skill, workspace, body, bodyFrom, bodyText }, { maxBytes: flags.maxBytes ?? 0 });
601
+ emitJson({ ok: true, ...skill, workspace, body, bodyFrom, bodyText });
599
602
  return EXIT.OK;
600
603
  }
601
604
  emit(
@@ -618,7 +621,6 @@ export async function showCommand(args, flags) {
618
621
  ]
619
622
  .filter((l) => l !== null)
620
623
  .join('\n'),
621
- { maxBytes: flags.maxBytes ?? 0 },
622
624
  );
623
625
  return EXIT.OK;
624
626
  }
@@ -626,7 +628,7 @@ export async function showCommand(args, flags) {
626
628
  // --- why ----------------------------------------------------------------------
627
629
 
628
630
  export async function whyCommand(args, flags) {
629
- const root = flags.root ?? process.cwd();
631
+ const root = commandRoot(flags);
630
632
  let facts;
631
633
  try {
632
634
  facts = await skillsWhyFacts(root, args[0], flags);
@@ -641,7 +643,7 @@ export async function whyCommand(args, flags) {
641
643
  // to scrape: a skill that can never be selected in this repo is a decision, not
642
644
  // a line of output.
643
645
  if (isJson(flags)) {
644
- emitJson({ ok: true, command: 'skills why', ...facts }, { maxBytes: flags.maxBytes ?? 0 });
646
+ emitJson({ ok: true, command: 'skills why', ...facts });
645
647
  return EXIT.OK;
646
648
  }
647
649
 
@@ -667,7 +669,7 @@ export async function whyCommand(args, flags) {
667
669
  if (facts.variantOf) lines.push('', `variant-of ${facts.variantOf} (sibling group, one body per stack)`);
668
670
  if (facts.requires?.length > 0) lines.push(`requires ${facts.requires.join(', ')}`);
669
671
 
670
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
672
+ emit(lines.join('\n'));
671
673
  return EXIT.OK;
672
674
  }
673
675
 
@@ -773,7 +775,7 @@ export async function evalCommand(positional, flags) {
773
775
  }
774
776
 
775
777
  lines.push('', okRun ? 'OK the resolver meets the gold corpus' : 'REGRESSION: the resolver no longer meets the gold corpus');
776
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
778
+ emit(lines.join('\n'));
777
779
  return okRun ? EXIT.OK : EXIT.FAIL;
778
780
  }
779
781
 
@@ -22,6 +22,7 @@ import { nearMatches } from '../resolve/near.mjs';
22
22
  import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
23
23
  import { CLI } from '../name.mjs';
24
24
  import { LOCAL_DIR } from '../state.mjs';
25
+ import { commandRoot } from '../repo-root.mjs';
25
26
 
26
27
  async function readSkillRawText(skill, workspaceRoot, root) {
27
28
  const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
@@ -68,7 +69,7 @@ export async function compileCommand(args, flags) {
68
69
  const { registry, errors, ok } = await loadRegistry(null, flags);
69
70
  if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
70
71
 
71
- const root = resolve(flags.root ?? process.cwd());
72
+ const root = resolve(commandRoot(flags));
72
73
  const ws = await resolveWorkspace(flags);
73
74
  const targetName = args[0] ?? null;
74
75
 
@@ -117,7 +118,6 @@ export async function compileCommand(args, flags) {
117
118
  splitPlan: s.splitPlan,
118
119
  })),
119
120
  },
120
- { maxBytes: flags.maxBytes ?? 0 },
121
121
  );
122
122
  return EXIT.OK;
123
123
  }
@@ -150,7 +150,7 @@ export async function compileCommand(args, flags) {
150
150
  }
151
151
  }
152
152
 
153
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
153
+ emit(lines.join('\n'));
154
154
  return EXIT.OK;
155
155
  }
156
156
 
@@ -223,7 +223,6 @@ async function runSingleSkillCompile(skill, registry, { root, workspaceRoot, fla
223
223
  : null,
224
224
  proposal,
225
225
  },
226
- { maxBytes: flags.maxBytes ?? 0 },
227
226
  );
228
227
  return EXIT.OK;
229
228
  }
@@ -246,7 +245,7 @@ async function runSingleSkillCompile(skill, registry, { root, workspaceRoot, fla
246
245
  }
247
246
  }
248
247
 
249
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
248
+ emit(lines.join('\n'));
250
249
  if (!mode.apply) {
251
250
  note(`note dry-run nothing written; apply with: ${CLI} skills compile ${skill.name} --yes`);
252
251
  }
@@ -38,6 +38,7 @@ import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
38
38
  import { CLI } from '../name.mjs';
39
39
  import { MANIFEST_FILE } from '../name.mjs';
40
40
  import { nameProblems } from '../graph/spec.mjs';
41
+ import { commandRoot } from '../repo-root.mjs';
41
42
 
42
43
  /**
43
44
  * Where skills live in this repo. The estate already has a convention - a
@@ -82,7 +83,7 @@ export async function installCommand(args, flags) {
82
83
  const ws = await resolveWorkspace(flags);
83
84
  if (ws.error) return fail(`workspace config: ${ws.error}`, EXIT.CONFIG, 'fix the JSON, or remove the file');
84
85
 
85
- const root = flags.root ?? process.cwd();
86
+ const root = commandRoot(flags);
86
87
  const repo = await fingerprint(root);
87
88
  const pick = await chooseSource(skill, repo.signals, flags, { root, workspaceRoot: ws.root });
88
89
  if (pick.error) return fail(pick.error, EXIT.USAGE, pick.remedy);
@@ -210,7 +211,6 @@ export async function installCommand(args, flags) {
210
211
  ]
211
212
  .filter((l) => l !== null)
212
213
  .join('\n'),
213
- { maxBytes: flags.maxBytes ?? 0 },
214
214
  );
215
215
  }
216
216
 
@@ -52,6 +52,7 @@ import { coreIgnoreCase } from '../repo-root.mjs';
52
52
  import { readManifest } from '../projection/manifest.mjs';
53
53
  import { CLI, MANIFEST_FILE } from '../name.mjs';
54
54
  import { LOCAL_DIR } from '../state.mjs';
55
+ import { commandRoot } from '../repo-root.mjs';
55
56
 
56
57
  const exec = promisify(execFile);
57
58
 
@@ -125,6 +126,15 @@ function subjectFor(type, id, title) {
125
126
  return `${type}(${id.toLowerCase()}): ${description}`;
126
127
  }
127
128
 
129
+ /**
130
+ * The trailer of every commit this tool writes: attribution to the tool, in the
131
+ * form git renders as a co-author. The slice-commit rule ends every message it
132
+ * defines with the same line, so a commit the tool writes and one an agent
133
+ * writes from the rule agree on attribution. Module-local: one caller needs no
134
+ * export, and an export is a promise to a caller (deadcode).
135
+ */
136
+ const CO_AUTHOR = 'Co-Authored-By: Simt Dev <noreply@simt.space>';
137
+
128
138
  /** Resolve one named slice through `plan next`, which already knows missing and ambiguous. */
129
139
  async function resolveSlice(main, id) {
130
140
  const found = await planNext(main, id);
@@ -209,7 +219,7 @@ async function releaseSlice(main, id, session) {
209
219
  }
210
220
 
211
221
  export async function sliceCommand(sub, args, flags) {
212
- const root = flags.root ?? process.cwd();
222
+ const root = commandRoot(flags);
213
223
  if (sub === 'start') {
214
224
  const id = args[0];
215
225
  if (!id) return fail(`${CLI} slice start needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
@@ -286,7 +296,7 @@ async function startSlice(root, id, flags) {
286
296
  `state ${preview.from} -> ${preview.to}`,
287
297
  `apply ${apply}`,
288
298
  ];
289
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
299
+ emit(lines.join('\n'));
290
300
  return EXIT.OK;
291
301
  }
292
302
 
@@ -363,7 +373,7 @@ async function startSlice(root, id, flags) {
363
373
  lease === null ? 'lease (none)' : `lease slice ${id} as ${session}`,
364
374
  `state ${row.from} -> ${row.to}`,
365
375
  ];
366
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
376
+ emit(lines.join('\n'));
367
377
  return EXIT.OK;
368
378
  }
369
379
 
@@ -468,7 +478,7 @@ async function finishSlice(root, id, flags) {
468
478
  `state ${row.from} -> ${row.to}`,
469
479
  `apply ${apply}`,
470
480
  ];
471
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
481
+ emit(lines.join('\n'));
472
482
  return EXIT.OK;
473
483
  }
474
484
 
@@ -545,17 +555,18 @@ async function finishSlice(root, id, flags) {
545
555
  return EXIT.FAIL;
546
556
  }
547
557
  for (const n of gates.notes ?? []) note(`note ${n}`);
548
- emit(renderGateReport('pre-commit', declared, gates).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
558
+ emit(renderGateReport('pre-commit', declared, gates).join('\n'));
549
559
  return EXIT.FAIL;
550
560
  }
551
561
 
552
562
  let sha = null;
563
+ const message = `${subject}\n\n${CO_AUTHOR}`;
553
564
  try {
554
- await git(root, ['commit', '-m', subject]);
565
+ await git(root, ['commit', '-m', message]);
555
566
  sha = await git(root, ['rev-parse', 'HEAD']);
556
567
  } catch (error) {
557
568
  const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
558
- return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(subject)}`);
569
+ return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(message)}`);
559
570
  }
560
571
 
561
572
  const row = await planDone(main, id, { sha, test, dryRun: false });
@@ -596,7 +607,7 @@ async function finishSlice(root, id, flags) {
596
607
  ];
597
608
  if (row.evidenceRecorded) lines.push(`evidence ${sha}${test ? ` test: ${test}` : ''}`);
598
609
  else if (row.evidenceNote) lines.push(`note ${row.evidenceNote}`);
599
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
610
+ emit(lines.join('\n'));
600
611
  return EXIT.OK;
601
612
  }
602
613
 
@@ -645,7 +656,7 @@ async function abandonSlice(root, id, flags) {
645
656
  `state ${preview.from} -> ${preview.to}`,
646
657
  `apply ${apply}`,
647
658
  ];
648
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
659
+ emit(lines.join('\n'));
649
660
  return EXIT.OK;
650
661
  }
651
662
 
@@ -707,6 +718,6 @@ async function abandonSlice(root, id, flags) {
707
718
  return EXIT.OK;
708
719
  }
709
720
  const lines = [`repo ${root}`, `slice ${id} ${doc}`, ...removed, `state ${row.from} -> ${row.to}`, `lease ${leaseLine}`];
710
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
721
+ emit(lines.join('\n'));
711
722
  return EXIT.OK;
712
723
  }
@@ -0,0 +1,151 @@
1
+ // The command spec: one row per path this binary dispatches (review 0003 Q60).
2
+ //
3
+ // The list of commands existed in four places that disagreed - the dispatch map, a
4
+ // hand-written `--help` template, the completions table and the README - so a command
5
+ // could work and be undiscoverable, or be documented and absent. A row here is the one
6
+ // place a command is declared; `src/commands/index.mjs` derives its dispatchable paths
7
+ // and group defaults from it, and `test/spec.test.mjs` fails when the registry and the
8
+ // spec stop agreeing in either direction.
9
+ //
10
+ // The fields that only some consumers need (`tier`, `writes`, `args`, `flags`) arrive
11
+ // with the slices that read them: help (Q61), validation (Q62), MCP (Q66). A field no
12
+ // code reads is a field that drifts, and a wrong `writes` claim is worse than none.
13
+ //
14
+ // `defaultFor` marks the subcommand a bare group resolves to. It sits on the row that
15
+ // IS the default rather than on a separate map, so the reason for a default lives beside
16
+ // the command it names - `skills` alone indexes because the read is the safe default,
17
+ // `slice` alone starts because an id-less `start` fails before it writes anything.
18
+
19
+ /**
20
+ * @typedef {object} SpecRow
21
+ * @property {string} path the dispatch key: `status`, or `group subcommand`
22
+ * @property {string} summary one phrase, printed by help and by the generated docs
23
+ * @property {string} [defaultFor] the group whose bare form resolves to this row
24
+ * @property {string} [mcp] the MCP tool name that answers this row, when one does
25
+ */
26
+
27
+ /** @type {SpecRow[]} */
28
+ export const SPEC = [
29
+ { path: 'agent-hook', summary: 'the harness entry point: decide one tool call, or deliver the prompt packet', flags: ["diff","harness"], },
30
+ { path: 'agent-hooks install', summary: "write this repository's policies into each harness hooks file", writes: 'repo', flags: ["yes","diff","harness"], },
31
+ { path: 'agent-hooks status', summary: 'what each harness will run, and what is not trusted yet', defaultFor: 'agent-hooks', flags: ["diff","harness"], },
32
+ { path: 'agents install', summary: 'write the Simt-Agent into each harness agents file', writes: 'repo', flags: ["yes","diff","harness"], },
33
+ // A group like `agent-hooks`: `install` writes the agent file into each harness's own
34
+ // directory and `status` reports what a harness carries. The default is the read.
35
+ { path: 'agents status', summary: 'what each harness carries, and what is not installed yet', defaultFor: 'agents', flags: ["diff","harness"], },
36
+ { path: 'bench ablate', summary: 'measure one arm of the brief against its ablation', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
37
+ { path: 'bench critic', summary: 'score the review critic against the labelled bench set', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
38
+ { path: 'bench export', summary: 'write the sealed bench corpus as one signed document', writes: 'repo', flags: ["yes","all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
39
+ { path: 'bench import', summary: 'verify a sealed corpus and load it into the vault', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
40
+ { path: 'bench mine', summary: 'mine tasks out of a repository history', writes: 'repo', defaultFor: 'bench', flags: ["yes","all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
41
+ { path: 'bench probes', summary: 'run the deterministic probes against a workspace', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
42
+ { path: 'bench report', summary: 'summarise the runs recorded in the vault', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
43
+ { path: 'bench run', summary: 'run agents against the mined tasks', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
44
+ { path: 'bench variants', summary: 'run one task across its variant prompts', flags: ["all","arm","compare","cross-group","harness","labels","model","regrade","replace","reps","task"], },
45
+ { path: 'brief', mcp: { name: 'brief', flags: ['budget', 'paths', 'task'] }, summary: 'the packet for a task: rules, skills and the files it names, within a budget', flags: ["budget","explain","paths","task"], },
46
+ { path: 'check', mcp: { name: 'check', flags: [] }, summary: 'report stale or missing generated regions and every check finding', flags: ["check","diff","dry-run"], },
47
+ { path: 'ci', summary: "refresh simt-dev's own .github workflow and Dependabot files", flags: ["check","diff"], },
48
+ { path: 'compare', mcp: { name: 'compare', flags: [] }, summary: 'the adoption and drift matrix across every sibling repository', flags: [], },
49
+ { path: 'completions', summary: 'print a bash, zsh or fish completion script generated from this spec', flags: [], },
50
+ { path: 'decide', summary: 'scaffold a numbered decision record with its frontmatter', writes: 'repo', flags: ["yes","check","diff","dry-run","paths","supersedes","task"], },
51
+ { path: 'distill', summary: 'turn a repository into a skill or rule proposal', flags: [], },
52
+ { path: 'doctor', mcp: { name: 'doctor', flags: [] }, summary: 'report what is wrong with this installation', writes: 'repo', flags: ["yes","diff","fix"], },
53
+ { path: 'evidence', summary: 'the runs recorded against the tree on disk', flags: [], },
54
+ { path: 'evidence check', summary: 'a commit message claiming a run this tree does not have', flags: [], },
55
+ { path: 'explain', summary: 'what one check finding code means, and how to fix it', flags: [], },
56
+ { path: 'gate', summary: 'the tests a change reaches, and only those', flags: ["changed"], },
57
+ { path: 'graph lint', summary: 'check the skill registry against the skills on disk', defaultFor: 'graph', flags: ["spec"], },
58
+ { path: 'harness detect', summary: 'which harness is installed, and what it carries', defaultFor: 'harness', flags: ["at","reader","table","user"], },
59
+ { path: 'harness loaders', summary: 'the instruction files each harness loads, and what they cost', flags: ["at","reader","table","user"], },
60
+ { path: 'hooks install', summary: 'install the shared dispatcher and report what it would run', writes: 'repo', flags: ["yes","diff","dry-run"], },
61
+ { path: 'hooks run', summary: "run one git event's gates now, as git would", writes: 'repo', flags: ["yes","diff","dry-run"], },
62
+ { path: 'hooks status', summary: 'what each git event routes to, and whether it is wired', defaultFor: 'hooks', flags: ["diff","dry-run"], },
63
+ { path: 'ignore', summary: 'render the one ignore list into every harness ignore file', flags: ["diff"], },
64
+ { path: 'impact', summary: 'who a change reaches: importers, their tests and the contract', flags: [], },
65
+ { path: 'index', summary: 'rebuild the local file index from git blob hashes', flags: ["allow-remote","embed"], },
66
+ { path: 'init', summary: 'adopt this repository: manifest, region, hooks and toolchain pins', writes: 'repo', flags: ["yes","accept-conflicts","backup","diff","surfaces"], },
67
+ { path: 'lease', summary: 'claim files for one session, or list the claims', flags: ["harness","session","ttl"], },
68
+ { path: 'lessons capture', summary: 'scan the ledger and history for new lesson candidates', flags: ["diff","expires","paths","task"], },
69
+ { path: 'lessons list', summary: 'deterministic lesson candidates and the promoted lessons', defaultFor: 'lessons', flags: ["diff","expires","paths","task"], },
70
+ { path: 'lessons review', summary: 'promote one lesson candidate into docs/lessons', flags: ["diff","expires","paths","task"], },
71
+ { path: 'local doctor', summary: 'the local model endpoints, and whether they answer', defaultFor: 'local', flags: ["allow-remote"], },
72
+ { path: 'map', summary: 'the files a task is most likely to touch, within a byte budget', flags: ["allow-remote","budget","embed","level","recall","task"], values: { level: ['outline', 'names', 'signatures'] }, },
73
+ { path: 'mcp', summary: 'serve the read-only MCP surface over stdio', flags: ["list"], },
74
+ // Plan 0003 R45. The default is the read: `pack` alone lints the working directory,
75
+ // the way `skills` alone indexes and `graph` alone lints.
76
+ { path: 'pack lint', summary: 'lint a rule pack in the working directory', defaultFor: 'pack', flags: ["diff","require-measured","source"], },
77
+ { path: 'pack new', summary: 'scaffold a rule pack', flags: ["diff","require-measured","source"], },
78
+ { path: 'pack publish', summary: 'publish a rule pack', flags: ["diff","require-measured","source"], },
79
+ { path: 'plan done', mcp: { name: 'plan_done', flags: ['state', 'test'] }, summary: 'mark one tracker row complete, with its SHA and its evidence', flags: ["diff","state","test"], },
80
+ { path: 'plan next', mcp: { name: 'plan_next', flags: [] }, summary: "one ready slice's section, or the one named", flags: ["diff","state","test"], },
81
+ { path: 'plan status', summary: 'count the tracker rows', defaultFor: 'plan', flags: ["diff","state","test"], },
82
+ { path: 'plugin', summary: 'generate a harness package from the same tables the hooks use', flags: ["diff","out"], },
83
+ { path: 'pr-body', mcp: { name: 'pr_body', flags: ['base'] }, summary: 'the pull request body: evidence, decisions, gate state and residual risk', flags: ["base"], },
84
+ { path: 'review', summary: 'review the staged diff against the estate rules', flags: [], },
85
+ { path: 'rules decisions', summary: 'every known decision, and which layer each came from', writes: 'repo', flags: ["yes","all"], },
86
+ { path: 'rules list', mcp: { name: 'rules_list', flags: [] }, summary: 'the rule catalogue, marked with what applies to this repository', defaultFor: 'rules', flags: ["all"], },
87
+ { path: 'rules show', summary: 'one rule body', flags: ["all"], },
88
+ { path: 'rules why', mcp: { name: 'rules_why', flags: [] }, summary: 'the decision that settled a rule, and whether it is still in force', flags: ["all"], },
89
+ { path: 'shared install', summary: 'install the shared rules into a reader home directory', writes: 'repo', flags: ["yes","diff"], },
90
+ // ADR-084. `shared` is the one group whose default writes nothing, and it is `plan`
91
+ // for the same reason `skills`'s is `index`: the read answers "what would this do, and
92
+ // where", and `install` - the subcommand that touches a reader's home directory - is
93
+ // the one that needs `--yes` before it does anything.
94
+ { path: 'shared plan', summary: 'what the shared install would write, and where', writes: 'repo', defaultFor: 'shared', flags: ["yes","diff"], },
95
+ { path: 'skills brief', mcp: { name: 'skills_brief', flags: ['all', 'task'] }, summary: 'the packet for a task, read from the skill registry', flags: ["all", "plan", "task"], },
96
+ { path: 'skills compile', summary: 'compile a skill to the Agent Skills standard', flags: [], },
97
+ { path: 'skills eval', summary: 'score the skill registry against transcripts', flags: ["all","plan"], },
98
+ { path: 'skills find', mcp: { name: 'skills_find', flags: [] }, summary: 'skills that match a query', flags: ["all","plan"], },
99
+ { path: 'skills index', mcp: { name: 'skills_index', flags: [] }, summary: 'rebuild the skill index', defaultFor: 'skills', flags: ["all","plan"], },
100
+ { path: 'skills install', summary: 'install skills into the workspace', writes: 'repo', flags: ["yes","diff","from","variant"], },
101
+ { path: 'skills show', mcp: { name: 'skills_show', flags: [] }, summary: 'one skill packet', flags: ["all","plan"], },
102
+ { path: 'skills why', mcp: { name: 'skills_why', flags: [] }, summary: 'which skill covers a task', flags: ["all","plan"], },
103
+ { path: 'slice abandon', summary: 'remove a slice worktree, delete its branch, and return the row', flags: ["session","test","type"], },
104
+ { path: 'slice finish', summary: 'run the gates, commit the slice and record its evidence', writes: 'repo', flags: ["yes","session","test","type"], },
105
+ // Plan 0003 R53. Both halves write, so a bare `slice` has no read to default to.
106
+ // `start` is the default because with the id it is missing it fails with a usage error
107
+ // before anything is created - the one default here that cannot mutate.
108
+ { path: 'slice start', summary: 'give a slice its own worktree, branch and brief', defaultFor: 'slice', flags: ["session","test","type"], },
109
+ { path: 'status', summary: 'gate state from the last check', flags: ["line"], },
110
+ { path: 'sync', summary: 're-render the generated regions from the rule content', writes: 'repo', flags: ["yes","check","diff","dry-run"], },
111
+ { path: 'tests', summary: 'the tests that exercise a file', flags: ["cochange"], },
112
+ { path: 'tokens', summary: 'count tokens per instruction file and per skill', flags: ["repos","session"], },
113
+ { path: 'undo', summary: 'restore the last journaled set of writes, if untouched', writes: 'repo', flags: ["yes","diff"], },
114
+ { path: 'upgrade', summary: 'replace this installation with the pinned release', writes: 'repo', flags: ["yes","all","diff","workspace"], },
115
+ { path: 'why', summary: 'what governs a file: decisions, rules, skills, gates and lessons', flags: [], },
116
+ ];
117
+
118
+
119
+ /**
120
+ * Which tier a command belongs to, decided by its group: the commands a reader reaches
121
+ * for while working are core and appear in `--help`; the rest are advanced and appear
122
+ * under `help --all`. A tier is a presentation fact, not a permission, so a command
123
+ * cannot be missing from both pages - `test/help.test.mjs` checks that every path is on
124
+ * one of them (review 0003 Q61).
125
+ */
126
+ const CORE_GROUPS = new Set(['brief', 'check', 'gate', 'graph', 'hooks', 'impact', 'init', 'map', 'plan', 'rules', 'skills', 'slice', 'status', 'sync', 'tests', 'why']);
127
+
128
+ /** The tier a path is listed under: `core` or `advanced`. */
129
+ export function tierOf(path) {
130
+ return CORE_GROUPS.has(path.split(' ')[0]) ? 'core' : 'advanced';
131
+ }
132
+
133
+ /**
134
+ * The tools that answer a question no command asks.
135
+ *
136
+ * `federation_query` reads the federation config and consults a sibling repository
137
+ * directly (src/federation/query.mjs). There is no CLI verb for it, so giving it a spec
138
+ * row would advertise a command this binary cannot dispatch; leaving it undeclared is
139
+ * how a tool list and a command list drift apart. It is listed here with the reason
140
+ * instead, and test/mcp-spec.test.mjs accepts a tool without a row only when this table
141
+ * says why.
142
+ */
143
+ export const MCP_ONLY = [
144
+ { tool: 'federation_query', why: 'a transport-only read: no command in this binary asks a sibling repository a question' },
145
+ ];
146
+
147
+ /** The dispatchable paths, sorted - the answer to "what can this binary do". */
148
+ export const SPEC_PATHS = SPEC.map((row) => row.path).sort();
149
+
150
+ /** group -> the subcommand its bare form resolves to. */
151
+ export const GROUP_DEFAULTS = new Map(SPEC.filter((row) => row.defaultFor !== undefined).map((row) => [row.defaultFor, row.path.split(' ')[1]]));
@@ -10,12 +10,13 @@
10
10
  // only mode this command has is `--line`, and running something else by
11
11
  // default would invent a second answer to what the status is.
12
12
 
13
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
13
+ import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
14
14
  import { CLI } from '../name.mjs';
15
15
  import { currentHead, readCheckCache, renderStatusLine } from '../status.mjs';
16
+ import { commandRoot } from '../repo-root.mjs';
16
17
 
17
18
  export async function statusCommand(args, flags) {
18
- const root = flags.root ?? args[0] ?? process.cwd();
19
+ const root = commandRoot(flags, args);
19
20
  if (flags.line !== true) {
20
21
  return fail('status needs --line', EXIT.USAGE, `run: ${CLI} status --line --root ${root}`);
21
22
  }
@@ -25,11 +26,7 @@ export async function statusCommand(args, flags) {
25
26
  // Informational, not a failure: a repository that never ran `check` — or
26
27
  // one a writer just invalidated — has no state to report, and a prompt
27
28
  // that errored until the first check would train its reader to ignore it.
28
- if (isJson(flags)) {
29
- emitJson({ ok: true, command: 'status', root, line: null, cached: false, stale: false });
30
- return EXIT.OK;
31
- }
32
- emit('simt no cache - run check');
29
+ present({ ok: true, command: 'status', root, line: null, cached: false, stale: false }, () => 'simt no cache - run check', flags);
33
30
  return EXIT.OK;
34
31
  }
35
32