simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -0,0 +1,90 @@
1
+ // The read-only half of `init`: the conflict scan and the pre-write escape hatch.
2
+ //
3
+ // Split out of init.mjs when it reached the repository ceiling. Neither function writes
4
+ // the manifest; both answer a question the command asks before it does.
5
+
6
+ import { mkdir, readFile, symlink } from 'node:fs/promises';
7
+ import { git as sysGit } from '../sys/git.mjs';
8
+ import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
9
+ import { parse as parseRegion } from '../projection/markers.mjs';
10
+
11
+ const BACKUP_TAG = 'backup-before-simt-dev';
12
+ const ADOPT_BRANCH = 'simt-dev/adopt';
13
+
14
+ async function scanRepo(root) {
15
+ const findings = [];
16
+ for (const path of await rulePaths(root)) {
17
+ let text;
18
+ try {
19
+ text = await readFile(path, 'utf8');
20
+ } catch {
21
+ continue;
22
+ }
23
+ const region = parseRegion(text);
24
+ const handWritten = region.status === 'ok' ? `${region.before}\n${region.after}` : text;
25
+ findings.push(...scanDocument(handWritten, path));
26
+ }
27
+ return findings;
28
+ }
29
+
30
+ /**
31
+ * The opt-in escape hatch before the first write, taken when --backup is passed
32
+ * (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
33
+ * are best-effort: a repo that is not a git repo, or has no commits yet, must still be
34
+ * adoptable. Failure is reported as a note rather than blocking, because refusing
35
+ * to initialise an unborn repo would be worse than proceeding without a backup.
36
+ */
37
+ async function establishEscapeHatch(root) {
38
+ const result = { branch: null, tag: null, from: null, notes: [] };
39
+ const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
40
+ if (!isRepo.ok) {
41
+ result.notes.push('not a git work tree; no backup branch or tag created');
42
+ return result;
43
+ }
44
+
45
+ const dirty = await git(root, ['status', '--porcelain']);
46
+ if (dirty.ok && dirty.stdout.trim() !== '') {
47
+ result.notes.push('worktree already dirty; left untouched, existing changes are not staged');
48
+ }
49
+
50
+ const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
51
+ if (!head.ok) {
52
+ result.notes.push('no commits yet; nothing to tag');
53
+ } else {
54
+ const hasTag = await git(root, ['rev-parse', '--verify', `refs/tags/${BACKUP_TAG}`]);
55
+ if (hasTag.ok) {
56
+ result.tag = BACKUP_TAG;
57
+ } else {
58
+ const tag = await git(root, ['tag', BACKUP_TAG]);
59
+ if (tag.ok) result.tag = BACKUP_TAG;
60
+ else result.notes.push(`could not create tag ${BACKUP_TAG}: ${tag.stderr.trim()}`);
61
+ }
62
+ }
63
+
64
+ const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
65
+ if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
66
+ // Recorded rather than re-read by the caller: only this function knows what the
67
+ // branch was before it moved it, and a second `rev-parse` would answer with the new
68
+ // value and make every "you were on X" line read as "you were on Y".
69
+ result.from = current.stdout.trim();
70
+ const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
71
+ if (branch.ok) result.branch = ADOPT_BRANCH;
72
+ else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
73
+ } else if (current.ok) {
74
+ result.branch = ADOPT_BRANCH;
75
+ }
76
+
77
+ return result;
78
+ }
79
+
80
+ async function git(cwd, args) {
81
+ try {
82
+ const stdout = await sysGit(cwd, args);
83
+ return { ok: true, stdout, stderr: '' };
84
+ } catch (error) {
85
+ return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
86
+ }
87
+ }
88
+
89
+
90
+ export { ADOPT_BRANCH, BACKUP_TAG, establishEscapeHatch, git, scanRepo };
@@ -15,10 +15,9 @@
15
15
  // 6. write only with --yes
16
16
  // Never commits, never pushes (ADR-001).
17
17
 
18
- import { execFile } from 'node:child_process';
19
- import { promisify } from 'node:util';
20
18
  import { mkdir, readFile, symlink } from 'node:fs/promises';
21
19
  import { commitJournal, ignoreJournal, journaledWriteFile } from '../journal.mjs';
20
+ import { applyPlan, change } from '../write/plan.mjs';
22
21
  import { invalidateCheckCache } from '../status.mjs';
