simt-dev 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
package/src/mcp/tools.mjs CHANGED
@@ -27,6 +27,10 @@ import { discover } from '../compare/discover.mjs';
27
27
  import { buildRow, summarise } from '../compare/row.mjs';
28
28
  import { GATES } from '../hooks/gates.mjs';
29
29
  import { dirtyPaths, parsePathList, readBody } from '../commands/skills.mjs';
30
+ import { assembleBrief } from '../brief/assemble.mjs';
31
+ import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
32
+ import { readFederation } from '../federation/config.mjs';
33
+ import { consult } from '../federation/query.mjs';
30
34
  import { planNext, planDone } from '../plan/index.mjs';
31
35
  import { resolveWorkspace } from '../workspace.mjs';
32
36
  import { measureSkill } from '../graph/load.mjs';
@@ -333,6 +337,47 @@ const output = {
333
337
  },
334
338
  required: ['root', 'id', 'wouldChange', 'dryRun'],
335
339
  },
340
+ brief: {
341
+ type: 'object',
342
+ properties: {
343
+ root: { type: 'string' },
344
+ kind: { type: 'string' },
345
+ basis: { type: 'string' },
346
+ task: { type: 'string' },
347
+ scope: { type: 'array', items: { type: 'string' } },
348
+ budget: { type: 'number' },
349
+ bytes: { type: 'number' },
350
+ before: { type: 'number' },
351
+ floorHeld: { type: 'boolean' },
352
+ cut: { type: 'array', items: { type: 'string' } },
353
+ sections: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } },
354
+ text: { type: 'string' },
355
+ },
356
+ required: ['root', 'kind', 'basis', 'task', 'scope', 'budget', 'bytes', 'before', 'floorHeld', 'cut', 'sections', 'text'],
357
+ },
358
+ federation_query: {
359
+ type: 'object',
360
+ properties: {
361
+ root: { type: 'string' },
362
+ query: { type: 'string' },
363
+ servers: { type: 'array', items: { type: 'string' } },
364
+ answers: {
365
+ type: 'array',
366
+ items: {
367
+ type: 'object',
368
+ properties: {
369
+ server: { type: 'string' },
370
+ tool: { type: 'string' },
371
+ query: { type: 'string' },
372
+ text: { type: 'string' },
373
+ },
374
+ required: ['server', 'tool', 'query', 'text'],
375
+ },
376
+ },
377
+ findings: { type: 'array', items: { type: 'string' } },
378
+ },
379
+ required: ['root', 'query', 'servers', 'answers', 'findings'],
380
+ },
336
381
  /** The shape of every tool failure, which is a result and not a protocol error. */
337
382
  error: ERROR_SHAPE,
338
383
  };
