@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.
- package/dist/design-critique/completed-arc-record.d.ts +8 -1
- package/dist/design-critique/completed-arc-record.js +43 -4
- package/dist/hooks/classify/host-session-identity.d.ts +1 -1
- package/dist/hooks/classify/host-session-identity.js +23 -5
- package/dist/hooks/dispatcher.d.ts +17 -1
- package/dist/hooks/dispatcher.js +83 -7
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/owner-liveness.d.ts +92 -0
- package/dist/hooks/owner-liveness.js +103 -0
- package/dist/hooks/tools.d.ts +47 -18
- package/dist/hooks/tools.js +82 -16
- package/dist/init-deposit/agent-hooks.d.ts +10 -0
- package/dist/init-deposit/agent-hooks.js +39 -0
- package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
- package/dist/init-deposit/host-tool-coverage.js +150 -0
- package/dist/init-deposit/index.d.ts +1 -0
- package/dist/init-deposit/index.js +1 -0
- package/dist/orchestration/subagent-monitor.d.ts +6 -0
- package/dist/orchestration/subagent-monitor.js +23 -1
- package/dist/session/child-occupancy.d.ts +72 -0
- package/dist/session/child-occupancy.js +209 -0
- package/dist/session/host-session-owner.d.ts +40 -0
- package/dist/session/host-session-owner.js +64 -5
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +1 -0
- package/dist/session/occupancy.d.ts +89 -4
- package/dist/session/occupancy.js +211 -31
- package/dist/swarm/complete-cohort.js +2 -0
- package/dist/swarm/pre-dispatch.js +2 -0
- package/dist/swarm/subagent-status-dir.d.ts +2 -1
- package/dist/swarm/subagent-status-dir.js +11 -2
- package/dist/swarm/worktrees.js +2 -0
- package/dist/verify-env/agent-hooks.d.ts +6 -1
- package/dist/verify-env/agent-hooks.js +28 -2
- 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 {
|
|
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
|
|
262
|
-
* the
|
|
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
|
|
271
|
-
if (
|
|
272
|
-
return
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
"
|
|
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
|
package/dist/swarm/worktrees.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
394
|
-
"@deftai/directive-types": "^0.109.
|
|
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": {
|