@deftai/directive-core 0.87.0 → 0.89.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 (193) hide show
  1. package/dist/authz/actions.d.ts +55 -0
  2. package/dist/authz/actions.js +125 -0
  3. package/dist/authz/classify.d.ts +23 -0
  4. package/dist/authz/classify.js +217 -0
  5. package/dist/authz/closed-verb.d.ts +42 -0
  6. package/dist/authz/closed-verb.js +279 -0
  7. package/dist/authz/evaluate.d.ts +41 -0
  8. package/dist/authz/evaluate.js +298 -0
  9. package/dist/authz/index.d.ts +15 -0
  10. package/dist/authz/index.js +15 -0
  11. package/dist/authz/origin.d.ts +28 -0
  12. package/dist/authz/origin.js +64 -0
  13. package/dist/authz/paths.d.ts +6 -0
  14. package/dist/authz/paths.js +19 -0
  15. package/dist/authz/store.d.ts +40 -0
  16. package/dist/authz/store.js +317 -0
  17. package/dist/authz/templates.d.ts +139 -0
  18. package/dist/authz/templates.js +241 -0
  19. package/dist/authz/types.d.ts +111 -0
  20. package/dist/authz/types.js +41 -0
  21. package/dist/authz/verb-classification.d.ts +44 -0
  22. package/dist/authz/verb-classification.js +237 -0
  23. package/dist/branch/evaluate.js +6 -1
  24. package/dist/cache/fetch.js +10 -5
  25. package/dist/cache/io.d.ts +9 -2
  26. package/dist/cache/io.js +30 -10
  27. package/dist/cache/scanner.d.ts +11 -1
  28. package/dist/cache/scanner.js +29 -4
  29. package/dist/cache/task-cache/store.js +11 -7
  30. package/dist/capacity/backfill.js +10 -4
  31. package/dist/category-b-namespace/index.js +10 -4
  32. package/dist/check/gate-lists.js +1 -0
  33. package/dist/codebase/default-extractor.js +3 -0
  34. package/dist/codebase/map.js +11 -4
  35. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  36. package/dist/content-contracts/skills/helpers.js +35 -0
  37. package/dist/deposit/copy-tree.d.ts +19 -1
  38. package/dist/deposit/copy-tree.js +134 -5
  39. package/dist/doctor/doctor-state.js +28 -4
  40. package/dist/doctor/main.d.ts +10 -0
  41. package/dist/doctor/main.js +208 -0
  42. package/dist/doctor/types.d.ts +11 -0
  43. package/dist/escalation/actions.d.ts +63 -0
  44. package/dist/escalation/actions.js +137 -0
  45. package/dist/escalation/index.d.ts +8 -0
  46. package/dist/escalation/index.js +8 -0
  47. package/dist/escalation/paths.d.ts +3 -0
  48. package/dist/escalation/paths.js +10 -0
  49. package/dist/escalation/store.d.ts +17 -0
  50. package/dist/escalation/store.js +172 -0
  51. package/dist/escalation/types.d.ts +73 -0
  52. package/dist/escalation/types.js +43 -0
  53. package/dist/eval/crud-telemetry.js +30 -10
  54. package/dist/eval/health.js +22 -7
  55. package/dist/eval/readback.js +11 -10
  56. package/dist/eval/run.js +32 -16
  57. package/dist/events/attribution-enrichment.js +10 -4
  58. package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
  59. package/dist/finish-loop/directive-finish-loop.js +239 -0
  60. package/dist/finish-loop/grant-gate.d.ts +31 -0
  61. package/dist/finish-loop/grant-gate.js +169 -0
  62. package/dist/finish-loop/index.d.ts +11 -0
  63. package/dist/finish-loop/index.js +11 -0
  64. package/dist/finish-loop/main.d.ts +35 -0
  65. package/dist/finish-loop/main.js +365 -0
  66. package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
  67. package/dist/finish-loop/pr-finish-loop.js +217 -0
  68. package/dist/finish-loop/progress.d.ts +17 -0
  69. package/dist/finish-loop/progress.js +45 -0
  70. package/dist/finish-loop/queue.d.ts +16 -0
  71. package/dist/finish-loop/queue.js +65 -0
  72. package/dist/finish-loop/types.d.ts +52 -0
  73. package/dist/finish-loop/types.js +10 -0
  74. package/dist/fs/contained-write.d.ts +101 -0
  75. package/dist/fs/contained-write.js +281 -0
  76. package/dist/fs/projection-containment.d.ts +18 -0
  77. package/dist/fs/projection-containment.js +40 -0
  78. package/dist/hooks/classify/classify.d.ts +10 -0
  79. package/dist/hooks/classify/classify.js +37 -0
  80. package/dist/hooks/classify/index.d.ts +14 -0
  81. package/dist/hooks/classify/index.js +13 -0
  82. package/dist/hooks/classify/paths.d.ts +22 -0
  83. package/dist/hooks/classify/paths.js +72 -0
  84. package/dist/hooks/classify/payload.d.ts +16 -0
  85. package/dist/hooks/classify/payload.js +45 -0
  86. package/dist/hooks/classify/stdin.d.ts +12 -0
  87. package/dist/hooks/classify/stdin.js +63 -0
  88. package/dist/hooks/classify/tool-name.d.ts +18 -0
  89. package/dist/hooks/classify/tool-name.js +85 -0
  90. package/dist/hooks/classify/types.d.ts +41 -0
  91. package/dist/hooks/classify/types.js +7 -0
  92. package/dist/hooks/dispatcher.d.ts +30 -15
  93. package/dist/hooks/dispatcher.js +326 -134
  94. package/dist/hooks/fixtures/cases.d.ts +44 -0
  95. package/dist/hooks/fixtures/cases.js +595 -0
  96. package/dist/hooks/fixtures/index.d.ts +2 -0
  97. package/dist/hooks/fixtures/index.js +2 -0
  98. package/dist/hooks/index.d.ts +2 -0
  99. package/dist/hooks/index.js +2 -0
  100. package/dist/hooks/tools.d.ts +31 -0
  101. package/dist/hooks/tools.js +74 -0
  102. package/dist/index.d.ts +4 -0
  103. package/dist/index.js +5 -0
  104. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  105. package/dist/init-deposit/agent-hooks.js +65 -10
  106. package/dist/init-deposit/gitignore.js +11 -3
  107. package/dist/init-deposit/headless-manifest.js +12 -4
  108. package/dist/init-deposit/hygiene.d.ts +16 -0
  109. package/dist/init-deposit/hygiene.js +26 -0
  110. package/dist/init-deposit/init-dispatch.js +28 -0
  111. package/dist/init-deposit/migrate.d.ts +1 -1
  112. package/dist/init-deposit/migrate.js +13 -3
  113. package/dist/init-deposit/prettierignore.js +12 -5
  114. package/dist/init-deposit/refresh.js +38 -7
  115. package/dist/init-deposit/scaffold.js +42 -29
  116. package/dist/init-deposit/xbrief-projections.js +17 -10
  117. package/dist/intake/candidates-log.js +46 -35
  118. package/dist/intake/github-body-cli.d.ts +6 -0
  119. package/dist/intake/github-body-cli.js +17 -0
  120. package/dist/intake/github-body.d.ts +46 -3
  121. package/dist/intake/github-body.js +191 -20
  122. package/dist/intake/issue-emit.d.ts +1 -0
  123. package/dist/intake/issue-emit.js +41 -23
  124. package/dist/intake/issue-ingest.js +11 -2
  125. package/dist/lifecycle/event-detect.js +12 -4
  126. package/dist/lifecycle/events.js +29 -20
  127. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  128. package/dist/packs/pack-render.d.ts +33 -0
  129. package/dist/packs/pack-render.js +172 -13
  130. package/dist/packs/quarantine-ext.d.ts +10 -0
  131. package/dist/packs/quarantine-ext.js +26 -2
  132. package/dist/plan-sequence/store.js +11 -4
  133. package/dist/policy/hotfix-criteria.d.ts +79 -0
  134. package/dist/policy/hotfix-criteria.js +197 -0
  135. package/dist/policy/index.d.ts +5 -0
  136. package/dist/policy/index.js +32 -1
  137. package/dist/policy/intent-ceiling.d.ts +79 -0
  138. package/dist/policy/intent-ceiling.js +181 -0
  139. package/dist/policy/no-deft-directive.d.ts +59 -0
  140. package/dist/policy/no-deft-directive.js +110 -0
  141. package/dist/policy/org-force-on-migration.d.ts +52 -0
  142. package/dist/policy/org-force-on-migration.js +269 -27
  143. package/dist/policy/require-human-merge.d.ts +75 -0
  144. package/dist/policy/require-human-merge.js +324 -0
  145. package/dist/policy/resolve.js +17 -6
  146. package/dist/policy/runtime-authority.d.ts +56 -2
  147. package/dist/policy/runtime-authority.js +297 -3
  148. package/dist/policy/write-fence.d.ts +78 -0
  149. package/dist/policy/write-fence.js +164 -0
  150. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  151. package/dist/pr-wait-mergeable/cascade.js +28 -0
  152. package/dist/preflight/evaluate.js +10 -0
  153. package/dist/product-signal/consent.js +21 -5
  154. package/dist/product-signal/submit.js +22 -12
  155. package/dist/release/build-dist-runner.js +5 -2
  156. package/dist/release/build-dist.d.ts +19 -1
  157. package/dist/release/build-dist.js +50 -2
  158. package/dist/release/native-steps.js +7 -2
  159. package/dist/release-publish/pipeline.d.ts +13 -0
  160. package/dist/release-publish/pipeline.js +46 -1
  161. package/dist/scope/audit-log.js +19 -24
  162. package/dist/scope/decompose.js +10 -5
  163. package/dist/scope/decomposed-refs.js +18 -3
  164. package/dist/scope/demote.js +9 -2
  165. package/dist/scope/undo.js +9 -2
  166. package/dist/session/release-availability.js +26 -6
  167. package/dist/session/ritual-sentinel.js +19 -12
  168. package/dist/session/session-start-hook.d.ts +3 -0
  169. package/dist/session/session-start-hook.js +15 -0
  170. package/dist/session/session-start.js +37 -0
  171. package/dist/staleness-tickler/state.js +26 -6
  172. package/dist/swarm/launch.js +13 -3
  173. package/dist/swarm/routing.js +9 -3
  174. package/dist/task-surface/index.js +24 -9
  175. package/dist/triage/bootstrap/gitignore.js +53 -10
  176. package/dist/triage/cache-path.js +10 -3
  177. package/dist/triage/welcome/summary.js +11 -4
  178. package/dist/triage/welcome/writers.js +45 -16
  179. package/dist/validate-content/validate-links.js +5 -0
  180. package/dist/value/readback.js +11 -6
  181. package/dist/vbrief-activate/activate.js +12 -4
  182. package/dist/vbrief-build/project-definition-io.js +14 -6
  183. package/dist/verify-source/contained-writes.d.ts +59 -0
  184. package/dist/verify-source/contained-writes.js +272 -0
  185. package/dist/verify-source/cursor-tier1.js +7 -2
  186. package/dist/verify-source/index.d.ts +1 -0
  187. package/dist/verify-source/index.js +1 -0
  188. package/dist/verify-source/openclaw-tier1.js +19 -1
  189. package/dist/verify-source/verify-stubs.js +3 -0
  190. package/dist/xbrief-migrate/migrate-project.js +26 -12
  191. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  192. package/dist/xbrief-migrate/transforms.js +37 -14
  193. package/package.json +16 -3
