@deftai/directive-core 0.108.0 → 0.109.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/cache/operations.js +1 -1
  2. package/dist/check/gate-lists.d.ts +14 -0
  3. package/dist/check/gate-lists.js +26 -3
  4. package/dist/check/session-completed-ac.d.ts +1 -1
  5. package/dist/check/session-completed-ac.js +1 -1
  6. package/dist/consumer-check-contract/evaluate.d.ts +47 -0
  7. package/dist/consumer-check-contract/evaluate.js +181 -15
  8. package/dist/delivery-attempt/evaluate.d.ts +9 -1
  9. package/dist/delivery-attempt/evaluate.js +69 -0
  10. package/dist/delivery-attempt/index.d.ts +1 -1
  11. package/dist/delivery-attempt/index.js +1 -1
  12. package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
  13. package/dist/deposit/live-procedure-exclusions.js +110 -0
  14. package/dist/deposit/live-procedure-targets.d.ts +45 -0
  15. package/dist/deposit/live-procedure-targets.js +274 -0
  16. package/dist/deposit/python-free.d.ts +6 -0
  17. package/dist/deposit/python-free.js +15 -0
  18. package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
  19. package/dist/deposit/rewrite-deposit-links.js +148 -0
  20. package/dist/deposit/run-stage-content-pack.d.ts +2 -0
  21. package/dist/deposit/run-stage-content-pack.js +3 -0
  22. package/dist/deposit/stage-content-pack.d.ts +17 -0
  23. package/dist/deposit/stage-content-pack.js +91 -0
  24. package/dist/design-critique/citation-grammar.d.ts +7 -0
  25. package/dist/design-critique/citation-grammar.js +1 -1
  26. package/dist/design-critique/completed-arc-record.d.ts +8 -1
  27. package/dist/design-critique/completed-arc-record.js +98 -14
  28. package/dist/hooks/classify/host-session-identity.d.ts +25 -12
  29. package/dist/hooks/classify/host-session-identity.js +64 -49
  30. package/dist/hooks/classify/index.d.ts +2 -2
  31. package/dist/hooks/classify/index.js +2 -2
  32. package/dist/hooks/classify/paths.d.ts +2 -0
  33. package/dist/hooks/classify/paths.js +8 -4
  34. package/dist/hooks/classify/stdin.js +69 -3
  35. package/dist/hooks/dest-form.d.ts +20 -1
  36. package/dist/hooks/dest-form.js +158 -21
  37. package/dist/hooks/dispatcher.d.ts +22 -2
  38. package/dist/hooks/dispatcher.js +338 -66
  39. package/dist/hooks/fixtures/cases.d.ts +6 -0
  40. package/dist/hooks/fixtures/cases.js +53 -0
  41. package/dist/hooks/git-destructive-log.d.ts +32 -0
  42. package/dist/hooks/git-destructive-log.js +46 -0
  43. package/dist/hooks/index.d.ts +3 -0
  44. package/dist/hooks/index.js +3 -0
  45. package/dist/hooks/owner-liveness.d.ts +92 -0
  46. package/dist/hooks/owner-liveness.js +103 -0
  47. package/dist/hooks/shell-write-targets.d.ts +10 -0
  48. package/dist/hooks/shell-write-targets.js +274 -0
  49. package/dist/hooks/tools.d.ts +49 -2
  50. package/dist/hooks/tools.js +96 -1
  51. package/dist/init-deposit/agent-hooks.d.ts +10 -0
  52. package/dist/init-deposit/agent-hooks.js +39 -0
  53. package/dist/init-deposit/gitignore.d.ts +7 -0
  54. package/dist/init-deposit/gitignore.js +24 -0
  55. package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
  56. package/dist/init-deposit/host-tool-coverage.js +150 -0
  57. package/dist/init-deposit/index.d.ts +1 -0
  58. package/dist/init-deposit/index.js +1 -0
  59. package/dist/init-deposit/init-deposit.js +3 -0
  60. package/dist/init-deposit/refresh.js +7 -0
  61. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  62. package/dist/init-deposit/runtime-writers.js +33 -0
  63. package/dist/intake/issue-ingest.d.ts +6 -1
  64. package/dist/intake/issue-ingest.js +21 -2
  65. package/dist/intake/reconcile-issues.js +13 -13
  66. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  67. package/dist/lifecycle/brief-envelope.js +42 -0
  68. package/dist/lifecycle/index.d.ts +1 -0
  69. package/dist/lifecycle/index.js +1 -0
  70. package/dist/literal-acceptance/evaluate.js +14 -5
  71. package/dist/literal-acceptance/index.d.ts +1 -1
  72. package/dist/literal-acceptance/index.js +1 -1
  73. package/dist/literal-acceptance/run.d.ts +2 -0
  74. package/dist/literal-acceptance/run.js +19 -1
  75. package/dist/orchestration/subagent-monitor.d.ts +6 -0
  76. package/dist/orchestration/subagent-monitor.js +23 -1
  77. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  78. package/dist/orphan-active/candidate-scope.js +157 -0
  79. package/dist/orphan-active/evaluate.d.ts +27 -0
  80. package/dist/orphan-active/evaluate.js +63 -6
  81. package/dist/orphan-active/index.d.ts +1 -0
  82. package/dist/orphan-active/index.js +1 -0
  83. package/dist/policy/merge-approval-head.js +7 -6
  84. package/dist/pr-closing-keywords/gh.js +32 -9
  85. package/dist/pr-closing-keywords/main.d.ts +1 -0
  86. package/dist/pr-closing-keywords/main.js +68 -3
  87. package/dist/pr-closing-keywords/types.d.ts +2 -0
  88. package/dist/pr-merge-readiness/gh.js +32 -9
  89. package/dist/pr-protected-issues/gh.js +36 -9
  90. package/dist/pr-wait-mergeable/wrappers.js +3 -3
  91. package/dist/product-first-done-gate/evaluate.js +6 -2
  92. package/dist/product-first-done-gate/types.js +2 -0
  93. package/dist/release/consumer-hard-stops.d.ts +48 -0
  94. package/dist/release/consumer-hard-stops.js +140 -0
  95. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  96. package/dist/release/consumer-readiness-disclosure.js +51 -0
  97. package/dist/release/index.d.ts +2 -0
  98. package/dist/release/index.js +2 -0
  99. package/dist/release/pipeline.js +55 -0
  100. package/dist/release/run-consumer-readiness.d.ts +15 -0
  101. package/dist/release/run-consumer-readiness.js +24 -0
  102. package/dist/release/types.d.ts +6 -0
  103. package/dist/review-monitor/github-lease.js +4 -3
  104. package/dist/run-summary/types.d.ts +2 -2
  105. package/dist/scm/build-command.d.ts +2 -2
  106. package/dist/scm/build-command.js +2 -2
  107. package/dist/scm/call-shape.d.ts +25 -0
  108. package/dist/scm/call-shape.js +59 -0
  109. package/dist/scm/call.d.ts +7 -4
  110. package/dist/scm/call.js +41 -11
  111. package/dist/scm/gh-rest.d.ts +25 -12
  112. package/dist/scm/gh-rest.js +65 -15
  113. package/dist/scm/index.d.ts +2 -0
  114. package/dist/scm/index.js +2 -0
  115. package/dist/scm/spawn-status.d.ts +33 -0
  116. package/dist/scm/spawn-status.js +53 -0
  117. package/dist/scope/acceptance-evidence.d.ts +1 -1
  118. package/dist/scope/transition.js +4 -14
  119. package/dist/session/ac-pass-banking.d.ts +2 -2
  120. package/dist/session/ac-pass-banking.js +2 -2
  121. package/dist/session/child-occupancy.d.ts +72 -0
  122. package/dist/session/child-occupancy.js +209 -0
  123. package/dist/session/host-session-owner.d.ts +93 -0
  124. package/dist/session/host-session-owner.js +148 -0
  125. package/dist/session/index.d.ts +2 -0
  126. package/dist/session/index.js +2 -0
  127. package/dist/session/occupancy.d.ts +107 -2
  128. package/dist/session/occupancy.js +272 -34
  129. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  130. package/dist/session/verify-ac-session-cache.js +2 -2
  131. package/dist/swarm/complete-cohort.js +2 -0
  132. package/dist/swarm/pre-dispatch.js +2 -0
  133. package/dist/swarm/subagent-status-dir.d.ts +2 -1
  134. package/dist/swarm/subagent-status-dir.js +11 -2
  135. package/dist/swarm/worktrees.js +2 -0
  136. package/dist/triage/evaluate/worktrees.js +153 -6
  137. package/dist/umbrella-current-shape/index.d.ts +51 -3
  138. package/dist/umbrella-current-shape/index.js +106 -18
  139. package/dist/validate-content/deposit-required.d.ts +39 -0
  140. package/dist/validate-content/deposit-required.js +147 -0
  141. package/dist/validate-content/index.d.ts +1 -0
  142. package/dist/validate-content/index.js +1 -0
  143. package/dist/validate-content/validate-links.d.ts +2 -3
  144. package/dist/validate-content/validate-links.js +29 -2
  145. package/dist/vbrief-activate/activate.d.ts +7 -2
  146. package/dist/vbrief-activate/activate.js +26 -13
  147. package/dist/verify-ac/evaluate.d.ts +9 -0
  148. package/dist/verify-ac/evaluate.js +32 -9
  149. package/dist/verify-env/agent-hooks.d.ts +6 -1
  150. package/dist/verify-env/agent-hooks.js +28 -2
  151. package/dist/verify-source/deposit-closure.d.ts +23 -0
  152. package/dist/verify-source/deposit-closure.js +162 -0
  153. package/dist/verify-source/index.d.ts +2 -0
  154. package/dist/verify-source/index.js +2 -0
  155. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  156. package/dist/verify-source/semantic-single-source.js +349 -0
  157. package/package.json +3 -3
