simt-dev 0.6.3 → 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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. 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
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
package/src/mcp/tools.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  import { loadRegistry } from '../graph/load.mjs';
23
23
  import { provenanceDigest } from '../projection/project.mjs';
24
24
  import { discover } from '../compare/discover.mjs';
25
- import { buildRow, summarise } from '../compare/row.mjs';
25
+ import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
27
  import { checkRepo } from '../commands/sync.mjs';
28
28
  import { diagnose, serialisedFindings } from '../commands/doctor.mjs';
@@ -248,9 +248,10 @@ const output = {
248
248
  workspace: { type: 'string' },
249
249
  body: { type: ['string', 'null'] },
250
250
  bodyFrom: { type: 'string' },
251
- bodyText: { type: 'string' },
251
+ bodyText: { type: ['string', 'null'] },
252
+ reason: { type: ['string', 'null'], description: 'why there is no body, when there is none' },
252
253
  },
253
- required: ['name', 'bytes', 'bodyFrom', 'requires', 'variants'],
254
+ required: ['name', 'bytes', 'body', 'bodyFrom', 'bodyText', 'reason', 'requires', 'variants'],
254
255
  },
255
256
  skills_find: {
256
257
  type: 'object',
@@ -381,6 +382,9 @@ const output = {
381
382
  repos: { type: 'array', items: { type: 'object' } },
382
383
  worktrees: { type: 'array', items: { type: 'object' } },
383
384
  directories: { type: 'array', items: { type: 'string' } },
385
+ partial: { type: 'boolean' },
386
+ cursor: { type: ['string', 'null'] },
387
+ total: { type: 'number' },
384
388
  },
385
389
  required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
386
390
  },
@@ -423,6 +427,7 @@ const output = {
423
427
  cut: { type: 'array', items: { type: 'string' } },
424
428
  sections: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } },
425
429
  text: { type: 'string' },
430
+ note: { type: ['string', 'null'] },
426
431
  },
427
432
  required: ['root', 'kind', 'basis', 'task', 'scope', 'budget', 'bytes', 'before', 'floorHeld', 'cut', 'sections', 'text'],
428
433
  },
