@deftai/directive-core 0.107.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 (167) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/check/cached-orchestrator.js +28 -3
  3. package/dist/check/named-cause.js +18 -3
  4. package/dist/codebase/provider.js +7 -1
  5. package/dist/delivery-attempt/handoff.js +1 -1
  6. package/dist/design-critique/citation-grammar.d.ts +53 -0
  7. package/dist/design-critique/citation-grammar.js +303 -0
  8. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  9. package/dist/design-critique/completed-arc-record.js +232 -0
  10. package/dist/freshness/bind.d.ts +32 -3
  11. package/dist/freshness/bind.js +58 -12
  12. package/dist/hooks/classify/host-session-identity.d.ts +76 -0
  13. package/dist/hooks/classify/host-session-identity.js +470 -0
  14. package/dist/hooks/classify/index.d.ts +3 -2
  15. package/dist/hooks/classify/index.js +3 -2
  16. package/dist/hooks/classify/paths.d.ts +7 -0
  17. package/dist/hooks/classify/paths.js +35 -0
  18. package/dist/hooks/classify/stdin.d.ts +5 -0
  19. package/dist/hooks/classify/stdin.js +27 -0
  20. package/dist/hooks/dispatcher.d.ts +52 -1
  21. package/dist/hooks/dispatcher.js +538 -45
  22. package/dist/hooks/fixtures/cases.d.ts +16 -2
  23. package/dist/hooks/fixtures/cases.js +175 -0
  24. package/dist/hooks/scope.js +3 -1
  25. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  26. package/dist/init-deposit/agent-hooks.js +8 -1
  27. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  28. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  29. package/dist/intake/clause-derivation.js +9 -3
  30. package/dist/intake/issue-ingest.js +62 -2
  31. package/dist/intake/platform-capabilities.d.ts +9 -2
  32. package/dist/intake/platform-capabilities.js +38 -9
  33. package/dist/orphan-active/evaluate.d.ts +25 -0
  34. package/dist/orphan-active/evaluate.js +193 -128
  35. package/dist/orphan-active/index.d.ts +1 -0
  36. package/dist/orphan-active/index.js +1 -0
  37. package/dist/orphan-active/issue-state.d.ts +121 -0
  38. package/dist/orphan-active/issue-state.js +278 -0
  39. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  40. package/dist/platform/cursor-managed-runtime.js +241 -0
  41. package/dist/platform/index.d.ts +1 -0
  42. package/dist/platform/index.js +1 -0
  43. package/dist/platform/platform-capabilities.d.ts +23 -1
  44. package/dist/platform/platform-capabilities.js +55 -15
  45. package/dist/policy/ceremony-dial.js +5 -9
  46. package/dist/policy/host-hooks.js +4 -9
  47. package/dist/policy/org-force-on-migration.js +5 -10
  48. package/dist/policy/plan-extensions.d.ts +14 -2
  49. package/dist/policy/plan-extensions.js +24 -3
  50. package/dist/policy/product-signal.js +5 -10
  51. package/dist/policy/require-human-merge.js +5 -9
  52. package/dist/policy/resolve.js +77 -16
  53. package/dist/policy/value-feedback.js +9 -18
  54. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  55. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  56. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  57. package/dist/pr-closeout-attestable/index.js +2 -0
  58. package/dist/pr-protected-issues/gh.d.ts +6 -2
  59. package/dist/pr-protected-issues/gh.js +7 -3
  60. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  61. package/dist/pr-wait-mergeable/cascade.js +21 -1
  62. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  63. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  64. package/dist/pr-wait-mergeable/wrappers.js +11 -0
  65. package/dist/preflight/evaluate.d.ts +15 -0
  66. package/dist/preflight/evaluate.js +29 -3
  67. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  68. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  69. package/dist/product-first-done-gate/evaluate.js +17 -7
  70. package/dist/release/cli-drift-report.d.ts +68 -0
  71. package/dist/release/cli-drift-report.js +189 -0
  72. package/dist/release/index.d.ts +1 -0
  73. package/dist/release/index.js +1 -0
  74. package/dist/release/issue-state-fetch.d.ts +5 -3
  75. package/dist/release/issue-state-fetch.js +86 -21
  76. package/dist/release/native-steps.js +1 -0
  77. package/dist/release/pipeline.js +19 -0
  78. package/dist/release/types.d.ts +14 -0
  79. package/dist/render/constants.d.ts +4 -0
  80. package/dist/render/constants.js +11 -8
  81. package/dist/render/export-spec.js +31 -5
  82. package/dist/render/index.d.ts +1 -1
  83. package/dist/render/index.js +1 -1
  84. package/dist/render/prd-render.d.ts +4 -1
  85. package/dist/render/prd-render.js +82 -23
  86. package/dist/render/project-render.js +14 -7
  87. package/dist/render/scope-outlook.d.ts +2 -0
  88. package/dist/render/scope-outlook.js +3 -0
  89. package/dist/render/spec-render.js +2 -2
  90. package/dist/resolution/package-manager.d.ts +33 -4
  91. package/dist/resolution/package-manager.js +210 -9
  92. package/dist/review-monitor/constants.d.ts +7 -0
  93. package/dist/review-monitor/constants.js +7 -0
  94. package/dist/review-monitor/github-lease.d.ts +98 -1
  95. package/dist/review-monitor/github-lease.js +182 -1
  96. package/dist/review-monitor/lease-comment.d.ts +36 -0
  97. package/dist/review-monitor/lease-comment.js +103 -2
  98. package/dist/scm/call.js +12 -3
  99. package/dist/scm/design-critique-chip.d.ts +1 -0
  100. package/dist/scm/design-critique-chip.js +22 -6
  101. package/dist/scm/gh-rest.d.ts +9 -1
  102. package/dist/scm/gh-rest.js +103 -0
  103. package/dist/scm/readiness.d.ts +2 -0
  104. package/dist/scm/readiness.js +28 -4
  105. package/dist/scope/project-definition-sync.js +14 -7
  106. package/dist/session/git.d.ts +24 -0
  107. package/dist/session/git.js +138 -2
  108. package/dist/session/occupancy.d.ts +238 -12
  109. package/dist/session/occupancy.js +815 -29
  110. package/dist/session/ritual-sentinel.d.ts +5 -0
  111. package/dist/session/ritual-sentinel.js +5 -0
  112. package/dist/session/session-ready.d.ts +5 -1
  113. package/dist/session/session-ready.js +98 -8
  114. package/dist/session/session-start.d.ts +5 -1
  115. package/dist/session/session-start.js +32 -17
  116. package/dist/session/toolchain-preflight.d.ts +13 -3
  117. package/dist/session/toolchain-preflight.js +93 -18
  118. package/dist/session/verify-session-ritual.d.ts +11 -0
  119. package/dist/session/verify-session-ritual.js +60 -15
  120. package/dist/slice/constants.d.ts +1 -1
  121. package/dist/spec-authority/constants.d.ts +10 -2
  122. package/dist/spec-authority/constants.js +53 -8
  123. package/dist/spec-authority/resolver.d.ts +3 -0
  124. package/dist/spec-authority/resolver.js +55 -7
  125. package/dist/subprocess/max-buffer.d.ts +14 -0
  126. package/dist/subprocess/max-buffer.js +15 -0
  127. package/dist/swarm/complete-cohort.d.ts +2 -0
  128. package/dist/swarm/complete-cohort.js +21 -13
  129. package/dist/swarm/index.d.ts +1 -0
  130. package/dist/swarm/index.js +1 -0
  131. package/dist/swarm/launch-cli.js +53 -33
  132. package/dist/swarm/launch.d.ts +8 -0
  133. package/dist/swarm/launch.js +63 -43
  134. package/dist/swarm/pre-dispatch-cli.js +2 -1
  135. package/dist/swarm/pre-dispatch.js +10 -1
  136. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  137. package/dist/swarm/subagent-status-dir.js +42 -0
  138. package/dist/swarm/worktrees.d.ts +17 -3
  139. package/dist/swarm/worktrees.js +48 -4
  140. package/dist/triage/scope/mutations-core.d.ts +2 -6
  141. package/dist/triage/scope/mutations-core.js +23 -55
  142. package/dist/triage/scope-drift/add-ignore.js +5 -39
  143. package/dist/triage/subscribe/index.d.ts +2 -6
  144. package/dist/triage/subscribe/index.js +15 -65
  145. package/dist/triage/welcome/writers.js +15 -74
  146. package/dist/vbrief-build/index.d.ts +2 -1
  147. package/dist/vbrief-build/index.js +2 -1
  148. package/dist/vbrief-build/parity-scenarios.js +5 -5
  149. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  150. package/dist/vbrief-build/project-definition-io.js +357 -31
  151. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  152. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  153. package/dist/vbrief-validate/conformance.d.ts +1 -0
  154. package/dist/vbrief-validate/conformance.js +85 -8
  155. package/dist/vbrief-validate/main.js +2 -0
  156. package/dist/vbrief-validate/precutover.js +5 -10
  157. package/dist/verify-ac/clauses.d.ts +62 -2
  158. package/dist/verify-ac/clauses.js +183 -111
  159. package/dist/verify-ac/index.d.ts +1 -1
  160. package/dist/verify-ac/index.js +1 -1
  161. package/dist/verify-env/node-runtime.d.ts +8 -4
  162. package/dist/verify-env/node-runtime.js +9 -6
  163. package/dist/verify-env/toolchain-check.d.ts +35 -3
  164. package/dist/verify-env/toolchain-check.js +155 -36
  165. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  166. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  167. 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,13 @@ 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";
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";
19
20
  import { classifyProductDestForms, payloadWithInjectedWriteTarget } from "./dest-form.js";
