@deftai/directive-core 0.109.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 (36) hide show
  1. package/dist/design-critique/completed-arc-record.d.ts +8 -1
  2. package/dist/design-critique/completed-arc-record.js +43 -4
  3. package/dist/hooks/classify/host-session-identity.d.ts +1 -1
  4. package/dist/hooks/classify/host-session-identity.js +23 -5
  5. package/dist/hooks/dispatcher.d.ts +17 -1
  6. package/dist/hooks/dispatcher.js +83 -7
  7. package/dist/hooks/index.d.ts +1 -0
  8. package/dist/hooks/index.js +1 -0
  9. package/dist/hooks/owner-liveness.d.ts +92 -0
  10. package/dist/hooks/owner-liveness.js +103 -0
  11. package/dist/hooks/tools.d.ts +47 -18
  12. package/dist/hooks/tools.js +82 -16
  13. package/dist/init-deposit/agent-hooks.d.ts +10 -0
  14. package/dist/init-deposit/agent-hooks.js +39 -0
  15. package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
  16. package/dist/init-deposit/host-tool-coverage.js +150 -0
  17. package/dist/init-deposit/index.d.ts +1 -0
  18. package/dist/init-deposit/index.js +1 -0
  19. package/dist/orchestration/subagent-monitor.d.ts +6 -0
  20. package/dist/orchestration/subagent-monitor.js +23 -1
  21. package/dist/session/child-occupancy.d.ts +72 -0
  22. package/dist/session/child-occupancy.js +209 -0
  23. package/dist/session/host-session-owner.d.ts +40 -0
  24. package/dist/session/host-session-owner.js +64 -5
  25. package/dist/session/index.d.ts +1 -0
  26. package/dist/session/index.js +1 -0
  27. package/dist/session/occupancy.d.ts +89 -4
  28. package/dist/session/occupancy.js +211 -31
  29. package/dist/swarm/complete-cohort.js +2 -0
  30. package/dist/swarm/pre-dispatch.js +2 -0
  31. package/dist/swarm/subagent-status-dir.d.ts +2 -1
  32. package/dist/swarm/subagent-status-dir.js +11 -2
  33. package/dist/swarm/worktrees.js +2 -0
  34. package/dist/verify-env/agent-hooks.d.ts +6 -1
  35. package/dist/verify-env/agent-hooks.js +28 -2
  36. 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,7 +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";
43
- import { ambientHostSessionOwner } from "./host-session-owner.js";
62
+ import { recordChildOccupancyLease } from "./child-occupancy.js";
63
+ import { ambientHostSessionOwner, claimsHostSessionIdShape, parseCanonicalHostSessionId, } from "./host-session-owner.js";
44
64
  import { stableJson } from "./json.js";
45
65
  import { parseTimestamp, timestampIso } from "./time.js";
46
66
  export const OCCUPANCY_SCHEMA_VERSION = 1;
@@ -258,25 +278,73 @@ export function formatOccupancyMemberAdministrationRefusal(record, grant, verb)
258
278
  `Ask the occupant (session ${record.sessionId}) to run it, or wait for the grant to expire.`);
259
279
  }
260
280
  /**
261
- * The owner a claim is made under: an explicit id, then `DEFT_SESSION_ID`, then
262
- * the id the running host published, then a mint.
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;
302
+ const explicit = input.sessionId?.trim();
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.
263
337
  *
264
338
  * The host step is what makes an identified host's claim reachable (#3873).
265
339
  * Minting instead binds the lease to an id no later hook process can present,
266
340
  * so the session that claimed the worktree is refused by its own lease. The
267
- * mint stays as the last resort for hosts that publish nothing.
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).
268
343
  */
269
344
  export function resolveOccupancySessionId(input = {}) {
270
- const explicit = input.sessionId?.trim();
271
- if (explicit)
272
- return explicit;
273
- const env = input.env ?? process.env;
274
- const envId = env.DEFT_SESSION_ID?.trim();
275
- if (envId)
276
- return envId;
277
- const hostOwner = ambientHostSessionOwner(env);
278
- if (hostOwner !== null)
279
- return hostOwner;
345
+ const presented = resolvePresentedIdentity(input).sessionId;
346
+ if (presented.length > 0)
347
+ return presented;
280
348
  return (input.newSessionId ?? randomUUID)();
281
349
  }
