simt-dev 0.7.0 → 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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. 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
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. 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';
@@ -55,14 +55,13 @@ import { MANIFEST_FILE } from '../name.mjs';
55
55
  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
+ 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';
58
61
 
59
- const run = promisify(execFile);
60
-
61
- const BACKUP_TAG = 'backup-before-simt-dev';
62
- const ADOPT_BRANCH = 'simt-dev/adopt';
63
62
 
64
63
  export async function initCommand(positional, flags) {
65
- const root = flags.root ?? positional[0] ?? process.cwd();
64
+ const root = commandRoot(flags, positional);
66
65
 
67
66
  const detected = await fingerprint(root);
68
67
  if (detected.error) {
@@ -170,10 +169,29 @@ export async function initCommand(positional, flags) {
170
169
  const ci = await planWorkflow(root);
171
170
  const dependabot = await planDependabot(root);
172
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;
173
178
  // ADR-111. The one agent this tool ships, planned with the rest so the operator sees
174
179
  // it in the same preview that lists everything else. Written in the apply path with
175
- // everything else, journaled the same way.
176
- 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
+ }
177
195
  // Per-developer, and planned with the rest so the operator sees it in the same
178
196
  // preview that lists everything else. `exists` short-circuits the write: a
179
197
  // repository that already has one keeps it exactly as it is.
@@ -255,6 +273,18 @@ export async function initCommand(positional, flags) {
255
273
  // plans strip their rendered bytes; the apply path writes from `agentPlan`, which
256
274
  // keeps it.
257
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,
258
288
  adoption,
259
289
  manifest: { file: MANIFEST, action: manifestAction, path: manifestFile },
260
290
  // Local state, not a repository file: gitignored, rebuildable from `git ls-files -s`,
@@ -349,6 +379,28 @@ export async function initCommand(positional, flags) {
349
379
  );
350
380
  }
351
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
+
352
404
  // One malformed target refuses the whole adoption, not just that file. Writing the
353
405
  // others first would leave the repo half-adopted with the half that failed having
354
406
  // produced no change at all — indistinguishable, in the diff, from never running.
@@ -407,6 +459,7 @@ export async function initCommand(positional, flags) {
407
459
  after: renderDependabot(payload.dependabot.facts, {
408
460
  stack: payload.dependabot.stack,
409
461
  own: payload.dependabot.own,
462
+ root: payload.dependabot.root,
410
463
  }),
411
464
  });
412
465
  }
@@ -537,6 +590,7 @@ export async function initCommand(positional, flags) {
537
590
  renderDependabot(payload.dependabot.facts, {
538
591
  stack: payload.dependabot.stack,
539
592
  own: payload.dependabot.own,
593
+ root: payload.dependabot.root,
540
594
  }),
541
595
  );