20
21
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
21
22
  import { inspectActiveScope } from "./scope.js";
@@ -193,8 +194,8 @@ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ =
193
194
  return true;
194
195
  return false;
195
196
  }
196
- function isWindowsDriveOnlyRoot(value) {
197
- return /^[A-Za-z]:[/\\]?$/.test(value.trim());
197
+ function isWindowsDriveOnlyRoot(value, platform = process.platform) {
198
+ return platform === "win32" && /^[A-Za-z]:[/\\]?$/.test(value.trim());
198
199
  }
199
200
  function hookPayloadRootCandidates(input) {
200
201
  const candidates = [];
@@ -250,22 +251,113 @@ export function projectRootFromHookPayload(payload, fallback) {
250
251
  }
251
252
  return fallbackResolved;
252
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
+ }
253
328
  export function isHookHost(value) {
254
329
  return HOOK_HOSTS.includes(value);
255
330
  }
256
331
  export function isHookEvent(value) {
257
332
  return HOOK_EVENTS.includes(value);
258
333
  }
259
- function scopeNotReadyCoverageHint(toolName) {
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) {
260
345
  if (isSpawnTool(toolName))
261
346
  return "";
262
347
  if (isShellTool(toolName)) {
263
348
  return (" This deny is the active-scope write gate on a recognized Shell dest-form " +
264
349
  "(git checkout --, git restore, rm/rmdir of in-repo paths).");
265
350
  }
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.");
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.");
269
361
  }
270
362
  function deny(input, code, toolName, message, scopePath = null) {
271
363
  return {
@@ -279,8 +371,55 @@ function deny(input, code, toolName, message, scopePath = null) {
279
371
  scopePath,
280
372
  };
281
373
  }
282
- 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/`.
283
421
  const projectRoot = resolve(input.projectRoot);
422
+ const fenceRoot = resolve(effectiveRoot);
284
423
  // Project policy load failure: treat as disabled project layer, still apply
285
424
  // independent story file_scope when present (#516 / #2443 Greptile P1).
286
425
  let basePolicy = DEFAULT_RUNTIME_AUTHORITY_POLICY;
@@ -296,7 +435,7 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
296
435
  let storyFence;
297
436
  try {
298
437
  storyFence = seams.loadStoryWriteFence
299
- ? seams.loadStoryWriteFence(projectRoot, scopePath)
438
+ ? seams.loadStoryWriteFence(fenceRoot, scopePath)
300
439
  : loadStoryWriteFenceFromPath(scopePath);
301
440
  }
302
441
  catch {
@@ -310,14 +449,19 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
310
449
  if (!fence.fenceActive)
311
450
  return null;
312
451
  const writeTarget = hookWriteTargetPath(input.payload);
313
- const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
452
+ const relPath = writeTarget !== null ? toProjectRelativePosix(fenceRoot, writeTarget) : null;
314
453
  const verdict = evaluateRuntimeAuthorityDirectWrite({
315
454
  policy: fence.policy,
316
455
  relPathPosix: relPath,
317
456
  });
318
457
  if (verdict.allowed)
319
458
  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);
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);
321
465
  }
322
466
  function loadAuthzContext(projectRoot, seams) {
323
467
  if (seams.loadAuthzState !== undefined) {
@@ -380,6 +524,8 @@ function recordAuthzAudit(projectRoot, decision, state, seams) {
380
524
  * Composes before runtimeAuthority path/scope checks for direct writes and shell/MCP.
381
525
  */
382
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.
383
529
  const projectRoot = resolve(input.projectRoot);
384
530
  let state;
385
531
  let grants;
@@ -413,7 +559,7 @@ function authzForMutation(input, toolName, seams, options) {
413
559
  // Structural context for bound grants (branch from git; repo optional env).
414
560
  let branch = null;
415
561
  try {
416
- branch = detectBranch(projectRoot);
562
+ branch = detectBranch(projectRoot, options.runGit);
417
563
  }
418
564
  catch {
419
565
  branch = null;
@@ -552,23 +698,50 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
552
698
  };
553
699
  }
554
700
  function inspectMutationGates(input, toolName, seams, options) {
555
- 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;
556
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
+ }
557
726
  // Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
558
727
  // assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
559
728
  // Tracked product paths never match the path fence. Read-only still denied upstream.
560
729
  if (!isSpawnTool(toolName)) {
561
730
  const scratchTarget = hookWriteTargetPath(input.payload);
562
- 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)) {
563
735
  const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
564
736
  const authzDeny = authzForMutation(input, toolName, seams, {
565
737
  isDirectWrite: true,
566
738
  relPath,
567
739
  scopePath: null,
740
+ runGit: dispatchGit,
568
741
  });
569
742
  if (authzDeny !== null)
570
743
  return authzDeny;
571
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
744
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
572
745
  if (runtimeDeny !== null)
573
746
  return runtimeDeny;
574
747
  return {
@@ -586,6 +759,72 @@ function inspectMutationGates(input, toolName, seams, options) {
586
759
  };
587
760
  }
588
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
+ }
589
828
  let ritual;
590
829
  try {
591
830
  // Mutation dispatch is a live gated boundary: active verification reruns
@@ -600,8 +839,8 @@ function inspectMutationGates(input, toolName, seams, options) {
600
839
  bypass: false,
601
840
  runner: seams.ritualRunner,
602
841
  detectWorkSelection: seams.detectWorkSelection,
603
- runGit: seams.ritualRunGit,
604
- }))))(projectRoot);
842
+ runGit: dispatchGit,
843
+ }))))(effectiveRoot);
605
844
  }
606
845
  catch (cause) {
607
846
  // #2994: best-effort local process-cost; never changes deny verdict.
@@ -612,20 +851,36 @@ function inspectMutationGates(input, toolName, seams, options) {
612
851
  detail: `inspect threw: ${String(cause)}`,
613
852
  }, { projectRoot });
614
853
  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}`);
854
+ formatRitualRecoveryInstruction("cold") +
855
+ rootsNote);
856
+ }
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
+ }
629
884
  }
