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
@@ -25,6 +25,7 @@ import {
25
25
  whyCommand as skillsWhy,
26
26
  evalCommand as skillsEval,
27
27
  } from './skills.mjs';
28
+ import { briefCommand } from './brief.mjs';
28
29
  import { syncCommand, checkCommand } from './sync.mjs';
29
30
  import { initCommand } from './init.mjs';
30
31
  import { ciCommand } from './ci.mjs';
@@ -34,6 +35,7 @@ import { compareCommand } from './compare.mjs';
34
35
  import { upgradeCommand } from './upgrade.mjs';
35
36
  import { mcpCommand } from './mcp.mjs';
36
37
  import { installCommand } from './skillsinstall.mjs';
38
+ import { compileCommand } from './skillscompile.mjs';
37
39
  import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
38
40
  import { tokensCommand } from './tokens.mjs';
39
41
  import { harnessCommand, harnessLoadersCommand } from './harness.mjs';
@@ -43,9 +45,22 @@ import {
43
45
  benchReportCommand,
44
46
  benchAblateCommand,
45
47
  benchProbesCommand,
48
+ benchVariantsCommand,
46
49
  } from './bench.mjs';
47
50
  import { planCommand } from './plan.mjs';
51
+ import { sharedPlanCommand, sharedInstallCommand, sharedWarning } from './shared.mjs';
52
+ import { evidenceCommand } from './evidence.mjs';
53
+ import { indexCommand } from './repoindex.mjs';
54
+ import { mapCommand } from './map.mjs';
55
+ import { testsCommand } from './tests.mjs';
56
+ import { whyCommand } from './why.mjs';
57
+ import { gateCommand } from './gate.mjs';
58
+ import { impactCommand } from './impact.mjs';
59
+ import { lessonsListCommand, lessonsReviewCommand } from './lessons.mjs';
60
+ import { decideCommand } from './decide.mjs';
61
+ import { localDoctorCommand } from './local.mjs';
48
62
  import { parse } from '../argv.mjs';
63
+ import { CLI } from '../name.mjs';
49
64
  import { fingerprint } from '../resolve/fingerprint.mjs';
50
65
  import { resolveWorkspace } from '../workspace.mjs';
51
66
 
@@ -66,6 +81,13 @@ const GROUPS = new Map([
66
81
  ['agent-hooks', 'status'],
67
82
  ['harness', 'detect'],
68
83
  ['bench', 'mine'],
84
+ // ADR-084. `shared` is the one group whose default writes nothing, and it is `plan` for
85
+ // the same reason `skills`'s is `index`: the read answers "what would this do, and
86
+ // where", and `install` — the subcommand that touches a reader's home directory — is the
87
+ // one that needs `--yes` before it does anything.
88
+ ['shared', 'plan'],
89
+ ['lessons', 'list'],
90
+ ['local', 'doctor'],
69
91
  ]);
70
92
 
71
93
  /**
@@ -82,6 +104,7 @@ const GROUPS = new Map([
82
104
  * just correct: nothing in the map depends on the call.
83
105
  */
