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
@@ -28,6 +28,10 @@ import { CLI } from '../name.mjs';
28
28
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
29
29
  import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
30
  import { resolvePacks } from '../packs.mjs';
31
+ import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
32
+ import { runDeclaredChecks } from '../kernel/checks.mjs';
33
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
34
+ import { lintDecisions } from '../memory/decide.mjs';
31
35
 
32
36
  /** Findings `sync` reports and does not fail on.
33
37
  *
@@ -49,7 +53,15 @@ import { resolvePacks } from '../packs.mjs';
49
53
  * people learn to ignore. A *foreign* one — a real file or a real directory where the
50
54
  * pointer belongs — is reported loudly with what is in the way, because that one is a
51
55
  * decision somebody has to make. */
52
- export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-unpinned', 'budget']);
56
+ export const SYNC_NON_BLOCKING = new Set([
57
+ 'pointer-unread',
58
+ 'orphaned',
59
+ 'pack-unpinned',
60
+ 'budget',
61
+ 'budget-cut',
62
+ 'file-lines-warn',
63
+ 'check-unbuilt',
64
+ ]);
53
65
 
54
66
  /**
55
67
  * Findings `check` reports and does not fail on.
@@ -82,7 +94,11 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-un
82
94
  // missing file. Nothing is cut: the projection is still in full mode, so a gate that
83
95
  // failed here would be failing a repository for exceeding a ceiling it set itself,
84
96
  // with no way to fix it except lowering the number it wrote down.
85
- export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget']);
97
+ // `budget-cut` is here for the same reason and is the second half of the same ruling
98
+ // (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
99
+ // remedy is a decision about the number — and a cut the repository asked for by declaring
100
+ // a budget is the tool working, not a repository in need of a file.
101
+ export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget', 'budget-cut', 'file-lines-warn', 'check-unbuilt']);
86
102
 
87
103
  export async function syncCommand(positional, flags) {
88
104
  const root = flags.root ?? positional[0] ?? process.cwd();
@@ -267,6 +283,18 @@ export async function syncCommand(positional, flags) {
267
283
  await writeFile(t.path, t.text);
268
284
  note(`wrote ${t.file}`);
269
285
  }
286
+ const lock = await computeLock(root, flags);
287
+ const lockText = `${JSON.stringify(lock, null, 2)}\n`;
288
+ let previousLock = null;
289
+ try {
290
+ previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
291
+ } catch (error) {
292
+ if (error.code !== 'ENOENT') throw error;
293
+ }
294
+ if (previousLock !== lockText) {
295
+ await writeFile(join(root, LOCK_FILE), lockText);
296
+ note(`wrote ${LOCK_FILE}`);
297
+ }
270
298
  for (const p of pointerPlan) {
271
299
  await mkdir(dirname(p.path), { recursive: true });
272
300
  // Only a symlink this tool wrote is replaced, and only because it has no content:
@@ -495,6 +523,25 @@ export async function checkCommand(positional, flags) { const root = flags.root
495
523
  const drift = await manifestDrift(root, stack, await provenanceDigest());
496
524
  if (drift) findings.push(drift);
497
525
  }
526
+ if (flags.frozen) findings.push(...(await frozenFindings(root, flags)));
527
+ // The checkers a selected rule declared (ADR-088). They read the rule source,
528
+ // not the folded region, so a line that left the prompt is still the line that runs.
529
+ const fp = await fingerprint(root);
530
+ const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
531
+ findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
532
+
533
+ const decisionsDir = join(root, 'docs', 'decisions');
534
+ const decisionLint = await lintDecisions(decisionsDir, {
535
+ allowHistorical: resolve(decisionsDir) === DECISIONS_DIR,
536
+ });
537
+ findings.push(
538
+ ...decisionLint.findings.map((f) => ({
539
+ file: ['docs', 'decisions', f.file].join('/'),
540
+ status: f.status,
541
+ detail: f.detail,
542
+ remedy: f.remedy,
543
+ })),
544
+ );
498
545
 
499
546
  // `CHECK_NON_BLOCKING`, not the union. This is the gate whose exit code a repository
500
547
  // acts on, so a finding it reports and does not fail on is a decision recorded in a
@@ -526,4 +573,41 @@ export async function checkCommand(positional, flags) { const root = flags.root
526
573
  return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
527
574
  }
528
575
 
529
- export { plan, checkTargets, checkTarget, select };
576
+ /**
577
+ * What `--frozen` adds to the gate. Nothing, when the lock matches. One finding
578
+ * otherwise, and the wording says which of the two failures it was (ADR-086).
579
+ */
580
+ async function frozenFindings(root, flags) {
581
+ const { lock, error } = await readLock(root);
582
+ if (error) {
583
+ return [{ file: LOCK_FILE, status: 'lock-unreadable', detail: error, remedy: `run: ${CLI} sync --yes` }];
584
+ }
585
+ if (lock === null) {
586
+ return [{
587
+ file: LOCK_FILE,
588
+ status: 'lock-absent',
589
+ detail: 'no lock is recorded',
590
+ remedy: `run: ${CLI} sync --yes`,
591
+ }];
592
+ }
593
+ const next = await computeLock(root, flags);
594
+ if (next.inputs !== lock.inputs) {
595
+ return [{
596
+ file: LOCK_FILE,
597
+ status: 'lock-stale',
598
+ detail: 'the inputs changed and the lock was not rewritten',
599
+ remedy: `run: ${CLI} sync --yes`,
600
+ }];
601
+ }
602
+ if (next.outputs !== lock.outputs) {
603
+ return [{
604
+ file: LOCK_FILE,
605
+ status: 'lock-unexplained',
606
+ detail: 'the projection changed and the inputs did not',
607
+ remedy: `run: ${CLI} sync --yes`,
608
+ }];
609
+ }
610
+ return [];
611
+ }
612
+
613
+ export { plan, checkTargets, checkTarget, select, frozenFindings };
@@ -0,0 +1,145 @@
1
+ // `simt tests [path]` lists the tests that exercise a file (R24, ADR-092).
2
+ //
3
+ // The index already stores imports, but a test import that spans lines is not
4
+ // one of them. Test files are read back from their blobs, once, in a single
5
+ // batch. Coverage is read only from a fixed list of paths. History is the last
6
+ // 200 non-merge commits, which is a map and not the whole changelog.
7
+
8
+ import { execFile } from 'node:child_process';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+
13
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { CLI } from '../name.mjs';
15
+ import { isTestPath } from '../bench/mine.mjs';
16
+ import { blobTexts, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
17
+ import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
18
+
19
+ const exec = promisify(execFile);
20
+
21
+ const COVERAGE_PATHS = [
22
+ 'coverage/lcov.info',
23
+ 'lcov.info',
24
+ 'coverage/coverage-final.json',
25
+ 'coverage.json',
26
+ 'jacoco.xml',
27
+ 'build/reports/jacoco/test/jacocoTestReport.xml',
28
+ 'coverage.out',
29
+ 'cover.out',
30
+ ];
31
+
32
+ export async function testsCommand(args, flags) {
33
+ if (args.length > 1) return fail(`unknown tests argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} tests [path]`);
34
+ const root = flags.root ?? process.cwd();
35
+ const loaded = await loadFiles(root);
36
+ if (loaded.error) return loaded.error;
37
+ const { files } = loaded;
38
+ const [stored, texts, coverage, commits] = await Promise.all([
39
+ readEdges(root),
40
+ testBlobs(root, files),
41
+ coverageTexts(root),
42
+ history(root),
43
+ ]);
44
+ const paths = new Set(files.map((file) => file.path));
45
+ const imports = stored.filter((edge) => edge.kind === 'import');
46
+ for (const file of files) {
47
+ if (!isTestPath(file.path)) continue;
48
+ const text = texts.get(file.blob);
49
+ if (!text) continue;
50
+ imports.push(...importsOf(file.path, text, langOf(file.path), paths));
51
+ }
52
+ const edges = testEdges({ files, imports, coverage, commits });
53
+ const target = args[0];
54
+ if (!target) return summarise(root, flags, edges);
55
+ if (!paths.has(target)) {
56
+ return fail(`not in the index: ${target}`, EXIT.USAGE, `pass a path ${CLI} index knows`);
57
+ }
58
+ const tests = testsFor(target, edges, { cochange: flags.cochange === true });
59
+ const payload = { ok: true, command: 'tests', root, path: target, tests };
60
+ if (isJson(flags)) {
61
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
62
+ return EXIT.OK;
63
+ }
64
+ const lines = [`tests ${tests.length}`, ...tests];
65
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
66
+ return EXIT.OK;
67
+ }
68
+
69
+ function summarise(root, flags, edges) {
70
+ let test = 0;
71
+ let cochange = 0;
72
+ for (const edge of edges) {
73
+ if (edge.kind === 'test') test += 1;
74
+ else if (edge.kind === 'cochange') cochange += 1;
75
+ }
76
+ const payload = { ok: true, command: 'tests', root, test, cochange };
77
+ if (isJson(flags)) {
78
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
79
+ return EXIT.OK;
80
+ }
81
+ emit(`test ${test}\ncochange ${cochange}`, { maxBytes: flags.maxBytes ?? 0 });
82
+ return EXIT.OK;
83
+ }
84
+
85
+ async function loadFiles(root) {
86
+ let files = await readFiles(root);
87
+ if (files.length === 0) {
88
+ try {
89
+ await reindex(root);
90
+ } catch (error) {
91
+ return {
92
+ error: fail(
93
+ `could not map tests in this repository (${error.message})`,
94
+ EXIT.UNSUPPORTED,
95
+ 'run this inside a git repository',
96
+ ),
97
+ };
98
+ }
99
+ files = await readFiles(root);
100
+ }
101
+ return { files };
102
+ }
103
+
104
+ async function testBlobs(root, files) {
105
+ const blobs = [];
106
+ for (const file of files) {
107
+ if (isTestPath(file.path) && file.blob) blobs.push(file.blob);
108
+ }
109
+ return blobTexts(root, blobs).catch(() => new Map());
110
+ }
111
+
112
+ async function coverageTexts(root) {
113
+ const texts = [];
114
+ for (const path of COVERAGE_PATHS) {
115
+ try {
116
+ const text = await readFile(join(root, path), 'utf8');
117
+ if (parseCoverage(text).length > 0 || text.trim() !== '') texts.push(text);
118
+ } catch {
119
+ // Absent coverage is the usual case. It is not a failed map.
120
+ }
121
+ }
122
+ return texts;
123
+ }
124
+
125
+ async function history(root) {
126
+ let stdout = '';
127
+ try {
128
+ ({ stdout } = await exec('git', ['log', '-n', String(HISTORY), '--no-merges', '--name-only', '--pretty=format:%x1e'], {
129
+ cwd: root,
130
+ maxBuffer: 16 << 20,
131
+ }));
132
+ } catch {
133
+ return [];
134
+ }
135
+ const commits = [];
136
+ for (const block of stdout.split('\x1e')) {
137
+ const paths = [];
138
+ for (const line of block.split('\n')) {
139
+ const path = line.trim();
140
+ if (path) paths.push(path);
141
+ }
142
+ if (paths.length > 0) commits.push(paths);
143
+ }
144
+ return commits;
145
+ }
@@ -34,6 +34,7 @@ import {
34
34
  totals,
35
35
  } from '../measure/context.mjs';
36
36
  import { measureSession } from '../measure/session.mjs';
37
+ import { sharedTotals } from '../projection/shared.mjs';
37
38
 
38
39
  /** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
39
40
  * this command exists so two machines produce two numbers a pull request can diff. */
@@ -109,7 +110,17 @@ export async function tokensCommand(positional, flags) {
109
110
  for (const path of roots) rows.push(await measureRoot(path));
110
111
  annotate(rows);
111
112
 
112
- const summary = totals(rows);
113
+ const measured = totals(rows);
114
+ // The workspace copy, counted once (ADR-084). A figure that measured the regions and
115
+ // ignored the file the rules moved into would show a saving the reader's context never
116
+ // saw, so it is in the total from the first run rather than as an afterthought.
117
+ const shared = await sharedTotals(rows);
118
+ const summary = {
119
+ ...measured,
120
+ sharedBytes: shared.bytes,
121
+ estateBytes: measured.generatedBytes + shared.bytes,
122
+ sharedFiles: shared.files,
123
+ };
113
124
  const mcp = mcpTools();
114
125
 
115
126
  if (isJson(flags)) {
@@ -142,6 +153,15 @@ export async function tokensCommand(positional, flags) {
142
153
  lines.push(
143
154
  `totals generated ${plain(summary.generatedBytes)} B duplicate ${plain(summary.duplicateBytes)} B (${plain(share)}% of generated) unique ${plain(summary.uniqueBytes)} B repeat ${plain(summary.repeatBytes)} B ~${plain(summary.tokens)} tokens`,
144
155
  );
156
+ // Only when there is a copy to count. The line a reader sees most is the one that
157
+ // says what their whole estate loads, and adding a second number to it that is
158
+ // usually zero is how a report stops being read.
159
+ if (summary.sharedBytes > 0) {
160
+ lines.push(
161
+ `shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) estate total ${plain(summary.estateBytes)} B`,
162
+ );
163
+ for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
164
+ }
145
165
  } else {
146
166
  const row = rows[0];
147
167
  lines.push(`tokens ${row.path}`);
@@ -155,6 +175,15 @@ export async function tokensCommand(positional, flags) {
155
175
  lines.push(
156
176
  `always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
157
177
  );
178
+ // The same reason as the `--repos` line, and it matters more here: measuring one
179
+ // repository is the default, so a reader who owns the workspace copy and never saw it
180
+ // counted would read a saving their own session does not get.
181
+ if (summary.sharedBytes > 0) {
182
+ lines.push(
183
+ `shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) always-on for you: ${plain(summary.estateBytes)} B`,
184
+ );
185
+ for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
186
+ }
158
187
  lines.push(
159
188
  `redundant ${plain(row.redundancy.duplicateBytes)} B repeats an earlier repository ${plain(row.redundancy.repeatBytes)} B repeats an earlier document here ${plain(row.redundancy.uniqueBytes)} B unique of ${plain(row.redundancy.blockBytes)} B counted in blocks`,
160
189
  );
@@ -0,0 +1,172 @@
1
+ // `simt why [path]` lists what governs a file (R25, ADR-093).
2
+ //
3
+ // With a path, one line per decision, rule, skill, gate and lesson. With no
4
+ // path, the Affects tokens that name nothing this tree contains. The file list
5
+ // is `git ls-files`. The index is not rebuilt: governance is the records, and
6
+ // writing the cache to answer a question would change the tree.
7
+
8
+ import { execFile } from 'node:child_process';
9
+ import { readFile, readdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+
13
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
15
+ import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
16
+ import {
17
+ decisionHits,
18
+ scopeHits,
19
+ scopePaths,
20
+ sortHits,
21
+ sourceRoots,
22
+ unresolvedAffects,
23
+ } from '../index/govern.mjs';
24
+ import { splitFrontmatter } from '../memory/decide.mjs';
25
+ import { listPromotedLessons } from '../memory/lessons.mjs';
26
+
27
+ const exec = promisify(execFile);
28
+
29
+ const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt-dev/profiles'];
30
+
31
+ export async function whyCommand(args, flags, commands = []) {
32
+ if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
33
+ const root = flags.root ?? process.cwd();
34
+ const listed = await tracked(root);
35
+ if (listed.error) return listed.error;
36
+ const files = new Set(listed.files);
37
+ const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
38
+ const records = await decisionRecords(root);
39
+ const target = args[0];
40
+ if (!target) return reportUnresolved(root, flags, unresolvedAffects(records, ctx));
41
+ if (!files.has(target)) return fail(`not in the tree: ${target}`, EXIT.USAGE, `pass a path git tracks`);
42
+ const corpus = await loadCorpus(root);
43
+ const { hits } = decisionHits(records, target, ctx);
44
+ const items = sortHits([...hits, ...scopeHits(target, corpus)]);
45
+ const payload = { ok: true, command: 'why', root, path: target, items };
46
+ if (isJson(flags)) {
47
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
48
+ return EXIT.OK;
49
+ }
50
+ const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
51
+ emit(lines.length > 0 ? lines.join('\n') : 'none', { maxBytes: flags.maxBytes ?? 0 });
52
+ return EXIT.OK;
53
+ }
54
+
55
+ function reportUnresolved(root, flags, unresolved) {
56
+ const payload = { ok: true, command: 'why', root, unresolved };
57
+ if (isJson(flags)) {
58
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
59
+ return EXIT.OK;
60
+ }
61
+ const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
62
+ emit([`unresolved ${unresolved.length}`, ...lines].join('\n'), { maxBytes: flags.maxBytes ?? 0 });
63
+ return EXIT.OK;
64
+ }
65
+
66
+ async function tracked(root) {
67
+ try {
68
+ const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
69
+ return { files: stdout.split('\n').filter(Boolean) };
70
+ } catch (error) {
71
+ return {
72
+ error: fail(
73
+ `could not read what governs files here (${error.message})`,
74
+ EXIT.UNSUPPORTED,
75
+ 'run this inside a git repository',
76
+ ),
77
+ };
78
+ }
79
+ }
80
+
81
+ export async function decisionRecords(root) {
82
+ const dir = join(root, 'docs', 'decisions');
83
+ let names = [];
84
+ try {
85
+ names = (await readdir(dir)).filter((name) => /^ADR-\d{3,}-.*\.md$/i.test(name)).sort();
86
+ } catch {
87
+ return [];
88
+ }
89
+ const records = [];
90
+ for (const name of names) {
91
+ const text = await readFile(join(dir, name), 'utf8');
92
+ const { body } = splitFrontmatter(text);
93
+ const id = /^ADR-\d{3,}/i.exec(name)[0].toUpperCase();
94
+ const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
95
+ const title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim();
96
+ records.push({ id, title, text });
97
+ }
98
+ return records;
99
+ }
100
+
101
+ async function loadCorpus(root) {
102
+ const [rules, skills, lessons, commands] = await Promise.all([
103
+ ruleScopes(root),
104
+ skillScopes(root),
105
+ lessonScopes(root),
106
+ gateCommands(root),
107
+ ]);
108
+ const gates = Object.values(GATES).map((gate) => ({
109
+ id: gate.name,
110
+ detail: gate.why,
111
+ extensions: gate.kind === 'staged' ? FORMATTABLE : null,
112
+ }));
113
+ return { rules, skills, gates, lessons, commands };
114
+ }
115
+
116
+ export async function ruleScopes(root) {
117
+ const rules = [];
118
+ for (const dir of RULE_DIRS) {
119
+ let names = [];
120
+ try {
121
+ names = await readdir(join(root, dir));
122
+ } catch {
123
+ continue;
124
+ }
125
+ for (const name of names.sort()) {
126
+ if (!name.endsWith('.md') && !name.endsWith('.mdc')) continue;
127
+ const text = await readFile(join(root, dir, name), 'utf8');
128
+ const paths = scopePaths(text);
129
+ if (paths.length === 0) continue;
130
+ const id = /^simt-id:\s*(\S+)/m.exec(text)?.[1] ?? name;
131
+ const detail = /^simt-title:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? id;
132
+ rules.push({ id, detail, paths });
133
+ }
134
+ }
135
+ return rules;
136
+ }
137
+
138
+ async function skillScopes(root) {
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(await readFile(join(root, 'graph', 'skills.graph.json'), 'utf8'));
142
+ } catch {
143
+ return [];
144
+ }
145
+ if (!Array.isArray(parsed?.skills)) return [];
146
+ return parsed.skills
147
+ .filter((skill) => Array.isArray(skill.paths) && skill.paths.length > 0)
148
+ .map((skill) => ({ id: String(skill.name ?? ''), detail: String(skill.summary ?? ''), paths: skill.paths }));
149
+ }
150
+
151
+ export async function lessonScopes(root) {
152
+ return (await listPromotedLessons(root))
153
+ .filter((lesson) => lesson.paths.length > 0)
154
+ .map((lesson) => ({
155
+ id: lesson.id,
156
+ detail: lesson.title,
157
+ paragraph: lesson.paragraph,
158
+ paths: lesson.paths,
159
+ signature: lesson.signature,
160
+ keywords: lesson.keywords,
161
+ }));
162
+ }
163
+
164
+ async function gateCommands(root) {
165
+ try {
166
+ const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), 'utf8'));
167
+ const commands = parsed?.gates?.commands;
168
+ return commands && typeof commands === 'object' ? commands : {};
169
+ } catch {
170
+ return {};
171
+ }
172
+ }