@deftai/directive-core 0.107.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 (167) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/check/cached-orchestrator.js +28 -3
  3. package/dist/check/named-cause.js +18 -3
  4. package/dist/codebase/provider.js +7 -1
  5. package/dist/delivery-attempt/handoff.js +1 -1
  6. package/dist/design-critique/citation-grammar.d.ts +53 -0
  7. package/dist/design-critique/citation-grammar.js +303 -0
  8. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  9. package/dist/design-critique/completed-arc-record.js +232 -0
  10. package/dist/freshness/bind.d.ts +32 -3
  11. package/dist/freshness/bind.js +58 -12
  12. package/dist/hooks/classify/host-session-identity.d.ts +76 -0
  13. package/dist/hooks/classify/host-session-identity.js +470 -0
  14. package/dist/hooks/classify/index.d.ts +3 -2
  15. package/dist/hooks/classify/index.js +3 -2
  16. package/dist/hooks/classify/paths.d.ts +7 -0
  17. package/dist/hooks/classify/paths.js +35 -0
  18. package/dist/hooks/classify/stdin.d.ts +5 -0
  19. package/dist/hooks/classify/stdin.js +27 -0
  20. package/dist/hooks/dispatcher.d.ts +52 -1
  21. package/dist/hooks/dispatcher.js +538 -45
  22. package/dist/hooks/fixtures/cases.d.ts +16 -2
  23. package/dist/hooks/fixtures/cases.js +175 -0
  24. package/dist/hooks/scope.js +3 -1
  25. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  26. package/dist/init-deposit/agent-hooks.js +8 -1
  27. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  28. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  29. package/dist/intake/clause-derivation.js +9 -3
  30. package/dist/intake/issue-ingest.js +62 -2
  31. package/dist/intake/platform-capabilities.d.ts +9 -2
  32. package/dist/intake/platform-capabilities.js +38 -9
  33. package/dist/orphan-active/evaluate.d.ts +25 -0
  34. package/dist/orphan-active/evaluate.js +193 -128
  35. package/dist/orphan-active/index.d.ts +1 -0
  36. package/dist/orphan-active/index.js +1 -0
  37. package/dist/orphan-active/issue-state.d.ts +121 -0
  38. package/dist/orphan-active/issue-state.js +278 -0
  39. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  40. package/dist/platform/cursor-managed-runtime.js +241 -0
  41. package/dist/platform/index.d.ts +1 -0
  42. package/dist/platform/index.js +1 -0
  43. package/dist/platform/platform-capabilities.d.ts +23 -1
  44. package/dist/platform/platform-capabilities.js +55 -15
  45. package/dist/policy/ceremony-dial.js +5 -9
  46. package/dist/policy/host-hooks.js +4 -9
  47. package/dist/policy/org-force-on-migration.js +5 -10
  48. package/dist/policy/plan-extensions.d.ts +14 -2
  49. package/dist/policy/plan-extensions.js +24 -3
  50. package/dist/policy/product-signal.js +5 -10
  51. package/dist/policy/require-human-merge.js +5 -9
  52. package/dist/policy/resolve.js +77 -16
  53. package/dist/policy/value-feedback.js +9 -18
  54. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  55. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  56. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  57. package/dist/pr-closeout-attestable/index.js +2 -0
  58. package/dist/pr-protected-issues/gh.d.ts +6 -2
  59. package/dist/pr-protected-issues/gh.js +7 -3
  60. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  61. package/dist/pr-wait-mergeable/cascade.js +21 -1
  62. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  63. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  64. package/dist/pr-wait-mergeable/wrappers.js +11 -0
  65. package/dist/preflight/evaluate.d.ts +15 -0
  66. package/dist/preflight/evaluate.js +29 -3
  67. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  68. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  69. package/dist/product-first-done-gate/evaluate.js +17 -7
  70. package/dist/release/cli-drift-report.d.ts +68 -0
  71. package/dist/release/cli-drift-report.js +189 -0
  72. package/dist/release/index.d.ts +1 -0
  73. package/dist/release/index.js +1 -0
  74. package/dist/release/issue-state-fetch.d.ts +5 -3
  75. package/dist/release/issue-state-fetch.js +86 -21
  76. package/dist/release/native-steps.js +1 -0
  77. package/dist/release/pipeline.js +19 -0
  78. package/dist/release/types.d.ts +14 -0
  79. package/dist/render/constants.d.ts +4 -0
  80. package/dist/render/constants.js +11 -8
  81. package/dist/render/export-spec.js +31 -5
  82. package/dist/render/index.d.ts +1 -1
  83. package/dist/render/index.js +1 -1
  84. package/dist/render/prd-render.d.ts +4 -1
  85. package/dist/render/prd-render.js +82 -23
  86. package/dist/render/project-render.js +14 -7
  87. package/dist/render/scope-outlook.d.ts +2 -0
  88. package/dist/render/scope-outlook.js +3 -0
  89. package/dist/render/spec-render.js +2 -2
  90. package/dist/resolution/package-manager.d.ts +33 -4
  91. package/dist/resolution/package-manager.js +210 -9
  92. package/dist/review-monitor/constants.d.ts +7 -0
  93. package/dist/review-monitor/constants.js +7 -0
  94. package/dist/review-monitor/github-lease.d.ts +98 -1
  95. package/dist/review-monitor/github-lease.js +182 -1
  96. package/dist/review-monitor/lease-comment.d.ts +36 -0
  97. package/dist/review-monitor/lease-comment.js +103 -2
  98. package/dist/scm/call.js +12 -3
  99. package/dist/scm/design-critique-chip.d.ts +1 -0
  100. package/dist/scm/design-critique-chip.js +22 -6
  101. package/dist/scm/gh-rest.d.ts +9 -1
  102. package/dist/scm/gh-rest.js +103 -0
  103. package/dist/scm/readiness.d.ts +2 -0
  104. package/dist/scm/readiness.js +28 -4
  105. package/dist/scope/project-definition-sync.js +14 -7
  106. package/dist/session/git.d.ts +24 -0
  107. package/dist/session/git.js +138 -2
  108. package/dist/session/occupancy.d.ts +238 -12
  109. package/dist/session/occupancy.js +815 -29
  110. package/dist/session/ritual-sentinel.d.ts +5 -0
  111. package/dist/session/ritual-sentinel.js +5 -0
  112. package/dist/session/session-ready.d.ts +5 -1
  113. package/dist/session/session-ready.js +98 -8
  114. package/dist/session/session-start.d.ts +5 -1
  115. package/dist/session/session-start.js +32 -17
  116. package/dist/session/toolchain-preflight.d.ts +13 -3
  117. package/dist/session/toolchain-preflight.js +93 -18
  118. package/dist/session/verify-session-ritual.d.ts +11 -0
  119. package/dist/session/verify-session-ritual.js +60 -15
  120. package/dist/slice/constants.d.ts +1 -1
  121. package/dist/spec-authority/constants.d.ts +10 -2
  122. package/dist/spec-authority/constants.js +53 -8
  123. package/dist/spec-authority/resolver.d.ts +3 -0
  124. package/dist/spec-authority/resolver.js +55 -7
  125. package/dist/subprocess/max-buffer.d.ts +14 -0
  126. package/dist/subprocess/max-buffer.js +15 -0
  127. package/dist/swarm/complete-cohort.d.ts +2 -0
  128. package/dist/swarm/complete-cohort.js +21 -13
  129. package/dist/swarm/index.d.ts +1 -0
  130. package/dist/swarm/index.js +1 -0
  131. package/dist/swarm/launch-cli.js +53 -33
  132. package/dist/swarm/launch.d.ts +8 -0
  133. package/dist/swarm/launch.js +63 -43
  134. package/dist/swarm/pre-dispatch-cli.js +2 -1
  135. package/dist/swarm/pre-dispatch.js +10 -1
  136. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  137. package/dist/swarm/subagent-status-dir.js +42 -0
  138. package/dist/swarm/worktrees.d.ts +17 -3
  139. package/dist/swarm/worktrees.js +48 -4
  140. package/dist/triage/scope/mutations-core.d.ts +2 -6
  141. package/dist/triage/scope/mutations-core.js +23 -55
  142. package/dist/triage/scope-drift/add-ignore.js +5 -39
  143. package/dist/triage/subscribe/index.d.ts +2 -6
  144. package/dist/triage/subscribe/index.js +15 -65
  145. package/dist/triage/welcome/writers.js +15 -74
  146. package/dist/vbrief-build/index.d.ts +2 -1
  147. package/dist/vbrief-build/index.js +2 -1
  148. package/dist/vbrief-build/parity-scenarios.js +5 -5
  149. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  150. package/dist/vbrief-build/project-definition-io.js +357 -31
  151. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  152. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  153. package/dist/vbrief-validate/conformance.d.ts +1 -0
  154. package/dist/vbrief-validate/conformance.js +85 -8
  155. package/dist/vbrief-validate/main.js +2 -0
  156. package/dist/vbrief-validate/precutover.js +5 -10
  157. package/dist/verify-ac/clauses.d.ts +62 -2
  158. package/dist/verify-ac/clauses.js +183 -111
  159. package/dist/verify-ac/index.d.ts +1 -1
  160. package/dist/verify-ac/index.js +1 -1
  161. package/dist/verify-env/node-runtime.d.ts +8 -4
  162. package/dist/verify-env/node-runtime.js +9 -6
  163. package/dist/verify-env/toolchain-check.d.ts +35 -3
  164. package/dist/verify-env/toolchain-check.js +155 -36
  165. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  166. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  167. 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,34 +39,182 @@ 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";
