@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
@@ -15,7 +15,14 @@ export type ThreadComment = {
15
15
  readonly id: number;
16
16
  readonly body: string;
17
17
  };
18
- export type CompletedArcBlockReason = "missing-record" | "lone-shape" | "cite-not-lean" | "missing-table-cite" | "ambiguous-table-cite";
18
+ /**
19
+ * Closed reason set, published in `content/contracts/design-critique.md`
20
+ * `### One parser, set membership, observed diagnostics`. The union is derived
21
+ * from this array so a member added here without a contract row fails the
22
+ * content-contract suite (#3942).
23
+ */
24
+ export declare const COMPLETED_ARC_BLOCK_REASONS: readonly ["missing-record", "lone-shape", "cite-not-lean", "missing-table-cite", "unshaped-table-cite", "ambiguous-table-cite"];
25
+ export type CompletedArcBlockReason = (typeof COMPLETED_ARC_BLOCK_REASONS)[number];
19
26
  export type CompletedArcVerdict = {
20
27
  readonly status: "not-in-arc";
21
28
  } | {
@@ -13,6 +13,20 @@
13
13
  */
14
14
  import { ACCEPTED_CITATION_FORMS, scanCitations, } from "./citation-grammar.js";
15
15
  import { DESIGN_CRITIQUE_CATALOG_CHIPS } from "./exclusive-chip.js";
16
+ /**
17
+ * Closed reason set, published in `content/contracts/design-critique.md`
18
+ * `### One parser, set membership, observed diagnostics`. The union is derived
19
+ * from this array so a member added here without a contract row fails the
20
+ * content-contract suite (#3942).
21
+ */
22
+ export const COMPLETED_ARC_BLOCK_REASONS = [
23
+ "missing-record",
24
+ "lone-shape",
25
+ "cite-not-lean",
26
+ "missing-table-cite",
27
+ "unshaped-table-cite",
28
+ "ambiguous-table-cite",
29
+ ];
16
30
  const SYNTHESIS_SHAPE_RE = /(?:^|\n)\s*design-critique:\s*synthesis accepted,\s*because\b/i;
17
31
  const LEAN_HEADING_RE = /(?:^|\n)\s*\*{0,2}Lean:\*{0,2}/;
18
32
  const TABLE_HEADING_RE = /(?:^|\n)\s*##\s+Verified-claims table\b/;
@@ -148,6 +162,14 @@ function resolveCitedLean(citations, byCommentId, latestLean) {
148
162
  * `comment` citations scan as kind `comment`, and that is the published form
149
163
  * these records use. Narrowing the citation contract so a table must be named
150
164
  * by keyword is a separate decision needing its own migration criteria.
165
+ *
166
+ * Refusal partition (#3942). A typed claim fails in two states, and only one of
167
+ * them is fixed by adding the heading: the id is not a comment on this thread,
168
+ * or it is a comment whose body fails `isVerifiedClaimsTableBody`. One reason
169
+ * and one detail for both asserted the first in either case, so an author whose
170
+ * table is on the thread read a true citation being called false and had no
171
+ * path to the missing heading. Absent ids rank first because a body that is not
172
+ * there cannot be given a heading.
151
173
  */
152
174
  function resolveCitedTable(citations, byCommentId) {
153
175
  const claimed = citations.filter((row) => row.kind === "table");
@@ -160,13 +182,30 @@ function resolveCitedTable(citations, byCommentId) {
160
182
  };
161
183
  }
162
184
  const resolved = claimed.map((row) => ({ id: row.id, cited: byCommentId.get(row.id) }));
163
- const unresolved = resolved.filter((row) => row.cited === undefined || !isVerifiedClaimsTableBody(row.cited.body));
164
- if (unresolved.length > 0) {
185
+ const absent = resolved.filter((row) => row.cited === undefined).map((row) => row.id);
186
+ const unshaped = resolved
187
+ .filter((row) => row.cited !== undefined && !isVerifiedClaimsTableBody(row.cited.body))
188
+ .map((row) => row.id);
189
+ if (absent.length > 0) {
165
190
  return {
166
191
  ok: false,
167
192
  reason: "missing-table-cite",
168
- detail: "synthesis cites a verified-claims table id that is not a table on this thread: " +
169
- renderIds(unresolved.map((row) => row.id)),
193
+ detail: "synthesis cites a verified-claims table id that is not a comment on this thread: " +
194
+ renderIds(absent) +
195
+ (unshaped.length > 0
196
+ ? "; also cited, on this thread and carrying no verified-claims-table heading: " +
197
+ renderIds(unshaped)
198
+ : ""),
199
+ };
200
+ }
201
+ if (unshaped.length > 0) {
202
+ return {
203
+ ok: false,
204
+ reason: "unshaped-table-cite",
205
+ detail: "synthesis cites a verified-claims table id that is a comment on this thread but " +
206
+ "opens no line with the `## Verified-claims table` heading: " +
207
+ renderIds(unshaped) +
208
+ "; add that heading to the cited comment",
170
209
  };
171
210
  }
172
211
  const distinct = [...new Set(claimed.map((row) => row.id))];
@@ -44,7 +44,7 @@ export declare function resolveHookHostIdentity(host: string, payload: unknown,
44
44
  * still denies. A present-but-malformed variable is `invalid` and fails closed.
45
45
  */
46
46
  export declare function hostIdentityFallsBackToExplicitOwner(host: string, resolution: HookHostIdentityResolution): boolean;
47
- export declare const EXACT_LIFECYCLE_VERBS: readonly ["session:start", "session:ready", "session:end", "occupancy:steal", "occupancy:release", "occupancy:heartbeat", "swarm:launch"];
47
+ export declare const EXACT_LIFECYCLE_VERBS: readonly ["session:start", "session:ready", "session:end", "occupancy:steal", "occupancy:release", "occupancy:heartbeat", "occupancy:grant", "swarm:launch"];
48
48
  export type ExactLifecycleVerb = (typeof EXACT_LIFECYCLE_VERBS)[number];
49
49
  export interface ExactLifecycleCommandRewrite {
50
50
  readonly kind: "rewrite";
@@ -7,7 +7,7 @@
7
7
  * `session/host-session-owner.ts` because the CLI claim path resolves the same
8
8
  * owner from the same host (#3873).
9
9
  */
10
- import { ambientHostSessionOwner, canonicalHostSessionId, HOST_IDENTITY_PROVIDERS, hookHostIdentitySource, isUsableHostSessionId, MAX_HOOK_HOST_IDENTITY_UTF8_BYTES, readHostEnvIdentity, } from "../../session/host-session-owner.js";
10
+ import { ambientHostSessionOwner, CANONICAL_OWNER_PATTERN, canonicalHostSessionId, HOST_IDENTITY_PROVIDERS, hookHostIdentitySource, isUsableHostSessionId, MAX_HOOK_HOST_IDENTITY_UTF8_BYTES, readHostEnvIdentity, } from "../../session/host-session-owner.js";
11
11
  import { isShellTool } from "../tools.js";
12
12
  import { record, toolInputRecord } from "./payload.js";
13
13
  import { hookToolName } from "./tool-name.js";
@@ -100,6 +100,10 @@ export const EXACT_LIFECYCLE_VERBS = [
100
100
  "occupancy:steal",
101
101
  "occupancy:release",
102
102
  "occupancy:heartbeat",
103
+ // #3954 item 2: `occupancy:grant` (and its `--revoke` arm) was the only
104
+ // occupancy lifecycle verb absent from this table, so the one verb that must
105
+ // be run by the occupant had no way to be told who the occupant is.
106
+ "occupancy:grant",
103
107
  "swarm:launch",
104
108
  ];
105
109
  const DIRECT_LIFECYCLE_VERBS = {
@@ -109,12 +113,9 @@ const DIRECT_LIFECYCLE_VERBS = {
109
113
  "occupancy:steal": "occupancy:steal",
110
114
  "occupancy:release": "occupancy:release",
111
115
  "occupancy:heartbeat": "occupancy:heartbeat",
116
+ "occupancy:grant": "occupancy:grant",
112
117
  "swarm-launch": "swarm:launch",
113
118
  };
114
- // Derived from the provider list so the rewrite surface cannot drift from the
115
- // identity surface: a provider added to one is added to both (#3873). Provider
116
- // ids are lowercase ASCII words, so the alternation needs no escaping.
117
- const CANONICAL_OWNER_PATTERN = new RegExp(`^host:(?:${HOST_IDENTITY_PROVIDERS.join("|")}):v1:[A-Za-z0-9_-]+$`);
118
119
  // Shell expansion markers are deliberately absent: `$`/backticks for POSIX,
119
120
  // `@` splatting for PowerShell, and `%NAME%` expansion for command shells.
120
121
  // Backslashes are inspectable so Windows path-bearing lifecycle commands fail
@@ -176,6 +177,23 @@ const LIFECYCLE_ARGUMENT_POLICIES = {
176
177
  valueFlags: new Set(["--session-id", "--project-root"]),
177
178
  rewriteUnsafeFlags: new Set(["--project-root"]),
178
179
  },
180
+ "occupancy:grant": {
181
+ booleanFlags: new Set(["--revoke"]),
182
+ valueFlags: new Set([
183
+ "--session-id",
184
+ "--child-session-id",
185
+ "--role",
186
+ "--worktree",
187
+ "--ttl-minutes",
188
+ "--host",
189
+ "--address",
190
+ "--join-protocol",
191
+ "--project-root",
192
+ ]),
193
+ // `--worktree` rebinds the tree the grant covers, so it joins the
194
+ // path/destination flags kept outside the auto-approved rewrite surface.
195
+ rewriteUnsafeFlags: new Set(["--project-root", "--worktree"]),
196
+ },
179
197
  "swarm:launch": {
180
198
  booleanFlags: new Set([
181
199
  "--autonomous",
@@ -5,9 +5,10 @@ import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
5
5
  import { type GitRunner } from "../session/git.js";
6
6
  import { type DetectWorkSelection, type RitualRunner, type VerifyResult } from "../session/verify-session-ritual.js";
7
7
  import { type HookPayloadContext } from "./classify/index.js";
8
+ import { type OwnerLivenessInput, type OwnerLivenessOutcome } from "./owner-liveness.js";
8
9
  import { type ActiveScopeInspection } from "./scope.js";
9
10
  export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
10
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
11
+ export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, HOST_TOOL_SURFACE_AUDIT, type HostMutationToolCatalog, type HostToolSurfaceAudit, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
11
12
  export declare const HOOK_HOSTS: readonly ["claude", "grok", "cursor", "codex"];
12
13
  export type HookHost = (typeof HOOK_HOSTS)[number];
13
14
  export declare const HOOK_EVENTS: readonly ["session.start", "session.compact", "tool.before"];
@@ -126,6 +127,8 @@ export interface HookPolicySeams {
126
127
  readonly realpathLifecycleExecutionRoot?: (path: string) => string;
127
128
  /** Test seam for Windows drive-only execution-root payloads (#2787). */
128
129
  readonly lifecycleExecutionPlatform?: NodeJS.Platform;
130
+ /** Test seam for the #3987 post-decision owner-liveness re-stamp. */
131
+ readonly restampOwnerLiveness?: (input: OwnerLivenessInput) => OwnerLivenessOutcome;
129
132
  }
130
133
  /** POSIX-ish project-relative path for lifecycle matching. */
131
134
  export declare function toProjectRelativePosix(projectRoot: string, targetPath: string): string;
@@ -200,6 +203,19 @@ export interface EffectiveHookRootAdmission {
200
203
  * target that resolved to some other toplevel whose identity cannot be read, is
201
204
  * a question asked and left unanswered — that fails closed rather than
202
205
  * inheriting payloadRoot's occupancy and ritual state (#3794).
206
+ *
207
+ * The `candidateRaw === null` fallback below — a target whose nearest existing
208
+ * ancestor has no Git toplevel at all (OS temp, a home file, anything outside
209
+ * every checkout) — is the third case and is deliberate, not a gap (#4013).
210
+ * `effectiveRoot` selects occupancy, ritual, active scope, the write fence and
211
+ * assist-scratch, so returning no root would drop all of them for an
212
+ * out-of-tree write, not just the lease; occupancy and ritual are cross-checked
213
+ * and the allow path re-stamps the lease, so exempting the lease alone is not a
214
+ * complete state transition; and `admitMutationTargetSet` demands one unique
215
+ * root, so a member contributing none has no defined combining rule and an
216
+ * untrusted patch path would become an authority-selection input. Gate-by-gate
217
+ * disposition, the three-surface matrix and the relative-target
218
+ * canonicalization limitation (#4023): content/docs/hook-root-admission.md.
203
219
  */
204
220
  export declare function admitEffectiveHookRoot(payloadRoot: string, writeTarget: string | null, runGit: GitRunner): EffectiveHookRootAdmission;
205
221
  /**
@@ -19,6 +19,7 @@ import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRit
19
19
  import { hookApplyPatchBodyPaths, hookApplyPatchBodyText, hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, hostIdentityFallsBackToExplicitOwner, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
20
20
  import { classifyGitDestructive, classifyProductDestForms, payloadWithInjectedWriteTarget, } from "./dest-form.js";
21
21
  import { appendGitDestructiveRecord, GIT_DESTRUCTIVE_LOG_ENV } from "./git-destructive-log.js";
22
+ import { restampOwnerLivenessOnHookEvent, } from "./owner-liveness.js";
22
23
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
23
24
  import { inspectActiveScope } from "./scope.js";
24
25
  import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
@@ -26,7 +27,7 @@ import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.
26
27
  // Pure parse/classify helpers are defined in ./classify/ and re-exported from
27
28
  // ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
28
29
  export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
29
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
30
+ export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, HOST_TOOL_SURFACE_AUDIT, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
30
31
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
31
32
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
32
33
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
@@ -330,6 +331,19 @@ function admitPayload(payload, candidate) {
330
331
  * target that resolved to some other toplevel whose identity cannot be read, is
331
332
  * a question asked and left unanswered — that fails closed rather than
332
333
  * inheriting payloadRoot's occupancy and ritual state (#3794).
334
+ *
335
+ * The `candidateRaw === null` fallback below — a target whose nearest existing
336
+ * ancestor has no Git toplevel at all (OS temp, a home file, anything outside
337
+ * every checkout) — is the third case and is deliberate, not a gap (#4013).
338
+ * `effectiveRoot` selects occupancy, ritual, active scope, the write fence and
339
+ * assist-scratch, so returning no root would drop all of them for an
340
+ * out-of-tree write, not just the lease; occupancy and ritual are cross-checked
341
+ * and the allow path re-stamps the lease, so exempting the lease alone is not a
342
+ * complete state transition; and `admitMutationTargetSet` demands one unique
343
+ * root, so a member contributing none has no defined combining rule and an
344
+ * untrusted patch path would become an authority-selection input. Gate-by-gate
345
+ * disposition, the three-surface matrix and the relative-target
346
+ * canonicalization limitation (#4023): content/docs/hook-root-admission.md.
333
347
  */
334
348
  export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
335
349
  const payload = normalizeHookProjectRoot(payloadRoot);
@@ -787,6 +801,13 @@ function inspectMutationGates(input, toolName, seams, options) {
787
801
  ? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
788
802
  : admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
789
803
  const effectiveRoot = admission.root;
804
+ if (options.observation !== undefined) {
805
+ if (!options.observation.effectiveRoots.includes(effectiveRoot)) {
806
+ options.observation.effectiveRoots.push(effectiveRoot);
807
+ }
808
+ if (admission.foreign)
809
+ options.observation.foreignTarget = true;
810
+ }
790
811
  const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
791
812
  if (admission.foreign) {
792
813
  const candidate = admission.candidate ?? "<none>";
@@ -1224,7 +1245,7 @@ function decideGitDestructive(input, toolName) {
1224
1245
  * Authorize recognized in-repo Shell file-write dests through inspectMutationGates (#3983 / #3987).
1225
1246
  * Deny is returned; allow falls through so dest-forms and push/merge still run.
1226
1247
  */
1227
- function decideShellWriteReissue(input, toolName, seams) {
1248
+ function decideShellWriteReissue(input, toolName, seams, observation) {
1228
1249
  const command = hookShellCommand(input.payload);
1229
1250
  if (command === null)
1230
1251
  return null;
@@ -1245,6 +1266,7 @@ function decideShellWriteReissue(input, toolName, seams) {
1245
1266
  };
1246
1267
  const destDecision = inspectMutationGates(destInput, toolName, seams, {
1247
1268
  proposedLifecycleExempt: true,
1269
+ observation,
1248
1270
  });
1249
1271
  if (destDecision.verdict === "deny")
1250
1272
  return destDecision;
@@ -1255,7 +1277,7 @@ function decideShellWriteReissue(input, toolName, seams) {
1255
1277
  * Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
1256
1278
  * Dest-form allow is kept when runtime authority has nothing classifiable.
1257
1279
  */
1258
- function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
1280
+ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation) {
1259
1281
  const command = hookShellCommand(input.payload);
1260
1282
  let destAllow = null;
1261
1283
  let expansionDeny = null;
@@ -1287,6 +1309,7 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
1287
1309
  };
1288
1310
  const destDecision = inspectMutationGates(destInput, toolName, seams, {
1289
1311
  proposedLifecycleExempt: true,
1312
+ observation,
1290
1313
  });
1291
1314
  if (destDecision.verdict === "deny")
1292
1315
  return destDecision;
@@ -1463,8 +1486,55 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1463
1486
  }
1464
1487
  return { ...decision, updatedInput: rewrite.updatedInput };
1465
1488
  }
1489
+ /**
1490
+ * Renew the owner's occupancy lease from a hook event that already proved the
1491
+ * owner is present (#3987).
1492
+ *
1493
+ * Deliberately runs AFTER the decision and never feeds it. Two reasons: a
1494
+ * liveness re-stamp must not be able to change a verdict, and re-stamping first
1495
+ * would reset the shared age floor and so suppress the mutation gate's own
1496
+ * `markWrite = true` refresh — leaving an actively-writing owner recorded as
1497
+ * having no recorded write. Failure is swallowed: liveness is bookkeeping, and
1498
+ * a lease that cannot be renewed simply ages out as it did before.
1499
+ */
1500
+ function restampOwnerLiveness(input, seams, observation) {
1501
+ if (input.event !== "tool.before")
1502
+ return;
1503
+ // A refused admission proves no tree at all, so there is nothing to renew.
1504
+ if (observation.foreignTarget)
1505
+ return;
1506
+ try {
1507
+ const actor = resolveMutationActor(input, input.environ ?? process.env);
1508
+ const restamp = seams.restampOwnerLiveness ?? restampOwnerLivenessOnHookEvent;
1509
+ // The trees occupancy was authorized against, not the payload root. Each
1510
+ // was admitted on its own, so each is renewed on its own.
1511
+ const roots = observation.effectiveRoots.length > 0
1512
+ ? observation.effectiveRoots
1513
+ : [resolve(input.projectRoot)];
1514
+ for (const projectRoot of roots) {
1515
+ restamp({
1516
+ projectRoot,
1517
+ ownerSessionId: actor.sessionId,
1518
+ hostAuthoritative: actor.hostAuthoritative && actor.issue === null,
1519
+ });
1520
+ }
1521
+ }
1522
+ catch {
1523
+ /* liveness is best-effort bookkeeping; never let it affect a verdict */
1524
+ }
1525
+ }
1466
1526
  /** Decide a normalized event using only the P0 direct-write policy. */
1467
1527
  export function decideHook(input, seams = {}) {
1528
+ const observation = { effectiveRoots: [], foreignTarget: false };
1529
+ const decision = routeHookDecision(input, seams, observation);
1530
+ // Skipped for the #3039 kill-switch and #2926 opt-out, which short-circuit
1531
+ // before any Directive enforcement runs — including this bookkeeping.
1532
+ if (decision.code !== "directive-disabled" && decision.code !== "session-start-disabled") {
1533
+ restampOwnerLiveness(input, seams, observation);
1534
+ }
1535
+ return decision;
1536
+ }
1537
+ function routeHookDecision(input, seams, observation) {
1468
1538
  const projectRoot = resolve(input.projectRoot);
1469
1539
  // #3039: local (untracked) `.deft-directive-disable` wins for enforcement
1470
1540
  // short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
@@ -1657,7 +1727,10 @@ export function decideHook(input, seams = {}) {
1657
1727
  scopePath: null,
1658
1728
  };
1659
1729
  }
1660
- return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
1730
+ return inspectMutationGates(input, toolName, seams, {
1731
+ proposedLifecycleExempt: false,
1732
+ observation,
1733
+ });
1661
1734
  }
1662
1735
  // Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
1663
1736
  // Dest-forms (#3438): recognized product mutations share inspectMutationGates
@@ -1668,11 +1741,11 @@ export function decideHook(input, seams = {}) {
1668
1741
  if (destructive !== null) {
1669
1742
  return attachLifecycleIdentityRewrite(input, toolName, destructive, seams);
1670
1743
  }
1671
- const writeReissue = decideShellWriteReissue(input, toolName, seams);
1744
+ const writeReissue = decideShellWriteReissue(input, toolName, seams, observation);
1672
1745
  if (writeReissue !== null) {
1673
1746
  return attachLifecycleIdentityRewrite(input, toolName, writeReissue, seams);
1674
1747
  }
1675
- const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1748
+ const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation);
1676
1749
  return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
1677
1750
  }
1678
1751
  // Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
@@ -1693,7 +1766,10 @@ export function decideHook(input, seams = {}) {
1693
1766
  scopePath: null,
1694
1767
  };
1695
1768
  }
1696
- return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: true });
1769
+ return inspectMutationGates(input, toolName, seams, {
1770
+ proposedLifecycleExempt: true,
1771
+ observation,
1772
+ });
1697
1773
  }
1698
1774
  /**
1699
1775
  * Soft AGENTS re-bind text for host injection when decision carries soft path (#3171).
@@ -4,6 +4,7 @@ export * from "./dest-form.js";
4
4
  export * from "./dispatcher.js";
5
5
  export * from "./fixtures/index.js";
6
6
  export * from "./git-destructive-log.js";
7
+ export * from "./owner-liveness.js";
7
8
  export * from "./scope.js";
8
9
  export * from "./shell-write-targets.js";
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -4,6 +4,7 @@ export * from "./dest-form.js";
4
4
  export * from "./dispatcher.js";
5
5
  export * from "./fixtures/index.js";
6
6
  export * from "./git-destructive-log.js";
7
+ export * from "./owner-liveness.js";
7
8
  export * from "./scope.js";
8
9
  export * from "./shell-write-targets.js";
9
10
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Owner liveness on non-write hook activity (#3987).
3
+ *
4
+ * The occupancy lease renews on one signal: a gated product write
5
+ * (`evaluateOccupancyWriteGate`, refresh path, #3599). After #3990 widened the
6
+ * PreToolUse matchers, coverage is necessary and still not sufficient, because
7
+ * eligibility — not coverage — is what keeps shell traffic off that path. Only
8
+ * five write verbs are recognized, and a dest carrying `$` / `*` / `?`, a temp
9
+ * path, an out-of-root target, or any compound command is refused before the
10
+ * gate re-stamps. `cd <root>; <command>` is compound, and the mandated Windows
11
+ * body-file flow (#2646 / #2744) is compound, `$`-bearing and temp-targeted at
12
+ * once. So an owner can commit and push all session and still starve its own
13
+ * lease, which is the data-loss half of #3987.
14
+ *
15
+ * The remedy is the one `occupancy.ts` already names in the
16
+ * `OCCUPANCY_MAX_LEASE_MS` docblock: "refresh on non-write activity". The hook
17
+ * fires on every matched tool call and resolves the actor from the host
18
+ * payload, so the owner's presence is already proven there — no new identity
19
+ * has to travel anywhere.
20
+ *
21
+ * Bounds, each one load-bearing (5471374558 F4):
22
+ *
23
+ * - Host-authoritative actor only. An ambient inherited `DEFT_SESSION_ID` is
24
+ * never enough. On Grok the owner id is a hook-sibling variable, and
25
+ * publishing it so an agent shell could present it would hand lease renewal
26
+ * to every descendant process — the widening the arc withdrew.
27
+ * - Owner only, via `heartbeatOccupancy`: it never claims, never mints, never
28
+ * resurrects, and refuses a foreign or expired lease. Reusing it keeps one
29
+ * renewal path with one set of refusal semantics.
30
+ * - `markWrite` stays false, so `last_write_at` still means "a product write
31
+ * was recorded" and `no recorded write` stays honest for a would-be stealer.
32
+ * - Keyed on the lease's own worktree path, so a record describing another tree
33
+ * is not renewed from this one. The caller supplies the tree the mutation
34
+ * gates authorized against — a linked worktree, not the payload root — so a
35
+ * worktree write renews the worktree's lease rather than the primary's.
36
+ * - `claimed_at` is untouched, so `OCCUPANCY_MAX_LEASE_MS` — 12 hours — is
37
+ * unmoved. Liveness renewal cannot outlive the absolute cap.
38
+ * - Runs after the decision, never as an input to it. A liveness re-stamp must
39
+ * not change any verdict, and re-stamping before the mutation gates would
40
+ * suppress their own `markWrite = true` refresh by resetting the age floor.
41
+ *
42
+ * Bound this deliberately does not cross: a call with no write target proves
43
+ * only the tree the host named. `projectRootFromHookPayload` takes that from
44
+ * the payload's own `cwd`-class fields, so a session working inside a linked
45
+ * worktree names the worktree and renews it; a session whose host reports the
46
+ * primary checkout renews the primary. When those differ and nothing in the
47
+ * payload says which tree the work is in, this renews neither by guessing —
48
+ * keeping a lease alive for a tree nobody occupies is a widening, not a fix,
49
+ * and the TTL reclaiming an unused tree is the behaviour the lease exists for.
50
+ * `deft occupancy:heartbeat --session-id <owner>` stays the explicit path for
51
+ * a session whose tree the host does not report.
52
+ */
53
+ import type { LockDeps } from "../slice/lock.js";
54
+ export type OwnerLivenessSkipReason =
55
+ /** No host-authoritative owner resolved; ambient identity is not accepted. */
56
+ "no-host-authoritative-owner"
57
+ /** Nothing to renew: no lease, or the lease is expired or age-capped. */
58
+ | "no-live-lease"
59
+ /** A lease exists but this actor is not its owner. */
60
+ | "not-owner"
61
+ /** The lease record describes a different worktree than the one hooked. */
62
+ | "foreign-worktree"
63
+ /** Younger than the shared re-stamp floor; renewing would rewrite per keystroke. */
64
+ | "within-refresh-floor"
65
+ /** The lock was busy or the lease changed under us; the lease is left alone. */
66
+ | "refresh-unavailable";
67
+ export type OwnerLivenessOutcome = {
68
+ readonly restamped: true;
69
+ readonly sessionId: string;
70
+ readonly heartbeatAt: Date;
71
+ } | {
72
+ readonly restamped: false;
73
+ readonly reason: OwnerLivenessSkipReason;
74
+ };
75
+ export interface OwnerLivenessInput {
76
+ /** Tree the hook fired against; also the tree whose lease may be renewed. */
77
+ readonly projectRoot: string;
78
+ /** Owner id the host payload resolved to, or undefined when none did. */
79
+ readonly ownerSessionId: string | undefined;
80
+ /** False for ambient/environment identity — such an actor never renews. */
81
+ readonly hostAuthoritative: boolean;
82
+ readonly now?: Date;
83
+ readonly lockDeps?: LockDeps;
84
+ }
85
+ /**
86
+ * Renew the owner's lease from a hook event that proved the owner is alive.
87
+ *
88
+ * Pure side effect on `occupancy.json`: the caller's decision is already made
89
+ * and this never alters it. Every refusal path leaves the lease untouched.
90
+ */
91
+ export declare function restampOwnerLivenessOnHookEvent(input: OwnerLivenessInput): OwnerLivenessOutcome;
92
+ //# sourceMappingURL=owner-liveness.d.ts.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Owner liveness on non-write hook activity (#3987).
3
+ *
4
+ * The occupancy lease renews on one signal: a gated product write
5
+ * (`evaluateOccupancyWriteGate`, refresh path, #3599). After #3990 widened the
6
+ * PreToolUse matchers, coverage is necessary and still not sufficient, because
7
+ * eligibility — not coverage — is what keeps shell traffic off that path. Only
8
+ * five write verbs are recognized, and a dest carrying `$` / `*` / `?`, a temp
9
+ * path, an out-of-root target, or any compound command is refused before the
10
+ * gate re-stamps. `cd <root>; <command>` is compound, and the mandated Windows
11
+ * body-file flow (#2646 / #2744) is compound, `$`-bearing and temp-targeted at
12
+ * once. So an owner can commit and push all session and still starve its own
13
+ * lease, which is the data-loss half of #3987.
14
+ *
15
+ * The remedy is the one `occupancy.ts` already names in the
16
+ * `OCCUPANCY_MAX_LEASE_MS` docblock: "refresh on non-write activity". The hook
17
+ * fires on every matched tool call and resolves the actor from the host
18
+ * payload, so the owner's presence is already proven there — no new identity
19
+ * has to travel anywhere.
20
+ *
21
+ * Bounds, each one load-bearing (5471374558 F4):
22
+ *
23
+ * - Host-authoritative actor only. An ambient inherited `DEFT_SESSION_ID` is
24
+ * never enough. On Grok the owner id is a hook-sibling variable, and
25
+ * publishing it so an agent shell could present it would hand lease renewal
26
+ * to every descendant process — the widening the arc withdrew.
27
+ * - Owner only, via `heartbeatOccupancy`: it never claims, never mints, never
28
+ * resurrects, and refuses a foreign or expired lease. Reusing it keeps one
29
+ * renewal path with one set of refusal semantics.
30
+ * - `markWrite` stays false, so `last_write_at` still means "a product write
31
+ * was recorded" and `no recorded write` stays honest for a would-be stealer.
32
+ * - Keyed on the lease's own worktree path, so a record describing another tree
33
+ * is not renewed from this one. The caller supplies the tree the mutation
34
+ * gates authorized against — a linked worktree, not the payload root — so a
35
+ * worktree write renews the worktree's lease rather than the primary's.
36
+ * - `claimed_at` is untouched, so `OCCUPANCY_MAX_LEASE_MS` — 12 hours — is
37
+ * unmoved. Liveness renewal cannot outlive the absolute cap.
38
+ * - Runs after the decision, never as an input to it. A liveness re-stamp must
39
+ * not change any verdict, and re-stamping before the mutation gates would
40
+ * suppress their own `markWrite = true` refresh by resetting the age floor.
41
+ *
42
+ * Bound this deliberately does not cross: a call with no write target proves
43
+ * only the tree the host named. `projectRootFromHookPayload` takes that from
44
+ * the payload's own `cwd`-class fields, so a session working inside a linked
45
+ * worktree names the worktree and renews it; a session whose host reports the
46
+ * primary checkout renews the primary. When those differ and nothing in the
47
+ * payload says which tree the work is in, this renews neither by guessing —
48
+ * keeping a lease alive for a tree nobody occupies is a widening, not a fix,
49
+ * and the TTL reclaiming an unused tree is the behaviour the lease exists for.
50
+ * `deft occupancy:heartbeat --session-id <owner>` stays the explicit path for
51
+ * a session whose tree the host does not report.
52
+ */
53
+ import { resolve } from "node:path";
54
+ import { heartbeatOccupancy, isOccupancyExpired, OCCUPANCY_REFRESH_AFTER_MS, readOccupancy, } from "../session/occupancy.js";
55
+ function skip(reason) {
56
+ return { restamped: false, reason };
57
+ }
58
+ /**
59
+ * Renew the owner's lease from a hook event that proved the owner is alive.
60
+ *
61
+ * Pure side effect on `occupancy.json`: the caller's decision is already made
62
+ * and this never alters it. Every refusal path leaves the lease untouched.
63
+ */
64
+ export function restampOwnerLivenessOnHookEvent(input) {
65
+ if (!input.hostAuthoritative)
66
+ return skip("no-host-authoritative-owner");
67
+ const owner = input.ownerSessionId?.trim() ?? "";
68
+ if (owner.length === 0)
69
+ return skip("no-host-authoritative-owner");
70
+ const root = resolve(input.projectRoot);
71
+ const now = input.now ?? new Date();
72
+ const record = readOccupancy(root);
73
+ if (record === null || isOccupancyExpired(record, now))
74
+ return skip("no-live-lease");
75
+ // Owner only. A granted member's presence is not the owner's presence, and
76
+ // `heartbeatOccupancy` refuses it anyway — checking here keeps the reason honest.
77
+ if (record.sessionId !== owner)
78
+ return skip("not-owner");
79
+ if (resolve(record.worktreePath) !== root)
80
+ return skip("foreign-worktree");
81
+ // Same floor the write gate uses, for the same reason: the hook runs on a
82
+ // large fraction of tool calls, so an unconditional renew would rewrite the
83
+ // lease file continuously without lengthening the safe window at all.
84
+ if (now.getTime() - record.heartbeatAt.getTime() < OCCUPANCY_REFRESH_AFTER_MS) {
85
+ return skip("within-refresh-floor");
86
+ }
87
+ const beat = heartbeatOccupancy(root, {
88
+ sessionId: owner,
89
+ // Never fall back to an ambient owner: the resolved host identity is the
90
+ // only thing that proves who is here.
91
+ env: {},
92
+ now,
93
+ lockDeps: input.lockDeps,
94
+ });
95
+ if (beat.code !== 0 || beat.record === null)
96
+ return skip("refresh-unavailable");
97
+ return {
98
+ restamped: true,
99
+ sessionId: beat.record.sessionId,
100
+ heartbeatAt: beat.record.heartbeatAt,
101
+ };
102
+ }
103
+ //# sourceMappingURL=owner-liveness.js.map