630
885
  if (ritual.code !== 0) {
631
886
  // #2992: prefer re-arm recovery when age/compact stale; cold when bind invalid.
@@ -637,23 +892,73 @@ function inspectMutationGates(input, toolName, seams, options) {
637
892
  recoveryTier,
638
893
  detail: ritual.message,
639
894
  }, { projectRoot });
640
- return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}`);
641
- }
895
+ return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ${formatRitualRecoveryInstruction(recoveryTier)}` +
896
+ rootsNote);
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}`;
642
943
  if (options.proposedLifecycleExempt) {
643
944
  const writeTarget = hookWriteTargetPath(input.payload);
644
- if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
945
+ if (isProposedLifecycleWrite(effectiveRoot, writeTarget)) {
645
946
  const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
646
947
  // UAT still allows xbrief/proposed/** as evidence/defect capture (#2944).
647
948
  const authzDeny = authzForMutation(input, toolName, seams, {
648
949
  isDirectWrite: true,
649
950
  relPath,
650
951
  scopePath: null,
952
+ runGit: dispatchGit,
651
953
  });
652
954
  if (authzDeny !== null)
653
955
  return authzDeny;
654
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
956
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null, effectiveRoot);
655
957
  if (runtimeDeny !== null)
656
958
  return runtimeDeny;
959
+ const occupancyDeny = recheckOccupancyBeforeWriteAllow();
960
+ if (occupancyDeny !== null)
961
+ return occupancyDeny;
657
962
  return {
658
963
  verdict: "allow",
659
964
  code: "write-propose-ready",
@@ -661,26 +966,33 @@ function inspectMutationGates(input, toolName, seams, options) {
661
966
  host: input.host,
662
967
  toolName,
663
968
  projectRoot,
664
- message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
665
- "(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)."),
666
971
  scopePath: null,
667
972
  };
668
973
  }
669
974
  }
670
975
  let scope;
671
976
  try {
672
- 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);
673
981
  }
674
982
  catch (cause) {
675
983
  scope = { ready: false, path: null, message: String(cause) };
676
984
  }
677
985
  if (!scope.ready) {
678
986
  const writeTarget = hookWriteTargetPath(input.payload);
679
- const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
987
+ const relTarget = writeTarget !== null ? toProjectRelativePosix(effectiveRoot, writeTarget) : null;
680
988
  // Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
681
989
  // (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
682
990
  // targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
683
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.
684
996
  const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
685
997
  if (!outsideRoot || isSpawnTool(toolName)) {
686
998
  let proposedPathHint;
@@ -720,8 +1032,17 @@ function inspectMutationGates(input, toolName, seams, options) {
720
1032
  "assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
721
1033
  }
722
1034
  const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
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}.`;
723
1042
  return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}` +
724
- scopeNotReadyCoverageHint(toolName));
1043
+ scopeNotReadyCoverageHint(toolName, loadRuntimeAuthorityPolicySafe(input, seams)?.shellDestForms === "enforce") +
1044
+ recoveryRootNote +
1045
+ rootsNote);
725
1046
  }
726
1047
  }
727
1048
  const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
@@ -740,12 +1061,16 @@ function inspectMutationGates(input, toolName, seams, options) {
740
1061
  isDirectWrite: true,
741
1062
  relPath,
742
1063
  scopePath: scope.path,
1064
+ runGit: dispatchGit,
743
1065
  });
744
1066
  if (authzDeny !== null)
745
1067
  return authzDeny;
746
- const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
1068
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path, effectiveRoot);
747
1069
  if (runtimeDeny !== null)
748
1070
  return runtimeDeny;
1071
+ const occupancyDeny = recheckOccupancyBeforeWriteAllow();
1072
+ if (occupancyDeny !== null)
1073
+ return occupancyDeny;
749
1074
  }
750
1075
  return {
751
1076
  verdict: "allow",
@@ -754,7 +1079,7 @@ function inspectMutationGates(input, toolName, seams, options) {
754
1079
  host: input.host,
755
1080
  toolName,
756
1081
  projectRoot,
757
- message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`,
1082
+ message: withOccupancyWarning(`Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`),
758
1083
  scopePath: scope.path,
