@deftai/directive-core 0.108.0 → 0.109.1

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 (157) hide show
  1. package/dist/cache/operations.js +1 -1
  2. package/dist/check/gate-lists.d.ts +14 -0
  3. package/dist/check/gate-lists.js +26 -3
  4. package/dist/check/session-completed-ac.d.ts +1 -1
  5. package/dist/check/session-completed-ac.js +1 -1
  6. package/dist/consumer-check-contract/evaluate.d.ts +47 -0
  7. package/dist/consumer-check-contract/evaluate.js +181 -15
  8. package/dist/delivery-attempt/evaluate.d.ts +9 -1
  9. package/dist/delivery-attempt/evaluate.js +69 -0
  10. package/dist/delivery-attempt/index.d.ts +1 -1
  11. package/dist/delivery-attempt/index.js +1 -1
  12. package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
  13. package/dist/deposit/live-procedure-exclusions.js +110 -0
  14. package/dist/deposit/live-procedure-targets.d.ts +45 -0
  15. package/dist/deposit/live-procedure-targets.js +274 -0
  16. package/dist/deposit/python-free.d.ts +6 -0
  17. package/dist/deposit/python-free.js +15 -0
  18. package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
  19. package/dist/deposit/rewrite-deposit-links.js +148 -0
  20. package/dist/deposit/run-stage-content-pack.d.ts +2 -0
  21. package/dist/deposit/run-stage-content-pack.js +3 -0
  22. package/dist/deposit/stage-content-pack.d.ts +17 -0
  23. package/dist/deposit/stage-content-pack.js +91 -0
  24. package/dist/design-critique/citation-grammar.d.ts +7 -0
  25. package/dist/design-critique/citation-grammar.js +1 -1
  26. package/dist/design-critique/completed-arc-record.d.ts +8 -1
  27. package/dist/design-critique/completed-arc-record.js +98 -14
  28. package/dist/hooks/classify/host-session-identity.d.ts +25 -12
  29. package/dist/hooks/classify/host-session-identity.js +64 -49
  30. package/dist/hooks/classify/index.d.ts +2 -2
  31. package/dist/hooks/classify/index.js +2 -2
  32. package/dist/hooks/classify/paths.d.ts +2 -0
  33. package/dist/hooks/classify/paths.js +8 -4
  34. package/dist/hooks/classify/stdin.js +69 -3
  35. package/dist/hooks/dest-form.d.ts +20 -1
  36. package/dist/hooks/dest-form.js +158 -21
  37. package/dist/hooks/dispatcher.d.ts +22 -2
  38. package/dist/hooks/dispatcher.js +338 -66
  39. package/dist/hooks/fixtures/cases.d.ts +6 -0
  40. package/dist/hooks/fixtures/cases.js +53 -0
  41. package/dist/hooks/git-destructive-log.d.ts +32 -0
  42. package/dist/hooks/git-destructive-log.js +46 -0
  43. package/dist/hooks/index.d.ts +3 -0
  44. package/dist/hooks/index.js +3 -0
  45. package/dist/hooks/owner-liveness.d.ts +92 -0
  46. package/dist/hooks/owner-liveness.js +103 -0
  47. package/dist/hooks/shell-write-targets.d.ts +10 -0
  48. package/dist/hooks/shell-write-targets.js +274 -0
  49. package/dist/hooks/tools.d.ts +49 -2
  50. package/dist/hooks/tools.js +96 -1
  51. package/dist/init-deposit/agent-hooks.d.ts +10 -0
  52. package/dist/init-deposit/agent-hooks.js +39 -0
  53. package/dist/init-deposit/gitignore.d.ts +7 -0
  54. package/dist/init-deposit/gitignore.js +24 -0
  55. package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
  56. package/dist/init-deposit/host-tool-coverage.js +150 -0
  57. package/dist/init-deposit/index.d.ts +1 -0
  58. package/dist/init-deposit/index.js +1 -0
  59. package/dist/init-deposit/init-deposit.js +3 -0
  60. package/dist/init-deposit/refresh.js +7 -0
  61. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  62. package/dist/init-deposit/runtime-writers.js +33 -0
  63. package/dist/intake/issue-ingest.d.ts +6 -1
  64. package/dist/intake/issue-ingest.js +21 -2
  65. package/dist/intake/reconcile-issues.js +13 -13
  66. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  67. package/dist/lifecycle/brief-envelope.js +42 -0
  68. package/dist/lifecycle/index.d.ts +1 -0
  69. package/dist/lifecycle/index.js +1 -0
  70. package/dist/literal-acceptance/evaluate.js +14 -5
  71. package/dist/literal-acceptance/index.d.ts +1 -1
  72. package/dist/literal-acceptance/index.js +1 -1
  73. package/dist/literal-acceptance/run.d.ts +2 -0
  74. package/dist/literal-acceptance/run.js +19 -1
  75. package/dist/orchestration/subagent-monitor.d.ts +6 -0
  76. package/dist/orchestration/subagent-monitor.js +23 -1
  77. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  78. package/dist/orphan-active/candidate-scope.js +157 -0
  79. package/dist/orphan-active/evaluate.d.ts +27 -0
  80. package/dist/orphan-active/evaluate.js +63 -6
  81. package/dist/orphan-active/index.d.ts +1 -0
  82. package/dist/orphan-active/index.js +1 -0
  83. package/dist/policy/merge-approval-head.js +7 -6
  84. package/dist/pr-closing-keywords/gh.js +32 -9
  85. package/dist/pr-closing-keywords/main.d.ts +1 -0
  86. package/dist/pr-closing-keywords/main.js +68 -3
  87. package/dist/pr-closing-keywords/types.d.ts +2 -0
  88. package/dist/pr-merge-readiness/gh.js +32 -9
  89. package/dist/pr-protected-issues/gh.js +36 -9
  90. package/dist/pr-wait-mergeable/wrappers.js +3 -3
  91. package/dist/product-first-done-gate/evaluate.js +6 -2
  92. package/dist/product-first-done-gate/types.js +2 -0
  93. package/dist/release/consumer-hard-stops.d.ts +48 -0
  94. package/dist/release/consumer-hard-stops.js +140 -0
  95. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  96. package/dist/release/consumer-readiness-disclosure.js +51 -0
  97. package/dist/release/index.d.ts +2 -0
  98. package/dist/release/index.js +2 -0
  99. package/dist/release/pipeline.js +55 -0
  100. package/dist/release/run-consumer-readiness.d.ts +15 -0
  101. package/dist/release/run-consumer-readiness.js +24 -0
  102. package/dist/release/types.d.ts +6 -0
  103. package/dist/review-monitor/github-lease.js +4 -3
  104. package/dist/run-summary/types.d.ts +2 -2
  105. package/dist/scm/build-command.d.ts +2 -2
  106. package/dist/scm/build-command.js +2 -2
  107. package/dist/scm/call-shape.d.ts +25 -0
  108. package/dist/scm/call-shape.js +59 -0
  109. package/dist/scm/call.d.ts +7 -4
  110. package/dist/scm/call.js +41 -11
  111. package/dist/scm/gh-rest.d.ts +25 -12
  112. package/dist/scm/gh-rest.js +65 -15
  113. package/dist/scm/index.d.ts +2 -0
  114. package/dist/scm/index.js +2 -0
  115. package/dist/scm/spawn-status.d.ts +33 -0
  116. package/dist/scm/spawn-status.js +53 -0
  117. package/dist/scope/acceptance-evidence.d.ts +1 -1
  118. package/dist/scope/transition.js +4 -14
  119. package/dist/session/ac-pass-banking.d.ts +2 -2
  120. package/dist/session/ac-pass-banking.js +2 -2
  121. package/dist/session/child-occupancy.d.ts +72 -0
  122. package/dist/session/child-occupancy.js +209 -0
  123. package/dist/session/host-session-owner.d.ts +93 -0
  124. package/dist/session/host-session-owner.js +148 -0
  125. package/dist/session/index.d.ts +2 -0
  126. package/dist/session/index.js +2 -0
  127. package/dist/session/occupancy.d.ts +107 -2
  128. package/dist/session/occupancy.js +272 -34
  129. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  130. package/dist/session/verify-ac-session-cache.js +2 -2
  131. package/dist/swarm/complete-cohort.js +2 -0
  132. package/dist/swarm/pre-dispatch.js +2 -0
  133. package/dist/swarm/subagent-status-dir.d.ts +2 -1
  134. package/dist/swarm/subagent-status-dir.js +11 -2
  135. package/dist/swarm/worktrees.js +2 -0
  136. package/dist/triage/evaluate/worktrees.js +153 -6
  137. package/dist/umbrella-current-shape/index.d.ts +51 -3
  138. package/dist/umbrella-current-shape/index.js +106 -18
  139. package/dist/validate-content/deposit-required.d.ts +39 -0
  140. package/dist/validate-content/deposit-required.js +147 -0
  141. package/dist/validate-content/index.d.ts +1 -0
  142. package/dist/validate-content/index.js +1 -0
  143. package/dist/validate-content/validate-links.d.ts +2 -3
  144. package/dist/validate-content/validate-links.js +29 -2
  145. package/dist/vbrief-activate/activate.d.ts +7 -2
  146. package/dist/vbrief-activate/activate.js +26 -13
  147. package/dist/verify-ac/evaluate.d.ts +9 -0
  148. package/dist/verify-ac/evaluate.js +32 -9
  149. package/dist/verify-env/agent-hooks.d.ts +6 -1
  150. package/dist/verify-env/agent-hooks.js +28 -2
  151. package/dist/verify-source/deposit-closure.d.ts +23 -0
  152. package/dist/verify-source/deposit-closure.js +162 -0
  153. package/dist/verify-source/index.d.ts +2 -0
  154. package/dist/verify-source/index.js +2 -0
  155. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  156. package/dist/verify-source/semantic-single-source.js +349 -0
  157. package/package.json +3 -3
