simt-dev 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -16,7 +16,8 @@
16
16
  // same matcher, so supporting an ecosystem nobody here has heard of requires no
17
17
  // code change and no release.
18
18
 
19
- import { readFile, readdir } from 'node:fs/promises';
19
+ import { constants as fsConstants } from 'node:fs';
20
+ import { access, readFile, readdir, stat } from 'node:fs/promises';
20
21
  import { join } from 'node:path';
21
22
 
22
23
  import { baselineTargets, misplaced } from './baselines.mjs';
@@ -31,8 +32,75 @@ import { measureRoot } from '../measure/context.mjs';
31
32
  import { loadMdcRules, renderMdc } from './mdc.mjs';
32
33
  import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
33
34
  import { pointerState, pointerTargets, ruleTargets, scopedTargets } from './targets.mjs';
34
- import { CLI } from '../name.mjs';
35
- import { MANIFEST_FILE } from '../name.mjs';
35
+ import { CLI, LOCK_FILE, LOCK_LEGACY, PACKAGE_NAME } from '../name.mjs';
36
+ import { MANIFEST_FILE, WORKFLOW_FILE_NAME } from '../name.mjs';
37
+ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
38
+ import { LOCAL_BIN } from '../distribute/release.mjs';
39
+
40
+ /**
41
+ * A checked-in gate workflow the locator cannot resolve (review 0003 Q31).
42
+ *
43
+ * Returns a finding, or null when some resolution path can still succeed. Only
44
+ * committed evidence counts: the pinned devDependency, the release binary, and
45
+ * `simt` on this machine's PATH (a global install CI may share — its presence
46
+ * here is a reason to stay silent, its absence is not proof CI lacks it, so the
47
+ * finding also requires the first two to be missing).
48
+ */
49
+ async function workflowResolvable(root) {
50
+ let pkg = null;
51
+ try {
52
+ pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
53
+ } catch {
54
+ pkg = null;
55
+ }
56
+ if (pkg !== null) {
57
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
58
+ if (Object.hasOwn(deps, PACKAGE_NAME)) return null;
59
+ }
60
+ try {
61
+ await stat(join(root, LOCAL_BIN));
62
+ return null;
63
+ } catch {
64
+ // No release binary: keep checking the last path.
65
+ }
66
+ for (const dir of (process.env.PATH ?? '').split(':').filter(Boolean)) {
67
+ try {
68
+ await access(join(dir, 'simt'), fsConstants.X_OK);
69
+ return null;
70
+ } catch {
71
+ // Not here; keep looking.
72
+ }
73
+ }
74
+ const file = `.github/workflows/${WORKFLOW_FILE_NAME}`;
75
+ if (pkg !== null) {
76
+ const lockfiles = [];
77
+ for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
78
+ try {
79
+ await stat(join(root, name));
80
+ lockfiles.push(name);
81
+ } catch {
82
+ // Absent is the normal case.
83
+ }
84
+ }
85
+ const runner = resolveNodeRunner({
86
+ lockfiles,
87
+ packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
88
+ });
89
+ return {
90
+ file,
91
+ status: 'unresolvable',
92
+ detail: `the checked-in gate workflow looks for ${PACKAGE_NAME} in node_modules, but nothing it tries can succeed: no pinned devDependency, no ${LOCAL_BIN}, no simt on PATH`,
93
+ remedy: `run: ${runner.addDev} ${PACKAGE_NAME} (the gate runs from the pinned dependency)`,
94
+ };
95
+ }
96
+ return {
97
+ file,
98
+ status: 'unresolvable',
99
+ detail: `the checked-in gate workflow cannot resolve ${PACKAGE_NAME}: no package.json to pin it in, no ${LOCAL_BIN}, no simt on PATH`,
100
+ remedy: `install the pinned gate binary into the repository, then re-run: simt init --yes`,
101
+ };
102
+ }
103
+ import { computeLock } from './lock.mjs';
36
104
  import {
37
105
  desiredRegion,
38
106
  provenanceDigest,
@@ -53,6 +121,16 @@ import {
53
121
  * most agents, including ones that ignore .cursor/ entirely. */
54
122
  export const PRIMARY_TARGET = 'AGENTS.md';
55
123
 
124
+ /** The gate's verdict on a region `sync` has already written. `current` rows carry
125
+ * `detail: null`, not `undefined`: a key whose value is `undefined` survives into the
126
+ * structured MCP surface but is dropped by `JSON.stringify`, so the two surfaces of one
127
+ * answer no longer deep-equal. */
128
+ function regionVerdict(action) {
129
+ return action === 'unchanged'
130
+ ? { status: 'current', detail: null }
131
+ : { status: 'drift', detail: `region is ${action}; run: ${CLI} sync --yes` };
132
+ }
133
+
56
134
 
57
135
  /**
58
136
  * @param {string} root repo root
@@ -62,7 +140,7 @@ export const PRIMARY_TARGET = 'AGENTS.md';
62
140
  export async function plan(root, flags = {}) {
63
141
  const desired = await desiredRegion(root, flags);
64
142
  const { stack, facts, profiles, fallback, notes, body, meta, all, manifest } = desired;
65
- const target = await targetFor(root, body, meta);
143
+ const target = await targetFor(root, root, body, meta);
66
144
 
67
145
  // Per-rule content projects to every editor the repo selected, not only Cursor. The
68
146
  // body is identical on each; only the frontmatter the editor reads before any rule
@@ -94,9 +172,39 @@ export async function plan(root, flags = {}) {
94
172
  // root's would read as though the packages were the repository.
95
173
  const packages = await packageRegions(root, flags, profiles);
96
174
  const nested = await Promise.all(
97
- packages.regions.map((r) => targetFor(join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
175
+ packages.regions.map((r) => targetFor(root, join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
98
176
  );
99
177
 
178
+ // The lock records the two digests a frozen check compares (ADR-086). It is a target
179
+ // like every other: `plan` produces it, so preview, `--diff`, the journal, the idle
180
+ // test and `init` all inherit it. Computing it here rather than in `sync` is what makes
181
+ // a deleted lock show up in the plan as a write instead of vanishing from it — the
182
+ // `sync` that printed "wrote simt-dev.lock.json" and then "nothing written" (Q29).
183
+ const lock = await computeLock(root, flags);
184
+ const lockText = `${JSON.stringify(lock, null, 2)}\n`;
185
+ let previousLockText = '';
186
+ let lockExists = false;
187
+ for (const name of [LOCK_FILE, LOCK_LEGACY]) {
188
+ try {
189
+ previousLockText = await readFile(join(root, name), 'utf8');
190
+ lockExists = true;
191
+ break;
192
+ } catch (error) {
193
+ if (error.code !== 'ENOENT') throw error;
194
+ }
195
+ }
196
+ const lockTarget = {
197
+ file: LOCK_FILE,
198
+ path: join(root, LOCK_FILE),
199
+ kind: 'lock',
200
+ surface: null,
201
+ exists: lockExists,
202
+ action: !lockExists ? 'create' : previousLockText === lockText ? 'unchanged' : 'update',
203
+ bytes: Buffer.byteLength(lockText),
204
+ previousBytes: Buffer.byteLength(previousLockText),
205
+ text: lockText,
206
+ };
207
+
100
208
  return {
101
209
  root,
102
210
  stack,
@@ -107,15 +215,25 @@ export async function plan(root, flags = {}) {
107
215
  surfaceSource: surfaces.source,
108
216
  surfaceProblems: surfaces.problems,
109
217
  rules: all,
110
- targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets],
218
+ targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets, lockTarget],
111
219
  packages: packages.regions.map((r) => ({ path: r.path, profiles: r.profiles })),
112
220
  mdcProblems: mdc.problems,
221
+ crossRefs: desired.crossRefs,
113
222
  notes: [...notes, ...mdc.notes, ...packages.notes],
114
223
  };
115
224
  }
116
225
 
117
- async function targetFor(root, body, meta, label = PRIMARY_TARGET) {
118
- const path = join(root, PRIMARY_TARGET);
226
+ /**
227
+ * Build the write target for one AGENTS.md. `root` is the repository: `regionOpts`
228
+ * reads its manifest, because placement is a repository-level decision that holds for
229
+ * every package's region too. `dir` is where the file lives, which differs from
230
+ * `root` only for a nested monorepo package (review 0003 Q26).
231
+ *
232
+ * @param {string} root repo root
233
+ * @param {string} dir directory the target file lives in
234
+ */
235
+ async function targetFor(root, dir, body, meta, label = PRIMARY_TARGET) {
236
+ const path = join(dir, PRIMARY_TARGET);
119
237
  let existing = '';
120
238
  let exists = true;
121
239
  try {
@@ -186,8 +304,24 @@ export async function checkTargets(root, flags = {}) {
186
304
  // Two `.github` files, one ownership rule, so one loop over both. The gate and the
187
305
  // dependency-updates config fail for the same reason and are fixed the same way,
188
306
  // and listing them separately is how the second copy of this rule gets written.
307
+ const workflowPlan = await planWorkflow(root);
308
+ // Q31: a workflow that exists but cannot resolve simt-dev by any path the
309
+ // locator tries is a green tick on a gate that never runs. The locator tries
310
+ // `$SIMT_DEV_BIN`, the pinned devDependency, the release binary and `simt`
311
+ // on PATH, in that order; only the middle two are committed evidence this
312
+ // check can see. When the repo pins nothing and carries no binary, the check
313
+ // can still see `simt` on its own PATH — a global install CI may share — so
314
+ // it stays silent then rather than crying wolf about a resolution it cannot
315
+ // disprove. The remedy names the repo's own manager, never a bare npm guess.
316
+ // simt-dev's own copy is exempt: it runs this checkout directly and never asks the
317
+ // locator to find a binary, so a machine without `simt` on PATH is not evidence that
318
+ // the gate cannot start. The consumer copy is not exempt, because its locator does.
319
+ if (!workflowPlan.own && workflowPlan.action !== 'create' && workflowPlan.action !== 'skip') {
320
+ const unresolvable = await workflowResolvable(root);
321
+ if (unresolvable) findings.push(unresolvable);
322
+ }
189
323
  for (const [plan, what] of [
190
- [await planWorkflow(root), 'nothing gates it'],
324
+ [workflowPlan, 'nothing gates it'],
191
325
  [await planDependabot(root), 'nothing watches its dependencies'],
192
326
  ]) {
193
327
  if (plan.action === 'create') {
@@ -258,7 +392,20 @@ export async function checkTargets(root, flags = {}) {
258
392
  for (const p of resolved.problems) {
259
393
  findings.push({ file: `${MANIFEST_FILE}`, status: 'invalid', detail: p, remedy: `known surfaces: ${SURFACE_IDS.join(', ')}` });
260
394
  }
261
- const rules = all.filter((r) => isEligible(r, new Set([...stack, ...facts]), declared.optIn));
395
+ const signals = new Set([...stack, ...facts]);
396
+ const rules = all.filter((r) => isEligible(r, signals, declared.optIn));
397
+
398
+ // A dangling cross-reference is a rule-text defect: the region renders, but it points
399
+ // the reader at a profile they cannot reach. A gate failure, like any other rule defect.
400
+ for (const p of desired.crossRefs ?? []) {
401
+ const idx = p.indexOf(': ');
402
+ findings.push({
403
+ file: p.slice(0, idx),
404
+ status: 'invalid',
405
+ detail: p.slice(idx + 2),
406
+ remedy: `co-select the profile, or name it with ${CLI} rules show <id>`,
407
+ });
408
+ }
262
409
 
263
410
  const sha = await provenanceDigest();
264
411
  const notes = [];
@@ -286,8 +433,7 @@ export async function checkTargets(root, flags = {}) {
286
433
  findings.push({
287
434
  file: target.file,
288
435
  surface: target.surface,
289
- status: action === 'unchanged' ? 'current' : 'drift',
290
- detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
436
+ ...regionVerdict(action),
291
437
  recordedSha: parse(target.previous).sha ?? null,
292
438
  });
293
439
  }
@@ -308,6 +454,7 @@ export async function checkTargets(root, flags = {}) {
308
454
  const { body, problems } = renderMdc(
309
455
  rule,
310
456
  ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
457
+ signals,
311
458
  );
312
459
  if (problems.length > 0) {
313
460
  findings.push({
@@ -359,8 +506,7 @@ export async function checkTargets(root, flags = {}) {
359
506
  findings.push({
360
507
  file: rel,
361
508
  surface: target.surface,
362
- status: action === 'unchanged' ? 'current' : 'drift',
363
- detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
509
+ ...regionVerdict(action),
364
510
  recordedSha: state.sha ?? null,
365
511
  });
366
512
  }
@@ -395,8 +541,7 @@ export async function checkTargets(root, flags = {}) {
395
541
  }
396
542
  findings.push({
397
543
  file: t.file,
398
- status: t.action === 'unchanged' ? 'current' : 'drift',
399
- detail: t.action === 'unchanged' ? undefined : `region is ${t.action}; run: ${CLI} sync --yes`,
544
+ ...regionVerdict(t.action),
400
545
  recordedSha: state.sha ?? null,
401
546
  });
402
547
  const off = misplaced(state, t.style);
@@ -566,8 +711,7 @@ async function checkRegion(root, dir, body, meta) {
566
711
  const d = diff(existing, body, meta, await regionOpts(root));
567
712
  return {
568
713
  file: PRIMARY_TARGET,
569
- status: d.action === 'unchanged' ? 'current' : 'drift',
570
- detail: d.action === 'unchanged' ? undefined : `region is ${d.action}; run: ${CLI} sync --yes`,
714
+ ...regionVerdict(d.action),
571
715
  recordedSha: state.sha ?? null,
572
716
  expectedSha: meta.sha,
573
717
  };
@@ -37,7 +37,7 @@ import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inf
37
37
  import { checkDefect, foldChecks } from '../kernel/checks.mjs';
38
38
  import { foldVariants, readLockedVariants, variantsDefect } from '../kernel/variants.mjs';
39
39
  import { BASELINES } from './baselines.mjs';
40
- import { CLI } from '../name.mjs';
40
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
41
41
  import { installedSkills } from './disclosure.mjs';
42
42
  import { PACK_DIR, resolvePacks } from '../packs.mjs';
43
43
  import { discoverPackages } from '../monorepo.mjs';
@@ -134,6 +134,10 @@ export async function provenanceInputs() {
134
134
  async function walk(dir, prefix = '') {
135
135
  const out = [];
136
136
  for (const entry of await readdir(dir, { withFileTypes: true })) {
137
+ // A dotfile is machine noise (.DS_Store on macOS), not rule content: the digest
138
+ // must be stable across checkouts, and a fresh worktree does not carry the
139
+ // author's Finder metadata. Rule files are .md/.mdc, never dotfiles.
140
+ if (entry.name.startsWith('.')) continue;
137
141
  const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
138
142
  if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), rel)));
139
143
  else if (entry.isFile()) out.push(rel);
@@ -282,8 +286,11 @@ export async function mdcTargets(root, sha, manifest, flags) {
282
286
  // same `bodies` from the same call, so the region and the files cannot disagree.
283
287
  if (foldsRules(resolved) && !resolved.surfaces.some((s) => s.rule)) {
284
288
  if (bodies.length > 0) {
289
+ // An empty selection has no row to read the primary from; the document is the
290
+ // one the region lives in, which is the answer in every case this note fires.
291
+ const primary = resolved.surfaces[0]?.primary ?? 'AGENTS.md';
285
292
  notes.push(
286
- `every rule is folded into ${resolved.surfaces[0].primary}; add a surface with its own rule path for a file per rule`,
293
+ `every rule is folded into ${primary}; add a surface with its own rule path for a file per rule`,
287
294
  );
288
295
  }
289
296
  return { targets, problems, notes };
@@ -411,6 +418,7 @@ async function ruleBodies(root, manifest, flags) {
411
418
  const { body, problems: renderProblems } = renderMdc(
412
419
  rule,
413
420
  ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
421
+ signals,
414
422
  );
415
423
  for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
416
424
  if (renderProblems.length > 0) continue;
@@ -558,6 +566,16 @@ export async function desiredRegion(root, flags) {
558
566
  for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
559
567
  shipped.push({ ...r, body: folded.body });
560
568
  }
569
+ // A body that points the reader at a profile they cannot reach is a rule-text defect:
570
+ // the region renders, but the reference dangles. Kept out of `problems` because that
571
+ // array is the mdc list `sync` already reports, and one fault must not print twice.
572
+ const crossRefs = [];
573
+ const selectedIds = new Set(selected.map((r) => r.id));
574
+ for (const r of shipped) {
575
+ for (const p of crossRefProblems(r.body, all, selectedIds)) {
576
+ crossRefs.push(`${r.id}: ${p}`);
577
+ }
578
+ }
561
579
  const lockedNonDefault = variantsSelected.filter((v) => v.locked && v.winner !== v.baseline);
562
580
  if (lockedNonDefault.length > 0) {
563
581
  notes.push(
@@ -906,6 +924,7 @@ export async function desiredRegion(root, flags) {
906
924
  surfaces: resolved.ids,
907
925
  surfaceSource: resolved.source,
908
926
  problems,
927
+ crossRefs,
909
928
  fallback,
910
929
  notes,
911
930
  body: assembled.body,
@@ -1301,9 +1320,13 @@ export function byPriorityThenId(a, b) {
1301
1320
  * `Applies to` is already the first line of a stack profile once its H1 is gone,
1302
1321
  * so it lands directly under `## Stack: …` instead of under whatever section the
1303
1322
  * previous source ended on.
1323
+ *
1324
+ * The rule text names the manifest with a `{manifest}` token and the render
1325
+ * substitutes it, so a rename of the file is a change in one place (MANIFEST_FILE),
1326
+ * not a search across every rule body.
1304
1327
  */
1305
1328
  function frameSource(rule, body, kind) {
1306
- const text = demoteHeadings(body).trim();
1329
+ const text = demoteHeadings(body.replaceAll('{manifest}', MANIFEST_FILE)).trim();
1307
1330
  if (!text) return '';
1308
1331
  const title = sourceTitle(rule, kind);
1309
1332
  if (!title) return text;
@@ -1317,6 +1340,34 @@ function sourceTitle(rule, kind) {
1317
1340
  return `Stack: ${rule.id}`;
1318
1341
  }
1319
1342
 
1343
+ /**
1344
+ * A body may point the reader at another profile in two ways: naming a profile
1345
+ * that is selected alongside it, or giving the command to fetch one
1346
+ * (`simt rules show <id>`). A reference that is neither is one the reader of
1347
+ * the projected region cannot follow. Both citation forms are checked: the
1348
+ * backtick-quoted `<id> profile` in prose, and the `simt rules show <id>`
1349
+ * command itself - an id that form names must exist, or the command 404s.
1350
+ */
1351
+ function crossRefProblems(body, all, selectedIds) {
1352
+ const problems = [];
1353
+ const seen = new Set();
1354
+ for (const m of String(body).matchAll(/`([a-z0-9][a-z0-9-]*)`\s+profile|rules show ([a-z0-9][a-z0-9-]*)/g)) {
1355
+ const id = m[1] ?? m[2];
1356
+ if (seen.has(id)) continue;
1357
+ seen.add(id);
1358
+ if (!all.some((r) => r.id === id)) {
1359
+ problems.push(`cross-reference: body cites the \`${id}\` profile, which is not a declared rule`);
1360
+ continue;
1361
+ }
1362
+ if (!selectedIds.has(id) && !new RegExp(`${CLI} rules show ${id}`).test(body)) {
1363
+ problems.push(
1364
+ `cross-reference: body cites the \`${id}\` profile, which is neither co-selected nor reachable via \`${CLI} rules show ${id}\``,
1365
+ );
1366
+ }
1367
+ }
1368
+ return problems;
1369
+ }
1370
+
1320
1371
  /** `##` becomes `###`. A fence is left alone: a heading in an example is an example. */
1321
1372
  function demoteHeadings(text) {
1322
1373
  let fence = false;
@@ -327,12 +327,20 @@ export const DEFAULT_SURFACES = ['opencode'];
327
327
  * Cursor reader gets them as `.mdc`. Folding and per-rule files therefore coexist,
328
328
  * and `ruleTargets` already skips a surface that has no rule path.
329
329
  *
330
+ * An empty selection folds too: with no surface carrying a rule path, the region is
331
+ * the only channel a rule has, and the old answer (no surface folds, so nothing
332
+ * folds) wrote every process rule nowhere while `check` reported current (review
333
+ * 0003 Q27).
334
+ *
330
335
  * Asked in exactly two places that must agree: `desiredRegion` folds the bodies in,
331
336
  * and `mdcTargets` writes the files. Asking it twice is how ADR-020's fold decision
332
337
  * could drift between the writer and the gate, which is the step-6 failure.
333
338
  */
334
339
  export function foldsRules(resolved) {
335
- return resolved.surfaces.some((s) => s.foldRules === true);
340
+ if (resolved.surfaces.length === 0) return true;
341
+ // A surface with no rule path has no per-rule channel, so its rules must be in the
342
+ // region; `foldRules` is that fact recorded on the row.
343
+ return resolved.surfaces.some((s) => s.foldRules === true || !s.rule);
336
344
  }
337
345
 
338
346
  /**
@@ -0,0 +1,69 @@
1
+ // The repository root a working directory belongs to, for the hook path.
2
+ //
3
+ // A payload's `cwd` is the directory the harness happens to be in, and a harness
4
+ // happens to be in subdirectories: an agent that was told to work in `src/` edits
5
+ // from there, and every repository-relative comparison that resolves against the
6
+ // payload's cwd — the lease file, `.gitmodules`, the manifest — compares against
7
+ // the wrong root and silently stops matching (review 0003 Q16). The root this
8
+ // module answers is the one git itself would use, so a lease taken at the top
9
+ // level matches an edit made three directories down.
10
+ //
11
+ // The answer is synchronous because the caller is a hook on the hot path: one
12
+ // `git rev-parse` is cheaper than the repository walk the fallback would do in
13
+ // the common case, and the fallback only pays when git cannot answer at all —
14
+ // a directory that is not in a repository, where the walk is short by
15
+ // definition.
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+
21
+ import { MANIFEST_FILE, STATE_DIR } from './name.mjs';
22
+
23
+ /**
24
+ * The top-level directory of the repository `cwd` is in, or `cwd` itself when it
25
+ * is in none.
26
+ *
27
+ * The fallback looks for the two facts that mark a directory as adopted by this
28
+ * tool — the state directory and the manifest — and walks up, because a hook
29
+ * fired from a subdirectory of an adopted repository must still find it: a
30
+ * repository with no `.git` at all (a worktree's parent, a fresh checkout
31
+ // before `git init` finishes) is the case the git call refuses and the walk
32
+ * answers.
33
+ */
34
+ export function repoRoot(cwd) {
35
+ const start = resolve(String(cwd ?? ''));
36
+ const result = spawnSync('git', ['-C', start, 'rev-parse', '--show-toplevel'], {
37
+ encoding: 'utf8',
38
+ timeout: 2000,
39
+ });
40
+ if (result.status === 0) {
41
+ const top = String(result.stdout ?? '').trim();
42
+ if (top !== '') return top;
43
+ }
44
+ let dir = start;
45
+ for (;;) {
46
+ if (existsSync(join(dir, STATE_DIR)) || existsSync(join(dir, MANIFEST_FILE))) return dir;
47
+ const parent = dirname(dir);
48
+ if (parent === dir) return start;
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether git on this repository compares paths case-insensitively, which is
55
+ * the one filesystem fact a path comparison must honour: on a case-insensitive
56
+ * volume `NB.ipynb` and `nb.ipynb` are the file, and a comparison that spells
57
+ * them differently is a comparison that misses.
58
+ *
59
+ * A git that cannot answer (no repository, no binary) answers `false`, which is
60
+ * the strict spelling: the two case variants are then treated as distinct, the
61
+ // way they are on the filesystem the comparison is being done for.
62
+ */
63
+ export function coreIgnoreCase(root) {
64
+ const result = spawnSync('git', ['-C', root, 'config', '--get', 'core.ignorecase'], {
65
+ encoding: 'utf8',
66
+ timeout: 1000,
67
+ });
68
+ return result.status === 0 && String(result.stdout ?? '').trim().toLowerCase() === 'true';
69
+ }
@@ -22,6 +22,10 @@ import { existsSync } from 'node:fs';
22
22
  import { locateSource, sourceHeldLocally } from './source.mjs';
23
23
 
24
24
  export async function sourcePath(skill, workspaceRoot, root) {
25
+ // A node synthesized from the repo's own skill directory already names its file.
26
+ // Asking the workspace for it would hunt for a body the registry never declared.
27
+ if (skill.localFile) return skill.localFile;
28
+
25
29
  const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
26
30
 
27
31
  if (canonical) {
@@ -180,6 +180,11 @@ const FACTS = [
180
180
  { name: 'has-mix', kind: 'file', paths: ['mix.exs'] },
181
181
  { name: 'has-deno-config', kind: 'file', paths: ['deno.json', 'deno.jsonc'] },
182
182
  { name: 'has-bun-lock', kind: 'file', paths: ['bun.lock', 'bun.lockb'] },
183
+ // Q31: one fact per Node lockfile, so the runner resolver, the card, the gate
184
+ // seeds and CI all read the same committed evidence instead of assuming npm.
185
+ { name: 'has-pnpm-lock', kind: 'file', paths: ['pnpm-lock.yaml'] },
186
+ { name: 'has-yarn-lock', kind: 'file', paths: ['yarn.lock'] },
187
+ { name: 'has-package-lock', kind: 'file', paths: ['package-lock.json'] },
183
188
  // A glob walk skips dot directories, and `.devcontainer` is one. Stat the path.
184
189
  { name: 'has-devcontainer', kind: 'path', paths: ['.devcontainer/devcontainer.json'] },
185
190
  ];
@@ -0,0 +1,58 @@
1
+ // Repo-local skills as resolver nodes.
2
+ //
3
+ // The registry is what simt-dev ships; the skill directories a repo holds are what
4
+ // that repo owns. After plan 0005 S2 the registry is small and generic, so a skill
5
+ // that lives only in a repo was invisible to `skills find` and `skills brief`, and
6
+ // the NOT SELECTED block points a reader at `skills show` by name - a remedy that
7
+ // 404s for a name the registry does not declare.
8
+ //
9
+ // Presence in the repo's skill directory is the eligibility signal: a skill the repo
10
+ // installed is about the repo, and the stack it belongs to is the stack the repo has.
11
+ // The node's `stack` is therefore the repo's own signals, which makes the stack gate
12
+ // a self-overlap that always passes, and the stack score reads "more this repo's"
13
+ // rather than "compatible with a stranger".
14
+
15
+ import { stat } from 'node:fs/promises';
16
+
17
+ import { BODY_BYTE_BUDGET } from '../graph/compile.mjs';
18
+ import { installedSkills } from '../projection/disclosure.mjs';
19
+
20
+ /**
21
+ * The repo's own skills, as nodes the resolver can rank.
22
+ *
23
+ * Names the registry declares are excluded: the registry entry is canonical and the
24
+ * local file is its installed copy, so keeping both would rank one skill twice.
25
+ *
26
+ * @param {string} root the repository
27
+ * @param {object} registry the loaded registry
28
+ * @param {string[]} repoStack the repo's signals (stack ∪ facts)
29
+ * @returns {Promise<object[]>} resolver-ready nodes, name-sorted
30
+ */
31
+ export async function localSkillNodes(root, registry, repoStack) {
32
+ const installed = await installedSkills(root);
33
+ const known = new Set(registry.skills.map((s) => s.name));
34
+ const nodes = [];
35
+ for (const { name, trigger, file } of installed) {
36
+ if (known.has(name)) continue;
37
+ let size;
38
+ try {
39
+ size = (await stat(file)).size;
40
+ } catch (error) {
41
+ if (error.code !== 'ENOENT') throw error;
42
+ continue; // gone between listing and measuring
43
+ }
44
+ nodes.push({
45
+ name,
46
+ summary: trigger,
47
+ keywords: [],
48
+ capabilities: [],
49
+ stack: [...repoStack],
50
+ paths: [],
51
+ bytes: size,
52
+ deferred: size > BODY_BYTE_BUDGET,
53
+ localFile: file,
54
+ });
55
+ }
56
+ nodes.sort((a, b) => a.name.localeCompare(b.name));
57
+ return nodes;
58
+ }
@@ -32,9 +32,11 @@ import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs
32
32
 
33
33
  const exec = promisify(execFile);
34
34
 
35
- // Locked on-disk beneath `.simt`, like `.simt/distilled` (R38) — a cache of
35
+ import { LOCAL_DIR } from '../state.mjs';
36
+
37
+ // Locked on-disk beneath machine-local state, like the distilled cache (R38) —
36
38
  // proposals keyed by content digest, never projected and never committed.
37
- const REVIEW_DIR = '.simt/reviews';
39
+ const REVIEW_DIR = join(LOCAL_DIR, 'reviews');
38
40
  const ENGINE_REVIEW_LOCAL = 'review-local';
39
41
 
40
42
  /** The diff brief's byte cap; `--budget` overrides it, `0` means unlimited. */