@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 { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
3
  import { IDENTITY_LOCAL_USER, IDENTITY_REAL_ROOT, IDENTITY_SANDBOX_REMAPPED_LOCAL_USER, IDENTITY_UNKNOWN, RUNTIME_MODE_CLOUD_HEADLESS, RUNTIME_MODE_CURSOR_NATIVE_SANDBOX, RUNTIME_MODE_LOCAL_UNSANDBOXED, } from "./constants.js";
4
+ import { hasExplicitHostGhSelection, probeManagedRuntime, RUNTIME_REASON_CI_MARKER, RUNTIME_REASON_CURSOR_MARKER_AMBIGUOUS, RUNTIME_REASON_CURSOR_SANDBOX_MARKER, RUNTIME_REASON_EXPLICIT_HOST_GH, RUNTIME_REASON_MANAGED_RUNTIME_PROBE, RUNTIME_REASON_NO_RUNTIME_MARKER, } from "./cursor-managed-runtime.js";
4
5
  import { detectEnvironmentContext, environmentContextToDict, } from "./shell-context.js";
5
6
  const TRUTHY = new Set(["1", "true", "yes", "on"]);
6
7
  const CURSOR_SIGNAL_VARS = [
@@ -69,16 +70,19 @@ export function classifyIdentityKind(options) {
69
70
  }
70
71
  return IDENTITY_LOCAL_USER;
71
72
  }
72
- function isCloudHeadless(environ) {
73
- if (envTruthy(environ, "CURSOR_AGENT"))
74
- return true;
73
+ /** Reason id when a non-Cursor CI/cloud marker forces cloud-headless, else null. */
74
+ function ciCloudReason(environ) {
75
75
  // GROK_BUILD / DEFT_AGENT_RUNTIME=grok-build identify a local TUI, not CI (#3469).
76
76
  const runtime = (environ.DEFT_AGENT_RUNTIME ?? "").trim().toLowerCase();
77
77
  if (runtime === "cloud" || runtime === "headless")
78
- return true;
79
- if (envTruthy(environ, "GITHUB_ACTIONS") || envTruthy(environ, "BUILDKITE"))
80
- return true;
81
- return envTruthy(environ, "CI") && !envTruthy(environ, "CURSOR_COMPOSER");
78
+ return RUNTIME_REASON_CI_MARKER;
79
+ if (envTruthy(environ, "GITHUB_ACTIONS") || envTruthy(environ, "BUILDKITE")) {
80
+ return RUNTIME_REASON_CI_MARKER;
81
+ }
82
+ if (envTruthy(environ, "CI") && !envTruthy(environ, "CURSOR_COMPOSER")) {
83
+ return RUNTIME_REASON_CI_MARKER;
84
+ }
85
+ return null;
82
86
  }
83
87
  function isCursorNativeSandbox(environ, sandboxUidRemap) {
84
88
  if (sandboxUidRemap)
@@ -87,12 +91,44 @@ function isCursorNativeSandbox(environ, sandboxUidRemap) {
87
91
  return true;
88
92
  return Boolean((environ.CURSOR_SANDBOX_LANDLOCK_STATUS ?? "").trim());
89
93
  }
90
- export function classifyRuntimeMode(environ, sandboxUidRemap) {
91
- if (isCloudHeadless(environ))
92
- return RUNTIME_MODE_CLOUD_HEADLESS;
93
- if (isCursorNativeSandbox(environ, sandboxUidRemap))
94
- return RUNTIME_MODE_CURSOR_NATIVE_SANDBOX;
95
- return RUNTIME_MODE_LOCAL_UNSANDBOXED;
94
+ /**
95
+ * Classify runtime mode and name the reason for the verdict (#3859).
96
+ *
97
+ * Ordering matches intake/platform-capabilities.ts: CI markers, then a positive
98
+ * managed-runtime read, then sandbox, then the ambiguous Cursor hop. The Cursor
99
+ * hop previously ran first here; it now runs last so neither a CI marker nor a
100
+ * managed read can be overridden by an explicit host-gh selection.
101
+ */
102
+ export function classifyRuntime(environ, sandboxUidRemap, options = {}) {
103
+ const ciReason = ciCloudReason(environ);
104
+ if (ciReason !== null)
105
+ return { mode: RUNTIME_MODE_CLOUD_HEADLESS, reason: ciReason };
106
+ const managedProbe = options.managedRuntimeProbe ?? probeManagedRuntime;
107
+ if (managedProbe(environ).verdict === "managed") {
108
+ return {
109
+ mode: RUNTIME_MODE_CLOUD_HEADLESS,
110
+ reason: RUNTIME_REASON_MANAGED_RUNTIME_PROBE,
111
+ };
112
+ }
113
+ if (isCursorNativeSandbox(environ, sandboxUidRemap)) {
114
+ return {
115
+ mode: RUNTIME_MODE_CURSOR_NATIVE_SANDBOX,
116
+ reason: RUNTIME_REASON_CURSOR_SANDBOX_MARKER,
117
+ };
118
+ }
119
+ if (envTruthy(environ, "CURSOR_AGENT")) {
120
+ if (hasExplicitHostGhSelection(environ)) {
121
+ return { mode: RUNTIME_MODE_LOCAL_UNSANDBOXED, reason: RUNTIME_REASON_EXPLICIT_HOST_GH };
122
+ }
123
+ return {
124
+ mode: RUNTIME_MODE_CLOUD_HEADLESS,
125
+ reason: RUNTIME_REASON_CURSOR_MARKER_AMBIGUOUS,
126
+ };
127
+ }
128
+ return { mode: RUNTIME_MODE_LOCAL_UNSANDBOXED, reason: RUNTIME_REASON_NO_RUNTIME_MARKER };
129
+ }
130
+ export function classifyRuntimeMode(environ, sandboxUidRemap, options = {}) {
131
+ return classifyRuntime(environ, sandboxUidRemap, options).mode;
96
132
  }
97
133
  function readOwnership(path, sandboxUidRemap) {
98
134
  try {
@@ -151,13 +187,16 @@ export function probeRuntimeCapabilities(options = {}) {
151
187
  const uidMap = readUidMap(uidMapFile);
152
188
  const sandboxUidRemap = detectSandboxUidRemap(uidMap, { effectiveUid, cursorOrigUid });
153
189
  const identityKind = classifyIdentityKind({ effectiveUid, sandboxUidRemap });
154
- const runtimeMode = classifyRuntimeMode(env, sandboxUidRemap);
190
+ const runtime = classifyRuntime(env, sandboxUidRemap, {
191
+ managedRuntimeProbe: options.managedRuntimeProbe,
192
+ });
155
193
  const cwdPath = options.cwd ?? process.cwd();
156
194
  const ownership = readOwnership(cwdPath, sandboxUidRemap);
157
195
  return {
158
196
  hostPlatform: environment.hostPlatform,
159
197
  shell: environment.shell,
160
- runtimeMode,
198
+ runtimeMode: runtime.mode,
199
+ runtimeModeReason: runtime.reason,
161
200
  identityKind,
162
201
  effectiveUid,
163
202
  effectiveUsername,
@@ -176,6 +215,7 @@ export function reportToDict(report) {
176
215
  return {
177
216
  ...environmentContextToDict({ hostPlatform: report.hostPlatform, shell: report.shell }),
178
217
  runtime_mode: report.runtimeMode,
218
+ runtime_mode_reason: report.runtimeModeReason,
179
219
  identity_kind: report.identityKind,
180
220
  effective_uid: report.effectiveUid,
181
221
  effective_username: report.effectiveUsername,
@@ -20,7 +20,7 @@
20
20
  import { existsSync, readFileSync, statSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { RunSummaryEmitter } from "../run-summary/emit.js";
23
- import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
23
+ import { withProjectDefinitionMutation } from "../vbrief-build/project-definition-mutation.js";
24
24
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
25
25
  import { policyColonInvocation } from "./policy-invocation.js";
26
26
  import { appendAuditLog, loadProjectDefinition, POLICY_AUDIT_NOOP_STDOUT, projectDefinitionPath, } from "./resolve.js";
@@ -510,14 +510,10 @@ export function setCeremonyDial(projectRoot, options) {
510
510
  changed: false,
511
511
  };
512
512
  }
513
- const path = projectDefinitionPath(projectRoot);
513
+ const _path = projectDefinitionPath(projectRoot);
514
514
  try {
515
- const { changed } = projectDefinitionMutationLock(projectRoot, () => {
516
- const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
517
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
518
- throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
519
- }
520
- const data = parsed;
515
+ const { changed } = withProjectDefinitionMutation(projectRoot, (mutation) => {
516
+ const data = mutation.load();
521
517
  if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
522
518
  if (data.plan === undefined) {
523
519
  data.plan = {};
@@ -569,7 +565,7 @@ export function setCeremonyDial(projectRoot, options) {
569
565
  legacyKeyMigrated;
570
566
  policyBlock.ceremonyDial = nextBlock;
571
567
  if (changedFlag) {
572
- atomicWriteProjectDefinition(path, data);
568
+ mutation.persist(data);
573
569
  }
574
570
  const actor = options.actor ?? policyColonInvocation("set-ceremony-dial");
575
571
  const note = options.note ?? "";
@@ -1,6 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { HOOK_HOSTS } from "../hooks/dispatcher.js";
3
- import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
2
+ import { withProjectDefinitionMutation } from "../vbrief-build/project-definition-mutation.js";
4
3
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
5
4
  import { policyColonInvocation } from "./policy-invocation.js";
6
5
  import { appendAuditLog, loadProjectDefinition, POLICY_AUDIT_NOOP_STDOUT, projectDefinitionPath, } from "./resolve.js";
@@ -145,12 +144,8 @@ export function disableHostHooks(projectRoot, options) {
145
144
  }
146
145
  const path = projectDefinitionPath(projectRoot);
147
146
  try {
148
- const { changed } = projectDefinitionMutationLock(projectRoot, () => {
149
- const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
150
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
151
- throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
152
- }
153
- const data = parsed;
147
+ const { changed } = withProjectDefinitionMutation(projectRoot, (mutation) => {
148
+ const data = mutation.load();
154
149
  if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
155
150
  if (data.plan === undefined) {
156
151
  data.plan = {};
@@ -178,7 +173,7 @@ export function disableHostHooks(projectRoot, options) {
178
173
  const changedFlag = previous[options.host] !== false || legacyKeyMigrated;
179
174
  policyBlock.hostHooks = next;
180
175
  if (changedFlag) {
181
- atomicWriteProjectDefinition(path, data);
176
+ mutation.persist(data);
182
177
  }
183
178
  const actor = options.actor ?? disableHostHooksInvocation();
184
179
  const note = options.note ?? "";
@@ -3,10 +3,10 @@ import { join, resolve } from "node:path";
3
3
  import { sortKeysDeep } from "../codebase/json.js";
4
4
  import { readCorePackageVersion } from "../engine-version.js";
5
5
  import { containedWrite } from "../fs/contained-write.js";
6
- import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
6
+ import { withProjectDefinitionMutation } from "../vbrief-build/project-definition-mutation.js";
7
7
  import { isNoDeftDirectivePresent } from "./no-deft-directive.js";
8
8
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
9
- import { appendAuditLog, loadProjectDefinition, projectDefinitionPath } from "./resolve.js";
9
+ import { appendAuditLog, loadProjectDefinition } from "./resolve.js";
10
10
  import { detectOriginOrg, isTrustedOrgAutoEnable, } from "./value-feedback-autoenable.js";
11
11
  /** Durable once-per-checkout marker for #2822 trusted-org install force-on. */
12
12
  export const ORG_FORCE_ON_MARKER_REL = join(".deft-cache", "org-force-on-v2822.json");
@@ -331,18 +331,13 @@ export function runOrgForceOnMigration(projectRoot, options = {}) {
331
331
  return applyForceOn(projectRoot, options, { existingMarker: null });
332
332
  }
333
333
  function applyForceOn(projectRoot, options, intent) {
334
- const path = projectDefinitionPath(projectRoot);
335
334
  let valueFeedbackChanged = false;
336
335
  let productSignalChanged = false;
337
336
  let ran = false;
338
337
  let skippedReason = null;
339
338
  const existing = intent.existingMarker;
340
- projectDefinitionMutationLock(projectRoot, () => {
341
- const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
342
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
343
- throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
344
- }
345
- const rootData = parsed;
339
+ withProjectDefinitionMutation(projectRoot, (mutation) => {
340
+ const rootData = mutation.load();
346
341
  if (typeof rootData.plan !== "object" ||
347
342
  rootData.plan === null ||
348
343
  Array.isArray(rootData.plan)) {
@@ -402,7 +397,7 @@ function applyForceOn(projectRoot, options, intent) {
402
397
  productSignalChanged = productSignalNeedsForceOn(previousPs);
403
398
  }
404
399
  if (valueFeedbackChanged || productSignalChanged) {
405
- atomicWriteProjectDefinition(path, rootData);
400
+ mutation.persist(rootData);
406
401
  }
407
402
  // Marker previous*: forced fields record pre-apply current; unforced fields
408
403
  // keep the prior marker snapshot so intentional opt-outs are not rewritten
@@ -54,11 +54,23 @@ export interface ShadowedPlanExtension {
54
54
  /** The bare legacy key that is silently ignored (e.g. `policy`). */
55
55
  readonly legacyKey: string;
56
56
  /**
57
- * Best-effort list of sub-keys present in the shadowed bare object (e.g.
58
- * `["triageScope", "wipCap"]`). Empty when the bare value is not an object.
57
+ * Complete, sorted list of the exact sub-keys present in the shadowed bare
58
+ * object (e.g. `["triageScope", "wipCap"]`). Empty when the bare value is not
59
+ * an object.
60
+ *
61
+ * These are raw semantic keys, kept whole so machine-readable inventories
62
+ * (the doctor finding's `shadowed_sub_keys`) stay complete. Bounding and
63
+ * redaction belong at a display boundary -- see
64
+ * {@link summarizeShadowedPlanSubKeys} (#3796).
59
65
  */
60
66
  readonly shadowedSubKeys: readonly string[];
61
67
  }
68
+ /**
69
+ * Bounded, redacted presentation of shadowed sub-keys for a human diagnostic.
70
+ * Callers keep the raw semantic inventory; only the rendered string is capped
71
+ * and sanitised (#3796).
72
+ */
73
+ export declare function summarizeShadowedPlanSubKeys(keys: readonly string[]): string[];
62
74
  /**
63
75
  * Detect every plan-extension key where a bare (legacy) block coexists with the
64
76
  * namespaced form (#2301). Because `readPlanExtension` is namespace-first, the
@@ -77,6 +77,26 @@ export const SHADOWABLE_PLAN_EXTENSIONS = [
77
77
  { namespacedKey: PLAN_POLICY_KEY, legacyKey: LEGACY_PLAN_POLICY_KEY },
78
78
  { namespacedKey: PLAN_COMPLETED_NOTE_KEY, legacyKey: LEGACY_PLAN_COMPLETED_NOTE_KEY },
79
79
  ];
80
+ const SAFE_DIAGNOSTIC_KEY = /^[A-Za-z][A-Za-z0-9._/-]{0,31}$/;
81
+ const SENSITIVE_DIAGNOSTIC_KEY = /(?:auth|credential|password|private|secret|token|api.?key)/i;
82
+ const MAX_DIAGNOSTIC_KEYS = 8;
83
+ /**
84
+ * Bounded, redacted presentation of shadowed sub-keys for a human diagnostic.
85
+ * Callers keep the raw semantic inventory; only the rendered string is capped
86
+ * and sanitised (#3796).
87
+ */
88
+ export function summarizeShadowedPlanSubKeys(keys) {
89
+ const ordered = [...keys].sort();
90
+ const summary = ordered
91
+ .slice(0, MAX_DIAGNOSTIC_KEYS)
92
+ .map((key) => SAFE_DIAGNOSTIC_KEY.test(key) && !SENSITIVE_DIAGNOSTIC_KEY.test(key)
93
+ ? key
94
+ : `<redacted-key length=${[...key].length}>`);
95
+ if (ordered.length > MAX_DIAGNOSTIC_KEYS) {
96
+ summary.push(`<${ordered.length - MAX_DIAGNOSTIC_KEYS} more keys>`);
97
+ }
98
+ return summary;
99
+ }
80
100
  /**
81
101
  * Detect every plan-extension key where a bare (legacy) block coexists with the
82
102
  * namespaced form (#2301). Because `readPlanExtension` is namespace-first, the
@@ -96,7 +116,7 @@ export function detectShadowedPlanExtensions(plan) {
96
116
  }
97
117
  const legacyValue = planObj[legacyKey];
98
118
  const shadowedSubKeys = typeof legacyValue === "object" && legacyValue !== null && !Array.isArray(legacyValue)
99
- ? Object.keys(legacyValue)
119
+ ? Object.keys(legacyValue).sort()
100
120
  : [];
101
121
  shadows.push({ namespacedKey, legacyKey, shadowedSubKeys });
102
122
  }
@@ -108,8 +128,9 @@ export function detectShadowedPlanExtensions(plan) {
108
128
  * prefix it (`[policy:show]`, a doctor finding, ...).
109
129
  */
110
130
  export function describeShadowedPlanExtension(shadow) {
111
- const subKeys = shadow.shadowedSubKeys.length > 0
112
- ? ` Shadowed field(s): ${shadow.shadowedSubKeys
131
+ const displaySubKeys = summarizeShadowedPlanSubKeys(shadow.shadowedSubKeys);
132
+ const subKeys = displaySubKeys.length > 0
133
+ ? ` Shadowed field(s): ${displaySubKeys
113
134
  .map((k) => `plan.${shadow.legacyKey}.${k}`)
114
135
  .join(", ")}.`
115
136
  : "";
@@ -1,5 +1,4 @@
1
- import { readFileSync } from "node:fs";
2
- import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
1
+ import { withProjectDefinitionMutation } from "../vbrief-build/project-definition-mutation.js";
3
2
  import { productSignalInstallForceOnSource } from "./org-force-on-migration.js";
4
3
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
5
4
  import { policyColonInvocation } from "./policy-invocation.js";
@@ -140,14 +139,10 @@ export function enableProductSignal(projectRoot, options) {
140
139
  changed: false,
141
140
  };
142
141
  }
143
- const path = projectDefinitionPath(projectRoot);
142
+ const _path = projectDefinitionPath(projectRoot);
144
143
  try {
145
- const { changed } = projectDefinitionMutationLock(projectRoot, () => {
146
- const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
147
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
148
- throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
149
- }
150
- const data = parsed;
144
+ const { changed } = withProjectDefinitionMutation(projectRoot, (mutation) => {
145
+ const data = mutation.load();
151
146
  if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
152
147
  if (data.plan === undefined) {
153
148
  data.plan = {};
@@ -185,7 +180,7 @@ export function enableProductSignal(projectRoot, options) {
185
180
  legacyKeyMigrated;
186
181
  policyBlock.productSignal = nextBlock;
187
182
  if (changedFlag) {
188
- atomicWriteProjectDefinition(path, data);
183
+ mutation.persist(data);
189
184
  }
190
185
  const actor = options.actor ?? "task product-signal:enable";
191
186
  const note = options.note ?? "";
@@ -14,8 +14,8 @@
14
14
  * `merge-approval-head.ts` (stale head → fail closed + disable auto-merge).
15
15
  * That gate is complementary to this policy surface and does not replace it.
16
16
  */
17
- import { existsSync, readFileSync } from "node:fs";
18
- import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
17
+ import { existsSync } from "node:fs";
18
+ import { withProjectDefinitionMutation } from "../vbrief-build/project-definition-mutation.js";
19
19
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
20
20
  import { policyColonInvocation } from "./policy-invocation.js";
21
21
  import { appendAuditLog, loadProjectDefinition, projectDefinitionPath, stampChangedToken, } from "./resolve.js";
@@ -270,12 +270,8 @@ export function setRequireHumanMerge(projectRoot, options) {
270
270
  if (!existsSync(path)) {
271
271
  throw new Error(`PROJECT-DEFINITION not found at ${path}`);
272
272
  }
273
- return projectDefinitionMutationLock(projectRoot, () => {
274
- const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
275
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
276
- throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
277
- }
278
- const data = parsed;
273
+ return withProjectDefinitionMutation(projectRoot, (mutation) => {
274
+ const data = mutation.load();
279
275
  if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
280
276
  if (data.plan === undefined) {
281
277
  data.plan = {};
@@ -322,7 +318,7 @@ export function setRequireHumanMerge(projectRoot, options) {
322
318
  }
323
319
  const auditEntry = stampChangedToken(parts.join(" "), changed);
324
320
  if (changed) {
325
- atomicWriteProjectDefinition(path, data);
321
+ mutation.persist(data);
326
322
  }
327
323
  appendAuditLog(projectRoot, auditEntry, changed);
328
324
  return { changed, auditEntry };
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve as pathResolve } from "node:path";
3
3
  import { containedWrite } from "../fs/contained-write.js";
4
- import { resolveProjectDefinitionPath } from "../layout/resolve.js";
5
- import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
6
- import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
4
+ import { projectDefinitionPath as resolveProjectDefinitionArtifactPath } from "../vbrief-build/project-definition-io.js";
5
+ import { withProjectDefinitionMutation } from "../vbrief-build/project-definition-mutation.js";
6
+ import { describeShadowedPlanExtension, detectShadowedPlanExtensions, LEGACY_PLAN_POLICY_KEY, migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy, } from "./plan-extensions.js";
7
7
  import { policyColonInvocation } from "./policy-invocation.js";
8
8
  /** Filesystem-relative location of the project-definition xBRIEF (display/back-compat). */
9
9
  export const PROJECT_DEFINITION_REL_PATH = "xbrief/PROJECT-DEFINITION.xbrief.json";
@@ -32,11 +32,13 @@ const TRUTHY = new Set(["1", "true", "yes", "on"]);
32
32
  export function projectDefinitionPath(projectRoot) {
33
33
  const root = pathResolve(projectRoot);
34
34
  try {
35
- return resolveProjectDefinitionPath(root);
35
+ return resolveProjectDefinitionArtifactPath(root);
36
36
  }
37
37
  catch {
38
- // No xbrief/ layout; return canonical xbrief path so callers get a predictable
39
- // "not found" result rather than a thrown error.
38
+ // Policy reads remain fail-closed on pre-migration trees. The shared resolver
39
+ // intentionally throws for legacy-only lifecycle layouts, but policy callers
40
+ // need a predictable missing canonical path rather than an exception while
41
+ // init/update is still responsible for reporting the migration requirement.
40
42
  return join(root, PROJECT_DEFINITION_REL_PATH);
41
43
  }
42
44
  }
@@ -70,6 +72,66 @@ function pythonRepr(value) {
70
72
  return value ? "True" : "False";
71
73
  return String(value);
72
74
  }
75
+ /** Render configuration values without exposing arbitrary string/object content. */
76
+ function diagnosticValue(value) {
77
+ if (value === undefined || value === null)
78
+ return "None";
79
+ if (typeof value === "boolean")
80
+ return value ? "True" : "False";
81
+ if (typeof value === "number")
82
+ return Number.isFinite(value) ? String(value) : "<number>";
83
+ if (typeof value === "string")
84
+ return `<string length=${[...value].length}>`;
85
+ if (Array.isArray(value))
86
+ return `<list length=${value.length}>`;
87
+ if (typeof value === "object")
88
+ return `<dict keys=${Object.keys(value).length}>`;
89
+ return `<${typeof value}>`;
90
+ }
91
+ function objectKeys(value) {
92
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
93
+ return [];
94
+ }
95
+ return Object.keys(value).sort();
96
+ }
97
+ function keyInventory(value) {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value)
99
+ ? `<dict keys=${Object.keys(value).length}>`
100
+ : diagnosticValue(value);
101
+ }
102
+ function branchPolicyValue(value) {
103
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
104
+ return diagnosticValue(value);
105
+ }
106
+ return diagnosticValue(value.allowDirectCommitsToMaster);
107
+ }
108
+ /**
109
+ * Refuse a policy write when the legacy and namespaced blocks coexist (#3609).
110
+ *
111
+ * A writer cannot safely decide how to combine arbitrary policy keys or resolve
112
+ * collisions. The diagnostic therefore inventories keys, exposes only the one
113
+ * value this writer owns, and gives the operator a lossless recovery sequence.
114
+ */
115
+ function assertPolicyBlockIsUnambiguous(plan) {
116
+ const shadow = detectShadowedPlanExtensions(plan).find((candidate) => candidate.namespacedKey === PLAN_POLICY_KEY);
117
+ if (shadow === undefined) {
118
+ return;
119
+ }
120
+ const namespaced = plan[PLAN_POLICY_KEY];
121
+ const legacy = plan[LEGACY_PLAN_POLICY_KEY];
122
+ const namespacedKeys = objectKeys(namespaced);
123
+ const legacyKeys = objectKeys(legacy);
124
+ const namespacedKeySet = new Set(namespacedKeys);
125
+ const collisions = legacyKeys.filter((key) => namespacedKeySet.has(key));
126
+ throw new Error(`${describeShadowedPlanExtension(shadow)} ` +
127
+ `Key inventory: namespaced=${keyInventory(namespaced)}; ` +
128
+ `bare=${keyInventory(legacy)}; collisions=<list length=${collisions.length}>. ` +
129
+ `Relevant branch-policy values: namespaced=${branchPolicyValue(namespaced)}; ` +
130
+ `bare=${branchPolicyValue(legacy)}. ` +
131
+ `Recovery: inventory both blocks; fold every bare-only key into ` +
132
+ `\`plan.${PLAN_POLICY_KEY}\`; explicitly resolve every collision; delete ` +
133
+ `\`plan.${LEGACY_PLAN_POLICY_KEY}\`; then rerun the policy command. No changes were written.`);
134
+ }
73
135
  /** Best-effort coerce a legacy narrative value to a boolean. */
74
136
  export function coerceLegacyNarrative(value) {
75
137
  if (typeof value === "boolean") {
@@ -147,7 +209,7 @@ export function resolvePolicy(projectRoot) {
147
209
  allowDirectCommits: false,
148
210
  source: "default-fail-closed",
149
211
  deprecationWarning: null,
150
- error: `plan.policy.allowDirectCommitsToMaster must be a boolean; got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
212
+ error: `plan.policy.allowDirectCommitsToMaster must be a boolean; got ${pythonTypeName(raw)} (${diagnosticValue(raw)})`,
151
213
  };
152
214
  }
153
215
  return {
@@ -164,7 +226,7 @@ export function resolvePolicy(projectRoot) {
164
226
  LEGACY_NARRATIVE_KEY in narratives) {
165
227
  const { allow, raw } = coerceLegacyNarrative(narratives[LEGACY_NARRATIVE_KEY]);
166
228
  const warn = `DEPRECATED: PROJECT-DEFINITION uses the legacy narrative key ` +
167
- `'${LEGACY_NARRATIVE_KEY}' (${pythonRepr(raw)}). Migrate to typed ` +
229
+ `'${LEGACY_NARRATIVE_KEY}' (${diagnosticValue(raw)}). Migrate to typed ` +
168
230
  `plan.policy.allowDirectCommitsToMaster (#746). Run ` +
169
231
  `\`${policyColonInvocation("enforce-branches")}\` or ` +
170
232
  `\`${policyColonInvocation("allow-direct-commits", " -- --confirm")}\` ` +
@@ -224,12 +286,8 @@ export function setPolicy(projectRoot, options) {
224
286
  // Serialise the read-modify-write + audit-log append behind the shared
225
287
  // PROJECT-DEFINITION mutation lock so a concurrent policy/ritual mutator
226
288
  // cannot lose this update or desync the typed flag from the audit row (#1260).
227
- return projectDefinitionMutationLock(projectRoot, () => {
228
- const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
229
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
230
- throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
231
- }
232
- const data = parsed;
289
+ return withProjectDefinitionMutation(projectRoot, (mutation) => {
290
+ const data = mutation.load();
233
291
  if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
234
292
  if (data.plan === undefined) {
235
293
  data.plan = {};
@@ -239,6 +297,9 @@ export function setPolicy(projectRoot, options) {
239
297
  }
240
298
  }
241
299
  const plan = data.plan;
300
+ // Repeat the shadow check inside the mutation lock so a concurrent writer
301
+ // cannot create a dual-block state between validation and persistence.
302
+ assertPolicyBlockIsUnambiguous(plan);
242
303
  const legacyKeyMigrated = migrateLegacyPolicyKey(plan);
243
304
  const existingPolicy = plan[PLAN_POLICY_KEY];
244
305
  if (typeof existingPolicy !== "object" ||
@@ -267,7 +328,7 @@ export function setPolicy(projectRoot, options) {
267
328
  const parts = [
268
329
  `actor=${actor}`,
269
330
  `allowDirectCommitsToMaster=${allowDirectCommits ? "true" : "false"}`,
270
- `previous=${pythonRepr(previous)}`,
331
+ `previous=${diagnosticValue(previous)}`,
271
332
  ];
272
333
  if (legacyDropped) {
273
334
  parts.push("legacy-narrative-migrated=true");
@@ -277,7 +338,7 @@ export function setPolicy(projectRoot, options) {
277
338
  }
278
339
  const auditEntry = stampChangedToken(parts.join(" "), changed);
279
340
  if (changed) {
280
- atomicWriteProjectDefinition(path, data);
341
+ mutation.persist(data);
281
342
  }
282
343
  appendAuditLog(projectRoot, auditEntry, changed);
283
344
  return { changed, auditEntry };
@@ -11,6 +11,22 @@ export interface RuntimeAuthorityScopes {
11
11
  }
12
12
  /** Layers that contribute to a resolved write fence (#516 / #2443 / #2948 Wave 3). */
13
13
  export type WriteFenceSource = "project" | "story";
14
+ /**
15
+ * Whether recognized Shell dest-forms are enforced (#3438 / #3594).
16
+ *
17
+ * `off` (default) leaves Shell mutations exactly as they were before #3438:
18
+ * unrecognized and fail-open. `enforce` routes recognized dest-forms through
19
+ * `inspectMutationGates` and fail-closes targets it cannot prove.
20
+ *
21
+ * ! Default is `off` on purpose. Before #3438 Bash mutations were not gated at
22
+ * all, so defaulting to `enforce` would land new denials on every consumer with
23
+ * no opt-out. Opting IN via tracked project policy is a narrowing, which this
24
+ * fence model permits; a tracked switch that DISABLED enforcement would not be
25
+ * (see policy/deft-directive-disable.ts — repository-controlled content must
26
+ * not disable hooks for downstream clones).
27
+ */
28
+ export type ShellDestFormsMode = "off" | "enforce";
29
+ export declare const SHELL_DEST_FORMS_MODES: readonly ShellDestFormsMode[];
14
30
  export interface RuntimeAuthorityPolicy {
15
31
  readonly enabled: boolean;
16
32
  /** When non-empty, write targets must match at least one pattern. Empty = allow all paths. */
@@ -18,6 +34,8 @@ export interface RuntimeAuthorityPolicy {
18
34
  /** Deny wins over allow. */
19
35
  readonly denyPaths: readonly string[];
20
36
  readonly scopes: RuntimeAuthorityScopes;
37
+ /** Shell dest-form enforcement (#3438 / #3594). Default `off`. */
38
+ readonly shellDestForms: ShellDestFormsMode;
21
39
  /**
22
40
  * Optional story-layer allow globs from `plan.metadata.swarm.file_scope`
23
41
  * (populated by `resolveWriteFence` only). When non-empty, path must match
@@ -3,6 +3,7 @@ import { readPlanPolicy } from "./plan-extensions.js";
3
3
  import { loadProjectDefinition } from "./resolve.js";
4
4
  export const FIELD_RUNTIME_AUTHORITY = "plan.policy.runtimeAuthority";
5
5
  export const FIELD_RUNTIME_AUTHORITY_CLI_ALIAS = "runtimeAuthority";
6
+ export const SHELL_DEST_FORMS_MODES = ["off", "enforce"];
6
7
  export const DEFAULT_RUNTIME_AUTHORITY_SCOPES = {
7
8
  edits: true,
8
9
  push: false,
@@ -14,6 +15,7 @@ export const DEFAULT_RUNTIME_AUTHORITY_POLICY = {
14
15
  allowPaths: [],
15
16
  denyPaths: [],
16
17
  scopes: DEFAULT_RUNTIME_AUTHORITY_SCOPES,
18
+ shellDestForms: "off",
17
19
  };
18
20
  function readBoolean(rec, key, fallback) {
19
21
  if (key in rec && typeof rec[key] === "boolean") {
@@ -37,6 +39,11 @@ function readScopes(raw) {
37
39
  merge: readBoolean(rec, "merge", DEFAULT_RUNTIME_AUTHORITY_SCOPES.merge),
38
40
  };
39
41
  }
42
+ function readShellDestForms(raw) {
43
+ // Unknown / malformed resolves to the safe default rather than throwing, but
44
+ // validateRuntimeAuthority reports it so a typo is not silent (#3594).
45
+ return raw === "enforce" ? "enforce" : DEFAULT_RUNTIME_AUTHORITY_POLICY.shellDestForms;
46
+ }
40
47
  export function resolveRuntimeAuthorityPolicy(raw) {
41
48
  if (raw === null || raw === undefined) {
42
49
  return DEFAULT_RUNTIME_AUTHORITY_POLICY;
@@ -50,6 +57,7 @@ export function resolveRuntimeAuthorityPolicy(raw) {
50
57
  allowPaths: readStringArray(rec.allowPaths),
51
58
  denyPaths: readStringArray(rec.denyPaths),
52
59
  scopes: readScopes(rec.scopes),
60
+ shellDestForms: readShellDestForms(rec.shellDestForms),
53
61
  };
54
62
  }
55
63
  export function validateRuntimeAuthority(value) {
@@ -69,6 +77,9 @@ export function validateRuntimeAuthority(value) {
69
77
  errors.push(`${FIELD_RUNTIME_AUTHORITY}.${key} must be an array of path globs`);
70
78
  }
71
79
  }
80
+ if ("shellDestForms" in rec && !SHELL_DEST_FORMS_MODES.includes(rec.shellDestForms)) {
81
+ errors.push(`${FIELD_RUNTIME_AUTHORITY}.shellDestForms must be one of ${SHELL_DEST_FORMS_MODES.join(" | ")}`);
82
+ }
72
83
  if ("scopes" in rec) {
73
84
  const scopes = rec.scopes;
74
85
  if (typeof scopes !== "object" || scopes === null || Array.isArray(scopes)) {