@@ -19,6 +19,25 @@
19
19
  * composite hook write gate measures the tree's verified ritual owner against
20
20
  * the occupant that issued the grant, not against the writer.
21
21
  *
22
+ * Parent and child, answered per identity-source kind (#3954, and it does not
23
+ * have one answer). On a `host-env` host the parent and its dispatched children
24
+ * are different actors, because the host publishes a different id into each
25
+ * agent session. The answer there is identity, not automatic membership: each
26
+ * side resolves its own owner through the shared lookup chain below and claims
27
+ * its own worktree, which is where the dispatch envelope already puts it.
28
+ * Membership stays explicit and owner-issued for the deliberate same-tree case,
29
+ * and it stays affordable only that way -- 32 grants at a four-hour TTL against
30
+ * a twenty-minute lease means granting on every dispatch exhausts a busy
31
+ * parent's lease inside a day. The revocation trigger is therefore the owner's
32
+ * own `occupancy:grant --revoke`, or expiry; releasing a child's lease on its
33
+ * terminal event is dispatcher lifecycle in `child-occupancy.ts` (#3999).
34
+ * On a `payload` host parent and subagents share one id, so there is no foreign
35
+ * child lease to admit and nothing to grant -- and the live consequence is the
36
+ * inverse one: `owns` is true for both, so a parent's `occupancy:release`
37
+ * removes a working child's lease mid-flight with no denial. That is a property
38
+ * of shared host identity, not of this module; a bearer boundary cannot
39
+ * distinguish two processes presenting one string.
40
+ *
22
41
  * Concurrency model:
23
42
  * - Assumptions: local filesystem; cooperating processes on one machine.
24
43
  * - Guarantees: mutual exclusion under crash-free operation; detect-and-abort
@@ -40,6 +59,8 @@ import { containedRemove, containedWrite } from "../fs/contained-write.js";
40
59
  import { assertWriteTargetSafe } from "../fs/projection-containment.js";
41
60
  import { assertAppendLockOwned, withAppendLock } from "../slice/lock.js";
42
61
  import { SWARM_WORKER_ROLES } from "../swarm/routing.js";
62
+ import { recordChildOccupancyLease } from "./child-occupancy.js";
63
+ import { ambientHostSessionOwner, claimsHostSessionIdShape, parseCanonicalHostSessionId, } from "./host-session-owner.js";
43
64
  import { stableJson } from "./json.js";
44
65
  import { parseTimestamp, timestampIso } from "./time.js";
45
66
  export const OCCUPANCY_SCHEMA_VERSION = 1;
@@ -166,6 +187,19 @@ export function occupancyAdmission(record, sessionId, now = new Date()) {
166
187
  return "owner";
167
188
  return occupancyGrantFor(record, presented, now) === null ? "stranger" : "member";
168
189
  }
190
+ // Session ids reach remediation text from operator flags, host environments and
191
+ // whatever a peer wrote into the lease, so a value can carry whitespace or shell
192
+ // metacharacters. Only a value a shell would take as one bare token is inlined
193
+ // into a printed command; anything else keeps its placeholder, because the right
194
+ // quoting differs per shell and a mis-parsed copyable command is worse than one
195
+ // the reader has to fill in. The id itself is still named in the prose above.
196
+ // A leading dash is excluded as well: every CLI parser here reads such a value
197
+ // as another option, so `--occupant --weird-id` fails argument parsing even
198
+ // though the shell itself would have passed the token through intact.
199
+ const SHELL_SAFE_SESSION_ID = /^(?!-)[A-Za-z0-9_.:+=,/-]+$/;
200
+ function commandSessionId(sessionId, placeholder) {
201
+ return SHELL_SAFE_SESSION_ID.test(sessionId) ? sessionId : placeholder;
202
+ }
169
203
  function occupancyClockLine(record) {
170
204
  const lastWrite = record.lastWriteAt === null ? "" : ` last_write_at=${timestampIso(record.lastWriteAt)}`;
171
205
  return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}${lastWrite}`;
@@ -178,7 +212,7 @@ export function formatOccupancyStaleWarning(record, now = new Date(), ttlMs = OC
178
212
  const age = heartbeatAgeSeconds(record, now);
179
213
  return (`Occupancy lease for session ${record.sessionId} has not beaten for ${age}s of its ` +
180
214
  `${Math.round(ttlMs / 1000)}s window; another session may read it as abandoned. ` +
181
- `Refresh it with \`deft occupancy:heartbeat --session-id=${record.sessionId}\`.`);
215
+ `Refresh it with \`deft occupancy:heartbeat --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\`.`);
182
216
  }
