@deftai/directive-core 0.107.0 → 0.109.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 (271) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/cache/operations.js +1 -1
  3. package/dist/check/cached-orchestrator.js +28 -3
  4. package/dist/check/gate-lists.d.ts +14 -0
  5. package/dist/check/gate-lists.js +26 -3
  6. package/dist/check/named-cause.js +18 -3
  7. package/dist/check/session-completed-ac.d.ts +1 -1
  8. package/dist/check/session-completed-ac.js +1 -1
  9. package/dist/codebase/provider.js +7 -1
  10. package/dist/consumer-check-contract/evaluate.d.ts +47 -0
  11. package/dist/consumer-check-contract/evaluate.js +181 -15
  12. package/dist/delivery-attempt/evaluate.d.ts +9 -1
  13. package/dist/delivery-attempt/evaluate.js +69 -0
  14. package/dist/delivery-attempt/handoff.js +1 -1
  15. package/dist/delivery-attempt/index.d.ts +1 -1
  16. package/dist/delivery-attempt/index.js +1 -1
  17. package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
  18. package/dist/deposit/live-procedure-exclusions.js +110 -0
  19. package/dist/deposit/live-procedure-targets.d.ts +45 -0
  20. package/dist/deposit/live-procedure-targets.js +274 -0
  21. package/dist/deposit/python-free.d.ts +6 -0
  22. package/dist/deposit/python-free.js +15 -0
  23. package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
  24. package/dist/deposit/rewrite-deposit-links.js +148 -0
  25. package/dist/deposit/run-stage-content-pack.d.ts +2 -0
  26. package/dist/deposit/run-stage-content-pack.js +3 -0
  27. package/dist/deposit/stage-content-pack.d.ts +17 -0
  28. package/dist/deposit/stage-content-pack.js +91 -0
  29. package/dist/design-critique/citation-grammar.d.ts +60 -0
  30. package/dist/design-critique/citation-grammar.js +303 -0
  31. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  32. package/dist/design-critique/completed-arc-record.js +277 -0
  33. package/dist/freshness/bind.d.ts +32 -3
  34. package/dist/freshness/bind.js +58 -12
  35. package/dist/hooks/classify/host-session-identity.d.ts +89 -0
  36. package/dist/hooks/classify/host-session-identity.js +467 -0
  37. package/dist/hooks/classify/index.d.ts +3 -2
  38. package/dist/hooks/classify/index.js +3 -2
  39. package/dist/hooks/classify/paths.d.ts +9 -0
  40. package/dist/hooks/classify/paths.js +39 -0
  41. package/dist/hooks/classify/stdin.d.ts +5 -0
  42. package/dist/hooks/classify/stdin.js +94 -1
  43. package/dist/hooks/dest-form.d.ts +20 -1
  44. package/dist/hooks/dest-form.js +158 -21
  45. package/dist/hooks/dispatcher.d.ts +58 -3
  46. package/dist/hooks/dispatcher.js +762 -73
  47. package/dist/hooks/fixtures/cases.d.ts +22 -2
  48. package/dist/hooks/fixtures/cases.js +228 -0
  49. package/dist/hooks/git-destructive-log.d.ts +32 -0
  50. package/dist/hooks/git-destructive-log.js +46 -0
  51. package/dist/hooks/index.d.ts +2 -0
  52. package/dist/hooks/index.js +2 -0
  53. package/dist/hooks/scope.js +3 -1
  54. package/dist/hooks/shell-write-targets.d.ts +10 -0
  55. package/dist/hooks/shell-write-targets.js +274 -0
  56. package/dist/hooks/tools.d.ts +20 -2
  57. package/dist/hooks/tools.js +30 -1
  58. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  59. package/dist/init-deposit/agent-hooks.js +8 -1
  60. package/dist/init-deposit/gitignore.d.ts +7 -0
  61. package/dist/init-deposit/gitignore.js +24 -0
  62. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  63. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  64. package/dist/init-deposit/init-deposit.js +3 -0
  65. package/dist/init-deposit/refresh.js +7 -0
  66. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  67. package/dist/init-deposit/runtime-writers.js +33 -0
  68. package/dist/intake/clause-derivation.js +9 -3
  69. package/dist/intake/issue-ingest.d.ts +6 -1
  70. package/dist/intake/issue-ingest.js +83 -4
  71. package/dist/intake/platform-capabilities.d.ts +9 -2
  72. package/dist/intake/platform-capabilities.js +38 -9
  73. package/dist/intake/reconcile-issues.js +13 -13
  74. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  75. package/dist/lifecycle/brief-envelope.js +42 -0
  76. package/dist/lifecycle/index.d.ts +1 -0
  77. package/dist/lifecycle/index.js +1 -0
  78. package/dist/literal-acceptance/evaluate.js +14 -5
  79. package/dist/literal-acceptance/index.d.ts +1 -1
  80. package/dist/literal-acceptance/index.js +1 -1
  81. package/dist/literal-acceptance/run.d.ts +2 -0
  82. package/dist/literal-acceptance/run.js +19 -1
  83. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  84. package/dist/orphan-active/candidate-scope.js +157 -0
  85. package/dist/orphan-active/evaluate.d.ts +52 -0
  86. package/dist/orphan-active/evaluate.js +250 -128
  87. package/dist/orphan-active/index.d.ts +2 -0
  88. package/dist/orphan-active/index.js +2 -0
  89. package/dist/orphan-active/issue-state.d.ts +121 -0
  90. package/dist/orphan-active/issue-state.js +278 -0
  91. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  92. package/dist/platform/cursor-managed-runtime.js +241 -0
  93. package/dist/platform/index.d.ts +1 -0
  94. package/dist/platform/index.js +1 -0
  95. package/dist/platform/platform-capabilities.d.ts +23 -1
  96. package/dist/platform/platform-capabilities.js +55 -15
  97. package/dist/policy/ceremony-dial.js +5 -9
  98. package/dist/policy/host-hooks.js +4 -9
  99. package/dist/policy/merge-approval-head.js +7 -6
  100. package/dist/policy/org-force-on-migration.js +5 -10
  101. package/dist/policy/plan-extensions.d.ts +14 -2
  102. package/dist/policy/plan-extensions.js +24 -3
  103. package/dist/policy/product-signal.js +5 -10
  104. package/dist/policy/require-human-merge.js +5 -9
  105. package/dist/policy/resolve.js +77 -16
  106. package/dist/policy/value-feedback.js +9 -18
  107. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  108. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  109. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  110. package/dist/pr-closeout-attestable/index.js +2 -0
  111. package/dist/pr-closing-keywords/gh.js +32 -9
  112. package/dist/pr-closing-keywords/main.d.ts +1 -0
  113. package/dist/pr-closing-keywords/main.js +68 -3
  114. package/dist/pr-closing-keywords/types.d.ts +2 -0
  115. package/dist/pr-merge-readiness/gh.js +32 -9
  116. package/dist/pr-protected-issues/gh.d.ts +6 -2
  117. package/dist/pr-protected-issues/gh.js +42 -11
  118. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  119. package/dist/pr-wait-mergeable/cascade.js +21 -1
  120. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  121. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  122. package/dist/pr-wait-mergeable/wrappers.js +14 -3
  123. package/dist/preflight/evaluate.d.ts +15 -0
  124. package/dist/preflight/evaluate.js +29 -3
  125. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  126. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  127. package/dist/product-first-done-gate/evaluate.js +23 -9
  128. package/dist/product-first-done-gate/types.js +2 -0
  129. package/dist/release/cli-drift-report.d.ts +68 -0
  130. package/dist/release/cli-drift-report.js +189 -0
  131. package/dist/release/consumer-hard-stops.d.ts +48 -0
  132. package/dist/release/consumer-hard-stops.js +140 -0
  133. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  134. package/dist/release/consumer-readiness-disclosure.js +51 -0
  135. package/dist/release/index.d.ts +3 -0
  136. package/dist/release/index.js +3 -0
  137. package/dist/release/issue-state-fetch.d.ts +5 -3
  138. package/dist/release/issue-state-fetch.js +86 -21
  139. package/dist/release/native-steps.js +1 -0
  140. package/dist/release/pipeline.js +74 -0
  141. package/dist/release/run-consumer-readiness.d.ts +15 -0
  142. package/dist/release/run-consumer-readiness.js +24 -0
  143. package/dist/release/types.d.ts +20 -0
  144. package/dist/render/constants.d.ts +4 -0
  145. package/dist/render/constants.js +11 -8
  146. package/dist/render/export-spec.js +31 -5
  147. package/dist/render/index.d.ts +1 -1
  148. package/dist/render/index.js +1 -1
  149. package/dist/render/prd-render.d.ts +4 -1
  150. package/dist/render/prd-render.js +82 -23
  151. package/dist/render/project-render.js +14 -7
  152. package/dist/render/scope-outlook.d.ts +2 -0
  153. package/dist/render/scope-outlook.js +3 -0
  154. package/dist/render/spec-render.js +2 -2
  155. package/dist/resolution/package-manager.d.ts +33 -4
  156. package/dist/resolution/package-manager.js +210 -9
  157. package/dist/review-monitor/constants.d.ts +7 -0
  158. package/dist/review-monitor/constants.js +7 -0
  159. package/dist/review-monitor/github-lease.d.ts +98 -1
  160. package/dist/review-monitor/github-lease.js +186 -4
  161. package/dist/review-monitor/lease-comment.d.ts +36 -0
  162. package/dist/review-monitor/lease-comment.js +103 -2
  163. package/dist/run-summary/types.d.ts +2 -2
  164. package/dist/scm/build-command.d.ts +2 -2
  165. package/dist/scm/build-command.js +2 -2
  166. package/dist/scm/call-shape.d.ts +25 -0
  167. package/dist/scm/call-shape.js +59 -0
  168. package/dist/scm/call.d.ts +7 -4
  169. package/dist/scm/call.js +48 -9
  170. package/dist/scm/design-critique-chip.d.ts +1 -0
  171. package/dist/scm/design-critique-chip.js +22 -6
  172. package/dist/scm/gh-rest.d.ts +34 -13
  173. package/dist/scm/gh-rest.js +168 -15
  174. package/dist/scm/index.d.ts +2 -0
  175. package/dist/scm/index.js +2 -0
  176. package/dist/scm/readiness.d.ts +2 -0
  177. package/dist/scm/readiness.js +28 -4
  178. package/dist/scm/spawn-status.d.ts +33 -0
  179. package/dist/scm/spawn-status.js +53 -0
  180. package/dist/scope/acceptance-evidence.d.ts +1 -1
  181. package/dist/scope/project-definition-sync.js +14 -7
  182. package/dist/scope/transition.js +4 -14
  183. package/dist/session/ac-pass-banking.d.ts +2 -2
  184. package/dist/session/ac-pass-banking.js +2 -2
  185. package/dist/session/git.d.ts +24 -0
  186. package/dist/session/git.js +138 -2
  187. package/dist/session/host-session-owner.d.ts +53 -0
  188. package/dist/session/host-session-owner.js +89 -0
  189. package/dist/session/index.d.ts +1 -0
  190. package/dist/session/index.js +1 -0
  191. package/dist/session/occupancy.d.ts +259 -13
  192. package/dist/session/occupancy.js +877 -33
  193. package/dist/session/ritual-sentinel.d.ts +5 -0
  194. package/dist/session/ritual-sentinel.js +5 -0
  195. package/dist/session/session-ready.d.ts +5 -1
  196. package/dist/session/session-ready.js +98 -8
  197. package/dist/session/session-start.d.ts +5 -1
  198. package/dist/session/session-start.js +32 -17
  199. package/dist/session/toolchain-preflight.d.ts +13 -3
  200. package/dist/session/toolchain-preflight.js +93 -18
  201. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  202. package/dist/session/verify-ac-session-cache.js +2 -2
  203. package/dist/session/verify-session-ritual.d.ts +11 -0
  204. package/dist/session/verify-session-ritual.js +60 -15
  205. package/dist/slice/constants.d.ts +1 -1
  206. package/dist/spec-authority/constants.d.ts +10 -2
  207. package/dist/spec-authority/constants.js +53 -8
  208. package/dist/spec-authority/resolver.d.ts +3 -0
  209. package/dist/spec-authority/resolver.js +55 -7
  210. package/dist/subprocess/max-buffer.d.ts +14 -0
  211. package/dist/subprocess/max-buffer.js +15 -0
  212. package/dist/swarm/complete-cohort.d.ts +2 -0
  213. package/dist/swarm/complete-cohort.js +21 -13
  214. package/dist/swarm/index.d.ts +1 -0
  215. package/dist/swarm/index.js +1 -0
  216. package/dist/swarm/launch-cli.js +53 -33
  217. package/dist/swarm/launch.d.ts +8 -0
  218. package/dist/swarm/launch.js +63 -43
  219. package/dist/swarm/pre-dispatch-cli.js +2 -1
  220. package/dist/swarm/pre-dispatch.js +10 -1
  221. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  222. package/dist/swarm/subagent-status-dir.js +42 -0
  223. package/dist/swarm/worktrees.d.ts +17 -3
  224. package/dist/swarm/worktrees.js +48 -4
  225. package/dist/triage/evaluate/worktrees.js +153 -6
  226. package/dist/triage/scope/mutations-core.d.ts +2 -6
  227. package/dist/triage/scope/mutations-core.js +23 -55
  228. package/dist/triage/scope-drift/add-ignore.js +5 -39
  229. package/dist/triage/subscribe/index.d.ts +2 -6
  230. package/dist/triage/subscribe/index.js +15 -65
  231. package/dist/triage/welcome/writers.js +15 -74
  232. package/dist/umbrella-current-shape/index.d.ts +51 -3
  233. package/dist/umbrella-current-shape/index.js +106 -18
  234. package/dist/validate-content/deposit-required.d.ts +39 -0
  235. package/dist/validate-content/deposit-required.js +147 -0
  236. package/dist/validate-content/index.d.ts +1 -0
  237. package/dist/validate-content/index.js +1 -0
  238. package/dist/validate-content/validate-links.d.ts +2 -3
  239. package/dist/validate-content/validate-links.js +29 -2
  240. package/dist/vbrief-activate/activate.d.ts +7 -2
  241. package/dist/vbrief-activate/activate.js +26 -13
  242. package/dist/vbrief-build/index.d.ts +2 -1
  243. package/dist/vbrief-build/index.js +2 -1
  244. package/dist/vbrief-build/parity-scenarios.js +5 -5
  245. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  246. package/dist/vbrief-build/project-definition-io.js +357 -31
  247. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  248. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  249. package/dist/vbrief-validate/conformance.d.ts +1 -0
  250. package/dist/vbrief-validate/conformance.js +85 -8
  251. package/dist/vbrief-validate/main.js +2 -0
  252. package/dist/vbrief-validate/precutover.js +5 -10
  253. package/dist/verify-ac/clauses.d.ts +62 -2
  254. package/dist/verify-ac/clauses.js +183 -111
  255. package/dist/verify-ac/evaluate.d.ts +9 -0
  256. package/dist/verify-ac/evaluate.js +32 -9
  257. package/dist/verify-ac/index.d.ts +1 -1
  258. package/dist/verify-ac/index.js +1 -1
  259. package/dist/verify-env/node-runtime.d.ts +8 -4
  260. package/dist/verify-env/node-runtime.js +9 -6
  261. package/dist/verify-env/toolchain-check.d.ts +35 -3
  262. package/dist/verify-env/toolchain-check.js +155 -36
  263. package/dist/verify-source/deposit-closure.d.ts +23 -0
  264. package/dist/verify-source/deposit-closure.js +162 -0
  265. package/dist/verify-source/index.d.ts +2 -0
  266. package/dist/verify-source/index.js +2 -0
  267. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  268. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  269. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  270. package/dist/verify-source/semantic-single-source.js +349 -0
  271. package/package.json +7 -3