22
43
  import { stableJson } from "./json.js";
23
44
  import { parseTimestamp, timestampIso } from "./time.js";
24
45
  export const OCCUPANCY_SCHEMA_VERSION = 1;
25
46
  export const OCCUPANCY_RELPATH = [".deft", "occupancy.json"];
26
47
  /** Crash recovery TTL: 20 minutes without heartbeat (15–30 window). */
27
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;
28
81
  export const OCCUPANCY_INTENTS = ["mutation", "swarm", "review"];
29
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;
30
97
  export function occupancyPath(projectRoot) {
31
98
  return join(resolve(projectRoot), ...OCCUPANCY_RELPATH);
32
99
  }
33
100
  export function heartbeatAgeSeconds(record, now = new Date()) {
34
101
  return Math.max(0, Math.round((now.getTime() - record.heartbeatAt.getTime()) / 1000));
35
102
  }
36
- export function isOccupancyExpired(record, now = new Date(), ttlMs = OCCUPANCY_TTL_MS) {
37
- 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";
38
168
  }
39
169
  function occupancyClockLine(record) {
40
- return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}`;
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.`);
41
195
  }
42
196
  export function formatOccupancyRemediation(record, now = new Date()) {
43
197
  const age = heartbeatAgeSeconds(record, now);
44
198
  return (`Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
45
- `${occupancyClockLine(record)}).\n` +
199
+ `${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n` +
46
200
  "Stay read-only (`session:start --read-only`), use another worktree,\n" +
47
- "queue a join (`occupancy:request`), or steal (`occupancy:steal --confirm`).\n" +
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" +
48
204
  "The occupant may release (`occupancy:release` / `session:end`).");
49
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.`);
217
+ }
50
218
  export function resolveOccupancySessionId(input = {}) {
51
219
  const explicit = input.sessionId?.trim();
52
220
  if (explicit)
@@ -74,9 +242,9 @@ export function readOccupancy(projectRoot) {
74
242
  }
75
243
  return parseOccupancy(payload, resolve(projectRoot));
76
244
  }