183
217
  /**
184
218
  * Tell the holder its lease aged out of the absolute cap (#3599). Distinct
@@ -191,17 +225,45 @@ export function formatOccupancyAgeCapRemediation(record, now = new Date(), maxLe
191
225
  `(claimed ${leaseAgeSeconds(record, now)}s ago, ${occupancyClockLine(record)}), so this ` +
192
226
  "worktree is no longer held and a peer may claim it at any moment. Heartbeats cannot " +
193
227
  "extend a capped lease — re-claim the worktree with " +
194
- `\`deft session:start --session-id=${record.sessionId}\` before writing again.`);
228
+ `\`deft session:start --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\` before writing again.`);
195
229
  }
196
- export function formatOccupancyRemediation(record, now = new Date()) {
230
+ /**
231
+ * Tell a refused caller who holds the lease and what it can actually run.
232
+ *
233
+ * `presented` is the id the refused caller offered (#3873). Without it the
234
+ * message can only print `<your-session-id>` placeholders, which is fine for a
235
+ * CLI caller that passed its own `--session-id` and useless to a hook process,
236
+ * which does not know what identity it presented. Passing it also keeps the
237
+ * message honest when there is none: a grant cannot name an empty child --
238
+ * `occupancy:grant --child-session-id=` is refused at parse and at membership --
239
+ * so that remediation is not printed to a caller who could never run it.
240
+ */
241
+ export function formatOccupancyRemediation(record, now = new Date(), presented) {
197
242
  const age = heartbeatAgeSeconds(record, now);
198
- return (`Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
199
- `${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n` +
243
+ const header = `Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
244
+ `${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n`;
245
+ const tail = "\nThe occupant may release (`occupancy:release` / `session:end`).";
246
+ if (presented === undefined) {
247
+ return (`${header}Stay read-only (\`session:start --read-only\`), use another worktree,\n` +
248
+ "ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
249
+ "--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
250
+ `(\`session:start --steal --confirm --occupant <reported-session-id> --session-id=<your-session-id>\`).${tail}`);
251
+ }
252
+ const actor = presented.trim();
253
+ const occupantArg = commandSessionId(record.sessionId, "<reported-session-id>");
254
+ if (actor.length === 0) {
255
+ return (`${header}This process presented no session identity, so a write grant cannot name it ` +
256
+ "and an owner transition would not be recognised on its next write.\n" +
257
+ "Stay read-only (`session:start --read-only`), use another worktree, or ask the occupant " +
258
+ `to release the lease (\`occupancy:release --session-id=${occupantArg}\` / \`session:end\`).${tail}`);
259
+ }
260
+ const actorArg = commandSessionId(actor, "<your-session-id>");
261
+ return (`${header}This process presented session ${actor}, which neither holds that lease nor has a ` +
262
+ "write grant on it.\n" +
200
263
  "Stay read-only (`session:start --read-only`), use another worktree,\n" +
201
- "ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
264
+ `ask the occupant for a write grant (\`occupancy:grant --child-session-id=${actorArg} ` +
202
265
  "--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`).");
266
+ `(\`session:start --steal --confirm --occupant ${occupantArg} --session-id=${actorArg}\`).${tail}`);
205
267
  }
206
268
  /**
207
269
  * Refuse an administrative verb to a granted child (#3755). Named apart from
@@ -215,13 +277,74 @@ export function formatOccupancyMemberAdministrationRefusal(record, grant, verb)
215
277
  "grant never escalates into administration.\n" +
216
278
  `Ask the occupant (session ${record.sessionId}) to run it, or wait for the grant to expire.`);
217
279
  }
218
- export function resolveOccupancySessionId(input = {}) {
280
+ /**
281
+ * The one lookup order every occupancy surface shares (#3954): an explicit
282
+ * `--session-id`, then `DEFT_SESSION_ID`, then the owner the running host
283
+ * published.
284
+ *
285
+ * The terminal is the caller's, not this function's. Claim mints, because
286
+ * claiming establishes an identity where none exists. Release, heartbeat and
287
+ * grant/revoke are proving one, so they take the empty string and keep the
288
+ * diagnosis written for it -- a shared mint would replace "you presented
289
+ * nothing" with a plausible id no later hook will ever present, on every host
290
+ * that publishes no owner of its own.
291
+ *
292
+ * Disagreement is reported, not resolved. The order stands, so an explicit id
293
+ * beats the environment and the environment beats the host; what changes is
294
+ * that a refused caller is told the host names someone else, which is the state
295
+ * a stale inherited `DEFT_SESSION_ID` produces and the one an operator cannot
296
+ * otherwise see.
297
+ */
298
+ export function resolvePresentedIdentity(input = {}) {
299
+ const env = input.env ?? process.env;
300
+ const hostOwner = ambientHostSessionOwner(env);
301
+ const disagreement = (chosen) => hostOwner !== null && hostOwner !== chosen ? hostOwner : null;
219
302
  const explicit = input.sessionId?.trim();
220
- if (explicit)
221
- return explicit;
222
- const envId = (input.env ?? process.env).DEFT_SESSION_ID?.trim();
223
- if (envId)
224
- return envId;
303
+ if (explicit) {
304
+ return {
305
+ sessionId: explicit,
306
+ source: "explicit",
307
+ disagreeingHostOwner: disagreement(explicit),
308
+ };
309
+ }
310
+ const envId = env.DEFT_SESSION_ID?.trim();
311
+ if (envId) {
312
+ return { sessionId: envId, source: "environment", disagreeingHostOwner: disagreement(envId) };
313
+ }
314
+ if (hostOwner !== null) {
315
+ return { sessionId: hostOwner, source: "host", disagreeingHostOwner: null };
316
+ }
317
+ return { sessionId: "", source: "none", disagreeingHostOwner: null };
318
+ }
319
+ /**
320
+ * Name a claimer-versus-presenter split on a refusal, or return "" (#3954).
321
+ *
322
+ * Appended only to denials: while the caller is admitted the split costs it
323
+ * nothing, and on a refusal it is the one fact that explains why an id the
324
+ * operator believes is theirs is being treated as a stranger's.
325
+ */
326
+ export function formatPresentedIdentityDisagreement(identity) {
327
+ const other = identity.disagreeingHostOwner;
328
+ if (other === null)
329
+ return "";
330
+ const named = identity.source === "explicit" ? "The id passed on the command line" : "DEFT_SESSION_ID";
331
+ return (`\n${named} names session ${identity.sessionId}, but this host published ` +
332
+ `${other}. Those are different sessions: re-run with ` +
333
+ `\`--session-id=${commandSessionId(other, "<host-published-id>")}\` to act as the host owner.`);
334
+ }
335
+ /**
336
+ * The owner a claim is made under: the shared lookup chain, then a mint.
337
+ *
338
+ * The host step is what makes an identified host's claim reachable (#3873).
339
+ * Minting instead binds the lease to an id no later hook process can present,
340
+ * so the session that claimed the worktree is refused by its own lease. The
341
+ * mint stays as the last resort for hosts that publish nothing, and it is the
342
+ * one terminal the prove-surfaces deliberately do not share (#3954).
343
+ */
344
+ export function resolveOccupancySessionId(input = {}) {
345
+ const presented = resolvePresentedIdentity(input).sessionId;
346
+ if (presented.length > 0)
347
+ return presented;
225
348
  return (input.newSessionId ?? randomUUID)();
226
349
  }
227
350
  export function readOccupancy(projectRoot) {
@@ -263,7 +386,10 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
263
386
  sessionId: incoming,
264
387
  record: live,
265
388
  path,
266
- message: formatOccupancyRemediation(live, now),
389
+ // A granted child reads the member refusal rather than an offer of the
390
+ // write grant it already holds (#3954 item 5): membership admits writes,
391
+ // and claiming the lease stays owner-only.
392
+ message: membershipOwnerDenial(live, incoming, now, "session:start"),
267
393
  code: 1,
268
394
  };
269
395
  }