@@ -525,7 +530,7 @@ export const TOOLS = [
525
530
  outputSchema: output.doctor,
526
531
  name: 'doctor',
527
532
  description:
528
- 'Classify a repository: stack tags, emitted facts with the file that proved each, and manifest presence. Call this first when the repo is unknown.',
533
+ 'Classify a repository: stack tags, the facts with the file that proved each, and manifest presence. Call this first when the repo is unknown. Read-only: `--fix` and `--diff` stay on the command.',
529
534
  inputSchema: rootArg,
530
535
  async run(args) {
531
536
  const root = rootOf(args);
@@ -548,7 +553,13 @@ export const TOOLS = [
548
553
  name: 'skills_index',
549
554
  description:
550
555
  'Full skill catalogue: every skill with summary, size, stack tags and deferred flag. The fallback when brief returns nothing useful.',
551
- inputSchema: noArgs,
556
+ inputSchema: {
557
+ type: 'object',
558
+ properties: {
559
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
560
+ },
561
+ required: [],
562
+ },
552
563
  async run(args) {
553
564
  const { registry: reg, verified, workspace } = await registry(args);
554
565
  return {
@@ -568,7 +579,7 @@ export const TOOLS = [
568
579
  outputSchema: output.skills_brief,
569
580
  name: 'skills_brief',
570
581
  description:
571
- 'THE entry point. Resolve the skill set for a task: selected with why, deferred, and not selected with reason - the reasons let you override the resolver instead of guessing at it.',
582
+ 'THE entry point. Resolve the skill set for a task: selected with why, deferred, and not selected with the reason to override.',
572
583
  inputSchema: {
573
584
  type: 'object',
574
585
  properties: {
@@ -578,6 +589,7 @@ export const TOOLS = [
578
589
  budget: { type: 'number', description: 'context budget in bytes (default 20480)' },
579
590
  skill: { type: 'string', description: 'force a skill in, comma-separated' },
580
591
  all: { type: 'boolean', description: 'ignore the budget' },
592
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
581
593
  },
582
594
  required: [],
583
595
  },
@@ -610,18 +622,25 @@ export const TOOLS = [
610
622
  name: { type: 'string' },
611
623
  root: { type: 'string' },
612
624
  maxBytes: { type: 'number', description: 'hard ceiling on the returned text' },
625
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
613
626
  },
614
627
  required: ['name'],
615
628
  },
616
629
  async run(args) {
617
630
  const root = rootOf(args);
618
631
  const facts = await skillsShowFacts(root, args?.name, args ?? {});
632
+ // The schema promises `body: null` when there is no body, and this used to hand the
633
+ // prose that says so to the field a client tests for null: a caller checking `body`
634
+ // for null read a paragraph as a body (Q56). The prose moves to `reason`, which is
635
+ // where a model looks for what to do about it.
636
+ const missing = typeof facts.bodyText === 'string' && facts.bodyText.startsWith('(body unavailable');
619
637
  return {
620
638
  ...facts.skill,
621
639
  workspace: facts.workspace,
622
- body: facts.body,
640
+ body: missing ? null : facts.body,
623
641
  bodyFrom: facts.bodyFrom,
624
- bodyText: facts.bodyText,
642
+ bodyText: missing ? null : facts.bodyText,
643
+ reason: missing ? facts.bodyText : null,
625
644
  };
626
645
  },
627
646
  },
@@ -632,11 +651,16 @@ export const TOOLS = [
632
651
  description: 'Raw search across capability, keyword and summary. Use when skills_brief has nothing and you suspect a skill exists.',
633
652
  inputSchema: {
634
653
  type: 'object',
635
- properties: { query: { type: 'string' } },
654
+ properties: {
655
+ query: { type: 'string' },
656
+ root: { type: 'string' },
657
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
658
+ },
636
659
  required: ['query'],
637
660
  },
638
661
  async run(args) {
639
- return skillsFindFacts(typeof args?.query === 'string' ? args.query : '', args ?? {});
662
+ const root = rootOf(args);
663
+ return skillsFindFacts(root, typeof args?.query === 'string' ? args.query : '', args ?? {});
640
664
  },
641
665
  },
642
666
 
@@ -647,7 +671,12 @@ export const TOOLS = [
647
671
  'The signal chain for one skill in one repository: its score, the reasons, whether the stack gate admits it, and what would select it.',
648
672
  inputSchema: {
649
673
  type: 'object',
650
- properties: { name: { type: 'string' }, root: { type: 'string' }, task: { type: 'string' } },
674
+ properties: {
675
+ name: { type: 'string' },
676
+ root: { type: 'string' },
677
+ task: { type: 'string' },
678
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
679
+ },
651
680
  required: ['name'],
652
681
  },
653
682
  async run(args) {
@@ -710,23 +739,37 @@ export const TOOLS = [
710
739
  outputSchema: output.compare,
711
740
  name: 'compare',
712
741
  description:
713
- 'The workspace matrix: every repository with its adoption, region state, pinned SHA, hook routing and gates, plus the problems found.',
742
+ 'The workspace matrix: adoption, region, SHA, hooks and gates per repo, plus problems. Pages with limit/cursor; honors progressToken and cancellation.',
714
743
  inputSchema: {
715
744
  type: 'object',
716
745
  properties: {
717
746
  root: { type: 'string', description: 'a workspace directory, or one repository' },
718
747
  workspace: { type: 'string', description: 'workspace root when root is not given' },
748
+ limit: { type: 'number', description: 'page size; without it every row is built' },
749
+ cursor: { type: 'string', description: 'opaque page start from a previous partial answer' },
750
+ concurrency: { type: 'number', description: 'rows built at once (default 4)' },
719
751
  },
720
752
  required: [],
721
753
  },
722
- async run(args) {
754
+ async run(args, ctx = {}) {
723
755
  const root = args?.root ?? args?.workspace ?? (await resolveWorkspace(args ?? {})).root ?? process.cwd();
724
756
  const found = await discover(root);
725
757
  if (found.error) throw Object.assign(new Error(`${root}: ${found.error}`), { remedy: 'pass root' });
726
758
  const expectedSha = await provenanceDigest();
727
759
  const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
728
- const rows = [];
729
- for (const repo of found.repos) rows.push(await buildRow(repo, context));
760
+ // A page names its start as an index into discovery order, which is
761
+ // stable between calls on an unchanged estate; garbage parses to 0
762
+ // rather than erroring, because a dashboard that refuses to run over a
763
+ // stale cursor is worse than one that restarts the listing.
764
+ const start = Math.max(0, parseInt(typeof args?.cursor === 'string' ? args.cursor : '0', 10) || 0);
765
+ const limit = typeof args?.limit === 'number' && args.limit >= 1 ? Math.floor(args.limit) : null;
766
+ const page = limit === null ? found.repos : found.repos.slice(start, start + limit);
767
+ const rows = await buildRows(page, context, {
768
+ concurrency: resolveConcurrency(args ?? {}),
769
+ signal: ctx?.signal ?? null,
770
+ onBatch: ctx?.onProgress ? (done, total) => ctx.onProgress(start + done, found.repos.length) : null,
771
+ });
772
+ const next = limit !== null && start + page.length < found.repos.length ? String(start + page.length) : null;
730
773
  return {
731
774
  root,
732
775
  shape: found.shape,
@@ -735,6 +778,7 @@ export const TOOLS = [
735
778
  repos: rows,
736
779
  worktrees: found.worktrees.map((w) => ({ name: w.name, owner: w.ownerName })),
737
780
  directories: found.directories,
781
+ ...(limit === null ? {} : { partial: next !== null, cursor: next, total: found.repos.length }),
738
782
  };
739
783
  },
740
784
  },
@@ -869,7 +913,37 @@ export const TOOLS = [
869
913
  build: false,
870
914
  });
871
915
  if (result.error) throw Object.assign(new Error('the index could not be read'), { remedy: `run: ${CLI} index` });
872
- if (result.cold) throw Object.assign(new Error('this repository has no index to answer from'), { remedy: `run: ${CLI} index` });
916
+ // A repository with no index is not a failed call. Every surface points here first,
917
+ // so the one tool told to be the entry point must answer in a repository that has
918
+ // never been indexed - it falls back to the skill packet, which is read from the
919
+ // package rather than the tree, and names the command that unlocks the rest (Q56).
920
+ if (result.cold) {
921
+ const task = typeof args?.task === 'string' ? args.task : '';
922
+ const facts = await skillsBriefFacts(root, { ...(args ?? {}), task });
923
+ // Through JSON once, so the packet carries exactly what the text block shows: a
924
+ // property whose value is `undefined` survives `stringify` as nothing at all, and
925
+ // the two blocks would then describe different objects.
926
+ const text = JSON.stringify(facts, null, 2);
927
+ const packet = JSON.parse(text);
928
+ const size = Buffer.byteLength(text);
929
+ return {
930
+ root,
931
+ kind: 'skills',
932
+ basis: 'registry',
933
+ task,
934
+ scope: [],
935
+ budget,
936
+ bytes: size,
937
+ before: size,
938
+ // The skill packet is one document and is not cut to fit: saying the floor held
939
+ // when the answer is over it is the class of claim this review exists to remove.
940
+ floorHeld: size <= budget,
941
+ cut: size <= budget ? [] : ['text'],
942
+ sections: { skills: (packet.resolved ?? []).map((s) => s.name) },
943
+ text,
944
+ note: `no index here, so this is the skill packet; run: ${CLI} index for the repository packet`,
945
+ };
946
+ }
873
947
  return {
874
948
  root: result.root,
875
949
  kind: result.kind,
@@ -8,6 +8,7 @@
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { resolve } from 'node:path';
10
10
 
11
+ import { CLI } from '../name.mjs';
11
12
  import { tokens } from './context.mjs';
12
13
 
13
14
  const RULE_PATH_RE =
@@ -53,14 +54,14 @@ export async function measureSession(transcriptPath) {
53
54
  return {
54
55
  ok: false,
55
56
  error: '--session requires a local transcript path',
56
- remedy: 'pass a local JSONL or JSON transcript path: simt tokens --session <path>',
57
+ remedy: `pass a local JSONL or JSON transcript path: ${CLI} tokens --session <path>`,
57
58
  };
58
59
  }
59
60
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(transcriptPath.trim())) {
60
61
  return {
61
62
  ok: false,
62
63
  error: `${transcriptPath}: remote URLs are refused; session transcripts are read locally only (ADR-075)`,
63
- remedy: 'pass a local file path on this machine: simt tokens --session ./session.jsonl',
64
+ remedy: `pass a local file path on this machine: ${CLI} tokens --session ./session.jsonl`,
64
65
  };
65
66
  }
66
67
 
@@ -7,7 +7,8 @@ import { execFile } from 'node:child_process';
7
7
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { promisify } from 'node:util';
10
- import { journaledWriteFile } from '../journal.mjs';
10
+ import { recordCreatedFile } from '../journal.mjs';
11
+ import { decisionsDir } from '../decisions/dir.mjs';
11
12
 
12
13
  const exec = promisify(execFile);
13
14
 
@@ -336,15 +337,19 @@ function renderDecisionScaffold({
336
337
  ].join('\n');
337
338
  }
338
339
 
339
- /** Scaffold a new ADR in `<root>/docs/decisions` with structured frontmatter. */
340
+ /**
341
+ * Scaffold a new ADR in the repository's decisions directory with structured frontmatter.
342
+ *
343
+ * The number is claimed with `O_EXCL`: two concurrent scaffolds that both read
344
+ * the same maximum must not land on the same number, and a check-then-write
345
+ * would let the second silently overwrite the first. On collision the number is
346
+ * re-read and the next free one taken, so the retry always terminates.
347
+ */
340
348
  export async function scaffoldDecision(root, options = {}) {
341
- const decisionsDir = options.decisionsDir ?? join(root, 'docs', 'decisions');
342
- const { number, id } = await nextDecisionNumber(decisionsDir);
349
+ const relDir = options.decisionsDir ?? (await decisionsDir(root));
350
+ const absDir = join(root, relDir);
343
351
  const title = String(options.title ?? 'Untitled decision').trim() || 'Untitled decision';
344
352
  const slug = slugifyTitle(title);
345
- const file = `${id}-${slug}.md`;
346
- const relPath = ['docs', 'decisions', file].join('/');
347
- const fullPath = join(decisionsDir, file);
348
353
  const affects = Array.isArray(options.affects)
349
354
  ? options.affects.map((p) => String(p).trim()).filter(Boolean)
350
355
  : await diffAffectedPaths(root);
@@ -352,38 +357,58 @@ export async function scaffoldDecision(root, options = {}) {
352
357
  const { entries: supersedes, errors } = parseSupersedesSpec(options.supersedes);
353
358
  if (errors.length > 0) return { ok: false, errors };
354
359
 
355
- const text = renderDecisionScaffold({
356
- id,
357
- title,
358
- date: options.date,
359
- status: options.status,
360
- affects,
361
- supersedes,
362
- });
363
-
364
- // A caller-supplied journal records the scaffold so `undo` can restore it
365
- // (R49). Callers without one (probes, tests staging fixtures) keep the old
366
- // behaviour exactly.
367
- if (options.write) {
368
- await mkdir(decisionsDir, { recursive: true });
369
- if (options.journal) await journaledWriteFile(root, options.journal, fullPath, text);
370
- else await writeFile(fullPath, text, 'utf8');
360
+ // A dry run names the number without claiming it: the file must not exist
361
+ // afterwards, so no exclusive create happens here.
362
+ if (!options.write) {
363
+ const { number, id } = await nextDecisionNumber(absDir);
364
+ const file = `${id}-${slug}.md`;
365
+ return {
366
+ ok: true,
367
+ id,
368
+ number,
369
+ title,
370
+ slug,
371
+ file,
372
+ relPath: [relDir, file].join('/'),
373
+ path: join(absDir, file),
374
+ affects,
375
+ supersedes,
376
+ text: renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes }),
377
+ written: false,
378
+ };
371
379
  }
372
380
 
373
- return {
374
- ok: true,
375
- id,
376
- number,
377
- title,
378
- slug,
379
- file,
380
- relPath,
381
- path: fullPath,
382
- affects,
383
- supersedes,
384
- text,
385
- written: Boolean(options.write),
386
- };
381
+ await mkdir(absDir, { recursive: true });
382
+ for (let attempt = 0; attempt < 100; attempt += 1) {
383
+ const { number, id } = await nextDecisionNumber(absDir);
384
+ const file = `${id}-${slug}.md`;
385
+ const fullPath = join(absDir, file);
386
+ const text = renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes });
387
+ try {
388
+ await writeFile(fullPath, text, { flag: 'wx' });
389
+ } catch (error) {
390
+ if (error.code !== 'EEXIST') throw error;
391
+ continue;
392
+ }
393
+ // The exclusive create is the journal entry's `before: null`: the file did
394
+ // not exist a moment ago, by construction rather than by re-reading.
395
+ if (options.journal) recordCreatedFile(root, options.journal, fullPath, text);
396
+ return {
397
+ ok: true,
398
+ id,
399
+ number,
400
+ title,
401
+ slug,
402
+ file,
403
+ relPath: [relDir, file].join('/'),
404
+ path: fullPath,
405
+ affects,
406
+ supersedes,
407
+ text,
408
+ written: true,
409
+ };
410
+ }
411
+ return { ok: false, errors: ['could not claim a free ADR number after 100 attempts'] };
387
412
  }
388
413
 
389
414
  /** Extract partial supersession claims stated in prose (`Status:` and header paragraphs). */
@@ -415,8 +440,15 @@ function prosePartialSupersessions(body) {
415
440
  return claims;
416
441
  }
417
442
 
418
- /** Lint a single ADR document for structured supersession conformance (plan 0003 R34). */
419
- export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = false } = {}) {
443
+ /**
444
+ * Lint a single ADR document for structured supersession conformance (plan 0003 R34).
445
+ *
446
+ * `historicalCutoff` exempts the first N records from the frontmatter requirement:
447
+ * they predate it, and demanding it retroactively would fail a history nobody can
448
+ * rewrite without rewriting history. Q55: the number lives in the repository's
449
+ * config (`decisions.historicalCutoff`, resolved by the caller), never in code.
450
+ */
451
+ export function lintDecision(text, { file = 'ADR-000.md', historicalCutoff = 0 } = {}) {
420
452
  const fileMatch = file !== 'ADR-000.md' ? /^(ADR-\d{3,})/i.exec(file) : null;
421
453
  const headMatch = /^#\s+(ADR-\d{3,})\b/m.exec(String(text ?? ''));
422
454
  const id = (fileMatch?.[1] ?? headMatch?.[1] ?? /^(ADR-\d{3,})/i.exec(file)?.[1] ?? '').toUpperCase() || null;
@@ -432,7 +464,7 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
432
464
  remedy: 'fix simt-supersedes frontmatter to use [{ adr: "ADR-NNN", ruling: N }]',
433
465
  }));
434
466
 
435
- if (allowHistorical && !fm.hasFrontmatter && num !== null && num > 0 && num <= 96) {
467
+ if (!fm.hasFrontmatter && num !== null && num > 0 && num <= historicalCutoff) {
436
468
  return { ok: findings.length === 0, findings };
437
469
  }
438
470
 
@@ -455,13 +487,36 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
455
487
  return { ok: findings.length === 0, findings };
456
488
  }
457
489
 
458
- /** Lint all ADR records in a decisions directory. */
459
- export async function lintDecisions(decisionsDir, { allowHistorical = false } = {}) {
490
+ /**
491
+ * Lint all ADR records in a decisions directory.
492
+ *
493
+ * Duplicate ids are a lint finding, not a silent keep-first: two files claiming
494
+ * one number means `byId` answers for only one of them, and the reader of the
495
+ * other gets a record the index cannot see. The kept record is the first in
496
+ * numeric filename order — the same one the loader keeps — so lint and load
497
+ * never name different winners.
498
+ */
499
+ export async function lintDecisions(decisionsDir, { historicalCutoff = 0 } = {}) {
460
500
  const names = (await readdir(decisionsDir).catch(() => [])).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n)).sort();
461
501
  const findings = [];
502
+ const seen = new Map();
462
503
  for (const file of names) {
504
+ const id = /^(ADR-\d{3,})/i.exec(file)?.[1]?.toUpperCase() ?? null;
505
+ if (id !== null) {
506
+ if (seen.has(id)) {
507
+ findings.push({
508
+ file,
509
+ id,
510
+ status: 'duplicate-decision-id',
511
+ detail: `decision id ${id} is claimed by ${seen.get(id)} and ${file}; the index only sees the first`,
512
+ remedy: 'renumber the newer record to a free number, then rerun the check',
513
+ });
514
+ continue;
515
+ }
516
+ seen.set(id, file);
517
+ }
463
518
  const text = await readFile(join(decisionsDir, file), 'utf8');
464
- findings.push(...lintDecision(text, { file, allowHistorical }).findings);
519
+ findings.push(...lintDecision(text, { file, historicalCutoff }).findings);
465
520
  }
466
521
  return { ok: findings.length === 0, findings };
467
522
  }