simt-dev 0.3.1 → 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 (52) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +14 -4
  3. package/package.json +2 -2
  4. package/rules/core.md +1 -1
  5. package/src/agenthooks/policy.mjs +10 -2
  6. package/src/agenthooks/prompt.mjs +81 -0
  7. package/src/agenthooks/render.mjs +138 -17
  8. package/src/argv.mjs +16 -1
  9. package/src/bench/grade.mjs +3 -0
  10. package/src/bench/run.mjs +85 -39
  11. package/src/bench/variants.mjs +456 -0
  12. package/src/brief/assemble.mjs +346 -0
  13. package/src/brief/kind.mjs +134 -0
  14. package/src/brief/packet.mjs +197 -0
  15. package/src/commands/agenthook.mjs +84 -5
  16. package/src/commands/bench.mjs +40 -15
  17. package/src/commands/brief.mjs +78 -0
  18. package/src/commands/compare.mjs +10 -2
  19. package/src/commands/decide.mjs +116 -0
  20. package/src/commands/help.mjs +34 -3
  21. package/src/commands/index.mjs +17 -1
  22. package/src/commands/lessons.mjs +127 -0
  23. package/src/commands/local.mjs +97 -0
  24. package/src/commands/map.mjs +42 -7
  25. package/src/commands/repoindex.mjs +4 -1
  26. package/src/commands/rules.mjs +17 -2
  27. package/src/commands/skills.mjs +3 -17
  28. package/src/commands/skillscompile.mjs +255 -0
  29. package/src/commands/sync.mjs +15 -0
  30. package/src/commands/tests.mjs +2 -49
  31. package/src/commands/why.mjs +18 -23
  32. package/src/compare/learn.mjs +680 -0
  33. package/src/decisions/index.mjs +116 -65
  34. package/src/federation/client.mjs +188 -0
  35. package/src/federation/config.mjs +134 -0
  36. package/src/federation/query.mjs +48 -0
  37. package/src/graph/compile.mjs +475 -0
  38. package/src/index/gate.mjs +1 -13
  39. package/src/index/govern.mjs +23 -11
  40. package/src/index/map.mjs +251 -17
  41. package/src/index/persist.mjs +74 -7
  42. package/src/index/store.mjs +222 -8
  43. package/src/kernel/variants.mjs +353 -0
  44. package/src/ledger/index.mjs +36 -1
  45. package/src/local/client.mjs +482 -0
  46. package/src/local/config.mjs +234 -0
  47. package/src/mcp/tools.mjs +138 -1
  48. package/src/memory/decide.mjs +462 -0
  49. package/src/memory/lessons.mjs +858 -0
  50. package/src/projection/lock.mjs +39 -7
  51. package/src/projection/rules.mjs +32 -8
  52. package/src/toolwords.mjs +6 -0
@@ -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
+ }
@@ -5,8 +5,15 @@
5
5
 
6
6
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
7
7
  import { CLI } from '../name.mjs';
8
- import { readEdges, readFiles, readSymbols, reindex } from '../index/store.mjs';
9
- import { DEFAULT_BUDGET, formatRecall, measureRecall, rankFiles, renderMap } from '../index/map.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';
10
17
 
11
18
  const LEVELS = new Set(['outline', 'names', 'signatures']);
12
19
 
@@ -25,7 +32,15 @@ export async function mapCommand(args, flags) {
25
32
  const loaded = await loadGraph(root);
26
33
  if (loaded.error) return loaded.error;
27
34
  const task = typeof flags.task === 'string' ? flags.task : '';
28
- const ranked = rankFiles({ ...loaded, 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 });
29
44
  const rendered = renderMap(ranked, loaded.symbols, { level, budget });
30
45
  const payload = {
31
46
  ok: true,
@@ -57,7 +72,10 @@ export async function mapCommand(args, flags) {
57
72
  async function recall(root, flags) {
58
73
  let result;
59
74
  try {
60
- result = await measureRecall(root);
75
+ result = await measureRecall(root, {
76
+ embed: flags.embed === true,
77
+ allowRemote: flags.allowRemote === true,
78
+ });
61
79
  } catch (error) {
62
80
  return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
63
81
  }
@@ -70,9 +88,21 @@ async function recall(root, flags) {
70
88
  return EXIT.OK;
71
89
  }
72
90
 
73
- async function loadGraph(root) {
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 } = {}) {
74
103
  let files = await readFiles(root);
75
104
  if (files.length === 0) {
105
+ if (!build) return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true };
76
106
  try {
77
107
  await reindex(root);
78
108
  } catch (error) {
@@ -86,6 +116,11 @@ async function loadGraph(root) {
86
116
  }
87
117
  files = await readFiles(root);
88
118
  }
89
- const [symbols, edges] = await Promise.all([readSymbols(root), readEdges(root)]);
90
- return { files, symbols, edges };
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 };
91
126
  }
@@ -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})`,
@@ -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
@@ -22,6 +22,9 @@ 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))
@@ -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).