@deftai/directive-core 0.106.0 → 0.108.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 (249) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/check/cached-orchestrator.js +28 -3
  3. package/dist/check/gate-lists.js +2 -0
  4. package/dist/check/named-cause.js +18 -3
  5. package/dist/codebase/provider.js +7 -1
  6. package/dist/delivery-attempt/handoff.js +1 -1
  7. package/dist/design-critique/citation-grammar.d.ts +53 -0
  8. package/dist/design-critique/citation-grammar.js +303 -0
  9. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  10. package/dist/design-critique/completed-arc-record.js +232 -0
  11. package/dist/design-critique/exclusive-chip.d.ts +33 -0
  12. package/dist/design-critique/exclusive-chip.js +68 -0
  13. package/dist/design-critique/parent-audit.d.ts +59 -0
  14. package/dist/design-critique/parent-audit.js +121 -0
  15. package/dist/doctor/checks.d.ts +6 -0
  16. package/dist/doctor/checks.js +35 -0
  17. package/dist/freshness/bind.d.ts +32 -3
  18. package/dist/freshness/bind.js +58 -12
  19. package/dist/hooks/classify/host-session-identity.d.ts +76 -0
  20. package/dist/hooks/classify/host-session-identity.js +470 -0
  21. package/dist/hooks/classify/index.d.ts +3 -2
  22. package/dist/hooks/classify/index.js +3 -2
  23. package/dist/hooks/classify/paths.d.ts +7 -0
  24. package/dist/hooks/classify/paths.js +35 -0
  25. package/dist/hooks/classify/stdin.d.ts +5 -0
  26. package/dist/hooks/classify/stdin.js +27 -0
  27. package/dist/hooks/dest-form.d.ts +46 -0
  28. package/dist/hooks/dest-form.js +575 -0
  29. package/dist/hooks/dispatcher.d.ts +69 -2
  30. package/dist/hooks/dispatcher.js +609 -45
  31. package/dist/hooks/fixtures/cases.d.ts +16 -2
  32. package/dist/hooks/fixtures/cases.js +175 -0
  33. package/dist/hooks/index.d.ts +1 -0
  34. package/dist/hooks/index.js +1 -0
  35. package/dist/hooks/scope.js +3 -1
  36. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  37. package/dist/init-deposit/agent-hooks.js +8 -1
  38. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  39. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  40. package/dist/intake/clause-derivation.js +9 -3
  41. package/dist/intake/github-auth-modes-cli.js +14 -2
  42. package/dist/intake/github-auth-modes.d.ts +54 -9
  43. package/dist/intake/github-auth-modes.js +326 -123
  44. package/dist/intake/issue-ingest.js +62 -2
  45. package/dist/intake/platform-capabilities.d.ts +9 -2
  46. package/dist/intake/platform-capabilities.js +38 -9
  47. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +26 -0
  48. package/dist/lifecycle/completed-tracked-on-delivery.js +37 -14
  49. package/dist/lifecycle/completed-write-guard.d.ts +48 -0
  50. package/dist/lifecycle/completed-write-guard.js +373 -0
  51. package/dist/lifecycle/index.d.ts +1 -0
  52. package/dist/lifecycle/index.js +1 -0
  53. package/dist/literal-acceptance/capture.d.ts +16 -0
  54. package/dist/literal-acceptance/capture.js +75 -7
  55. package/dist/literal-acceptance/evaluate.js +25 -3
  56. package/dist/literal-acceptance/index.d.ts +2 -2
  57. package/dist/literal-acceptance/index.js +2 -2
  58. package/dist/literal-acceptance/run.js +9 -3
  59. package/dist/literal-acceptance/types.d.ts +6 -0
  60. package/dist/literal-acceptance/types.js +6 -0
  61. package/dist/orphan-active/evaluate.d.ts +25 -0
  62. package/dist/orphan-active/evaluate.js +193 -128
  63. package/dist/orphan-active/index.d.ts +1 -0
  64. package/dist/orphan-active/index.js +1 -0
  65. package/dist/orphan-active/issue-state.d.ts +121 -0
  66. package/dist/orphan-active/issue-state.js +278 -0
  67. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  68. package/dist/platform/cursor-managed-runtime.js +241 -0
  69. package/dist/platform/index.d.ts +1 -0
  70. package/dist/platform/index.js +1 -0
  71. package/dist/platform/platform-capabilities.d.ts +23 -1
  72. package/dist/platform/platform-capabilities.js +55 -15
  73. package/dist/policy/ceremony-dial.js +5 -9
  74. package/dist/policy/host-hooks.js +4 -9
  75. package/dist/policy/org-force-on-migration.js +5 -10
  76. package/dist/policy/plan-extensions.d.ts +14 -2
  77. package/dist/policy/plan-extensions.js +24 -3
  78. package/dist/policy/product-signal.js +5 -10
  79. package/dist/policy/require-human-merge.js +5 -9
  80. package/dist/policy/resolve.js +77 -16
  81. package/dist/policy/runtime-authority.d.ts +18 -0
  82. package/dist/policy/runtime-authority.js +11 -0
  83. package/dist/policy/value-feedback.js +9 -18
  84. package/dist/policy/write-fence.js +3 -0
  85. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  86. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  87. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  88. package/dist/pr-closeout-attestable/index.js +2 -0
  89. package/dist/pr-protected-issues/gh.d.ts +6 -2
  90. package/dist/pr-protected-issues/gh.js +7 -3
  91. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  92. package/dist/pr-wait-mergeable/cascade.js +21 -1
  93. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  94. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  95. package/dist/pr-wait-mergeable/wrappers.js +11 -0
  96. package/dist/preflight/evaluate.d.ts +15 -0
  97. package/dist/preflight/evaluate.js +29 -3
  98. package/dist/preflight-cache/evaluate.js +9 -3
  99. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  100. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  101. package/dist/product-first-done-gate/evaluate.js +53 -12
  102. package/dist/release/cli-drift-report.d.ts +68 -0
  103. package/dist/release/cli-drift-report.js +189 -0
  104. package/dist/release/index.d.ts +1 -0
  105. package/dist/release/index.js +1 -0
  106. package/dist/release/issue-state-fetch.d.ts +5 -3
  107. package/dist/release/issue-state-fetch.js +86 -21
  108. package/dist/release/native-steps.js +1 -0
  109. package/dist/release/pipeline.js +19 -0
  110. package/dist/release/types.d.ts +14 -0
  111. package/dist/render/constants.d.ts +4 -0
  112. package/dist/render/constants.js +11 -8
  113. package/dist/render/export-spec.js +31 -5
  114. package/dist/render/index.d.ts +1 -1
  115. package/dist/render/index.js +1 -1
  116. package/dist/render/prd-render.d.ts +4 -1
  117. package/dist/render/prd-render.js +82 -23
  118. package/dist/render/project-render.js +14 -7
  119. package/dist/render/scope-outlook.d.ts +2 -0
  120. package/dist/render/scope-outlook.js +3 -0
  121. package/dist/render/spec-render.js +2 -2
  122. package/dist/resolution/package-manager.d.ts +33 -4
  123. package/dist/resolution/package-manager.js +210 -9
  124. package/dist/review-monitor/constants.d.ts +7 -0
  125. package/dist/review-monitor/constants.js +7 -0
  126. package/dist/review-monitor/github-lease.d.ts +98 -1
  127. package/dist/review-monitor/github-lease.js +182 -1
  128. package/dist/review-monitor/lease-comment.d.ts +36 -0
  129. package/dist/review-monitor/lease-comment.js +103 -2
  130. package/dist/scm/call.js +12 -3
  131. package/dist/scm/design-critique-chip.d.ts +38 -0
  132. package/dist/scm/design-critique-chip.js +187 -0
  133. package/dist/scm/gh-rest.d.ts +9 -1
  134. package/dist/scm/gh-rest.js +103 -0
  135. package/dist/scm/index.d.ts +1 -0
  136. package/dist/scm/index.js +1 -0
  137. package/dist/scm/main.d.ts +3 -0
  138. package/dist/scm/main.js +15 -1
  139. package/dist/scm/readiness-cli.d.ts +2 -0
  140. package/dist/scm/readiness-cli.js +42 -1
  141. package/dist/scm/readiness.d.ts +4 -1
  142. package/dist/scm/readiness.js +35 -5
  143. package/dist/scope/decompose.js +2 -1
  144. package/dist/scope/delivery-evidence.d.ts +27 -1
  145. package/dist/scope/index.d.ts +1 -0
  146. package/dist/scope/index.js +1 -0
  147. package/dist/scope/lifecycle-write.d.ts +24 -0
  148. package/dist/scope/lifecycle-write.js +64 -0
  149. package/dist/scope/main.js +3 -1
  150. package/dist/scope/project-definition-sync.js +14 -7
  151. package/dist/scope/transition.js +90 -11
  152. package/dist/scope-provenance/evaluate.js +5 -1
  153. package/dist/session/git.d.ts +42 -0
  154. package/dist/session/git.js +219 -3
  155. package/dist/session/occupancy.d.ts +239 -12
  156. package/dist/session/occupancy.js +820 -27
  157. package/dist/session/ritual-sentinel.d.ts +5 -0
  158. package/dist/session/ritual-sentinel.js +5 -0
  159. package/dist/session/session-ready.d.ts +5 -1
  160. package/dist/session/session-ready.js +98 -8
  161. package/dist/session/session-start.d.ts +17 -1
  162. package/dist/session/session-start.js +49 -17
  163. package/dist/session/toolchain-preflight.d.ts +13 -3
  164. package/dist/session/toolchain-preflight.js +93 -18
  165. package/dist/session/verify-session-ritual.d.ts +33 -3
  166. package/dist/session/verify-session-ritual.js +89 -19
  167. package/dist/slice/constants.d.ts +1 -1
  168. package/dist/spec-authority/constants.d.ts +10 -2
  169. package/dist/spec-authority/constants.js +53 -8
  170. package/dist/spec-authority/resolver.d.ts +3 -0
  171. package/dist/spec-authority/resolver.js +55 -7
  172. package/dist/subprocess/max-buffer.d.ts +14 -0
  173. package/dist/subprocess/max-buffer.js +15 -0
  174. package/dist/swarm/complete-cohort.d.ts +2 -0
  175. package/dist/swarm/complete-cohort.js +21 -13
  176. package/dist/swarm/index.d.ts +1 -0
  177. package/dist/swarm/index.js +1 -0
  178. package/dist/swarm/launch-cli.js +53 -33
  179. package/dist/swarm/launch.d.ts +77 -0
  180. package/dist/swarm/launch.js +248 -38
  181. package/dist/swarm/pre-dispatch-cli.js +2 -1
  182. package/dist/swarm/pre-dispatch.js +10 -1
  183. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  184. package/dist/swarm/subagent-status-dir.js +42 -0
  185. package/dist/swarm/worktrees.d.ts +17 -3
  186. package/dist/swarm/worktrees.js +48 -4
  187. package/dist/triage/actions/index.d.ts +4 -0
  188. package/dist/triage/actions/index.js +6 -0
  189. package/dist/triage/evaluate/evaluate.d.ts +11 -0
  190. package/dist/triage/evaluate/evaluate.js +169 -0
  191. package/dist/triage/evaluate/github.d.ts +16 -0
  192. package/dist/triage/evaluate/github.js +153 -0
  193. package/dist/triage/evaluate/index.d.ts +9 -0
  194. package/dist/triage/evaluate/index.js +8 -0
  195. package/dist/triage/evaluate/paths.d.ts +7 -0
  196. package/dist/triage/evaluate/paths.js +25 -0
  197. package/dist/triage/evaluate/sink.d.ts +5 -0
  198. package/dist/triage/evaluate/sink.js +52 -0
  199. package/dist/triage/evaluate/types.d.ts +93 -0
  200. package/dist/triage/evaluate/types.js +13 -0
  201. package/dist/triage/evaluate/validity.d.ts +8 -0
  202. package/dist/triage/evaluate/validity.js +87 -0
  203. package/dist/triage/evaluate/value.d.ts +12 -0
  204. package/dist/triage/evaluate/value.js +33 -0
  205. package/dist/triage/evaluate/wip-census.d.ts +5 -0
  206. package/dist/triage/evaluate/wip-census.js +39 -0
  207. package/dist/triage/evaluate/worktrees.d.ts +8 -0
  208. package/dist/triage/evaluate/worktrees.js +68 -0
  209. package/dist/triage/evaluate/xbrief-refs.d.ts +8 -0
  210. package/dist/triage/evaluate/xbrief-refs.js +60 -0
  211. package/dist/triage/help/registry-data.d.ts +21 -7
  212. package/dist/triage/help/registry-data.js +53 -5
  213. package/dist/triage/index.d.ts +1 -0
  214. package/dist/triage/index.js +1 -0
  215. package/dist/triage/scope/mutations-core.d.ts +2 -6
  216. package/dist/triage/scope/mutations-core.js +23 -55
  217. package/dist/triage/scope-drift/add-ignore.js +5 -39
  218. package/dist/triage/subscribe/index.d.ts +2 -6
  219. package/dist/triage/subscribe/index.js +15 -65
  220. package/dist/triage/welcome/writers.js +15 -74
  221. package/dist/value/feedback-file.d.ts +17 -2
  222. package/dist/value/feedback-file.js +80 -6
  223. package/dist/vbrief-build/index.d.ts +2 -1
  224. package/dist/vbrief-build/index.js +2 -1
  225. package/dist/vbrief-build/parity-scenarios.js +5 -5
  226. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  227. package/dist/vbrief-build/project-definition-io.js +357 -31
  228. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  229. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  230. package/dist/vbrief-reconcile/labels.d.ts +1 -0
  231. package/dist/vbrief-reconcile/labels.js +30 -0
  232. package/dist/vbrief-validate/conformance.d.ts +1 -0
  233. package/dist/vbrief-validate/conformance.js +85 -8
  234. package/dist/vbrief-validate/main.js +2 -0
  235. package/dist/vbrief-validate/precutover.js +5 -10
  236. package/dist/vbrief-validation/story-quality.d.ts +7 -0
  237. package/dist/vbrief-validation/story-quality.js +8 -1
  238. package/dist/verify-ac/clauses.d.ts +62 -2
  239. package/dist/verify-ac/clauses.js +183 -111
  240. package/dist/verify-ac/index.d.ts +1 -1
  241. package/dist/verify-ac/index.js +1 -1
  242. package/dist/verify-env/node-runtime.d.ts +8 -4
  243. package/dist/verify-env/node-runtime.js +9 -6
  244. package/dist/verify-env/toolchain-check.d.ts +35 -3
  245. package/dist/verify-env/toolchain-check.js +155 -36
  246. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  247. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  248. package/dist/xbrief-migrate/agents-header.js +69 -7
  249. 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,13 +10,14 @@ 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, } from "../session/verify-session-ritual.js";