@@ -288,7 +414,7 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
288
414
  sessionId: incoming,
289
415
  record: liveLocked,
290
416
  path,
291
- message: formatOccupancyRemediation(liveLocked, now),
417
+ message: membershipOwnerDenial(liveLocked, incoming, now, "session:start"),
292
418
  code: 1,
293
419
  };
294
420
  }
@@ -309,6 +435,9 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
309
435
  grants: liveLocked === null ? [] : liveOccupancyGrants(liveLocked, now),
310
436
  }, fence);
311
437
  const action = liveLocked !== null ? "heartbeat" : "claimed";
438
+ if (action === "claimed") {
439
+ maybeRecordChildOccupancyOnClaim(projectRoot, incoming, input.env);
440
+ }
312
441
  return {
313
442
  action,
314
443
  sessionId: record.sessionId,
@@ -321,6 +450,32 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
321
450
  };
322
451
  }, input.lockDeps);
323
452
  }
453
+ /**
454
+ * Stamp the dispatch-recorded child occupancy store at claim time (#3999).
455
+ * Pre-dispatch / worktree mkdir cannot know a host-env child's occupancy
456
+ * owner; the claiming process does. Heartbeat `agent_id` on this host is the
457
+ * raw GROK_SESSION_ID, so that is the store key the terminal monitor looks up.
458
+ */
459
+ function maybeRecordChildOccupancyOnClaim(projectRoot, occupancyOwner, env) {
460
+ const resolved = env ?? process.env;
461
+ const grokRaw = resolved.GROK_SESSION_ID?.trim() ?? "";
462
+ const agentId = grokRaw.length > 0 ? grokRaw : occupancyHost(env);
463
+ if (agentId.length === 0 || agentId === "none")
464
+ return;
465
+ const parentId = occupancyAddress(env);
466
+ try {
467
+ recordChildOccupancyLease(projectRoot, {
468
+ agentId,
469
+ parentId,
470
+ occupancyOwner,
471
+ worktreePath: resolve(projectRoot),
472
+ identitySourceKind: grokRaw.length > 0 ? "host-env" : "payload",
473
+ });
474
+ }
475
+ catch {
476
+ // Claim already succeeded; a missing dispatch record is a no-op on terminal.
477
+ }
478
+ }
324
479
  export function stealOccupancy(projectRoot, input = {}) {
325
480
  const now = input.now ?? new Date();
326
481
  const path = occupancyPath(projectRoot);
@@ -451,10 +606,26 @@ export function stealOccupancy(projectRoot, input = {}) {
451
606
  };
452
607
  }, input.lockDeps);