84
106
  export const IMPLEMENTED = {
107
+ brief: briefCommand,
85
108
  doctor: runDoctor,
86
109
  'graph lint': lintCommand,
87
110
  'skills index': skillsIndex,
@@ -91,6 +114,7 @@ export const IMPLEMENTED = {
91
114
  'skills why': skillsWhy,
92
115
  'skills eval': skillsEval,
93
116
  'skills install': installCommand,
117
+ 'skills compile': compileCommand,
94
118
  'rules list': rulesList,
95
119
  'rules show': rulesShow,
96
120
  'rules why': rulesWhy,
@@ -99,6 +123,20 @@ export const IMPLEMENTED = {
99
123
  'plan status': (args, flags) => planCommand('status', args, flags),
100
124
  'plan next': (args, flags) => planCommand('next', args, flags),
101
125
  'plan done': (args, flags) => planCommand('done', args, flags),
126
+ 'shared plan': sharedPlanCommand,
127
+ 'shared install': sharedInstallCommand,
128
+ evidence: evidenceCommand,
129
+ index: indexCommand,
130
+ map: mapCommand,
131
+ tests: testsCommand,
132
+ why: (args, flags) => whyCommand(args, flags, COMMANDS),
133
+ gate: gateCommand,
134
+ impact: (args, flags) => impactCommand(args, flags, COMMANDS),
135
+ 'lessons list': lessonsListCommand,
136
+ 'lessons review': lessonsReviewCommand,
137
+ decide: decideCommand,
138
+ 'local doctor': localDoctorCommand,
139
+ 'evidence check': (args, flags) => evidenceCommand(['check', ...args], flags),
102
140
  init: initCommand,
103
141
  ci: ciCommand,
104
142
  'hooks install': (args, flags) => hooksCommand('install', args, flags),
@@ -116,6 +154,7 @@ export const IMPLEMENTED = {
116
154
  'bench report': benchReportCommand,
117
155
  'bench ablate': benchAblateCommand,
118
156
  'bench probes': benchProbesCommand,
157
+ 'bench variants': benchVariantsCommand,
119
158
  upgrade: upgradeCommand,
120
159
  mcp: mcpCommand,
121
160
  };
@@ -163,7 +202,7 @@ export async function dispatch(argv) {
163
202
  // simt-dev: they run this tool, not its source tree. The step number is the
164
203
  // stable handle; the file that documents it is an implementation detail.
165
204
  return fail(
166
- `\`simt-dev ${path}\` is declared but not implemented`,
205
+ `\`${CLI} ${path}\` is declared but not implemented`,
167
206
  EXIT.UNSUPPORTED,
168
207
  `implementation step ${step} is not built yet`,
169
208
  );
@@ -220,6 +259,13 @@ async function runDoctor(positional, flags) {
220
259
  // answer confidently wrong, so the answer is reported rather than inferred
221
260
  // (ADR-014).
222
261
  const ws = await resolveWorkspace(flags);
262
+ // The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
263
+ // because it is the one thing about a repository that is true of the reader's machine
264
+ // rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
265
+ // that can notice a region that dropped rules into a file nobody has. Reported, never
266
+ // blocking — a stale copy costs a reader some of their rules, which is worth knowing and
267
+ // is not this repository's gate failing.
268
+ const shared = await sharedWarning(root, result.manifest ?? null, flags);
223
269
 
224
270
  // Under `--json` the document is the whole report, so it has to carry both
225
271
  // failures the text path reports AND the workspace answer the text path prints.
@@ -254,6 +300,7 @@ async function runDoctor(positional, flags) {
254
300
  detail: ws.detail,
255
301
  error: ws.error ?? null,
256
302
  },
303
+ shared,
257
304
  ...(failure ?? {}),
258
305
  });
259
306
  return code;
@@ -276,6 +323,10 @@ async function runDoctor(positional, flags) {
276
323
  }`,
277
324
  `manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
278
325
  `workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
326
+ // Printed only when this repository handed rules to a workspace copy: a line in
327
+ // every `doctor` is a line in every reader's terminal, and the reader who has
328
+ // nothing to install is not served by one (ADR-084).
329
+ ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
279
330
  ].join('\n'),
280
331
  { maxBytes: flags.maxBytes ?? 0 },
281
332
  );
@@ -0,0 +1,127 @@
1
+ // `simt lessons [list|review]` — deterministic lesson candidates and promotion to
2
+ // `docs/lessons/NNNN-<slug>.md` (plan 0003 R32).
3
+
4
+ import { resolve } from 'node:path';
5
+
6
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { parsePathList } from '../argv.mjs';
9
+ import {
10
+ LESSONS_DIR,
11
+ LESSONS_FILE,
12
+ captureFromLedger,
13
+ captureRevertedCommits,
14
+ listPromotedLessons,
15
+ promoteLesson,
16
+ readLessonCandidates,
17
+ } from '../memory/lessons.mjs';
18
+
19
+ export async function lessonsListCommand(args, flags) {
20
+ if (args.length > 0) {
21
+ return fail(`unknown lessons argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons`);
22
+ }
23
+ const root = resolve(flags.root ?? process.cwd());
24
+ await captureFromLedger(root);
25
+ await captureRevertedCommits(root);
26
+
27
+ const [{ candidates, errors }, promoted] = await Promise.all([
28
+ readLessonCandidates(root),
29
+ listPromotedLessons(root),
30
+ ]);
31
+
32
+ const payload = {
33
+ ok: errors.length === 0,
34
+ command: 'lessons list',
35
+ root,
36
+ ledgerFile: LESSONS_FILE,
37
+ lessonsDir: LESSONS_DIR,
38
+ candidateCount: candidates.length,
39
+ promotedCount: promoted.length,
40
+ candidates,
41
+ promoted,
42
+ errors,
43
+ };
44
+
45
+ if (isJson(flags)) {
46
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
47
+ return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
48
+ }
49
+
50
+ const lines = [
51
+ `candidates ${candidates.length} (${LESSONS_FILE})`,
52
+ `promoted ${promoted.length} (${LESSONS_DIR})`,
53
+ ];
54
+
55
+ if (candidates.length > 0) {
56
+ lines.push('', 'CANDIDATES');
57
+ for (const c of candidates) {
58
+ const sigShort = c.signature.slice(0, 19);
59
+ const state = c.status === 'promoted' ? `promoted -> ${c.promotedTo}` : 'candidate';
60
+ lines.push(` ${c.id} ${c.kind.padEnd(14)} ${sigShort} ${state} ${c.summary}`);
61
+ }
62
+ }
63
+
64
+ if (promoted.length > 0) {
65
+ lines.push('', 'LESSONS');
66
+ for (const l of promoted) {
67
+ lines.push(` ${l.id.padEnd(28)} ${(l.signature ?? 'no-signature').slice(0, 19)} ${l.title}`);
68
+ }
69
+ }
70
+
71
+ for (const err of errors) lines.push(`error ${err}`);
72
+
73
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
74
+ return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
75
+ }
76
+
77
+ export async function lessonsReviewCommand(args, flags) {
78
+ if (args.length > 1) {
79
+ return fail(`unknown lessons review argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} lessons review [<id>] [--yes]`);
80
+ }
81
+ const root = resolve(flags.root ?? process.cwd());
82
+ const mode = writeMode(flags);
83
+ const parsedPaths = parsePathList(flags.paths);
84
+ const paths = parsedPaths.length > 0 ? parsedPaths : undefined;
85
+ const title = flags.task ? String(flags.task) : undefined;
86
+
87
+ const result = await promoteLesson(root, {
88
+ id: args[0],
89
+ apply: mode.apply,
90
+ paths,
91
+ title,
92
+ });
93
+
94
+ if (!result.ok) {
95
+ return fail(result.error, EXIT.FAIL, `record a session failure-then-fix or run: ${CLI} lessons`);
96
+ }
97
+
98
+ const payload = {
99
+ ok: true,
100
+ command: 'lessons review',
101
+ root,
102
+ dryRun: mode.dryRun,
103
+ written: result.written,
104
+ path: result.path,
105
+ candidate: result.candidate,
106
+ markdown: result.markdown,
107
+ };
108
+
109
+ if (isJson(flags)) {
110
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
111
+ return EXIT.OK;
112
+ }
113
+
114
+ const lines = [
115
+ `promote ${result.candidate.id} (${result.candidate.kind}) -> ${result.path}`,
116
+ `signature ${result.candidate.signature}`,
117
+ `mode ${mode.apply ? 'applied' : mode.dryRun ? 'dry-run (writes nothing)' : `preview (pass --yes to write ${result.path})`}`,
118
+ '',
119
+ result.markdown.trimEnd(),
120
+ ];
121
+
122
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
123
+ if (!mode.apply) {
124
+ note(`note dry-run nothing written; apply with: ${CLI} lessons review --yes`);
125
+ }
126
+ return EXIT.OK;
127
+ }
@@ -0,0 +1,97 @@
1
+ // `simt local doctor` — probe configured local model endpoints (plan 0003 R37).
2
+ //
3
+ // Reads `local: { chat: { url, model }, embed: { url, model } }` from `simt.json`.
4
+ // When `local` is unset, opens no socket and exits 0. Refuses non-loopback URLs
5
+ // without `--allow-remote` (ADR-075).
6
+
7
+ import { resolve } from 'node:path';
8
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
10
+ import { probeLocal } from '../local/client.mjs';
11
+
12
+ function formatRoleLine(role, report) {
13
+ if (!report) return `${role.padEnd(8)} (unset)`;
14
+ if (report.status === 'refused-remote') {
15
+ return `${role.padEnd(8)} refused-remote ${report.model} @ ${report.url}`;
16
+ }
17
+ if (!report.ok) {
18
+ return `${role.padEnd(8)} ${report.status} ${report.model} @ ${report.url} (${report.error})`;
19
+ }
20
+ const extra =
21
+ role === 'embed' && Number.isInteger(report.dimensions)
22
+ ? ` dimensions=${report.dimensions}`
23
+ : '';
24
+ const listed =
25
+ report.modelListed === true
26
+ ? ' listed=yes'
27
+ : report.modelListed === false
28
+ ? ' listed=no'
29
+ : '';
30
+ return `${role.padEnd(8)} ok ${report.model} @ ${report.url}${extra}${listed}`;
31
+ }
32
+
33
+ function remedyFor(result) {
34
+ if (result.status === 'refused-remote') {
35
+ return `set a loopback URL (http://127.0.0.1:<port>/v1) in ${MANIFEST_FILE}, or run: ${CLI} local doctor --allow-remote`;
36
+ }
37
+ if (result.status === 'invalid-config' || result.status === 'invalid-manifest') {
38
+ return `fix the \`local\` configuration in ${MANIFEST_FILE}`;
39
+ }
40
+ if (result.status === 'unreachable') {
41
+ return `start the local model server at the URL in ${MANIFEST_FILE}, then rerun: ${CLI} local doctor`;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export async function localDoctorCommand(args, flags) {
47
+ const root = resolve(flags.root ?? args[0] ?? process.cwd());
48
+ const allowRemote = flags.allowRemote === true;
49
+ const result = await probeLocal(root, { allowRemote });
50
+
51
+ const code = result.ok
52
+ ? EXIT.OK
53
+ : result.status === 'refused-remote' ||
54
+ result.status === 'invalid-config' ||
55
+ result.status === 'invalid-manifest'
56
+ ? EXIT.CONFIG
57
+ : EXIT.FAIL;
58
+
59
+ const error = result.ok ? null : result.findings[0] ?? `local model probe failed (${result.status})`;
60
+ const remedy = result.ok ? null : remedyFor(result);
61
+
62
+ if (isJson(flags)) {
63
+ emitJson(
64
+ {
65
+ ok: code === EXIT.OK,
66
+ command: 'local doctor',
67
+ root,
68
+ configured: result.configured,
69
+ status: result.status,
70
+ allowRemote: result.allowRemote,
71
+ remoteBlocked: result.remoteBlocked,
72
+ socketsOpened: result.socketsOpened,
73
+ chat: result.chat,
74
+ embed: result.embed,
75
+ findings: result.findings,
76
+ ...(error ? { error, remedy, exitCode: code } : {}),
77
+ },
78
+ { maxBytes: flags.maxBytes ?? 0 },
79
+ );
80
+ return code;
81
+ }
82
+
83
+ const lines = [
84
+ `root ${root}`,
85
+ `local ${result.configured ? result.status : '(unset) optional adapter is off; no socket opened'}`,
86
+ formatRoleLine('chat', result.chat),
87
+ formatRoleLine('embed', result.embed),
88
+ `sockets ${result.socketsOpened}`,
89
+ ];
90
+
91
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
92
+
93
+ if (!result.ok) {
94
+ return fail(error, code, remedy);
95
+ }
96
+ return EXIT.OK;
97
+ }
@@ -0,0 +1,126 @@
1
+ // `simt map` ranks the files a task is most likely to touch (R22, ADR-091).
2
+ // The cache is read, and rebuilt once when it is empty, because a map of no
3
+ // files is not an answer. The rebuild writes the gitignored index, which is
4
+ // the same cache `simt index` writes.
5
+
6
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
9
+ import {
10
+ DEFAULT_BUDGET,
11
+ embedTaskQuery,
12
+ formatRecall,
13
+ measureRecall,
14
+ rankFiles,
15
+ renderMap,
16
+ } from '../index/map.mjs';
17
+
18
+ const LEVELS = new Set(['outline', 'names', 'signatures']);
19
+
20
+ export async function mapCommand(args, flags) {
21
+ if (args.length > 0) return fail(`unknown map argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} map`);
22
+ const root = flags.root ?? process.cwd();
23
+ if (flags.recall === true) return recall(root, flags);
24
+ const level = flags.level ?? 'outline';
25
+ if (!LEVELS.has(level)) {
26
+ return fail(`unknown level ${level}`, EXIT.USAGE, 'use outline, names or signatures');
27
+ }
28
+ const budget = flags.budget === undefined ? DEFAULT_BUDGET : Number(flags.budget);
29
+ if (!Number.isInteger(budget) || budget < 0) {
30
+ return fail(`budget must be a non-negative integer`, EXIT.USAGE, `run: ${CLI} map --budget 2048`);
31
+ }
32
+ const loaded = await loadGraph(root);
33
+ if (loaded.error) return loaded.error;
34
+ const task = typeof flags.task === 'string' ? flags.task : '';
35
+ const bm25 = task.trim().length > 0 ? await searchFtsBm25(root, task, { fts: loaded.fts }) : [];
36
+ const queryVector =
37
+ flags.embed === true && loaded.embeddings.length > 0
38
+ ? await embedTaskQuery(root, task, {
39
+ embed: true,
40
+ allowRemote: flags.allowRemote === true,
41
+ })
42
+ : null;
43
+ const ranked = rankFiles({ ...loaded, bm25, queryVector, task });
44
+ const rendered = renderMap(ranked, loaded.symbols, { level, budget });
45
+ const payload = {
46
+ ok: true,
47
+ command: 'map',
48
+ root,
49
+ level,
50
+ budget,
51
+ shown: rendered.shown,
52
+ total: rendered.total,
53
+ truncated: rendered.truncated,
54
+ files: rendered.files,
55
+ text: rendered.text,
56
+ };
57
+ if (isJson(flags)) {
58
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
59
+ return EXIT.OK;
60
+ }
61
+ const lines = [
62
+ `level ${level}`,
63
+ `shown ${rendered.shown}`,
64
+ `total ${rendered.total}`,
65
+ `budget ${budget}`,
66
+ ];
67
+ if (rendered.text) lines.push(rendered.text);
68
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
69
+ return EXIT.OK;
70
+ }
71
+
72
+ async function recall(root, flags) {
73
+ let result;
74
+ try {
75
+ result = await measureRecall(root, {
76
+ embed: flags.embed === true,
77
+ allowRemote: flags.allowRemote === true,
78
+ });
79
+ } catch (error) {
80
+ return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
81
+ }
82
+ const payload = { ok: true, command: 'map', root, recall: result };
83
+ if (isJson(flags)) {
84
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
85
+ return EXIT.OK;
86
+ }
87
+ emit(formatRecall(result), { maxBytes: flags.maxBytes ?? 0 });
88
+ return EXIT.OK;
89
+ }
90
+
91
+ /**
92
+ * The files, symbols, edges, FTS rows and stored vector embeddings the index holds,
93
+ * building the index once when it is empty.
94
+ *
95
+ * `build: false` refuses that rebuild and answers with `cold: true` instead. The one
96
+ * caller that passes it is the prompt-submit hook (R29), and the reason is the bar that
97
+ * hook has to meet: a delivered packet that built an index would make a person's first
98
+ * prompt in a fresh clone wait on a walk of the whole repository, and a prompt that takes
99
+ * a minute to appear is how a hook gets uninstalled. The next prompt gets the packet, and
100
+ * a command a person typed at a terminal always builds what it needs.
101
+ */
102
+ export async function loadGraph(root, { build = true } = {}) {
103
+ let files = await readFiles(root);
104
+ if (files.length === 0) {
105
+ if (!build) return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true };
106
+ try {
107
+ await reindex(root);
108
+ } catch (error) {
109
+ return {
110
+ error: fail(
111
+ `could not map this repository (${error.message})`,
112
+ EXIT.UNSUPPORTED,
113
+ 'run this inside a git repository',
114
+ ),
115
+ };
116
+ }
117
+ files = await readFiles(root);
118
+ }
119
+ const [symbols, edges, fts, embeddings] = await Promise.all([
120
+ readSymbols(root),
121
+ readEdges(root),
122
+ readFts(root),
123
+ readEmbeddings(root),
124
+ ]);
125
+ return { files, symbols, edges, fts, embeddings };
126
+ }
@@ -0,0 +1,55 @@
1
+ // `simt index` rebuilds the local cache from git. The cache is not repository
2
+ // content: it is gitignored, and a second run with the same blobs writes the same
3
+ // rows (ADR-089).
4
+
5
+ import { execFile } from 'node:child_process';
6
+
7
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
8
+ import { CLI } from '../name.mjs';
9
+ import { JSONL_FILE, SQLITE_FILE, reindex } from '../index/store.mjs';
10
+
11
+ export async function indexCommand(args, flags) {
12
+ if (args.length > 0) return fail(`unknown index argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} index`);
13
+ const root = flags.root ?? process.cwd();
14
+ let result;
15
+ try {
16
+ result = await reindex(root, {
17
+ embed: flags.embed === true,
18
+ allowRemote: flags.allowRemote === true,
19
+ });
20
+ } catch (error) {
21
+ return fail(
22
+ `could not index this repository (${error.message})`,
23
+ EXIT.UNSUPPORTED,
24
+ 'run this inside a git repository',
25
+ );
26
+ }
27
+ const file = result.backend === 'sqlite' ? SQLITE_FILE : JSONL_FILE;
28
+ const ignored = await isIgnored(root, file);
29
+ const payload = { ok: true, command: 'index', root, file, ignored, ...result };
30
+ if (isJson(flags)) {
31
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
32
+ return EXIT.OK;
33
+ }
34
+ const lines = [
35
+ `index ${result.backend}`,
36
+ `files ${result.files}`,
37
+ `updated ${result.updated}`,
38
+ `removed ${result.removed}`,
39
+ `symbols ${result.symbols}`,
40
+ `edges ${result.edges}`,
41
+ `ms ${result.ms}`,
42
+ ];
43
+ if (result.parsers === 'missing') lines.push('note index.parsers did not load, so the built-in extractors ran');
44
+ if (!ignored) lines.push(`note ${file} is not gitignored, and the cache is not meant to be committed`);
45
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
46
+ return EXIT.OK;
47
+ }
48
+
49
+ function isIgnored(root, file) {
50
+ return new Promise((resolve) => {
51
+ execFile('git', ['check-ignore', '-q', '--', file], { cwd: root }, (error) => {
52
+ resolve(!error);
53
+ });
54
+ });
55
+ }
@@ -14,6 +14,8 @@
14
14
  // one, because "this rule has no decision behind it" is exactly the fact a
15
15
  // reviewer needs and exactly the fact an empty output would hide.
16
16
 
17
+ import { readdir } from 'node:fs/promises';
18
+ import { join } from 'node:path';
17
19
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
18
20
  import { loadRules, select } from '../projection/project.mjs';
19
21
  import { loadMdcRules } from '../projection/mdc.mjs';
@@ -217,10 +219,17 @@ export async function whyCommand(args, flags) {
217
219
  if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
218
220
 
219
221
  const { rules, broken } = await load(flags.root ?? null);
220
- const { ok, decisions, errors } = await loadDecisions({ all: flags.all === true });
222
+ const localDir = flags.root ? join(flags.root, 'docs', 'decisions') : null;
223
+ const hasLocal = localDir
224
+ ? (await readdir(localDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
225
+ : false;
226
+ const { ok, decisions, errors } = await loadDecisions({
227
+ all: flags.all === true,
228
+ ...(hasLocal ? { dir: localDir } : {}),
229
+ });
221
230
  if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
222
231
 
223
- return what.match(/^ADR-\d{3}$/i)
232
+ return what.match(/^ADR-\d{3,}$/i)
224
233
  ? adrDirection(what.toUpperCase(), rules, decisions, flags)
225
234
  : ruleDirection(what, rules, decisions, flags, broken);
226
235
  }
@@ -326,6 +335,12 @@ async function adrDirection(adr, rules, decisions, flags) {
326
335
  ];
327
336
  if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
328
337
  if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
338
+ const partialForward = (d.supersedesRulings ?? []).filter((s) => s.ruling !== null);
339
+ if (partialForward.length > 0) {
340
+ lines.push(
341
+ `supersedes ${partialForward.map((s) => `${s.adr} ruling ${s.ruling} (${s.kind})`).join('; ')}`,
342
+ );
343
+ }
329
344
  // A partial supersession is named here as well as on the ruling it moved, because the
330
345
  // two print different sets: this prints every ruling the record lost, and the
331
346
  // annotation prints the ones inside the excerpt below. A ruling past the excerpt