18
- import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
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
21
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
20
22
  import { inspectActiveScope } from "./scope.js";
21
23
  import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
@@ -192,8 +194,8 @@ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ =
192
194
  return true;
193
195
  return false;
194
196
  }
195
- function isWindowsDriveOnlyRoot(value) {
196
- return /^[A-Za-z]:[/\\]?$/.test(value.trim());
197
+ function isWindowsDriveOnlyRoot(value, platform = process.platform) {
198
+ return platform === "win32" && /^[A-Za-z]:[/\\]?$/.test(value.trim());
197
199
  }
198
200
  function hookPayloadRootCandidates(input) {
199
201
  const candidates = [];
@@ -249,12 +251,114 @@ export function projectRootFromHookPayload(payload, fallback) {
249
251
  }
250
252
  return fallbackResolved;
251
253
  }
254
+ /** Interpolate both roots so a deny names the tree that was judged (#3794). */
255
+ export function formatHookRootNote(payloadRoot, effectiveRoot) {
256
+ return `payloadRoot=${payloadRoot} effectiveRoot=${effectiveRoot}`;
257
+ }
258
+ function admitPayload(payload, candidate) {
259
+ return { root: payload, foreign: false, candidate, refusal: null };
260
+ }
261
+ /**
262
+ * Admit the Git toplevel of the write target only when it shares
263
+ * `--git-common-dir` with `payloadRoot`.
264
+ *
265
+ * Identity resolves to one of three outcomes, not two. A `payloadRoot` that is
266
+ * not a Git repository never posed a containment question, so it keeps today's
267
+ * payloadRoot gating. A `payloadRoot` that *is* a repository, paired with a
268
+ * target that resolved to some other toplevel whose identity cannot be read, is
269
+ * a question asked and left unanswered — that fails closed rather than
270
+ * inheriting payloadRoot's occupancy and ritual state (#3794).
271
+ */
272
+ export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
273
+ const payload = normalizeHookProjectRoot(payloadRoot);
274
+ if (writeTarget === null)
275
+ return admitPayload(payload, null);
276
+ const ancestor = existingAncestorDir(writeTarget);
277
+ if (ancestor === null)
278
+ return admitPayload(payload, null);
279
+ const candidateRaw = worktreePathOrNull(ancestor, runGit);
280
+ if (candidateRaw === null)
281
+ return admitPayload(payload, null);
282
+ const candidate = normalizeHookProjectRoot(candidateRaw);
283
+ if (candidate === payload)
284
+ return admitPayload(payload, candidate);
285
+ const payloadCommon = gitCommonDir(payload, runGit);
286
+ if (payloadCommon === null) {
287
+ // No containment question exists: payloadRoot is not a repository, so there
288
+ // is no boundary for the target to be inside or outside of. Denying here
289
+ // would invent a class this defect never had.
290
+ return admitPayload(payload, candidate);
291
+ }
292
+ const candidateCommon = gitCommonDir(candidate, runGit);
293
+ if (candidateCommon === null) {
294
+ return { root: payload, foreign: true, candidate, refusal: "unproven-identity" };
295
+ }
296
+ if (normalizeHookProjectRoot(payloadCommon) === normalizeHookProjectRoot(candidateCommon)) {
297
+ return { root: candidate, foreign: false, candidate, refusal: null };
298
+ }
299
+ return { root: payload, foreign: true, candidate, refusal: "foreign-repository" };
300
+ }
301
+ /**
302
+ * Admit every mutation target and require one effectiveRoot. A refused member
303
+ * short-circuits; a span of two admitted worktrees is also refused so
304
+ * occupancy/ritual cannot follow only the declared ApplyPatch path (#3794).
305
+ */
306
+ export function admitMutationTargetSet(payloadRoot, targets, runGit) {
307
+ const payload = normalizeHookProjectRoot(payloadRoot);
308
+ if (targets.length === 0)
309
+ return admitPayload(payload, null);
310
+ const admitted = [];
311
+ for (const target of targets) {
312
+ const next = admitEffectiveHookRoot(payload, target, runGit);
313
+ if (next.foreign)
314
+ return next;
315
+ admitted.push(next);
316
+ }
317
+ const roots = [...new Set(admitted.map((item) => item.root))];
318
+ if (roots.length > 1) {
319
+ return {
320
+ root: payload,
321
+ foreign: true,
322
+ candidate: roots.join(" | "),
323
+ refusal: "worktree-span",
324
+ };
325
+ }
326
+ return admitted[0] ?? admitPayload(payload, null);
327
+ }
252
328
  export function isHookHost(value) {
253
329
  return HOOK_HOSTS.includes(value);
254
330
  }
