simt-dev 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/README.md +138 -74
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -2
- package/rules/core.md +2 -2
- 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 +34 -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/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +53 -16
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +65 -3
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +52 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +126 -0
- package/src/commands/repoindex.mjs +55 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/shared.mjs +398 -0
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +87 -3
- package/src/commands/tests.mjs +145 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +172 -0
- 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/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +89 -0
- package/src/index/govern.mjs +234 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +627 -0
- package/src/index/persist.mjs +239 -0
- package/src/index/store.mjs +515 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +147 -0
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/measure/context.mjs +7 -5
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +125 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +407 -49
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +8 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// `simt shared` - carry a workspace's repeated rules once, in the reader's home directory.
|
|
2
|
+
//
|
|
3
|
+
// Plan 0003 R11. The estate measured 90 KB of always-on rules across seven repositories
|
|
4
|
+
// with 72% of it a paragraph an earlier root had already loaded (review 0002 S38), and the
|
|
5
|
+
// plan's answer was to install the repeated part once per harness and let each region carry
|
|
6
|
+
// a delta.
|
|
7
|
+
//
|
|
8
|
+
// Two subcommands and no more. `plan` answers "what would this do, and where", because a
|
|
9
|
+
// command that writes into `$HOME` without printing the plan first is the one command in
|
|
10
|
+
// this tool that can surprise somebody on their own machine. `install` does it. Staleness
|
|
11
|
+
// is `doctor`'s job rather than a third subcommand: a question asked about a repository is
|
|
12
|
+
// answered by the command that already reports on a repository (ADR-084).
|
|
13
|
+
|
|
14
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
15
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
16
|
+
import { targetRoot } from '../workspace.mjs';
|
|
17
|
+
import {
|
|
18
|
+
intersect,
|
|
19
|
+
sharedBody,
|
|
20
|
+
sharedCandidates,
|
|
21
|
+
sharedCoverage,
|
|
22
|
+
sharedMeta,
|
|
23
|
+
sharedRoots,
|
|
24
|
+
sharedState,
|
|
25
|
+
sharedTargets,
|
|
26
|
+
writeSharedFile,
|
|
27
|
+
} from '../projection/shared.mjs';
|
|
28
|
+
import { buildManifest, declaredShared, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
29
|
+
import { desiredRegion } from '../projection/rules.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything `plan` and `install` both need, computed once.
|
|
33
|
+
*
|
|
34
|
+
* One function because the two commands must not be able to disagree about what they are
|
|
35
|
+
* about: an `install` that computed a different shared set from the one it printed is a
|
|
36
|
+
* command that writes something the reader was not shown, into their home directory.
|
|
37
|
+
*/
|
|
38
|
+
async function gather(flags) {
|
|
39
|
+
const target = await targetRoot(flags, []);
|
|
40
|
+
const { roots, error } = await sharedRoots(target.root);
|
|
41
|
+
if (error !== null) return { error: `${target.root}: ${error}` };
|
|
42
|
+
if (roots.length === 0) {
|
|
43
|
+
return {
|
|
44
|
+
error: `${target.root} contains no git repositories`,
|
|
45
|
+
remedy: `point --root at the directory holding the repositories, or run this in one repository with --root <path>`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const rows = await sharedCandidates(roots, flags);
|
|
49
|
+
const { shared, divergent } = intersect(rows);
|
|
50
|
+
const meta = await sharedMeta(shared);
|
|
51
|
+
|
|
52
|
+
// The union of every root's surfaces: the reader's home carries one file per harness any
|
|
53
|
+
// repository in the estate uses, because a reader works in more than one of these roots
|
|
54
|
+
// and the file is per-machine rather than per-repository.
|
|
55
|
+
const surfaces = [...new Set(rows.flatMap((row) => row.desired.surfaces))];
|
|
56
|
+
const targets = await sharedTargets(surfaces);
|
|
57
|
+
// Deduped across roots, in the order the first root names them: two roots that both
|
|
58
|
+
// selected a rule and both refused to carry it are one line, not two identical ones.
|
|
59
|
+
const excluded = [];
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
for (const entry of row.desired.excluded) {
|
|
62
|
+
if (!excluded.some((e) => e.id === entry.id)) excluded.push(entry);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { target, rows, shared, divergent, excluded, meta, targets, roots };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One sentence per rule the workspace cannot carry once, in the words its reason deserves.
|
|
70
|
+
*
|
|
71
|
+
* A rule every root carries but renders differently, a rule only some roots carry, and a
|
|
72
|
+
* rule this repository selected and cannot hand over at all are three different events, and
|
|
73
|
+
* a reader deciding whether to act on one needs to know which happened.
|
|
74
|
+
*/
|
|
75
|
+
function divergentNote(d, total) {
|
|
76
|
+
if (d.reason === 'coverage') {
|
|
77
|
+
return `${d.id} carried by ${plain(d.roots.length)} of ${plain(total)} repositories, so each region keeps its own`;
|
|
78
|
+
}
|
|
79
|
+
return `${d.id} rendered differently in ${plain(d.roots.length)} repositories, so each region keeps its own`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Why a selected rule cannot leave any region, in the words its reason deserves. */
|
|
83
|
+
function excludedNote({ id, why }) {
|
|
84
|
+
if (why === 'policy') return `${id} is a repository's own profile or pack policy, which stays where it was written`;
|
|
85
|
+
if (why === 'error') return `${id} could not be read cleanly, so it stays where it was written`;
|
|
86
|
+
return `${id} renders to nothing, so there is nothing to carry`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What to say when the copy is larger than the harness's own limit on that file.
|
|
91
|
+
*
|
|
92
|
+
* Reported rather than prevented: the cap is a fact about the reader's harness and the
|
|
93
|
+
* estate's rules are the reader's to choose, but a copy that Codex stops reading halfway
|
|
94
|
+
* through is a failure the reader would otherwise discover as "the agent ignored my rules".
|
|
95
|
+
*/
|
|
96
|
+
function capNote(target, bytes) {
|
|
97
|
+
if (target.path === null || target.cap === null || bytes <= target.cap.bytes) return null;
|
|
98
|
+
return [
|
|
99
|
+
`${target.surface} ${plain(bytes)} B exceeds the ${plain(target.cap.bytes)} B it allows for ${target.cap.appliesTo}`,
|
|
100
|
+
` ${target.cap.why}`,
|
|
101
|
+
` the rules above that limit are not read; move rules this workspace does not share back into each region, or drop the shared copy for this surface`,
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The per-root line: what this repository carries now, and what it would stop carrying. */
|
|
106
|
+
function perRoot(rows, shared) {
|
|
107
|
+
const ids = new Set(shared.map((r) => r.id));
|
|
108
|
+
return rows.map((row) => {
|
|
109
|
+
const full = row.desired.shareable.filter((r) => ids.has(r.id));
|
|
110
|
+
return {
|
|
111
|
+
root: row.path,
|
|
112
|
+
name: basename(row.path),
|
|
113
|
+
surfaces: row.desired.surfaces,
|
|
114
|
+
stack: row.desired.stack,
|
|
115
|
+
mode: row.desired.mode,
|
|
116
|
+
// The bytes the region gives up, measured as the frames themselves rather than as a
|
|
117
|
+
// difference between two whole projections: the region's own header, card and index
|
|
118
|
+
// stay, and a number that hid them would be a saving no reader would ever see.
|
|
119
|
+
bytes: full.reduce((n, r) => n + Buffer.byteLength(`${r.rendered}\n\n`), 0),
|
|
120
|
+
rules: full.map((r) => r.id),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function sharedPlanCommand(_args, flags) {
|
|
126
|
+
const gathered = await gather(flags);
|
|
127
|
+
if (gathered.error !== undefined) return fail(gathered.error, EXIT.CONFIG, gathered.remedy);
|
|
128
|
+
const { target, roots, shared, divergent, excluded, meta, targets } = gathered;
|
|
129
|
+
const rows = perRoot(gathered.rows, shared);
|
|
130
|
+
const body = sharedBody(shared);
|
|
131
|
+
const bytes = Buffer.byteLength(body);
|
|
132
|
+
const save = rows.reduce((n, r) => n + r.bytes, 0);
|
|
133
|
+
// Every id any root put forward, so the count cannot read as "and the rest were carried".
|
|
134
|
+
const candidates = new Set([...shared, ...divergent, ...excluded].map((r) => r.id)).size;
|
|
135
|
+
|
|
136
|
+
if (isJson(flags)) {
|
|
137
|
+
emitJson({
|
|
138
|
+
ok: true,
|
|
139
|
+
command: 'shared plan',
|
|
140
|
+
root: target.root,
|
|
141
|
+
mode: 'plan',
|
|
142
|
+
source: target.source,
|
|
143
|
+
roots,
|
|
144
|
+
shared,
|
|
145
|
+
divergent,
|
|
146
|
+
excluded,
|
|
147
|
+
candidates,
|
|
148
|
+
bytes,
|
|
149
|
+
saving: save,
|
|
150
|
+
targets,
|
|
151
|
+
perRoot: rows,
|
|
152
|
+
provenance: meta,
|
|
153
|
+
});
|
|
154
|
+
return EXIT.OK;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const lines = [
|
|
158
|
+
`shared ${target.root} ${plain(roots.length)} repositories (${target.source})`,
|
|
159
|
+
`rules ${plain(shared.length)} of ${plain(candidates)} ids carried once ${plain(bytes)} B`,
|
|
160
|
+
];
|
|
161
|
+
for (const r of shared) lines.push(` ${r.id} ${r.priority ?? 'unclassified'} ${plain(Buffer.byteLength(r.rendered))} B ${r.source}`);
|
|
162
|
+
if (divergent.length > 0) {
|
|
163
|
+
// Named, not summarised: a reader who expected a python rule here and did not get it
|
|
164
|
+
// is owed the reason, and "divergent: 2" is not a reason.
|
|
165
|
+
lines.push(`left in every region ${divergent.length} rule(s) the roots do not carry identically:`);
|
|
166
|
+
for (const d of divergent) lines.push(` ${divergentNote(d, roots.length)}`);
|
|
167
|
+
}
|
|
168
|
+
if (excluded.length > 0) {
|
|
169
|
+
lines.push(`left in every region ${excluded.length} rule(s) no root can hand over:`);
|
|
170
|
+
for (const e of excluded) lines.push(` ${excludedNote(e)}`);
|
|
171
|
+
}
|
|
172
|
+
lines.push(`saves ${plain(save)} B across ${plain(roots.length)} regions`);
|
|
173
|
+
for (const r of rows) {
|
|
174
|
+
lines.push(` ${r.name} ${plain(r.bytes)} B ${r.mode} ${r.rules.length > 0 ? r.rules.join(', ') : '(carries nothing extra)'}`);
|
|
175
|
+
}
|
|
176
|
+
lines.push('writes');
|
|
177
|
+
for (const t of targets) {
|
|
178
|
+
lines.push(
|
|
179
|
+
t.path === null
|
|
180
|
+
? ` ${t.surface} printed for you to paste - ${t.why}`
|
|
181
|
+
: ` ${t.path} ${t.why}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
lines.push(`manifest ${MANIFEST_FILE} in each repository shared.level = user, shared.rules = the ids above`);
|
|
185
|
+
for (const t of targets) {
|
|
186
|
+
const over = capNote(t, bytes);
|
|
187
|
+
if (over !== null) lines.push(`warn ${over}`);
|
|
188
|
+
}
|
|
189
|
+
lines.push(`nothing written; re-run with: ${CLI} shared install --yes`);
|
|
190
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
191
|
+
return EXIT.OK;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function sharedInstallCommand(_args, flags) {
|
|
195
|
+
const gathered = await gather(flags);
|
|
196
|
+
if (gathered.error !== undefined) return fail(gathered.error, EXIT.CONFIG, gathered.remedy);
|
|
197
|
+
const { roots, shared, divergent, meta, targets } = gathered;
|
|
198
|
+
|
|
199
|
+
if (shared.length === 0) {
|
|
200
|
+
// A workspace where every root carries different rules has nothing to share, and
|
|
201
|
+
// writing an empty file into a reader's home directory is a thing that looks like an
|
|
202
|
+
// install and is not one.
|
|
203
|
+
return fail(
|
|
204
|
+
`no rule is carried with identical bytes by all ${roots.length} repositories`,
|
|
205
|
+
EXIT.UNSUPPORTED,
|
|
206
|
+
divergent.length > 0
|
|
207
|
+
? `${divergent.map((d) => d.id).join(', ')} differ between repositories; each region keeps its own copy`
|
|
208
|
+
: `every repository in ${roots.length === 1 ? 'this repository' : 'this workspace'} carries a different rule set`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The declaration goes into every root that already has a manifest. A root without
|
|
213
|
+
// one is skipped: adopting it is `init`'s job, and this command does not adopt a
|
|
214
|
+
// repository in order to share its rules. The list is never empty — a workspace with
|
|
215
|
+
// nothing to share was refused above — so the reader of the manifest sees a level and
|
|
216
|
+
// a set of ids, which is what `doctor` checks the reader's copy against.
|
|
217
|
+
const declaration = { level: 'user', rules: shared.map((r) => r.id) };
|
|
218
|
+
const manifests = [];
|
|
219
|
+
for (const root of roots) {
|
|
220
|
+
const { manifest, exists } = await readManifest(root);
|
|
221
|
+
if (!exists || manifest === null) {
|
|
222
|
+
manifests.push({ root, action: 'skipped', detail: `no ${MANIFEST_FILE}; run ${CLI} init in it first` });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (JSON.stringify(manifest.shared ?? null) === JSON.stringify(declaration)) {
|
|
226
|
+
manifests.push({ root, action: 'unchanged' });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// `buildManifest` rather than a spread, so `shared` lands where `budget` and `mode`
|
|
230
|
+
// land and a reviewer sees it in the declared block. Its own generated fields are
|
|
231
|
+
// handed back the repository's existing pin rather than this build's: `shared install`
|
|
232
|
+
// does not re-project anything, and a manifest whose pin silently became `null` would
|
|
233
|
+
// be a repository that now claims no release produced its rules.
|
|
234
|
+
const built = buildManifest({
|
|
235
|
+
root,
|
|
236
|
+
stack: manifest.stack ?? [],
|
|
237
|
+
sha: manifest.projection?.simtDevSha ?? null,
|
|
238
|
+
version: manifest.projection?.version ?? null,
|
|
239
|
+
existing: { ...manifest, shared: declaration },
|
|
240
|
+
});
|
|
241
|
+
manifests.push({ root, action: 'declared', manifest: built });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (flags.yes !== true) {
|
|
245
|
+
// Nothing above this line writes, and nothing below it runs without the reader's
|
|
246
|
+
// consent — because one of the two writes lands in their home directory and the other
|
|
247
|
+
// edits repositories other people may be reviewing. The states printed here come from
|
|
248
|
+
// the same `sharedState` `doctor` reads, so a preview cannot say "current" and then
|
|
249
|
+
// have `install` write anyway.
|
|
250
|
+
const states = [];
|
|
251
|
+
for (const t of targets) {
|
|
252
|
+
if (t.path === null) {
|
|
253
|
+
states.push({ surface: t.surface, path: null, state: 'paste' });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
states.push({ ...t, ...(await sharedState(t.path, shared, meta)) });
|
|
257
|
+
}
|
|
258
|
+
if (isJson(flags)) {
|
|
259
|
+
emitJson({
|
|
260
|
+
ok: false,
|
|
261
|
+
command: 'shared install',
|
|
262
|
+
mode: 'preview',
|
|
263
|
+
roots,
|
|
264
|
+
shared,
|
|
265
|
+
divergent,
|
|
266
|
+
targets: states,
|
|
267
|
+
manifests: manifests.map(({ root: r, action, detail }) => ({ root: r, action, detail: detail ?? null })),
|
|
268
|
+
});
|
|
269
|
+
return EXIT.FAIL;
|
|
270
|
+
}
|
|
271
|
+
emit(
|
|
272
|
+
[
|
|
273
|
+
`shared ${plain(shared.length)} rule(s), ${plain(Buffer.byteLength(sharedBody(shared)))} B`,
|
|
274
|
+
...states.map((s) =>
|
|
275
|
+
s.path === null
|
|
276
|
+
? ` paste ${s.surface} no user-level file is recorded for it`
|
|
277
|
+
: ` ${s.state.padEnd(9)}${s.path}`,
|
|
278
|
+
),
|
|
279
|
+
`manifest ${manifests.map((m) => `${m.action}:${basename(m.root)}`).join(' ')}`,
|
|
280
|
+
`nothing written; re-run with: ${CLI} shared install --yes`,
|
|
281
|
+
].join('\n'),
|
|
282
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
283
|
+
);
|
|
284
|
+
return EXIT.FAIL;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const written = [];
|
|
288
|
+
for (const t of targets) {
|
|
289
|
+
if (t.path === null) continue;
|
|
290
|
+
written.push(await writeSharedFile(t.path, shared, meta));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const declared = [];
|
|
294
|
+
for (const row of manifests) {
|
|
295
|
+
if (row.action !== 'declared') continue;
|
|
296
|
+
await writeManifest(row.root, row.manifest);
|
|
297
|
+
declared.push(row.root);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const lines = [
|
|
301
|
+
`shared installed for ${plain(roots.length)} repositories`,
|
|
302
|
+
`rules ${plain(shared.length)} carried once ${shared.map((r) => r.id).join(', ')}`,
|
|
303
|
+
];
|
|
304
|
+
for (const w of written) {
|
|
305
|
+
// `inject`'s word for a file with no region in it is "added"; the word for a file
|
|
306
|
+
// that did not exist is "created", and printing "added" for a file this command
|
|
307
|
+
// brought into existence would be true and unreadable.
|
|
308
|
+
lines.push(` ${w.created ? 'created' : w.action} ${w.path}`);
|
|
309
|
+
}
|
|
310
|
+
for (const d of declared) lines.push(` declared ${d}/${MANIFEST_FILE}`);
|
|
311
|
+
// Now the delta, so the reader knows what each region stopped carrying — and that the
|
|
312
|
+
// next `simt sync` in each repository is what actually applies it.
|
|
313
|
+
if (declared.length > 0) {
|
|
314
|
+
lines.push(`regions ${declared.length} repository region(s) change on their next ${CLI} sync`);
|
|
315
|
+
}
|
|
316
|
+
for (const t of targets) {
|
|
317
|
+
if (t.path !== null) continue;
|
|
318
|
+
lines.push(`paste into ${t.surface} (${t.why}) - the block below, markers included:`);
|
|
319
|
+
lines.push('');
|
|
320
|
+
lines.push(sharedBody(shared));
|
|
321
|
+
lines.push('');
|
|
322
|
+
}
|
|
323
|
+
for (const t of targets) {
|
|
324
|
+
const over = capNote(t, Buffer.byteLength(sharedBody(shared)));
|
|
325
|
+
if (over !== null) lines.push(`warn ${over}`);
|
|
326
|
+
}
|
|
327
|
+
lines.push(`verify ${CLI} doctor reports the copy as current, and ${CLI} tokens --repos --root . reports what is left`);
|
|
328
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
329
|
+
return EXIT.OK;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* What `doctor` says about the reader's copy of this repository's shared rules (ADR-084).
|
|
334
|
+
*
|
|
335
|
+
* Silent for a repository that declared nothing — the overwhelmingly common case — because
|
|
336
|
+
* a `doctor` line every repository carries is a line every reader learns to skip. Reported
|
|
337
|
+
* when there is a declaration, and never blocking: the region is correct either way, and
|
|
338
|
+
* what is wrong is the reader's machine.
|
|
339
|
+
*
|
|
340
|
+
* @returns {Promise<{declared: boolean, state: string, rules: string[], targets: object[],
|
|
341
|
+
* detail: string|null}>}
|
|
342
|
+
*/
|
|
343
|
+
export async function sharedWarning(root, manifest, flags) {
|
|
344
|
+
const declared = declaredShared(manifest);
|
|
345
|
+
if (declared.level === null) {
|
|
346
|
+
return { declared: false, state: 'none', rules: [], targets: [], detail: null };
|
|
347
|
+
}
|
|
348
|
+
const desired = await desiredRegion(root, flags ?? {});
|
|
349
|
+
// What this repository expects to find in the copy: the declared ids it still carries.
|
|
350
|
+
// An id the repository no longer selects is simply not checked, because there is nothing
|
|
351
|
+
// this repository could do about it — a sibling declares it and `shared plan` reports it.
|
|
352
|
+
const rules = desired.shareable.filter((r) => declared.rules.has(r.id));
|
|
353
|
+
const targets = [];
|
|
354
|
+
for (const t of await sharedTargets(desired.surfaces)) {
|
|
355
|
+
if (t.path === null) continue;
|
|
356
|
+
targets.push({ surface: t.surface, path: t.path, ...(await sharedCoverage(t.path, rules)) });
|
|
357
|
+
}
|
|
358
|
+
const worst =
|
|
359
|
+
targets.find((t) => t.state === 'absent') ??
|
|
360
|
+
targets.find((t) => t.state === 'stale') ??
|
|
361
|
+
targets.find((t) => t.state === 'unreadable') ??
|
|
362
|
+
targets[0];
|
|
363
|
+
const state = targets.length === 0 ? 'no-target' : (worst?.state ?? 'none');
|
|
364
|
+
return {
|
|
365
|
+
declared: true,
|
|
366
|
+
state,
|
|
367
|
+
rules: [...declared.rules],
|
|
368
|
+
targets,
|
|
369
|
+
detail: describeShared(state, targets),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function describeShared(state, targets) {
|
|
374
|
+
if (state === 'no-target') {
|
|
375
|
+
return 'this repository declares shared rules, but no surface it selects has a user-level file this tool can write; paste the block from `simt shared plan` into each reader';
|
|
376
|
+
}
|
|
377
|
+
if (state === 'absent') {
|
|
378
|
+
return `no copy of the workspace rules at ${targets.find((t) => t.state === 'absent').path}; run: ${CLI} shared install --yes`;
|
|
379
|
+
}
|
|
380
|
+
if (state === 'stale') {
|
|
381
|
+
const stale = targets.find((t) => t.state === 'stale');
|
|
382
|
+
return `${stale.path} is missing ${stale.missing.join(', ')}; run: ${CLI} shared install --yes`;
|
|
383
|
+
}
|
|
384
|
+
if (state === 'unreadable') {
|
|
385
|
+
return `${targets.find((t) => t.state === 'unreadable').path} could not be read: ${targets.find((t) => t.state === 'unreadable').reason}`;
|
|
386
|
+
}
|
|
387
|
+
return `${targets.length} user-level file(s) carry every rule this repository handed over`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** `1234567`, never `1,234,567`: two machines must print the same bytes (ADR-013). */
|
|
391
|
+
function plain(n) {
|
|
392
|
+
return String(n);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The directory name, for a line that has to name several repositories at once. */
|
|
396
|
+
function basename(path) {
|
|
397
|
+
return path.split('/').filter(Boolean).pop() ?? path;
|
|
398
|
+
}
|
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
|
+
}
|