simt-dev 0.1.1 → 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 (91) hide show
  1. package/CHANGELOG.md +209 -7
  2. package/README.md +32 -5
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  34. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  35. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  36. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  37. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  38. package/docs/decisions/README.md +20 -1
  39. package/package.json +1 -2
  40. package/rules/stacks/dart.md +38 -0
  41. package/rules/stacks/deno.md +36 -0
  42. package/rules/stacks/dotnet.md +35 -0
  43. package/rules/stacks/elixir.md +39 -0
  44. package/rules/stacks/go.md +34 -0
  45. package/rules/stacks/ruby.md +36 -0
  46. package/rules/stacks/rust.md +34 -0
  47. package/rules/stacks/swift.md +37 -0
  48. package/src/argv.mjs +35 -6
  49. package/src/ci/dependabot.mjs +1 -1
  50. package/src/ci/workflow.mjs +33 -11
  51. package/src/commands/graph.mjs +13 -4
  52. package/src/commands/help.mjs +22 -4
  53. package/src/commands/hooks.mjs +183 -67
  54. package/src/commands/index.mjs +53 -45
  55. package/src/commands/init.mjs +156 -14
  56. package/src/commands/rules.mjs +54 -3
  57. package/src/commands/skills.mjs +143 -15
  58. package/src/commands/skillsinstall.mjs +4 -4
  59. package/src/commands/sync.mjs +153 -11
  60. package/src/compare/discover.mjs +1 -1
  61. package/src/compare/hooksstate.mjs +96 -9
  62. package/src/compare/row.mjs +36 -7
  63. package/src/decisions/index.mjs +51 -12
  64. package/src/graph/load.mjs +126 -15
  65. package/src/hooks/capability.mjs +49 -5
  66. package/src/hooks/dispatcher.mjs +51 -6
  67. package/src/hooks/gates.mjs +1 -3
  68. package/src/hooks/resolve.mjs +24 -25
  69. package/src/hooks/shim.mjs +29 -5
  70. package/src/hooks/where.mjs +1 -1
  71. package/src/identity.mjs +207 -0
  72. package/src/mcp/tools.mjs +2 -2
  73. package/src/output/index.mjs +8 -7
  74. package/src/package-version.mjs +74 -0
  75. package/src/projection/baselines.mjs +1 -1
  76. package/src/projection/conflicts.mjs +57 -6
  77. package/src/projection/manifest.mjs +248 -26
  78. package/src/projection/markers.mjs +3 -3
  79. package/src/projection/mdc.mjs +6 -15
  80. package/src/projection/project.mjs +1 -1
  81. package/src/projection/rules.mjs +21 -8
  82. package/src/projection/surfaces.mjs +2 -2
  83. package/src/projection/targets.mjs +53 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +4 -3
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +2 -2
  89. package/src/tsconfig.mjs +1 -1
  90. package/src/workspace.mjs +0 -9
  91. 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
 
@@ -29,12 +29,22 @@ import { readHooksState } from '../compare/hooksstate.mjs';
29
29
  import { planWorkflow, renderWorkflow, WORKFLOWS_DIR } from '../ci/workflow.mjs';
30
30
  import { planDependabot, renderDependabot } from '../ci/dependabot.mjs';
31
31
  import { planTsconfigBase, renderTsconfigBase, TSCONFIG_BASE_FILE } from '../tsconfig.mjs';
32
+ import {
33
+ IDENTITY_FILE,
34
+ INCLUDE_KEY,
35
+ ignoreIdentity,
36
+ includeOwnIdentity,
37
+ includesOwnIdentity,
38
+ renderIdentityFile,
39
+ resolveIdentity,
40
+ } from '../identity.mjs';
32
41
  import { pointerState, pointerTargets } from '../projection/project.mjs';
33
42
  import { surfacesLine } from '../projection/surfaces.mjs';
34
43
  import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../projection/project.mjs';
35
44
  import { parse as parseRegion } from '../projection/markers.mjs';
36
45
  import { CLI } from '../name.mjs';
37
46
  import { MANIFEST_FILE } from '../name.mjs';
47
+ import { packageVersion, hasNpm } from '../package-version.mjs';
38
48
 
39
49
  const run = promisify(execFile);
40
50
 
@@ -50,6 +60,11 @@ export async function initCommand(positional, flags) {
50
60
  }
51
61
 
52
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);
53
68
  const existing = await readManifest(root);
54
69
  const { manifest: current, exists, error } = existing;
55
70
  if (error) {
@@ -58,11 +73,15 @@ export async function initCommand(positional, flags) {
58
73
 
59
74
  // What this repo can demonstrably run, not what its stack suggests. Without this the
60
75
  // stack table proposes gates the repo has no way to execute — the adoption of
61
- // simt-social-media declared five and could run two, which would have blocked every
76
+ // a repository declared five and could run two, which would have blocked every
62
77
  // commit once hooks were installed.
63
78
  const capability = await readCapability(root);
64
- const { dropped } = planGates(detected.stack, capability);
65
- 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 });
66
85
  const findings = await scanRepo(root);
67
86
  const summary = summarise(findings);