255
331
  export function isHookEvent(value) {
256
332
  return HOOK_EVENTS.includes(value);
257
333
  }
334
+ /**
335
+ * Coverage sentence appended to a scope-not-ready deny.
336
+ *
337
+ * #3828: this used to claim Shell dest-form coverage unconditionally. Coverage
338
+ * is opt-in (`plan.policy.runtimeAuthority.shellDestForms: "enforce"`) and the
339
+ * default is `off`, so on a default-policy repository the sentence advertised a
340
+ * fence that was not there and, by naming `python -c` as the exception, implied
341
+ * the gap was a sliver rather than the whole Shell surface. State the coverage
342
+ * actually in force.
343
+ */
344
+ function scopeNotReadyCoverageHint(toolName, shellDestFormsEnforced) {
345
+ if (isSpawnTool(toolName))
346
+ return "";
347
+ if (isShellTool(toolName)) {
348
+ return (" This deny is the active-scope write gate on a recognized Shell dest-form " +
349
+ "(git checkout --, git restore, rm/rmdir of in-repo paths).");
350
+ }
351
+ if (shellDestFormsEnforced) {
352
+ return (" This gate covers direct-write tools (Edit/Write), implementation spawn, and " +
353
+ "recognized Shell dest-forms (git checkout --, git restore, rm/rmdir), because " +
354
+ "plan.policy.runtimeAuthority.shellDestForms is enforce. Shell whose target " +
355
+ "cannot be resolved to a product path stays outside this deny.");
356
+ }
357
+ return (" This gate covers direct-write tools (Edit/Write) and implementation spawn. " +
358
+ "plan.policy.runtimeAuthority.shellDestForms is off (the default), so no Shell " +
359
+ "command is covered by this deny -- reaching for one to get around it defeats " +
360
+ "the gate rather than satisfying it.");
361
+ }
258
362
  function deny(input, code, toolName, message, scopePath = null) {
259
363
  return {
260
364
  verdict: "deny",
@@ -267,8 +371,55 @@ function deny(input, code, toolName, message, scopePath = null) {
267
371
  scopePath,
268
372
  };
269
373
  }
270
- function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
374
+ /**
375
+ * 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.
379
+ */
380
+ function resolveMutationActor(input, environ) {
381
+ const hostIdentity = resolveHookHostIdentity(input.host, input.payload);
382
+ const environmentId = environ.DEFT_SESSION_ID?.trim() || undefined;
383
+ if (hostIdentity.status === "unsupported") {
384
+ return {
385
+ sessionId: environmentId,
386
+ issue: null,
387
+ message: null,
388
+ payloadAuthoritative: false,
389
+ };
390
+ }
391
+ if (hostIdentity.status === "ok" && hostIdentity.sessionId !== null) {
392
+ if (environmentId !== undefined && environmentId !== hostIdentity.sessionId) {
393
+ return {
394
+ sessionId: hostIdentity.sessionId,
395
+ issue: "conflict",
396
+ message: `Host payload owner ${hostIdentity.sessionId} conflicts with ` +
397
+ `DEFT_SESSION_ID ${environmentId}.`,
398
+ payloadAuthoritative: true,
399
+ };
400
+ }
401
+ return {
402
+ sessionId: hostIdentity.sessionId,
403
+ issue: null,
404
+ message: null,
405
+ payloadAuthoritative: true,
406
+ };
407
+ }
408
+ return {
409
+ sessionId: undefined,
410
+ issue: hostIdentity.status === "conflict" ? "conflict" : "unavailable",
411
+ message: hostIdentity.message,
412
+ payloadAuthoritative: true,
413
+ };
414
+ }
415
+ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath, effectiveRoot) {
416
+ // #3794 commit 2: the project runtimeAuthority layer is project-level
417
+ // authorization config and stays on payloadRoot, alongside authz. Only the
418
+ // story fence and the path it is matched against follow the tree the write
419
+ // lands in, so a story `file_scope` of `packages/**` still matches a write
420
+ // inside a linked worktree rather than `.deft-scratch/worktrees/<x>/packages/`.
271
421
  const projectRoot = resolve(input.projectRoot);
422
+ const fenceRoot = resolve(effectiveRoot);
272
423
  // Project policy load failure: treat as disabled project layer, still apply
273
424
  // independent story file_scope when present (#516 / #2443 Greptile P1).
274
425
  let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
@@ -284,7 +435,7 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
284
435
  let storyFence;
285
436
  try {
286
437
  storyFence = seams.loadStoryWriteFence
287
- ? seams.loadStoryWriteFence(projectRoot, scopePath)
438
+ ? seams.loadStoryWriteFence(fenceRoot, scopePath)
288
439
  : loadStoryWriteFenceFromPath(scopePath);
289
440
  }
290
441
  catch {
@@ -298,14 +449,19 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
298
449
  if (!fence.fenceActive)
299
450
  return null;
300
451
  const writeTarget = hookWriteTargetPath(input.payload);
301
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
452
+ const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
302
453
  const verdict = evaluateRuntimeAuthorityDirectWrite({
303
454
  policy: fence.policy,
304
455
  relPathPosix: relPath,
305
456
  });
306
457
  if (verdict.allowed)
307
458
  return null;
308
- return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under write fence policy.", scopePath);
459
+ // #3794: relPath is now measured from the tree the write lands in. When that
460
+ // differs from the payload root, a bare "outside file_scope" reason does not
461
+ // say which tree the fence was relativised against.
462
+ 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);
309
465
  }