282
350
  export function readOccupancy(projectRoot) {
@@ -318,7 +386,10 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
318
386
  sessionId: incoming,
319
387
  record: live,
320
388
  path,
321
- 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"),
322
393
  code: 1,
323
394
  };
324
395
  }
@@ -343,7 +414,7 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
343
414
  sessionId: incoming,
344
415
  record: liveLocked,
345
416
  path,
346
- message: formatOccupancyRemediation(liveLocked, now),
417
+ message: membershipOwnerDenial(liveLocked, incoming, now, "session:start"),
347
418
  code: 1,
348
419
  };
349
420
  }
@@ -364,6 +435,9 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
364
435
  grants: liveLocked === null ? [] : liveOccupancyGrants(liveLocked, now),
365
436
  }, fence);
366
437
  const action = liveLocked !== null ? "heartbeat" : "claimed";
438
+ if (action === "claimed") {
439
+ maybeRecordChildOccupancyOnClaim(projectRoot, incoming, input.env);
440
+ }
367
441
  return {
368
442
  action,
369
443
  sessionId: record.sessionId,
@@ -376,6 +450,32 @@ export function applyWorktreeOccupancy(projectRoot, input = {}) {
376
450
  };
377
451
  }, input.lockDeps);
378
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
+ }
379
479
  export function stealOccupancy(projectRoot, input = {}) {
380
480
  const now = input.now ?? new Date();
381
481
  const path = occupancyPath(projectRoot);
@@ -506,10 +606,26 @@ export function stealOccupancy(projectRoot, input = {}) {
506
606
  };
507
607
  }, input.lockDeps);
508
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
+ */
509
623
  export function releaseOccupancy(projectRoot, input = {}) {
510
624
  const now = input.now ?? new Date();
511
625
  const path = occupancyPath(projectRoot);
512
- 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);
513
629
  return withOccupancyLock(projectRoot, (fence) => {
514
630
  const existing = readOccupancy(projectRoot);
515
631
  if (existing === null) {
@@ -530,7 +646,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
530
646
  sessionId: caller,
531
647
  record: existing,
532
648
  path,
533
- message: membershipOwnerDenial(existing, caller, now, "occupancy:release"),
649
+ message: membershipOwnerDenial(existing, caller, now, "occupancy:release") + split,
534
650
  code: 1,
535
651
  };
536
652
  }
@@ -555,7 +671,7 @@ export function releaseOccupancy(projectRoot, input = {}) {
555
671
  sessionId: caller,
556
672
  record: still,
557
673
  path,
558
- message: membershipOwnerDenial(still, caller, now, "occupancy:release"),
674
+ message: membershipOwnerDenial(still, caller, now, "occupancy:release") + split,
559
675
  code: 1,
560
676
  };
561
677
  }
@@ -570,10 +686,18 @@ export function releaseOccupancy(projectRoot, input = {}) {
570
686
  };
571
687
  }, input.lockDeps);
572
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
+ */
573
697
  function membershipOwnerDenial(live, caller, now, verb) {
574
698
  const grant = occupancyGrantFor(live, caller, now);
575
699
  return grant === null
576
- ? formatOccupancyRemediation(live, now)
700
+ ? formatOccupancyRemediation(live, now, caller)
577
701
  : formatOccupancyMemberAdministrationRefusal(live, grant, verb);
578
702
  }