542
596
  note(
@@ -547,26 +601,32 @@ export async function initCommand(positional, flags) {
547
601
  // scaffolds the manifest and one region but leaves the `.mdc` rules for a later
548
602
  // `sync` produces a repo whose `check` fails on files the user was never told
549
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 });
550
627
  for (const target of projected.targets) {
551
- if (target.action === 'unchanged') {
552
- note(`current ${target.file} (already up to date)`);
553
- continue;
554
- }
555
- if (target.action === 'malformed') {
556
- // Checked before any write above; reaching here means the guard moved.
557
- return fail(
558
- `${target.file}: ${target.reason}`,
559
- EXIT.FAIL,
560
- `fix the markers at ${target.file}:${target.line}, then rerun: ${CLI} init --yes`,
561
- );
562
- }
563
- // A per-rule target lives in `.cursor/rules/`, `.claude/rules/`,
564
- // `.agents/rules/` or `.github/instructions/` — four directories a repo that had
565
- // no such configuration at all will not have. Creating them is part of adopting,
566
- // not a detail of writing the file.
567
- await mkdir(dirname(target.path), { recursive: true });
568
- await journaledWriteFile(root, journal, target.path, target.text);
569
- 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})`);
570
630
  }
571
631
 
572
632
  // Pointers, after the file they point at exists. A regular file already at one of
@@ -593,6 +653,20 @@ export async function initCommand(positional, flags) {
593
653
  // which keeps the rendered bytes the display payload strips.
594
654
  const agentWritten = await writeAgentPlan(root, journal, agentPlan);
595
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
+ }
596
670
  await commitJournal(root, 'init', journal);
597
671
  // The tree changed, so the last `check` no longer describes it: the line
598
672
  // goes back to "no cache" rather than reporting the tree as it was (R48).
@@ -644,81 +718,6 @@ export async function initCommand(positional, flags) {
644
718
  /** Scan whichever rule files exist, named or globbed. A missing file is not a
645
719
  * finding. Generated regions are skipped: quoting a ruling is not a conflict with
646
720
  * it, and the region is rewritten by `sync` anyway. */
647
- async function scanRepo(root) {
648
- const findings = [];
649
- for (const path of await rulePaths(root)) {
650
- let text;
651
- try {
652
- text = await readFile(path, 'utf8');
653
- } catch {
654
- continue;
655
- }
656
- const region = parseRegion(text);
657
- const handWritten = region.status === 'ok' ? `${region.before}\n${region.after}` : text;
658
- findings.push(...scanDocument(handWritten, path));
659
- }
660
- return findings;
661
- }
662
-
663
- /**
664
- * The opt-in escape hatch before the first write, taken when --backup is passed
665
- * (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
666
- * are best-effort: a repo that is not a git repo, or has no commits yet, must still be
667
- * adoptable. Failure is reported as a note rather than blocking, because refusing
668
- * to initialise an unborn repo would be worse than proceeding without a backup.
669
- */
670
- async function establishEscapeHatch(root) {
671
- const result = { branch: null, tag: null, from: null, notes: [] };
672
- const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
673
- if (!isRepo.ok) {
674
- result.notes.push('not a git work tree; no backup branch or tag created');
675
- return result;
676
- }
677
-
678
- const dirty = await git(root, ['status', '--porcelain']);
679
- if (dirty.ok && dirty.stdout.trim() !== '') {
680
- result.notes.push('worktree already dirty; left untouched, existing changes are not staged');
681
- }
682
-
683
- const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
684
- if (!head.ok) {
685
- result.notes.push('no commits yet; nothing to tag');
686
- } else {
687
- const hasTag = await git(root, ['rev-parse', '--verify', `refs/tags/${BACKUP_TAG}`]);
688
- if (hasTag.ok) {
689
- result.tag = BACKUP_TAG;
690
- } else {
691
- const tag = await git(root, ['tag', BACKUP_TAG]);
692
- if (tag.ok) result.tag = BACKUP_TAG;
693
- else result.notes.push(`could not create tag ${BACKUP_TAG}: ${tag.stderr.trim()}`);
694
- }
695
- }
696
-
697
- const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
698
- if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
699
- // Recorded rather than re-read by the caller: only this function knows what the
700
- // branch was before it moved it, and a second `rev-parse` would answer with the new
701
- // value and make every "you were on X" line read as "you were on Y".
702
- result.from = current.stdout.trim();
703
- const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
704
- if (branch.ok) result.branch = ADOPT_BRANCH;
705
- else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
706
- } else if (current.ok) {
707
- result.branch = ADOPT_BRANCH;
708
- }
709
-
710
- return result;
711
- }
712
-
713
- async function git(cwd, args) {
714
- try {
715
- const { stdout, stderr } = await run('git', args, { cwd });
716
- return { ok: true, stdout, stderr };
717
- } catch (error) {
718
- return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
719
- }
720
- }
721
-
722
721
  /** Every line is a decision with a reason. Nothing is silent. */
723
722
  function render(payload, manifest, { npm = true, manager = null, version } = {}) {
724
723
  const lines = [
@@ -829,6 +828,26 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
829
828
  lines.push(` ${a.action.padEnd(9)} ${a.path} agent (ADR-111)`);
830
829
  }
831
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
+
832
851
  // The CI gate is listed as its own kind because it is the one plan entry whose
833
852
  // absence is a decision rather than a default. Skipped states its reason inline so
834
853
  // a reader can tell "this repo does not use GitHub Actions" from "this repo was
@@ -862,7 +881,7 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
862
881
  // produced by two hand-written renderers.
863
882
  const db = payload.dependabot;
864
883
  if (db && db.action === 'create') {
865
- 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`);
866
885
  lines.push(
867
886
  db.own
868
887
  ? ` watches ${db.ecosystems.join(', ')}; ${CLI} ci --yes refreshes this copy when the template changes`
@@ -911,4 +930,4 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
911
930
  }