310
466
  function loadAuthzContext(projectRoot, seams) {
311
467
  if (seams.loadAuthzState !== undefined) {
@@ -368,6 +524,8 @@ function recordAuthzAudit(projectRoot, decision, state, seams) {
368
524
  * Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
369
525
  */
370
526
  function authzForMutation(input, toolName, seams, options) {
527
+ // #3794: grant scoping and the authz audit trail stay on payloadRoot.
528
+ // Re-pointing would break exact-string UAT grants and shard the audit file.
371
529
  const projectRoot = resolve(input.projectRoot);
372
530
  let state;
373
531
  let grants;
@@ -401,7 +559,7 @@ function authzForMutation(input, toolName, seams, options) {
401
559
  // Structural context for bound grants (branch from git; repo optional env).
402
560
  let branch = null;
403
561
  try {
404
- branch = detectBranch(projectRoot);
562
+ branch = detectBranch(projectRoot, options.runGit);
405
563
  }
406
564
  catch {
407
565
  branch = null;
@@ -540,23 +698,50 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
540
698
  };
541
699
  }
542
700
  function inspectMutationGates(input, toolName, seams, options) {
543
- const projectRoot = resolve(input.projectRoot);
701
+ // payloadRoot stays authoritative for authz grant scoping, the authz audit
702
+ // trail, the kill-switch, the #2885 outside-root carve-out, and
703
+ // deny().projectRoot. effectiveRoot governs occupancy and ritual (commit 1),
704
+ // and active scope, the story write fence and assist-scratch (commit 2).
705
+ const payloadRoot = resolve(input.projectRoot);
706
+ const projectRoot = payloadRoot;
544
707
  const environ = input.environ ?? process.env;
708
+ const dispatchGit = memoizeGitRunner(seams.ritualRunGit ?? defaultGitRunner);
709
+ const mutationTargets = isSpawnTool(toolName) ? [] : hookMutationTargetPaths(input.payload);
710
+ const admission = isSpawnTool(toolName)
711
+ ? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
712
+ : admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
713
+ const effectiveRoot = admission.root;
714
+ const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
715
+ if (admission.foreign) {
716
+ const candidate = admission.candidate ?? "<none>";
717
+ const reason = admission.refusal === "worktree-span"
718
+ ? `write targets span more than one Git worktree (candidate=${candidate}).`
719
+ : admission.refusal === "unproven-identity"
720
+ ? `write target resolved to a separate Git working tree whose repository ` +
721
+ `identity could not be read, so containment is unproven (candidate=${candidate}).`
722
+ : `write target is in a different Git repository ` +
723
+ `than the hook payload root (candidate=${candidate}).`;
724
+ return deny(input, "foreign-repository-deny", toolName, `Directive denied ${toolName}: ${reason}${rootsNote}`);
725
+ }
545
726
  // Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
546
727
  // assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
547
728
  // Tracked product paths never match the path fence. Read-only still denied upstream.
548
729
  if (!isSpawnTool(toolName)) {
549
730
  const scratchTarget = hookWriteTargetPath(input.payload);
550
- if (isAssistScratchWrite(projectRoot, scratchTarget, input.payload, environ)) {
731
+ // #3794 commit 2: classify against effectiveRoot. Relative to payloadRoot every
732
+ // file in a `.deft-scratch/worktrees/<x>/` worktree read as assist scratch, so
733
+ // assist/ephemeral worktree writes skipped ritual, occupancy and scope entirely.
734
+ if (isAssistScratchWrite(effectiveRoot, scratchTarget, input.payload, environ)) {
551
735
  const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
552
736
  const authzDeny = authzForMutation(input, toolName, seams, {
553
737
  isDirectWrite: true,
554
738
  relPath,
555
739
  scopePath: null,
740
+ runGit: dispatchGit,
556
741
  });
557
742
  if (authzDeny !== null)
558
743
  return authzDeny;
559
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
744
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
560
745
  if (runtimeDeny !== null)
561
746
  return runtimeDeny;
562
747
  return {
@@ -574,19 +759,88 @@ function inspectMutationGates(input, toolName, seams, options) {
574
759
  };
575
760
  }
576
761
  }
762
+ const actor = isSpawnTool(toolName) ? null : resolveMutationActor(input, environ);
763
+ const occupancyGate = isSpawnTool(toolName)
764
+ ? {
765
+ allow: true,
766
+ message: null,
767
+ occupant: null,
768
+ admitted: null,
769
+ }
770
+ : evaluateOccupancyWriteGate(effectiveRoot, {
771
+ sessionId: actor?.sessionId,
772
+ // Payload-supported hosts must not fall back to a stale ambient owner.
773
+ env: actor?.payloadAuthoritative === true ? {} : environ,
774
+ });
775
+ // #3769: occupancy decides before any ritual persist. The gated verifier
776
+ // executes agent_hooks and rebinds a forward HEAD, and both rewrite
777
+ // .deft/ritual-state.json — so verifying first let a session this gate was
778
+ // about to deny rewrite the occupant's record. The deny paths below read the
779
+ // ritual through the no-write inspect, which runs no step and never rebinds;
780
+ // only a session occupancy would admit reaches the verifier.
781
+ //
782
+ // Ritual detail on an occupancy deny is telemetry and message decoration, so
783
+ // an inspect failure must not convert an occupancy verdict into a ritual one.
784
+ const ritualDetailForOccupancyDeny = () => {
785
+ try {
786
+ return (seams.inspectRitual ??
787
+ seams.verifyRitual ??
788
+ ((root) => inspectSessionRitual(root, {
789
+ tier: "gated",
790
+ posture: "mutation",
791
+ runGit: dispatchGit,
792
+ requiredGatedSteps: WRITE_GATED_REQUIRED_STEPS,
793
+ })))(effectiveRoot);
794
+ }
795
+ catch {
796
+ return null;
797
+ }
798
+ };
799
+ if (occupancyGate.occupant !== null && actor?.issue !== null && actor !== null) {
800
+ const inspected = ritualDetailForOccupancyDeny();
801
+ const code = actor.issue === "conflict" ? "occupancy-identity-conflict" : "occupancy-identity-unavailable";
802
+ const detail = actor.message ??
803
+ "The host did not supply a usable cooperative session/conversation identity.";
804
+ emitSessionRitualBlockedProcessCost({
805
+ toolName,
806
+ code,
807
+ recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
808
+ detail,
809
+ }, { projectRoot });
810
+ return deny(input, code, toolName, `Directive denied ${toolName}: ${detail} A live occupancy lease exists for ` +
811
+ `session ${occupancyGate.occupant.sessionId}; use an exact host-mediated lifecycle ` +
812
+ "command or pass the matching --session-id explicitly." +
813
+ rootsNote);
814
+ }
815
+ if (!occupancyGate.allow && occupancyGate.message !== null) {
816
+ const inspected = ritualDetailForOccupancyDeny();
817
+ const ritualNote = inspected !== null && inspected.code !== 0
818
+ ? ` Also ritual-not-ready: ${inspected.message}`
819
+ : "";
820
+ emitSessionRitualBlockedProcessCost({
821
+ toolName,
822
+ code: "occupancy-occupied",
823
+ recoveryTier: inspected?.recoveryTier === "rearm" ? "rearm" : "cold",
824
+ detail: occupancyGate.message,
825
+ }, { projectRoot });
826
+ return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: ${occupancyGate.message}${ritualNote}${rootsNote}`);
827
+ }
577
828
  let ritual;
578
829
  try {
579
830
  // Mutation dispatch is a live gated boundary: active verification reruns
580
831
  // non-cacheable agent-hook readiness instead of trusting persisted success.
832
+ // Write-surface authority executes agent_hooks only (#3738). cache_fresh
833
+ // is not required and is never executed. doctor is read from the record.
581
834
  // Its allow fixture is non-write and its deny fixture is read-only, so the
582
835
  // installed-shim probe cannot recurse into this mutation path.
583
836
  ritual = (seams.verifyRitual ??
584
837
  seams.inspectRitual ??
585
- ((root) => verifySessionRitual(root, {
586
- tier: "gated",
587
- posture: "mutation",
838
+ ((root) => verifySessionRitual(root, writeGateRitualOptions({
588
839
  bypass: false,
589
- })))(projectRoot);
840
+ runner: seams.ritualRunner,
841
+ detectWorkSelection: seams.detectWorkSelection,
842
+ runGit: dispatchGit,
843
+ }))))(effectiveRoot);
590
844
  }
591
845
  catch (cause) {
592
846
  // #2994: best-effort local process-cost; never changes deny verdict.
@@ -597,20 +851,36 @@ function inspectMutationGates(input, toolName, seams, options) {
597
851
  detail: `inspect threw: ${String(cause)}`,
598
852
  }, { projectRoot });
599
853
  return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
600
- formatRitualRecoveryInstruction("cold"));
854
+ formatRitualRecoveryInstruction("cold") +
855
+ rootsNote);
601
856
  }
602
- const occupancyGate = isSpawnTool(toolName)
603
- ? { allow: true, message: null, occupant: null }
604
- : evaluateOccupancyWriteGate(projectRoot, { env: environ });
605
- if (!occupancyGate.allow && occupancyGate.message !== null) {
606
- const ritualNote = ritual.code !== 0 ? ` Also ritual-not-ready: ${ritual.message}` : "";
607
- emitSessionRitualBlockedProcessCost({
608
- toolName,
609
- code: "occupancy-occupied",
610
- recoveryTier: ritual.recoveryTier === "rearm" ? "rearm" : "cold",
611
- detail: occupancyGate.message,
612
- }, { projectRoot });
613
- return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: ${occupancyGate.message}${ritualNote}`);
857
+ if (occupancyGate.occupant !== null && actor !== null) {
858
+ // A granted member writes under the owner's ceremony (#3755). Ritual state
859
+ // is single-owner and a child cannot bind its own while the owner's lease is
860
+ // live, so requiring the writer's own binding would make every admitted
861
+ // member's write unreachable the grant would authorize nothing. What is
862
+ // checked instead is the identity that issued the grant: the tree's exact
863
+ // verified ritual owner must be the occupant whose lease admits this member.
864
+ const expectedOwner = occupancyGate.admitted === "member" ? occupancyGate.occupant.sessionId : actor.sessionId;
865
+ const verifiedOwner = ritual.boundSessionId;
866
+ const ritualOwnerMismatch = expectedOwner !== undefined &&
867
+ (verifiedOwner !== undefined ? verifiedOwner !== expectedOwner : ritual.code === 0);
868
+ if (ritualOwnerMismatch) {
869
+ const detail = verifiedOwner === undefined
870
+ ? "gated ritual verification returned ready without an exact bound owner"
871
+ : `exact verified ritual owner ${verifiedOwner} differs from host/lease owner ${expectedOwner}`;
872
+ emitSessionRitualBlockedProcessCost({
873
+ toolName,
874
+ code: "occupancy-ritual-mismatch",
875
+ recoveryTier: ritual.recoveryTier === "rearm" ? "rearm" : "cold",
876
+ detail,
877
+ }, { projectRoot });
878
+ return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: ${detail}. ` +
879
+ "Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
880
+ "otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony. " +
881
+ "Intermediate lease/ritual mismatches fail closed." +
882
+ rootsNote);
883
+ }
614
884
  }
615
885
  if (ritual.code !== 0) {
616
886
  // #2992: prefer re-arm recovery when age/compact stale; cold when bind invalid.
@@ -622,23 +892,73 @@ function inspectMutationGates(input, toolName, seams, options) {
622
892
  recoveryTier,
623
893
  detail: ritual.message,
624
894
  }, { projectRoot });
625
- return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}`);
895
+ return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}` +
896
+ rootsNote);
626
897
  }
898
+ // Narrow the cooperative file-gate race: authorization/scope checks can
899
+ // take long enough for another session to transition the lease. Re-read
900
+ // only occupancy immediately before each direct-write allow, while retaining
901
+ // the exact ritual owner above (never adopt a later ritual file here).
902
+ // #3599: the owner's lease is only kept alive here, on the last evaluation
903
+ // before an allowed write, so the stamp records a write that really happened.
904
+ let occupancyWarning = null;
905
+ const recheckOccupancyBeforeWriteAllow = () => {
906
+ if (actor === null)
907
+ return null;
908
+ const finalOccupancy = evaluateOccupancyWriteGate(effectiveRoot, {
909
+ sessionId: actor.sessionId,
910
+ env: actor.payloadAuthoritative ? {} : environ,
911
+ refresh: true,
912
+ });
913
+ occupancyWarning = finalOccupancy.warning;
914
+ if (finalOccupancy.occupant !== null && actor.issue !== null) {
915
+ return deny(input, actor.issue === "conflict"
916
+ ? "occupancy-identity-conflict"
917
+ : "occupancy-identity-unavailable", toolName, `Directive denied ${toolName}: the live occupancy owner changed while mutation ` +
918
+ "gates were running, and this hook has no unambiguous matching actor identity." +
919
+ rootsNote);
920
+ }
921
+ if (!finalOccupancy.allow && finalOccupancy.message !== null) {
922
+ return deny(input, "occupancy-occupied", toolName, `Directive denied ${toolName}: occupancy changed while mutation gates were running. ` +
923
+ finalOccupancy.message +
924
+ rootsNote);
925
+ }
926
+ // Same member rule as the pre-gate check above (#3755): an admitted member
927
+ // is measured against the occupant that granted it, not against itself.
928
+ const finalExpectedOwner = finalOccupancy.admitted === "member" && finalOccupancy.occupant !== null
929
+ ? finalOccupancy.occupant.sessionId
930
+ : actor.sessionId;
931
+ if (finalOccupancy.occupant !== null &&
932
+ finalExpectedOwner !== undefined &&
933
+ ritual.boundSessionId !== finalExpectedOwner) {
934
+ return deny(input, "occupancy-ritual-mismatch", toolName, `Directive denied ${toolName}: final lease owner ${finalExpectedOwner} does not match ` +
935
+ `the exact verified ritual owner ${ritual.boundSessionId ?? "<unbound>"}. ` +
936
+ "Run `deft session:start --rearm --session-id=<same-session-id>` when re-arm is eligible; " +
937
+ "otherwise run `deft session:start --session-id=<same-session-id>` for a cold ceremony." +
938
+ rootsNote);
939
+ }
940
+ return null;
941
+ };
942
+ const withOccupancyWarning = (message) => occupancyWarning === null ? message : `${message} ${occupancyWarning}`;
627
943
  if (options.proposedLifecycleExempt) {
628
944
  const writeTarget = hookWriteTargetPath(input.payload);
629
- if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
945
+ if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
630
946
  const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
631
947
  // UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
632
948
  const authzDeny = authzForMutation(input, toolName, seams, {
633
949
  isDirectWrite: true,
634
950
  relPath,
635
951
  scopePath: null,
952
+ runGit: dispatchGit,
636
953
  });
637
954
  if (authzDeny !== null)
638
955
  return authzDeny;
639
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
956
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
640
957
  if (runtimeDeny !== null)
641
958
  return runtimeDeny;
959
+ const occupancyDeny = recheckOccupancyBeforeWriteAllow();
960
+ if (occupancyDeny !== null)
961
+ return occupancyDeny;
642
962
  return {
643
963
  verdict: "allow",
644
964
  code: "write-propose-ready",
@@ -646,26 +966,33 @@ function inspectMutationGates(input, toolName, seams, options) {
646
966
  host: input.host,
647
967
  toolName,
648
968
  projectRoot,
649
- message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
650
- "(planning write; active scope not required).",
969
+ message: withOccupancyWarning(`Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
970
+ "(planning write; active scope not required)."),
651
971
  scopePath: null,
652
972
  };
653
973
  }
654
974
  }
655
975
  let scope;
656
976
  try {
657
- scope = (seams.inspectScope ?? inspectActiveScope)(projectRoot);
977
+ // #3794 commit 2: a write is governed by the active scope of the worktree it
978
+ // lands in, not the primary checkout's. Admission has already proved
979
+ // effectiveRoot shares --git-common-dir with payloadRoot.
980
+ scope = (seams.inspectScope ?? inspectActiveScope)(effectiveRoot);
658
981
  }
659
982
  catch (cause) {
660
983
  scope = { ready: false, path: null, message: String(cause) };
661
984
  }
662
985
  if (!scope.ready) {
663
986
  const writeTarget = hookWriteTargetPath(input.payload);
664
- const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
987
+ const relTarget = writeTarget !== null ? toProjectRelativePosix(effectiveRoot, writeTarget) : null;
665
988
  // Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
666
989
  // (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
667
990
  // targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
668
991
  // Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
992
+ // #3794: the #2885 carve-out is measured from payloadRoot, so a linked
993
+ // worktree that lives outside the payload root keeps today's skip. Worktrees
994
+ // under the payload root -- `.deft-scratch/worktrees/`, the swarm layout --
995
+ // are inside it and are now gated on their own active scope.
669
996
  const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
670
997
  if (!outsideRoot || isSpawnTool(toolName)) {
671
998
  let proposedPathHint;
@@ -705,7 +1032,17 @@ function inspectMutationGates(input, toolName, seams, options) {
705
1032
  "assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
706
1033
  }
707
1034
  const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
708
- return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
1035
+ // #3794: the recovery has to run in the tree whose active scope was read.
1036
+ // Once the two roots differ, "the project root" names no single directory
1037
+ // the agent can act on, and activating in the payload root does not clear
1038
+ // a deny measured against the worktree.
1039
+ const recoveryRootNote = resolve(effectiveRoot) === payloadRoot
1040
+ ? ""
1041
+ : ` Active scope was read from ${effectiveRoot}, so run that recovery there rather than in ${payloadRoot}.`;
1042
+ return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}` +
1043
+ scopeNotReadyCoverageHint(toolName, loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce") +
1044
+ recoveryRootNote +
1045
+ rootsNote);
709
1046
  }
710
1047
  }
711
1048
  const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
@@ -724,12 +1061,16 @@ function inspectMutationGates(input, toolName, seams, options) {
724
1061
  isDirectWrite: true,
725
1062
  relPath,
726
1063
  scopePath: scope.path,
1064
+ runGit: dispatchGit,
727
1065
  });
728
1066
  if (authzDeny !== null)
729
1067
  return authzDeny;
730
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
1068
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path, effectiveRoot);
731
1069
  if (runtimeDeny !== null)
732
1070
  return runtimeDeny;
1071
+ const occupancyDeny = recheckOccupancyBeforeWriteAllow();
1072
+ if (occupancyDeny !== null)
1073
+ return occupancyDeny;
733
1074
  }
734
1075
  return {
735
1076
  verdict: "allow",
@@ -738,10 +1079,203 @@ function inspectMutationGates(input, toolName, seams, options) {
738
1079
  host: input.host,
739
1080
  toolName,
740
1081
  projectRoot,
741
- message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`,
1082
+ message: withOccupancyWarning(`Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`),
742
1083
  scopePath: scope.path,
743
1084
  };
744
1085
  }
1086
+ /**
1087
+ * Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
1088
+ * Dest-form allow is kept when runtime authority has nothing classifiable.
1089
+ */
1090
+ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
1091
+ const command = hookShellCommand(input.payload);
1092
+ let destAllow = null;
1093
+ let expansionDeny = null;
1094
+ // #3438 / #3594: opt-in. `off` (the default) leaves Shell exactly as it was
1095
+ // before this gate existed — unrecognized and fail-open — so landing the
1096
+ // classifier cannot deny anything a consumer runs today. `enforce` turns on
1097
+ // BOTH halves together: resolved dests through inspectMutationGates and
1098
+ // fail-closed for targets that cannot be proved. ⊗ Do not split those two:
1099
+ // enforcing only resolved dests would allow `cd x && rm y` while denying
1100
+ // `rm x/y`, and enforcing only fail-closed would deny the compound while
1101
+ // letting the in-scope simple form through unchecked.
1102
+ // An unreadable policy resolves to `off`, which is the no-new-denials
1103
+ // direction and matches the opt-in default rather than failing closed.
1104
+ const destFormsEnforced = loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce";
1105
+ if (command !== null && destFormsEnforced) {
1106
+ for (const dest of classifyProductDestForms(command)) {
1107
+ if (dest.expansion === true) {
1108
+ expansionDeny = deny(input, "scope-not-ready", toolName, `Directive denied ${toolName}: Shell dest-form target is not reconstructable ` +
1109
+ "(compound command, glob/variable, leading ~, or a git context option). Name a " +
1110
+ "concrete path in ONE simple command — split `cd x && rm y` into a single " +
1111
+ "`rm x/y`, or one tool call per command — or use Edit/Write. Only a single simple " +
1112
+ "command with an absolute dest is checked soundly; a relative dest assumes the " +
1113
+ "shell is at the project root. Everything else stays fail-closed (#3438).");
1114
+ continue;
1115
+ }
1116
+ const destInput = {
1117
+ ...input,
1118
+ payload: payloadWithInjectedWriteTarget(input.payload, dest.path),
1119
+ };
1120
+ const destDecision = inspectMutationGates(destInput, toolName, seams, {
1121
+ proposedLifecycleExempt: true,
1122
+ });
1123
+ if (destDecision.verdict === "deny")
1124
+ return destDecision;
1125
+ destAllow = destDecision;
1126
+ }
1127
+ }
1128
+ const runtime = decideShellOrMcpRuntimeAuthority(input, toolName, seams);
1129
+ if (runtime.verdict === "deny")
1130
+ return runtime;
1131
+ if (expansionDeny !== null)
1132
+ return expansionDeny;
1133
+ if (destAllow !== null && runtime.code === "shell-op-unclassifiable") {
1134
+ return destAllow;
1135
+ }
1136
+ return runtime;
1137
+ }
1138
+ const LIFECYCLE_EXECUTION_DIRECTORY_FIELDS = [
1139
+ "cwd",
1140
+ "workdir",
1141
+ "working_directory",
1142
+ "workingDirectory",
1143
+ ];
1144
+ function sameExecutionDirectory(left, right) {
1145
+ if (process.platform === "win32")
1146
+ return left.toLowerCase() === right.toLowerCase();
1147
+ return left === right;
1148
+ }
1149
+ function lifecycleExecutionRootCheck(payload, projectRoot, realpath, platform) {
1150
+ const input = record(payload);
1151
+ if (input === null) {
1152
+ return { aligned: false, message: "the hook payload has no inspectable execution root" };
1153
+ }
1154
+ const expected = normalizeHookProjectRoot(projectRoot);
1155
+ const toolInput = toolInputRecord(input);
1156
+ const records = toolInput === null || toolInput === input ? [input] : [input, toolInput];
1157
+ for (const source of records) {
1158
+ for (const field of LIFECYCLE_EXECUTION_DIRECTORY_FIELDS) {
1159
+ if (!(field in source))
1160
+ continue;
1161
+ const raw = source[field];
1162
+ if (typeof raw !== "string" || raw.trim().length === 0) {
1163
+ return {
1164
+ aligned: false,
1165
+ message: `execution-directory field ${field} is missing or invalid`,
1166
+ };
1167
+ }
1168
+ const trimmed = raw.trim();
1169
+ // Cursor can report a drive-only cwd (for example `C:`) while the
1170
+ // explicit/workspace-derived project root remains usable (#2787). Keep
1171
+ // this fence aligned with projectRootFromHookPayload: a drive-only value
1172
+ // is not a concrete execution directory and must not override that root.
1173
+ if (isWindowsDriveOnlyRoot(trimmed, platform))
1174
+ continue;
1175
+ if (trimmed.replace(/\\/g, "/").split("/").includes("..")) {
1176
+ return {
1177
+ aligned: false,
1178
+ message: `execution-directory field ${field} contains parent traversal`,
1179
+ };
1180
+ }
1181
+ const candidate = isAbsolute(trimmed) ? trimmed : `${expected}${sep}${trimmed}`;
1182
+ let actual;
1183
+ let expectedReal;
1184
+ try {
1185
+ // Keep relative `..` segments intact until realpath resolution. Lexical
1186
+ // path.resolve would collapse `link/..` before observing that `link`
1187
+ // can be a symlink to a foreign tree.
1188
+ expectedReal = normalizeHookProjectRoot(realpath(expected));
1189
+ actual = normalizeHookProjectRoot(realpath(candidate));
1190
+ }
1191
+ catch (cause) {
1192
+ return {
1193
+ aligned: false,
1194
+ message: `execution-directory field ${field} could not be realpath-bound: ${String(cause)}`,
1195
+ };
1196
+ }
1197
+ if (!sameExecutionDirectory(actual, expectedReal)) {
1198
+ return {
1199
+ aligned: false,
1200
+ message: `execution-directory field ${field} resolves to ${actual}, not the hook project ` +
1201
+ `root ${expectedReal}`,
1202
+ };
1203
+ }
1204
+ }
1205
+ }
1206
+ return { aligned: true, message: "execution directory is project-root aligned" };
1207
+ }
1208
+ /**
1209
+ * Add the claim-time owner only after the existing shell decision allowed the
1210
+ * command. The host wire format must emit `allow` with updated input, so this
1211
+ * path is intentionally restricted by rewriteExactLifecycleCommand.
1212
+ */
1213
+ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
1214
+ if (decision.verdict !== "allow")
1215
+ return decision;
1216
+ const lifecycle = inspectExactLifecycleCommand(input.payload);
1217
+ if (lifecycle === null)
1218
+ return decision;
1219
+ if (!lifecycle.requiresOwner)
1220
+ return decision;
1221
+ if (lifecycle.sessionIdStatus === "invalid") {
1222
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1223
+ "--session-id is empty, duplicated, or otherwise ambiguous.");
1224
+ }
1225
+ const identity = resolveHookHostIdentity(input.host, input.payload);
1226
+ if (identity.status === "unsupported")
1227
+ return decision;
1228
+ if (identity.status !== "ok" || identity.sessionId === null) {
1229
+ const code = identity.status === "conflict"
1230
+ ? "occupancy-identity-conflict"
1231
+ : "occupancy-identity-unavailable";
1232
+ return deny(input, code, toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ` +
1233
+ `${identity.message ?? "host payload identity is unavailable"}. ` +
1234
+ "Directive cannot bind the occupancy claim without a stable host owner; " +
1235
+ "manual callers must pass --session-id explicitly outside the host rewrite path.");
1236
+ }
1237
+ const environmentId = (input.environ ?? process.env).DEFT_SESSION_ID?.trim();
1238
+ if (environmentId !== undefined &&
1239
+ environmentId.length > 0 &&
1240
+ environmentId !== identity.sessionId) {
1241
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: host payload owner ${identity.sessionId} conflicts with ` +
1242
+ `DEFT_SESSION_ID ${environmentId}; refusing an auto-approved lifecycle rewrite.`);
1243
+ }
1244
+ if (lifecycle.sessionIdStatus === "present" && lifecycle.sessionId !== identity.sessionId) {
1245
+ 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 ` +
1247
+ `${identity.sessionId}.`);
1248
+ }
1249
+ const sourceTask = !lifecycle.task ||
1250
+ (seams.runningInsideDeftRepo ?? runningInsideDeftRepo)(resolve(input.projectRoot));
1251
+ const executionRoot = lifecycleExecutionRootCheck(input.payload, input.projectRoot, seams.realpathLifecycleExecutionRoot ?? realpathSync, seams.lifecycleExecutionPlatform ?? process.platform);
1252
+ if (!executionRoot.aligned) {
1253
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${executionRoot.message}. ` +
1254
+ `Run the lifecycle command from ${normalizeHookProjectRoot(resolve(input.projectRoot))} ` +
1255
+ "so policy, lease, and ritual state target the same worktree.");
1256
+ }
1257
+ const rewriteAllowed = lifecycle.rewriteSafe && sourceTask;
1258
+ if (lifecycle.sessionIdStatus === "absent" && !rewriteAllowed) {
1259
+ let reason;
1260
+ if (lifecycle.task && !sourceTask) {
1261
+ reason = "Task lifecycle rewrites are only trusted in the Directive source repository";
1262
+ }
1263
+ else {
1264
+ reason = "this argument shape is outside the narrowly auto-approved rewrite surface";
1265
+ }
1266
+ return deny(input, "occupancy-identity-unavailable", toolName, `Directive denied exact lifecycle command ${lifecycle.verb}: ${reason}. ` +
1267
+ `Re-run the command with --session-id=${identity.sessionId}; Directive will verify ` +
1268
+ "that explicit owner without auto-approving or replacing the command.");
1269
+ }
1270
+ const rewrite = rewriteExactLifecycleCommand(input.payload, identity.sessionId);
1271
+ if (rewrite === null)
1272
+ return decision;
1273
+ if (rewrite.kind === "conflict") {
1274
+ return deny(input, "occupancy-identity-conflict", toolName, `Directive denied ${toolName}: ${rewrite.message} Host payload owner is ` +
1275
+ `${identity.sessionId}, but the command names ${rewrite.existingSessionId}.`);
1276
+ }
1277
+ return { ...decision, updatedInput: rewrite.updatedInput };
1278
+ }
745
1279
  /** Decide a normalized event using only the P0 direct-write policy. */
