simt-dev 0.6.3 → 0.7.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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -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
  };
@@ -27,17 +27,18 @@ import { dirname, join, resolve } from 'node:path';
27
27
 
28
28
  import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
29
29
  import { loadMdcRules, renderMdc } from './mdc.mjs';
30
- import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
30
+ import { foldsRules, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
31
31
  import { ruleTargets } from './targets.mjs';
32
32
  import { compose } from './markers.mjs';
33
- import { declaredBudget, declaredShared, manifestPath, readManifest } from './manifest.mjs';
33
+ import { declaredBudget, declaredShared, readManifest } from './manifest.mjs';
34
+ import { loadManifest } from '../manifest/schema.mjs';
34
35
  import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
35
36
  import { packToBudget } from '../kernel/budget.mjs';
36
37
  import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
37
38
  import { checkDefect, foldChecks } from '../kernel/checks.mjs';
38
39
  import { foldVariants, readLockedVariants, variantsDefect } from '../kernel/variants.mjs';
39
40
  import { BASELINES } from './baselines.mjs';
40
- import { CLI } from '../name.mjs';
41
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
41
42
  import { installedSkills } from './disclosure.mjs';
42
43
  import { PACK_DIR, resolvePacks } from '../packs.mjs';
43
44
  import { discoverPackages } from '../monorepo.mjs';
@@ -134,6 +135,10 @@ export async function provenanceInputs() {
134
135
  async function walk(dir, prefix = '') {
135
136
  const out = [];
136
137
  for (const entry of await readdir(dir, { withFileTypes: true })) {
138
+ // A dotfile is machine noise (.DS_Store on macOS), not rule content: the digest
139
+ // must be stable across checkouts, and a fresh worktree does not carry the
140
+ // author's Finder metadata. Rule files are .md/.mdc, never dotfiles.
141
+ if (entry.name.startsWith('.')) continue;
137
142
  const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
138
143
  if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), rel)));
139
144
  else if (entry.isFile()) out.push(rel);