453
608
  }
609
+ /**
610
+ * Release the caller's own lease.
611
+ *
612
+ * Owner-only, deliberately (#3954 item 4, answering the open question the
613
+ * design-critique arc left for the builder). Letting an unidentified caller
614
+ * release the occupant the lease file itself records would make possession of
615
+ * that file path into authority to delete a live lease, which is exactly what
616
+ * the `!expired && !owns` refusal exists to prevent -- and the cooperative
617
+ * bearer model (#3755) has no second check behind it. The unreachable printed
618
+ * recovery is fixed by the shared lookup chain instead: on a host that
619
+ * publishes an owner, the occupant now resolves itself and a bare
620
+ * `occupancy:release` is the occupant, so the message the deny prints is one
621
+ * the party it addresses can actually run.
622
+ */
454
623
  export function releaseOccupancy(projectRoot, input = {}) {
455
624
  const now = input.now ?? new Date();
456
625
  const path = occupancyPath(projectRoot);
457
- const caller = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
626
+ const identity = resolvePresentedIdentity(input);
627
+ const caller = identity.sessionId;
628
+ const split = formatPresentedIdentityDisagreement(identity);
458
629
  return withOccupancyLock(projectRoot, (fence) => {
459
630
  const existing = readOccupancy(projectRoot);
460
631
  if (existing === null) {
@@ -475,7 +646,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
475
646
  sessionId: caller,
476
647
  record: existing,
477
648
  path,
478
- message: membershipOwnerDenial(existing, caller, now, "occupancy:release"),
649
+ message: membershipOwnerDenial(existing, caller, now, "occupancy:release") + split,
479
650
  code: 1,
480
651
  };
481
652
  }
@@ -500,7 +671,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
500
671
  sessionId: caller,
501
672
  record: still,
502
673
  path,
503
- message: membershipOwnerDenial(still, caller, now, "occupancy:release"),
674
+ message: membershipOwnerDenial(still, caller, now, "occupancy:release") + split,
504
675
  code: 1,
505
676
  };
506
677
  }
@@ -515,10 +686,18 @@ export function releaseOccupancy(projectRoot, input = {}) {
515
686
  };
516
687
  }, input.lockDeps);
517
688
  }