579
703
  /**
@@ -588,7 +712,9 @@ function membershipOwnerDenial(live, caller, now, verb) {
588
712
  export function grantOccupancyMembership(projectRoot, input = {}) {
589
713
  const now = input.now ?? new Date();
590
714
  const path = occupancyPath(projectRoot);
591
- 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);
592
718
  const child = input.childSessionId?.trim() ?? "";
593
719
  const role = input.role?.trim() ?? "";
594
720
  if (owner.length === 0) {
@@ -624,6 +750,46 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
624
750
  code: 2,
625
751
  };
626
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
+ }
627
793
  if (!SWARM_WORKER_ROLES.includes(role)) {
628
794
  return {
629
795
  action: "denied",
@@ -661,7 +827,7 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
661
827
  sessionId: owner,
662
828
  record: live,
663
829
  path,
664
- message: membershipOwnerDenial(live, owner, now, "occupancy:grant"),
830
+ message: membershipOwnerDenial(live, owner, now, "occupancy:grant") + split,
665
831
  code: 1,
666
832
  };
667
833
  }
@@ -742,7 +908,11 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
742
908
  export function revokeOccupancyMembership(projectRoot, input = {}) {
743
909
  const now = input.now ?? new Date();
744
910
  const path = occupancyPath(projectRoot);
745
- 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).
746
916
  const child = input.childSessionId?.trim() ?? "";
747
917
  if (owner.length === 0 || child.length === 0) {
748
918
  return {
@@ -775,7 +945,7 @@ export function revokeOccupancyMembership(projectRoot, input = {}) {
775
945
  sessionId: owner,
776
946
  record: live,
777
947
  path,
778
- message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke"),
948
+ message: membershipOwnerDenial(live, owner, now, "occupancy:grant --revoke") + split,
779
949
  code: 1,
780
950
  };
781
951
  }
@@ -1006,7 +1176,9 @@ memberSessionId) {
1006
1176
  export function heartbeatOccupancy(projectRoot, input = {}) {
1007
1177
  const now = input.now ?? new Date();
1008
1178
  const path = occupancyPath(projectRoot);
1009
- 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);
1010
1182
  if (caller.length === 0) {
1011
1183
  return {
1012
1184
  action: "denied",
@@ -1042,7 +1214,7 @@ export function heartbeatOccupancy(projectRoot, input = {}) {
1042
1214
  sessionId: caller,
1043
1215
  record: live,
1044
1216
  path,
1045
- message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat"),
1217
+ message: membershipOwnerDenial(live, caller, now, "occupancy:heartbeat") + split,
1046
1218
  code: 1,
1047
1219
  };
1048
1220
  }
@@ -1072,10 +1244,18 @@ export function heartbeatOccupancy(projectRoot, input = {}) {
1072
1244
  code: 0,
1073
1245
  };
1074
1246
  }
1075
- /** 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
+ */
1076
1256
  export function releaseSwarmOccupancy(projectRoot, input = {}) {
1077
1257
  const env = input.env ?? process.env;
1078
- const sessionId = input.sessionId?.trim() || env.DEFT_SESSION_ID?.trim() || "";
1258
+ const sessionId = resolvePresentedIdentity({ sessionId: input.sessionId, env }).sessionId;
1079
1259
  if (sessionId.length === 0) {
1080
1260
  const occupant = readOccupancy(projectRoot);
1081
1261
  return {
@@ -1083,9 +1263,9 @@ export function releaseSwarmOccupancy(projectRoot, input = {}) {
1083
1263
  sessionId: "",
1084
1264
  record: occupant,
1085
1265
  path: occupancyPath(projectRoot),
1086
- message: "swarm close-out has no occupancy_session_id (manifest missing or predates the field) " +
1087
- "and DEFT_SESSION_ID is unset. Re-establish an aligned owner with " +
1088
- "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> " +
1089
1269
  "--session-id=<your-session-id>.",
1090
1270
  code: 1,
1091
1271
  };
@@ -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 }) => ({
@@ -1,4 +1,5 @@
1
1
  import { type AgentHookInspection } from "../init-deposit/agent-hooks.js";
2
+ import { type HostToolCoverageFinding, inspectHostToolCoverage } from "../init-deposit/host-tool-coverage.js";
2
3
  import type { HostHooksPolicy } from "../policy/host-hooks.js";
3
4
  import type { OutputStream } from "./verify-hooks-installed.js";
4
5
  export interface AgentHookHealthResult {
@@ -6,7 +7,11 @@ export interface AgentHookHealthResult {
6
7
  readonly message: string;
7
8
  readonly stream: OutputStream;
8
9
  readonly registrations: readonly AgentHookInspection[];
10
+ /** Host tool-surface coverage gaps found alongside registration health (#3987). */
11
+ readonly coverage: readonly HostToolCoverageFinding[];
9
12
  }
10
13
  /** Read-only P0 agent-host registration health, independent of git hooks. */
11
- export declare function evaluateAgentHooks(projectRoot: string, hostHooksPolicy?: HostHooksPolicy): AgentHookHealthResult;
14
+ export declare function evaluateAgentHooks(projectRoot: string, hostHooksPolicy?: HostHooksPolicy,
15
+ /** Test seam for the #3987 tool-surface coverage probe. */
16
+ inspectCoverage?: typeof inspectHostToolCoverage): AgentHookHealthResult;
12
17
  //# sourceMappingURL=agent-hooks.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { inspectAgentHookDeposit } from "../init-deposit/agent-hooks.js";
4
+ import { HOST_TOOL_COVERAGE_RECOVERY, inspectHostToolCoverage, } from "../init-deposit/host-tool-coverage.js";
4
5
  import { loadHostHooksPolicyFromProject, UNUSED_HOST_HOOKS_RECOVERY, } from "../policy/host-hooks.js";
5
6
  function isDirectory(path) {
6
7
  try {
@@ -11,7 +12,9 @@ function isDirectory(path) {
11
12
  }
12
13
  }
13
14
  /** Read-only P0 agent-host registration health, independent of git hooks. */
14
- export function evaluateAgentHooks(projectRoot, hostHooksPolicy = loadHostHooksPolicyFromProject(projectRoot)) {
15
+ export function evaluateAgentHooks(projectRoot, hostHooksPolicy = loadHostHooksPolicyFromProject(projectRoot),
16
+ /** Test seam for the #3987 tool-surface coverage probe. */
17
+ inspectCoverage = inspectHostToolCoverage) {
15
18
  const root = resolve(projectRoot);
16
19
  if (!isDirectory(root)) {
17
20
  return {
@@ -19,9 +22,11 @@ export function evaluateAgentHooks(projectRoot, hostHooksPolicy = loadHostHooksP
19
22
  message: `❌ deft agent hooks: project root ${root} does not exist (config error).`,
20
23
  stream: "stderr",
21
24
  registrations: [],
25
+ coverage: [],
22
26
  };
23
27
  }
24
28
  const registrations = inspectAgentHookDeposit(root, hostHooksPolicy);
29
+ const coverage = inspectCoverage(root, hostHooksPolicy);
25
30
  const unhealthy = registrations.filter((entry) => entry.status === "missing" || entry.status === "drifted");
26
31
  if (unhealthy.length > 0) {
27
32
  return {
@@ -34,6 +39,25 @@ export function evaluateAgentHooks(projectRoot, hostHooksPolicy = loadHostHooksP
34
39
  UNUSED_HOST_HOOKS_RECOVERY,
35
40
  stream: "stderr",
36
41
  registrations,
42
+ coverage,
43
+ };
44
+ }
45
+ // #3987: a structurally current registration can still leave a host tool
46
+ // uncovered, which is the failure this issue is about — a deposit the host
47
+ // never matches is enforcement that never runs.
48
+ if (coverage.length > 0) {
49
+ return {
50
+ code: 1,
51
+ message: "❌ deft agent hook tool-surface coverage INCOMPLETE:\n" +
52
+ coverage
53
+ .map((finding) => ` - ${finding.host}: ${finding.kind}` +
54
+ `${finding.toolName === null ? "" : ` \`${finding.toolName}\``} ` +
55
+ `at ${finding.path} — ${finding.detail}`)
56
+ .join("\n") +
57
+ `\n ${HOST_TOOL_COVERAGE_RECOVERY}`,
58
+ stream: "stderr",
59
+ registrations,
60
+ coverage,
37
61
  };
38
62
  }
39
63
  const disabledHosts = registrations
@@ -48,9 +72,11 @@ export function evaluateAgentHooks(projectRoot, hostHooksPolicy = loadHostHooksP
48
72
  "DEFT_HOOK_READ_ONLY=1 and explore subagent_type. Codex runtime trust is user-controlled and must be reviewed with `/hooks`; shell/MCP policy is deferred." +
49
73
  (disabledHosts.length > 0
50
74
  ? ` Intentional hostHooks disabled: ${disabledHosts.join(", ")}.`
51
- : ""),
75
+ : "") +
76
+ " Per-host mutation tool names verified against the deposited matchers (#3987).",
52
77
  stream: "stdout",
53
78
  registrations,
79
+ coverage,
54
80
  };
55
81
  }
56
82
  //# sourceMappingURL=agent-hooks.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.109.0",
3
+ "version": "0.109.1",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -390,8 +390,8 @@
390
390
  "provenance": true
391
391
  },
392
392
  "dependencies": {
393
- "@deftai/directive-content": "^0.109.0",
394
- "@deftai/directive-types": "^0.109.0",
393
+ "@deftai/directive-content": "^0.109.1",
394
+ "@deftai/directive-types": "^0.109.1",
395
395
  "archiver": "^8.0.0"
396
396
  },
397
397
  "scripts": {