746
1280
  export function decideHook(input, seams = {}) {
747
1281
  const projectRoot = resolve(input.projectRoot);
@@ -749,6 +1283,8 @@ export function decideHook(input, seams = {}) {
749
1283
  // short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
750
1284
  // Tracked/committed flags do NOT bypass gates (repo-controlled content must
751
1285
  // not disable enforcement for clones) — doctor warns instead.
1286
+ // #3794: the kill-switch is pinned to payloadRoot. The flag is untracked, so
1287
+ // linked worktrees do not carry it; re-pointing would silently re-arm.
752
1288
  {
753
1289
  const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
754
1290
  const kill = detectKill(projectRoot);
@@ -775,6 +1311,12 @@ export function decideHook(input, seams = {}) {
775
1311
  }
776
1312
  }
777
1313
  if (input.event === "session.compact") {
1314
+ // #3769 Path 2: compact stays always-allow and never routes through
1315
+ // inspectMutationGates — entering the mutation gates would re-run the
1316
+ // ritual persist Path 1 closes, or occupancy-deny an owner's own compact.
1317
+ // The stale-mark is fail-open: it writes even when no acting identity can
1318
+ // be bound, because compact has no issue context and no surface a human
1319
+ // reads. The accepted cost is an unidentified compact re-arming the owner.
778
1320
  try {
779
1321
  const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
780
1322
  if (!result.changed) {
@@ -930,13 +1472,17 @@ export function decideHook(input, seams = {}) {
930
1472
  }
931
1473
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
932
1474
  }
933
- // Shell/Bash and classifiable MCP: enforce scopes.push / scopes.merge (#2711).
1475
+ // Shell dest-forms (#3438): recognized product mutations share inspectMutationGates
1476
+ // with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
1477
+ // on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
1478
+ if (isShellTool(toolName)) {
1479
+ const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1480
+ return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
1481
+ }
1482
+ // Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
934
1483
  // Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
935
1484
  // isMcpTool is false — classifyMcpTool is the gate (dispatcher-side, no tools↔policy cycle).
936
- // Does not require active-scope ritual only runtimeAuthority when enabled.
937
- if (isShellTool(toolName) ||
938
- isMcpTool(toolName) ||
939
- classifyMcpTool(toolName, hookMcpArgsText(input.payload)) !== null) {
1485
+ if (isMcpTool(toolName) || classifyMcpTool(toolName, hookMcpArgsText(input.payload)) !== null) {
940
1486
  return decideShellOrMcpRuntimeAuthority(input, toolName, seams);
941
1487
  }
942
1488
  if (!isDirectWriteTool(toolName)) {
@@ -986,6 +1532,24 @@ function softAgentsRebindWireText(decision) {
986
1532
  */
987
1533
  export function renderHostDecision(host, decision) {
988
1534
  if (decision.verdict === "allow") {
1535
+ if (decision.event === "tool.before" && decision.updatedInput !== undefined) {
1536
+ if (host === "cursor") {
1537
+ return JSON.stringify({
1538
+ permission: "allow",
1539
+ code: decision.code,
1540
+ updated_input: decision.updatedInput,
1541
+ });
1542
+ }
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
+ }
1552
+ }
989
1553
  const soft = softAgentsRebindWireText(decision);
990
1554
  if (host === "cursor") {
991
1555
  if (decision.event === "session.start" && soft !== null) {