912
931
  if (summary.total === 0) lines.push('', 'CONFLICTS none detected');
913
932
  return lines.filter((l) => l !== undefined).join('\n');
914
- }
933
+ }
@@ -20,18 +20,22 @@ 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';
33
+ import { commandRoot } from '../repo-root.mjs';
30
34
 
31
35
  const ISO = (ms) => new Date(ms).toISOString();
32
36
 
33
37
  export async function leaseCommand(args, flags) {
34
- const root = flags.root ?? process.cwd();
38
+ const root = commandRoot(flags);
35
39
  const [sub, ...rest] = args;
36
40
  // The denial tells a blocked session to ask the holder to release the path,
37
41
  // so releasing is a subcommand, not a missing flag (review 0003 Q20).
@@ -59,6 +63,8 @@ export async function leaseCommand(args, flags) {
59
63
 
60
64
  const wanted = [];
61
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();
62
68
  // The claim is stored in the one spelling the hook's comparison will produce:
63
69
  // a claim taken as `src/Keep.mjs` and an edit that spells `src/keep.mjs` must
64
70
  // be the same claim on a case-insensitive volume, so the stored path is
@@ -72,19 +78,36 @@ export async function leaseCommand(args, flags) {
72
78
  if (!seen.has(rel)) {
73
79
  seen.add(rel);
74
80
  wanted.push(rel);
81
+ kinds.set(rel, String(arg).endsWith('/') ? 'dir' : 'path');
75
82
  }
76
83
  }
77
84
 
78
85
  // Older claim files hold the pre-canonical spelling, so a stored path is
79
86
  // canonicalised before it is compared: a claim written before the resolver
80
87
  // existed must still conflict with a claim taken for the same file.
81
- const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
82
- const newClaim = (expiresAt) =>
83
- 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
+ });
84
107
 
85
108
  const mode = writeMode(flags);
86
109
  if (!mode.apply) {
87
- const mine = newClaim(Date.now() + ttl * 1000);
110
+ const mine = newClaim(Date.now() + ttl * 1000, []);
88
111
  const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
89
112
  if (isJson(flags)) {
90
113
  emitJson({ ok: true, command: 'lease', root, dryRun: true, session, ttl, leases: mine, remedy: apply });
@@ -97,7 +120,7 @@ export async function leaseCommand(args, flags) {
97
120
  ...mine.map((l) => `would lease ${l.path} until ${ISO(l.expiresAt)}`),
98
121
  `apply ${apply}`,
99
122
  ];
100
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
123
+ emit(lines.join('\n'));
101
124
  return EXIT.OK;
102
125
  }
103
126
 
@@ -108,12 +131,12 @@ export async function leaseCommand(args, flags) {
108
131
  const read = await readLeases(root);
109
132
  if (read.error !== null) return { error: read.error, path: read.path };
110
133
  const held = activeLeases(read);
111
- const conflicts = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
134
+ const conflicts = held.filter((l) => overlaps(l) && l.session !== session);
112
135
  if (conflicts.length > 0) return { conflicts };
113
136
  // Keep every other live claim, refresh ours, and let an expired one fall out:
114
137
  // the write is also the only sweep the file ever gets.
115
- const others = held.filter((l) => !seen.has(keyOf(l)));
116
- const mine = newClaim(Date.now() + ttl * 1000);
138
+ const others = held.filter((l) => !overlaps(l));
139
+ const mine = newClaim(Date.now() + ttl * 1000, held);
117
140
  const path = await writeLeases(root, [...others, ...mine]);
118
141
  return { mine, path };
119
142
  });
@@ -128,6 +151,15 @@ export async function leaseCommand(args, flags) {
128
151
  );
129
152
  }
130
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
+ });
131
163
 
132
164
  if (isJson(flags)) {
133
165
  emitJson({ ok: true, command: 'lease', root, session, ttl, dryRun: false, leases: mine, file: path });
@@ -139,7 +171,7 @@ export async function leaseCommand(args, flags) {
139
171
  `ttl ${ttl}s`,
140
172
  ...mine.map((l) => `LEASED ${l.path} until ${ISO(l.expiresAt)}`),
141
173
  ];
142
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
174
+ emit(lines.join('\n'));
143
175
  return EXIT.OK;
144
176
  }