689
+ /**
690
+ * Refuse an owner-only verb, saying which of the three the caller is.
691
+ *
692
+ * The caller is passed to `formatOccupancyRemediation` as the presented id
693
+ * (#3954): every one of these surfaces now resolves an actor the caller may not
694
+ * have chosen explicitly, so a refusal that does not name what was presented
695
+ * leaves it guessing which identity it was refused under.
696
+ */
518
697
  function membershipOwnerDenial(live, caller, now, verb) {
519
698
  const grant = occupancyGrantFor(live, caller, now);
520
699
  return grant === null
521
- ? formatOccupancyRemediation(live, now)
700
+ ? formatOccupancyRemediation(live, now, caller)
522
701
  : formatOccupancyMemberAdministrationRefusal(live, grant, verb);
523
702
  }
524
703
  /**
@@ -533,7 +712,9 @@ function membershipOwnerDenial(live, caller, now, verb) {
533
712
  export function grantOccupancyMembership(projectRoot, input = {}) {
534
713
  const now = input.now ?? new Date();
535
714
  const path = occupancyPath(projectRoot);
536
- const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
715
+ const identity = resolvePresentedIdentity(input);
716
+ const owner = identity.sessionId;
717
+ const split = formatPresentedIdentityDisagreement(identity);
537
718
  const child = input.childSessionId?.trim() ?? "";
538
719
  const role = input.role?.trim() ?? "";
539
720
  if (owner.length === 0) {
@@ -569,6 +750,46 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
569
750
  code: 2,
570
751
  };
571
752
  }
753
+ // #3954 item 3. A child id under the reserved `host:` prefix must be a
754
+ // well-formed canonical owner: measured, `host:nosuchhost:v9:zzzz` and
755
+ // `host:grok:v1:!!!not-base64url!!!` were granted and then admitted as
756
+ // `member` by the write gate, so the lease read as membership while admitting
757
+ // nobody. An id outside that prefix is still accepted, because a child on a
758
+ // host with no identity contract presents whatever `DEFT_SESSION_ID` holds
759
+ // and refusing that would deny a grant nothing has measured wrong.
760
+ if (claimsHostSessionIdShape(child) && parseCanonicalHostSessionId(child) === null) {
761
+ return {
762
+ action: "denied",
763
+ sessionId: owner,
764
+ record: readOccupancy(projectRoot),
765
+ path,
766
+ message: `occupancy:grant refuses the child id ${child}: the \`host:\` prefix is reserved for ` +
767
+ "host-published identity, and this is not a well-formed owner " +
768
+ "(`host:<provider>:v1:<base64url>`), so no session could ever present it. Pass the id " +
769
+ "the child's own host publishes, or an opaque id the child sets as DEFT_SESSION_ID.",
770
+ code: 2,
771
+ };
772
+ }
773
+ // Same defect one step subtler: re-prefixing the owner's own payload under a
774
+ // second provider passes the shape check and is a self-grant in disguise --
775
+ // no session on that other host would present it, and the owner already holds
776
+ // the lease outright.
777
+ const childParts = parseCanonicalHostSessionId(child);
778
+ const ownerParts = parseCanonicalHostSessionId(owner);
779
+ if (childParts !== null &&
780
+ ownerParts !== null &&
781
+ childParts.rawSessionId === ownerParts.rawSessionId) {
782
+ return {
783
+ action: "denied",
784
+ sessionId: owner,
785
+ record: readOccupancy(projectRoot),
786
+ path,
787
+ message: `occupancy:grant refuses the child id ${child}: it carries this lease owner's own host ` +
788
+ `session (${ownerParts.rawSessionId}) under provider ${childParts.provider}. That is a ` +
789
+ "self-grant across a provider prefix, and the child it names cannot exist.",
790
+ code: 2,
791
+ };
792
+ }
572
793
  if (!SWARM_WORKER_ROLES.includes(role)) {
573
794
  return {
574
795
  action: "denied",
@@ -596,7 +817,7 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
596
817
  message: capped && current !== null
597
818
  ? formatOccupancyAgeCapRemediation(current, now)
598
819
  : "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}\`.`,
820
+ `so claim the worktree first with \`deft session:start --session-id=${commandSessionId(owner, "<your-session-id>")}\`.`,
600
821
  code: 1,
601
822
  };
602
823
  }
@@ -606,7 +827,7 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
606
827
  sessionId: owner,
607
828
  record: live,
608
829
  path,
609
- message: membershipOwnerDenial(live, owner, now, "occupancy:grant"),
830
+ message: membershipOwnerDenial(live, owner, now, "occupancy:grant") + split,
610
831
  code: 1,
611
832
  };
612
833
  }
@@ -687,7 +908,11 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
687
908
  export function revokeOccupancyMembership(projectRoot, input = {}) {
688
909
  const now = input.now ?? new Date();
689
910
  const path = occupancyPath(projectRoot);
690
- const owner = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
911
+ const identity = resolvePresentedIdentity(input);
912
+ const owner = identity.sessionId;
913
+ const split = formatPresentedIdentityDisagreement(identity);
914
+ // Revoke deliberately skips the grant-time child-id checks: a malformed grant
915
+ // written before those checks existed must stay withdrawable (#3954 item 3).
691
916
  const child = input.childSessionId?.trim() ?? "";
692
917
  if (owner.length === 0 || child.length === 0) {
693
918
  return {
@@ -720,7 +945,7 @@ export function revokeOccupancyMembership(projectRoot, input = {}) {
720
945
  sessionId: owner,
721
946
  record: live,
722
947
  path,
723
- message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke"),
948
+ message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke") + split,
724
949
  code: 1,
725
950
  };
726
951
  }
@@ -809,7 +1034,10 @@ export function evaluateOccupancyWriteGate(projectRoot, input = {}) {
809
1034
  if (admission === "stranger") {
810
1035
  return {
811
1036
  allow: false,
812
- message: formatOccupancyRemediation(live, now),
1037
+ // The refused caller is told what identity it actually presented (#3873).
1038
+ // A hook process cannot otherwise know, and the grant this message offers
1039
+ // is only runnable when the occupant can name a non-empty child.
1040
+ message: formatOccupancyRemediation(live, now, incoming),
813
1041
  occupant: live,
814
1042
  refreshed: false,
815
1043
  warning: null,
@@ -948,7 +1176,9 @@ memberSessionId) {
948
1176
  export function heartbeatOccupancy(projectRoot, input = {}) {
949
1177
  const now = input.now ?? new Date();
950
1178
  const path = occupancyPath(projectRoot);
951
- const caller = input.sessionId?.trim() || (input.env ?? process.env).DEFT_SESSION_ID?.trim() || "";
1179
+ const identity = resolvePresentedIdentity(input);
1180
+ const caller = identity.sessionId;
1181
+ const split = formatPresentedIdentityDisagreement(identity);
952
1182
  if (caller.length === 0) {
953
1183
  return {
954
1184
  action: "denied",
@@ -974,7 +1204,7 @@ export function heartbeatOccupancy(projectRoot, input = {}) {
974
1204
  message: capped && existing !== null
975
1205
  ? formatOccupancyAgeCapRemediation(existing, now)
976
1206
  : "occupancy:heartbeat found no live lease to refresh. Claim one with " +
977
- `\`deft session:start --session-id=${caller}\`.`,
1207
+ `\`deft session:start --session-id=${commandSessionId(caller, "<your-session-id>")}\`.`,
978
1208
  code: 1,
979
1209
  };
980
1210
  }
@@ -984,7 +1214,7 @@ export function heartbeatOccupancy(projectRoot, input = {}) {
984
1214
  sessionId: caller,
985
1215
  record: live,
986
1216
  path,
987
- message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat"),
1217
+ message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat") + split,
988
1218
  code: 1,
989
1219
  };
990
1220
  }
@@ -1014,10 +1244,18 @@ export function heartbeatOccupancy(projectRoot, input = {}) {
1014
1244
  code: 0,
1015
1245
  };
1016
1246
  }
1017
- /** Close-out identity comes from the launch manifest or DEFT_SESSION_ID — never occupancy.json. */
1247
+ /**
1248
+ * Close-out identity comes from the launch manifest, `DEFT_SESSION_ID`, or the
1249
+ * owner the running host published — never occupancy.json (#3954).
1250
+ *
1251
+ * Reading the lease for identity would be the anonymous recorded-occupant
1252
+ * release refused in `releaseOccupancy`; the host step is the same shared
1253
+ * lookup chain every other occupancy surface uses, so a cohort launched on a
1254
+ * host that publishes an owner can close out without an explicit id.
1255
+ */
1018
1256
  export function releaseSwarmOccupancy(projectRoot, input = {}) {
1019
1257
  const env = input.env ?? process.env;
1020
- const sessionId = input.sessionId?.trim() || env.DEFT_SESSION_ID?.trim() || "";
1258
+ const sessionId = resolvePresentedIdentity({ sessionId: input.sessionId, env }).sessionId;
1021
1259
  if (sessionId.length === 0) {
1022
1260
  const occupant = readOccupancy(projectRoot);
1023
1261
  return {
@@ -1025,9 +1263,9 @@ export function releaseSwarmOccupancy(projectRoot, input = {}) {
1025
1263
  sessionId: "",
1026
1264
  record: occupant,
1027
1265
  path: occupancyPath(projectRoot),
1028
- message: "swarm close-out has no occupancy_session_id (manifest missing or predates the field) " +
1029
- "and DEFT_SESSION_ID is unset. Re-establish an aligned owner with " +
1030
- "session:start --steal --confirm --occupant <reported-session-id> " +
1266
+ message: "swarm close-out has no occupancy_session_id (manifest missing or predates the field), " +
1267
+ "DEFT_SESSION_ID is unset, and this host published no owner. Re-establish an aligned " +
1268
+ "owner with session:start --steal --confirm --occupant <reported-session-id> " +
1031
1269
  "--session-id=<your-session-id>.",
1032
1270
  code: 1,
1033
1271
  };
@@ -4,8 +4,8 @@
4
4
  * Disk-backed so check composition (spawned verify:ac) can serve from_cache
5
5
  * after a green run in the same session when plan + product hash still match.
6
6
  */
7
- export declare const VERIFY_AC_SESSION_CACHE_DIR = ".deft/verify-ac-session-cache";
8
- export declare const AC_SERVED_FROM: readonly ["bank", "cache", "executed"];
7
+ export declare const VERIFY_AC_SESSION_CACHE_DIR = ".deft/cache/verify-ac-session-cache";
8
+ export declare const AC_SERVED_FROM: readonly ["bank", "cache", "executed", "refused"];
9
9
  export type AcServedFrom = (typeof AC_SERVED_FROM)[number];
10
10
  export interface CachedVerifyAcSnapshot {
11
11
  readonly ok: boolean;
@@ -8,8 +8,8 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
9
  import { containedWrite } from "../fs/contained-write.js";
10
10
  import { sanitizeScopeIdForFilename } from "./ac-pass-banking.js";
11
- export const VERIFY_AC_SESSION_CACHE_DIR = ".deft/verify-ac-session-cache";
12
- export const AC_SERVED_FROM = ["bank", "cache", "executed"];
11
+ export const VERIFY_AC_SESSION_CACHE_DIR = ".deft/cache/verify-ac-session-cache";
12
+ export const AC_SERVED_FROM = ["bank", "cache", "executed", "refused"];
13
13
  function asRecord(value) {
14
14
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
15
15
  return value;
@@ -496,6 +496,8 @@ export function completeCohort(args) {
496
496
  }
497
497
  }
498
498
  if (result.ok && sessionId.length > 0) {
499
+ // Launcher occupancy only. Child owner leases release on the
500
+ // orchestration terminal transition (#3999), not here.
499
501
  const released = releaseSwarmOccupancy(projectRoot, {
500
502
  env,
501
503
  sessionId,
@@ -177,6 +177,8 @@ function runBegin(input) {
177
177
  // heuristic, so accept an on-disk worktree as the second arming signal.
178
178
  const armTarget = resolve(input.projectRoot, input.targetId.trim());
179
179
  if (looksLikeFilesystemTarget(input.targetId) || looksLikeWorktreeDir(armTarget)) {
180
+ // Heartbeat dir only. Child occupancy owner is recorded at claim time
181
+ // (#3999): this parent process does not know a host-env child's id.
180
182
  ensureSubagentStatusDir(armTarget);
181
183
  }
182
184
  // Exclusive lock → reload → evaluate → begin+save (same decision under lock;
@@ -6,6 +6,7 @@
6
6
  * worktree create and at pre-dispatch begin so a later missing record is
7
7
  * exit 1. Does not write heartbeat records or liveness onto the C2 manifest.
8
8
  */
9
+ import { type ChildOccupancyDispatchInput } from "../session/child-occupancy.js";
9
10
  /**
10
11
  * True when a resolved path is an on-disk git worktree, i.e. it carries a `.git`
11
12
  * entry (a file for `git worktree add` trees, a directory for the main clone).
@@ -23,5 +24,5 @@ export declare function looksLikeWorktreeDir(resolvedPath: string): boolean;
23
24
  * Returns the directory path, or null when the worktree is not on disk yet
24
25
  * (do not mkdir a stray tree for a branch-name target).
25
26
  */
26
- export declare function ensureSubagentStatusDir(worktreeRoot: string): string | null;
27
+ export declare function ensureSubagentStatusDir(worktreeRoot: string, childLease?: Omit<ChildOccupancyDispatchInput, "worktreePath">): string | null;
27
28
  //# sourceMappingURL=subagent-status-dir.d.ts.map
@@ -7,8 +7,9 @@
7
7
  * exit 1. Does not write heartbeat records or liveness onto the C2 manifest.
8
8
  */
9
9
  import { existsSync, mkdirSync } from "node:fs";
10
- import { join } from "node:path";
10
+ import { join, resolve } from "node:path";
11
11
  import { defaultScratchDir } from "../orchestration/subagent-monitor.js";
12
+ import { recordChildOccupancyLease, } from "../session/child-occupancy.js";
12
13
  /**
13
14
  * True when a resolved path is an on-disk git worktree, i.e. it carries a `.git`
14
15
  * entry (a file for `git worktree add` trees, a directory for the main clone).
@@ -31,12 +32,20 @@ export function looksLikeWorktreeDir(resolvedPath) {
31
32
  * Returns the directory path, or null when the worktree is not on disk yet
32
33
  * (do not mkdir a stray tree for a branch-name target).
33
34
  */
34
- export function ensureSubagentStatusDir(worktreeRoot) {
35
+ export function ensureSubagentStatusDir(worktreeRoot, childLease) {
35
36
  const trimmed = worktreeRoot.trim();
36
37
  if (trimmed.length === 0 || !existsSync(trimmed))
37
38
  return null;
38
39
  const dir = defaultScratchDir(trimmed);
39
40
  mkdirSync(dir, { recursive: true });
41
+ // Optional: parent already knows the child's occupancy owner (spawn return).
42
+ // Host-env production claims also record via applyWorktreeOccupancy (#3999).
43
+ if (childLease !== undefined) {
44
+ recordChildOccupancyLease(trimmed, {
45
+ ...childLease,
46
+ worktreePath: resolve(trimmed),
47
+ });
48
+ }
40
49
  return dir;
41
50
  }
42
51
  //# sourceMappingURL=subagent-status-dir.js.map
@@ -198,6 +198,7 @@ export function resolveWorktreeMap(mapping, baseBranch, createMissing = true, op
198
198
  `but requested base '${trimmedBase}' resolves to ${requestedOid}; ` +
199
199
  `this is a snapshot check at resolution time, not a pin on the worker's start revision`);
200
200
  }
201
+ // Heartbeat dir only. Child occupancy owner is recorded at claim (#3999).
201
202
  ensureSubagentStatusDir(entry._abs);
202
203
  continue;
203
204
  }
@@ -205,6 +206,7 @@ export function resolveWorktreeMap(mapping, baseBranch, createMissing = true, op
205
206
  throw new MissingWorktreeError(`story '${entry.story_id}' maps to '${entry.worktree_path}' which is not a registered git worktree and create_missing is disabled`);
206
207
  }
207
208
  createWorktree(root, entry._abs, trimmedBase, git);
209
+ // Heartbeat dir only. Child occupancy owner is recorded at claim (#3999).
208
210
  ensureSubagentStatusDir(entry._abs);
209
211
  }
210
212
  return resolved.map(({ story_id, worktree_path, base_branch }) => ({