@deftai/directive-core 0.107.0 → 0.109.0

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 (271) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/cache/operations.js +1 -1
  3. package/dist/check/cached-orchestrator.js +28 -3
  4. package/dist/check/gate-lists.d.ts +14 -0
  5. package/dist/check/gate-lists.js +26 -3
  6. package/dist/check/named-cause.js +18 -3
  7. package/dist/check/session-completed-ac.d.ts +1 -1
  8. package/dist/check/session-completed-ac.js +1 -1
  9. package/dist/codebase/provider.js +7 -1
  10. package/dist/consumer-check-contract/evaluate.d.ts +47 -0
  11. package/dist/consumer-check-contract/evaluate.js +181 -15
  12. package/dist/delivery-attempt/evaluate.d.ts +9 -1
  13. package/dist/delivery-attempt/evaluate.js +69 -0
  14. package/dist/delivery-attempt/handoff.js +1 -1
  15. package/dist/delivery-attempt/index.d.ts +1 -1
  16. package/dist/delivery-attempt/index.js +1 -1
  17. package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
  18. package/dist/deposit/live-procedure-exclusions.js +110 -0
  19. package/dist/deposit/live-procedure-targets.d.ts +45 -0
  20. package/dist/deposit/live-procedure-targets.js +274 -0
  21. package/dist/deposit/python-free.d.ts +6 -0
  22. package/dist/deposit/python-free.js +15 -0
  23. package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
  24. package/dist/deposit/rewrite-deposit-links.js +148 -0
  25. package/dist/deposit/run-stage-content-pack.d.ts +2 -0
  26. package/dist/deposit/run-stage-content-pack.js +3 -0
  27. package/dist/deposit/stage-content-pack.d.ts +17 -0
  28. package/dist/deposit/stage-content-pack.js +91 -0
  29. package/dist/design-critique/citation-grammar.d.ts +60 -0
  30. package/dist/design-critique/citation-grammar.js +303 -0
  31. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  32. package/dist/design-critique/completed-arc-record.js +277 -0
  33. package/dist/freshness/bind.d.ts +32 -3
  34. package/dist/freshness/bind.js +58 -12
  35. package/dist/hooks/classify/host-session-identity.d.ts +89 -0
  36. package/dist/hooks/classify/host-session-identity.js +467 -0
  37. package/dist/hooks/classify/index.d.ts +3 -2
  38. package/dist/hooks/classify/index.js +3 -2
  39. package/dist/hooks/classify/paths.d.ts +9 -0
  40. package/dist/hooks/classify/paths.js +39 -0
  41. package/dist/hooks/classify/stdin.d.ts +5 -0
  42. package/dist/hooks/classify/stdin.js +94 -1
  43. package/dist/hooks/dest-form.d.ts +20 -1
  44. package/dist/hooks/dest-form.js +158 -21
  45. package/dist/hooks/dispatcher.d.ts +58 -3
  46. package/dist/hooks/dispatcher.js +762 -73
  47. package/dist/hooks/fixtures/cases.d.ts +22 -2
  48. package/dist/hooks/fixtures/cases.js +228 -0
  49. package/dist/hooks/git-destructive-log.d.ts +32 -0
  50. package/dist/hooks/git-destructive-log.js +46 -0
  51. package/dist/hooks/index.d.ts +2 -0
  52. package/dist/hooks/index.js +2 -0
  53. package/dist/hooks/scope.js +3 -1
  54. package/dist/hooks/shell-write-targets.d.ts +10 -0
  55. package/dist/hooks/shell-write-targets.js +274 -0
  56. package/dist/hooks/tools.d.ts +20 -2
  57. package/dist/hooks/tools.js +30 -1
  58. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  59. package/dist/init-deposit/agent-hooks.js +8 -1
  60. package/dist/init-deposit/gitignore.d.ts +7 -0
  61. package/dist/init-deposit/gitignore.js +24 -0
  62. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  63. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  64. package/dist/init-deposit/init-deposit.js +3 -0
  65. package/dist/init-deposit/refresh.js +7 -0
  66. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  67. package/dist/init-deposit/runtime-writers.js +33 -0
  68. package/dist/intake/clause-derivation.js +9 -3
  69. package/dist/intake/issue-ingest.d.ts +6 -1
  70. package/dist/intake/issue-ingest.js +83 -4
  71. package/dist/intake/platform-capabilities.d.ts +9 -2
  72. package/dist/intake/platform-capabilities.js +38 -9
  73. package/dist/intake/reconcile-issues.js +13 -13
  74. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  75. package/dist/lifecycle/brief-envelope.js +42 -0
  76. package/dist/lifecycle/index.d.ts +1 -0
  77. package/dist/lifecycle/index.js +1 -0
  78. package/dist/literal-acceptance/evaluate.js +14 -5
  79. package/dist/literal-acceptance/index.d.ts +1 -1
  80. package/dist/literal-acceptance/index.js +1 -1
  81. package/dist/literal-acceptance/run.d.ts +2 -0
  82. package/dist/literal-acceptance/run.js +19 -1
  83. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  84. package/dist/orphan-active/candidate-scope.js +157 -0
  85. package/dist/orphan-active/evaluate.d.ts +52 -0
  86. package/dist/orphan-active/evaluate.js +250 -128
  87. package/dist/orphan-active/index.d.ts +2 -0
  88. package/dist/orphan-active/index.js +2 -0
  89. package/dist/orphan-active/issue-state.d.ts +121 -0
  90. package/dist/orphan-active/issue-state.js +278 -0
  91. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  92. package/dist/platform/cursor-managed-runtime.js +241 -0
  93. package/dist/platform/index.d.ts +1 -0
  94. package/dist/platform/index.js +1 -0
  95. package/dist/platform/platform-capabilities.d.ts +23 -1
  96. package/dist/platform/platform-capabilities.js +55 -15
  97. package/dist/policy/ceremony-dial.js +5 -9
  98. package/dist/policy/host-hooks.js +4 -9
  99. package/dist/policy/merge-approval-head.js +7 -6
  100. package/dist/policy/org-force-on-migration.js +5 -10
  101. package/dist/policy/plan-extensions.d.ts +14 -2
  102. package/dist/policy/plan-extensions.js +24 -3
  103. package/dist/policy/product-signal.js +5 -10
  104. package/dist/policy/require-human-merge.js +5 -9
  105. package/dist/policy/resolve.js +77 -16
  106. package/dist/policy/value-feedback.js +9 -18
  107. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  108. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  109. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  110. package/dist/pr-closeout-attestable/index.js +2 -0
  111. package/dist/pr-closing-keywords/gh.js +32 -9
  112. package/dist/pr-closing-keywords/main.d.ts +1 -0
  113. package/dist/pr-closing-keywords/main.js +68 -3
  114. package/dist/pr-closing-keywords/types.d.ts +2 -0
  115. package/dist/pr-merge-readiness/gh.js +32 -9
  116. package/dist/pr-protected-issues/gh.d.ts +6 -2
  117. package/dist/pr-protected-issues/gh.js +42 -11
  118. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  119. package/dist/pr-wait-mergeable/cascade.js +21 -1
  120. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  121. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  122. package/dist/pr-wait-mergeable/wrappers.js +14 -3
  123. package/dist/preflight/evaluate.d.ts +15 -0
  124. package/dist/preflight/evaluate.js +29 -3
  125. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  126. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  127. package/dist/product-first-done-gate/evaluate.js +23 -9
  128. package/dist/product-first-done-gate/types.js +2 -0
  129. package/dist/release/cli-drift-report.d.ts +68 -0
  130. package/dist/release/cli-drift-report.js +189 -0
  131. package/dist/release/consumer-hard-stops.d.ts +48 -0
  132. package/dist/release/consumer-hard-stops.js +140 -0
  133. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  134. package/dist/release/consumer-readiness-disclosure.js +51 -0
  135. package/dist/release/index.d.ts +3 -0
  136. package/dist/release/index.js +3 -0
  137. package/dist/release/issue-state-fetch.d.ts +5 -3
  138. package/dist/release/issue-state-fetch.js +86 -21
  139. package/dist/release/native-steps.js +1 -0
  140. package/dist/release/pipeline.js +74 -0
  141. package/dist/release/run-consumer-readiness.d.ts +15 -0
  142. package/dist/release/run-consumer-readiness.js +24 -0
  143. package/dist/release/types.d.ts +20 -0
  144. package/dist/render/constants.d.ts +4 -0
  145. package/dist/render/constants.js +11 -8
  146. package/dist/render/export-spec.js +31 -5
  147. package/dist/render/index.d.ts +1 -1
  148. package/dist/render/index.js +1 -1
  149. package/dist/render/prd-render.d.ts +4 -1
  150. package/dist/render/prd-render.js +82 -23
  151. package/dist/render/project-render.js +14 -7
  152. package/dist/render/scope-outlook.d.ts +2 -0
  153. package/dist/render/scope-outlook.js +3 -0
  154. package/dist/render/spec-render.js +2 -2
  155. package/dist/resolution/package-manager.d.ts +33 -4
  156. package/dist/resolution/package-manager.js +210 -9
  157. package/dist/review-monitor/constants.d.ts +7 -0
  158. package/dist/review-monitor/constants.js +7 -0
  159. package/dist/review-monitor/github-lease.d.ts +98 -1
  160. package/dist/review-monitor/github-lease.js +186 -4
  161. package/dist/review-monitor/lease-comment.d.ts +36 -0
  162. package/dist/review-monitor/lease-comment.js +103 -2
  163. package/dist/run-summary/types.d.ts +2 -2
  164. package/dist/scm/build-command.d.ts +2 -2
  165. package/dist/scm/build-command.js +2 -2
  166. package/dist/scm/call-shape.d.ts +25 -0
  167. package/dist/scm/call-shape.js +59 -0
  168. package/dist/scm/call.d.ts +7 -4
  169. package/dist/scm/call.js +48 -9
  170. package/dist/scm/design-critique-chip.d.ts +1 -0
  171. package/dist/scm/design-critique-chip.js +22 -6
  172. package/dist/scm/gh-rest.d.ts +34 -13
  173. package/dist/scm/gh-rest.js +168 -15
  174. package/dist/scm/index.d.ts +2 -0
  175. package/dist/scm/index.js +2 -0
  176. package/dist/scm/readiness.d.ts +2 -0
  177. package/dist/scm/readiness.js +28 -4
  178. package/dist/scm/spawn-status.d.ts +33 -0
  179. package/dist/scm/spawn-status.js +53 -0
  180. package/dist/scope/acceptance-evidence.d.ts +1 -1
  181. package/dist/scope/project-definition-sync.js +14 -7
  182. package/dist/scope/transition.js +4 -14
  183. package/dist/session/ac-pass-banking.d.ts +2 -2
  184. package/dist/session/ac-pass-banking.js +2 -2
  185. package/dist/session/git.d.ts +24 -0
  186. package/dist/session/git.js +138 -2
  187. package/dist/session/host-session-owner.d.ts +53 -0
  188. package/dist/session/host-session-owner.js +89 -0
  189. package/dist/session/index.d.ts +1 -0
  190. package/dist/session/index.js +1 -0
  191. package/dist/session/occupancy.d.ts +259 -13
  192. package/dist/session/occupancy.js +877 -33
  193. package/dist/session/ritual-sentinel.d.ts +5 -0
  194. package/dist/session/ritual-sentinel.js +5 -0
  195. package/dist/session/session-ready.d.ts +5 -1
  196. package/dist/session/session-ready.js +98 -8
  197. package/dist/session/session-start.d.ts +5 -1
  198. package/dist/session/session-start.js +32 -17
  199. package/dist/session/toolchain-preflight.d.ts +13 -3
  200. package/dist/session/toolchain-preflight.js +93 -18
  201. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  202. package/dist/session/verify-ac-session-cache.js +2 -2
  203. package/dist/session/verify-session-ritual.d.ts +11 -0
  204. package/dist/session/verify-session-ritual.js +60 -15
  205. package/dist/slice/constants.d.ts +1 -1
  206. package/dist/spec-authority/constants.d.ts +10 -2
  207. package/dist/spec-authority/constants.js +53 -8
  208. package/dist/spec-authority/resolver.d.ts +3 -0
  209. package/dist/spec-authority/resolver.js +55 -7
  210. package/dist/subprocess/max-buffer.d.ts +14 -0
  211. package/dist/subprocess/max-buffer.js +15 -0
  212. package/dist/swarm/complete-cohort.d.ts +2 -0
  213. package/dist/swarm/complete-cohort.js +21 -13
  214. package/dist/swarm/index.d.ts +1 -0
  215. package/dist/swarm/index.js +1 -0
  216. package/dist/swarm/launch-cli.js +53 -33
  217. package/dist/swarm/launch.d.ts +8 -0
  218. package/dist/swarm/launch.js +63 -43
  219. package/dist/swarm/pre-dispatch-cli.js +2 -1
  220. package/dist/swarm/pre-dispatch.js +10 -1
  221. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  222. package/dist/swarm/subagent-status-dir.js +42 -0
  223. package/dist/swarm/worktrees.d.ts +17 -3
  224. package/dist/swarm/worktrees.js +48 -4
  225. package/dist/triage/evaluate/worktrees.js +153 -6
  226. package/dist/triage/scope/mutations-core.d.ts +2 -6
  227. package/dist/triage/scope/mutations-core.js +23 -55
  228. package/dist/triage/scope-drift/add-ignore.js +5 -39
  229. package/dist/triage/subscribe/index.d.ts +2 -6
  230. package/dist/triage/subscribe/index.js +15 -65
  231. package/dist/triage/welcome/writers.js +15 -74
  232. package/dist/umbrella-current-shape/index.d.ts +51 -3
  233. package/dist/umbrella-current-shape/index.js +106 -18
  234. package/dist/validate-content/deposit-required.d.ts +39 -0
  235. package/dist/validate-content/deposit-required.js +147 -0
  236. package/dist/validate-content/index.d.ts +1 -0
  237. package/dist/validate-content/index.js +1 -0
  238. package/dist/validate-content/validate-links.d.ts +2 -3
  239. package/dist/validate-content/validate-links.js +29 -2
  240. package/dist/vbrief-activate/activate.d.ts +7 -2
  241. package/dist/vbrief-activate/activate.js +26 -13
  242. package/dist/vbrief-build/index.d.ts +2 -1
  243. package/dist/vbrief-build/index.js +2 -1
  244. package/dist/vbrief-build/parity-scenarios.js +5 -5
  245. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  246. package/dist/vbrief-build/project-definition-io.js +357 -31
  247. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  248. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  249. package/dist/vbrief-validate/conformance.d.ts +1 -0
  250. package/dist/vbrief-validate/conformance.js +85 -8
  251. package/dist/vbrief-validate/main.js +2 -0
  252. package/dist/vbrief-validate/precutover.js +5 -10
  253. package/dist/verify-ac/clauses.d.ts +62 -2
  254. package/dist/verify-ac/clauses.js +183 -111
  255. package/dist/verify-ac/evaluate.d.ts +9 -0
  256. package/dist/verify-ac/evaluate.js +32 -9
  257. package/dist/verify-ac/index.d.ts +1 -1
  258. package/dist/verify-ac/index.js +1 -1
  259. package/dist/verify-env/node-runtime.d.ts +8 -4
  260. package/dist/verify-env/node-runtime.js +9 -6
  261. package/dist/verify-env/toolchain-check.d.ts +35 -3
  262. package/dist/verify-env/toolchain-check.js +155 -36
  263. package/dist/verify-source/deposit-closure.d.ts +23 -0
  264. package/dist/verify-source/deposit-closure.js +162 -0
  265. package/dist/verify-source/index.d.ts +2 -0
  266. package/dist/verify-source/index.js +2 -0
  267. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  268. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  269. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  270. package/dist/verify-source/semantic-single-source.js +349 -0
  271. package/package.json +7 -3
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { appendAuthzAudit, classifyHookAuthzOps, evaluateAuthzMutation, evidenceSatisfiesImplementationApproval, listActiveHumanGrants, loadAuthzStateResult, markGrantUsed, shouldConsumeSingleUseGrant, utcIso, } from "../authz/index.js";
4
+ import { runningInsideDeftRepo } from "../doctor/paths.js";
4
5
  import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