145
177
 
@@ -151,6 +183,7 @@ function resolveTargets(root, args, session, verb) {
151
183
  const ignoreCase = coreIgnoreCase(root);
152
184
  const wanted = [];
153
185
  const seen = new Set();
186
+ const kinds = new Map();
154
187
  for (const arg of args) {
155
188
  const rel = canonicalLeasePath(root, arg, { ignoreCase });
156
189
  if (rel === null) {
@@ -159,9 +192,10 @@ function resolveTargets(root, args, session, verb) {
159
192
  if (!seen.has(rel)) {
160
193
  seen.add(rel);
161
194
  wanted.push(rel);
195
+ kinds.set(rel, String(arg).endsWith('/') ? 'dir' : 'path');
162
196
  }
163
197
  }
164
- return { wanted, seen, ignoreCase };
198
+ return { wanted, seen, ignoreCase, kinds };
165
199
  }
166
200
 
167
201
  function claimSession(flags) {
@@ -177,23 +211,28 @@ async function releaseLeases(root, args, flags) {
177
211
  if (sessionError !== undefined) return sessionError;
178
212
  const resolved = resolveTargets(root, args, session, 'release');
179
213
  if (resolved.error !== undefined) return resolved.error;
180
- const { wanted, seen, ignoreCase } = resolved;
181
- 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
+ );
182
221
  const mode = writeMode(flags);
183
222
 
184
223
  const released = await withLeaseLock(root, async () => {
185
224
  const read = await readLeases(root);
186
225
  if (read.error !== null) return { error: read.error, path: read.path };
187
226
  const held = activeLeases(read);
188
- const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
227
+ const foreign = held.filter((l) => overlaps(l) && l.session !== session);
189
228
  if (foreign.length > 0) {
190
229
  return {
191
230
  conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
192
231
  };
193
232
  }
194
- const dropped = held.filter((l) => seen.has(keyOf(l)));
233
+ const dropped = held.filter((l) => overlaps(l));
195
234
  if (!mode.apply) return { preview: dropped };
196
- const kept = held.filter((l) => !seen.has(keyOf(l)));
235
+ const kept = held.filter((l) => !overlaps(l));
197
236
  const path = await writeLeases(root, kept);
198
237
  return { dropped, path };
199
238
  });
@@ -212,9 +251,15 @@ async function releaseLeases(root, args, flags) {
212
251
  `apply run: ${CLI} lease release ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`,
213
252
  ];
214
253
  if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: true, session, leases: released.preview });
215
- else emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
254
+ else emit(lines.join('\n'));
216
255
  return EXIT.OK;
217
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
+ });
218
263
  for (const l of released.dropped) note(`note released ${l.path} (was held by ${l.session})`);
219
264
  if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: false, session, leases: released.dropped });
220
265
  else {
@@ -225,7 +270,6 @@ async function releaseLeases(root, args, flags) {
225
270
  ...released.dropped.map((l) => `RELEASED ${l.path}`),
226
271
  ...(released.dropped.length === 0 ? ['(no active claims on those paths)'] : []),
227
272
  ].join('\n'),
228
- { maxBytes: flags.maxBytes ?? 0 },
229
273
  );
230
274
  }
231
275
  return EXIT.OK;
@@ -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 };
@@ -278,7 +327,6 @@ async function renewLeases(root, args, flags) {
278
327
  ...rows.map((l) => `${mode.apply ? 'RENEWED ' : 'would renew '} ${l.path} until ${ISO(l.expiresAt)}`),
279
328
  ...(rows.length === 0 ? ['(no active claims on those paths)'] : []),
280
329
  ].join('\n'),
281
- { maxBytes: flags.maxBytes ?? 0 },
282
330
  );
283
331
  }
284
332
  return EXIT.OK;
@@ -299,6 +347,6 @@ async function listLeases(root, flags) {
299
347
  ...held.map((l) => `LEASED ${l.path} ${l.session}${l.harness ? `/${l.harness}` : ''} until ${ISO(l.expiresAt)}`),
300
348
  ];
301
349
  if (held.length === 0) lines.push(`(no active leases)`);
302
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
350
+ emit(lines.join('\n'));
303
351
  return EXIT.OK;
304
352
  }