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.
Files changed (56) hide show
  1. package/CHANGELOG.md +173 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/README.md +17 -0
  10. package/package.json +1 -2
  11. package/rules/stacks/dart.md +38 -0
  12. package/rules/stacks/deno.md +36 -0
  13. package/rules/stacks/dotnet.md +35 -0
  14. package/rules/stacks/elixir.md +39 -0
  15. package/rules/stacks/go.md +34 -0
  16. package/rules/stacks/ruby.md +36 -0
  17. package/rules/stacks/rust.md +34 -0
  18. package/rules/stacks/swift.md +37 -0
  19. package/src/argv.mjs +35 -6
  20. package/src/ci/dependabot.mjs +1 -1
  21. package/src/ci/workflow.mjs +33 -11
  22. package/src/commands/graph.mjs +13 -4
  23. package/src/commands/help.mjs +22 -4
  24. package/src/commands/hooks.mjs +173 -57
  25. package/src/commands/index.mjs +53 -45
  26. package/src/commands/init.mjs +89 -13
  27. package/src/commands/rules.mjs +54 -3
  28. package/src/commands/skills.mjs +143 -15
  29. package/src/commands/skillsinstall.mjs +3 -3
  30. package/src/commands/sync.mjs +153 -11
  31. package/src/compare/hooksstate.mjs +96 -9
  32. package/src/compare/row.mjs +34 -5
  33. package/src/decisions/index.mjs +21 -6
  34. package/src/graph/load.mjs +125 -14
  35. package/src/hooks/capability.mjs +48 -4
  36. package/src/hooks/dispatcher.mjs +50 -5
  37. package/src/hooks/gates.mjs +0 -2
  38. package/src/hooks/resolve.mjs +24 -25
  39. package/src/hooks/shim.mjs +29 -5
  40. package/src/identity.mjs +0 -11
  41. package/src/mcp/tools.mjs +2 -2
  42. package/src/output/index.mjs +8 -7
  43. package/src/package-version.mjs +74 -0
  44. package/src/projection/conflicts.mjs +56 -5
  45. package/src/projection/manifest.mjs +247 -25
  46. package/src/projection/mdc.mjs +4 -13
  47. package/src/projection/project.mjs +1 -1
  48. package/src/projection/rules.mjs +21 -8
  49. package/src/projection/targets.mjs +53 -7
  50. package/src/resolve/eval.mjs +64 -0
  51. package/src/resolve/fingerprint.mjs +1 -0
  52. package/src/resolve/near.mjs +26 -7
  53. package/src/resolve/resolve.mjs +1 -1
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/workspace.mjs +0 -9
  56. package/graph/skills.graph.json +0 -1622
@@ -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 a named escape hatch, before the first write
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
- const { dropped } = planGates(detected.stack, capability);
74
- const manifest = buildManifest({ root, stack: detected.stack, sha, existing: current, capability });
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
- const git = await establishEscapeHatch(root);
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
- * A named escape hatch before the first write, per plan 0001. Both operations are
427
- * best-effort: a repo that is not a git repo, or has no commits yet, must still be
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`
@@ -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
- /** `simt-applies` is the string "always" for core and an array for profiles. */
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
@@ -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 colour when piped, bounded by --max-bytes with an explicit
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, REGISTRY_PATH } from '../graph/load.mjs';
17
+ import { loadRegistry } from '../graph/load.mjs';
18
18
  import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
19
- import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
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(REGISTRY_PATH, flags);
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(REGISTRY_PATH, flags);
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(REGISTRY_PATH, flags);
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 = parsePathList(flags.skill);
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(REGISTRY_PATH, flags);
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(REGISTRY_PATH, flags);
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
- export async function sourcePath(skill, workspaceRoot, root) {
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, REGISTRY_PATH, measureSkill } from '../graph/load.mjs';
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(REGISTRY_PATH, flags);
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) {