@@ -624,7 +669,7 @@ export const TOOLS = [
624
669
  const { decisions } = await loadDecisions();
625
670
  const index = byId(decisions);
626
671
 
627
- if (/^ADR-\d{3}$/i.test(what)) {
672
+ if (/^ADR-\d{3,}$/i.test(what)) {
628
673
  const id = what.toUpperCase();
629
674
  const d = decisions.find((x) => x.id === id);
630
675
  if (!d) throw new Error(`no decision record ${id}`);
@@ -772,6 +817,98 @@ export const TOOLS = [
772
817
  };
773
818
  },
774
819
  },
820
+ {
821
+ outputSchema: output.brief,
822
+ name: 'brief',
823
+ description:
824
+ 'The packet for one task, in the bytes a prompt can carry: the kind of task it is, the commands that kind needs, the files the index ranks highest, the tests that cover them, the decisions that govern them, the skills that apply and the lessons that name those paths. Every argument is optional - with no task it describes the repository itself.',
825
+ inputSchema: {
826
+ type: 'object',
827
+ properties: {
828
+ root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
829
+ task: { type: 'string', description: "what the agent is about to do, in the user's own words" },
830
+ paths: { type: 'string', description: 'comma-separated paths to scope the packet to, instead of the files git reports as modified' },
831
+ budget: { type: 'number', description: 'byte ceiling; 0 means the whole packet (default 2048)' },
832
+ },
833
+ required: [],
834
+ },
835
+ async run(args) {
836
+ const root = rootOf(args);
837
+ const budget = Number.isFinite(args?.budget) && args.budget >= 0 ? args.budget : DEFAULT_BRIEF_BYTES;
838
+ // Read, never build: every tool here declares itself read-only, and a call that
839
+ // indexed the tree would make that declaration false. A brief with no index to read
840
+ // says which command makes one - the same rule the delivered packet follows, so a
841
+ // pull and a push pay the same price and neither one writes.
842
+ const result = await assembleBrief({
843
+ root,
844
+ task: typeof args?.task === 'string' ? args.task : '',
845
+ paths: typeof args?.paths === 'string' ? args.paths : null,
846
+ budget,
847
+ build: false,
848
+ });
849
+ if (result.error) throw Object.assign(new Error('the index could not be read'), { remedy: `run: ${CLI} index` });
850
+ if (result.cold) throw Object.assign(new Error('this repository has no index to answer from'), { remedy: `run: ${CLI} index` });
851
+ return {
852
+ root: result.root,
853
+ kind: result.kind,
854
+ basis: result.basis,
855
+ task: result.task,
856
+ scope: result.scope,
857
+ budget: result.budget,
858
+ bytes: result.packed.after,
859
+ before: result.packed.before,
860
+ floorHeld: result.packed.floorHeld,
861
+ cut: result.packed.cut,
862
+ sections: result.sections,
863
+ text: result.packed.text,
864
+ };
865
+ },
866
+ },
867
+ {
868
+ outputSchema: output.federation_query,
869
+ name: 'federation_query',
870
+ description:
871
+ 'Ask a code-graph server this repository declared one structural question, and return its answer with the name of the server that gave it. Read-only: it reads the "federation" block from simt.json and starts only the servers the manifest itself names, and a repository that declared none is told so rather than queried.',
872
+ inputSchema: {
873
+ type: 'object',
874
+ properties: {
875
+ root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
876
+ query: { type: 'string', description: "the structural question to ask, in the user's own words" },
877
+ server: { type: 'string', description: 'ask only this declared server, by name; omit to ask every one of them' },
878
+ },
879
+ required: ['query'],
880
+ },
881
+ async run(args) {
882
+ const root = rootOf(args);
883
+ const query = typeof args?.query === 'string' ? args.query.trim() : '';
884
+ if (query.length === 0) {
885
+ throw Object.assign(new Error('the query is empty'), {
886
+ remedy: 'pass a query, for example the symbol or file to look up',
887
+ });
888
+ }
889
+ const { servers, findings } = await readFederation(root);
890
+ if (servers.length === 0) {
891
+ throw Object.assign(new Error('this repository declares no federation server'), {
892
+ remedy: 'add a "federation" block naming a server to simt.json, or use the brief tool',
893
+ });
894
+ }
895
+ const only = typeof args?.server === 'string' && args.server.length > 0 ? args.server : null;
896
+ const chosen = only === null ? servers : servers.filter((server) => server.name === only);
897
+ if (only !== null && chosen.length === 0) {
898
+ throw Object.assign(new Error(`no declared federation server is named ${only}`), {
899
+ remedy: `declared servers: ${servers.map((server) => server.name).join(', ')}`,
900
+ });
901
+ }
902
+ const { answers, findings: failures } = await consult({ root, query, servers: chosen });
903
+ return {
904
+ root,
905
+ query,
906
+ servers: chosen.map((server) => server.name),
907
+ answers,
908
+ findings: [...findings, ...failures],
909
+ };
910
+ },
911
+ },
775
912
  ];
776
913
 