@@ -251,7 +256,7 @@ export function isEligible(rule, signals, optedIn) {
251
256
  export async function readDeclared(root) {
252
257
  let parsed = {};
253
258
  try {
254
- parsed = JSON.parse(await readFile(await manifestPath(root), 'utf8')) ?? {};
259
+ parsed = (await loadManifest(root)).manifest ?? {};
255
260
  } catch {
256
261
  parsed = {};
257
262
  }
@@ -282,8 +287,11 @@ export async function mdcTargets(root, sha, manifest, flags) {
282
287
  // same `bodies` from the same call, so the region and the files cannot disagree.
283
288
  if (foldsRules(resolved) && !resolved.surfaces.some((s) => s.rule)) {
284
289
  if (bodies.length > 0) {
290
+ // An empty selection has no row to read the primary from; the document is the
291
+ // one the region lives in, which is the answer in every case this note fires.
292
+ const primary = resolved.surfaces[0]?.primary ?? 'AGENTS.md';
285
293
  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`,
294
+ `every rule is folded into ${primary}; add a surface with its own rule path for a file per rule`,
287
295
  );
288
296
  }
289
297
  return { targets, problems, notes };
@@ -411,6 +419,7 @@ async function ruleBodies(root, manifest, flags) {
411
419
  const { body, problems: renderProblems } = renderMdc(
412
420
  rule,
413
421
  ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
422
+ signals,
414
423
  );
415
424
  for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
416
425
  if (renderProblems.length > 0) continue;
@@ -558,6 +567,16 @@ export async function desiredRegion(root, flags) {
558
567
  for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
559
568
  shipped.push({ ...r, body: folded.body });
560
569
  }
570
+ // A body that points the reader at a profile they cannot reach is a rule-text defect:
571
+ // the region renders, but the reference dangles. Kept out of `problems` because that
572
+ // array is the mdc list `sync` already reports, and one fault must not print twice.
573
+ const crossRefs = [];
574
+ const selectedIds = new Set(selected.map((r) => r.id));
575
+ for (const r of shipped) {
576
+ for (const p of crossRefProblems(r.body, all, selectedIds)) {
577
+ crossRefs.push(`${r.id}: ${p}`);
578
+ }
579
+ }
561
580
  const lockedNonDefault = variantsSelected.filter((v) => v.locked && v.winner !== v.baseline);
562
581
  if (lockedNonDefault.length > 0) {
563
582
  notes.push(
@@ -906,6 +925,7 @@ export async function desiredRegion(root, flags) {
906
925
  surfaces: resolved.ids,
907
926
  surfaceSource: resolved.source,
908
927
  problems,
928
+ crossRefs,
909
929
  fallback,
910
930
  notes,
911
931
  body: assembled.body,
@@ -1301,9 +1321,13 @@ export function byPriorityThenId(a, b) {
1301
1321
  * `Applies to` is already the first line of a stack profile once its H1 is gone,
1302
1322
  * so it lands directly under `## Stack: …` instead of under whatever section the
1303
1323
  * previous source ended on.
1324
+ *
1325
+ * The rule text names the manifest with a `{manifest}` token and the render
1326
+ * substitutes it, so a rename of the file is a change in one place (MANIFEST_FILE),
1327
+ * not a search across every rule body.
1304
1328
  */
1305
1329
  function frameSource(rule, body, kind) {
1306
- const text = demoteHeadings(body).trim();
1330
+ const text = demoteHeadings(body.replaceAll('{manifest}', MANIFEST_FILE)).trim();
1307
1331
  if (!text) return '';
1308
1332
  const title = sourceTitle(rule, kind);
1309
1333
  if (!title) return text;
@@ -1317,6 +1341,34 @@ function sourceTitle(rule, kind) {
1317
1341
  return `Stack: ${rule.id}`;
1318
1342
  }
1319
1343
 
1344
+ /**
1345
+ * A body may point the reader at another profile in two ways: naming a profile
1346
+ * that is selected alongside it, or giving the command to fetch one
1347
+ * (`simt rules show <id>`). A reference that is neither is one the reader of
1348
+ * the projected region cannot follow. Both citation forms are checked: the
1349
+ * backtick-quoted `<id> profile` in prose, and the `simt rules show <id>`
1350
+ * command itself - an id that form names must exist, or the command 404s.
1351
+ */
1352
+ function crossRefProblems(body, all, selectedIds) {
1353
+ const problems = [];
1354
+ const seen = new Set();
1355
+ for (const m of String(body).matchAll(/`([a-z0-9][a-z0-9-]*)`\s+profile|rules show ([a-z0-9][a-z0-9-]*)/g)) {
1356
+ const id = m[1] ?? m[2];
1357
+ if (seen.has(id)) continue;
1358
+ seen.add(id);
1359
+ if (!all.some((r) => r.id === id)) {
1360
+ problems.push(`cross-reference: body cites the \`${id}\` profile, which is not a declared rule`);
1361
+ continue;
1362
+ }
1363
+ if (!selectedIds.has(id) && !new RegExp(`${CLI} rules show ${id}`).test(body)) {
1364
+ problems.push(
1365
+ `cross-reference: body cites the \`${id}\` profile, which is neither co-selected nor reachable via \`${CLI} rules show ${id}\``,
1366
+ );
1367
+ }
1368
+ }
1369
+ return problems;
1370
+ }
1371
+
1320
1372
  /** `##` becomes `###`. A fence is left alone: a heading in an example is an example. */
1321
1373
  function demoteHeadings(text) {
1322
1374
  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,88 @@
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
+ * The directory a command works on: `--root`, else the first positional when the command
55
+ * takes one, else the current directory.
56
+ *
57
+ * This precedence is a user-visible contract, not an implementation detail: it decides
58
+ * which directory `--root .` and a bare positional mean, and thirty-four commands had
59
+ * their own copy of it. The copies drifted - some resolved the answer, some did not; some
60
+ * read `args[0]`, some `positional[0]` - so the same argv could name two directories
61
+ * depending on which command read it (review 0003 Q65).
62
+ *
63
+ * The answer is returned verbatim, not resolved: a caller that wants an absolute path
64
+ * says so, and a command that reports the root it was given still reports what the reader
65
+ * typed. `repoRoot` answers the different question of which repository that directory
66
+ * belongs to.
67
+ */
68
+ export function commandRoot(flags = {}, positional = []) {
69
+ return flags.root ?? positional[0] ?? process.cwd();
70
+ }
71
+
72
+ /**
73
+ * Whether git on this repository compares paths case-insensitively, which is
74
+ * the one filesystem fact a path comparison must honour: on a case-insensitive
75
+ * volume `NB.ipynb` and `nb.ipynb` are the file, and a comparison that spells
76
+ * them differently is a comparison that misses.
77
+ *
78
+ * A git that cannot answer (no repository, no binary) answers `false`, which is
79
+ * the strict spelling: the two case variants are then treated as distinct, the
80
+ // way they are on the filesystem the comparison is being done for.
81
+ */
82
+ export function coreIgnoreCase(root) {
83
+ const result = spawnSync('git', ['-C', root, 'config', '--get', 'core.ignorecase'], {
84
+ encoding: 'utf8',
85
+ timeout: 1000,
86
+ });
87
+ return result.status === 0 && String(result.stdout ?? '').trim().toLowerCase() === 'true';
88
+ }
@@ -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) {
@@ -19,10 +19,11 @@
19
19
 
20
20
  import { readdir, readFile, stat } from 'node:fs/promises';
21
21
  import { spawn } from 'node:child_process';
22
- import { basename, join } from 'node:path';
22
+ import { join } from 'node:path';
23
23
 
24
24
  import { matchesGlob } from './resolve.mjs';
25
25
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
26
+ import { loadManifest } from '../manifest/schema.mjs';
26
27
 
27
28
  /** tag -> human-readable marker(s) that imply it. First match wins per tag. */
28
29
  const MARKERS = [
@@ -180,6 +181,11 @@ const FACTS = [
180
181
  { name: 'has-mix', kind: 'file', paths: ['mix.exs'] },
181
182
  { name: 'has-deno-config', kind: 'file', paths: ['deno.json', 'deno.jsonc'] },
182
183
  { name: 'has-bun-lock', kind: 'file', paths: ['bun.lock', 'bun.lockb'] },
184
+ // Q31: one fact per Node lockfile, so the runner resolver, the card, the gate
185
+ // seeds and CI all read the same committed evidence instead of assuming npm.
186
+ { name: 'has-pnpm-lock', kind: 'file', paths: ['pnpm-lock.yaml'] },
187
+ { name: 'has-yarn-lock', kind: 'file', paths: ['yarn.lock'] },
188
+ { name: 'has-package-lock', kind: 'file', paths: ['package-lock.json'] },
183
189
  // A glob walk skips dot directories, and `.devcontainer` is one. Stat the path.
184
190
  { name: 'has-devcontainer', kind: 'path', paths: ['.devcontainer/devcontainer.json'] },
185
191
  ];
@@ -220,6 +226,12 @@ export async function fingerprint(root) {
220
226
  }
221
227
  const names = new Set(entries);
222
228
 
229
+ // One walk answers every `glob` fact. Each candidate used to walk the tree again, so
230
+ // a repository with seven globs paid for seven identical walks - the same directories,
231
+ // in the same order, for the same answer (review 0003 Q67).
232
+ let nested = null;
233
+ const nestedPaths = async () => (nested ??= await listNested(root));
234
+
223
235
  for (const [tag, markers] of MARKERS) {
224
236
  const hit = markers.find((m) => names.has(m));
225
237
  if (hit) {
@@ -244,7 +256,7 @@ export async function fingerprint(root) {
244
256
  break;
245
257
  }
246
258
  if (fact.kind === 'glob') {
247
- const hit = (await findNested(root, candidate))[0];
259
+ const hit = findNestedIn(await nestedPaths(), candidate)[0];
248
260
  if (hit) {
249
261
  facts.add(fact.name);
250
262
  factEvidence[fact.name] ??= hit;
@@ -315,9 +327,9 @@ export async function fingerprint(root) {
315
327
  : names.has(MANIFEST_LEGACY)
316
328
  ? MANIFEST_LEGACY
317
329
  : null;
318
- const manifest = manifestName === null
319
- ? null
320
- : await readManifest(join(root, manifestName));
330
+ // One reader, so a file that does not parse is a missing declaration rather than an
331
+ // object whose only key is "error" pretending to be this repo manifest (Q69).
332
+ const manifest = manifestName === null ? null : (await loadManifest(root)).manifest;
321
333
 
322
334
  const stackList = [...stack].sort();
323
335
  const factList = [...facts].sort();
@@ -334,14 +346,6 @@ export async function fingerprint(root) {
334
346
  };
335
347
  }
336
348
 
337
- async function readManifest(path) {
338
- try {
339
- return JSON.parse(await readFile(path, 'utf8'));
340
- } catch {
341
- return { error: `${basename(path)} unparseable` };
342
- }
343
- }
344
-
345
349
  async function isDir(path) {
346
350
  try {
347
351
  return (await stat(path)).isDirectory();
@@ -359,7 +363,7 @@ async function isDir(path) {
359
363
  * are skipped for the reason the extension sample skips them: detection is not a
360
364
  * scan of vendored output.
361
365
  */
362
- async function findNested(root, pattern) {
366
+ async function listNested(root) {
363
367
  const found = [];
364
368
  const walk = async (dir, prefix, depth) => {
365
369
  let items;
@@ -373,7 +377,7 @@ async function findNested(root, pattern) {
373
377
  const rel = prefix ? `${prefix}/${item.name}` : item.name;
374
378
  if (item.isDirectory()) {
375
379
  if (depth < GLOB_DEPTH) await walk(join(dir, item.name), rel, depth + 1);
376
- } else if (matchesGlob(rel, pattern)) {
380
+ } else {
377
381
  found.push(rel);
378
382
  }
379
383
  }
@@ -382,6 +386,11 @@ async function findNested(root, pattern) {
382
386
  return found.sort();
383
387
  }
384
388
 
389
+ /** The paths in the one walk that match a shape. */
390
+ function findNestedIn(paths, pattern) {
391
+ return paths.filter((rel) => matchesGlob(rel, pattern)).sort();
392
+ }
393
+
385
394
  /**
386
395
  * Which extensions this repository is written in.
387
396
  *