759
1084
  };
760
1085
  }
@@ -810,6 +1135,147 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
810
1135
  }
811
1136
  return runtime;
812
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
+ }
813
1279
  /** Decide a normalized event using only the P0 direct-write policy. */
814
1280
  export function decideHook(input, seams = {}) {
815
1281
  const projectRoot = resolve(input.projectRoot);
@@ -817,6 +1283,8 @@ export function decideHook(input, seams = {}) {
817
1283
  // short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
818
1284
  // Tracked/committed flags do NOT bypass gates (repo-controlled content must
819
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.
820
1288
  {
821
1289
  const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
822
1290
  const kill = detectKill(projectRoot);
@@ -843,6 +1311,12 @@ export function decideHook(input, seams = {}) {
843
1311
  }
844
1312
  }
845
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.
846
1320
  try {
847
1321
  const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
848
1322
  if (!result.changed) {
@@ -1002,7 +1476,8 @@ export function decideHook(input, seams = {}) {
1002
1476
  // with Edit/Write (assist/scratch, proposed lifecycle, file_scope). Push/merge stay
1003
1477
  // on runtimeAuthority (#2711). Non-dest unclassifiable shell (git status) fail-open.
1004
1478
  if (isShellTool(toolName)) {
1005
- return decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1479
+ const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
1480
+ return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
1006
1481
  }
1007
1482
  // Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
1008
1483
  // Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
@@ -1057,6 +1532,24 @@ function softAgentsRebindWireText(decision) {
1057
1532
  */
1058
1533
  export function renderHostDecision(host, decision) {
1059
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
+ }
1060
1553
  const soft = softAgentsRebindWireText(decision);
1061
1554
  if (host === "cursor") {
1062
1555
  if (decision.event === "session.start" && soft !== null) {