777
914
  export function toolNames() {
@@ -26,7 +26,12 @@ import { basename, join } from 'node:path';
26
26
 
27
27
  import { resolveSurfaces } from '../projection/surfaces.mjs';
28
28
  import { parse } from '../projection/markers.mjs';
29
- import { budgetFinding, DEFAULT_BUDGET_BYTES, readManifest } from '../projection/manifest.mjs';
29
+ import {
30
+ budgetFinding,
31
+ declaredBudget,
32
+ DEFAULT_BUDGET_BYTES,
33
+ readManifest,
34
+ } from '../projection/manifest.mjs';
30
35
  import { extractFrontmatter } from '../projection/targets.mjs';
31
36
  import { readLimitFindings } from '../projection/project.mjs';
32
37
  import { loader } from '../projection/loaders.mjs';
@@ -328,10 +333,7 @@ export async function measureRoot(root) {
328
333
 
329
334
  const generatedBytes = documents.reduce((sum, d) => sum + d.generatedBytes, 0);
330
335
  const readerBytes = documents.reduce((sum, d) => sum + d.readerBytes, 0);
331
- const budgetBytes =
332
- manifest?.budget?.bytes !== undefined && Number.isFinite(manifest.budget.bytes)
333
- ? manifest.budget.bytes
334
- : ALWAYS_ON_BUDGET_BYTES;
336
+ const budgetBytes = declaredBudget(manifest);
335
337
 
336
338
  const byChannel = new Map();
337
339
  for (const doc of documents) {
@@ -0,0 +1,462 @@
1
+ // Decision assistant (plan 0003 R34): scaffold ADRs with structured frontmatter
2
+ // (`simt-affects` from the diff and `simt-supersedes: [{adr, ruling}]`), parse
3
+ // frontmatter first for `rules why` and `why`, and fail lint on partial
4
+ // supersessions stated only in prose.
5
+
6
+ import { execFile } from 'node:child_process';
7
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+
11
+ const exec = promisify(execFile);
12
+
13
+ export const DECISION_KEYS = ['simt-affects', 'simt-supersedes'];
14
+
15
+ const VALID_KINDS = new Set(['superseded', 'narrowed', 'withdrawn']);
16
+
17
+ export const PARTIAL_CLAUSE =
18
+ /\b(?:one ruling|rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*))\s+(superseded|narrowed|withdrawn)\s+by\s+(ADR-\d{3,}|the same record)\b/gi;
19
+
20
+ const ACTIVE_PARTIAL_AFTER_RE =
21
+ /\b(supersedes?|narrowed|narrows|withdrawn|withdraws|amends?|refines?)\s+(?:\[?(ADR-\d{3,})\]?(?:\([^)]*\))?)\s+rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*)\b/gi;
22
+
23
+ const ACTIVE_PARTIAL_BEFORE_RE =
24
+ /\b(supersedes?|narrowed|narrows|withdrawn|withdraws|amends?|refines?)\s+rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*)\s+of\s+(?:\[?(ADR-\d{3,})\]?(?:\([^)]*\))?)\b/gi;
25
+
26
+ function normaliseKind(verb) {
27
+ const v = String(verb ?? 'superseded').toLowerCase();
28
+ if (v.startsWith('narrow')) return 'narrowed';
29
+ if (v.startsWith('withdraw')) return 'withdrawn';
30
+ return 'superseded';
31
+ }
32
+
33
+ function unquote(value) {
34
+ const t = String(value ?? '').trim();
35
+ return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))
36
+ ? t.slice(1, -1)
37
+ : t;
38
+ }
39
+
40
+ /** Strip leading YAML frontmatter (`---\n...\n---`) from a markdown document. */
41
+ export function splitFrontmatter(text) {
42
+ const raw = String(text ?? '');
43
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
44
+ return match ? { front: match[1], body: raw.slice(match[0].length) } : { front: null, body: raw };
45
+ }
46
+
47
+ function parseInlineStringList(inner) {
48
+ return inner.trim() ? inner.split(',').map(unquote).filter(Boolean) : [];
49
+ }
50
+
51
+ function parseObjectLiteral(rawObj) {
52
+ const inner = rawObj.trim().replace(/^\{/, '').replace(/\}$/, '').trim();
53
+ if (!inner) return {};
54
+ const out = {};
55
+ for (const pair of inner.split(',')) {
56
+ const colon = pair.indexOf(':');
57
+ if (colon < 0) continue;
58
+ const key = unquote(pair.slice(0, colon));
59
+ const valRaw = pair.slice(colon + 1).trim();
60
+ out[key] = /^null$/i.test(valRaw) ? null : /^-?\d+$/.test(valRaw) ? Number(valRaw) : unquote(valRaw);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function normaliseSupersedesEntry(obj, errors) {
66
+ if (!obj || typeof obj !== 'object') {
67
+ errors.push('simt-supersedes entry must be an object {adr, ruling}');
68
+ return null;
69
+ }
70
+ const rawAdr = obj.adr !== undefined && obj.adr !== null ? String(obj.adr).trim().toUpperCase() : '';
71
+ if (!/^ADR-\d{3,}$/.test(rawAdr)) {
72
+ errors.push(`simt-supersedes entry has invalid adr "${obj.adr ?? ''}" (expected ADR-NNN)`);
73
+ return null;
74
+ }
75
+ let ruling = null;
76
+ if (obj.ruling !== undefined && obj.ruling !== null && obj.ruling !== '') {
77
+ const num = Number(obj.ruling);
78
+ if (!Number.isInteger(num) || num < 1) {
79
+ errors.push(`simt-supersedes entry for ${rawAdr} has invalid ruling "${obj.ruling}"`);
80
+ return null;
81
+ }
82
+ ruling = num;
83
+ }
84
+ const rawKind = obj.kind !== undefined && obj.kind !== null ? String(obj.kind).trim().toLowerCase() : 'superseded';
85
+ if (!VALID_KINDS.has(rawKind)) {
86
+ errors.push(`simt-supersedes entry for ${rawAdr} has unknown kind "${obj.kind}"`);
87
+ return null;
88
+ }
89
+ return { adr: rawAdr, ruling, kind: rawKind };
90
+ }
91
+
92
+ /** Parse `simt-affects` and `simt-supersedes` from an ADR's YAML frontmatter. */
93
+ export function parseDecisionFrontmatter(text) {
94
+ const { front } = splitFrontmatter(text);
95
+ if (front === null) {
96
+ return { hasFrontmatter: false, hasAffects: false, hasSupersedes: false, affects: [], supersedes: [], errors: [] };
97
+ }
98
+ const errors = [];
99
+ let hasAffects = false;
100
+ let hasSupersedes = false;
101
+ const affects = [];
102
+ const supersedes = [];
103
+ const lines = front.split(/\r?\n/);
104
+
105
+ for (let i = 0; i < lines.length; i += 1) {
106
+ const line = lines[i];
107
+ const affectsMatch = /^simt-affects:\s*(.*)$/.exec(line);
108
+ if (affectsMatch) {
109
+ hasAffects = true;
110
+ const rest = affectsMatch[1].trim();
111
+ if (rest.startsWith('[')) {
112
+ const close = rest.lastIndexOf(']');
113
+ affects.push(...parseInlineStringList(close >= 0 ? rest.slice(1, close) : rest.slice(1)));
114
+ } else if (rest === '') {
115
+ while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
116
+ i += 1;
117
+ const val = unquote(lines[i].replace(/^\s*-\s+/, ''));
118
+ if (val) affects.push(val);
119
+ }
120
+ } else if (unquote(rest)) {
121
+ affects.push(unquote(rest));
122
+ }
123
+ continue;
124
+ }
125
+
126
+ const supMatch = /^simt-supersedes:\s*(.*)$/.exec(line);
127
+ if (supMatch) {
128
+ hasSupersedes = true;
129
+ const rest = supMatch[1].trim();
130
+ if (rest.startsWith('[')) {
131
+ let combined = rest;
132
+ while (!combined.includes(']') && i + 1 < lines.length) {
133
+ i += 1;
134
+ combined += ` ${lines[i].trim()}`;
135
+ }
136
+ const close = combined.lastIndexOf(']');
137
+ const inner = (close >= 0 ? combined.slice(1, close) : combined.slice(1)).trim();
138
+ if (inner) {
139
+ const objMatches = [...inner.matchAll(/\{[^{}]*\}/g)];
140
+ if (objMatches.length === 0) errors.push('simt-supersedes list must contain {adr, ruling} objects');
141
+ for (const m of objMatches) {
142
+ const parsed = normaliseSupersedesEntry(parseObjectLiteral(m[0]), errors);
143
+ if (parsed) supersedes.push(parsed);
144
+ }
145
+ }
146
+ } else if (rest === '') {
147
+ let currentObj = null;
148
+ const flush = () => {
149
+ if (!currentObj) return;
150
+ const parsed = normaliseSupersedesEntry(currentObj, errors);
151
+ if (parsed) supersedes.push(parsed);
152
+ currentObj = null;
153
+ };
154
+ while (i + 1 < lines.length && /^\s+(?:-\s+|[a-z]+:)/i.test(lines[i + 1])) {
155
+ i += 1;
156
+ const sub = lines[i].trim();
157
+ if (sub.startsWith('-')) {
158
+ flush();
159
+ const afterDash = sub.replace(/^-\s*/, '').trim();
160
+ if (afterDash.startsWith('{')) {
161
+ const parsed = normaliseSupersedesEntry(parseObjectLiteral(afterDash), errors);
162
+ if (parsed) supersedes.push(parsed);
163
+ } else if (afterDash.includes(':')) {
164
+ const [k, ...vParts] = afterDash.split(':');
165
+ currentObj = { [unquote(k)]: unquote(vParts.join(':')) };
166
+ }
167
+ } else if (currentObj && sub.includes(':')) {
168
+ const [k, ...vParts] = sub.split(':');
169
+ currentObj[unquote(k)] = unquote(vParts.join(':'));
170
+ }
171
+ }
172
+ flush();
173
+ } else if (rest.startsWith('{')) {
174
+ const parsed = normaliseSupersedesEntry(parseObjectLiteral(rest), errors);
175
+ if (parsed) supersedes.push(parsed);
176
+ }
177
+ }
178
+ }
179
+
180
+ return { hasFrontmatter: true, hasAffects, hasSupersedes, affects, supersedes, errors };
181
+ }
182
+
183
+ /** Parse CLI or API `--supersedes` values into structured `[{adr, ruling, kind}]` entries. */
184
+ export function parseSupersedesSpec(input) {
185
+ if (input === undefined || input === null) return { entries: [], errors: [] };
186
+ const rawList = Array.isArray(input) ? input : [input];
187
+ const entries = [];
188
+ const errors = [];
189
+
190
+ for (const item of rawList) {
191
+ if (item && typeof item === 'object') {
192
+ const norm = normaliseSupersedesEntry(item, errors);
193
+ if (norm) entries.push(norm);
194
+ continue;
195
+ }
196
+ for (const part of String(item).split(',')) {
197
+ const token = part.trim();
198
+ if (!token) continue;
199
+ const colonMatch = /^(ADR-\d{3,})(?:[:#](\d+))?(?::(superseded|narrowed|withdrawn))?$/i.exec(token);
200
+ if (colonMatch) {
201
+ entries.push({
202
+ adr: colonMatch[1].toUpperCase(),
203
+ ruling: colonMatch[2] ? Number(colonMatch[2]) : null,
204
+ kind: colonMatch[3] ? colonMatch[3].toLowerCase() : 'superseded',
205
+ });
206
+ continue;
207
+ }
208
+ const proseMatch = /^(ADR-\d{3,})\s+ruling\s+(\d+)(?:\s+\(?(superseded|narrowed|withdrawn)\)?)?$/i.exec(token);
209
+ if (proseMatch) {
210
+ entries.push({
211
+ adr: proseMatch[1].toUpperCase(),
212
+ ruling: Number(proseMatch[2]),
213
+ kind: proseMatch[3] ? proseMatch[3].toLowerCase() : 'superseded',
214
+ });
215
+ continue;
216
+ }
217
+ errors.push(`invalid --supersedes spec "${token}" (expected ADR-NNN or ADR-NNN:<ruling>[:<kind>])`);
218
+ }
219
+ }
220
+ return { entries, errors };
221
+ }
222
+
223
+ /** Slugify an ADR title into a filename suffix. */
224
+ function slugifyTitle(title) {
225
+ const slug = String(title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 72);
226
+ return slug || 'decision';
227
+ }
228
+
229
+ /** Determine the next free ADR number in a decisions directory. */
230
+ export async function nextDecisionNumber(decisionsDir) {
231
+ const names = await readdir(decisionsDir).catch(() => []);
232
+ let max = 0;
233
+ for (const name of names) {
234
+ const m = /^ADR-(\d+)-.*\.md$/i.exec(name);
235
+ if (m && Number(m[1]) > max) max = Number(m[1]);
236
+ }
237
+ const next = max + 1;
238
+ return { number: next, id: `ADR-${String(next).padStart(3, '0')}` };
239
+ }
240
+
241
+ /** Collect affected repository paths from the working tree / staged diff (R25, R34). */
242
+ export async function diffAffectedPaths(root) {
243
+ const paths = new Set();
244
+ const addLines = (stdout) => {
245
+ for (const line of String(stdout ?? '').split(/\r?\n/)) {
246
+ const trimmed = line.trim();
247
+ if (trimmed && !trimmed.startsWith('.simt-dev/')) paths.add(trimmed);
248
+ }
249
+ };
250
+ for (const args of [
251
+ ['diff', '--name-only', '--cached'],
252
+ ['diff', '--name-only', 'HEAD'],
253
+ ['diff', '--name-only'],
254
+ ]) {
255
+ try {
256
+ const { stdout } = await exec('git', args, { cwd: root });
257
+ addLines(stdout);
258
+ } catch {
259
+ // Ignore if not a git repo or HEAD absent.
260
+ }
261
+ }
262
+ if (paths.size === 0) {
263
+ try {
264
+ const { stdout } = await exec('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], { cwd: root });
265
+ addLines(stdout);
266
+ } catch {
267
+ // Ignore.
268
+ }
269
+ }
270
+ return [...paths].sort();
271
+ }
272
+
273
+ function formatSupersedesFrontmatter(supersedes) {
274
+ if (!Array.isArray(supersedes) || supersedes.length === 0) return '[]';
275
+ const items = supersedes.map((entry) => {
276
+ const parts = [`adr: "${entry.adr}"`, `ruling: ${entry.ruling === null ? 'null' : entry.ruling}`];
277
+ if (entry.kind && entry.kind !== 'superseded') parts.push(`kind: "${entry.kind}"`);
278
+ return `{ ${parts.join(', ')} }`;
279
+ });
280
+ return `[${items.join(', ')}]`;
281
+ }
282
+
283
+ function formatAffectsFrontmatter(affects) {
284
+ return Array.isArray(affects) && affects.length > 0
285
+ ? `[${affects.map((p) => JSON.stringify(String(p))).join(', ')}]`
286
+ : '[]';
287
+ }
288
+
289
+ /** Render an ADR markdown document with structured frontmatter, a Status line, and a rulings skeleton. */
290
+ function renderDecisionScaffold({
291
+ id,
292
+ title,
293
+ date = new Date().toISOString().slice(0, 10),
294
+ status = 'accepted',
295
+ affects = [],
296
+ supersedes = [],
297
+ }) {
298
+ const cleanTitle = String(title ?? 'Untitled decision').trim() || 'Untitled decision';
299
+ const wholeSupersedes = supersedes.filter((s) => s.ruling === null).map((s) => s.adr);
300
+ const partialSupersedes = supersedes.filter((s) => s.ruling !== null);
301
+ const statusClauses = [`${status} · ${date}`];
302
+ if (wholeSupersedes.length > 0) statusClauses.push(`supersedes ${wholeSupersedes.join(', ')}`);
303
+ for (const p of partialSupersedes) {
304
+ const verb = p.kind === 'narrowed' ? 'narrows' : p.kind === 'withdrawn' ? 'withdraws' : 'supersedes';
305
+ statusClauses.push(`${verb} ${p.adr} ruling ${p.ruling}`);
306
+ }
307
+
308
+ return [
309
+ '---',
310
+ `simt-affects: ${formatAffectsFrontmatter(affects)}`,
311
+ `simt-supersedes: ${formatSupersedesFrontmatter(supersedes)}`,
312
+ '---',
313
+ `# ${id} — ${cleanTitle}`,
314
+ '',
315
+ `Status: ${statusClauses.join(' · ')}`,
316
+ '',
317
+ '## Context',
318
+ '',
319
+ 'State the context and the measurement that prompted this decision.',
320
+ '',
321
+ '## Rulings',
322
+ '',
323
+ '1. **First ruling.** State the first constraint or invariant clearly so it can be cited by number.',
324
+ '',
325
+ '2. **Second ruling.** State the second constraint or invariant clearly so it can be cited by number.',
326
+ '',
327
+ '## Consequences',
328
+ '',
329
+ '- State what changes in the repository or projection as a result of these rulings.',
330
+ '',
331
+ '## Rejected',
332
+ '',
333
+ '- **Alternative considered.** Rejected: state why this option was refused.',
334
+ '',
335
+ ].join('\n');
336
+ }
337
+
338
+ /** Scaffold a new ADR in `<root>/docs/decisions` with structured frontmatter. */
339
+ export async function scaffoldDecision(root, options = {}) {
340
+ const decisionsDir = options.decisionsDir ?? join(root, 'docs', 'decisions');
341
+ const { number, id } = await nextDecisionNumber(decisionsDir);
342
+ const title = String(options.title ?? 'Untitled decision').trim() || 'Untitled decision';
343
+ const slug = slugifyTitle(title);
344
+ const file = `${id}-${slug}.md`;
345
+ const relPath = ['docs', 'decisions', file].join('/');
346
+ const fullPath = join(decisionsDir, file);
347
+ const affects = Array.isArray(options.affects)
348
+ ? options.affects.map((p) => String(p).trim()).filter(Boolean)
349
+ : await diffAffectedPaths(root);
350
+
351
+ const { entries: supersedes, errors } = parseSupersedesSpec(options.supersedes);
352
+ if (errors.length > 0) return { ok: false, errors };
353
+
354
+ const text = renderDecisionScaffold({
355
+ id,
356
+ title,
357
+ date: options.date,
358
+ status: options.status,
359
+ affects,
360
+ supersedes,
361
+ });
362
+
363
+ if (options.write) {
364
+ await mkdir(decisionsDir, { recursive: true });
365
+ await writeFile(fullPath, text, 'utf8');
366
+ }
367
+
368
+ return {
369
+ ok: true,
370
+ id,
371
+ number,
372
+ title,
373
+ slug,
374
+ file,
375
+ relPath,
376
+ path: fullPath,
377
+ affects,
378
+ supersedes,
379
+ text,
380
+ written: Boolean(options.write),
381
+ };
382
+ }
383
+
384
+ /** Extract partial supersession claims stated in prose (`Status:` and header paragraphs). */
385
+ function prosePartialSupersessions(body) {
386
+ const claims = [];
387
+ const headSection = String(body ?? '').split(/^##\s+/m)[0] ?? '';
388
+ const clean = headSection.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*`]/g, '').replace(/\s+/g, ' ');
389
+
390
+ let lastNamed = null;
391
+ for (const m of clean.matchAll(PARTIAL_CLAUSE)) {
392
+ const [raw, list, kind, ref] = m;
393
+ const adr = /^ADR-/i.test(ref) ? ref.toUpperCase() : lastNamed;
394
+ if (/^ADR-/i.test(ref)) lastNamed = ref.toUpperCase();
395
+ const rulings = list ? list.split(/\D+/).filter(Boolean).map(Number) : [null];
396
+ for (const ruling of rulings) claims.push({ adr, ruling, kind: normaliseKind(kind), raw });
397
+ }
398
+ for (const m of clean.matchAll(ACTIVE_PARTIAL_AFTER_RE)) {
399
+ const [raw, verb, adrRaw, list] = m;
400
+ for (const ruling of list.split(/\D+/).filter(Boolean).map(Number)) {
401
+ claims.push({ adr: adrRaw.toUpperCase(), ruling, kind: normaliseKind(verb), raw });
402
+ }
403
+ }
404
+ for (const m of clean.matchAll(ACTIVE_PARTIAL_BEFORE_RE)) {
405
+ const [raw, verb, list, adrRaw] = m;
406
+ for (const ruling of list.split(/\D+/).filter(Boolean).map(Number)) {
407
+ claims.push({ adr: adrRaw.toUpperCase(), ruling, kind: normaliseKind(verb), raw });
408
+ }
409
+ }
410
+ return claims;
411
+ }
412
+
413
+ /** Lint a single ADR document for structured supersession conformance (plan 0003 R34). */
414
+ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = false } = {}) {
415
+ const fileMatch = file !== 'ADR-000.md' ? /^(ADR-\d{3,})/i.exec(file) : null;
416
+ const headMatch = /^#\s+(ADR-\d{3,})\b/m.exec(String(text ?? ''));
417
+ const id = (fileMatch?.[1] ?? headMatch?.[1] ?? /^(ADR-\d{3,})/i.exec(file)?.[1] ?? '').toUpperCase() || null;
418
+ const num = id ? Number(id.replace(/^ADR-/, '')) : null;
419
+
420
+ const fm = parseDecisionFrontmatter(text);
421
+ const { body } = splitFrontmatter(text);
422
+ const findings = fm.errors.map((err) => ({
423
+ file,
424
+ id,
425
+ status: 'invalid-decision-frontmatter',
426
+ detail: err,
427
+ remedy: 'fix simt-supersedes frontmatter to use [{ adr: "ADR-NNN", ruling: N }]',
428
+ }));
429
+
430
+ if (allowHistorical && !fm.hasFrontmatter && num !== null && num > 0 && num <= 96) {
431
+ return { ok: findings.length === 0, findings };
432
+ }
433
+
434
+ for (const claim of prosePartialSupersessions(body)) {
435
+ const covered = fm.supersedes.some(
436
+ (e) => e.ruling === claim.ruling && (claim.adr === null || e.adr === claim.adr || (id !== null && e.adr === id)),
437
+ );
438
+ if (!covered) {
439
+ const targetAdr = claim.adr ?? id ?? 'ADR-NNN';
440
+ const rulingLabel = claim.ruling !== null ? `ruling ${claim.ruling}` : 'unnumbered ruling';
441
+ findings.push({
442
+ file,
443
+ id,
444
+ status: 'prose-partial-supersession',
445
+ detail: `partial supersession (${targetAdr} ${rulingLabel}) is stated only in prose ("${claim.raw}")`,
446
+ remedy: `declare simt-supersedes: [{ adr: "${targetAdr}", ruling: ${claim.ruling ?? 1} }] in the ADR frontmatter`,
447
+ });
448
+ }
449
+ }
450
+ return { ok: findings.length === 0, findings };
451
+ }
452
+
453
+ /** Lint all ADR records in a decisions directory. */
454
+ export async function lintDecisions(decisionsDir, { allowHistorical = false } = {}) {
455
+ const names = (await readdir(decisionsDir).catch(() => [])).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n)).sort();
456
+ const findings = [];
457
+ for (const file of names) {
458
+ const text = await readFile(join(decisionsDir, file), 'utf8');
459
+ findings.push(...lintDecision(text, { file, allowHistorical }).findings);
460
+ }
461
+ return { ok: findings.length === 0, findings };
462
+ }