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.
- package/CHANGELOG.md +109 -0
- package/README.md +14 -4
- package/package.json +2 -2
- package/rules/core.md +1 -1
- 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 +16 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +40 -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/help.mjs +34 -3
- package/src/commands/index.mjs +17 -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/rules.mjs +17 -2
- package/src/commands/skills.mjs +3 -17
- 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 +18 -23
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- 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 +234 -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/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
|
+
}
|
package/src/commands/map.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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([
|
|
90
|
-
|
|
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})`,
|
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
|
@@ -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
|
+
}
|
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).
|