23
22
  import { dirname, join } from 'node:path';
24
23
 
@@ -47,6 +46,7 @@ import { pointerState, pointerTargets } from '../projection/project.mjs';
47
46
  import { surfacesLine } from '../projection/surfaces.mjs';
48
47
  import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../projection/project.mjs';
49
48
  import { planAgents, writeAgentPlan, writeSkillFile } from '../agents/render.mjs';
49
+ import { applyAgentHooks, planAgentHooks } from './agenthook.mjs';
50
50
  import { computeLock } from '../projection/lock.mjs';
51
51
  import { parse as parseRegion } from '../projection/markers.mjs';
52
52
  import { reindex, SQLITE_FILE } from '../index/store.mjs';
@@ -56,11 +56,9 @@ import { packageVersion, hasNpm } from '../package-version.mjs';
56
56
  import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
57
57
  import { PACKAGE_NAME } from '../name.mjs';
58
58
  import { commandRoot } from '../repo-root.mjs';
59
+ import { git as sysGit } from '../sys/git.mjs';
60
+ import { ADOPT_BRANCH, BACKUP_TAG, establishEscapeHatch, git, scanRepo } from './init-scan.mjs';
59
61
 
60
- const run = promisify(execFile);
61
-
62
- const BACKUP_TAG = 'backup-before-simt-dev';
63
- const ADOPT_BRANCH = 'simt-dev/adopt';
64
62
 
65
63
  export async function initCommand(positional, flags) {
66
64
  const root = commandRoot(flags, positional);
@@ -171,10 +169,29 @@ export async function initCommand(positional, flags) {
171
169
  const ci = await planWorkflow(root);
172
170
  const dependabot = await planDependabot(root);
173
171
  const tsconfigBase = await planTsconfigBase(root);
172
+ // `--no-agents` opts out of the agent files and the agent-hooks in one flag: they are
173
+ // the two halves of the same decision — which harnesses this adoption speaks to — and
174
+ // splitting them into two flags would let a reader ask for the files without the hooks
175
+ // and end up with an agent the repository's own gate does not enforce. The parser's
176
+ // `no-` convention delivers it as the positive `agents` set to false.
177
+ const noAgents = flags.agents === false;
174
178
  // ADR-111. The one agent this tool ships, planned with the rest so the operator sees
175
179
  // it in the same preview that lists everything else. Written in the apply path with
176
- // everything else, journaled the same way.
177
- const agentPlan = await planAgents(root);
180
+ // everything else, journaled the same way. `--harness` narrows it to one reader, the
181
+ // same axis as the agent-hooks below, so the files and the hooks speak to the same set
182
+ // of harnesses (the rule surfaces are a separate axis: `--surfaces`).
183
+ const agentPlan = noAgents ? [] : await planAgents(root, { harness: flags.harness });
184
+ // The agent-hooks entry, absorbed into this run so the adoption enforces its own
185
+ // projection while the agent works. Planned with the rest for the same reason, and
186
+ // written into this run's journal (committed with the rest, not a second journal).
187
+ // `--harness` narrows it to the same reader the agent files are narrowed to.
188
+ let agentHooks = null;
189
+ if (!noAgents) {
190
+ agentHooks = await planAgentHooks(root, { harness: flags.harness });
191
+ if (agentHooks.error) {
192
+ return fail(agentHooks.error, EXIT.USAGE, agentHooks.remedy);
193
+ }
194
+ }
178
195
  // Per-developer, and planned with the rest so the operator sees it in the same
179
196
  // preview that lists everything else. `exists` short-circuits the write: a
180
197
  // repository that already has one keeps it exactly as it is.
@@ -256,6 +273,18 @@ export async function initCommand(positional, flags) {
256
273
  // plans strip their rendered bytes; the apply path writes from `agentPlan`, which
257
274
  // keeps it.
258
275
  agents: agentPlan.map(({ text, ...rest }) => rest),
276
+ // The agent-hooks entry the apply path will write, absorbed into this run so the
277
+ // adoption enforces its own projection while the agent works. `text` is stripped the
278
+ // same way; the apply path writes from `agentHooks`, which keeps it. `null` when
279
+ // `--no-agents` opted out of the hooks (and the agent files with them).
280
+ agentHooks: agentHooks
281
+ ? {
282
+ plan: agentHooks.plan.map(({ text, ...rest }) => rest),
283
+ shim: agentHooks.shim.row,
284
+ notWritten: agentHooks.others,
285
+ refused: agentHooks.refused.map(({ text, ...rest }) => rest),
286
+ }
287
+ : null,
259
288
  adoption,
260
289
  manifest: { file: MANIFEST, action: manifestAction, path: manifestFile },
261
290
  // Local state, not a repository file: gitignored, rebuildable from `git ls-files -s`,
@@ -350,6 +379,28 @@ export async function initCommand(positional, flags) {
350
379
  );
351
380
  }
352
381
 
382
+ // An unreadable hooks config is a refusal, not a failure to try harder: init inherits
383
+ // the standalone command's refusal and blocks the whole run, because writing the other
384
+ // hooks while one is unreadable would leave the adoption in a state where the report it
385
+ // just printed is no longer true. The remedy names the opt-out, because a repository
386
+ // that cannot be read by one harness can still be adopted without the agent layer.
387
+ if (agentHooks !== null && agentHooks.refused.length > 0) {
388
+ payload.ok = false;
389
+ payload.blockedBy = 'agent-hooks-refused';
390
+ if (isJson(flags)) {
391
+ emitJson(payload);
392
+ } else {
393
+ emit(render(payload, manifest, { npm, manager, version }));
394
+ note('');
395
+ note(`refused ${agentHooks.refused.length} hooks config(s) could not be read; nothing written`);
396
+ }
397
+ return fail(
398
+ `${root} has a hooks config init cannot read: ${agentHooks.refused.map((p) => `${p.path} (${p.error})`).join('; ')}`,
399
+ EXIT.FAIL,
400
+ `fix or remove the named config file, then rerun: ${CLI} init --yes | or opt out of the agent layer: ${CLI} init --no-agents --yes`,
401
+ );
402
+ }
403
+
353
404
  // One malformed target refuses the whole adoption, not just that file. Writing the
354
405
  // others first would leave the repo half-adopted with the half that failed having
355
406
  // produced no change at all — indistinguishable, in the diff, from never running.
@@ -408,6 +459,7 @@ export async function initCommand(positional, flags) {
408
459
  after: renderDependabot(payload.dependabot.facts, {
409
460
  stack: payload.dependabot.stack,
410
461
  own: payload.dependabot.own,
462
+ root: payload.dependabot.root,
411
463
  }),
412
464
  });
413
465
  }