5
6
  import { hasArtifactSuffix } from "../layout/resolve.js";
6
7
  import { detectDeftDirectiveDisable, formatDeftDirectiveDisableMessage, isDeftDirectiveDisableActive, } from "../policy/deft-directive-disable.js";
@@ -9,21 +10,23 @@ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRE
9
10
  import { classifyMcpTool, DEFAULT_RUNTIME_AUTHORITY_POLICY, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
10
11
  import { loadStoryWriteFenceFromPath, resolveWriteFence } from "../policy/write-fence.js";
11
12
  import { appendSoftAgentsRebindToMessage, decisionCarriesSoftAgentsRebind, formatSoftAgentsRebindChecklist, } from "../session/compact-ritual.js";
12
- import { detectBranch } from "../session/git.js";
13
+ import { defaultGitRunner, detectBranch, existingAncestorDir, gitCommonDir, memoizeGitRunner, worktreePathOrNull, } from "../session/git.js";
13
14
  import { evaluateOccupancyWriteGate } from "../session/occupancy.js";
14
15
  import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js";
15
16
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
16
17
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
17
- import { formatRitualRecoveryInstruction, verifySessionRitual, writeGateRitualOptions, } from "../session/verify-session-ritual.js";
18
- import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
19
- import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
18
+ import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRitual, WRITE_GATED_REQUIRED_STEPS, writeGateRitualOptions, } from "../session/verify-session-ritual.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";
20
22
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
21
23
  import { inspectActiveScope } from "./scope.js";