68
87
  const projected = await plan(root, flags);
@@ -98,6 +117,26 @@ export async function initCommand(positional, flags) {
98
117
  const ci = await planWorkflow(root);
99
118
  const dependabot = await planDependabot(root);
100
119
  const tsconfigBase = await planTsconfigBase(root);
120
+ // Per-developer, and planned with the rest so the operator sees it in the same
121
+ // preview that lists everything else. `exists` short-circuits the write: a
122
+ // repository that already has one keeps it exactly as it is.
123
+ const identity = await resolveIdentity(root);
124
+ const identityPlan = {
125
+ file: IDENTITY_FILE,
126
+ action: identity.exists ? 'unchanged' : identity.from === null ? 'skip' : 'created',
127
+ from: identity.from,
128
+ name: identity.name,
129
+ email: identity.email,
130
+ reason: identity.exists
131
+ ? `${IDENTITY_FILE} already exists and is left exactly as it is`
132
+ : identity.from === null
133
+ ? 'no identity is configured anywhere, and this tool does not invent one'
134
+ : null,
135
+ // Only worth fixing when this repository is the one being routed: an include
136
+ // pointing at another repository's identity file works here and breaks for the
137
+ // next person who clones without that sibling checkout.
138
+ include: await includesOwnIdentity(root),
139
+ };
101
140
 
102
141
  const payload = {
103
142
  ok: summary.conflicts === 0 || flags.acceptConflicts === true,
@@ -112,11 +151,18 @@ export async function initCommand(positional, flags) {
112
151
  // adoption: an existing manifest keeps its gates on purpose, so its drops would
113
152
  // describe proposals that were never made.
114
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 })),
115
160
  surfaces: projected.surfaces,
116
161
  surfaceSource: projected.surfaceSource,
117
162
  pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
118
163
  pointersBlocked: pointerBlocked,
119
164
  ci: { file: ci.file, action: ci.action, bytes: ci.bytes, own: ci.own, reason: ci.reason },