@@ -538,6 +590,7 @@ export async function initCommand(positional, flags) {
538
590
  renderDependabot(payload.dependabot.facts, {
539
591
  stack: payload.dependabot.stack,
540
592
  own: payload.dependabot.own,
593
+ root: payload.dependabot.root,
541
594
  }),
542
595
  );
543
596
  note(
@@ -548,26 +601,32 @@ export async function initCommand(positional, flags) {
548
601
  // scaffolds the manifest and one region but leaves the `.mdc` rules for a later
549
602
  // `sync` produces a repo whose `check` fails on files the user was never told
550
603
  // about — which reads as a broken adoption rather than an unfinished one.
604
+ //
605
+ // The malformed guard runs before anything is applied. A target this run cannot write
606
+ // has to stop the run, not leave the earlier targets written and the late one reported.
607
+ const badTarget = projected.targets.find((t) => t.action === 'malformed');
608
+ if (badTarget) {
609
+ // Checked before any write above; reaching here means the guard moved.
610
+ return fail(
611
+ `${badTarget.file}: ${badTarget.reason}`,
612
+ EXIT.FAIL,
613
+ `fix the markers at ${badTarget.file}:${badTarget.line}, then rerun: ${CLI} init --yes`,
614
+ );
615
+ }
616
+ // `before` is read, not inferred from `target.action`: the engine derives create from
617
+ // missing before-bytes, and a writer that passes its own action is the bug it removes.
618
+ const targetChanges = await Promise.all(
619
+ projected.targets
620
+ .filter((t) => t.action !== 'unchanged')
621
+ .map(async (t) =>
622
+ change({ path: t.path, before: await readBefore(t.path), after: t.text, owner: 'init', reason: t.action }),
623
+ ),
624
+ );
625
+ // The caller's journal, committed once below with the manifest and identity writes.
626
+ await applyPlan(root, targetChanges, { mode: { apply: true }, journal, commit: false });
551
627
  for (const target of projected.targets) {
552
- if (target.action === 'unchanged') {
553
- note(`current ${target.file} (already up to date)`);
554
- continue;
555
- }
556
- if (target.action === 'malformed') {
557
- // Checked before any write above; reaching here means the guard moved.
558
- return fail(
559
- `${target.file}: ${target.reason}`,
560
- EXIT.FAIL,
561
- `fix the markers at ${target.file}:${target.line}, then rerun: ${CLI} init --yes`,
562
- );
563
- }
564
- // A per-rule target lives in `.cursor/rules/`, `.claude/rules/`,
565
- // `.agents/rules/` or `.github/instructions/` — four directories a repo that had
566
- // no such configuration at all will not have. Creating them is part of adopting,
567
- // not a detail of writing the file.
568
- await mkdir(dirname(target.path), { recursive: true });
569
- await journaledWriteFile(root, journal, target.path, target.text);
570
- note(`wrote ${target.file} (${target.action})`);
628
+ if (target.action === 'unchanged') note(`current ${target.file} (already up to date)`);
629
+ else note(`wrote ${target.file} (${target.action})`);
571
630
  }
572
631
 
573
632
  // Pointers, after the file they point at exists. A regular file already at one of
@@ -594,6 +653,20 @@ export async function initCommand(positional, flags) {
594
653
  // which keeps the rendered bytes the display payload strips.
595
654
  const agentWritten = await writeAgentPlan(root, journal, agentPlan);
596
655
  for (const f of agentWritten) note(`wrote ${f} (agent)`);
656
+ // The agent-hooks entry, written into this run's journal and committed with the rest of
657
+ // the adoption. It is part of the adoption, not a second run: a second journal would
658
+ // make `undo` restore the hooks separately from the files they enforce, and the reader
659
+ // would not know the two moves belong together.
660
+ if (agentHooks !== null) {
661
+ const { written, shimWritten } = await applyAgentHooks(root, journal, {
662
+ plan: agentHooks.plan,
663
+ shim: agentHooks.shim,
664
+ owner: 'init',
665
+ });
666
+ for (const p of written) note(`wrote ${p.path} (agent-hooks)`);
667
+ if (shimWritten) note(`wrote ${agentHooks.shim.row.path} (agent-hooks shim)`);
668
+ if (written.length === 0 && !shimWritten) note('agent-hooks every documented config already carried this tools entry');
669
+ }
597
670
  await commitJournal(root, 'init', journal);
598
671
  // The tree changed, so the last `check` no longer describes it: the line
599
672
  // goes back to "no cache" rather than reporting the tree as it was (R48).
@@ -645,81 +718,6 @@ export async function initCommand(positional, flags) {
645
718
  /** Scan whichever rule files exist, named or globbed. A missing file is not a
646
719
  * finding. Generated regions are skipped: quoting a ruling is not a conflict with
647
720
  * it, and the region is rewritten by `sync` anyway. */
648
- async function scanRepo(root) {
649
- const findings = [];
650
- for (const path of await rulePaths(root)) {
651
- let text;
652
- try {
653
- text = await readFile(path, 'utf8');
654
- } catch {
655
- continue;
656
- }
657
- const region = parseRegion(text);
658
- const handWritten = region.status === 'ok' ? `${region.before}\n${region.after}` : text;
659
- findings.push(...scanDocument(handWritten, path));
660
- }
661
- return findings;
662
- }
663
-
664
- /**
665
- * The opt-in escape hatch before the first write, taken when --backup is passed
666
- * (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
667
- * are best-effort: a repo that is not a git repo, or has no commits yet, must still be
668
- * adoptable. Failure is reported as a note rather than blocking, because refusing
669
- * to initialise an unborn repo would be worse than proceeding without a backup.
670
- */
671
- async function establishEscapeHatch(root) {
672
- const result = { branch: null, tag: null, from: null, notes: [] };
673
- const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
674
- if (!isRepo.ok) {
675
- result.notes.push('not a git work tree; no backup branch or tag created');
676
- return result;
677
- }
678
-
679
- const dirty = await git(root, ['status', '--porcelain']);
680
- if (dirty.ok && dirty.stdout.trim() !== '') {
681
- result.notes.push('worktree already dirty; left untouched, existing changes are not staged');
682
- }
683
-
684
- const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
685
- if (!head.ok) {
686
- result.notes.push('no commits yet; nothing to tag');
687
- } else {
688
- const hasTag = await git(root, ['rev-parse', '--verify', `refs/tags/${BACKUP_TAG}`]);
689
- if (hasTag.ok) {
690
- result.tag = BACKUP_TAG;
691
- } else {
692
- const tag = await git(root, ['tag', BACKUP_TAG]);
693
- if (tag.ok) result.tag = BACKUP_TAG;
694
- else result.notes.push(`could not create tag ${BACKUP_TAG}: ${tag.stderr.trim()}`);
695
- }
696
- }
697
-
698
- const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
699
- if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
700
- // Recorded rather than re-read by the caller: only this function knows what the
701
- // branch was before it moved it, and a second `rev-parse` would answer with the new
702
- // value and make every "you were on X" line read as "you were on Y".
703
- result.from = current.stdout.trim();
704
- const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
705
- if (branch.ok) result.branch = ADOPT_BRANCH;
706
- else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
707
- } else if (current.ok) {
708
- result.branch = ADOPT_BRANCH;
709
- }
710
-
711
- return result;
712
- }
713
-
714
- async function git(cwd, args) {
715
- try {
716
- const { stdout, stderr } = await run('git', args, { cwd });
717
- return { ok: true, stdout, stderr };
718
- } catch (error) {
719
- return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
720
- }
721
- }
722
-
723
721
  /** Every line is a decision with a reason. Nothing is silent. */
724
722
  function render(payload, manifest, { npm = true, manager = null, version } = {}) {
725
723
  const lines = [
@@ -830,6 +828,26 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
830
828
  lines.push(` ${a.action.padEnd(9)} ${a.path} agent (ADR-111)`);
831
829
  }
832
830
 
831
+ // The agent-hooks entry, absorbed into the run so the adoption enforces its own
832
+ // projection while the agent works. Printed with the same shape the standalone command
833
+ // uses: one line per config the tool writes, the shim, and the readers it narrows past.
834
+ // `--no-agents` suppresses the section and says so, because a line that appears after
835
+ // the run without being in the plan is the same silent write as any other.
836
+ if (payload.agentHooks === null) {
837
+ lines.push(` ${'skip'.padEnd(9)} agent-hooks opted out with --no-agents; the agent files are skipped with it`);
838
+ } else {
839
+ for (const p of payload.agentHooks.plan) {
840
+ lines.push(` ${p.action.padEnd(9)} ${p.path} agent-hooks${p.error ? ` (${p.error})` : ''}`);
841
+ }
842
+ const shim = payload.agentHooks.shim;
843
+ lines.push(` ${shim.action.padEnd(9)} ${shim.path} agent-hooks shim`);
844
+ for (const o of payload.agentHooks.notWritten ?? []) {
845
+ if (o.state === 'skipped') {
846
+ lines.push(` ${'skip'.padEnd(9)} ${o.id} ${o.note}`);
847
+ }
848
+ }
849
+ }
850
+
833
851
  // The CI gate is listed as its own kind because it is the one plan entry whose
834
852
  // absence is a decision rather than a default. Skipped states its reason inline so
835
853
  // a reader can tell "this repo does not use GitHub Actions" from "this repo was
@@ -863,7 +881,7 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
863
881
  // produced by two hand-written renderers.
864
882
  const db = payload.dependabot;
865
883
  if (db && db.action === 'create') {
866
- lines.push(` ${'create'.padEnd(9)} ${db.file} ${renderDependabot(db.facts, { stack: db.stack, own: db.own }).length} bytes`);
884
+ lines.push(` ${'create'.padEnd(9)} ${db.file} ${renderDependabot(db.facts, { stack: db.stack, own: db.own, root: db.root }).length} bytes`);
867
885
  lines.push(
868
886
  db.own
869
887
  ? ` watches ${db.ecosystems.join(', ')}; ${CLI} ci --yes refreshes this copy when the template changes`
@@ -912,4 +930,4 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
912
930
  }
913
931
  if (summary.total === 0) lines.push('', 'CONFLICTS none detected');
914
932
  return lines.filter((l) => l !== undefined).join('\n');
915
- }
933
+ }
@@ -20,11 +20,14 @@ import {
20
20
  DEFAULT_TTL_SECONDS,
21
21
  activeLeases,
22
22
  canonicalLeasePath,
23
+ flattenLease,
24
+ leaseCovers,
23
25
  readLeases,
24
26
  withLeaseLock,
25
27
  writeLeases,
26
28
  } from '../lease.mjs';
27
29
  import { coreIgnoreCase } from '../repo-root.mjs';
30
+ import { noteEvent } from '../state/events.mjs';
28
31
  import { SESSION_ENV } from '../agenthooks/policy.mjs';
29
32
  import { CLI } from '../name.mjs';
30
33
  import { commandRoot } from '../repo-root.mjs';
@@ -60,6 +63,8 @@ export async function leaseCommand(args, flags) {
60
63
 
61
64
  const wanted = [];
62
65
  const seen = new Set();
66
+ // A trailing slash asks for the directory (Q74): one claim covering every path under it.
67
+ const kinds = new Map();
63
68
  // The claim is stored in the one spelling the hook's comparison will produce:
64
69
  // a claim taken as `src/Keep.mjs` and an edit that spells `src/keep.mjs` must
65
70
  // be the same claim on a case-insensitive volume, so the stored path is
@@ -73,19 +78,36 @@ export async function leaseCommand(args, flags) {
73
78
  if (!seen.has(rel)) {
74
79
  seen.add(rel);
75
80
  wanted.push(rel);
81
+ kinds.set(rel, String(arg).endsWith('/') ? 'dir' : 'path');
76
82
  }
77
83
  }
78
84
 
79
85
  // Older claim files hold the pre-canonical spelling, so a stored path is
80
86
  // canonicalised before it is compared: a claim written before the resolver
81
87
  // existed must still conflict with a claim taken for the same file.
82
- const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
83
- const newClaim = (expiresAt) =>
84
- wanted.map((path) => ({ path, session, expiresAt, ...(harness === null ? {} : { harness }) }));
88
+ // A claim and a target overlap when either covers the other: claiming `src/` while
89
+ // `src/a.mjs` is held is the conflict the directory claim exists to catch (Q74).
90
+ const overlaps = (l) =>
91
+ wanted.some(
92
+ (rel) =>
93
+ leaseCovers(l, rel) ||
94
+ (kinds.get(rel) === 'dir' && (l.path === rel || l.path.startsWith(`${rel}/`))),
95
+ );
96
+ const newClaim = (expiresAt, held) =>
97
+ wanted.map((path) => {
98
+ const fences = held.filter((l) => leaseCovers(l, path)).map((l) => Number(l.fence) || 0);
99
+ return flattenLease({
100
+ resource: { kind: kinds.get(path) ?? 'path', id: path },
101
+ holder: { session, harness },
102
+ fence: 1 + (fences.length === 0 ? 0 : Math.max(...fences)),
103
+ acquiredAt: Date.now(),
104
+ expiresAt,
105
+ });
106
+ });
85
107
 
86
108
  const mode = writeMode(flags);
87
109
  if (!mode.apply) {
88
- const mine = newClaim(Date.now() + ttl * 1000);
110
+ const mine = newClaim(Date.now() + ttl * 1000, []);
89
111
  const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
90
112
  if (isJson(flags)) {
91
113
  emitJson({ ok: true, command: 'lease', root, dryRun: true, session, ttl, leases: mine, remedy: apply });
@@ -109,12 +131,12 @@ export async function leaseCommand(args, flags) {
109
131
  const read = await readLeases(root);
110
132
  if (read.error !== null) return { error: read.error, path: read.path };
111
133
  const held = activeLeases(read);
112
- const conflicts = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
134
+ const conflicts = held.filter((l) => overlaps(l) && l.session !== session);
113
135
  if (conflicts.length > 0) return { conflicts };
114
136
  // Keep every other live claim, refresh ours, and let an expired one fall out:
115
137
  // the write is also the only sweep the file ever gets.
116
- const others = held.filter((l) => !seen.has(keyOf(l)));
117
- const mine = newClaim(Date.now() + ttl * 1000);
138
+ const others = held.filter((l) => !overlaps(l));
139
+ const mine = newClaim(Date.now() + ttl * 1000, held);
118
140
  const path = await writeLeases(root, [...others, ...mine]);
119
141
  return { mine, path };
120
142
  });
@@ -129,6 +151,15 @@ export async function leaseCommand(args, flags) {
129
151
  );
130
152
  }
131
153
  const { mine, path } = claimed;
154
+ // Coordination state a hook acts on belongs in the log a UI can tail, not only in a
155
+ // file a person has to open (Q73).
156
+ await noteEvent(root, {
157
+ kind: 'lease.acquired',
158
+ session,
159
+ harness: harness ?? null,
160
+ subject: mine.map((l) => l.path).join(','),
161
+ data: { ttl, fence: mine[0]?.fence ?? null },
162
+ });
132
163
 
133
164
  if (isJson(flags)) {
134
165
  emitJson({ ok: true, command: 'lease', root, session, ttl, dryRun: false, leases: mine, file: path });
@@ -152,6 +183,7 @@ function resolveTargets(root, args, session, verb) {
152
183
  const ignoreCase = coreIgnoreCase(root);
153
184
  const wanted = [];
154
185
  const seen = new Set();
186
+ const kinds = new Map();
155
187
  for (const arg of args) {
156
188
  const rel = canonicalLeasePath(root, arg, { ignoreCase });
157
189
  if (rel === null) {
@@ -160,9 +192,10 @@ function resolveTargets(root, args, session, verb) {
160
192
  if (!seen.has(rel)) {
161
193
  seen.add(rel);
162
194
  wanted.push(rel);
195
+ kinds.set(rel, String(arg).endsWith('/') ? 'dir' : 'path');
163
196
  }
164
197
  }
165
- return { wanted, seen, ignoreCase };
198
+ return { wanted, seen, ignoreCase, kinds };
166
199
  }
167
200
 
168
201
  function claimSession(flags) {
@@ -178,23 +211,28 @@ async function releaseLeases(root, args, flags) {
178
211
  if (sessionError !== undefined) return sessionError;
179
212
  const resolved = resolveTargets(root, args, session, 'release');
180
213
  if (resolved.error !== undefined) return resolved.error;
181
- const { wanted, seen, ignoreCase } = resolved;
182
- const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
214
+ const { wanted, seen, ignoreCase, kinds } = resolved;
215
+ const overlaps = (l) =>
216
+ wanted.some(
217
+ (rel) =>
218
+ leaseCovers(l, rel) ||
219
+ (kinds.get(rel) === 'dir' && (l.path === rel || l.path.startsWith(`${rel}/`))),
220
+ );
183
221
  const mode = writeMode(flags);
184
222
 
185
223
  const released = await withLeaseLock(root, async () => {
186
224
  const read = await readLeases(root);
187
225
  if (read.error !== null) return { error: read.error, path: read.path };
188
226
  const held = activeLeases(read);
189
- const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
227
+ const foreign = held.filter((l) => overlaps(l) && l.session !== session);
190
228
  if (foreign.length > 0) {
191
229
  return {
192
230
  conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
193
231
  };
194
232
  }
195
- const dropped = held.filter((l) => seen.has(keyOf(l)));
233
+ const dropped = held.filter((l) => overlaps(l));
196
234
  if (!mode.apply) return { preview: dropped };
197
- const kept = held.filter((l) => !seen.has(keyOf(l)));
235
+ const kept = held.filter((l) => !overlaps(l));
198
236
  const path = await writeLeases(root, kept);
199
237
  return { dropped, path };
200
238
  });
@@ -216,6 +254,12 @@ async function releaseLeases(root, args, flags) {
216
254
  else emit(lines.join('\n'));
217
255
  return EXIT.OK;
218
256
  }
257
+ await noteEvent(root, {
258
+ kind: 'lease.released',
259
+ session,
260
+ subject: released.dropped.map((l) => l.path).join(','),
261
+ data: { released: released.dropped.length },
262
+ });
219
263
  for (const l of released.dropped) note(`note released ${l.path} (was held by ${l.session})`);
220
264
  if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: false, session, leases: released.dropped });
221
265
  else {
@@ -236,27 +280,32 @@ async function renewLeases(root, args, flags) {
236
280
  if (sessionError !== undefined) return sessionError;
237
281
  const resolved = resolveTargets(root, args, session, 'renew');
238
282
  if (resolved.error !== undefined) return resolved.error;
239
- const { wanted, seen, ignoreCase } = resolved;
283
+ const { wanted, seen, ignoreCase, kinds } = resolved;
240
284
  const ttl = flags.ttl === undefined ? DEFAULT_TTL_SECONDS : Number(flags.ttl);
241
285
  if (!Number.isInteger(ttl) || ttl < 1) {
242
286
  return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease renew <paths> --ttl 900`);
243
287
  }
244
- const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
288
+ const overlaps = (l) =>
289
+ wanted.some(
290
+ (rel) =>
291
+ leaseCovers(l, rel) ||
292
+ (kinds.get(rel) === 'dir' && (l.path === rel || l.path.startsWith(`${rel}/`))),
293
+ );
245
294
  const mode = writeMode(flags);
246
295
 
247
296
  const renewed = await withLeaseLock(root, async () => {
248
297
  const read = await readLeases(root);
249
298
  if (read.error !== null) return { error: read.error, path: read.path };
250
299
  const held = activeLeases(read);
251
- const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
300
+ const foreign = held.filter((l) => overlaps(l) && l.session !== session);
252
301
  if (foreign.length > 0) {
253
302
  return {
254
303
  conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
255
304
  };
256
305
  }
257
306
  const expiresAt = Date.now() + ttl * 1000;
258
- const mine = held.map((l) => (seen.has(keyOf(l)) ? { ...l, expiresAt } : l));
259
- const touched = held.filter((l) => seen.has(keyOf(l)));
307
+ const mine = held.map((l) => (overlaps(l) ? { ...l, expiresAt } : l));
308
+ const touched = held.filter((l) => overlaps(l));
260
309
  if (!mode.apply) return { preview: touched.map((l) => ({ ...l, expiresAt })) };
261
310
  const path = await writeLeases(root, mine);
262
311
  return { touched: touched.map((l) => ({ ...l, expiresAt })), path };
@@ -32,9 +32,9 @@ export async function mapCommand(args, flags) {
32
32
  if (!Number.isInteger(budget) || budget < 0) {
33
33
  return fail(`budget must be a non-negative integer`, EXIT.USAGE, `run: ${CLI} map --budget 2048`);
34
34
  }
35
- const loaded = await loadGraph(root);
36
- if (loaded.error) return loaded.error;
37
35
  const task = typeof flags.task === 'string' ? flags.task : '';
36
+ const loaded = await loadGraph(root, { task, embed: flags.embed === true });
37
+ if (loaded.error) return loaded.error;
38
38
  const bm25 = task.trim().length > 0 ? await searchFtsBm25(root, task, { fts: loaded.fts }) : [];
39
39
  const queryVector =
40
40
  flags.embed === true && loaded.embeddings.length > 0
@@ -109,8 +109,12 @@ async function recall(root, flags) {
109
109
  * first prompt in a fresh clone wait, and a prompt that takes a minute to appear is
110
110
  * how a hook gets uninstalled. The next prompt gets the packet, and a command a
111
111
  * person typed at a terminal always builds what it needs.
112
+ *
113
+ * FTS bodies and stored vectors load only when the caller names them (review 0003
114
+ * Q76): BM25 runs in SQLite, so the graph read pays for files, symbols and edges
115
+ * and nothing more.
112
116
  */
113
- export async function loadGraph(root, { build = true } = {}) {
117
+ export async function loadGraph(root, { build = true, task = '', embed = false } = {}) {
114
118
  if (build) {
115
119
  try {
116
120
  await reindex(root);
@@ -124,12 +128,13 @@ export async function loadGraph(root, { build = true } = {}) {
124
128
  };
125
129
  }
126
130
  }
131
+ const wantFts = String(task ?? '').trim().length > 0;
127
132
  const [files, symbols, edges, fts, embeddings] = await Promise.all([
128
133
  readFiles(root),
129
134
  readSymbols(root),
130
135
  readEdges(root),
131
- readFts(root),
132
- readEmbeddings(root),
136
+ wantFts ? readFts(root) : Promise.resolve([]),
137
+ embed === true ? readEmbeddings(root) : Promise.resolve([]),
133
138
  ]);
134
139
  // A present index that cannot be read is named, not served as empty
135
140
  // (review 0003 Q44): `cold` is "nothing here", `broken` is "something is