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.
- package/CHANGELOG.md +157 -0
- package/README.md +271 -62
- package/package.json +6 -2
- package/rules/core.md +1 -1
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +28 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +88 -54
- package/src/bench/tasks.mjs +38 -0
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +368 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +200 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +72 -15
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/distill.mjs +36 -0
- package/src/commands/help.mjs +62 -3
- package/src/commands/index.mjs +29 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +42 -7
- package/src/commands/repoindex.mjs +4 -1
- package/src/commands/review.mjs +65 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/skills.mjs +5 -50
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +15 -0
- package/src/commands/tests.mjs +2 -49
- package/src/commands/why.mjs +23 -25
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/distill/distill.mjs +246 -0
- package/src/distill/engine.mjs +187 -0
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/index/gate.mjs +1 -13
- package/src/index/govern.mjs +23 -11
- package/src/index/map.mjs +251 -17
- package/src/index/persist.mjs +74 -7
- package/src/index/store.mjs +222 -8
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +36 -1
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +269 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/lock.mjs +39 -7
- package/src/projection/rules.mjs +32 -8
- package/src/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- 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
|
+
}
|
package/src/commands/rules.mjs
CHANGED
|
@@ -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
|
|
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
|
package/src/commands/skills.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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).
|
package/src/commands/tests.mjs
CHANGED
|
@@ -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
|
|
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
|
-
}
|
package/src/commands/why.mjs
CHANGED
|
@@ -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
|
|
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
|
|
92
|
-
const
|
|
93
|
-
const
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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) {
|