simt-dev 0.2.0 → 0.2.1
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 +173 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +89 -13
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +3 -3
- package/src/commands/sync.mjs +153 -11
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +34 -5
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +50 -5
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +247 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/commands/init.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 2. scan for conflicts does it contradict a ruling
|
|
12
12
|
// 3. block on conflicts unless --accept-conflicts
|
|
13
13
|
// 4. plan everything manifest + region, dry
|
|
14
|
-
// 5. branch and tag
|
|
14
|
+
// 5. branch and tag an opt-in escape hatch (--backup), before the first write
|
|
15
15
|
// 6. write only with --yes
|
|
16
16
|
// Never commits, never pushes (ADR-001).
|
|
17
17
|
|
|
@@ -44,6 +44,7 @@ import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../pr
|
|
|
44
44
|
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
45
45
|
import { CLI } from '../name.mjs';
|
|
46
46
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
47
|
+
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
47
48
|
|
|
48
49
|
const run = promisify(execFile);
|
|
49
50
|
|
|
@@ -59,6 +60,11 @@ export async function initCommand(positional, flags) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const sha = await provenanceDigest();
|
|
63
|
+
// The release this adoption records, and the kind of repository it is adopting: the
|
|
64
|
+
// version goes into the manifest pin and the rendered gate, and the kind decides how
|
|
65
|
+
// a repository without node_modules obtains the binary in CI.
|
|
66
|
+
const version = packageVersion();
|
|
67
|
+
const npm = await hasNpm(root);
|
|
62
68
|
const existing = await readManifest(root);
|
|
63
69
|
const { manifest: current, exists, error } = existing;
|
|
64
70
|
if (error) {
|
|
@@ -70,8 +76,12 @@ export async function initCommand(positional, flags) {
|
|
|
70
76
|
// a repository declared five and could run two, which would have blocked every
|
|
71
77
|
// commit once hooks were installed.
|
|
72
78
|
const capability = await readCapability(root);
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// `facts` carries the ecosystem rows (has-cargo-toml, ...) — the same signal set that
|
|
80
|
+
// selects this repo's profiles, so one marker file reaches both the region the reader
|
|
81
|
+
// gets and the gates its commits run. Without it the ecosystem rows never fire here
|
|
82
|
+
// and a fresh Rust repo is adopted with gates it cannot run.
|
|
83
|
+
const { dropped, commands } = planGates(detected.stack, capability, detected.facts);
|
|
84
|
+
const manifest = buildManifest({ root, stack: detected.stack, sha, version, existing: current, capability, facts: detected.facts });
|
|
75
85
|
const findings = await scanRepo(root);
|
|
76
86
|
const summary = summarise(findings);
|
|
77
87
|
const projected = await plan(root, flags);
|
|
@@ -141,6 +151,12 @@ export async function initCommand(positional, flags) {
|
|
|
141
151
|
// adoption: an existing manifest keeps its gates on purpose, so its drops would
|
|
142
152
|
// describe proposals that were never made.
|
|
143
153
|
droppedGates: exists ? [] : dropped,
|
|
154
|
+
// Commands a FRESH manifest declares under gates.commands. The plan must show them:
|
|
155
|
+
// a command that lands in the manifest without appearing here is a silent write,
|
|
156
|
+
// and the reader would approve it by reading the diff afterwards — the review step
|
|
157
|
+
// this tool exists to make unnecessary. On an existing manifest nothing is seeded
|
|
158
|
+
// (the repo's declaration owns that field), so the empty list is deliberate.
|
|
159
|
+
seededCommands: exists ? [] : Object.entries(commands).map(([gate, command]) => ({ gate, command })),
|
|
144
160
|
surfaces: projected.surfaces,
|
|
145
161
|
surfaceSource: projected.surfaceSource,
|
|
146
162
|
pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
|
|
@@ -193,6 +209,24 @@ export async function initCommand(positional, flags) {
|
|
|
193
209
|
git: null,
|
|
194
210
|
};
|
|
195
211
|
|
|
212
|
+
// A surface this build does not know cannot be written to. Checked before the rule
|
|
213
|
+
// branch, and named for what it is: `init --surfaces` accepts a list, a reader
|
|
214
|
+
// mistypes one, and the misspelling used to be reported as an unprojectable rule with
|
|
215
|
+
// a remedy about substitution keys — the one problem on this path that had no
|
|
216
|
+
// substitution keys anywhere near it.
|
|
217
|
+
const surfaceProblems = projected.surfaceProblems ?? [];
|
|
218
|
+
if (surfaceProblems.length > 0) {
|
|
219
|
+
payload.ok = false;
|
|
220
|
+
payload.blockedBy = 'surface-problem';
|
|
221
|
+
for (const p of surfaceProblems) note(`PROBLEM ${p}`);
|
|
222
|
+
if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
223
|
+
return fail(
|
|
224
|
+
`${surfaceProblems.length} surface(s) cannot be resolved (${surfaceProblems[0]})`,
|
|
225
|
+
EXIT.FAIL,
|
|
226
|
+
`pass a known surface id to --surfaces, or omit it to take the default`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
196
230
|
// A `.mdc` rule that cannot render is a defect in this build. Adopting a repo
|
|
197
231
|
// without it would leave the family half-retired, and the user would have no way
|
|
198
232
|
// to tell which half from the diff.
|
|
@@ -217,7 +251,7 @@ export async function initCommand(positional, flags) {
|
|
|
217
251
|
if (isJson(flags)) {
|
|
218
252
|
emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
219
253
|
} else {
|
|
220
|
-
emit(render(payload, manifest));
|
|
254
|
+
emit(render(payload, manifest, { npm, version }));
|
|
221
255
|
note('');
|
|
222
256
|
note(
|
|
223
257
|
`blocked ${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}; nothing written`,
|
|
@@ -238,7 +272,7 @@ export async function initCommand(positional, flags) {
|
|
|
238
272
|
payload.ok = false;
|
|
239
273
|
payload.blockedBy = 'malformed-region';
|
|
240
274
|
if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
241
|
-
else emit(render(payload, manifest));
|
|
275
|
+
else emit(render(payload, manifest, { npm, version }));
|
|
242
276
|
return fail(
|
|
243
277
|
`${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
|
|
244
278
|
EXIT.FAIL,
|
|
@@ -250,7 +284,7 @@ export async function initCommand(positional, flags) {
|
|
|
250
284
|
if (isJson(flags)) {
|
|
251
285
|
if (!apply) emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
252
286
|
} else {
|
|
253
|
-
emit(render(payload, manifest));
|
|
287
|
+
emit(render(payload, manifest, { npm, version }));
|
|
254
288
|
}
|
|
255
289
|
if (!apply) {
|
|
256
290
|
note('');
|
|
@@ -262,9 +296,40 @@ export async function initCommand(positional, flags) {
|
|
|
262
296
|
return EXIT.OK;
|
|
263
297
|
}
|
|
264
298
|
|
|
265
|
-
|
|
299
|
+
// The escape hatch is opt-in (ADR-063): plan 0002 §8.2 item 3 said "always", and the
|
|
300
|
+
// tool complied, but consumers kept reporting the branch switch as a surprise. The
|
|
301
|
+
// default now writes in place on the branch the reader already has — `git diff` still
|
|
302
|
+
// shows the full adoption, and nothing is tagged or branched. `--backup` restores the
|
|
303
|
+
// tag and the branch for readers who want the named undo path.
|
|
304
|
+
const git = flags.backup
|
|
305
|
+
? await establishEscapeHatch(root)
|
|
306
|
+
: { branch: null, tag: null, from: null, notes: [] };
|
|
266
307
|
payload.git = git;
|
|
267
308
|
for (const message of git.notes) note(`note ${message}`);
|
|
309
|
+
if (!flags.backup) {
|
|
310
|
+
note(
|
|
311
|
+
'in place no backup tag or adopt branch created; the adoption is written on your current branch - git diff shows it and git checkout -- . discards it; re-run with --backup for the tag and the branch',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
// The operations that succeeded, not just the ones that failed.
|
|
315
|
+
//
|
|
316
|
+
// With --backup, `init` moves the reader onto a different branch and writes a tag
|
|
317
|
+
// into their repository. Both were once reported only under `--json`, so the run —
|
|
318
|
+
// the one almost everybody reads — did not say that the branch under them had
|
|
319
|
+
// changed. A reader who ran the documented escape hatch and then committed would be
|
|
320
|
+
// committing to a branch they had not chosen, and nothing in the output told them
|
|
321
|
+
// that had happened.
|
|
322
|
+
//
|
|
323
|
+
// The plan's heading is "Nothing is silent", so whatever the run does to git state —
|
|
324
|
+
// or deliberately does not — the output names it and the command that undoes it.
|
|
325
|
+
// `notes` carried the failures because a failure is the kind of thing an operator
|
|
326
|
+
// notices; a success is not.
|
|
327
|
+
if (git.tag !== null) {
|
|
328
|
+
note(`tagged ${git.tag} at the pre-adoption commit; \`git reset --hard ${git.tag}\` returns this repository to what it was`);
|
|
329
|
+
}
|
|
330
|
+
if (git.branch !== null && git.from !== null) {
|
|
331
|
+
note(`branch switched to ${git.branch}; ${git.from} is untouched and \`git checkout ${git.from}\` returns to it`);
|
|
332
|
+
}
|
|
268
333
|
|
|
269
334
|
await writeManifest(root, manifest);
|
|
270
335
|
note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
|
|
@@ -304,7 +369,7 @@ export async function initCommand(positional, flags) {
|
|
|
304
369
|
// guaranteed to be misled.
|
|
305
370
|
if (payload.ci.action === 'create') {
|
|
306
371
|
await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
|
|
307
|
-
await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own }));
|
|
372
|
+
await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own, npm, version }));
|
|
308
373
|
note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
|
|
309
374
|
}
|
|
310
375
|
if (payload.dependabot.action === 'create') {
|
|
@@ -423,13 +488,14 @@ async function scanRepo(root) {
|
|
|
423
488
|
}
|
|
424
489
|
|
|
425
490
|
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
491
|
+
* The opt-in escape hatch before the first write, taken when --backup is passed
|
|
492
|
+
* (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
|
|
493
|
+
* are best-effort: a repo that is not a git repo, or has no commits yet, must still be
|
|
428
494
|
* adoptable. Failure is reported as a note rather than blocking, because refusing
|
|
429
495
|
* to initialise an unborn repo would be worse than proceeding without a backup.
|
|
430
496
|
*/
|
|
431
497
|
async function establishEscapeHatch(root) {
|
|
432
|
-
const result = { branch: null, tag: null, notes: [] };
|
|
498
|
+
const result = { branch: null, tag: null, from: null, notes: [] };
|
|
433
499
|
const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
434
500
|
if (!isRepo.ok) {
|
|
435
501
|
result.notes.push('not a git work tree; no backup branch or tag created');
|
|
@@ -457,6 +523,10 @@ async function establishEscapeHatch(root) {
|
|
|
457
523
|
|
|
458
524
|
const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
459
525
|
if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
|
|
526
|
+
// Recorded rather than re-read by the caller: only this function knows what the
|
|
527
|
+
// branch was before it moved it, and a second `rev-parse` would answer with the new
|
|
528
|
+
// value and make every "you were on X" line read as "you were on Y".
|
|
529
|
+
result.from = current.stdout.trim();
|
|
460
530
|
const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
|
|
461
531
|
if (branch.ok) result.branch = ADOPT_BRANCH;
|
|
462
532
|
else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
|
|
@@ -477,7 +547,7 @@ async function git(cwd, args) {
|
|
|
477
547
|
}
|
|
478
548
|
|
|
479
549
|
/** Every line is a decision with a reason. Nothing is silent. */
|
|
480
|
-
function render(payload, manifest) {
|
|
550
|
+
function render(payload, manifest, { npm = true, version } = {}) {
|
|
481
551
|
const lines = [
|
|
482
552
|
`repo ${payload.root}`,
|
|
483
553
|
`stack ${payload.stack.length > 0 ? payload.stack.join(', ') : '(undetected)'}`,
|
|
@@ -518,6 +588,12 @@ function render(payload, manifest) {
|
|
|
518
588
|
lines.push(` ${'dropped'.padEnd(9)} gate ${d.gate}: ${d.why}`);
|
|
519
589
|
lines.push(` remedy: ${d.remedy}`);
|
|
520
590
|
}
|
|
591
|
+
// A seed is a write, and the same rule as for dropped gates applies: the reader must
|
|
592
|
+
// see the line or they approve it from the diff afterwards. Naming the manifest field
|
|
593
|
+
// keeps the line actionable — this is where the reader edits it if the default is wrong.
|
|
594
|
+
for (const s of payload.seededCommands ?? []) {
|
|
595
|
+
lines.push(` ${'seeded'.padEnd(9)} gates.commands["${s.gate}"] = ${s.command}`);
|
|
596
|
+
}
|
|
521
597
|
lines.push(
|
|
522
598
|
` ${'profiles'.padEnd(9)} ${LOCAL_PROFILE_DIR}/*.md ` +
|
|
523
599
|
`repo-local profiles are selected by the same matcher (ADR-013)`,
|
|
@@ -560,7 +636,7 @@ function render(payload, manifest) {
|
|
|
560
636
|
// a reader can tell "this repo does not use GitHub Actions" from "this repo was
|
|
561
637
|
// not offered the gate".
|
|
562
638
|
if (payload.ci.action === 'create') {
|
|
563
|
-
lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own }).length} bytes`);
|
|
639
|
+
lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own, npm, version }).length} bytes`);
|
|
564
640
|
lines.push(
|
|
565
641
|
payload.ci.own
|
|
566
642
|
? ` this repository is simt-dev's own; ${CLI} ci --yes refreshes this copy when the gate changes`
|
package/src/commands/rules.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
18
18
|
import { loadRules, select } from '../projection/project.mjs';
|
|
19
|
+
import { loadMdcRules } from '../projection/mdc.mjs';
|
|
19
20
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
20
21
|
import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
|
|
21
22
|
import { nearMatches } from '../resolve/near.mjs';
|
|
@@ -23,9 +24,18 @@ import { CLI } from '../name.mjs';
|
|
|
23
24
|
|
|
24
25
|
const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `simt-applies` is the string "always" for core and an array for profiles.
|
|
29
|
+
*
|
|
30
|
+
* An opt-in rule has an EMPTY array, and `-` is a true reading of that which answers
|
|
31
|
+
* none of the questions a reader has. `simt-opt-in` is why it is empty — detection cannot
|
|
32
|
+
* distinguish a repository with the problem from one without it, so the repository
|
|
33
|
+
* declares it — and that is the fact worth printing. It only became visible when the
|
|
34
|
+
* per-surface rules entered this catalogue; before that the rule was not listed at all.
|
|
35
|
+
*/
|
|
27
36
|
function appliesLabel(rule) {
|
|
28
37
|
if (rule.applies === 'always') return 'always';
|
|
38
|
+
if (rule.optIn === true) return 'opt-in (the repo declares it)';
|
|
29
39
|
if (Array.isArray(rule.applies)) return rule.applies.join(',') || '-';
|
|
30
40
|
return '-';
|
|
31
41
|
}
|
|
@@ -38,7 +48,37 @@ function sorted(rules) {
|
|
|
38
48
|
async function load(root) {
|
|
39
49
|
const rules = await loadRules(root ?? null);
|
|
40
50
|
const broken = rules.filter((r) => r.error);
|
|
41
|
-
return { rules, broken };
|
|
51
|
+
return { rules: [...rules, ...(await loadPerSurfaceRules())], broken };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `.mdc` rules, in the shape this file's three commands print.
|
|
56
|
+
*
|
|
57
|
+
* `loadRules` reads `rules/*.md` and `rules/stacks/*.md`, which is every rule that is a
|
|
58
|
+
* *stack profile* — and the four per-surface rules are not that. They are projected by
|
|
59
|
+
* `ruleBodies` inside `desiredRegion` rather than by `select`, so they were invisible to
|
|
60
|
+
* `rules list`, `rules show` and `rules why`: a reader could be told by an agent to run
|
|
61
|
+
* `simt rules why test-gate` and be met with "no rule with that id", for a rule that was
|
|
62
|
+
* in their own `AGENTS.md` at the time.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT added to `loadRules`. That function's result is `select`ed and
|
|
65
|
+
* `render`ed into the region, so putting these there would project every one of them
|
|
66
|
+
* twice — once as a profile section and once as `## Process: <id>` — which is the
|
|
67
|
+
* duplication ADR-046 exists to prevent. The catalogue is the right layer for this: it is
|
|
68
|
+
* a view, and a view may be wider than what one projection writes.
|
|
69
|
+
*/
|
|
70
|
+
async function loadPerSurfaceRules() {
|
|
71
|
+
const mdc = await loadMdcRules();
|
|
72
|
+
return mdc.map((r) => ({
|
|
73
|
+
...r,
|
|
74
|
+
// These have no `simt-title`; their `description` is what a reader sees in the
|
|
75
|
+
// generated file, so it is the title here rather than a column of blanks.
|
|
76
|
+
title: r.title ?? r.description ?? null,
|
|
77
|
+
// `mdc` rather than `central`: the column exists so a reader can tell where a rule
|
|
78
|
+
// came from, and "central" is true of all of them and useless for this distinction.
|
|
79
|
+
origin: 'per-surface',
|
|
80
|
+
fallback: false,
|
|
81
|
+
}));
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
// --- list ---------------------------------------------------------------------
|
|
@@ -182,7 +222,7 @@ export async function whyCommand(args, flags) {
|
|
|
182
222
|
if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
|
|
183
223
|
|
|
184
224
|
const { rules, broken } = await load(flags.root ?? null);
|
|
185
|
-
const { ok, decisions, errors } = await loadDecisions();
|
|
225
|
+
const { ok, decisions, errors } = await loadDecisions({ all: flags.all === true });
|
|
186
226
|
if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
|
|
187
227
|
|
|
188
228
|
return what.match(/^ADR-\d{3}$/i)
|
|
@@ -342,6 +382,17 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
342
382
|
if (effective.summary.ruled.length > 0) {
|
|
343
383
|
lines.push('', replacement ? 'ruled now' : 'ruled');
|
|
344
384
|
for (const r of effective.summary.ruled) lines.push(` - ${redactEstate(r)}`);
|
|
385
|
+
// The count, because the excerpt above is two rulings and a record may carry eight.
|
|
386
|
+
// Without it the header reads as a claim about everything the record settled, which
|
|
387
|
+
// is how a reader goes looking for a ruling that was never printed and concludes it
|
|
388
|
+
// does not exist.
|
|
389
|
+
const shown = effective.summary.ruled.length;
|
|
390
|
+
const total = effective.summary.rulingCount ?? shown;
|
|
391
|
+
if (total > shown) {
|
|
392
|
+
lines.push(
|
|
393
|
+
` (${shown} of ${total} rulings shown; the rest: ${CLI} rules why ${effective.id} --all)`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
345
396
|
}
|
|
346
397
|
if (effective.summary.rejected.length > 0) {
|
|
347
398
|
// The rejected alternatives are the part a reader cannot reconstruct. A ruling is
|
package/src/commands/skills.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Implementation sequence steps 4 and 5.
|
|
4
4
|
//
|
|
5
5
|
// Output design (plan 0002 section 6): one fact per line, fixed field order, no
|
|
6
|
-
// wrapping, no
|
|
6
|
+
// wrapping, no ANSI on any stream, bounded by --max-bytes with an explicit
|
|
7
7
|
// TRUNCATED line. The NOT SELECTED block is as important as the resolved block -
|
|
8
8
|
// the agent can see what exists and why it was excluded, and override in one
|
|
9
9
|
// token instead of guessing. Suppressing it is how a discovery tool becomes a
|
|
@@ -14,9 +14,10 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
|
|
16
16
|
import { EXIT, emit, emitJson, fail, isJson, note as emitNote } from '../output/index.mjs';
|
|
17
|
-
import { loadRegistry
|
|
17
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
18
18
|
import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { unknownSkills, scoreCorpus } from '../resolve/eval.mjs';
|
|
20
|
+
import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
20
21
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
21
22
|
import { tokenize, rankSkills, closure, selectWithinBudget, resolveAlias, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
|
|
22
23
|
import { nearMatches } from '../resolve/near.mjs';
|
|
@@ -48,6 +49,22 @@ export function parsePathList(value) {
|
|
|
48
49
|
.filter(Boolean);
|
|
49
50
|
}
|
|
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
|
+
export function listFlag(value) {
|
|
63
|
+
return (Array.isArray(value) ? value : [value])
|
|
64
|
+
.flatMap((v) => parsePathList(v))
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
/** Compact stack label: the tags that actually discriminate, not all of them. */
|
|
52
69
|
function stackLabel(tags = []) {
|
|
53
70
|
const DISCRIMINATING = [
|
|
@@ -75,8 +92,8 @@ const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
|
75
92
|
// --- tier 0: index ------------------------------------------------------------
|
|
76
93
|
|
|
77
94
|
export async function indexCommand(args, flags) {
|
|
78
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
79
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
95
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
96
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
80
97
|
const workspace = await workspaceLine(flags);
|
|
81
98
|
|
|
82
99
|
if (isJson(flags)) {
|
|
@@ -189,8 +206,8 @@ export async function findCommand(args, flags) {
|
|
|
189
206
|
const query = args.join(' ').trim();
|
|
190
207
|
if (!query) return fail('find needs a query', EXIT.USAGE, `usage: ${CLI} skills find <query>`);
|
|
191
208
|
|
|
192
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
193
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
209
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
210
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
194
211
|
|
|
195
212
|
const tokens = tokenize(query);
|
|
196
213
|
const workspace = await workspaceLine(flags);
|
|
@@ -227,8 +244,8 @@ export async function findCommand(args, flags) {
|
|
|
227
244
|
// --- brief: tier 1 ------------------------------------------------------------
|
|
228
245
|
|
|
229
246
|
export async function briefCommand(args, flags) {
|
|
230
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
231
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
247
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
248
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
232
249
|
|
|
233
250
|
const root = flags.root ?? process.cwd();
|
|
234
251
|
const repo = await fingerprint(root);
|
|
@@ -237,7 +254,7 @@ export async function briefCommand(args, flags) {
|
|
|
237
254
|
const repoStack = repo.signals;
|
|
238
255
|
const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
|
|
239
256
|
const taskTokens = tokenize(flags.task ?? '');
|
|
240
|
-
const forced =
|
|
257
|
+
const forced = listFlag(flags.skill);
|
|
241
258
|
const budget = flags.budget ?? DEFAULT_BUDGET;
|
|
242
259
|
|
|
243
260
|
// A brief with no task and no paths resolves on stack alone, which produces a
|
|
@@ -432,8 +449,8 @@ export async function showCommand(args, flags) {
|
|
|
432
449
|
const name = args[0];
|
|
433
450
|
if (!name) return fail('show needs a skill name', EXIT.USAGE, `usage: ${CLI} skills show <name>`);
|
|
434
451
|
|
|
435
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
436
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
452
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
453
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
437
454
|
|
|
438
455
|
const skill = registry.skills.find((s) => s.name === name);
|
|
439
456
|
if (!skill) {
|
|
@@ -490,8 +507,8 @@ export async function whyCommand(args, flags) {
|
|
|
490
507
|
const name = args[0];
|
|
491
508
|
if (!name) return fail('why needs a skill name', EXIT.USAGE, `usage: ${CLI} skills why <name>`);
|
|
492
509
|
|
|
493
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
494
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
510
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
511
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
495
512
|
|
|
496
513
|
const skill = registry.skills.find((s) => s.name === name);
|
|
497
514
|
if (!skill) {
|
|
@@ -565,6 +582,117 @@ export async function whyCommand(args, flags) {
|
|
|
565
582
|
return EXIT.OK;
|
|
566
583
|
}
|
|
567
584
|
|
|
585
|
+
// --- eval ---------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The gold corpus when the reader names none. A checkout holds one at this fixed path;
|
|
589
|
+
* `test/` is not in the published files, so an installed copy does not. That absence is
|
|
590
|
+
* a fact about where this copy runs, reported as such (ADR-061's tiered resolution is
|
|
591
|
+
* the same shape: explicit path, then the package's own file, then an honest absence).
|
|
592
|
+
*/
|
|
593
|
+
const DEFAULT_CORPUS = join(PACKAGE_ROOT, 'test', 'fixtures', 'eval-corpus.json');
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `simt skills eval [corpus]` - measure the resolver against the gold corpus.
|
|
597
|
+
*
|
|
598
|
+
* Plan 0002 section 9: "Reports precision and recall and fails on regression." The
|
|
599
|
+
* corpus states, per case, which skills must come back (`select`) and which must not
|
|
600
|
+
* (`reject`); a case that names no selection is exhaustive, and anything it returns is
|
|
601
|
+
* a miss. The exit code rests on `clean`, because the two ratios can both read 1.0
|
|
602
|
+
* while an exhaustive case returns a skill neither list named (see src/resolve/eval.mjs).
|
|
603
|
+
*/
|
|
604
|
+
export async function evalCommand(positional, flags) {
|
|
605
|
+
const corpusPath = positional[0] ?? (existsSync(DEFAULT_CORPUS) ? DEFAULT_CORPUS : null);
|
|
606
|
+
|
|
607
|
+
if (corpusPath === null) {
|
|
608
|
+
return fail(
|
|
609
|
+
`no eval corpus: ${DEFAULT_CORPUS} is not on this machine (test data does not ship in the package)`,
|
|
610
|
+
EXIT.FAIL,
|
|
611
|
+
`run: ${CLI} skills eval <path-to-corpus> (a file of cases with select and reject lists)`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let corpus;
|
|
616
|
+
try {
|
|
617
|
+
corpus = JSON.parse(await readFile(corpusPath, 'utf8'));
|
|
618
|
+
} catch (error) {
|
|
619
|
+
return fail(
|
|
620
|
+
`${corpusPath}: unreadable or invalid JSON (${error.message})`,
|
|
621
|
+
EXIT.FAIL,
|
|
622
|
+
`run: ${CLI} skills eval <path-to-corpus>`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (!Array.isArray(corpus.cases) || corpus.cases.length === 0) {
|
|
626
|
+
return fail(
|
|
627
|
+
`${corpusPath}: no cases (a corpus is {"cases": [{"name", "repo", "task", "select", "reject"}]})`,
|
|
628
|
+
EXIT.FAIL,
|
|
629
|
+
`run: ${CLI} skills eval <path-to-corpus>`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// The corpus is measured against the registry it cites, so it needs the same
|
|
634
|
+
// resolution and the same failure as every other skills command.
|
|
635
|
+
const { registry, errors, ok, path } = await loadRegistry(null, flags);
|
|
636
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
637
|
+
|
|
638
|
+
const unknown = unknownSkills(registry, corpus);
|
|
639
|
+
const score = scoreCorpus(registry, corpus);
|
|
640
|
+
const okRun = unknown.length === 0 && score.clean;
|
|
641
|
+
|
|
642
|
+
if (isJson(flags)) {
|
|
643
|
+
emit(
|
|
644
|
+
`${JSON.stringify(
|
|
645
|
+
{
|
|
646
|
+
ok: okRun,
|
|
647
|
+
command: 'skills eval',
|
|
648
|
+
corpus: corpusPath,
|
|
649
|
+
registry: path,
|
|
650
|
+
cases: score.cases.map((c) => ({
|
|
651
|
+
name: c.name,
|
|
652
|
+
selected: c.selected,
|
|
653
|
+
missing: c.missing,
|
|
654
|
+
unexpected: c.unexpected,
|
|
655
|
+
})),
|
|
656
|
+
unknownSkills: unknown,
|
|
657
|
+
precision: score.precision,
|
|
658
|
+
recall: score.recall,
|
|
659
|
+
},
|
|
660
|
+
null,
|
|
661
|
+
2,
|
|
662
|
+
)}\n`,
|
|
663
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
664
|
+
);
|
|
665
|
+
return okRun ? EXIT.OK : EXIT.FAIL;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const failed = score.cases.filter((c) => c.missing.length > 0 || c.unexpected.length > 0);
|
|
669
|
+
const lines = [
|
|
670
|
+
`corpus ${corpusPath} (${score.cases.length} cases)`,
|
|
671
|
+
`registry ${path}`,
|
|
672
|
+
`${score.cases.length - failed.length} pass, ${failed.length} fail`,
|
|
673
|
+
`precision ${score.precision.toFixed(3)} recall ${score.recall.toFixed(3)}`,
|
|
674
|
+
'',
|
|
675
|
+
];
|
|
676
|
+
|
|
677
|
+
for (const c of score.cases) {
|
|
678
|
+
if (c.missing.length === 0 && c.unexpected.length === 0) {
|
|
679
|
+
lines.push(`PASS ${c.name}`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
lines.push(`FAIL ${c.name}`);
|
|
683
|
+
for (const name of c.missing) lines.push(` missed ${name}`);
|
|
684
|
+
for (const name of c.unexpected) lines.push(` unexpected ${name}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (unknown.length > 0) {
|
|
688
|
+
lines.push('', `FAIL corpus cites skills the registry does not declare: ${unknown.join(', ')}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
lines.push('', okRun ? 'OK the resolver meets the gold corpus' : 'REGRESSION: the resolver no longer meets the gold corpus');
|
|
692
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
693
|
+
return okRun ? EXIT.OK : EXIT.FAIL;
|
|
694
|
+
}
|
|
695
|
+
|
|
568
696
|
// --- bodies -------------------------------------------------------------------
|
|
569
697
|
|
|
570
698
|
/**
|
|
@@ -581,7 +709,7 @@ export async function whyCommand(args, flags) {
|
|
|
581
709
|
* location for a declared source, matched on bytes rather than on hope (ADR-015).
|
|
582
710
|
* The canonical source is still tried first, so a workspace body always wins.
|
|
583
711
|
*/
|
|
584
|
-
|
|
712
|
+
async function sourcePath(skill, workspaceRoot, root) {
|
|
585
713
|
const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
586
714
|
|
|
587
715
|
if (canonical) {
|
|
@@ -25,7 +25,7 @@ import { existsSync } from 'node:fs';
|
|
|
25
25
|
import { dirname, join } from 'node:path';
|
|
26
26
|
|
|
27
27
|
import { EXIT, emit, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
28
|
-
import { loadRegistry,
|
|
28
|
+
import { loadRegistry, measureSkill } from '../graph/load.mjs';
|
|
29
29
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
30
30
|
import { readCapability } from '../hooks/capability.mjs';
|
|
31
31
|
import { MANIFEST, buildManifest, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
@@ -59,8 +59,8 @@ export async function installCommand(args, flags) {
|
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
63
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
62
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
63
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
64
64
|
|
|
65
65
|
const skill = registry.skills.find((s) => s.name === name);
|
|
66
66
|
if (!skill) {
|