165
+ identity: identityPlan,
120
166
  dependabot: {
121
167
  file: dependabot.file,
122
168
  action: dependabot.action,
@@ -163,6 +209,24 @@ export async function initCommand(positional, flags) {
163
209
  git: null,
164
210
  };
165
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
+
166
230
  // A `.mdc` rule that cannot render is a defect in this build. Adopting a repo
167
231
  // without it would leave the family half-retired, and the user would have no way
168
232
  // to tell which half from the diff.
@@ -187,7 +251,7 @@ export async function initCommand(positional, flags) {
187
251
  if (isJson(flags)) {
188
252
  emit(`${JSON.stringify(payload, null, 2)}\n`);
189
253
  } else {
190
- emit(render(payload, manifest));
254
+ emit(render(payload, manifest, { npm, version }));
191
255
  note('');
192
256
  note(
193
257
  `blocked ${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}; nothing written`,
@@ -208,7 +272,7 @@ export async function initCommand(positional, flags) {
208
272
  payload.ok = false;
209
273
  payload.blockedBy = 'malformed-region';
210
274
  if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
211
- else emit(render(payload, manifest));
275
+ else emit(render(payload, manifest, { npm, version }));
212
276
  return fail(
213
277
  `${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
214
278
  EXIT.FAIL,
@@ -220,7 +284,7 @@ export async function initCommand(positional, flags) {
220
284
  if (isJson(flags)) {
221
285
  if (!apply) emit(`${JSON.stringify(payload, null, 2)}\n`);
222
286
  } else {
223
- emit(render(payload, manifest));
287
+ emit(render(payload, manifest, { npm, version }));
224
288
  }
225
289
  if (!apply) {
226
290
  note('');
@@ -232,13 +296,68 @@ export async function initCommand(positional, flags) {
232
296
  return EXIT.OK;
233
297
  }
234
298
 
235
- 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: [] };
236
307
  payload.git = git;
237
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
+ }
238
333
 
239
334
  await writeManifest(root, manifest);
240
335
  note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
241
336
 
337
+ // Identity last, and for the same reason the gate is written after the manifest:
338
+ // the things that change what this repository does come first, and the one that
339
+ // only records who is doing them comes once there is something to be committing.
340
+ //
341
+ // Three separate refusals, each of which is the correct answer rather than a
342
+ // failure to try harder. An existing file is not this tool's to rewrite. An
343
+ // unconfigured identity is not one to invent - a placeholder is an identity git
344
+ // would use. And the include is only repointed when it is missing or wrong,
345
+ // because replacing it when it is already right would be a no-op that reads as
346
+ // a change.
347
+ if (payload.identity.action === 'created') {
348
+ await writeFile(join(root, IDENTITY_FILE), renderIdentityFile(identity));
349
+ await ignoreIdentity(root);
350
+ note(`wrote ${IDENTITY_FILE} (${identity.from === 'last-commit' ? 'from the last commit in this repository' : 'from the identity configured on this machine'}); gitignored, because it is yours and not the repository's`);
351
+ } else if (payload.identity.action === 'unchanged') {
352
+ note(`kept ${IDENTITY_FILE} (${payload.identity.reason})`);
353
+ } else {
354
+ note(`skipped ${IDENTITY_FILE} ${payload.identity.reason}`);
355
+ }
356
+ if (!payload.identity.include) {
357
+ await includeOwnIdentity(root);
358
+ note(`set ${INCLUDE_KEY} = ../${IDENTITY_FILE} this repository's own identity file`);
359
+ }
360
+
242
361
  // Written after the manifest so the gate exists in the same adoption that declares
243
362
  // what the gates are. Skipped with its reason printed rather than silently: a repo
244
363
  // with no `.github/workflows` has no CI surface to attach to, and inventing one
@@ -250,7 +369,7 @@ export async function initCommand(positional, flags) {
250
369
  // guaranteed to be misled.
251
370
  if (payload.ci.action === 'create') {
252
371
  await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
253
- 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 }));
254
373
  note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
255
374
  }
256
375
  if (payload.dependabot.action === 'create') {
@@ -369,13 +488,14 @@ async function scanRepo(root) {
369
488
  }
370
489
 
371
490
  /**
372
- * A named escape hatch before the first write, per plan 0001. Both operations are
373
- * 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
374
494
  * adoptable. Failure is reported as a note rather than blocking, because refusing
375
495
  * to initialise an unborn repo would be worse than proceeding without a backup.
376
496
  */
377
497
  async function establishEscapeHatch(root) {
378
- const result = { branch: null, tag: null, notes: [] };
498
+ const result = { branch: null, tag: null, from: null, notes: [] };
379
499
  const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
380
500
  if (!isRepo.ok) {
381
501
  result.notes.push('not a git work tree; no backup branch or tag created');
@@ -403,6 +523,10 @@ async function establishEscapeHatch(root) {
403
523
 
404
524
  const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
405
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();
406
530
  const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
407
531
  if (branch.ok) result.branch = ADOPT_BRANCH;
408
532
  else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
@@ -423,7 +547,7 @@ async function git(cwd, args) {
423
547
  }
424
548
 
425
549
  /** Every line is a decision with a reason. Nothing is silent. */
426
- function render(payload, manifest) {
550
+ function render(payload, manifest, { npm = true, version } = {}) {
427
551
  const lines = [
428
552
  `repo ${payload.root}`,
429
553
  `stack ${payload.stack.length > 0 ? payload.stack.join(', ') : '(undetected)'}`,
@@ -444,6 +568,18 @@ function render(payload, manifest) {
444
568
  `coverage=${manifest.limits.coverage ?? 'undeclared (ADR-006)'} ` +
445
569
  `fileLines=${manifest.limits.fileLines.warn}/${manifest.limits.fileLines.error} (ADR-005)`,
446
570
  );
571
+ // The identity line, with its source named. "Created" is a statement about somebody's
572
+ // name landing in a file, so where the name came from is not a detail of the line.
573
+ const id = payload.identity;
574
+ if (id) {
575
+ const verb = id.action === 'skip' ? 'skip' : id.action;
576
+ const detail = id.action === 'unchanged'
577
+ ? id.reason
578
+ : id.action === 'skip'
579
+ ? id.reason
580
+ : `${id.name} <${id.email}> from ${id.from === 'last-commit' ? "this repository's last commit" : 'the identity configured on this machine'}`;
581
+ lines.push(` ${verb.padEnd(9)} ${id.file}${detail ? `\n ${detail}` : ''}`);
582
+ }
447
583
  // Every gate the stack suggested and this repo cannot run is named, with the reason.
448
584
  // A silently shorter gate list reads as "this repo needs no formatting check", when
449
585
  // the truth is "declaring one would block every commit". The remedy is stated because
@@ -452,6 +588,12 @@ function render(payload, manifest) {
452
588
  lines.push(` ${'dropped'.padEnd(9)} gate ${d.gate}: ${d.why}`);
453
589
  lines.push(` remedy: ${d.remedy}`);
454
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
+ }
455
597
  lines.push(
456
598
  ` ${'profiles'.padEnd(9)} ${LOCAL_PROFILE_DIR}/*.md ` +
457
599
  `repo-local profiles are selected by the same matcher (ADR-013)`,
@@ -494,7 +636,7 @@ function render(payload, manifest) {
494
636
  // a reader can tell "this repo does not use GitHub Actions" from "this repo was
495
637
  // not offered the gate".
496
638
  if (payload.ci.action === 'create') {
497
- 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`);
498
640
  lines.push(
499
641
  payload.ci.own
500
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';
@@ -39,7 +39,7 @@ import { MANIFEST_FILE } from '../name.mjs';
39
39
  /**
40
40
  * Where skills live in this repo. The estate already has a convention - a
41
41
  * canonical directory plus symlink fan-out - and it is not the same directory in
42
- * every repo (`.agents/skills` in five, `.cursor/skills` in simt-app). So the
42
+ * every repo (`.agents/skills` in some, `.cursor/skills` in others). So the
43
43
  * existing layout is respected and only invented when there is none.
44
44
  */
45
45
  export function skillsDir(root, entries) {
@@ -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) {