@@ -4,14 +4,34 @@
4
4
  * Ritual-state is "this session completed ceremony." Occupancy is "who may
5
5
  * mutate this tree right now." Those lifetimes differ; do not overload
6
6
  * ritual-state.json. Ordinary end is occupancy:release / session:end (#3604).
7
- * Join negotiation (`occupancy:request`) is out of scope.
7
+ *
8
+ * What this boundary is (#3755): a cooperative bearer-id boundary, not a
9
+ * lineage. The lease admits whoever presents an id the record itself names —
10
+ * the occupant's id, or a child id the occupant granted — so possession of a
11
+ * string is the whole credential. Nothing here observes parentage, so a
12
+ * dispatched child is admitted because a grant records it, never because it
13
+ * inherited the holder's rights. Membership is explicit, attributable and
14
+ * expiring (`grantOccupancyMembership`), and it admits writes only: release,
15
+ * steal, heartbeat and cohort close-out stay owner-only, so a grant cannot be
16
+ * spent on the lease itself. Child-initiated join queuing stays out of scope —
17
+ * the owner issues membership; the child does not request it. Ritual state is
18
+ * still single-owner, so a member writes under the occupant's ceremony: the
19
+ * composite hook write gate measures the tree's verified ritual owner against
20
+ * the occupant that issued the grant, not against the writer.
8
21
  *
9
22
  * Concurrency model:
10
23
  * - Assumptions: local filesystem; cooperating processes on one machine.
11
24
  * - Guarantees: mutual exclusion under crash-free operation; detect-and-abort
12
25
  * if the sidecar lock is compromised (fence before rename/unlink).
13
26
  * - Non-goals: network filesystems; Byzantine processes; perfect off-Linux
14
- * PID-reuse detection (hard age cap + fence instead).
27
+ * PID-reuse detection (hard age cap `OCCUPANCY_MAX_LEASE_MS` — plus fence
28
+ * instead).
29
+ * - Residual: the write gate authorizes a write it does not itself perform, so
30
+ * no verdict is atomic with the write. A takeover that publishes after the
31
+ * allow — including one already holding the lock but not yet written — is
32
+ * outside what this gate can see. Closing that would mean denying every
33
+ * owner whose lease file is momentarily locked, which is the load-shedding
34
+ * regression #3736 fixed. The bound is the TTL, not the gate.
15
35
  */
16
36
  import { randomUUID } from "node:crypto";
17
37
  import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
@@ -19,41 +39,244 @@ import { dirname, join, resolve } from "node:path";
19
39
  import { containedRemove, containedWrite } from "../fs/contained-write.js";
20
40
  import { assertWriteTargetSafe } from "../fs/projection-containment.js";
21
41
  import { assertAppendLockOwned, withAppendLock } from "../slice/lock.js";
42
+ import { SWARM_WORKER_ROLES } from "../swarm/routing.js";
43
+ import { ambientHostSessionOwner } from "./host-session-owner.js";
22
44
  import { stableJson } from "./json.js";
23
45
  import { parseTimestamp, timestampIso } from "./time.js";
24
46
  export const OCCUPANCY_SCHEMA_VERSION = 1;
25
47
  export const OCCUPANCY_RELPATH = [".deft", "occupancy.json"];
26
48
  /** Crash recovery TTL: 20 minutes without heartbeat (15–30 window). */
27
49
  export const OCCUPANCY_TTL_MS = 20 * 60 * 1000;
50
+ /**
51
+ * Owner-allow re-stamp floor (#3599). The write gate runs on every gated write,
52
+ * so refreshing unconditionally would rewrite the lease file per keystroke-scale
53
+ * event. A quarter of the TTL bounds that without shortening the safe window:
54
+ * a write at any age past this floor resets the clock, so an owner that writes
55
+ * at least once per TTL never expires.
56
+ */
57
+ export const OCCUPANCY_REFRESH_AFTER_MS = OCCUPANCY_TTL_MS / 4;
58
+ /** Owner-allow staleness warning floor: three quarters of the TTL (#3599). */
59
+ export const OCCUPANCY_STALE_WARN_MS = (OCCUPANCY_TTL_MS * 3) / 4;
60
+ /**
61
+ * Absolute lease age cap, keyed on `claimedAt` and independent of refresh
62
+ * (#3599). Occupancy admits whoever presents the occupant's session id, so
63
+ * "the owner is still writing" only proves that some process holds that
64
+ * string. Without a bound on claim age, refresh would turn the heartbeat TTL —
65
+ * the sole mechanism that reclaims a worktree from a dead session — into
66
+ * something a writer can extend forever.
67
+ *
68
+ * Thirty-six TTLs is twelve hours, sized by the stalled owner rather than the
69
+ * busy one. Refresh keys on writes, so an agent that finishes overnight and
70
+ * waits for its operator is alive, correct, and silent — it stops refreshing
71
+ * while staying entirely legitimate. Twelve hours spans a 23:00 dispatch to a
72
+ * 09:00 handoff and still bounds reclaim well inside a day. Reaching the cap
73
+ * costs the owner one re-claim, not its work.
74
+ *
75
+ * Known limitation: a pure time cap cannot tell a stalled-but-live owner from a
76
+ * dead one, because the only liveness signal on this path is a write. If that
77
+ * ambiguity starts to bite, the answer is a liveness signal that needs no write
78
+ * — an explicit parked state, or refresh on non-write activity — not a larger
79
+ * number here.
80
+ */
81
+ export const OCCUPANCY_MAX_LEASE_MS = OCCUPANCY_TTL_MS * 36;
28
82
  export const OCCUPANCY_INTENTS = ["mutation", "swarm", "review"];
29
83
  export const OCCUPANCY_JOIN_PROTOCOLS = ["none", "heartbeat-file", "parent-message"];
84
+ /**
85
+ * Default life of a grant (#3755), sized by one dispatched unit of work:
86
+ * implement, open the PR, run the review cycle. Four hours is a third of the
87
+ * absolute lease cap, so a grant that outlives its child still dies well inside
88
+ * the lease that issued it, and re-granting costs the owner one command.
89
+ */
90
+ export const OCCUPANCY_GRANT_TTL_MS = 4 * 60 * 60 * 1000;
91
+ /**
92
+ * Grants a single lease may carry (#3755). Bounded because the list is rewritten
93
+ * into the lease file on every touch and the topology it serves is a nuclear
94
+ * family (#3155), not a mesh — a lease needing more than this is a design
95
+ * problem, not a capacity one.
96
+ */
97
+ export const OCCUPANCY_MAX_GRANTS = 32;
30
98
  export function occupancyPath(projectRoot) {
31
99
  return join(resolve(projectRoot), ...OCCUPANCY_RELPATH);
32
100
  }
33
101
  export function heartbeatAgeSeconds(record, now = new Date()) {
34
102
  return Math.max(0, Math.round((now.getTime() - record.heartbeatAt.getTime()) / 1000));
35
103
  }
36
- export function isOccupancyExpired(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
37
- return now.getTime() - record.heartbeatAt.getTime() > ttlMs;
104
+ /** Age of the occupant's last recorded product write, or null when none (#3599). */
105
+ export function lastWriteAgeSeconds(record, now = new Date()) {
106
+ if (record.lastWriteAt === null)
107
+ return null;
108
+ return Math.max(0, Math.round((now.getTime() - record.lastWriteAt.getTime()) / 1000));
109
+ }
110
+ /**
111
+ * Human phrase for how recently the occupant wrote (#3599). Heartbeat age alone
112
+ * cannot distinguish an occupant mid-edit from one that merely claimed and left.
113
+ */
114
+ export function formatLastWritePhrase(record, now = new Date()) {
115
+ const age = lastWriteAgeSeconds(record, now);
116
+ return age === null ? "no recorded write" : `last write ${age}s ago`;
117
+ }
118
+ /** Age of the lease itself, measured from the claim that opened it (#3599). */
119
+ export function leaseAgeSeconds(record, now = new Date()) {
120
+ return Math.max(0, Math.round((now.getTime() - record.claimedAt.getTime()) / 1000));
121
+ }
122
+ export function occupancyLiveness(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
123
+ // The cap is checked first because it is the answer that survives (#3599).
124
+ // A lease can be both, and then the stale reading is actively misleading:
125
+ // it sends the holder to refresh, which a capped lease cannot accept. Order
126
+ // also decides the write gate — the capped-holder refusal below keys on this
127
+ // value, so reading a doubly-dead lease as merely stale would give the more
128
+ // dead lease the more permissive answer.
129
+ if (now.getTime() - record.claimedAt.getTime() > maxLeaseMs)
130
+ return "age-capped";
131
+ if (now.getTime() - record.heartbeatAt.getTime() > ttlMs)
132
+ return "heartbeat-stale";
133
+ return "live";
134
+ }
135
+ export function isOccupancyExpired(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
136
+ return occupancyLiveness(record, now, ttlMs, maxLeaseMs) !== "live";
137
+ }
138
+ /** Grants that still admit somebody (#3755). An expired grant admits nobody. */
139
+ export function liveOccupancyGrants(record, now = new Date()) {
140
+ return record.grants.filter((grant) => grant.expiresAt.getTime() > now.getTime());
141
+ }
142
+ /**
143
+ * The grant admitting `sessionId`, or null (#3755). Expiry is refused here, on
144
+ * read, rather than trusted to a sweep: nothing guarantees a lease is ever
145
+ * touched again after the grant is written, so a grant that outlived its clock
146
+ * must stop admitting the moment it is read, not the next time it is rewritten.
147
+ */
148
+ export function occupancyGrantFor(record, sessionId, now = new Date()) {
149
+ const presented = sessionId.trim();
150
+ if (presented.length === 0)
151
+ return null;
152
+ // The owner holds the lease outright; a grant naming it would add nothing.
153
+ if (presented === record.sessionId)
154
+ return null;
155
+ return (liveOccupancyGrants(record, now).find((grant) => grant.childSessionId === presented) ?? null);
156
+ }
157
+ /**
158
+ * What the presented id is to this lease (#3755). Deliberately not a liveness
159
+ * question: it answers who, and callers pair it with `occupancyLiveness` to
160
+ * answer whether the lease is still worth anything.
161
+ */
162
+ export function occupancyAdmission(record, sessionId, now = new Date()) {
163
+ const presented = sessionId.trim();
164
+ if (presented.length === 0)
165
+ return "stranger";
166
+ if (presented === record.sessionId)
167
+ return "owner";
168
+ return occupancyGrantFor(record, presented, now) === null ? "stranger" : "member";
169
+ }
170
+ // Session ids reach remediation text from operator flags, host environments and
171
+ // whatever a peer wrote into the lease, so a value can carry whitespace or shell
172
+ // metacharacters. Only a value a shell would take as one bare token is inlined
173
+ // into a printed command; anything else keeps its placeholder, because the right
174
+ // quoting differs per shell and a mis-parsed copyable command is worse than one
175
+ // the reader has to fill in. The id itself is still named in the prose above.
176
+ // A leading dash is excluded as well: every CLI parser here reads such a value
177
+ // as another option, so `--occupant --weird-id` fails argument parsing even
178
+ // though the shell itself would have passed the token through intact.
179
+ const SHELL_SAFE_SESSION_ID = /^(?!-)[A-Za-z0-9_.:+=,/-]+$/;
180
+ function commandSessionId(sessionId, placeholder) {
181
+ return SHELL_SAFE_SESSION_ID.test(sessionId) ? sessionId : placeholder;
38
182
  }
39
183
  function occupancyClockLine(record) {
40
- return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}`;
184
+ const lastWrite = record.lastWriteAt === null ? "" : ` last_write_at=${timestampIso(record.lastWriteAt)}`;
185
+ return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}${lastWrite}`;
186
+ }
187
+ /**
188
+ * Warn the holder that its own lease is inside the staleness window (#3599).
189
+ * Without this the owner learns it went stale only when a peer steals the lease.
190
+ */
191
+ export function formatOccupancyStaleWarning(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
192
+ const age = heartbeatAgeSeconds(record, now);
193
+ return (`Occupancy lease for session ${record.sessionId} has not beaten for ${age}s of its ` +
194
+ `${Math.round(ttlMs / 1000)}s window; another session may read it as abandoned. ` +
195
+ `Refresh it with \`deft occupancy:heartbeat --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\`.`);
196
+ }
197
+ /**
198
+ * Tell the holder its lease aged out of the absolute cap (#3599). Distinct
199
+ * remediation from a stale heartbeat: beating harder cannot help, because the
200
+ * lease is gone rather than merely quiet, so the answer is to re-claim.
201
+ */
202
+ export function formatOccupancyAgeCapRemediation(record, now = new Date(), maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
203
+ const hours = Math.round(maxLeaseMs / (60 * 60 * 1000));
204
+ return (`Occupancy lease for session ${record.sessionId} passed its ${hours}h absolute age cap ` +
205
+ `(claimed ${leaseAgeSeconds(record, now)}s ago, ${occupancyClockLine(record)}), so this ` +
206
+ "worktree is no longer held and a peer may claim it at any moment. Heartbeats cannot " +
207
+ "extend a capped lease — re-claim the worktree with " +
208
+ `\`deft session:start --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\` before writing again.`);
41
209
  }
42
- export function formatOccupancyRemediation(record, now = new Date()) {
210
+ /**
211
+ * Tell a refused caller who holds the lease and what it can actually run.
212
+ *
213
+ * `presented` is the id the refused caller offered (#3873). Without it the
214
+ * message can only print `<your-session-id>` placeholders, which is fine for a
215
+ * CLI caller that passed its own `--session-id` and useless to a hook process,
216
+ * which does not know what identity it presented. Passing it also keeps the
217
+ * message honest when there is none: a grant cannot name an empty child --
218
+ * `occupancy:grant --child-session-id=` is refused at parse and at membership --
219
+ * so that remediation is not printed to a caller who could never run it.
220
+ */
221
+ export function formatOccupancyRemediation(record, now = new Date(), presented) {
43
222
  const age = heartbeatAgeSeconds(record, now);
44
- return (`Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
45
- `${occupancyClockLine(record)}).\n` +
223
+ const header = `Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
224
+ `${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n`;
225
+ const tail = "\nThe occupant may release (`occupancy:release` / `session:end`).";
226
+ if (presented === undefined) {
227
+ return (`${header}Stay read-only (\`session:start --read-only\`), use another worktree,\n` +
228
+ "ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
229
+ "--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
230
+ `(\`session:start --steal --confirm --occupant <reported-session-id> --session-id=<your-session-id>\`).${tail}`);
231
+ }
232
+ const actor = presented.trim();
233
+ const occupantArg = commandSessionId(record.sessionId, "<reported-session-id>");
234
+ if (actor.length === 0) {
235
+ return (`${header}This process presented no session identity, so a write grant cannot name it ` +
236
+ "and an owner transition would not be recognised on its next write.\n" +
237
+ "Stay read-only (`session:start --read-only`), use another worktree, or ask the occupant " +
238
+ `to release the lease (\`occupancy:release --session-id=${occupantArg}\` / \`session:end\`).${tail}`);
239
+ }
240
+ const actorArg = commandSessionId(actor, "<your-session-id>");
241
+ return (`${header}This process presented session ${actor}, which neither holds that lease nor has a ` +
242
+ "write grant on it.\n" +
46
243
  "Stay read-only (`session:start --read-only`), use another worktree,\n" +
47
- "queue a join (`occupancy:request`), or steal (`occupancy:steal --confirm`).\n" +
48
- "The occupant may release (`occupancy:release` / `session:end`).");
244
+ `ask the occupant for a write grant (\`occupancy:grant --child-session-id=${actorArg} ` +
245
+ "--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
246
+ `(\`session:start --steal --confirm --occupant ${occupantArg} --session-id=${actorArg}\`).${tail}`);
49
247
  }
248
+ /**
249
+ * Refuse an administrative verb to a granted child (#3755). Named apart from
250
+ * the stranger refusal because the answer differs: this caller is admitted, and
251
+ * telling it to steal or wait would send it to take the very lease its grant
252
+ * derives from. A grant admits writes; the lease has one owner.
253
+ */
254
+ export function formatOccupancyMemberAdministrationRefusal(record, grant, verb) {
255
+ return (`${verb} is owner-only. Session ${grant.childSessionId} holds a write grant on this lease ` +
256
+ `(role=${grant.role}, expires ${timestampIso(grant.expiresAt)}), not the lease itself, and a ` +
257
+ "grant never escalates into administration.\n" +
258
+ `Ask the occupant (session ${record.sessionId}) to run it, or wait for the grant to expire.`);
259
+ }
260
+ /**
261
+ * The owner a claim is made under: an explicit id, then `DEFT_SESSION_ID`, then
262
+ * the id the running host published, then a mint.
263
+ *
264
+ * The host step is what makes an identified host's claim reachable (#3873).
265
+ * Minting instead binds the lease to an id no later hook process can present,
266
+ * so the session that claimed the worktree is refused by its own lease. The
267
+ * mint stays as the last resort for hosts that publish nothing.
268
+ */
50
269
  export function resolveOccupancySessionId(input = {}) {
51
270
  const explicit = input.sessionId?.trim();
52
271
  if (explicit)
53
272
  return explicit;
54
- const envId = (input.env ?? process.env).DEFT_SESSION_ID?.trim();
273
+ const env = input.env ?? process.env;
274
+ const envId = env.DEFT_SESSION_ID?.trim();
55
275
  if (envId)
56
276
  return envId;
277
+ const hostOwner = ambientHostSessionOwner(env);
278
+ if (hostOwner !== null)
279
+ return hostOwner;
57
280
  return (input.newSessionId ?? randomUUID)();
58
281
  }
59
282
  export function readOccupancy(projectRoot) {
@@ -74,9 +297,9 @@ export function readOccupancy(projectRoot) {
74
297
  }
75
298
  return parseOccupancy(payload, resolve(projectRoot));
76
299
  }
77
- export function liveOccupant(projectRoot, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
300
+ export function liveOccupant(projectRoot, now = new Date(), ttlMs = OCCUPANCY_TTL_MS, maxLeaseMs = OCCUPANCY_MAX_LEASE_MS) {
78
301
  const record = readOccupancy(projectRoot);
79
- if (record === null || isOccupancyExpired(record, now, ttlMs))
302
+ if (record === null || isOccupancyExpired(record, now, ttlMs, maxLeaseMs))
80
303
  return null;
81
304
  return record;
82
305
  }
@@ -130,10 +353,15 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
130
353
  intent: input.intent ?? liveLocked?.intent ?? "mutation",
131
354
  claimedAt: liveLocked?.claimedAt ?? now,
132
355
  heartbeatAt: now,
356
+ lastWriteAt: input.markWrite === true ? now : (liveLocked?.lastWriteAt ?? null),
133
357
  host: input.host ?? liveLocked?.host ?? occupancyHost(input.env),
134
358
  address: input.address ?? liveLocked?.address ?? occupancyAddress(input.env),
135
359
  retainCapable: input.retainCapable ?? liveLocked?.retainCapable ?? false,
136
360
  joinProtocol: input.joinProtocol ?? liveLocked?.joinProtocol ?? "none",
361
+ // Grants belong to the lease that issued them (#3755): the same owner
362
+ // keeps its members across a heartbeat, and a fresh claim over expired
363
+ // residue starts with none.
364
+ grants: liveLocked === null ? [] : liveOccupancyGrants(liveLocked, now),
137
365
  }, fence);
138
366
  const action = liveLocked !== null ? "heartbeat" : "claimed";
139
367
  return {
@@ -151,13 +379,20 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
151
379
  export function stealOccupancy(projectRoot, input = {}) {
152
380
  const now = input.now ?? new Date();
153
381
  const path = occupancyPath(projectRoot);
382
+ const incoming = resolveOccupancySessionId(input);
154
383
  if (input.confirm !== true) {
384
+ const current = readOccupancy(projectRoot);
385
+ // Show the occupant's write recency before the steal, not only after it
386
+ // (#3599): heartbeat age alone hides an occupant that is mid-edit.
387
+ const occupantDetail = current !== null && !isOccupancyExpired(current, now)
388
+ ? `\n${formatOccupancyRemediation(current, now)}`
389
+ : "";
155
390
  return {
156
391
  action: "denied",
157
- sessionId: resolveOccupancySessionId(input),
158
- record: readOccupancy(projectRoot),
392
+ sessionId: incoming,
393
+ record: current,
159
394
  path,
160
- message: "occupancy:steal requires --confirm after naming the occupant.",
395
+ message: `occupancy:steal requires --confirm after naming the occupant.${occupantDetail}`,
161
396
  code: 2,
162
397
  };
163
398
  }
@@ -165,7 +400,7 @@ export function stealOccupancy(projectRoot, input = {}) {
165
400
  if (named.length === 0) {
166
401
  return {
167
402
  action: "denied",
168
- sessionId: resolveOccupancySessionId(input),
403
+ sessionId: incoming,
169
404
  record: readOccupancy(projectRoot),
170
405
  path,
171
406
  message: "occupancy:steal requires --occupant <session-id> to name the current occupant.",
@@ -174,10 +409,26 @@ export function stealOccupancy(projectRoot, input = {}) {
174
409
  }
175
410
  const existing = readOccupancy(projectRoot);
176
411
  const live = existing !== null && !isOccupancyExpired(existing, now) ? existing : null;
412
+ // A grant admits writes, never the lease itself (#3755). Letting a child steal
413
+ // from the owner that admitted it would turn delegated write access into a
414
+ // path to replace the delegator — the escalation explicit membership exists to
415
+ // remove. Cooperative, like the rest of this file: a caller can present some
416
+ // other id, and then it is a stranger doing a confirmed steal, on the record.
417
+ const stealerGrant = live === null ? null : occupancyGrantFor(live, incoming, now);
418
+ if (live !== null && stealerGrant !== null) {
419
+ return {
420
+ action: "denied",
421
+ sessionId: incoming,
422
+ record: live,
423
+ path,
424
+ message: formatOccupancyMemberAdministrationRefusal(live, stealerGrant, "occupancy:steal"),
425
+ code: 1,
426
+ };
427
+ }
177
428
  if (live !== null && live.sessionId !== named) {
178
429
  return {
179
430
  action: "denied",
180
- sessionId: resolveOccupancySessionId(input),
431
+ sessionId: incoming,
181
432
  record: live,
182
433
  path,
183
434
  message: `occupancy:steal named occupant ${named} does not match live occupant ${live.sessionId}.\n` +
@@ -185,13 +436,36 @@ export function stealOccupancy(projectRoot, input = {}) {
185
436
  code: 1,
186
437
  };
187
438
  }
439
+ if (input.write === false) {
440
+ return {
441
+ action: "stolen",
442
+ sessionId: incoming,
443
+ record: live,
444
+ path,
445
+ message: live === null
446
+ ? `occupancy steal preview: writer would be session ${incoming}`
447
+ : `occupancy steal preview: ${live.sessionId} would be replaced by session ${incoming}`,
448
+ code: 0,
449
+ };
450
+ }
188
451
  return withOccupancyLock(projectRoot, (fence) => {
189
452
  const existingLocked = readOccupancy(projectRoot);
190
453
  const liveLocked = existingLocked !== null && !isOccupancyExpired(existingLocked, now) ? existingLocked : null;
454
+ const lockedStealerGrant = liveLocked === null ? null : occupancyGrantFor(liveLocked, incoming, now);
455
+ if (liveLocked !== null && lockedStealerGrant !== null) {
456
+ return {
457
+ action: "denied",
458
+ sessionId: incoming,
459
+ record: liveLocked,
460
+ path,
461
+ message: formatOccupancyMemberAdministrationRefusal(liveLocked, lockedStealerGrant, "occupancy:steal"),
462
+ code: 1,
463
+ };
464
+ }
191
465
  if (liveLocked !== null && liveLocked.sessionId !== named) {
192
466
  return {
193
467
  action: "denied",
194
- sessionId: resolveOccupancySessionId(input),
468
+ sessionId: incoming,
195
469
  record: liveLocked,
196
470
  path,
197
471
  message: `occupancy:steal named occupant ${named} does not match live occupant ${liveLocked.sessionId}.\n` +
@@ -199,25 +473,35 @@ export function stealOccupancy(projectRoot, input = {}) {
199
473
  code: 1,
200
474
  };
201
475
  }
202
- const incoming = resolveOccupancySessionId(input);
203
- const priorClock = existingLocked !== null ? ` (${occupancyClockLine(existingLocked)})` : "";
476
+ const priorClock = existingLocked !== null
477
+ ? ` (${formatLastWritePhrase(existingLocked, now)}, ${occupancyClockLine(existingLocked)})`
478
+ : "";
204
479
  const record = writeOccupancyRecord(projectRoot, {
205
480
  sessionId: incoming,
206
481
  worktreePath: resolve(projectRoot),
207
482
  intent: input.intent ?? "mutation",
208
483
  claimedAt: now,
209
484
  heartbeatAt: now,
485
+ lastWriteAt: null,
210
486
  host: input.host ?? occupancyHost(input.env),
211
487
  address: input.address ?? occupancyAddress(input.env),
212
488
  retainCapable: input.retainCapable ?? false,
213
489
  joinProtocol: input.joinProtocol ?? "none",
490
+ // A steal replaces the owner, and grants are that owner's word about
491
+ // who may write. The new owner never said it, so it does not inherit
492
+ // the members either (#3755).
493
+ grants: [],
214
494
  }, fence);
215
495
  return {
216
496
  action: "stolen",
217
497
  sessionId: record.sessionId,
218
498
  record,
219
499
  path,
220
- message: `occupancy stolen from ${named}${priorClock}; writer is now session ${record.sessionId}`,
500
+ message: `occupancy stolen from ${named}${priorClock}; writer is now session ${record.sessionId}. ` +
501
+ "This command changes the lease only; direct writes remain denied unless ritual state already names the same owner. " +
502
+ "If the owners differ, run `deft session:start --rearm --session-id=<same-session-id>` " +
503
+ "when re-arm is eligible; otherwise run `deft session:start --session-id=<same-session-id>` " +
504
+ "for a cold ceremony, using the writer ID above.",
221
505
  code: 0,
222
506
  };
223
507
  }, input.lockDeps);
@@ -246,7 +530,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
246
530
  sessionId: caller,
247
531
  record: existing,
248
532
  path,
249
- message: formatOccupancyRemediation(existing, now),
533
+ message: membershipOwnerDenial(existing, caller, now, "occupancy:release"),
250
534
  code: 1,
251
535
  };
252
536
  }
@@ -271,7 +555,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
271
555
  sessionId: caller,
272
556
  record: still,
273
557
  path,
274
- message: formatOccupancyRemediation(still, now),
558
+ message: membershipOwnerDenial(still, caller, now, "occupancy:release"),
275
559
  code: 1,
276
560
  };
277
561
  }
@@ -286,19 +570,506 @@ export function releaseOccupancy(projectRoot, input = {}) {
286
570
  };
287
571
  }, input.lockDeps);
288
572
  }
573
+ function membershipOwnerDenial(live, caller, now, verb) {
574
+ const grant = occupancyGrantFor(live, caller, now);
575
+ return grant === null
576
+ ? formatOccupancyRemediation(live, now)
577
+ : formatOccupancyMemberAdministrationRefusal(live, grant, verb);
578
+ }
579
+ /**
580
+ * Admit a dispatched child to this lease for writes (#3755).
581
+ *
582
+ * Owner-only, and the record is the point: a child that writes here is named
583
+ * on the lease it writes under, so an unexpected edit resolves to a session, a
584
+ * role and a tree instead of to "somebody who had the string". The grant cannot
585
+ * outlive the lease that issued it — expiry is clamped to the absolute lease
586
+ * cap — and it buys writes alone.
587
+ */
588
+ export function grantOccupancyMembership(projectRoot, input = {}) {
589
+ const now = input.now ?? new Date();
590
+ const path = occupancyPath(projectRoot);
591
+ const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
592
+ const child = input.childSessionId?.trim() ?? "";
593
+ const role = input.role?.trim() ?? "";
594
+ if (owner.length === 0) {
595
+ return {
596
+ action: "denied",
597
+ sessionId: "",
598
+ record: readOccupancy(projectRoot),
599
+ path,
600
+ message: "occupancy:grant needs the owner id: pass --session-id <your-session-id> or set " +
601
+ "DEFT_SESSION_ID. Only the occupant may admit a child to its lease.",
602
+ code: 2,
603
+ };
604
+ }
605
+ if (child.length === 0) {
606
+ return {
607
+ action: "denied",
608
+ sessionId: owner,
609
+ record: readOccupancy(projectRoot),
610
+ path,
611
+ message: "occupancy:grant needs --child-session-id <session-id>: the id the dispatched child " +
612
+ "will present on its own writes.",
613
+ code: 2,
614
+ };
615
+ }
616
+ if (child === owner) {
617
+ return {
618
+ action: "denied",
619
+ sessionId: owner,
620
+ record: readOccupancy(projectRoot),
621
+ path,
622
+ message: "occupancy:grant refuses a self-grant: the lease already admits its owner, so a grant " +
623
+ "naming the same id records nothing and would only blur who wrote what.",
624
+ code: 2,
625
+ };
626
+ }
627
+ if (!SWARM_WORKER_ROLES.includes(role)) {
628
+ return {
629
+ action: "denied",
630
+ sessionId: owner,
631
+ record: readOccupancy(projectRoot),
632
+ path,
633
+ message: `occupancy:grant needs --role from ${SWARM_WORKER_ROLES.join(", ")}. The role is what the ` +
634
+ "grant is for; an unnamed role makes the record unreadable after the fact.",
635
+ code: 2,
636
+ };
637
+ }
638
+ const ttlMs = input.ttlMs ?? OCCUPANCY_GRANT_TTL_MS;
639
+ return withOccupancyLock(projectRoot, (fence) => {
640
+ const current = readOccupancy(projectRoot);
641
+ const live = current !== null && !isOccupancyExpired(current, now) ? current : null;
642
+ if (live === null) {
643
+ const capped = current !== null &&
644
+ current.sessionId === owner &&
645
+ occupancyLiveness(current, now) === "age-capped";
646
+ return {
647
+ action: "denied",
648
+ sessionId: owner,
649
+ record: capped ? current : null,
650
+ path,
651
+ message: capped && current !== null
652
+ ? formatOccupancyAgeCapRemediation(current, now)
653
+ : "occupancy:grant found no live lease to grant on. A grant is derived authority, " +
654
+ `so claim the worktree first with \`deft session:start --session-id=${commandSessionId(owner, "<your-session-id>")}\`.`,
655
+ code: 1,
656
+ };
657
+ }
658
+ if (live.sessionId !== owner) {
659
+ return {
660
+ action: "denied",
661
+ sessionId: owner,
662
+ record: live,
663
+ path,
664
+ message: membershipOwnerDenial(live, owner, now, "occupancy:grant"),
665
+ code: 1,
666
+ };
667
+ }
668
+ const requested = input.expiresAt ?? new Date(now.getTime() + ttlMs);
669
+ if (requested.getTime() <= now.getTime()) {
670
+ return {
671
+ action: "denied",
672
+ sessionId: owner,
673
+ record: live,
674
+ path,
675
+ message: "occupancy:grant refuses an expiry that is already past: a grant that admits nobody " +
676
+ "is indistinguishable from no grant, and recording one would only mislead.",
677
+ code: 2,
678
+ };
679
+ }
680
+ // A grant is derived authority, so it dies with the lease it derives from
681
+ // (#3755). Without this clamp a chain of grants would outlast the absolute
682
+ // cap that keeps a worktree reclaimable.
683
+ const leaseEnds = live.claimedAt.getTime() + OCCUPANCY_MAX_LEASE_MS;
684
+ const expiresAt = requested.getTime() > leaseEnds ? new Date(leaseEnds) : requested;
685
+ const clamped = expiresAt.getTime() !== requested.getTime();
686
+ const kept = liveOccupancyGrants(live, now).filter((existing) => existing.childSessionId !== child);
687
+ if (kept.length >= OCCUPANCY_MAX_GRANTS) {
688
+ return {
689
+ action: "denied",
690
+ sessionId: owner,
691
+ record: live,
692
+ path,
693
+ message: `occupancy:grant refuses a ${OCCUPANCY_MAX_GRANTS + 1}th live grant on one lease. ` +
694
+ "Revoke a finished child (`occupancy:grant --revoke --child-session-id=<id>`) or let " +
695
+ "its grant expire.",
696
+ code: 1,
697
+ };
698
+ }
699
+ const grant = {
700
+ ownerSessionId: owner,
701
+ childSessionId: child,
702
+ worktreePath: input.worktreePath?.trim() || live.worktreePath,
703
+ role: role,
704
+ expiresAt,
705
+ host: input.host?.trim() || "none",
706
+ address: input.address?.trim() || "none",
707
+ joinProtocol: input.joinProtocol ?? "parent-message",
708
+ };
709
+ const record = writeOccupancyRecord(projectRoot, {
710
+ sessionId: live.sessionId,
711
+ worktreePath: live.worktreePath,
712
+ intent: live.intent,
713
+ claimedAt: live.claimedAt,
714
+ // Issuing a grant is the owner touching its own lease, which is what
715
+ // heartbeat_at records; claimed_at is untouched, so the cap holds.
716
+ heartbeatAt: now,
717
+ lastWriteAt: live.lastWriteAt,
718
+ host: live.host,
719
+ address: live.address,
720
+ retainCapable: live.retainCapable,
721
+ joinProtocol: live.joinProtocol,
722
+ grants: [...kept, grant],
723
+ }, fence);
724
+ return {
725
+ action: "granted",
726
+ sessionId: owner,
727
+ record,
728
+ path,
729
+ message: `occupancy grant issued to session ${child} (role=${grant.role}, ` +
730
+ `worktree=${grant.worktreePath}, expires ${timestampIso(expiresAt)}` +
731
+ `${clamped ? ", clamped to this lease's absolute age cap" : ""}). ` +
732
+ "It admits writes only; release, steal, heartbeat and cohort close-out stay yours.",
733
+ code: 0,
734
+ };
735
+ }, input.lockDeps);
736
+ }
737
+ /**
738
+ * Withdraw a child's grant early (#3755). Expiry already bounds every grant, so
739
+ * this exists for the case expiry cannot serve: the child finished, or should
740
+ * never have been admitted, and the owner wants that true now.
741
+ */
742
+ export function revokeOccupancyMembership(projectRoot, input = {}) {
743
+ const now = input.now ?? new Date();
744
+ const path = occupancyPath(projectRoot);
745
+ const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
746
+ const child = input.childSessionId?.trim() ?? "";
747
+ if (owner.length === 0 || child.length === 0) {
748
+ return {
749
+ action: "denied",
750
+ sessionId: owner,
751
+ record: readOccupancy(projectRoot),
752
+ path,
753
+ message: "occupancy:grant --revoke needs both the owner id (--session-id or DEFT_SESSION_ID) and " +
754
+ "--child-session-id <session-id>.",
755
+ code: 2,
756
+ };
757
+ }
758
+ return withOccupancyLock(projectRoot, (fence) => {
759
+ const current = readOccupancy(projectRoot);
760
+ const live = current !== null && !isOccupancyExpired(current, now) ? current : null;
761
+ if (live === null) {
762
+ return {
763
+ action: "revoked",
764
+ sessionId: owner,
765
+ record: null,
766
+ path,
767
+ message: "occupancy:grant --revoke found no live lease, so no grant survives it either: " +
768
+ "grants die with the lease that issued them.",
769
+ code: 0,
770
+ };
771
+ }
772
+ if (live.sessionId !== owner) {
773
+ return {
774
+ action: "denied",
775
+ sessionId: owner,
776
+ record: live,
777
+ path,
778
+ message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke"),
779
+ code: 1,
780
+ };
781
+ }
782
+ const before = liveOccupancyGrants(live, now);
783
+ const remaining = before.filter((existing) => existing.childSessionId !== child);
784
+ if (remaining.length === before.length) {
785
+ return {
786
+ action: "revoked",
787
+ sessionId: owner,
788
+ record: live,
789
+ path,
790
+ message: `occupancy has no live grant for session ${child}; nothing to revoke.`,
791
+ code: 0,
792
+ };
793
+ }
794
+ const record = writeOccupancyRecord(projectRoot, {
795
+ sessionId: live.sessionId,
796
+ worktreePath: live.worktreePath,
797
+ intent: live.intent,
798
+ claimedAt: live.claimedAt,
799
+ heartbeatAt: now,
800
+ lastWriteAt: live.lastWriteAt,
801
+ host: live.host,
802
+ address: live.address,
803
+ retainCapable: live.retainCapable,
804
+ joinProtocol: live.joinProtocol,
805
+ grants: remaining,
806
+ }, fence);
807
+ return {
808
+ action: "revoked",
809
+ sessionId: owner,
810
+ record,
811
+ path,
812
+ message: `occupancy grant revoked for session ${child}; its writes are refused from now on.`,
813
+ code: 0,
814
+ };
815
+ }, input.lockDeps);
816
+ }
817
+ /**
818
+ * Decide whether the presented session may write, and — on the owner-allow
819
+ * path — keep the owner's lease alive (#3599).
820
+ *
821
+ * Before this, the gate was read-only on owner-allow, so the one event that
822
+ * proves the owner is alive did not extend its lease: the live window was
823
+ * twenty minutes from claim, once, regardless of how long the session worked.
824
+ */
289
825
  export function evaluateOccupancyWriteGate(projectRoot, input = {}) {
290
826
  const now = input.now ?? new Date();
291
- const live = liveOccupant(projectRoot, now);
292
- if (live === null)
293
- return { allow: true, message: null, occupant: null };
294
827
  const incoming = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
295
- if (incoming.length > 0 && incoming === live.sessionId) {
296
- return { allow: true, message: null, occupant: live };
828
+ const record = readOccupancy(projectRoot);
829
+ const liveness = record === null ? null : occupancyLiveness(record, now);
830
+ const admission = record === null ? "stranger" : occupancyAdmission(record, incoming, now);
831
+ if (record !== null && liveness === "age-capped" && admission !== "stranger") {
832
+ // Refuse the capped holder rather than warn it (#3599). Its tree is now
833
+ // unheld, so allowing the write would let the very bearer the cap exists to
834
+ // bound keep mutating a worktree a peer may claim between this allow and
835
+ // the write itself. On gated writes the identity comes from the host
836
+ // payload, so the holder cannot present a stranger's id to dodge this.
837
+ //
838
+ // A granted child is refused on the same footing (#3755). Its grant is
839
+ // derived from this lease, so once the lease is gone the grant authorizes
840
+ // writes to a tree nobody holds — the exact bypass the cap exists to close,
841
+ // one hop removed.
842
+ return {
843
+ allow: false,
844
+ message: formatOccupancyAgeCapRemediation(record, now),
845
+ occupant: null,
846
+ refreshed: false,
847
+ warning: null,
848
+ admitted: null,
849
+ grant: null,
850
+ };
851
+ }
852
+ if (record === null || liveness !== "live") {
853
+ return {
854
+ allow: true,
855
+ message: null,
856
+ occupant: null,
857
+ refreshed: false,
858
+ warning: null,
859
+ admitted: null,
860
+ grant: null,
861
+ };
862
+ }
863
+ const live = record;
864
+ if (admission === "stranger") {
865
+ return {
866
+ allow: false,
867
+ // The refused caller is told what identity it actually presented (#3873).
868
+ // A hook process cannot otherwise know, and the grant this message offers
869
+ // is only runnable when the occupant can name a non-empty child.
870
+ message: formatOccupancyRemediation(live, now, incoming),
871
+ occupant: live,
872
+ refreshed: false,
873
+ warning: null,
874
+ admitted: null,
875
+ grant: null,
876
+ };
877
+ }
878
+ if (admission === "member") {
879
+ // A member's write keeps the lease alive (#3755). The lease answers "who may
880
+ // mutate this tree right now", and a tree a granted child is actively
881
+ // writing is in use — letting it lapse would hand the worktree to a peer
882
+ // mid-edit, which is the loss the TTL exists to prevent, not the abandonment
883
+ // it exists to detect. Two bounds still hold: `claimedAt` is untouched, so
884
+ // the absolute age cap is unmoved, and the grant expires on its own clock.
885
+ const memberAgeMs = now.getTime() - live.heartbeatAt.getTime();
886
+ const memberWarning = memberAgeMs >= OCCUPANCY_STALE_WARN_MS ? formatOccupancyStaleWarning(live, now) : null;
887
+ if (input.refresh !== true || memberAgeMs < OCCUPANCY_REFRESH_AFTER_MS) {
888
+ return {
889
+ allow: true,
890
+ message: null,
891
+ occupant: live,
892
+ refreshed: false,
893
+ warning: memberWarning,
894
+ admitted: "member",
895
+ grant: occupancyGrantFor(live, incoming, now),
896
+ };
897
+ }
898
+ const memberOutcome = restampOccupancyHeartbeat(projectRoot, live.sessionId, now, true, input.lockDeps, incoming);
899
+ if (memberOutcome.status !== "refreshed") {
900
+ // Same re-decide as the owner path: contention says nothing about who
901
+ // holds the lease now, so ask the file rather than the pre-lock snapshot.
902
+ return evaluateOccupancyWriteGate(projectRoot, { ...input, now, refresh: false });
903
+ }
904
+ return {
905
+ allow: true,
906
+ message: null,
907
+ occupant: memberOutcome.record,
908
+ refreshed: true,
909
+ warning: null,
910
+ admitted: "member",
911
+ grant: occupancyGrantFor(memberOutcome.record, incoming, now),
912
+ };
913
+ }
914
+ const ageMs = now.getTime() - live.heartbeatAt.getTime();
915
+ const warning = ageMs >= OCCUPANCY_STALE_WARN_MS ? formatOccupancyStaleWarning(live, now) : null;
916
+ if (input.refresh !== true || ageMs < OCCUPANCY_REFRESH_AFTER_MS) {
917
+ return {
918
+ allow: true,
919
+ message: null,
920
+ occupant: live,
921
+ refreshed: false,
922
+ warning,
923
+ admitted: "owner",
924
+ grant: null,
925
+ };
926
+ }
927
+ const outcome = restampOccupancyHeartbeat(projectRoot, live.sessionId, now, true, input.lockDeps);
928
+ if (outcome.status !== "refreshed") {
929
+ // Neither failure leaves the pre-lock record usable. `lost` says the lease
930
+ // changed hands outright. `unavailable` says only that the lock could not
931
+ // be taken — but a peer takeover is one of the things that holds it, so a
932
+ // re-stamp that blocks until timeout hides the same handover. Decide
933
+ // against what is on disk now instead of the snapshot read at 598: a lease
934
+ // still ours under contention re-allows exactly as before (#3736), while a
935
+ // replacement owner wins (#3599). Re-entry cannot recurse — refresh is off.
936
+ return evaluateOccupancyWriteGate(projectRoot, { ...input, now, refresh: false });
937
+ }
938
+ return {
939
+ allow: true,
940
+ message: null,
941
+ occupant: outcome.record,
942
+ refreshed: true,
943
+ // `warning` was measured against the pre-refresh heartbeat. Returning it
944
+ // beside a successful re-stamp would tell the owner its lease is going
945
+ // stale on the very write that renewed it.
946
+ warning: null,
947
+ admitted: "owner",
948
+ grant: null,
949
+ };
950
+ }
951
+ /**
952
+ * Re-stamp an existing live lease held by `sessionId`. Reports `lost` when the
953
+ * lease is gone, expired, or now held by someone else — refresh must never
954
+ * claim or resurrect a lease, only extend one the caller already holds.
955
+ *
956
+ * Lock contention and IO errors report `unavailable` rather than `lost`: they
957
+ * observed no owner at all, so they are not evidence of replacement. `heartbeat`
958
+ * says so and leaves the lease alone; the write gate re-reads the file rather
959
+ * than trusting either its own stale snapshot or a denial the lock never earned.
960
+ */
961
+ function restampOccupancyHeartbeat(projectRoot, sessionId, now, markWrite, lockDeps,
962
+ /** Refresh on behalf of this granted member rather than the owner (#3755). */
963
+ memberSessionId) {
964
+ try {
965
+ return withOccupancyLock(projectRoot, (fence) => {
966
+ const current = readOccupancy(projectRoot);
967
+ if (current === null || current.sessionId !== sessionId)
968
+ return { status: "lost" };
969
+ if (isOccupancyExpired(current, now))
970
+ return { status: "lost" };
971
+ // Re-check membership under the lock: the grant read before the wait may
972
+ // have been revoked or expired while it ran (#3755).
973
+ if (memberSessionId !== undefined &&
974
+ occupancyGrantFor(current, memberSessionId, now) === null) {
975
+ return { status: "lost" };
976
+ }
977
+ const record = writeOccupancyRecord(projectRoot, {
978
+ sessionId: current.sessionId,
979
+ worktreePath: current.worktreePath,
980
+ intent: current.intent,
981
+ claimedAt: current.claimedAt,
982
+ heartbeatAt: now,
983
+ lastWriteAt: markWrite ? now : current.lastWriteAt,
984
+ host: current.host,
985
+ address: current.address,
986
+ retainCapable: current.retainCapable,
987
+ joinProtocol: current.joinProtocol,
988
+ // Refresh extends the lease, so it also prunes the grants that died
989
+ // while it ran; expiry is already refused on read (#3755).
990
+ grants: liveOccupancyGrants(current, now),
991
+ }, fence);
992
+ return { status: "refreshed", record };
993
+ }, lockDeps);
994
+ }
995
+ catch {
996
+ return { status: "unavailable" };
997
+ }
998
+ }
999
+ /**
1000
+ * Refresh the caller's own live lease (#3599). Discoverable counterpart to the
1001
+ * automatic write-gate refresh, for sessions whose work is long and quiet:
1002
+ * reading, building, or waiting produces no gated write to ride on.
1003
+ *
1004
+ * Never claims and never mints an owner — an unheld or foreign lease is denied.
1005
+ */
1006
+ export function heartbeatOccupancy(projectRoot, input = {}) {
1007
+ const now = input.now ?? new Date();
1008
+ const path = occupancyPath(projectRoot);
1009
+ const caller = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
1010
+ if (caller.length === 0) {
1011
+ return {
1012
+ action: "denied",
1013
+ sessionId: "",
1014
+ record: readOccupancy(projectRoot),
1015
+ path,
1016
+ message: "occupancy:heartbeat needs the owner id: pass --session-id <your-session-id> or set " +
1017
+ "DEFT_SESSION_ID. Refresh extends an existing lease and never mints an owner.",
1018
+ code: 2,
1019
+ };
297
1020
  }
1021
+ const existing = readOccupancy(projectRoot);
1022
+ const live = existing !== null && !isOccupancyExpired(existing, now) ? existing : null;
1023
+ if (live === null) {
1024
+ const capped = existing !== null &&
1025
+ existing.sessionId === caller &&
1026
+ occupancyLiveness(existing, now) === "age-capped";
1027
+ return {
1028
+ action: "denied",
1029
+ sessionId: caller,
1030
+ record: capped ? existing : null,
1031
+ path,
1032
+ message: capped && existing !== null
1033
+ ? formatOccupancyAgeCapRemediation(existing, now)
1034
+ : "occupancy:heartbeat found no live lease to refresh. Claim one with " +
1035
+ `\`deft session:start --session-id=${commandSessionId(caller, "<your-session-id>")}\`.`,
1036
+ code: 1,
1037
+ };
1038
+ }
1039
+ if (live.sessionId !== caller) {
1040
+ return {
1041
+ action: "denied",
1042
+ sessionId: caller,
1043
+ record: live,
1044
+ path,
1045
+ message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat"),
1046
+ code: 1,
1047
+ };
1048
+ }
1049
+ const outcome = restampOccupancyHeartbeat(projectRoot, caller, now, false, input.lockDeps);
1050
+ if (outcome.status !== "refreshed") {
1051
+ return {
1052
+ action: "denied",
1053
+ sessionId: caller,
1054
+ record: readOccupancy(projectRoot),
1055
+ path,
1056
+ message: outcome.status === "lost"
1057
+ ? "occupancy:heartbeat could not refresh the lease: it expired or changed owner " +
1058
+ "while the refresh was running."
1059
+ : "occupancy:heartbeat could not take the occupancy lock, so the lease is " +
1060
+ "unchanged and still yours. Retry in a moment.",
1061
+ code: 1,
1062
+ };
1063
+ }
1064
+ const record = outcome.record;
298
1065
  return {
299
- allow: false,
300
- message: formatOccupancyRemediation(live, now),
301
- occupant: live,
1066
+ action: "heartbeat",
1067
+ sessionId: record.sessionId,
1068
+ record,
1069
+ path,
1070
+ message: `occupancy heartbeat session ${record.sessionId} (intent=${record.intent}, ` +
1071
+ `${occupancyClockLine(record)})`,
1072
+ code: 0,
302
1073
  };
303
1074
  }
304
1075
  /** Close-out identity comes from the launch manifest or DEFT_SESSION_ID — never occupancy.json. */
@@ -313,7 +1084,9 @@ export function releaseSwarmOccupancy(projectRoot, input = {}) {
313
1084
  record: occupant,
314
1085
  path: occupancyPath(projectRoot),
315
1086
  message: "swarm close-out has no occupancy_session_id (manifest missing or predates the field) " +
316
- "and DEFT_SESSION_ID is unset. Steal with occupancy:steal --confirm --occupant <id>.",
1087
+ "and DEFT_SESSION_ID is unset. Re-establish an aligned owner with " +
1088
+ "session:start --steal --confirm --occupant <reported-session-id> " +
1089
+ "--session-id=<your-session-id>.",
317
1090
  code: 1,
318
1091
  };
319
1092
  }
@@ -350,6 +1123,9 @@ function parseOccupancy(payload, fallbackWorktree) {
350
1123
  const heartbeatAt = parseTimestamp(obj.heartbeat_at) ?? claimedAt;
351
1124
  if (claimedAt === null || heartbeatAt === null)
352
1125
  return null;
1126
+ // Additive and optional (#3599): records written before the field exists,
1127
+ // and by older CLIs, stay readable — absence means "no recorded write".
1128
+ const lastWriteAt = parseTimestamp(obj.last_write_at);
353
1129
  const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
354
1130
  const joinProtocol = OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
355
1131
  ? joinRaw
@@ -364,13 +1140,63 @@ function parseOccupancy(payload, fallbackWorktree) {
364
1140
  intent,
365
1141
  claimedAt,
366
1142
  heartbeatAt,
1143
+ lastWriteAt,
367
1144
  host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
368
1145
  address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
369
1146
  retainCapable: obj.retain_capable === true,
370
1147
  joinProtocol,
1148
+ grants: parseOccupancyGrants(obj.grants, sessionId, worktreePath),
371
1149
  raw: { ...obj },
372
1150
  };
373
1151
  }
1152
+ /**
1153
+ * Read the grant list (#3755). A malformed entry is dropped rather than failing
1154
+ * the whole record: the lease still has an owner, and losing one grant denies a
1155
+ * child a write it can ask for again, while losing the record would strand the
1156
+ * tree. Expiry is parsed but not judged here — `occupancyGrantFor` decides that
1157
+ * against a clock, so an expired grant stays visible to the owner reading its
1158
+ * own lease and is still refused on admission.
1159
+ */
1160
+ function parseOccupancyGrants(payload, ownerSessionId, fallbackWorktree) {
1161
+ if (!Array.isArray(payload))
1162
+ return [];
1163
+ const grants = [];
1164
+ for (const entry of payload.slice(0, OCCUPANCY_MAX_GRANTS)) {
1165
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
1166
+ continue;
1167
+ const obj = entry;
1168
+ const childSessionId = typeof obj.child_session_id === "string" ? obj.child_session_id.trim() : "";
1169
+ if (childSessionId.length === 0)
1170
+ continue;
1171
+ const owner = typeof obj.owner_session_id === "string" ? obj.owner_session_id.trim() : "";
1172
+ // A grant naming a different owner is residue from a lease that has since
1173
+ // changed hands; the current occupant never issued it, so it admits nobody.
1174
+ if (owner.length === 0 || owner !== ownerSessionId)
1175
+ continue;
1176
+ const expiresAt = parseTimestamp(obj.expires_at);
1177
+ if (expiresAt === null)
1178
+ continue;
1179
+ const roleRaw = typeof obj.role === "string" ? obj.role : "";
1180
+ if (!SWARM_WORKER_ROLES.includes(roleRaw))
1181
+ continue;
1182
+ const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
1183
+ grants.push({
1184
+ ownerSessionId: owner,
1185
+ childSessionId,
1186
+ worktreePath: typeof obj.worktree_path === "string" && obj.worktree_path.trim().length > 0
1187
+ ? obj.worktree_path
1188
+ : fallbackWorktree,
1189
+ role: roleRaw,
1190
+ expiresAt,
1191
+ host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
1192
+ address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
1193
+ joinProtocol: OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
1194
+ ? joinRaw
1195
+ : "none",
1196
+ });
1197
+ }
1198
+ return grants;
1199
+ }
374
1200
  function occupancyPayload(record) {
375
1201
  return {
376
1202
  schemaVersion: OCCUPANCY_SCHEMA_VERSION,
@@ -379,10 +1205,28 @@ function occupancyPayload(record) {
379
1205
  intent: record.intent,
380
1206
  claimed_at: timestampIso(record.claimedAt),
381
1207
  heartbeat_at: timestampIso(record.heartbeatAt),
1208
+ ...(record.lastWriteAt === null ? {} : { last_write_at: timestampIso(record.lastWriteAt) }),
382
1209
  host: record.host,
383
1210
  address: record.address,
384
1211
  retain_capable: record.retainCapable,
385
1212
  join_protocol: record.joinProtocol,
1213
+ // Additive (#3755): absent on records written before membership existed,
1214
+ // and omitted again when empty so an ungranted lease keeps its old shape.
1215
+ ...(record.grants.length === 0
1216
+ ? {}
1217
+ : { grants: record.grants.map((grant) => occupancyGrantPayload(grant)) }),
1218
+ };
1219
+ }
1220
+ function occupancyGrantPayload(grant) {
1221
+ return {
1222
+ owner_session_id: grant.ownerSessionId,
1223
+ child_session_id: grant.childSessionId,
1224
+ worktree_path: grant.worktreePath,
1225
+ role: grant.role,
1226
+ expires_at: timestampIso(grant.expiresAt),
1227
+ host: grant.host,
1228
+ address: grant.address,
1229
+ join_protocol: grant.joinProtocol,
386
1230
  };
387
1231
  }
388
1232
  function writeOccupancyRecord(projectRoot, record, fence) {