@@ -21,6 +21,11 @@ export const SPAWN_TOOL_NAMES = [
21
21
  "start_agent",
22
22
  "CreateAgent",
23
23
  ];
24
+ /**
25
+ * Host spellings for Shell/Bash execution tools (#2711).
26
+ * Used for runtimeAuthority scopes.push / scopes.merge classification.
27
+ */
28
+ export const SHELL_TOOL_NAMES = ["Shell", "Bash", "BashTool", "shell", "bash"];
24
29
  /** Env override forcing hook-level read-only write denial (#1185). */
25
30
  export const READ_ONLY_HOOK_ENV = "DEFT_HOOK_READ_ONLY";
26
31
  function normalizedToolName(toolName) {
@@ -28,12 +33,81 @@ function normalizedToolName(toolName) {
28
33
  }
29
34
  const DIRECT_WRITE_TOOLS = new Set(DIRECT_WRITE_TOOL_NAMES.map(normalizedToolName));
30
35
  const SPAWN_TOOLS = new Set(SPAWN_TOOL_NAMES.map(normalizedToolName));
36
+ const SHELL_TOOLS = new Set(SHELL_TOOL_NAMES.map(normalizedToolName));
31
37
  export function isDirectWriteTool(toolName) {
32
38
  return DIRECT_WRITE_TOOLS.has(normalizedToolName(toolName));
33
39
  }
34
40
  export function isSpawnTool(toolName) {
35
41
  return SPAWN_TOOLS.has(normalizedToolName(toolName));
36
42
  }
43
+ /** True for host Shell/Bash-class tools that carry a command string (#2711). */
44
+ export function isShellTool(toolName) {
45
+ return SHELL_TOOLS.has(normalizedToolName(toolName));
46
+ }
47
+ /**
48
+ * Best-effort MCP tool detection for runtimeAuthority push/merge scopes (#2711).
49
+ * Host spellings vary (`mcp__*`, `server__tool`, bare MCP-looking names).
50
+ * Unclassifiable MCP tools fail open at the operation classifier.
51
+ *
52
+ * Bare push/merge names (e.g. `merge_pull_request`) are NOT detected here —
53
+ * they are classified by `classifyMcpTool` and routed from `decideHook` so
54
+ * tools.ts stays free of policy imports (#2711 Greptile conf holdout).
55
+ */
56
+ export function isMcpTool(toolName) {
57
+ const raw = toolName.trim();
58
+ if (raw.length === 0)
59
+ return false;
60
+ const lower = raw.toLowerCase();
61
+ if (lower.startsWith("mcp__") || lower.startsWith("mcp_"))
62
+ return true;
63
+ if (lower.includes("__") && !isDirectWriteTool(toolName) && !isShellTool(toolName)) {
64
+ // server__tool style used by some MCP bridges
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ /**
70
+ * Bare tool names that hosts may emit without mcp__/server__ prefixes and that
71
+ * `classifyMcpTool` treats as push or merge (#2711).
72
+ * Keep in sync with classifyMcpTool name patterns (narrow list for PreToolUse matchers).
73
+ */
74
+ export const MCP_PUSH_MERGE_BARE_NAMES = [
75
+ "merge_pull_request",
76
+ "merge-pull-request",
77
+ "pull_request_merge",
78
+ "pull-request-merge",
79
+ "merge_pr",
80
+ "pr_merge",
81
+ "pr-merge",
82
+ "git_push",
83
+ "git-push",
84
+ "push_branch",
85
+ "push-branch",
86
+ ];
37
87
  export const DIRECT_WRITE_HOOK_MATCHER = DIRECT_WRITE_TOOL_NAMES.join("|");
38
88
  export const SPAWN_HOOK_MATCHER = SPAWN_TOOL_NAMES.join("|");
89
+ export const SHELL_HOOK_MATCHER = SHELL_TOOL_NAMES.join("|");
90
+ /**
91
+ * PreToolUse matcher for MCP-class push/merge tools (#2711).
92
+ * Regex-friendly for Claude/nested hosts. Prefer broad install match +
93
+ * fail-open classify in the dispatcher over missing a classifiable push/merge
94
+ * tool name (e.g. `server__push_to_remote` via push+remote).
95
+ */
96
+ export const MCP_HOOK_MATCHER = [
97
+ // Prefixed / server-bridge styles (isMcpTool); dispatcher fail-opens non-ops.
98
+ "mcp__.*",
99
+ "mcp_.*",
100
+ ".*__.*",
101
+ // Bare names classifyMcpTool recognizes without prefixes.
102
+ ...MCP_PUSH_MERGE_BARE_NAMES,
103
+ // Name fragments for hosts that strip server prefixes differently.
104
+ ".*merge[_-]?pull[_-]?request.*",
105
+ ".*pull[_-]?request[_-]?merge.*",
106
+ ".*(?:^|[_-])merge_pr(?:$|[_-]).*",
107
+ ".*pr[_-]?merge.*",
108
+ ".*git[_-]?push.*",
109
+ ".*push[_-]?branch.*",
110
+ ".*push.*(?:git|branch|remote|ref).*",
111
+ ".*(?:git|branch|remote|ref).*push.*",
112
+ ].join("|");
39
113
  //# sourceMappingURL=tools.js.map
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import type { EngineInfo } from "@deftai/directive-types";
9
9
  * namespaces here (and as `@deftai/directive-core/<gate>` subpaths in package.json) to
10
10
  * avoid colliding on those shared symbol names.
11
11
  */
12
+ export * as authz from "./authz/index.js";
12
13
  export * as branch from "./branch/index.js";
13
14
  export * as cache from "./cache/index.js";
14
15
  export * as capacity from "./capacity/index.js";
@@ -16,12 +17,15 @@ export * as codebase from "./codebase/index.js";
16
17
  export * from "./coverage-hotspots/index.js";
17
18
  export * as doctor from "./doctor/index.js";
18
19
  export * from "./encoding/index.js";
20
+ export * as escalation from "./escalation/index.js";
19
21
  export * as evalCrud from "./eval/crud-telemetry.js";
20
22
  export * as evalHealth from "./eval/health.js";
21
23
  export * as evalReport from "./eval/report.js";
22
24
  export * as evalRun from "./eval/run.js";
23
25
  export * as events from "./events/attribution-ledger.js";
24
26
  export * from "./forward-coverage/evaluate.js";
27
+ export { ContainedWriteError, ContainedWriteErrorCode, type ContainedWriteInput, type ContainedWriteMode, type ContainedWriteResult, containedWrite, resolveContainedTarget, } from "./fs/contained-write.js";
28
+ export { assertDestinationNotSymlink, assertDirectoryNotSymlink, assertProjectionContained, assertWriteTargetSafe, PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE, ProjectionContainmentError, walkDirectoryRejectSymlinks, } from "./fs/projection-containment.js";
25
29
  export * as intake from "./intake/index.js";
26
30
  export * as layout from "./layout/index.js";
27
31
  export * as legacyBridge from "./legacy-bridge/index.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { readCorePackageVersion } from "./engine-version.js";
9
9
  * namespaces here (and as `@deftai/directive-core/<gate>` subpaths in package.json) to
10
10
  * avoid colliding on those shared symbol names.
11
11
  */
12
+ export * as authz from "./authz/index.js";
12
13
  export * as branch from "./branch/index.js";
13
14
  export * as cache from "./cache/index.js";
14
15
  export * as capacity from "./capacity/index.js";
@@ -16,12 +17,16 @@ export * as codebase from "./codebase/index.js";
16
17
  export * from "./coverage-hotspots/index.js";
17
18
  export * as doctor from "./doctor/index.js";
18
19
  export * from "./encoding/index.js";
20
+ export * as escalation from "./escalation/index.js";
19
21
  export * as evalCrud from "./eval/crud-telemetry.js";
20
22
  export * as evalHealth from "./eval/health.js";
21
23
  export * as evalReport from "./eval/report.js";
22
24
  export * as evalRun from "./eval/run.js";
23
25
  export * as events from "./events/attribution-ledger.js";
24
26
  export * from "./forward-coverage/evaluate.js";
27
+ // #2951 Phase 1: contained-write API for product sinks.
28
+ export { ContainedWriteError, ContainedWriteErrorCode, containedWrite, resolveContainedTarget, } from "./fs/contained-write.js";
29
+ export { assertDestinationNotSymlink, assertDirectoryNotSymlink, assertProjectionContained, assertWriteTargetSafe, PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE, ProjectionContainmentError, walkDirectoryRejectSymlinks, } from "./fs/projection-containment.js";
25
30
  export * as intake from "./intake/index.js";
26
31
  export * as layout from "./layout/index.js";
27
32
  export * as legacyBridge from "./legacy-bridge/index.js";
@@ -1,7 +1,7 @@
1
1
  import type { HookHost } from "../hooks/dispatcher.js";
2
2
  import { type HostHooksPolicy } from "../policy/host-hooks.js";
3
3
  import type { InitDepositIo } from "./constants.js";
4
- export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
4
+ export { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
5
5
  export declare const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
6
6
  export declare const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
7
7
  export declare const AGENT_HOOK_PATHS: readonly [".claude/settings.json", ".grok/hooks/deft.json", ".cursor/hooks.json", ".codex/hooks.json"];
@@ -1,9 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { assertDepositContained } from "../deposit/contain.js";
4
- import { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
4
+ import { containedWrite } from "../fs/contained-write.js";
5
+ import { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
5
6
  import { isHostHookDepositEnabled, loadHostHooksPolicyFromProject, } from "../policy/host-hooks.js";
6
- export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
7
+ export { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
7
8
  export const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
8
9
  export const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
9
10
  export const AGENT_HOOK_PATHS = [
@@ -103,6 +104,10 @@ function mergeNestedConfig(config, path, host, options = {}) {
103
104
  ...preTool,
104
105
  nestedGroup(host, "tool.before", DIRECT_WRITE_HOOK_MATCHER),
105
106
  nestedGroup(host, "tool.before", SPAWN_HOOK_MATCHER),
107
+ // Shell/Bash for runtimeAuthority scopes.push / scopes.merge (#2711)
108
+ nestedGroup(host, "tool.before", SHELL_HOOK_MATCHER),
109
+ // MCP push/merge (mcp__*, bare merge_pull_request / git_push, …) (#2711)
110
+ nestedGroup(host, "tool.before", MCP_HOOK_MATCHER),
106
111
  ];
107
112
  if (options.compact) {
108
113
  const preCompact = eventArray(hooks, "PreCompact", path).filter((entry) => !isManagedNestedGroup(entry));
@@ -164,18 +169,48 @@ function mergeCursorConfig(config, path) {
164
169
  failClosed: true,
165
170
  timeout: 5,
166
171
  },
172
+ {
173
+ command: command("cursor", "tool.before"),
174
+ matcher: SHELL_HOOK_MATCHER,
175
+ failClosed: true,
176
+ timeout: 5,
177
+ },
178
+ {
179
+ command: command("cursor", "tool.before"),
180
+ matcher: MCP_HOOK_MATCHER,
181
+ failClosed: true,
182
+ timeout: 5,
183
+ },
167
184
  ];
168
185
  hooks.preCompact = [...preCompact, { command: command("cursor", "session.compact"), timeout: 5 }];
169
186
  return { ...config, version: 1, hooks };
170
187
  }
171
- function writeJsonIfChanged(path, payload) {
188
+ function writeJsonIfChanged(projectRoot, path, payload) {
172
189
  const next = `${JSON.stringify(payload, null, 2)}\n`;
173
190
  if (existsSync(path) && readFileSync(path, "utf8") === next)
174
191
  return false;
175
- mkdirSync(dirname(path), { recursive: true });
176
- const temporary = `${path}.deft-${process.pid}.tmp`;
177
- writeFileSync(temporary, next, "utf8");
178
- renameSync(temporary, path);
192
+ // Atomic replace via temp under project root (#2951 / #2980 wave A).
193
+ const parent = dirname(path);
194
+ const tmpName = `${basename(path)}.deft-${process.pid}.tmp`;
195
+ const temporary = join(parent, tmpName);
196
+ try {
197
+ containedWrite({
198
+ root: resolve(projectRoot),
199
+ target: temporary,
200
+ data: next,
201
+ mode: "replace",
202
+ });
203
+ renameSync(temporary, path);
204
+ }
205
+ catch (err) {
206
+ try {
207
+ rmSync(temporary, { force: true });
208
+ }
209
+ catch {
210
+ /* best-effort cleanup */
211
+ }
212
+ throw err;
213
+ }
179
214
  return true;
180
215
  }
181
216
  /** Merge Directive-owned project hook entries without replacing user configuration. */
@@ -231,7 +266,7 @@ export function writeAgentHookDeposit(projectRoot, io = { printf: () => undefine
231
266
  for (const item of prepared) {
232
267
  if (item.mode === "skip")
233
268
  continue;
234
- if (writeJsonIfChanged(item.absolute, item.payload)) {
269
+ if (writeJsonIfChanged(projectRoot, item.absolute, item.payload)) {
235
270
  if (item.mode === "strip")
236
271
  strippedPaths.push(item.path);
237
272
  else
@@ -285,6 +320,14 @@ function hasNestedRegistration(config, host, options = {}) {
285
320
  preTool.some((entry) => {
286
321
  const group = object(entry);
287
322
  return group?.matcher === SPAWN_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
323
+ }) &&
324
+ preTool.some((entry) => {
325
+ const group = object(entry);
326
+ return group?.matcher === SHELL_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
327
+ }) &&
328
+ preTool.some((entry) => {
329
+ const group = object(entry);
330
+ return group?.matcher === MCP_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
288
331
  });
289
332
  if (!base)
290
333
  return false;
@@ -315,6 +358,18 @@ function hasCursorRegistration(config) {
315
358
  hook.matcher === SPAWN_HOOK_MATCHER &&
316
359
  hook.failClosed === true);
317
360
  }) &&
361
+ preTool.some((entry) => {
362
+ const hook = object(entry);
363
+ return (hook?.command === command("cursor", "tool.before") &&
364
+ hook.matcher === SHELL_HOOK_MATCHER &&
365
+ hook.failClosed === true);
366
+ }) &&
367
+ preTool.some((entry) => {
368
+ const hook = object(entry);
369
+ return (hook?.command === command("cursor", "tool.before") &&
370
+ hook.matcher === MCP_HOOK_MATCHER &&
371
+ hook.failClosed === true);
372
+ }) &&
318
373
  preCompact.some((entry) => object(entry)?.command === command("cursor", "session.compact")));
319
374
  }
320
375
  /** Read-only registration probe shared by verify and doctor. */
@@ -9,8 +9,9 @@
9
9
  * Refs #1942, #1941, #1015, #1464, #1672.
10
10
  */
11
11
  import { execFileSync } from "node:child_process";
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import { containedWrite } from "../fs/contained-write.js";
14
15
  import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
15
16
  import { FORBIDDEN_BLANKET_EVAL_LINES, stripGitignoreInlineComment, } from "../triage/bootstrap/gitignore.js";
16
17
  /** Directory ignore entry for the hybrid deposit (greenfield only). */
@@ -196,8 +197,15 @@ function reconcileGitignoreFile(projectDir, targetLines, includeDeftCoreRational
196
197
  }
197
198
  }
198
199
  try {
200
+ // Keep early containment so ProjectionContainmentError type is preserved for callers/tests.
199
201
  assertWriteTargetSafe(projectDir, path);
200
- writeFileSync(path, body, { encoding: "utf8", mode: 0o644 });
202
+ // #2980 wave A: product write sink routes through containedWrite.
203
+ containedWrite({
204
+ root: resolve(projectDir),
205
+ target: path,
206
+ data: body,
207
+ mode: "replace",
208
+ });
201
209
  }
202
210
  catch (cause) {
203
211
  if (cause instanceof ProjectionContainmentError) {
@@ -22,10 +22,11 @@
22
22
  * emits a JSON error object so the calling backend can treat it as non-fatal
23
23
  * (never a crash or a partial write).
24
24
  */
25
- import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
26
- import { dirname, join, relative, resolve, sep } from "node:path";
25
+ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
26
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
27
27
  import { ContentPackageNotFoundError, resolveInstalledContentRoot, } from "../deposit/resolve-content.js";
28
28
  import { readCorePackageVersion } from "../engine-version.js";
29
+ import { containedWrite } from "../fs/contained-write.js";
29
30
  import { agentsRefreshPlan } from "../platform/agents-md.js";
30
31
  import { plan } from "../resolution/plan.js";
31
32
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
@@ -233,8 +234,15 @@ export async function runInitHeadlessCli(options) {
233
234
  const abs = resolve(options.outputPath);
234
235
  const writeFile = options.writeFile ??
235
236
  ((path, data) => {
236
- mkdirSync(dirname(path), { recursive: true });
237
- writeFileSync(path, data);
237
+ // Contain under the output parent (arbitrary --output path; #2980 wave A).
238
+ const parent = dirname(path);
239
+ mkdirSync(parent, { recursive: true });
240
+ containedWrite({
241
+ root: resolve(parent),
242
+ target: basename(path),
243
+ data,
244
+ mode: "replace",
245
+ });
238
246
  });
239
247
  writeFile(abs, serialized);
240
248
  options.writeErr(`directive init --headless: wrote ${manifest.files.length}-file manifest (v${manifest.version}) to ${abs}\n`);
@@ -77,8 +77,24 @@ export interface PrunePackageAbsentDepositPathsResult {
77
77
  * Remove deposit files not shipped by `@deftai/directive-content`, preserving
78
78
  * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
79
79
  * hard-coded `packages/` prune (#2347).
80
+ *
81
+ * Individual removal failures are reported but do not throw — callers that must
82
+ * refuse a VERSION stamp until the deposit matches the content package should
83
+ * use {@link reconcileDepositToContentPackage} (#2913).
80
84
  */
81
85
  export declare function prunePackageAbsentDepositPaths(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
86
+ /**
87
+ * Fail-closed deposit reconcile against the installed content package (#2913).
88
+ *
89
+ * Runs {@link prunePackageAbsentDepositPaths}, then re-scans. If any
90
+ * package-absent path remains, throws so callers refuse the VERSION stamp
91
+ * (dst-only stale/malicious agent content must not survive a refresh).
92
+ *
93
+ * After a successful {@link replaceTree} full-swap this is typically a no-op
94
+ * verification; it also covers additive copy seams and the already-current
95
+ * refresh path that skips payload copy.
96
+ */
97
+ export declare function reconcileDepositToContentPackage(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
82
98
  export interface PruneStrayDepositPathsResult {
83
99
  readonly pruned: string[];
84
100
  }
@@ -257,6 +257,10 @@ async function pruneEmptyParentsForFile(deftDir, contentDirs, relFile) {
257
257
  * Remove deposit files not shipped by `@deftai/directive-content`, preserving
258
258
  * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
259
259
  * hard-coded `packages/` prune (#2347).
260
+ *
261
+ * Individual removal failures are reported but do not throw — callers that must
262
+ * refuse a VERSION stamp until the deposit matches the content package should
263
+ * use {@link reconcileDepositToContentPackage} (#2913).
260
264
  */
261
265
  export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
262
266
  const absent = await findPackageAbsentDepositPaths(deftDir, contentRoot);
@@ -278,6 +282,28 @@ export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
278
282
  }
279
283
  return { pruned, prunedDirs };
280
284
  }
285
+ /**
286
+ * Fail-closed deposit reconcile against the installed content package (#2913).
287
+ *
288
+ * Runs {@link prunePackageAbsentDepositPaths}, then re-scans. If any
289
+ * package-absent path remains, throws so callers refuse the VERSION stamp
290
+ * (dst-only stale/malicious agent content must not survive a refresh).
291
+ *
292
+ * After a successful {@link replaceTree} full-swap this is typically a no-op
293
+ * verification; it also covers additive copy seams and the already-current
294
+ * refresh path that skips payload copy.
295
+ */
296
+ export async function reconcileDepositToContentPackage(deftDir, contentRoot, io) {
297
+ const result = await prunePackageAbsentDepositPaths(deftDir, contentRoot, io);
298
+ const remaining = await findPackageAbsentDepositPaths(deftDir, contentRoot);
299
+ if (remaining.length > 0) {
300
+ const sample = remaining.slice(0, 5).join(", ");
301
+ const more = remaining.length > 5 ? ` (+${remaining.length - 5} more)` : "";
302
+ throw new Error(`deposit reconcile failed: ${remaining.length} package-absent path(s) remain under .deft/core ` +
303
+ `(e.g. ${sample}${more}). Refusing VERSION stamp until dst-only content is removed (#2913).`);
304
+ }
305
+ return result;
306
+ }
281
307
  /**
282
308
  * Remove framework-source subdirectories from `.deft/core/` that are not
283
309
  * present in the deposited content package (#2347). Silently skips any path
@@ -21,6 +21,7 @@
21
21
  * dispatcher never re-implements deposit, refresh, or migrate behavior.
22
22
  */
23
23
  import { resolve } from "node:path";
24
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
24
25
  import { classify, plan } from "../resolution/index.js";
25
26
  import { runInitDepositCli } from "./init-deposit.js";
26
27
  import { runMigrateCli } from "./migrate.js";
@@ -119,6 +120,33 @@ export async function runInitDispatchCli(options) {
119
120
  options.writeOut(text);
120
121
  },
121
122
  };
123
+ // #2926: root opt-out — do not scaffold/install/refresh when flag is present.
124
+ const optOut = detectNoDeftDirective(projectDir);
125
+ if (optOut.present) {
126
+ io.printf(`\n[directive init] ${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE}\n`);
127
+ if (optOut.inconsistent) {
128
+ io.printf(`[directive init] ${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE}\n`);
129
+ }
130
+ if (options.jsonOut) {
131
+ options.writeOut(`${JSON.stringify({
132
+ success: false,
133
+ action: "init",
134
+ disabled: true,
135
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
136
+ inconsistent: optOut.inconsistent,
137
+ ...(optOut.inconsistent
138
+ ? { inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY }
139
+ : {}),
140
+ deposit_present: optOut.depositPresent,
141
+ project_dir: projectDir,
142
+ message: optOut.inconsistent
143
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE
144
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE,
145
+ }, null, 2)}\n`);
146
+ }
147
+ // Clean opt-out is a successful short-circuit; inconsistent state fails closed.
148
+ return optOut.inconsistent ? 1 : 0;
149
+ }
122
150
  const classification = classifyInitDispatch(projectDir, seams.classifySeams ?? {});
123
151
  const { decision, plan: resolutionPlan } = classification;
124
152
  printInitDispatchSummary(io, decision, resolutionPlan);
@@ -49,7 +49,7 @@ export interface MigrateSeams {
49
49
  isFile?: (path: string) => boolean;
50
50
  /** Text read returning null on failure (default: node:fs readFileSync, utf8). */
51
51
  readText?: (path: string) => string | null;
52
- /** Text write (default: node:fs writeFileSync, utf8). */
52
+ /** Text write (default: containedWrite under projectRoot; #2980 wave A). */
53
53
  writeText?: (path: string, text: string) => void;
54
54
  /** ISO-8601 UTC timestamp source for the backup filename (default: Date.now). */
55
55
  nowIso?: () => string;
@@ -24,11 +24,12 @@
24
24
  *
25
25
  * Refs #1941, #1912 (freeze prerequisite b), #1933 (never-first-start), #1670.
26
26
  */
27
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
28
- import { dirname, join } from "node:path";
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { dirname, join, resolve } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { resolveContentPackageRoot } from "../content-root.js";
31
31
  import { locateManifest, parseInstallManifest } from "../doctor/manifest.js";
32
+ import { containedWrite } from "../fs/contained-write.js";
32
33
  import { CANONICAL_INSTALL_ROOT } from "./scaffold.js";
33
34
  /**
34
35
  * The npm-managed handshake sentinel. `directive migrate` adds this key/value to
@@ -99,7 +100,16 @@ function engineMissingMessage() {
99
100
  export function runMigrate(projectRoot, seams = {}) {
100
101
  const isFile = seams.isFile ?? existsSync;
101
102
  const readText = seams.readText ?? defaultReadText;
102
- const writeText = seams.writeText ?? ((path, text) => writeFileSync(path, text, "utf8"));
103
+ const writeText = seams.writeText ??
104
+ ((path, text) => {
105
+ // #2980 wave A: product write sink routes through containedWrite.
106
+ containedWrite({
107
+ root: resolve(projectRoot),
108
+ target: path,
109
+ data: text,
110
+ mode: "replace",
111
+ });
112
+ });
103
113
  const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
104
114
  const resolveEngine = seams.resolveEngine ?? defaultResolveEngine;
105
115
  const manifestPath = detectCanonicalVendoredManifest(projectRoot, isFile);
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Refs #2534, #670.
9
9
  */
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { assertProjectionContained } from "../fs/projection-containment.js";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { containedWrite } from "../fs/contained-write.js";
13
+ import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
13
14
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
14
15
  /** Directory ignore entry for the hybrid deposit. */
15
16
  export const PRETTIERIGNORE_DEFT_CORE_LINE = ".deft/core/";
@@ -18,7 +19,7 @@ const DEFT_CORE_COVERING_LINES = new Set([".deft/core/", ".deft/core", ".deft/co
18
19
  const DEFT_FRAMEWORK_PRETTIERIGNORE_HEADER = "# Deft framework: the vendored payload is outside the consumer Prettier gate (#2534).\n";
19
20
  function projectionTarget(projectDir) {
20
21
  const target = join(projectDir, ".prettierignore");
21
- assertProjectionContained(projectDir, target);
22
+ assertDestinationNotSymlink(projectDir, target);
22
23
  return target;
23
24
  }
24
25
  function prettierIgnoreCoversLine(present, line) {
@@ -72,7 +73,13 @@ export function ensurePrettierIgnoreLines(projectDir, io) {
72
73
  body += `${add}\n`;
73
74
  }
74
75
  try {
75
- writeFileSync(path, body, { encoding: "utf8", mode: 0o644 });
76
+ // #2980 wave A: product write sink routes through containedWrite.
77
+ containedWrite({
78
+ root: resolve(projectDir),
79
+ target: path,
80
+ data: body,
81
+ mode: "replace",
82
+ });
76
83
  }
77
84
  catch (cause) {
78
85
  throw new Error(`could not write .prettierignore: ${String(cause)}`);
@@ -12,19 +12,20 @@ import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
12
12
  import { platform as osPlatform } from "node:os";
13
13
  import { join, resolve } from "node:path";
14
14
  import { assertDepositContained } from "../deposit/contain.js";
15
- import { copyTree } from "../deposit/copy-tree.js";
15
+ import { replaceTree } from "../deposit/copy-tree.js";
16
16
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
17
17
  import { resolveInstalledContentRoot } from "../deposit/resolve-content.js";
18
18
  import { manifestTagToVersion, parseInstallManifest } from "../doctor/manifest.js";
19
19
  import { readCorePackageVersion } from "../engine-version.js";
20
20
  import { resolveLifecycleRoot } from "../layout/resolve.js";
21
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
21
22
  import { runOrgForceOnMigration } from "../policy/org-force-on-migration.js";
22
23
  import { checkLocalEngineIntegrity, classify, ENGINE_PACKAGE, plan, renderGlobalInstall, resolveEngine, } from "../resolution/index.js";
23
24
  import { gitPorcelain } from "../story-ready/git.js";
24
25
  import { removeStaleMigratedFrameworkNarrative } from "../xbrief-migrate/migrate-project.js";
25
26
  import { writeAgentHookDeposit } from "./agent-hooks.js";
26
27
  import { ensureInitGitignoreLines, isDepositTrackedInGit } from "./gitignore.js";
27
- import { depositStagePaths, isInstallerManagedPath, printCommitGuidance, prunePackageAbsentDepositPaths, } from "./hygiene.js";
28
+ import { depositStagePaths, isInstallerManagedPath, printCommitGuidance, reconcileDepositToContentPackage, } from "./hygiene.js";
28
29
  import { parseInitArgv } from "./init-deposit.js";
29
30
  import { buildLegacyRefusalJson, buildLegacyRefusalMessage, detectLegacyLayout, LEGACY_LAYOUT_REFUSED_EXIT_CODE, LegacyLayoutRefusedError, } from "./legacy-detect.js";
30
31
  import { printMigrateNudgeIfNeeded } from "./migrate.js";
@@ -392,7 +393,9 @@ export async function runRefreshDeposit(args, io, seams = {}) {
392
393
  // the resolved project tree. Writes nothing on refusal.
393
394
  assertDepositContained(projectDir, deftDir);
394
395
  const resolveContent = seams.resolveContentRoot ?? resolveInstalledContentRoot;
395
- const copyContent = seams.copyContent ?? copyTree;
396
+ // #2913: default is full-tree replace (Go swapInCore parity), not additive copyTree.
397
+ // Injected seams.copyContent still wins (tests / specialized callers).
398
+ const copyContent = seams.copyContent ?? replaceTree;
396
399
  const readEngine = seams.readEngineVersion ?? readCorePackageVersion;
397
400
  const readPackageVersion = seams.readPackageVersion ?? readCorePackageVersion;
398
401
  const contentRoot = await resolveContent();
@@ -406,15 +409,19 @@ export async function runRefreshDeposit(args, io, seams = {}) {
406
409
  const strategy = alreadyCurrent ? "no-op" : "file-swap";
407
410
  if (alreadyCurrent) {
408
411
  io.printf("[deft update] Framework payload already current; skipping payload copy.\n");
412
+ // #2913: still fail-closed reconcile so dst-only leftovers cannot linger when
413
+ // VERSION already matches (e.g. pre-#2804 additive deposits). Does not re-stamp.
414
+ await reconcileDepositToContentPackage(deftDir, contentRoot, io);
409
415
  migrateLegacyInstallManifest(projectDir, join(deftDir, "VERSION"));
410
416
  }
411
417
  else {
418
+ // Full-tree replace (or injected seam). Additive copy is no longer the default.
412
419
  await copyContent(contentRoot, deftDir);
413
420
  await prunePythonArtifactsFromDeposit(deftDir, projectDir, io);
414
- // #2804 / #2347: prune deposit paths absent from the content package. The
415
- // additive file-swap never removes them, causing stale bridge-era files to
416
- // survive across upgrades until manually cleaned.
417
- await prunePackageAbsentDepositPaths(deftDir, contentRoot, io);
421
+ // #2913 / #2804 / #2347: fail-closed delete-not-in-source BEFORE VERSION stamp.
422
+ // replaceTree already drops dst-only paths; reconcile verifies and covers
423
+ // additive seams. Throws => no VERSION rewrite (refuse stamp until clean).
424
+ await reconcileDepositToContentPackage(deftDir, contentRoot, io);
418
425
  const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
419
426
  const manifestFields = {
420
427
  ref: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
@@ -586,6 +593,30 @@ export async function runRefreshDepositCli(options) {
586
593
  // Legacy layouts keep their existing refusal path (thrown by runRefreshDeposit
587
594
  // and handled below), so classification is skipped for them.
588
595
  const projectDir = resolve(options.projectDir);
596
+ // #2926: root opt-out wins over update/refresh and ambient force-on.
597
+ const optOut = detectNoDeftDirective(projectDir);
598
+ if (optOut.present) {
599
+ const message = optOut.inconsistent
600
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE
601
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE;
602
+ io.printf(`${message}\n`);
603
+ if (options.jsonOut) {
604
+ options.writeOut(`${JSON.stringify({
605
+ success: false,
606
+ action: "update",
607
+ disabled: true,
608
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
609
+ inconsistent: optOut.inconsistent,
610
+ ...(optOut.inconsistent
611
+ ? { inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY }
612
+ : {}),
613
+ deposit_present: optOut.depositPresent,
614
+ project_dir: projectDir,
615
+ message,
616
+ }, null, 2)}\n`);
617
+ }
618
+ return UPDATE_REFUSED_EXIT_CODE;
619
+ }
589
620
  const detectLegacy = options.seams?.detectLegacy ?? detectLegacyLayout;
590
621
  let classification = null;
591
622
  if (!detectLegacy(projectDir).legacy) {