24
+ import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
22
25
  import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
23
26
  // Pure parse/classify helpers are defined in ./classify/ and re-exported from
24
27
  // ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
25
28
  export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
26
- 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";
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";
27
30
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
28
31
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
29
32
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
@@ -103,6 +106,66 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
103
106
  return false;
104
107
  return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
105
108
  }
109
+ /**
110
+ * ApplyPatch body present but no mutation header could be classified.
111
+ * Fail closed rather than authorizing the declared path alone (#3614).
112
+ */
113
+ function applyPatchBodyUnclassified(payload) {
114
+ const text = hookApplyPatchBodyText(payload);
115
+ return text !== null && hookApplyPatchBodyPaths(payload).length === 0;
116
+ }
117
+ /**
118
+ * Proposed-lifecycle exemption is universally quantified over every mutated
119
+ * path, not the declared write target alone (#3614).
120
+ */
121
+ function allMutationTargetsAreProposedLifecycleWrites(projectRoot, payload) {
122
+ if (applyPatchBodyUnclassified(payload))
123
+ return false;
124
+ const targets = hookMutationTargetPaths(payload);
125
+ if (targets.length === 0)
126
+ return false;
127
+ return targets.every((target) => isProposedLifecycleWrite(projectRoot, target));
128
+ }
129
+ function authzForMutationTargets(input, toolName, seams, options) {
130
+ const targets = hookMutationTargetPaths(input.payload);
131
+ const paths = targets.length > 0 ? targets : [null];
132
+ const pendingConsume = [];
133
+ for (const target of paths) {
134
+ const relPath = target !== null ? toProjectRelativePosix(options.projectRoot, target) : null;
135
+ const denied = authzForMutation(input, toolName, seams, {
136
+ isDirectWrite: options.isDirectWrite,
137
+ relPath,
138
+ scopePath: options.scopePath,
139
+ runGit: options.runGit,
140
+ consumeSingleUse: false,
141
+ pendingConsume,
142
+ });
143
+ if (denied !== null)
144
+ return denied;
145
+ }
146
+ const seen = new Set();
147
+ for (const ref of pendingConsume) {
148
+ if (seen.has(ref))
149
+ continue;
150
+ seen.add(ref);
151
+ try {
152
+ markGrantUsed(options.projectRoot, ref);
153
+ }
154
+ catch {
155
+ // Persistence failure must not flip allow after a successful check.
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ function allMutationTargetsAreAssistScratch(projectRoot, payload, environ) {
161
+ if (applyPatchBodyUnclassified(payload))
162
+ return false;
163
+ const targets = hookMutationTargetPaths(payload);
164
+ if (targets.length === 0) {
165
+ return isAssistScratchWrite(projectRoot, hookWriteTargetPath(payload), payload, environ);
166
+ }
167
+ return targets.every((target) => isAssistScratchWrite(projectRoot, target, payload, environ));
168
+ }
106
169
  /**
107
170
  * Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
108
171
  * Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
@@ -193,8 +256,8 @@ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ =
193
256
  return true;
194
257
  return false;
195
258
  }
196
- function isWindowsDriveOnlyRoot(value) {
197
- return /^[A-Za-z]:[/\\]?$/.test(value.trim());
259
+ function isWindowsDriveOnlyRoot(value, platform = process.platform) {
260
+ return platform === "win32" && /^[A-Za-z]:[/\\]?$/.test(value.trim());
198
261
  }
199
262
  function hookPayloadRootCandidates(input) {
200
263
  const candidates = [];
@@ -250,22 +313,113 @@ export function projectRootFromHookPayload(payload, fallback) {
250
313
  }
251
314
  return fallbackResolved;
252
315
  }
316
+ /** Interpolate both roots so a deny names the tree that was judged (#3794). */
317
+ export function formatHookRootNote(payloadRoot, effectiveRoot) {
318
+ return `payloadRoot=${payloadRoot} effectiveRoot=${effectiveRoot}`;
319
+ }
320
+ function admitPayload(payload, candidate) {
321
+ return { root: payload, foreign: false, candidate, refusal: null };
322
+ }
323
+ /**
324
+ * Admit the Git toplevel of the write target only when it shares
325
+ * `--git-common-dir` with `payloadRoot`.
326
+ *
327
+ * Identity resolves to one of three outcomes, not two. A `payloadRoot` that is
328
+ * not a Git repository never posed a containment question, so it keeps today's
329
+ * payloadRoot gating. A `payloadRoot` that *is* a repository, paired with a
330
+ * target that resolved to some other toplevel whose identity cannot be read, is
331
+ * a question asked and left unanswered — that fails closed rather than
332
+ * inheriting payloadRoot's occupancy and ritual state (#3794).
333
+ */
334
+ export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
335
+ const payload = normalizeHookProjectRoot(payloadRoot);
336
+ if (writeTarget === null)
337
+ return admitPayload(payload, null);
338
+ const ancestor = existingAncestorDir(writeTarget);
339
+ if (ancestor === null)
340
+ return admitPayload(payload, null);
341
+ const candidateRaw = worktreePathOrNull(ancestor, runGit);
342
+ if (candidateRaw === null)
343
+ return admitPayload(payload, null);
344
+ const candidate = normalizeHookProjectRoot(candidateRaw);
345
+ if (candidate === payload)
346
+ return admitPayload(payload, candidate);
347
+ const payloadCommon = gitCommonDir(payload, runGit);
348
+ if (payloadCommon === null) {
349
+ // No containment question exists: payloadRoot is not a repository, so there
350
+ // is no boundary for the target to be inside or outside of. Denying here
351
+ // would invent a class this defect never had.
352
+ return admitPayload(payload, candidate);
353
+ }
354
+ const candidateCommon = gitCommonDir(candidate, runGit);
355
+ if (candidateCommon === null) {
356
+ return { root: payload, foreign: true, candidate, refusal: "unproven-identity" };
357
+ }
358
+ if (normalizeHookProjectRoot(payloadCommon) === normalizeHookProjectRoot(candidateCommon)) {
359
+ return { root: candidate, foreign: false, candidate, refusal: null };
360
+ }
361
+ return { root: payload, foreign: true, candidate, refusal: "foreign-repository" };
362
+ }
363
+ /**
364
+ * Admit every mutation target and require one effectiveRoot. A refused member
365
+ * short-circuits; a span of two admitted worktrees is also refused so
366
+ * occupancy/ritual cannot follow only the declared ApplyPatch path (#3794).
367
+ */
368
+ export function admitMutationTargetSet(payloadRoot, targets, runGit) {
369
+ const payload = normalizeHookProjectRoot(payloadRoot);
370
+ if (targets.length === 0)
371
+ return admitPayload(payload, null);
372
+ const admitted = [];
373
+ for (const target of targets) {
374
+ const next = admitEffectiveHookRoot(payload, target, runGit);
375
+ if (next.foreign)
376
+ return next;
377
+ admitted.push(next);
378
+ }
379
+ const roots = [...new Set(admitted.map((item) => item.root))];
380
+ if (roots.length > 1) {
381
+ return {
382
+ root: payload,
383
+ foreign: true,
384
+ candidate: roots.join(" | "),
385
+ refusal: "worktree-span",
386
+ };
387
+ }
388
+ return admitted[0] ?? admitPayload(payload, null);
389
+ }
253
390
  export function isHookHost(value) {
254
391
  return HOOK_HOSTS.includes(value);
255
392
  }
256
393
  export function isHookEvent(value) {
257
394
  return HOOK_EVENTS.includes(value);
258
395
  }
259
- function scopeNotReadyCoverageHint(toolName) {
396
+ /**
397
+ * Coverage sentence appended to a scope-not-ready deny.
398
+ *
399
+ * #3828: this used to claim Shell dest-form coverage unconditionally. Coverage
400
+ * is opt-in (`plan.policy.runtimeAuthority.shellDestForms: "enforce"`) and the
401
+ * default is `off`, so on a default-policy repository the sentence advertised a
402
+ * fence that was not there and, by naming `python -c` as the exception, implied
403
+ * the gap was a sliver rather than the whole Shell surface. State the coverage
404
+ * actually in force.
405
+ */
406
+ function scopeNotReadyCoverageHint(toolName, shellDestFormsEnforced) {
260
407
  if (isSpawnTool(toolName))
261
408
  return "";
262
409
  if (isShellTool(toolName)) {
263
410
  return (" This deny is the active-scope write gate on a recognized Shell dest-form " +
264
411
  "(git checkout --, git restore, rm/rmdir of in-repo paths).");
265
412
  }
266
- return (" This gate covers direct-write tools (Edit/Write) and implementation spawn, " +
267
- "plus recognized Shell dest-forms (git checkout --, git restore, rm/rmdir). " +
268
- "Other shell (python -c, cmd /c copy, git status) is not this deny.");
413
+ if (shellDestFormsEnforced) {
414
+ return (" This gate covers direct-write tools (Edit/Write), implementation spawn, and " +
415
+ "recognized Shell dest-forms (git checkout --, git restore, rm/rmdir), because " +
416
+ "plan.policy.runtimeAuthority.shellDestForms is enforce. Shell whose target " +
417
+ "cannot be resolved to a product path stays outside this deny.");
418
+ }
419
+ return (" This gate covers direct-write tools (Edit/Write) and implementation spawn. " +
420
+ "plan.policy.runtimeAuthority.shellDestForms is off (the default), so no Shell " +
421
+ "command is covered by this deny -- reaching for one to get around it defeats " +
422
+ "the gate rather than satisfying it.");
269
423
  }
270
424
  function deny(input, code, toolName, message, scopePath = null) {
271
425
  return {
@@ -279,8 +433,56 @@ function deny(input, code, toolName, message, scopePath = null) {
279
433
  scopePath,
280
434
  };
281
435
  }
282
- function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
436
+ /**
437
+ * Resolve the cooperative actor presented to the occupancy gate.
438
+ * A resolved host identity is authoritative; ambient identity may only
439
+ * corroborate. Hosts whose identity source is the hook environment fall back to
440
+ * the explicit owner flow when that variable is absent (#3873).
441
+ */
442
+ function resolveMutationActor(input, environ) {
443
+ const hostIdentity = resolveHookHostIdentity(input.host, input.payload, environ);
444
+ const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
445
+ if (hostIdentity.status === "unsupported" ||
446
+ hostIdentityFallsBackToExplicitOwner(input.host, hostIdentity)) {
447
+ return {
448
+ sessionId: environmentId,
449
+ issue: null,
450
+ message: null,
451
+ hostAuthoritative: false,
452
+ };
453
+ }
454
+ if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
455
+ if (environmentId !== undefined && environmentId !== hostIdentity.sessionId) {
456
+ return {
457
+ sessionId: hostIdentity.sessionId,
458
+ issue: "conflict",
459
+ message: `Host owner ${hostIdentity.sessionId} conflicts with ` +
460
+ `DEFT_SESSION_ID ${environmentId}.`,
461
+ hostAuthoritative: true,
462
+ };
463
+ }
464
+ return {
465
+ sessionId: hostIdentity.sessionId,
466
+ issue: null,
467
+ message: null,
468
+ hostAuthoritative: true,
469
+ };
470
+ }
471
+ return {
472
+ sessionId: undefined,
473
+ issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
474
+ message: hostIdentity.message,
475
+ hostAuthoritative: true,
476
+ };
477
+ }
478
+ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
479
+ // #3794 commit 2: the project runtimeAuthority layer is project-level
480
+ // authorization config and stays on payloadRoot, alongside authz. Only the
481
+ // story fence and the path it is matched against follow the tree the write
482
+ // lands in, so a story `file_scope` of `packages/**` still matches a write
483
+ // inside a linked worktree rather than `.deft-scratch/worktrees/<x>/packages/`.
283
484
  const projectRoot = resolve(input.projectRoot);
485
+ const fenceRoot = resolve(effectiveRoot);
284
486
  // Project policy load failure: treat as disabled project layer, still apply
285
487
  // independent story file_scope when present (#516 / #2443 Greptile P1).
286
488
  let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
@@ -296,7 +498,7 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
296
498
  let storyFence;
297
499
  try {
298
500
  storyFence = seams.loadStoryWriteFence
299
- ? seams.loadStoryWriteFence(projectRoot, scopePath)
501
+ ? seams.loadStoryWriteFence(fenceRoot, scopePath)
300
502
  : loadStoryWriteFenceFromPath(scopePath);
301
503
  }
302
504
  catch {
@@ -309,15 +511,28 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
309
511
  // Neither layer active → allow (same as disabled runtimeAuthority historically).
310
512
  if (!fence.fenceActive)
311
513
  return null;
312
- const writeTarget = hookWriteTargetPath(input.payload);
313
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
314
- const verdict = evaluateRuntimeAuthorityDirectWrite({
315
- policy: fence.policy,
316
- relPathPosix: relPath,
317
- });
318
- if (verdict.allowed)
319
- return null;
320
- return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under write fence policy.", scopePath);
514
+ // #3794: relPath is now measured from the tree the write lands in. When that
515
+ // differs from the payload root, a bare "outside file_scope" reason does not
516
+ // say which tree the fence was relativised against.
517
+ const fenceRootNote = fenceRoot === projectRoot ? "" : ` ${formatHookRootNote(projectRoot, fenceRoot)}`;
518
+ if (applyPatchBodyUnclassified(input.payload)) {
519
+ 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." +
520
+ fenceRootNote, scopePath);
521
+ }
522
+ const targets = hookMutationTargetPaths(input.payload);
523
+ const toCheck = targets.length > 0 ? targets : [null];
524
+ for (const target of toCheck) {
525
+ const relPath = target !== null ? toProjectRelativePosix(fenceRoot, target) : null;
526
+ const verdict = evaluateRuntimeAuthorityDirectWrite({
527
+ policy: fence.policy,
528
+ relPathPosix: relPath,
529
+ });
530
+ if (!verdict.allowed) {
531
+ return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, (verdict.reason ?? "Directive denied this direct write under write fence policy.") +
532
+ fenceRootNote, scopePath);
533
+ }
534
+ }
535
+ return null;
321
536
  }
322
537
  function loadAuthzContext(projectRoot, seams) {
323
538
  if (seams.loadAuthzState !== undefined) {
@@ -380,6 +595,8 @@ function recordAuthzAudit(projectRoot, decision, state, seams) {
380
595
  * Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
381
596
  */
382
597
  function authzForMutation(input, toolName, seams, options) {
598
+ // #3794: grant scoping and the authz audit trail stay on payloadRoot.
599
+ // Re-pointing would break exact-string UAT grants and shard the audit file.
383
600
  const projectRoot = resolve(input.projectRoot);
384
601
  let state;
385
602
  let grants;
@@ -413,7 +630,7 @@ function authzForMutation(input, toolName, seams, options) {
413
630
  // Structural context for bound grants (branch from git; repo optional env).
414
631
  let branch = null;
415
632
  try {
416
- branch = detectBranch(projectRoot);
633
+ branch = detectBranch(projectRoot, options.runGit);
417
634
  }
418
635
  catch {
419
636
  branch = null;
@@ -436,12 +653,17 @@ function authzForMutation(input, toolName, seams, options) {
436
653
  }
437
654
  // Consume single-use grants after allow (persist usedAt).
438
655
  if (shouldConsumeSingleUseGrant(decision) && decision.humanApprovalRef !== null) {
439
- try {
440
- markGrantUsed(projectRoot, decision.humanApprovalRef);
656
+ if (options.consumeSingleUse === false) {
657
+ options.pendingConsume?.push(decision.humanApprovalRef);
441
658
  }
442
- catch {
443
- // Persistence failure must not flip allow → deny after a successful check;
444
- // next load will still see unused if write failed (prefer allow-once risk over deadlock).
659
+ else {
660
+ try {
661
+ markGrantUsed(projectRoot, decision.humanApprovalRef);
662
+ }
663
+ catch {
664
+ // Persistence failure must not flip allow → deny after a successful check;
665
+ // next load will still see unused if write failed (prefer allow-once risk over deadlock).
666
+ }
445
667
  }
446
668
  }
447
669
  }
@@ -552,23 +774,48 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
552
774
  };
553
775
  }
554
776
  function inspectMutationGates(input, toolName, seams, options) {
555
- const projectRoot = resolve(input.projectRoot);
777
+ // payloadRoot stays authoritative for authz grant scoping, the authz audit
778
+ // trail, the kill-switch, the #2885 outside-root carve-out, and
779
+ // deny().projectRoot. effectiveRoot governs occupancy and ritual (commit 1),
780
+ // and active scope, the story write fence and assist-scratch (commit 2).
781
+ const payloadRoot = resolve(input.projectRoot);
782
+ const projectRoot = payloadRoot;
556
783
  const environ = input.environ ?? process.env;
784
+ const dispatchGit = memoizeGitRunner(seams.ritualRunGit ?? defaultGitRunner);
785
+ const mutationTargets = isSpawnTool(toolName) ? [] : hookMutationTargetPaths(input.payload);
786
+ const admission = isSpawnTool(toolName)
787
+ ? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
788
+ : admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
789
+ const effectiveRoot = admission.root;
790
+ const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
791
+ if (admission.foreign) {
792
+ const candidate = admission.candidate ?? "<none>";
793
+ const reason = admission.refusal === "worktree-span"
794
+ ? `write targets span more than one Git worktree (candidate=${candidate}).`
795
+ : admission.refusal === "unproven-identity"
796
+ ? `write target resolved to a separate Git working tree whose repository ` +
797
+ `identity could not be read, so containment is unproven (candidate=${candidate}).`
798
+ : `write target is in a different Git repository ` +
799
+ `than the hook payload root (candidate=${candidate}).`;
800
+ return deny(input, "foreign-repository-deny", toolName, `Directive denied ${toolName}: ${reason}${rootsNote}`);
801
+ }
557
802
  // Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
558
803
  // assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
559
804
  // Tracked product paths never match the path fence. Read-only still denied upstream.
560
805
  if (!isSpawnTool(toolName)) {
561
- const scratchTarget = hookWriteTargetPath(input.payload);
562
- if (isAssistScratchWrite(projectRoot, scratchTarget, input.payload, environ)) {
563
- const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
564
- const authzDeny = authzForMutation(input, toolName, seams, {
806
+ // #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
807
+ // file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
808
+ // assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
809
+ if (allMutationTargetsAreAssistScratch(effectiveRoot, input.payload, environ)) {
810
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
565
811
  isDirectWrite: true,
566
- relPath,
567
812
  scopePath: null,
813
+ runGit: dispatchGit,
814
+ projectRoot,
568
815
  });
569
816
  if (authzDeny !== null)
570
817
  return authzDeny;
571
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
818
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
572
819
  if (runtimeDeny !== null)
573
820
  return runtimeDeny;
574
821
  return {
@@ -586,6 +833,72 @@ function inspectMutationGates(input, toolName, seams, options) {
586
833
  };
587
834
  }
588
835
  }
836
+ const actor = isSpawnTool(toolName) ? null : resolveMutationActor(input, environ);
837
+ const occupancyGate = isSpawnTool(toolName)
838
+ ? {
839
+ allow: true,
840
+ message: null,
841
+ occupant: null,
842
+ admitted: null,
843
+ }
844
+ : evaluateOccupancyWriteGate(effectiveRoot, {
845
+ sessionId: actor?.sessionId,
846
+ // Hosts with a resolved owner must not fall back to a stale ambient one.
847
+ env: actor?.hostAuthoritative === true ? {} : environ,
848
+ });
849
+ // #3769: occupancy decides before any ritual persist. The gated verifier
850
+ // executes agent_hooks and rebinds a forward HEAD, and both rewrite
851
+ // .deft/ritual-state.json — so verifying first let a session this gate was
852
+ // about to deny rewrite the occupant's record. The deny paths below read the
853
+ // ritual through the no-write inspect, which runs no step and never rebinds;
854
+ // only a session occupancy would admit reaches the verifier.
855
+ //
856
+ // Ritual detail on an occupancy deny is telemetry and message decoration, so
857
+ // an inspect failure must not convert an occupancy verdict into a ritual one.
858
+ const ritualDetailForOccupancyDeny = () => {
859
+ try {
860
+ return (seams.inspectRitual ??
861
+ seams.verifyRitual ??
862
+ ((root) => inspectSessionRitual(root, {
863
+ tier: "gated",
864
+ posture: "mutation",
865
+ runGit: dispatchGit,
866
+ requiredGatedSteps: WRITE_GATED_REQUIRED_STEPS,
867
+ })))(effectiveRoot);
868
+ }
869
+ catch {
870
+ return null;
871
+ }
872
+ };
873
+ if (occupancyGate.occupant !== null && actor?.issue !== null && actor !== null) {
874
+ const inspected = ritualDetailForOccupancyDeny();
875
+ const code = actor.issue === "conflict" ? "occupancy-identity-conflict" : "occupancy-identity-unavailable";
876
+ const detail = actor.message ??
877
+ "The host did not supply a usable cooperative session/conversation identity.";
878
+ emitSessionRitualBlockedProcessCost({
879
+ toolName,
880
+ code,
881
+ recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
882
+ detail,
883
+ }, { projectRoot });
884
+ return deny(input, code, toolName, `Directive denied ${toolName}: ${detail} A live occupancy lease exists for ` +
885
+ `session ${occupancyGate.occupant.sessionId}; use an exact host-mediated lifecycle ` +
886
+ "command or pass the matching --session-id explicitly." +
887
+ rootsNote);
888
+ }
889
+ if (!occupancyGate.allow && occupancyGate.message !== null) {
890
+ const inspected = ritualDetailForOccupancyDeny();
891
+ const ritualNote = inspected !== null && inspected.code !== 0
892
+ ? ` Also ritual-not-ready: ${inspected.message}`
893
+ : "";
894
+ emitSessionRitualBlockedProcessCost({
895
+ toolName,
896
+ code: "occupancy-occupied",
897
+ recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
898
+ detail: occupancyGate.message,
899
+ }, { projectRoot });
900
+ return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: ${occupancyGate.message}${ritualNote}${rootsNote}`);
901
+ }
589
902
  let ritual;
590
903
  try {
591
904
  // Mutation dispatch is a live gated boundary: active verification reruns
@@ -600,8 +913,8 @@ function inspectMutationGates(input, toolName, seams, options) {
600
913
  bypass: false,
601
914
  runner: seams.ritualRunner,
602
915
  detectWorkSelection: seams.detectWorkSelection,
603
- runGit: seams.ritualRunGit,
604
- }))))(projectRoot);
916
+ runGit: dispatchGit,
917
+ }))))(effectiveRoot);
605
918
  }
606
919
  catch (cause) {
607
920
  // #2994: best-effort local process-cost; never changes deny verdict.
@@ -612,20 +925,36 @@ function inspectMutationGates(input, toolName, seams, options) {
612
925
  detail: `inspect threw: ${String(cause)}`,
613
926
  }, { projectRoot });
614
927
  return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
615
- formatRitualRecoveryInstruction("cold"));
616
- }
617
- const occupancyGate = isSpawnTool(toolName)
618
- ? { allow: true, message: null, occupant: null }
619
- : evaluateOccupancyWriteGate(projectRoot, { env: environ });
620
- if (!occupancyGate.allow && occupancyGate.message !== null) {
621
- const ritualNote = ritual.code !== 0 ? ` Also ritual-not-ready: ${ritual.message}` : "";
622
- emitSessionRitualBlockedProcessCost({
623
- toolName,
624
- code: "occupancy-occupied",
625
- recoveryTier: ritual.recoveryTier === "rearm" ? "rearm" : "cold",
626
- detail: occupancyGate.message,
627
- }, { projectRoot });
628
- return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: ${occupancyGate.message}${ritualNote}`);
928
+ formatRitualRecoveryInstruction("cold") +
929
+ rootsNote);
930
+ }
931
+ if (occupancyGate.occupant !== null && actor !== null) {
932
+ // A granted member writes under the owner's ceremony (#3755). Ritual state
933
+ // is single-owner and a child cannot bind its own while the owner's lease is
934
+ // live, so requiring the writer's own binding would make every admitted
935
+ // member's write unreachable — the grant would authorize nothing. What is
936
+ // checked instead is the identity that issued the grant: the tree's exact
937
+ // verified ritual owner must be the occupant whose lease admits this member.
938
+ const expectedOwner = occupancyGate.admitted === "member" ? occupancyGate.occupant.sessionId : actor.sessionId;
939
+ const verifiedOwner = ritual.boundSessionId;
940
+ const ritualOwnerMismatch = expectedOwner !== undefined &&
941
+ (verifiedOwner !== undefined ? verifiedOwner !== expectedOwner : ritual.code === 0);
942
+ if (ritualOwnerMismatch) {
943
+ const detail = verifiedOwner === undefined
944
+ ? "gated ritual verification returned ready without an exact bound owner"
945
+ : `exact verified ritual owner ${verifiedOwner} differs from host/lease owner ${expectedOwner}`;
946
+ emitSessionRitualBlockedProcessCost({
947
+ toolName,
948
+ code: "occupancy-ritual-mismatch",
949
+ recoveryTier: ritual.recoveryTier === "rearm" ? "rearm" : "cold",
950
+ detail,
951
+ }, { projectRoot });
952
+ return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: ${detail}. ` +
953
+ "Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
954
+ "otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony. " +
955
+ "Intermediate lease/ritual mismatches fail closed." +
956
+ rootsNote);
957
+ }
629
958
  }
630
959
  if (ritual.code !== 0) {
631
960
  // #2992: prefer re-arm recovery when age/compact stale; cold when bind invalid.
@@ -637,23 +966,73 @@ function inspectMutationGates(input, toolName, seams, options) {
637
966
  recoveryTier,
638
967
  detail: ritual.message,
639
968
  }, { projectRoot });
640
- return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}`);
641
- }
969
+ return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}` +
970
+ rootsNote);
971
+ }
972
+ // Narrow the cooperative file-gate race: authorization/scope checks can
973
+ // take long enough for another session to transition the lease. Re-read
974
+ // only occupancy immediately before each direct-write allow, while retaining
975
+ // the exact ritual owner above (never adopt a later ritual file here).
976
+ // #3599: the owner's lease is only kept alive here, on the last evaluation
977
+ // before an allowed write, so the stamp records a write that really happened.
978
+ let occupancyWarning = null;
979
+ const recheckOccupancyBeforeWriteAllow = () => {
980
+ if (actor === null)
981
+ return null;
982
+ const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
983
+ sessionId: actor.sessionId,
984
+ env: actor.hostAuthoritative ? {} : environ,
985
+ refresh: true,
986
+ });
987
+ occupancyWarning = finalOccupancy.warning;
988
+ if (finalOccupancy.occupant !== null && actor.issue !== null) {
989
+ return deny(input, actor.issue === "conflict"
990
+ ? "occupancy-identity-conflict"
991
+ : "occupancy-identity-unavailable", toolName, `Directive denied ${toolName}: the live occupancy owner changed while mutation ` +
992
+ "gates were running, and this hook has no unambiguous matching actor identity." +
993
+ rootsNote);
994
+ }
995
+ if (!finalOccupancy.allow && finalOccupancy.message !== null) {
996
+ return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: occupancy changed while mutation gates were running. ` +
997
+ finalOccupancy.message +
998
+ rootsNote);
999
+ }
1000
+ // Same member rule as the pre-gate check above (#3755): an admitted member
1001
+ // is measured against the occupant that granted it, not against itself.
1002
+ const finalExpectedOwner = finalOccupancy.admitted === "member" && finalOccupancy.occupant !== null
1003
+ ? finalOccupancy.occupant.sessionId
1004
+ : actor.sessionId;
1005
+ if (finalOccupancy.occupant !== null &&
1006
+ finalExpectedOwner !== undefined &&
1007
+ ritual.boundSessionId !== finalExpectedOwner) {
1008
+ return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: final lease owner ${finalExpectedOwner} does not match ` +
1009
+ `the exact verified ritual owner ${ritual.boundSessionId ?? "<unbound>"}. ` +
1010
+ "Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
1011
+ "otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony." +
1012
+ rootsNote);
1013
+ }
1014
+ return null;
1015
+ };
1016
+ const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
642
1017
  if (options.proposedLifecycleExempt) {
643
- const writeTarget = hookWriteTargetPath(input.payload);
644
- if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
645
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
1018
+ if (allMutationTargetsAreProposedLifecycleWrites(effectiveRoot, input.payload)) {
646
1019
  // UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
647
- const authzDeny = authzForMutation(input, toolName, seams, {
1020
+ // Authz and the write fence run on every mutated path, not the declared
1021
+ // one (#3614).
1022
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
648
1023
  isDirectWrite: true,
649
- relPath,
650
1024
  scopePath: null,
1025
+ runGit: dispatchGit,
1026
+ projectRoot,
651
1027
  });
652
1028
  if (authzDeny !== null)
653
1029
  return authzDeny;
654
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
1030
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
655
1031
  if (runtimeDeny !== null)
656
1032
  return runtimeDeny;
1033
+ const occupancyDeny = recheckOccupancyBeforeWriteAllow();
1034
+ if (occupancyDeny !== null)
1035
+ return occupancyDeny;
657
1036
  return {
658
1037
  verdict: "allow",
659
1038
  code: "write-propose-ready",
@@ -661,27 +1040,36 @@ function inspectMutationGates(input, toolName, seams, options) {
661
1040
  host: input.host,
662
1041
  toolName,
663
1042
  projectRoot,
664
- message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
665
- "(planning write; active scope not required).",
1043
+ message: withOccupancyWarning(`Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
1044
+ "(planning write; active scope not required)."),
666
1045
  scopePath: null,
667
1046
  };
668
1047
  }
669
1048
  }
670
1049
  let scope;
671
1050
  try {
672
- scope = (seams.inspectScope ?? inspectActiveScope)(projectRoot);
1051
+ // #3794 commit 2: a write is governed by the active scope of the worktree it
1052
+ // lands in, not the primary checkout's. Admission has already proved
1053
+ // effectiveRoot shares --git-common-dir with payloadRoot.
1054
+ scope = (seams.inspectScope ?? inspectActiveScope)(effectiveRoot);
673
1055
  }
674
1056
  catch (cause) {
675
1057
  scope = { ready: false, path: null, message: String(cause) };
676
1058
  }
677
1059
  if (!scope.ready) {
678
1060
  const writeTarget = hookWriteTargetPath(input.payload);
679
- const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
1061
+ const relTarget = writeTarget !== null ? toProjectRelativePosix(effectiveRoot, writeTarget) : null;
680
1062
  // Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
681
1063
  // (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
682
1064
  // targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
683
1065
  // Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
684
- const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
1066
+ // #3794: the #2885 carve-out is measured from payloadRoot, so a linked
1067
+ // worktree that lives outside the payload root keeps today's skip. Worktrees
1068
+ // under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
1069
+ // are inside it and are now gated on their own active scope.
1070
+ const mutationTargets = hookMutationTargetPaths(input.payload);
1071
+ const outsideRoot = mutationTargets.length > 0 &&
1072
+ mutationTargets.every((target) => isOutsideProjectRootWrite(projectRoot, target));
685
1073
  if (!outsideRoot || isSpawnTool(toolName)) {
686
1074
  let proposedPathHint;
687
1075
  if (isSpawnTool(toolName)) {
@@ -720,8 +1108,17 @@ function inspectMutationGates(input, toolName, seams, options) {
720
1108
  "assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
721
1109
  }
722
1110
  const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
1111
+ // #3794: the recovery has to run in the tree whose active scope was read.
1112
+ // Once the two roots differ, "the project root" names no single directory
1113
+ // the agent can act on, and activating in the payload root does not clear
1114
+ // a deny measured against the worktree.
1115
+ const recoveryRootNote = resolve(effectiveRoot) === payloadRoot
1116
+ ? ""
1117
+ : ` Active scope was read from ${effectiveRoot}, so run that recovery there rather than in ${payloadRoot}.`;
723
1118
  return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}` +
724
- scopeNotReadyCoverageHint(toolName));
1119
+ scopeNotReadyCoverageHint(toolName, loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce") +
1120
+ recoveryRootNote +
1121
+ rootsNote);
725
1122
  }
726
1123
  }
727
1124
  const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
@@ -733,19 +1130,21 @@ function inspectMutationGates(input, toolName, seams, options) {
733
1130
  }
734
1131
  }
735
1132
  if (!isSpawnTool(toolName)) {
736
- const writeTarget = hookWriteTargetPath(input.payload);
737
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
738
1133
  // Wave 1 authz (UAT lease + human-origin grant) before runtimeAuthority (#2944 / #2948 L1–L2).
739
- const authzDeny = authzForMutation(input, toolName, seams, {
1134
+ const authzDeny = authzForMutationTargets(input, toolName, seams, {
740
1135
  isDirectWrite: true,
741
- relPath,
742
1136
  scopePath: scope.path,
1137
+ runGit: dispatchGit,
1138
+ projectRoot,
743
1139
  });
744
1140
  if (authzDeny !== null)
745
1141
  return authzDeny;
746
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
1142
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path, effectiveRoot);
747
1143
  if (runtimeDeny !== null)
748
1144
  return runtimeDeny;
1145
+ const occupancyDeny = recheckOccupancyBeforeWriteAllow();
1146
+ if (occupancyDeny !== null)
1147
+ return occupancyDeny;
749
1148
  }
750
1149
  return {
751
1150
  verdict: "allow",
@@ -754,10 +1153,104 @@ function inspectMutationGates(input, toolName, seams, options) {
754
1153
  host: input.host,
755
1154
  toolName,
756
1155
  projectRoot,
757
- message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`,
1156
+ message: withOccupancyWarning(`Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`),
758
1157
  scopePath: scope.path,
759
1158
  };
760
1159
  }
1160
+ function gitDestructiveIsFixture(form, projectRoot) {
1161
+ if (form.unprovable || form.relocators.length === 0)
1162
+ return false;
1163
+ return form.relocators.every((raw) => {
1164
+ const path = raw.trim();
1165
+ if (path.length === 0 || !isAbsolute(path))
1166
+ return false;
1167
+ return isOutsideProjectRootWrite(projectRoot, path);
1168
+ });
1169
+ }
1170
+ const GIT_DESTRUCTIVE_REMEDIATION = "Tree-wide destructive git (reset --hard, clean -f, checkout -f / switch -f, " +
1171
+ "stash drop/clear) is fail-closed when aimed at this checkout. Rewrite as a " +
1172
+ "non-destructive command, or run it against an absolute out-of-root fixture " +
1173
+ "(`git -C /abs/fixture reset --hard`). This is a guard, not a root-cause claim.";
1174
+ /**
1175
+ * Always-on recognition for tree-wide destructive git (#3917). Independent of
1176
+ * `shellDestForms`. Records a durable JSONL line outside the repository so the
1177
+ * next occurrence names its actor even if reflogs are gone.
1178
+ */
1179
+ function decideGitDestructive(input, toolName) {
1180
+ const command = hookShellCommand(input.payload);
1181
+ if (command === null)
1182
+ return null;
1183
+ const forms = classifyGitDestructive(command);
1184
+ if (forms.length === 0)
1185
+ return null;
1186
+ const projectRoot = resolve(input.projectRoot);
1187
+ const env = input.environ ?? process.env;
1188
+ const actor = env.DEFT_SESSION_ID?.trim() || env.GROK_SESSION_ID?.trim() || "";
1189
+ const logPath = env[GIT_DESTRUCTIVE_LOG_ENV]?.trim() || undefined;
1190
+ let anyProjectAimed = false;
1191
+ for (const form of forms) {
1192
+ const fixture = gitDestructiveIsFixture(form, projectRoot);
1193
+ if (!fixture)
1194
+ anyProjectAimed = true;
1195
+ appendGitDestructiveRecord({
1196
+ ts: new Date().toISOString(),
1197
+ kind: form.kind,
1198
+ disposition: fixture ? "allow-fixture" : "deny",
1199
+ command,
1200
+ projectRoot,
1201
+ host: input.host,
1202
+ toolName,
1203
+ actor,
1204
+ pid: process.pid,
1205
+ relocators: form.relocators,
1206
+ unprovable: form.unprovable,
1207
+ }, { env, logPath });
1208
+ }
1209
+ if (!anyProjectAimed) {
1210
+ return {
1211
+ verdict: "allow",
1212
+ code: "git-destructive-fixture",
1213
+ event: input.event,
1214
+ host: input.host,
1215
+ toolName,
1216
+ projectRoot,
1217
+ message: `Directive allowed ${toolName}: destructive git is aimed at an out-of-root fixture.`,
1218
+ scopePath: null,
1219
+ };
1220
+ }
1221
+ return deny(input, "git-destructive-deny", toolName, `Directive denied ${toolName}: ${GIT_DESTRUCTIVE_REMEDIATION}`);
1222
+ }
1223
+ /**
1224
+ * Authorize recognized in-repo Shell file-write dests through inspectMutationGates (#3983 / #3987).
1225
+ * Deny is returned; allow falls through so dest-forms and push/merge still run.
1226
+ */
1227
+ function decideShellWriteReissue(input, toolName, seams) {
1228
+ const command = hookShellCommand(input.payload);
1229
+ if (command === null)
1230
+ return null;
1231
+ const projectRoot = resolve(input.projectRoot);
1232
+ for (const dest of classifyShellWriteTargets(command)) {
1233
+ if (dest.unprovable === true) {
1234
+ if (!isInRepoShellWritePath(projectRoot, dest.path))
1235
+ continue;
1236
+ return deny(input, "scope-not-ready", toolName, "Directive denied " +
1237
+ toolName +
1238
+ ": Shell write dest is not reconstructable (compound command). Use one simple command or Edit/Write.");
1239
+ }
1240
+ if (!isInRepoShellWritePath(projectRoot, dest.path))
1241
+ continue;
1242
+ const destInput = {
1243
+ ...input,
1244
+ payload: payloadWithInjectedWriteTarget(input.payload, dest.path),
1245
+ };
1246
+ const destDecision = inspectMutationGates(destInput, toolName, seams, {
1247
+ proposedLifecycleExempt: true,
1248
+ });
1249
+ if (destDecision.verdict === "deny")
1250
+ return destDecision;
1251
+ }
1252
+ return null;
1253
+ }
761
1254
  /**
762
1255
  * Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
763
1256
  * Dest-form allow is kept when runtime authority has nothing classifiable.
@@ -810,6 +1303,166 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
810
1303
  }
811
1304
  return runtime;
812
1305
  }
1306
+ const LIFECYCLE_EXECUTION_DIRECTORY_FIELDS = [
1307
+ "cwd",
1308
+ "workdir",
1309
+ "working_directory",
1310
+ "workingDirectory",
1311
+ ];
1312
+ function sameExecutionDirectory(left, right) {
1313
+ if (process.platform === "win32")
1314
+ return left.toLowerCase() === right.toLowerCase();
1315
+ return left === right;
1316
+ }
1317
+ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
1318
+ const input = record(payload);
1319
+ if (input === null) {
1320
+ return { aligned: false, message: "the hook payload has no inspectable execution root" };
1321
+ }
1322
+ const expected = normalizeHookProjectRoot(projectRoot);
1323
+ const toolInput = toolInputRecord(input);
1324
+ const records = toolInput === null || toolInput === input ? [input] : [input, toolInput];
1325
+ for (const source of records) {
1326
+ for (const field of LIFECYCLE_EXECUTION_DIRECTORY_FIELDS) {
1327
+ if (!(field in source))
1328
+ continue;
1329
+ const raw = source[field];
1330
+ if (typeof raw !== "string" || raw.trim().length === 0) {
1331
+ return {
1332
+ aligned: false,
1333
+ message: `execution-directory field ${field} is missing or invalid`,
1334
+ };
1335
+ }
1336
+ const trimmed = raw.trim();
1337
+ // Cursor can report a drive-only cwd (for example `C:`) while the
1338
+ // explicit/workspace-derived project root remains usable (#2787). Keep
1339
+ // this fence aligned with projectRootFromHookPayload: a drive-only value
1340
+ // is not a concrete execution directory and must not override that root.
1341
+ if (isWindowsDriveOnlyRoot(trimmed, platform))
1342
+ continue;
1343
+ if (trimmed.replace(/\\/g, "/").split("/").includes("..")) {
1344
+ return {
1345
+ aligned: false,
1346
+ message: `execution-directory field ${field} contains parent traversal`,
1347
+ };
1348
+ }
1349
+ const candidate = isAbsolute(trimmed) ? trimmed : `${expected}${sep}${trimmed}`;
1350
+ let actual;
1351
+ let expectedReal;
1352
+ try {
1353
+ // Keep relative `..` segments intact until realpath resolution. Lexical
1354
+ // path.resolve would collapse `link/..` before observing that `link`
1355
+ // can be a symlink to a foreign tree.
1356
+ expectedReal = normalizeHookProjectRoot(realpath(expected));
1357
+ actual = normalizeHookProjectRoot(realpath(candidate));
1358
+ }
1359
+ catch (cause) {
1360
+ return {
1361
+ aligned: false,
1362
+ message: `execution-directory field ${field} could not be realpath-bound: ${String(cause)}`,
1363
+ };
1364
+ }
1365
+ if (!sameExecutionDirectory(actual, expectedReal)) {
1366
+ return {
1367
+ aligned: false,
1368
+ message: `execution-directory field ${field} resolves to ${actual}, not the hook project ` +
1369
+ `root ${expectedReal}`,
1370
+ };
1371
+ }
1372
+ }
1373
+ }
1374
+ return { aligned: true, message: "execution directory is project-root aligned" };
1375
+ }
1376
+ /**
1377
+ * Hosts whose PreToolUse wire format can carry a rewritten tool input.
1378
+ *
1379
+ * `renderHostDecision` reads the same list, so the bridge cannot offer -- or
1380
+ * deny on behalf of -- a delivery channel a host does not have (#3873). A host
1381
+ * outside it binds its claim in the CLI instead: `resolveOccupancySessionId`
1382
+ * resolves the same ambient host owner the write gate will present.
1383
+ */
1384
+ const UPDATED_INPUT_WIRE_HOSTS = ["codex", "claude", "cursor"];
1385
+ function hostAcceptsUpdatedInput(host) {
1386
+ return UPDATED_INPUT_WIRE_HOSTS.includes(host);
1387
+ }
1388
+ /**
1389
+ * Add the claim-time owner only after the existing shell decision allowed the
1390
+ * command. The host wire format must emit `allow` with updated input, so this
1391
+ * path is intentionally restricted by rewriteExactLifecycleCommand.
1392
+ */
1393
+ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1394
+ if (decision.verdict !== "allow")
1395
+ return decision;
1396
+ if (!hostAcceptsUpdatedInput(input.host))
1397
+ return decision;
1398
+ const lifecycle = inspectExactLifecycleCommand(input.payload);
1399
+ if (lifecycle === null)
1400
+ return decision;
1401
+ if (!lifecycle.requiresOwner)
1402
+ return decision;
1403
+ if (lifecycle.sessionIdStatus === "invalid") {
1404
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1405
+ "--session-id is empty, duplicated, or otherwise ambiguous.");
1406
+ }
1407
+ const identity = resolveHookHostIdentity(input.host, input.payload, input.environ ?? process.env);
1408
+ // #3873: an absent host-env variable is the pre-#3873 explicit-owner flow, not
1409
+ // a broken contract. Claim binding is skipped, the command keeps ordinary host
1410
+ // permission handling, and any owner it carries is still its own.
1411
+ if (identity.status === "unsupported" ||
1412
+ hostIdentityFallsBackToExplicitOwner(input.host, identity)) {
1413
+ return decision;
1414
+ }
1415
+ if (identity.status !== "ok" || identity.sessionId === null) {
1416
+ const code = identity.status === "conflict"
1417
+ ? "occupancy-identity-conflict"
1418
+ : "occupancy-identity-unavailable";
1419
+ return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1420
+ `${identity.message ?? "host session identity is unavailable"}. ` +
1421
+ "Directive cannot bind the occupancy claim without a stable host owner; " +
1422
+ "manual callers must pass --session-id explicitly outside the host rewrite path.");
1423
+ }
1424
+ const environmentId = (input.environ ?? process.env).DEFT_SESSION_ID?.trim();
1425
+ if (environmentId !== undefined &&
1426
+ environmentId.length > 0 &&
1427
+ environmentId !== identity.sessionId) {
1428
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host owner ${identity.sessionId} conflicts with ` +
1429
+ `DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
1430
+ }
1431
+ if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
1432
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: lifecycle command ${lifecycle.verb} names ` +
1433
+ `${lifecycle.sessionId ?? "<missing>"}, but the host owner is ` +
1434
+ `${identity.sessionId}.`);
1435
+ }
1436
+ const sourceTask = !lifecycle.task ||
1437
+ (seams.runningInsideDeftRepo ?? runningInsideDeftRepo)(resolve(input.projectRoot));
1438
+ const executionRoot = lifecycleExecutionRootCheck(input.payload, input.projectRoot, seams.realpathLifecycleExecutionRoot ?? realpathSync, seams.lifecycleExecutionPlatform ?? process.platform);
1439
+ if (!executionRoot.aligned) {
1440
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${executionRoot.message}. ` +
1441
+ `Run the lifecycle command from ${normalizeHookProjectRoot(resolve(input.projectRoot))} ` +
1442
+ "so policy, lease, and ritual state target the same worktree.");
1443
+ }
1444
+ const rewriteAllowed = lifecycle.rewriteSafe && sourceTask;
1445
+ if (lifecycle.sessionIdStatus === "absent" && !rewriteAllowed) {
1446
+ let reason;
1447
+ if (lifecycle.task && !sourceTask) {
1448
+ reason = "Task lifecycle rewrites are only trusted in the Directive source repository";
1449
+ }
1450
+ else {
1451
+ reason = "this argument shape is outside the narrowly auto-approved rewrite surface";
1452
+ }
1453
+ return deny(input, "occupancy-identity-unavailable", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${reason}. ` +
1454
+ `Re-run the command with --session-id=${identity.sessionId}; Directive will verify ` +
1455
+ "that explicit owner without auto-approving or replacing the command.");
1456
+ }
1457
+ const rewrite = rewriteExactLifecycleCommand(input.payload, identity.sessionId);
1458
+ if (rewrite === null)
1459
+ return decision;
1460
+ if (rewrite.kind === "conflict") {
1461
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: ${rewrite.message} Host payload owner is ` +
1462
+ `${identity.sessionId}, but the command names ${rewrite.existingSessionId}.`);
1463
+ }
1464
+ return { ...decision, updatedInput: rewrite.updatedInput };
1465
+ }
813
1466
  /** Decide a normalized event using only the P0 direct-write policy. */