@@ -16,15 +16,18 @@ import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js"
16
16
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
17
17
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
18
18
  import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRitual, WRITE_GATED_REQUIRED_STEPS, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
19
- import { hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
20
- import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
19
+ import { hookApplyPatchBodyPaths, hookApplyPatchBodyText, hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, hostIdentityFallsBackToExplicitOwner, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
20
+ import { classifyGitDestructive, classifyProductDestForms, payloadWithInjectedWriteTarget, } from "./dest-form.js";
21
+ import { appendGitDestructiveRecord, GIT_DESTRUCTIVE_LOG_ENV } from "./git-destructive-log.js";
22
+ import { restampOwnerLivenessOnHookEvent, } from "./owner-liveness.js";
21
23
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
22
24
  import { inspectActiveScope } from "./scope.js";
25
+ import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
23
26
  import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
24
27
  // Pure parse/classify helpers are defined in ./classify/ and re-exported from
25
28
  // ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
26
29
  export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
27
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, 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";
28
31
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
29
32
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
30
33
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
@@ -104,6 +107,66 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
104
107
  return false;
105
108
  return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
106
109
  }
110
+ /**
111
+ * ApplyPatch body present but no mutation header could be classified.
112
+ * Fail closed rather than authorizing the declared path alone (#3614).
113
+ */
114
+ function applyPatchBodyUnclassified(payload) {
115
+ const text = hookApplyPatchBodyText(payload);
116
+ return text !== null && hookApplyPatchBodyPaths(payload).length === 0;
117
+ }
118
+ /**
119
+ * Proposed-lifecycle exemption is universally quantified over every mutated
120
+ * path, not the declared write target alone (#3614).
121
+ */
122
+ function allMutationTargetsAreProposedLifecycleWrites(projectRoot, payload) {
123
+ if (applyPatchBodyUnclassified(payload))
124
+ return false;
125
+ const targets = hookMutationTargetPaths(payload);
126
+ if (targets.length === 0)
127
+ return false;
128
+ return targets.every((target) => isProposedLifecycleWrite(projectRoot, target));
129
+ }
130
+ function authzForMutationTargets(input, toolName, seams, options) {
131
+ const targets = hookMutationTargetPaths(input.payload);
132
+ const paths = targets.length > 0 ? targets : [null];
133
+ const pendingConsume = [];
134
+ for (const target of paths) {
135
+ const relPath = target !== null ? toProjectRelativePosix(options.projectRoot, target) : null;
136
+ const denied = authzForMutation(input, toolName, seams, {
137
+ isDirectWrite: options.isDirectWrite,
138
+ relPath,
139
+ scopePath: options.scopePath,
140
+ runGit: options.runGit,
141
+ consumeSingleUse: false,
142
+ pendingConsume,
143
+ });
144
+ if (denied !== null)
145
+ return denied;
146
+ }
147
+ const seen = new Set();
148
+ for (const ref of pendingConsume) {
149
+ if (seen.has(ref))
150
+ continue;
151
+ seen.add(ref);
152
+ try {
153
+ markGrantUsed(options.projectRoot, ref);
154
+ }
155
+ catch {
156
+ // Persistence failure must not flip allow after a successful check.
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+ function allMutationTargetsAreAssistScratch(projectRoot, payload, environ) {
162
+ if (applyPatchBodyUnclassified(payload))
163
+ return false;
164
+ const targets = hookMutationTargetPaths(payload);
165
+ if (targets.length === 0) {
166
+ return isAssistScratchWrite(projectRoot, hookWriteTargetPath(payload), payload, environ);
167
+ }
168
+ return targets.every((target) => isAssistScratchWrite(projectRoot, target, payload, environ));
169
+ }
107
170
  /**
108
171
  * Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
109
172
  * Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
@@ -268,6 +331,19 @@ function admitPayload(payload, candidate) {
268
331
  * target that resolved to some other toplevel whose identity cannot be read, is
269
332
  * a question asked and left unanswered — that fails closed rather than
270
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.
271
347
  */
272
348
  export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
273
349
  const payload = normalizeHookProjectRoot(payloadRoot);
@@ -373,19 +449,20 @@ function deny(input, code, toolName, message, scopePath = null) {
373
449
  }
374
450
  /**
375
451
  * Resolve the cooperative actor presented to the occupancy gate.
376
- * Supported payload hosts are payload-authoritative; ambient identity may only
377
- * corroborate. Grok retains the pre-#3611 environment path until its payload
378
- * contract is verified.
452
+ * A resolved host identity is authoritative; ambient identity may only
453
+ * corroborate. Hosts whose identity source is the hook environment fall back to
454
+ * the explicit owner flow when that variable is absent (#3873).
379
455
  */
380
456
  function resolveMutationActor(input, environ) {
381
- const hostIdentity = resolveHookHostIdentity(input.host, input.payload);
457
+ const hostIdentity = resolveHookHostIdentity(input.host, input.payload, environ);
382
458
  const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
383
- if (hostIdentity.status === "unsupported") {
459
+ if (hostIdentity.status === "unsupported" ||
460
+ hostIdentityFallsBackToExplicitOwner(input.host, hostIdentity)) {
384
461
  return {
385
462
  sessionId: environmentId,
386
463
  issue: null,
387
464
  message: null,
388
- payloadAuthoritative: false,
465
+ hostAuthoritative: false,
389
466
  };
390
467
  }
391
468
  if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
@@ -393,23 +470,23 @@ function resolveMutationActor(input, environ) {
393
470
  return {
394
471
  sessionId: hostIdentity.sessionId,
395
472
  issue: "conflict",
396
- message: `Host payload owner ${hostIdentity.sessionId} conflicts with ` +
473
+ message: `Host owner ${hostIdentity.sessionId} conflicts with ` +
397
474
  `DEFT_SESSION_ID ${environmentId}.`,
398
- payloadAuthoritative: true,
475
+ hostAuthoritative: true,
399
476
  };
400
477
  }
401
478
  return {
402
479
  sessionId: hostIdentity.sessionId,
403
480
  issue: null,
404
481
  message: null,
405
- payloadAuthoritative: true,
482
+ hostAuthoritative: true,
406
483
  };
407
484
  }
408
485
  return {
409
486
  sessionId: undefined,
410
487
  issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
411
488
  message: hostIdentity.message,
412
- payloadAuthoritative: true,
489
+ hostAuthoritative: true,
413
490
  };
414
491
  }
415
492
  function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
@@ -448,20 +525,28 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effec
448
525
  // Neither layer active → allow (same as disabled runtimeAuthority historically).
449
526
  if (!fence.fenceActive)
450
527
  return null;
451
- const writeTarget = hookWriteTargetPath(input.payload);
452
- const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
453
- const verdict = evaluateRuntimeAuthorityDirectWrite({
454
- policy: fence.policy,
455
- relPathPosix: relPath,
456
- });
457
- if (verdict.allowed)
458
- return null;
459
528
  // #3794: relPath is now measured from the tree the write lands in. When that
460
529
  // differs from the payload root, a bare "outside file_scope" reason does not
461
530
  // say which tree the fence was relativised against.
462
531
  const fenceRootNote = fenceRoot === projectRoot ? "" : ` ${formatHookRootNote(projectRoot, fenceRoot)}`;
463
- return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
464
- fenceRootNote, scopePath);
532
+ if (applyPatchBodyUnclassified(input.payload)) {
533
+ return deny(input, "runtime-policy-deny-path", toolName, "Directive denied this direct write: apply_patch body named no classifiable mutation target, so the write fence cannot authorize it." +
534
+ fenceRootNote, scopePath);
535
+ }
536
+ const targets = hookMutationTargetPaths(input.payload);
537
+ const toCheck = targets.length > 0 ? targets : [null];
538
+ for (const target of toCheck) {
539
+ const relPath = target !== null ? toProjectRelativePosix(fenceRoot, target) : null;
540
+ const verdict = evaluateRuntimeAuthorityDirectWrite({
541
+ policy: fence.policy,
542
+ relPathPosix: relPath,
543
+ });
544
+ if (!verdict.allowed) {
545
+ return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
546
+ fenceRootNote, scopePath);
547
+ }
548
+ }
549
+ return null;
465
550
  }
466
551
  function loadAuthzContext(projectRoot, seams) {
467
552
  if (seams.loadAuthzState !== undefined) {
@@ -582,12 +667,17 @@ function authzForMutation(input, toolName, seams, options) {
582
667
  }
583
668
  // Consume single-use grants after allow (persist usedAt).
584
669
  if (shouldConsumeSingleUseGrant(decision) && decision.humanApprovalRef !== null) {
585
- try {
586
- markGrantUsed(projectRoot, decision.humanApprovalRef);
670
+ if (options.consumeSingleUse === false) {
671
+ options.pendingConsume?.push(decision.humanApprovalRef);
587
672
  }
588
- catch {
589
- // Persistence failure must not flip allow → deny after a successful check;
590
- // next load will still see unused if write failed (prefer allow-once risk over deadlock).
673
+ else {
674
+ try {
675
+ markGrantUsed(projectRoot, decision.humanApprovalRef);
676
+ }
677
+ catch {
678
+ // Persistence failure must not flip allow → deny after a successful check;
679
+ // next load will still see unused if write failed (prefer allow-once risk over deadlock).
680
+ }
591
681
  }
592
682
  }
593
683
  }
@@ -711,6 +801,13 @@ function inspectMutationGates(input, toolName, seams, options) {
711
801
  ? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
712
802
  : admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
713
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
+ }
714
811
  const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
715
812
  if (admission.foreign) {
716
813
  const candidate = admission.candidate ?? "<none>";
@@ -727,17 +824,15 @@ function inspectMutationGates(input, toolName, seams, options) {
727
824
  // assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
728
825
  // Tracked product paths never match the path fence. Read-only still denied upstream.
729
826
  if (!isSpawnTool(toolName)) {
730
- const scratchTarget = hookWriteTargetPath(input.payload);
731
827
  // #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
732
828
  // file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
733
829
  // assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
734
- if (isAssistScratchWrite(effectiveRoot, scratchTarget, input.payload, environ)) {
735
- const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
736
- const authzDeny = authzForMutation(input, toolName, seams, {
830
+ if (allMutationTargetsAreAssistScratch(effectiveRoot, input.payload, environ)) {
831
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
737
832
  isDirectWrite: true,
738
- relPath,
739
833
  scopePath: null,
740
834
  runGit: dispatchGit,
835
+ projectRoot,
741
836
  });
742
837
  if (authzDeny !== null)
743
838
  return authzDeny;
@@ -769,8 +864,8 @@ function inspectMutationGates(input, toolName, seams, options) {
769
864
  }
770
865
  : evaluateOccupancyWriteGate(effectiveRoot, {
771
866
  sessionId: actor?.sessionId,
772
- // Payload-supported hosts must not fall back to a stale ambient owner.
773
- env: actor?.payloadAuthoritative === true ? {} : environ,
867
+ // Hosts with a resolved owner must not fall back to a stale ambient one.
868
+ env: actor?.hostAuthoritative === true ? {} : environ,
774
869
  });
775
870
  // #3769: occupancy decides before any ritual persist. The gated verifier
776
871
  // executes agent_hooks and rebinds a forward HEAD, and both rewrite
@@ -907,7 +1002,7 @@ function inspectMutationGates(input, toolName, seams, options) {
907
1002
  return null;
908
1003
  const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
909
1004
  sessionId: actor.sessionId,
910
- env: actor.payloadAuthoritative ? {} : environ,
1005
+ env: actor.hostAuthoritative ? {} : environ,
911
1006
  refresh: true,
912
1007
  });
913
1008
  occupancyWarning = finalOccupancy.warning;
@@ -941,15 +1036,15 @@ function inspectMutationGates(input, toolName, seams, options) {
941
1036
  };
942
1037
  const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
943
1038
  if (options.proposedLifecycleExempt) {
944
- const writeTarget = hookWriteTargetPath(input.payload);
945
- if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
946
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
1039
+ if (allMutationTargetsAreProposedLifecycleWrites(effectiveRoot, input.payload)) {
947
1040
  // UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
948
- const authzDeny = authzForMutation(input, toolName, seams, {
1041
+ // Authz and the write fence run on every mutated path, not the declared
1042
+ // one (#3614).
1043
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
949
1044
  isDirectWrite: true,
950
- relPath,
951
1045
  scopePath: null,
952
1046
  runGit: dispatchGit,
1047
+ projectRoot,
953
1048
  });
954
1049
  if (authzDeny !== null)
955
1050
  return authzDeny;
@@ -993,7 +1088,9 @@ function inspectMutationGates(input, toolName, seams, options) {
993
1088
  // worktree that lives outside the payload root keeps today's skip. Worktrees
994
1089
  // under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
995
1090
  // are inside it and are now gated on their own active scope.
996
- const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
1091
+ const mutationTargets = hookMutationTargetPaths(input.payload);
1092
+ const outsideRoot = mutationTargets.length > 0 &&
1093
+ mutationTargets.every((target) => isOutsideProjectRootWrite(projectRoot, target));
997
1094
  if (!outsideRoot || isSpawnTool(toolName)) {
998
1095
  let proposedPathHint;
999
1096
  if (isSpawnTool(toolName)) {
@@ -1054,14 +1151,12 @@ function inspectMutationGates(input, toolName, seams, options) {
1054
1151
  }
1055
1152
  }
1056
1153
  if (!isSpawnTool(toolName)) {
1057
- const writeTarget = hookWriteTargetPath(input.payload);
1058
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
1059
1154
  // Wave 1 authz (UAT lease + human-origin grant) before runtimeAuthority (#2944 / #2948 L1–L2).
1060
- const authzDeny = authzForMutation(input, toolName, seams, {
1155
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
1061
1156
  isDirectWrite: true,
1062
- relPath,
1063
1157
  scopePath: scope.path,
1064
1158
  runGit: dispatchGit,
1159
+ projectRoot,
1065
1160
  });
1066
1161
  if (authzDeny !== null)
1067
1162
  return authzDeny;
@@ -1083,11 +1178,106 @@ function inspectMutationGates(input, toolName, seams, options) {
1083
1178
  scopePath: scope.path,
1084
1179
  };
1085
1180
  }
1181
+ function gitDestructiveIsFixture(form, projectRoot) {
1182
+ if (form.unprovable || form.relocators.length === 0)
1183
+ return false;
1184
+ return form.relocators.every((raw) => {
1185
+ const path = raw.trim();
1186
+ if (path.length === 0 || !isAbsolute(path))
1187
+ return false;
1188
+ return isOutsideProjectRootWrite(projectRoot, path);
1189
+ });
1190
+ }
1191
+ const GIT_DESTRUCTIVE_REMEDIATION = "Tree-wide destructive git (reset --hard, clean -f, checkout -f / switch -f, " +
1192
+ "stash drop/clear) is fail-closed when aimed at this checkout. Rewrite as a " +
1193
+ "non-destructive command, or run it against an absolute out-of-root fixture " +
1194
+ "(`git -C /abs/fixture reset --hard`). This is a guard, not a root-cause claim.";
1195
+ /**
1196
+ * Always-on recognition for tree-wide destructive git (#3917). Independent of
1197
+ * `shellDestForms`. Records a durable JSONL line outside the repository so the
1198
+ * next occurrence names its actor even if reflogs are gone.
1199
+ */
1200
+ function decideGitDestructive(input, toolName) {
1201
+ const command = hookShellCommand(input.payload);
1202
+ if (command === null)
1203
+ return null;
1204
+ const forms = classifyGitDestructive(command);
1205
+ if (forms.length === 0)
1206
+ return null;
1207
+ const projectRoot = resolve(input.projectRoot);
1208
+ const env = input.environ ?? process.env;
1209
+ const actor = env.DEFT_SESSION_ID?.trim() || env.GROK_SESSION_ID?.trim() || "";
1210
+ const logPath = env[GIT_DESTRUCTIVE_LOG_ENV]?.trim() || undefined;
1211
+ let anyProjectAimed = false;
1212
+ for (const form of forms) {
1213
+ const fixture = gitDestructiveIsFixture(form, projectRoot);
1214
+ if (!fixture)
1215
+ anyProjectAimed = true;
1216
+ appendGitDestructiveRecord({
1217
+ ts: new Date().toISOString(),
1218
+ kind: form.kind,
1219
+ disposition: fixture ? "allow-fixture" : "deny",
1220
+ command,
1221
+ projectRoot,
1222
+ host: input.host,
1223
+ toolName,
1224
+ actor,
1225
+ pid: process.pid,
1226
+ relocators: form.relocators,
1227
+ unprovable: form.unprovable,
1228
+ }, { env, logPath });
1229
+ }
1230
+ if (!anyProjectAimed) {
1231
+ return {
1232
+ verdict: "allow",
1233
+ code: "git-destructive-fixture",
1234
+ event: input.event,
1235
+ host: input.host,
1236
+ toolName,
1237
+ projectRoot,
1238
+ message: `Directive allowed ${toolName}: destructive git is aimed at an out-of-root fixture.`,
1239
+ scopePath: null,
1240
+ };
1241
+ }
1242
+ return deny(input, "git-destructive-deny", toolName, `Directive denied ${toolName}: ${GIT_DESTRUCTIVE_REMEDIATION}`);
1243
+ }
1244
+ /**
1245
+ * Authorize recognized in-repo Shell file-write dests through inspectMutationGates (#3983 / #3987).
1246
+ * Deny is returned; allow falls through so dest-forms and push/merge still run.
1247
+ */
1248
+ function decideShellWriteReissue(input, toolName, seams, observation) {
1249
+ const command = hookShellCommand(input.payload);
1250
+ if (command === null)
1251
+ return null;
1252
+ const projectRoot = resolve(input.projectRoot);
1253
+ for (const dest of classifyShellWriteTargets(command)) {
1254
+ if (dest.unprovable === true) {
1255
+ if (!isInRepoShellWritePath(projectRoot, dest.path))
1256
+ continue;
1257
+ return deny(input, "scope-not-ready", toolName, "Directive denied " +
1258
+ toolName +
1259
+ ": Shell write dest is not reconstructable (compound command). Use one simple command or Edit/Write.");
1260
+ }
1261
+ if (!isInRepoShellWritePath(projectRoot, dest.path))
1262
+ continue;
1263
+ const destInput = {
1264
+ ...input,
1265
+ payload: payloadWithInjectedWriteTarget(input.payload, dest.path),
1266
+ };
1267
+ const destDecision = inspectMutationGates(destInput, toolName, seams, {
1268
+ proposedLifecycleExempt: true,
1269
+ observation,
1270
+ });
1271
+ if (destDecision.verdict === "deny")
1272
+ return destDecision;
1273
+ }
1274
+ return null;
1275
+ }
1086
1276
  /**
1087
1277
  * Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
1088
1278
  * Dest-form allow is kept when runtime authority has nothing classifiable.
1089
1279
  */
1090
- function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
1280
+ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation) {
1091
1281
  const command = hookShellCommand(input.payload);
1092
1282
  let destAllow = null;
1093
1283
  let expansionDeny = null;
@@ -1119,6 +1309,7 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
1119
1309
  };
1120
1310
  const destDecision = inspectMutationGates(destInput, toolName, seams, {
1121
1311
  proposedLifecycleExempt: true,
1312
+ observation,
1122
1313
  });
1123
1314
  if (destDecision.verdict === "deny")
1124
1315
  return destDecision;
@@ -1205,6 +1396,18 @@ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
1205
1396
  }
1206
1397
  return { aligned: true, message: "execution directory is project-root aligned" };
1207
1398
  }
1399
+ /**
1400
+ * Hosts whose PreToolUse wire format can carry a rewritten tool input.
1401
+ *
1402
+ * `renderHostDecision` reads the same list, so the bridge cannot offer -- or
1403
+ * deny on behalf of -- a delivery channel a host does not have (#3873). A host
1404
+ * outside it binds its claim in the CLI instead: `resolveOccupancySessionId`
1405
+ * resolves the same ambient host owner the write gate will present.
1406
+ */
1407
+ const UPDATED_INPUT_WIRE_HOSTS = ["codex", "claude", "cursor"];
1408
+ function hostAcceptsUpdatedInput(host) {
1409
+ return UPDATED_INPUT_WIRE_HOSTS.includes(host);
1410
+ }
1208
1411
  /**
1209
1412
  * Add the claim-time owner only after the existing shell decision allowed the
1210
1413
  * command. The host wire format must emit `allow` with updated input, so this
@@ -1213,6 +1416,8 @@ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
1213
1416
  function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1214
1417
  if (decision.verdict !== "allow")
1215
1418
  return decision;
1419
+ if (!hostAcceptsUpdatedInput(input.host))
1420
+ return decision;
1216
1421
  const lifecycle = inspectExactLifecycleCommand(input.payload);
1217
1422
  if (lifecycle === null)
1218
1423
  return decision;
@@ -1222,15 +1427,20 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1222
1427
  return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1223
1428
  "--session-id is empty, duplicated, or otherwise ambiguous.");
1224
1429
  }
1225
- const identity = resolveHookHostIdentity(input.host, input.payload);
1226
- if (identity.status === "unsupported")
1430
+ const identity = resolveHookHostIdentity(input.host, input.payload, input.environ ?? process.env);
1431
+ // #3873: an absent host-env variable is the pre-#3873 explicit-owner flow, not
1432
+ // a broken contract. Claim binding is skipped, the command keeps ordinary host
1433
+ // permission handling, and any owner it carries is still its own.
1434
+ if (identity.status === "unsupported" ||
1435
+ hostIdentityFallsBackToExplicitOwner(input.host, identity)) {
1227
1436
  return decision;
1437
+ }
1228
1438
  if (identity.status !== "ok" || identity.sessionId === null) {
1229
1439
  const code = identity.status === "conflict"
1230
1440
  ? "occupancy-identity-conflict"
1231
1441
  : "occupancy-identity-unavailable";
1232
1442
  return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1233
- `${identity.message ?? "host payload identity is unavailable"}. ` +
1443
+ `${identity.message ?? "host session identity is unavailable"}. ` +
1234
1444
  "Directive cannot bind the occupancy claim without a stable host owner; " +
1235
1445
  "manual callers must pass --session-id explicitly outside the host rewrite path.");
1236
1446
  }
@@ -1238,12 +1448,12 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1238
1448
  if (environmentId !== undefined &&
1239
1449
  environmentId.length > 0 &&
1240
1450
  environmentId !== identity.sessionId) {
1241
- return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host payload owner ${identity.sessionId} conflicts with ` +
1451
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host owner ${identity.sessionId} conflicts with ` +
1242
1452
  `DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
1243
1453
  }
1244
1454
  if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
1245
1455
  return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: lifecycle command ${lifecycle.verb} names ` +
1246
- `${lifecycle.sessionId ?? "<missing>"}, but the host payload owner is ` +
1456
+ `${lifecycle.sessionId ?? "<missing>"}, but the host owner is ` +
1247
1457
  `${identity.sessionId}.`);
1248
1458
  }
1249
1459
  const sourceTask = !lifecycle.task ||
@@ -1276,8 +1486,55 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1276
1486
  }
1277
1487
  return { ...decision, updatedInput: rewrite.updatedInput };
1278
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
+ }
1279
1526
  /** Decide a normalized event using only the P0 direct-write policy. */
1280
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) {
1281
1538
  const projectRoot = resolve(input.projectRoot);
1282
1539
  // #3039: local (untracked) `.deft-directive-disable` wins for enforcement
1283
1540
  // short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
@@ -1470,13 +1727,25 @@ export function decideHook(input, seams = {}) {
1470
1727
  scopePath: null,
1471
1728
  };
1472
1729
  }
1473
- return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
1730
+ return inspectMutationGates(input, toolName, seams, {
1731
+ proposedLifecycleExempt: false,
1732
+ observation,
1733
+ });
1474
1734
  }
1475
- // Shell dest-forms (#3438): recognized product mutations share inspectMutationGates
1735
+ // Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
1736
+ // Dest-forms (#3438): recognized product mutations share inspectMutationGates
1476
1737
  // with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
1477
1738
  // on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
1478
1739
  if (isShellTool(toolName)) {
1479
- const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1740
+ const destructive = decideGitDestructive(input, toolName);
1741
+ if (destructive !== null) {
1742
+ return attachLifecycleIdentityRewrite(input, toolName, destructive, seams);
1743
+ }
1744
+ const writeReissue = decideShellWriteReissue(input, toolName, seams, observation);
1745
+ if (writeReissue !== null) {
1746
+ return attachLifecycleIdentityRewrite(input, toolName, writeReissue, seams);
1747
+ }
1748
+ const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation);
1480
1749
  return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
1481
1750
  }
1482
1751
  // Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
@@ -1497,7 +1766,10 @@ export function decideHook(input, seams = {}) {
1497
1766
  scopePath: null,
1498
1767
  };
1499
1768
  }
1500
- return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: true });
1769
+ return inspectMutationGates(input, toolName, seams, {
1770
+ proposedLifecycleExempt: true,
1771
+ observation,
1772
+ });
1501
1773
  }
1502
1774
  /**
1503
1775
  * Soft AGENTS re-bind text for host injection when decision carries soft path (#3171).
@@ -1532,7 +1804,9 @@ function softAgentsRebindWireText(decision) {
1532
1804
  */
1533
1805
  export function renderHostDecision(host, decision) {
1534
1806
  if (decision.verdict === "allow") {
1535
- if (decision.event === "tool.before" && decision.updatedInput !== undefined) {
1807
+ if (decision.event === "tool.before" &&
1808
+ decision.updatedInput !== undefined &&
1809
+ hostAcceptsUpdatedInput(host)) {
1536
1810
  if (host === "cursor") {
1537
1811
  return JSON.stringify({
1538
1812
  permission: "allow",
@@ -1540,15 +1814,13 @@ export function renderHostDecision(host, decision) {
1540
1814
  updated_input: decision.updatedInput,
1541
1815
  });
1542
1816
  }
1543
- if (host === "claude" || host === "codex") {
1544
- return JSON.stringify({
1545
- hookSpecificOutput: {
1546
- hookEventName: "PreToolUse",
1547
- permissionDecision: "allow",
1548
- updatedInput: decision.updatedInput,
1549
- },
1550
- });
1551
- }
1817
+ return JSON.stringify({
1818
+ hookSpecificOutput: {
1819
+ hookEventName: "PreToolUse",
1820
+ permissionDecision: "allow",
1821
+ updatedInput: decision.updatedInput,
1822
+ },
1823
+ });
1552
1824
  }
1553
1825
  const soft = softAgentsRebindWireText(decision);
1554
1826
  if (host === "cursor") {
@@ -19,6 +19,12 @@ export interface HookFixtureCase {
19
19
  readonly payload?: unknown;
20
20
  /** Raw stdin when testing BOM / free-form ApplyPatch parse. */
21
21
  readonly raw?: string;
22
+ /**
23
+ * Hook process environment for `host-env` identity providers (#3873).
24
+ * Identity assertions resolve against this instead of the ambient
25
+ * environment, so a corpus case cannot pass or fail by where it runs.
26
+ */
27
+ readonly environ?: Readonly<Record<string, string>>;
22
28
  readonly expected: {
23
29
  readonly toolName: string | null;
24
30
  readonly writeIntent: HookWriteIntent;