77
- 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) {
78
246
  const record = readOccupancy(projectRoot);
79
- if (record === null || isOccupancyExpired(record, now, ttlMs))
247
+ if (record === null || isOccupancyExpired(record, now, ttlMs, maxLeaseMs))
80
248
  return null;
81
249
  return record;
82
250
  }
@@ -130,10 +298,15 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
130
298
  intent: input.intent ?? liveLocked?.intent ?? "mutation",
131
299
  claimedAt: liveLocked?.claimedAt ?? now,
132
300
  heartbeatAt: now,
301
+ lastWriteAt: input.markWrite === true ? now : (liveLocked?.lastWriteAt ?? null),
133
302
  host: input.host ?? liveLocked?.host ?? occupancyHost(input.env),
134
303
  address: input.address ?? liveLocked?.address ?? occupancyAddress(input.env),
135
304
  retainCapable: input.retainCapable ?? liveLocked?.retainCapable ?? false,
136
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),
137
310
  }, fence);
138
311
  const action = liveLocked !== null ? "heartbeat" : "claimed";
139
312
  return {
@@ -151,13 +324,20 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
151
324
  export function stealOccupancy(projectRoot, input = {}) {
152
325
  const now = input.now ?? new Date();
153
326
  const path = occupancyPath(projectRoot);
327
+ const incoming = resolveOccupancySessionId(input);
154
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
+ : "";
155
335
  return {
156
336
  action: "denied",
157
- sessionId: resolveOccupancySessionId(input),
158
- record: readOccupancy(projectRoot),
337
+ sessionId: incoming,
338
+ record: current,
159
339
  path,
160
- message: "occupancy:steal requires --confirm after naming the occupant.",
340
+ message: `occupancy:steal requires --confirm after naming the occupant.${occupantDetail}`,
161
341
  code: 2,
162
342
  };
163
343
  }
@@ -165,7 +345,7 @@ export function stealOccupancy(projectRoot, input = {}) {
165
345
  if (named.length === 0) {
166
346
  return {
167
347
  action: "denied",
168
- sessionId: resolveOccupancySessionId(input),
348
+ sessionId: incoming,
169
349
  record: readOccupancy(projectRoot),
170
350
  path,
171
351
  message: "occupancy:steal requires --occupant <session-id> to name the current occupant.",
@@ -174,10 +354,26 @@ export function stealOccupancy(projectRoot, input = {}) {
174
354
  }
175
355
  const existing = readOccupancy(projectRoot);
176
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
+ }
177
373
  if (live !== null && live.sessionId !== named) {
178
374
  return {
179
375
  action: "denied",
180
- sessionId: resolveOccupancySessionId(input),
376
+ sessionId: incoming,
181
377
  record: live,
182
378
  path,
183
379
  message: `occupancy:steal named occupant ${named} does not match live occupant ${live.sessionId}.\n` +
@@ -185,13 +381,36 @@ export function stealOccupancy(projectRoot, input = {}) {
185
381
  code: 1,
186
382
  };
187
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
+ }
188
396
  return withOccupancyLock(projectRoot, (fence) => {
189
397
  const existingLocked = readOccupancy(projectRoot);
190
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
+ }
191
410
  if (liveLocked !== null && liveLocked.sessionId !== named) {
192
411
  return {
193
412
  action: "denied",
194
- sessionId: resolveOccupancySessionId(input),
413
+ sessionId: incoming,
195
414
  record: liveLocked,
196
415
  path,
197
416
  message: `occupancy:steal named occupant ${named} does not match live occupant ${liveLocked.sessionId}.\n` +
@@ -199,25 +418,35 @@ export function stealOccupancy(projectRoot, input = {}) {
199
418
  code: 1,
200
419
  };
201
420
  }
202
- const incoming = resolveOccupancySessionId(input);
203
- const priorClock = existingLocked !== null ? ` (${occupancyClockLine(existingLocked)})` : "";
421
+ const priorClock = existingLocked !== null
422
+ ? ` (${formatLastWritePhrase(existingLocked, now)}, ${occupancyClockLine(existingLocked)})`
423
+ : "";
204
424
  const record = writeOccupancyRecord(projectRoot, {
205
425
  sessionId: incoming,
206
426
  worktreePath: resolve(projectRoot),
207
427
  intent: input.intent ?? "mutation",
208
428
  claimedAt: now,
209
429
  heartbeatAt: now,
430
+ lastWriteAt: null,
210
431
  host: input.host ?? occupancyHost(input.env),
211
432
  address: input.address ?? occupancyAddress(input.env),
212
433
  retainCapable: input.retainCapable ?? false,
213
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: [],
214
439
  }, fence);
215
440
  return {
216
441
  action: "stolen",
217
442
  sessionId: record.sessionId,
218
443
  record,
219
444
  path,
220
- message: `occupancy stolen from ${named}${priorClock}; 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.",
221
450
  code: 0,
222
451
  };
223
452
  }, input.lockDeps);
@@ -246,7 +475,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
246
475
  sessionId: caller,
247
476
  record: existing,
248
477
  path,
249
- message: formatOccupancyRemediation(existing, now),
478
+ message: membershipOwnerDenial(existing, caller, now, "occupancy:release"),
250
479
  code: 1,
251
480
  };
252
481
  }
@@ -271,7 +500,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
271
500
  sessionId: caller,
272
501
  record: still,
273
502
  path,
274
- message: formatOccupancyRemediation(still, now),
503
+ message: membershipOwnerDenial(still, caller, now, "occupancy:release"),
275
504
  code: 1,
276
505
  };
277
506
  }
@@ -286,19 +515,503 @@ export function releaseOccupancy(projectRoot, input = {}) {
286
515
  };
287
516
  }, input.lockDeps);
288
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
+ */
289
770
  export function evaluateOccupancyWriteGate(projectRoot, input = {}) {
290
771
  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
772
  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 };
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 });
297
879
  }
298
880
  return {
299
- allow: false,
300
- message: formatOccupancyRemediation(live, now),
301
- occupant: live,
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
+ };
1005
+ }
1006
+ const record = outcome.record;
1007
+ return {
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,
302
1015
  };
303
1016
  }
304
1017
  /** Close-out identity comes from the launch manifest or DEFT_SESSION_ID — never occupancy.json. */
@@ -313,7 +1026,9 @@ export function releaseSwarmOccupancy(projectRoot, input = {}) {
313
1026
  record: occupant,
314
1027
  path: occupancyPath(projectRoot),
315
1028
  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>.",
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>.",
317
1032
  code: 1,
318
1033
  };
319
1034
  }
@@ -350,6 +1065,9 @@ function parseOccupancy(payload, fallbackWorktree) {
350
1065
  const heartbeatAt = parseTimestamp(obj.heartbeat_at) ?? claimedAt;
351
1066
  if (claimedAt === null || heartbeatAt === null)
352
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);
353
1071
  const joinRaw = typeof obj.join_protocol === "string" ? obj.join_protocol : "none";
354
1072
  const joinProtocol = OCCUPANCY_JOIN_PROTOCOLS.includes(joinRaw)
355
1073
  ? joinRaw
@@ -364,13 +1082,63 @@ function parseOccupancy(payload, fallbackWorktree) {
364
1082
  intent,
365
1083
  claimedAt,
366
1084
  heartbeatAt,
1085
+ lastWriteAt,
367
1086
  host: typeof obj.host === "string" && obj.host.length > 0 ? obj.host : "none",
368
1087
  address: typeof obj.address === "string" && obj.address.length > 0 ? obj.address : "none",
369
1088
  retainCapable: obj.retain_capable === true,
370
1089
  joinProtocol,
1090
+ grants: parseOccupancyGrants(obj.grants, sessionId, worktreePath),
371
1091
  raw: { ...obj },
372
1092
  };
373
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
+ }
374
1142
  function occupancyPayload(record) {
375
1143
  return {
376
1144
  schemaVersion: OCCUPANCY_SCHEMA_VERSION,
@@ -379,10 +1147,28 @@ function occupancyPayload(record) {
379
1147
  intent: record.intent,
380
1148
  claimed_at: timestampIso(record.claimedAt),
381
1149
  heartbeat_at: timestampIso(record.heartbeatAt),
1150
+ ...(record.lastWriteAt === null ? {} : { last_write_at: timestampIso(record.lastWriteAt) }),
382
1151
  host: record.host,
383
1152
  address: record.address,
384
1153
  retain_capable: record.retainCapable,
385
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,
386
1172
  };
387
1173
  }
388
1174
  function writeOccupancyRecord(projectRoot, record, fence) {