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