simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -13,7 +13,10 @@ export async function indexCommand(args, flags) {
13
13
  const root = flags.root ?? process.cwd();
14
14
  let result;
15
15
  try {
16
- result = await reindex(root);
16
+ result = await reindex(root, {
17
+ embed: flags.embed === true,
18
+ allowRemote: flags.allowRemote === true,
19
+ });
17
20
  } catch (error) {
18
21
  return fail(
19
22
  `could not index this repository (${error.message})`,
@@ -0,0 +1,65 @@
1
+ // `simt review [--local]` (plan 0003 R39).
2
+ //
3
+ // The critic's report layer: the deterministic path prints the brief (staged diff
4
+ // plus what governs it); `--local` adds the model verdict, which always arrives as
5
+ // a proposal a human reads — nothing here is a gate, and the exit code never
6
+ // carries the verdict, because a critic that fails the run would be a gate by
7
+ // another name (ADR-090, principle P2: the reply's text is model output and never
8
+ // lands in projected or committed text).
9
+
10
+ import { CLI } from '../name.mjs';
11
+ import { EXIT, emit, emitJson, isJson } from '../output/index.mjs';
12
+ import { reviewTarget } from '../review/critic.mjs';
13
+
14
+ function governsLine(items) {
15
+ if (items.length === 0) return 'none';
16
+ const ids = items.slice(0, 8).map((item) => item.id);
17
+ const more = items.length > ids.length ? ` …+${items.length - ids.length}` : '';
18
+ return ids.join(', ') + more;
19
+ }
20
+
21
+ export async function reviewCommand(args, flags, commands = []) {
22
+ const result = await reviewTarget(args, flags, { commands });
23
+ if (typeof result === 'number') return result;
24
+
25
+ if (isJson(flags)) {
26
+ emitJson(result, { maxBytes: flags.maxBytes ?? 0 });
27
+ return EXIT.OK;
28
+ }
29
+
30
+ const lines = [
31
+ `staged ${result.staged.files.length} files, ${result.staged.bytes} bytes`,
32
+ `governs ${governsLine(result.governs)}`,
33
+ ];
34
+ // The branch keys on the path, not on the verdict: a local run whose reply
35
+ // lacked a verdict line still carries faults and a proposal, and hiding them
36
+ // behind the "needs --local" reading would erase the model's failure.
37
+ if (result.brief !== undefined) {
38
+ // The nothing-staged run is a plain answer, not an invitation to rerun with
39
+ // --local: there is no brief yet, so there is nothing for the critic to read.
40
+ if (result.staged.files.length === 0) {
41
+ lines.push('verdict none (nothing is staged)');
42
+ } else {
43
+ lines.push(
44
+ `verdict needs ${CLI} review --local (advisory; a critic is never a gate)`,
45
+ );
46
+ }
47
+ } else {
48
+ if (result.verdict !== null) {
49
+ lines.push(
50
+ `verdict ${result.verdict} (${result.verified ? 'verified' : 'unverified'})`,
51
+ `findings ${result.findings}`,
52
+ );
53
+ } else {
54
+ lines.push('verdict absent (unverified)');
55
+ }
56
+ if (!result.verified) {
57
+ for (const fault of result.faults) lines.push(`unverified ${fault}`);
58
+ }
59
+ lines.push(`proposal ${result.proposal.path} (${result.proposal.state}, ${result.proposal.bytes} bytes)`);
60
+ }
61
+ if (result.metric) lines.push(`metric ${result.metric.note}`);
62
+
63
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
64
+ return EXIT.OK;
65
+ }
@@ -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
@@ -15,13 +15,16 @@ import { existsSync } from 'node:fs';
15
15
 
16
16
  import { EXIT, emit, emitJson, fail, isJson, note as emitNote } from '../output/index.mjs';
17
17
  import { loadRegistry } from '../graph/load.mjs';
18
- import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
18
+ import { bodyFile, sourcePath } from '../resolve/body.mjs';
19
19
  import { unknownSkills, scoreCorpus } from '../resolve/eval.mjs';
20
20
  import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
21
21
  import { fingerprint } from '../resolve/fingerprint.mjs';
22
22
  import { tokenize, rankSkills, closure, selectWithinBudget, resolveAlias, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
23
23
  import { nearMatches } from '../resolve/near.mjs';
24
24
  import { CLI } from '../name.mjs';
25
+ import { parsePathList } from '../argv.mjs';
26
+
27
+ export { parsePathList };
25
28
 
26
29
  const DEFAULT_BUDGET = 20480;
27
30
 
@@ -42,23 +45,6 @@ export async function dirtyPaths(root) {
42
45
  }
43
46
  }
44
47
 
45
- export function parsePathList(value) {
46
- return String(value ?? '')
47
- .split(',')
48
- .map((s) => s.trim())
49
- .filter(Boolean);
50
- }
51
-
52
- /**
53
- * Read a list flag in both the forms this tool ships.
54
- *
55
- * `--skill` accepts `--skill a --skill b` and `--skill a,b`, and arrives here as an
56
- * array in the first case and a string in the second. `String(['a','b'])` happens to
57
- * produce `'a,b'`, so the old one-line reader would have kept working by accident — and
58
- * would have started throwing the moment a skill name contained the character the
59
- * join inserts. Both shapes are handled rather than one being relied upon, because
60
- * relying on it is the same class of bug as the one being fixed here.
61
- */
62
48
  export function listFlag(value) {
63
49
  return (Array.isArray(value) ? value : [value])
64
50
  .flatMap((v) => parsePathList(v))
@@ -670,36 +656,6 @@ export async function evalCommand(positional, flags) {
670
656
 
671
657
  // --- bodies -------------------------------------------------------------------
672
658
 
673
- /**
674
- * Resolve a skill's body to an absolute path.
675
- *
676
- * Sources are declared as `repo/path`, so finding one needs the workspace that
677
- * CONTAINS the repos. That used to be `resolve(here, '..', '..', '..')`, which is
678
- * right in the checkout and wrong in `node_modules` - so every source resolved to a
679
- * path that could not exist and `skills` failed with 38 ENOENT lines. Now it comes
680
- * from configuration, and `null` is a legitimate answer meaning "this installation
681
- * has no workspace" (ADR-014).
682
- *
683
- * A second location exists and is not a fallback: the repo being read is itself a
684
- * location for a declared source, matched on bytes rather than on hope (ADR-015).
685
- * The canonical source is still tried first, so a workspace body always wins.
686
- */
687
- async function sourcePath(skill, workspaceRoot, root) {
688
- const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
689
-
690
- if (canonical) {
691
- const found = await locateSource(canonical, { workspaceRoot, root });
692
- if (found.path) return found.path;
693
- }
694
-
695
- const held = await sourceHeldLocally(skill, root, { workspaceRoot });
696
- if (held && !held.ambiguous) return held.path;
697
-
698
- // A skill with no canonical source, or one whose bytes match nothing, has no
699
- // body. Say so rather than printing an empty section.
700
- return canonical && workspaceRoot ? join(workspaceRoot, canonical.repo, canonical.path) : null;
701
- }
702
-
703
659
  /** A one-line pointer. The reasons stay, so a brief that cannot inline still says why. */
704
660
  function pointerLine(entry) {
705
661
  const why = [...entry.reasons, ...[...entry.via].map((v) => `requires:${v}`)];
@@ -719,8 +675,7 @@ export async function readBody(skill, workspaceRoot, root) {
719
675
  }
720
676
  if (!existsSync(full)) return `(body unavailable: ${full} is not on this machine)`;
721
677
  try {
722
- const target = existsSync(join(full, 'SKILL.md')) ? join(full, 'SKILL.md') : full;
723
- return (await readFile(target, 'utf8')).trimEnd();
678
+ return (await readFile(bodyFile(full), 'utf8')).trimEnd();
724
679
  } catch (error) {
725
680
  return `(body unreadable: ${error.message})`;
726
681
  }
@@ -0,0 +1,255 @@
1
+ // `simt skills compile [<name>]` - compile skills to Agent Skills standard, flag bodies
2
+ // over 5k tokens with a progressive-disclosure split plan, and report per-skill trigger
3
+ // recall and precision from R02 transcripts (plan 0003 R30).
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
7
+ import { dirname, join, resolve } from 'node:path';
8
+
9
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
10
+ import { loadRegistry } from '../graph/load.mjs';
11
+ import {
12
+ BODY_TOKEN_BUDGET,
13
+ compileSkill,
14
+ evaluateSkillTriggers,
15
+ proposeSkillDescription,
16
+ } from '../graph/compile.mjs';
17
+ import { loadRunRecords } from '../bench/report.mjs';
18
+ import { extractPatchFiles } from '../bench/grade.mjs';
19
+ import { resolveVaultDir } from '../bench/mine.mjs';
20
+ import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
21
+ import { nearMatches } from '../resolve/near.mjs';
22
+ import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
23
+ import { CLI, CONFIG_DIR } from '../name.mjs';
24
+
25
+ const STATE_DIR = `.${CONFIG_DIR}`;
26
+
27
+ async function readSkillRawText(skill, workspaceRoot, root) {
28
+ const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
29
+ let full = null;
30
+ if (canonical) {
31
+ const found = await locateSource(canonical, { workspaceRoot, root });
32
+ if (found.path) full = found.path;
33
+ }
34
+ if (!full) {
35
+ const held = await sourceHeldLocally(skill, root, { workspaceRoot });
36
+ if (held && !held.ambiguous) full = held.path;
37
+ }
38
+ if (!full || !existsSync(full)) return null;
39
+ try {
40
+ const target = existsSync(join(full, 'SKILL.md')) ? join(full, 'SKILL.md') : full;
41
+ return await readFile(target, 'utf8');
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ async function loadGoldPathsByTask(root) {
48
+ const map = new Map();
49
+ const tasksDir = join(root, STATE_DIR, 'bench', 'tasks');
50
+ const vaultRoot = resolveVaultDir(root);
51
+ const entries = (await readdir(tasksDir).catch(() => [])).filter((f) => f.endsWith('.json')).sort();
52
+ for (const entry of entries) {
53
+ try {
54
+ const task = JSON.parse(await readFile(join(tasksDir, entry), 'utf8'));
55
+ const patch = await readFile(join(vaultRoot, task.id, 'gold.patch'), 'utf8').catch(() => '');
56
+ if (patch) map.set(task.id, extractPatchFiles(patch));
57
+ } catch {
58
+ // ignore unreadable task
59
+ }
60
+ }
61
+ return map;
62
+ }
63
+
64
+ function fmtMetric(v) {
65
+ return v === null || v === undefined ? 'n/a' : v.toFixed(3);
66
+ }
67
+
68
+ export async function compileCommand(args, flags) {
69
+ const { registry, errors, ok } = await loadRegistry(null, flags);
70
+ if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
71
+
72
+ const root = resolve(flags.root ?? process.cwd());
73
+ const ws = await resolveWorkspace(flags);
74
+ const targetName = args[0] ?? null;
75
+
76
+ if (targetName) {
77
+ const skill = registry.skills.find((s) => s.name === targetName);
78
+ if (!skill) {
79
+ const near = nearMatches(targetName, registry.skills.map((s) => s.name));
80
+ return fail(
81
+ `no skill named "${targetName}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
82
+ EXIT.USAGE,
83
+ `run: ${CLI} skills find <query>`,
84
+ );
85
+ }
86
+ return runSingleSkillCompile(skill, registry, { root, workspaceRoot: ws.root, flags });
87
+ }
88
+
89
+ const [runs, goldPathsByTask] = await Promise.all([
90
+ loadRunRecords(root),
91
+ loadGoldPathsByTask(root),
92
+ ]);
93
+
94
+ const bodiesBySkill = new Map();
95
+ for (const skill of registry.skills) {
96
+ const raw = await readSkillRawText(skill, ws.root, root);
97
+ if (raw) bodiesBySkill.set(skill.name, raw);
98
+ }
99
+
100
+ const report = evaluateSkillTriggers(registry, runs, { bodiesBySkill, goldPathsByTask });
101
+ const flagged = report.skills.filter((s) => s.flagged);
102
+
103
+ if (isJson(flags)) {
104
+ emitJson(
105
+ {
106
+ ok: true,
107
+ command: 'skills compile',
108
+ root,
109
+ tokenBudget: BODY_TOKEN_BUDGET,
110
+ runCount: report.runCount,
111
+ skillCount: report.skillCount,
112
+ flaggedCount: report.flaggedCount,
113
+ skills: report.skills,
114
+ flagged: flagged.map((s) => ({
115
+ name: s.name,
116
+ bytes: s.bytes,
117
+ tokens: s.tokens,
118
+ splitPlan: s.splitPlan,
119
+ })),
120
+ },
121
+ { maxBytes: flags.maxBytes ?? 0 },
122
+ );
123
+ return EXIT.OK;
124
+ }
125
+
126
+ const lines = [
127
+ `skills ${report.skillCount} total, ${report.flaggedCount} over ${BODY_TOKEN_BUDGET} tokens (flagged with split plan)`,
128
+ `runs ${report.runCount} benchmark run(s) in ${root}`,
129
+ '',
130
+ 'TRIGGER REPORT',
131
+ 'name tokens exp open recall precision split',
132
+ ...report.skills.map((s) => {
133
+ const name = s.name.padEnd(28);
134
+ const tok = String(s.tokens).padStart(6);
135
+ const exp = String(s.expected).padStart(4);
136
+ const opn = String(s.opened).padStart(5);
137
+ const rec = fmtMetric(s.recall).padStart(7);
138
+ const prec = fmtMetric(s.precision).padStart(10);
139
+ const split = s.flagged ? `SPLIT (${s.splitPlan.references.length} refs)` : 'ok';
140
+ return `${name} ${tok} ${exp} ${opn} ${rec} ${prec} ${split}`;
141
+ }),
142
+ ];
143
+
144
+ if (flagged.length > 0) {
145
+ lines.push('', `SPLIT PLANS (${flagged.length} over ${BODY_TOKEN_BUDGET} tokens)`);
146
+ for (const s of flagged) {
147
+ const refs = s.splitPlan.references.map((r) => `${r.path} (~${r.tokens}t)`).join(', ');
148
+ lines.push(
149
+ ` ${s.name.padEnd(26)} ~${s.tokens}t (${s.bytes} B) -> SKILL.md (~${s.splitPlan.mainTokens}t) + ${s.splitPlan.references.length} ref(s): ${refs}`,
150
+ );
151
+ }
152
+ }
153
+
154
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
155
+ return EXIT.OK;
156
+ }
157
+
158
+ async function runSingleSkillCompile(skill, registry, { root, workspaceRoot, flags }) {
159
+ const mode = writeMode(flags);
160
+ const [rawText, runs, goldPathsByTask] = await Promise.all([
161
+ readSkillRawText(skill, workspaceRoot, root),
162
+ loadRunRecords(root),
163
+ loadGoldPathsByTask(root),
164
+ ]);
165
+
166
+ const bodiesBySkill = new Map();
167
+ if (rawText) bodiesBySkill.set(skill.name, rawText);
168
+
169
+ const triggerReport = evaluateSkillTriggers(registry, runs, { bodiesBySkill, goldPathsByTask });
170
+ const triggerStats = triggerReport.skills.find((s) => s.name === skill.name) ?? null;
171
+ const compiled = compileSkill(skill, rawText);
172
+ const proposal = await proposeSkillDescription(skill, {
173
+ currentDescription: compiled.description,
174
+ triggerStats,
175
+ });
176
+
177
+ const outDir = join(root, STATE_DIR, 'compiled', 'skills', skill.name);
178
+ const proposalPath = join(root, STATE_DIR, 'proposals', 'skills', `${skill.name}.json`);
179
+ const writtenFiles = [];
180
+
181
+ if (mode.apply) {
182
+ await mkdir(outDir, { recursive: true });
183
+ await writeFile(join(outDir, 'SKILL.md'), compiled.mainText, 'utf8');
184
+ writtenFiles.push(`${STATE_DIR}/compiled/skills/${skill.name}/SKILL.md`);
185
+
186
+ for (const ref of compiled.references) {
187
+ if (!ref.content) continue;
188
+ const refFull = join(outDir, ref.path);
189
+ await mkdir(dirname(refFull), { recursive: true });
190
+ await writeFile(refFull, ref.content, 'utf8');
191
+ writtenFiles.push(`${STATE_DIR}/compiled/skills/${skill.name}/${ref.path}`);
192
+ }
193
+
194
+ await mkdir(dirname(proposalPath), { recursive: true });
195
+ await writeFile(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
196
+ writtenFiles.push(`${STATE_DIR}/proposals/skills/${skill.name}.json`);
197
+ }
198
+
199
+ if (isJson(flags)) {
200
+ emitJson(
201
+ {
202
+ ok: true,
203
+ command: 'skills compile',
204
+ root,
205
+ skill: skill.name,
206
+ dryRun: mode.dryRun,
207
+ written: mode.apply,
208
+ files: writtenFiles,
209
+ description: compiled.description,
210
+ mainBytes: compiled.mainBytes,
211
+ mainTokens: compiled.mainTokens,
212
+ conformance: compiled.conformance,
213
+ splitPlan: compiled.splitPlan,
214
+ trigger: triggerStats
215
+ ? {
216
+ expected: triggerStats.expected,
217
+ opened: triggerStats.opened,
218
+ tp: triggerStats.tp,
219
+ fp: triggerStats.fp,
220
+ fn: triggerStats.fn,
221
+ recall: triggerStats.recall,
222
+ precision: triggerStats.precision,
223
+ }
224
+ : null,
225
+ proposal,
226
+ },
227
+ { maxBytes: flags.maxBytes ?? 0 },
228
+ );
229
+ return EXIT.OK;
230
+ }
231
+
232
+ const lines = [
233
+ `compile ${skill.name}`,
234
+ `mode ${mode.apply ? 'applied' : mode.dryRun ? 'dry-run (writes nothing)' : `preview (pass --yes to write compiled skill)`}`,
235
+ `source ${rawText ? 'body on disk' : 'registry metadata'}`,
236
+ `tokens ~${compiled.splitPlan.totalTokens} (${compiled.splitPlan.totalBytes} bytes)`,
237
+ `split ${compiled.splitPlan.flagged ? `flagged (${compiled.splitPlan.reason}) -> SKILL.md (~${compiled.splitPlan.mainTokens}t) + ${compiled.splitPlan.references.length} reference(s)` : 'within 5000-token budget'}`,
238
+ `conformance ${compiled.conformance.errors.length === 0 ? 'pass' : `fail (${compiled.conformance.errors.length})`}`,
239
+ `trigger expected=${triggerStats?.expected ?? 0} opened=${triggerStats?.opened ?? 0} recall=${fmtMetric(triggerStats?.recall)} precision=${fmtMetric(triggerStats?.precision)}`,
240
+ `proposal ${proposal.provenance.model} (${proposal.provenance.digest})`,
241
+ ];
242
+
243
+ if (compiled.splitPlan.references.length > 0) {
244
+ lines.push('', 'REFERENCES');
245
+ for (const ref of compiled.splitPlan.references) {
246
+ lines.push(` ${ref.path.padEnd(36)} ~${String(ref.tokens).padStart(5)}t (${ref.bytes} B) ${ref.title}`);
247
+ }
248
+ }
249
+
250
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
251
+ if (!mode.apply) {
252
+ note(`note dry-run nothing written; apply with: ${CLI} skills compile ${skill.name} --yes`);
253
+ }
254
+ return EXIT.OK;
255
+ }
@@ -30,6 +30,8 @@ import { assertVersionPin, packageVersion } from '../package-version.mjs';
30
30
  import { resolvePacks } from '../packs.mjs';
31
31
  import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
32
32
  import { runDeclaredChecks } from '../kernel/checks.mjs';
33
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
34
+ import { lintDecisions } from '../memory/decide.mjs';
33
35
 
34
36
  /** Findings `sync` reports and does not fail on.
35
37
  *
@@ -528,6 +530,19 @@ export async function checkCommand(positional, flags) { const root = flags.root
528
530
  const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
529
531
  findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
530
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
+ );
545
+
531
546
  // `CHECK_NON_BLOCKING`, not the union. This is the gate whose exit code a repository
532
547
  // acts on, so a finding it reports and does not fail on is a decision recorded in a
533
548
  // set — not one inherited from the writer that shares this file (ADR-051).
@@ -13,17 +13,11 @@ import { promisify } from 'node:util';
13
13
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
14
  import { CLI } from '../name.mjs';
15
15
  import { isTestPath } from '../bench/mine.mjs';
16
- import { readEdges, readFiles, reindex } from '../index/store.mjs';
16
+ import { blobTexts, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
17
17
  import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
18
18
 
19
19
  const exec = promisify(execFile);
20
20
 
21
- const LANG = {
22
- js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
23
- ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
24
- py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
25
- };
26
-
27
21
  const COVERAGE_PATHS = [
28
22
  'coverage/lcov.info',
29
23
  'lcov.info',
@@ -112,7 +106,7 @@ async function testBlobs(root, files) {
112
106
  for (const file of files) {
113
107
  if (isTestPath(file.path) && file.blob) blobs.push(file.blob);
114
108
  }
115
- return cat(root, blobs);
109
+ return blobTexts(root, blobs).catch(() => new Map());
116
110
  }
117
111
 
118
112
  async function coverageTexts(root) {
@@ -149,44 +143,3 @@ async function history(root) {
149
143
  }
150
144
  return commits;
151
145
  }
152
-
153
- async function cat(root, blobs) {
154
- const unique = [...new Set(blobs)];
155
- const texts = new Map();
156
- if (unique.length === 0) return texts;
157
- const body = await batch(root, unique);
158
- let offset = 0;
159
- while (offset < body.length) {
160
- const nl = body.indexOf(0x0a, offset);
161
- if (nl < 0) break;
162
- const header = body.toString('utf8', offset, nl);
163
- const parts = header.split(' ');
164
- const size = Number(parts[2]);
165
- if (parts[1] !== 'blob' || !Number.isFinite(size)) {
166
- offset = nl + 1;
167
- continue;
168
- }
169
- const start = nl + 1;
170
- const bytes = body.subarray(start, start + size);
171
- offset = start + size + 1;
172
- texts.set(parts[0], bytes.includes(0) ? null : bytes.toString('utf8'));
173
- }
174
- return texts;
175
- }
176
-
177
- function batch(root, blobs) {
178
- return new Promise((resolve) => {
179
- const child = execFile('git', ['cat-file', '--batch'], { cwd: root, maxBuffer: 32 << 20, encoding: 'buffer' }, (error, stdout) => {
180
- resolve(error ? Buffer.alloc(0) : stdout);
181
- });
182
- child.stdin.write(`${blobs.join('\n')}\n`);
183
- child.stdin.end();
184
- });
185
- }
186
-
187
- function langOf(path) {
188
- const base = path.slice(path.lastIndexOf('/') + 1);
189
- const dot = base.lastIndexOf('.');
190
- if (dot <= 0) return '';
191
- return LANG[base.slice(dot + 1)] ?? '';
192
- }
@@ -15,13 +15,14 @@ import { CLI, MANIFEST_FILE } from '../name.mjs';
15
15
  import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
16
16
  import {
17
17
  decisionHits,
18
- lessonPaths,
19
18
  scopeHits,
20
19
  scopePaths,
21
20
  sortHits,
22
21
  sourceRoots,
23
22
  unresolvedAffects,
24
23
  } from '../index/govern.mjs';
24
+ import { splitFrontmatter } from '../memory/decide.mjs';
25
+ import { listPromotedLessons } from '../memory/lessons.mjs';
25
26
 
26
27
  const exec = promisify(execFile);
27
28
 
@@ -62,7 +63,9 @@ function reportUnresolved(root, flags, unresolved) {
62
63
  return EXIT.OK;
63
64
  }
64
65
 
65
- async function tracked(root) {
66
+ // `async function tracked(root)` (read by `simt why`) becomes `export async`:
67
+ // the critic's brief resolves governance the same way without a second copy.
68
+ export async function tracked(root) {
66
69
  try {
67
70
  const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
68
71
  return { files: stdout.split('\n').filter(Boolean) };
@@ -81,22 +84,24 @@ export async function decisionRecords(root) {
81
84
  const dir = join(root, 'docs', 'decisions');
82
85
  let names = [];
83
86
  try {
84
- names = (await readdir(dir)).filter((name) => /^ADR-\d{3}-.*\.md$/.test(name)).sort();
87
+ names = (await readdir(dir)).filter((name) => /^ADR-\d{3,}-.*\.md$/i.test(name)).sort();
85
88
  } catch {
86
89
  return [];
87
90
  }
88
91
  const records = [];
89
92
  for (const name of names) {
90
93
  const text = await readFile(join(dir, name), 'utf8');
91
- const id = /^ADR-\d{3}/.exec(name)[0];
92
- const heading = /^#\s+(.*)$/m.exec(text)?.[1] ?? id;
93
- const title = heading.replace(/^ADR-\d{3}\s+[—–-]\s+/, '');
94
+ const { body } = splitFrontmatter(text);
95
+ const id = /^ADR-\d{3,}/i.exec(name)[0].toUpperCase();
96
+ const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
97
+ const title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim();
94
98
  records.push({ id, title, text });
95
99
  }
96
100
  return records;
97
101
  }
98
102
 
99
- async function loadCorpus(root) {
103
+ // `async function loadCorpus(root)` (read by `simt why`) becomes `export async`.
104
+ export async function loadCorpus(root) {
100
105
  const [rules, skills, lessons, commands] = await Promise.all([
101
106
  ruleScopes(root),
102
107
  skillScopes(root),
@@ -146,24 +151,17 @@ async function skillScopes(root) {
146
151
  .map((skill) => ({ id: String(skill.name ?? ''), detail: String(skill.summary ?? ''), paths: skill.paths }));
147
152
  }
148
153
 
149
- async function lessonScopes(root) {
150
- const dir = join(root, 'docs', 'lessons');
151
- let names = [];
152
- try {
153
- names = (await readdir(dir)).filter((name) => name.endsWith('.md')).sort();
154
- } catch {
155
- return [];
156
- }
157
- const lessons = [];
158
- for (const name of names) {
159
- const text = await readFile(join(dir, name), 'utf8');
160
- const paths = lessonPaths(text);
161
- if (paths.length === 0) continue;
162
- const id = name.replace(/\.md$/, '');
163
- const detail = /^#\s+(.*)$/m.exec(text)?.[1] ?? id;
164
- lessons.push({ id, detail, paths });
165
- }
166
- return lessons;
154
+ export async function lessonScopes(root) {
155
+ return (await listPromotedLessons(root))
156
+ .filter((lesson) => lesson.paths.length > 0)
157
+ .map((lesson) => ({
158
+ id: lesson.id,
159
+ detail: lesson.title,
160
+ paragraph: lesson.paragraph,
161
+ paths: lesson.paths,
162
+ signature: lesson.signature,
163
+ keywords: lesson.keywords,
164
+ }));
167
165
  }
168
166
 
169
167
  async function gateCommands(root) {