814
1467
  export function decideHook(input, seams = {}) {
815
1468
  const projectRoot = resolve(input.projectRoot);
@@ -817,6 +1470,8 @@ export function decideHook(input, seams = {}) {
817
1470
  // short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
818
1471
  // Tracked/committed flags do NOT bypass gates (repo-controlled content must
819
1472
  // not disable enforcement for clones) — doctor warns instead.
1473
+ // #3794: the kill-switch is pinned to payloadRoot. The flag is untracked, so
1474
+ // linked worktrees do not carry it; re-pointing would silently re-arm.
820
1475
  {
821
1476
  const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
822
1477
  const kill = detectKill(projectRoot);
@@ -843,6 +1498,12 @@ export function decideHook(input, seams = {}) {
843
1498
  }
844
1499
  }
845
1500
  if (input.event === "session.compact") {
1501
+ // #3769 Path 2: compact stays always-allow and never routes through
1502
+ // inspectMutationGates — entering the mutation gates would re-run the
1503
+ // ritual persist Path 1 closes, or occupancy-deny an owner's own compact.
1504
+ // The stale-mark is fail-open: it writes even when no acting identity can
1505
+ // be bound, because compact has no issue context and no surface a human
1506
+ // reads. The accepted cost is an unidentified compact re-arming the owner.
846
1507
  try {
847
1508
  const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
848
1509
  if (!result.changed) {
@@ -998,11 +1659,21 @@ export function decideHook(input, seams = {}) {
998
1659
  }
999
1660
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
1000
1661
  }
1001
- // Shell dest-forms (#3438): recognized product mutations share inspectMutationGates
1662
+ // Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
1663
+ // Dest-forms (#3438): recognized product mutations share inspectMutationGates
1002
1664
  // with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
1003
1665
  // on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
1004
1666
  if (isShellTool(toolName)) {
1005
- return decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1667
+ const destructive = decideGitDestructive(input, toolName);
1668
+ if (destructive !== null) {
1669
+ return attachLifecycleIdentityRewrite(input, toolName, destructive, seams);
1670
+ }
1671
+ const writeReissue = decideShellWriteReissue(input, toolName, seams);
1672
+ if (writeReissue !== null) {
1673
+ return attachLifecycleIdentityRewrite(input, toolName, writeReissue, seams);
1674
+ }
1675
+ const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1676
+ return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
1006
1677
  }
1007
1678
  // Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
1008
1679
  // Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
@@ -1057,6 +1728,24 @@ function softAgentsRebindWireText(decision) {
1057
1728
  */
1058
1729
  export function renderHostDecision(host, decision) {
1059
1730
  if (decision.verdict === "allow") {
1731
+ if (decision.event === "tool.before" &&
1732
+ decision.updatedInput !== undefined &&
1733
+ hostAcceptsUpdatedInput(host)) {
1734
+ if (host === "cursor") {
1735
+ return JSON.stringify({
1736
+ permission: "allow",
1737
+ code: decision.code,
1738
+ updated_input: decision.updatedInput,
1739
+ });
1740
+ }
1741
+ return JSON.stringify({
1742
+ hookSpecificOutput: {
1743
+ hookEventName: "PreToolUse",
1744
+ permissionDecision: "allow",
1745
+ updatedInput: decision.updatedInput,
1746
+ },
1747
+ });
1748
+ }
1060
1749
  const soft = softAgentsRebindWireText(decision);
1061
1750
  if (host === "cursor") {
1062
1751
  if (decision.event === "session.start" && soft !== null) {