@deftai/directive-core 0.84.0 → 0.85.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 (69) hide show
  1. package/dist/cache/fetch.d.ts +17 -0
  2. package/dist/cache/fetch.js +50 -0
  3. package/dist/doctor/checks.js +16 -1
  4. package/dist/doctor/constants.d.ts +2 -0
  5. package/dist/doctor/constants.js +8 -4
  6. package/dist/doctor/index.d.ts +1 -0
  7. package/dist/doctor/index.js +1 -0
  8. package/dist/doctor/main.js +13 -4
  9. package/dist/doctor/npm-registry.d.ts +23 -0
  10. package/dist/doctor/npm-registry.js +128 -0
  11. package/dist/doctor/npm-view.d.ts +10 -0
  12. package/dist/doctor/npm-view.js +31 -0
  13. package/dist/doctor/payload-staleness.js +2 -9
  14. package/dist/doctor/signpost-checks.d.ts +2 -1
  15. package/dist/doctor/signpost-checks.js +2 -0
  16. package/dist/doctor/types.d.ts +9 -0
  17. package/dist/eval-health-relocation/evaluate.js +15 -1
  18. package/dist/fs/projection-containment.js +1 -1
  19. package/dist/hooks/cursor-hooks.d.ts +6 -20
  20. package/dist/hooks/cursor-hooks.js +7 -92
  21. package/dist/init-deposit/agent-hooks.d.ts +2 -2
  22. package/dist/init-deposit/agent-hooks.js +15 -37
  23. package/dist/init-deposit/hygiene.d.ts +22 -0
  24. package/dist/init-deposit/hygiene.js +136 -4
  25. package/dist/init-deposit/legacy-detect.d.ts +25 -1
  26. package/dist/init-deposit/legacy-detect.js +143 -11
  27. package/dist/init-deposit/refresh.d.ts +2 -0
  28. package/dist/init-deposit/refresh.js +18 -5
  29. package/dist/policy/index.d.ts +2 -0
  30. package/dist/policy/index.js +2 -0
  31. package/dist/policy/org-force-on-migration.d.ts +44 -0
  32. package/dist/policy/org-force-on-migration.js +240 -0
  33. package/dist/policy/product-signal.d.ts +3 -1
  34. package/dist/policy/product-signal.js +15 -4
  35. package/dist/policy/value-feedback.d.ts +18 -1
  36. package/dist/policy/value-feedback.js +78 -5
  37. package/dist/preflight-cache/evaluate.js +12 -1
  38. package/dist/product-signal/consent-prompt.d.ts +13 -0
  39. package/dist/product-signal/consent-prompt.js +30 -0
  40. package/dist/product-signal/submit.js +22 -1
  41. package/dist/render/index.d.ts +1 -0
  42. package/dist/render/index.js +1 -0
  43. package/dist/render/rule-map-template.d.ts +2 -0
  44. package/dist/render/rule-map-template.js +407 -0
  45. package/dist/render/rule-map.d.ts +2 -0
  46. package/dist/render/rule-map.js +703 -0
  47. package/dist/review-monitor/constants.d.ts +7 -1
  48. package/dist/review-monitor/constants.js +40 -14
  49. package/dist/review-monitor/github-lease.d.ts +45 -0
  50. package/dist/review-monitor/github-lease.js +106 -0
  51. package/dist/review-monitor/index.d.ts +2 -0
  52. package/dist/review-monitor/index.js +2 -0
  53. package/dist/review-monitor/lease-comment.d.ts +36 -0
  54. package/dist/review-monitor/lease-comment.js +152 -0
  55. package/dist/review-monitor/record.d.ts +61 -13
  56. package/dist/review-monitor/record.js +379 -132
  57. package/dist/review-monitor/verify.d.ts +2 -0
  58. package/dist/review-monitor/verify.js +32 -13
  59. package/dist/scm/gh-rest.d.ts +7 -1
  60. package/dist/scm/gh-rest.js +35 -0
  61. package/dist/session/session-start.js +5 -0
  62. package/dist/task-surface/index.js +53 -13
  63. package/dist/xbrief-migrate/constants.d.ts +6 -0
  64. package/dist/xbrief-migrate/constants.js +6 -0
  65. package/dist/xbrief-migrate/index.d.ts +2 -2
  66. package/dist/xbrief-migrate/index.js +2 -2
  67. package/dist/xbrief-migrate/migrate-project.d.ts +7 -0
  68. package/dist/xbrief-migrate/migrate-project.js +18 -1
  69. package/package.json +3 -3
@@ -1,95 +1,10 @@
1
- import { DIRECT_WRITE_TOOL_NAMES } from "./tools.js";
2
- /** Cursor ApplyPatch spellings handled by the project adapter, not generic write dispatch. */
1
+ import { DIRECT_WRITE_HOOK_MATCHER } from "./tools.js";
2
+ /** Cursor's ApplyPatch payload is parsed directly by the shared hook executable. */
3
3
  export const APPLY_PATCH_TOOL_NAMES = ["ApplyPatch", "apply_patch"];
4
4
  export const APPLY_PATCH_HOOK_MATCHER = APPLY_PATCH_TOOL_NAMES.join("|");
5
- const APPLY_PATCH_TOOLS = new Set(APPLY_PATCH_TOOL_NAMES);
6
- /** Generic Cursor write matcher excludes ApplyPatch adapter owns it (#2764). */
7
- export const CURSOR_GENERIC_WRITE_TOOL_NAMES = DIRECT_WRITE_TOOL_NAMES.filter((name) => !APPLY_PATCH_TOOLS.has(name));
8
- export const CURSOR_GENERIC_WRITE_HOOK_MATCHER = CURSOR_GENERIC_WRITE_TOOL_NAMES.join("|");
9
- export const CURSOR_APPLY_PATCH_ADAPTER_RELATIVE = ".cursor/hooks/deft-cursor-hook-adapter.mjs";
10
- export const DEFT_CURSOR_ADAPTER_COMMAND_MARKER = "deft-cursor-hook-adapter.mjs";
11
- export const CURSOR_APPLY_PATCH_ADAPTER_COMMAND = `node ${CURSOR_APPLY_PATCH_ADAPTER_RELATIVE} ApplyPatch`;
12
- /** Deposited adapter forwards free-form ApplyPatch stdin to hook:dispatch with explicit project root. */
13
- export const CURSOR_APPLY_PATCH_ADAPTER_SOURCE = `#!/usr/bin/env node
14
- import { spawnSync } from "node:child_process";
15
- import { readFileSync } from "node:fs";
16
- import { resolve } from "node:path";
17
-
18
- const projectRoot = resolve(process.cwd());
19
- const stdin = readFileSync(0, "utf8");
20
-
21
- function deftCommand() {
22
- for (const candidate of ["deft", "directive"]) {
23
- const probe = spawnSync(candidate, ["--version"], {
24
- encoding: "utf8",
25
- stdio: "ignore",
26
- shell: process.platform === "win32",
27
- windowsHide: true,
28
- });
29
- if (probe.error === undefined && probe.status === 0) return candidate;
30
- }
31
- process.stderr.write(
32
- "Directive ApplyPatch adapter: neither deft nor directive is on PATH.\\n",
33
- );
34
- process.exit(2);
35
- }
36
-
37
- const cli = deftCommand();
38
- const result = spawnSync(
39
- cli,
40
- [
41
- "hook:dispatch",
42
- "--host",
43
- "cursor",
44
- "--event",
45
- "tool.before",
46
- "--project-root",
47
- projectRoot,
48
- ],
49
- {
50
- input: stdin,
51
- encoding: "utf8",
52
- stdio: ["pipe", "pipe", "pipe"],
53
- shell: process.platform === "win32",
54
- windowsHide: true,
55
- },
56
- );
57
-
58
- if (result.stderr) process.stderr.write(result.stderr);
59
- if (result.error) {
60
- process.stderr.write(String(result.error));
61
- process.exit(2);
62
- }
63
- if (result.status !== 0 && result.status !== null) {
64
- if (result.stdout) process.stdout.write(result.stdout);
65
- process.exit(result.status);
66
- }
67
- // Cursor failClosed treats empty stdout as failure — normalize allow.
68
- const out = (result.stdout ?? "").trim();
69
- process.stdout.write((out.length > 0 ? out : '{"permission":"allow"}') + "\\n");
70
- process.exit(0);
71
- `;
72
- export function cursorApplyPatchAdapterEntry() {
73
- return {
74
- command: CURSOR_APPLY_PATCH_ADAPTER_COMMAND,
75
- matcher: APPLY_PATCH_HOOK_MATCHER,
76
- failClosed: true,
77
- timeout: 5,
78
- };
79
- }
80
- /** True when generic and adapter matchers share no tool tokens. */
81
- export function cursorApplyPatchMatchersDisjoint() {
82
- const generic = new Set(CURSOR_GENERIC_WRITE_HOOK_MATCHER.split("|"));
83
- for (const token of APPLY_PATCH_HOOK_MATCHER.split("|")) {
84
- if (generic.has(token))
85
- return false;
86
- }
87
- return true;
88
- }
89
- /** Fail closed when Cursor hook projection would double-dispatch ApplyPatch (#2764). */
90
- export function assertCursorApplyPatchMatchersDisjoint() {
91
- if (!cursorApplyPatchMatchersDisjoint()) {
92
- throw new Error("Cursor ApplyPatch and generic direct-write matchers overlap — refusing hook deposit (#2764).");
93
- }
94
- }
5
+ /**
6
+ * Cursor uses one disjoint direct-write registration: hook-dispatch recognizes
7
+ * JSON and free-form ApplyPatch payloads, avoiding an adapter process (#2790).
8
+ */
9
+ export const CURSOR_DIRECT_WRITE_HOOK_MATCHER = DIRECT_WRITE_HOOK_MATCHER;
95
10
  //# sourceMappingURL=cursor-hooks.js.map
@@ -1,9 +1,9 @@
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 { CURSOR_GENERIC_WRITE_HOOK_MATCHER, DEFT_CURSOR_ADAPTER_COMMAND_MARKER, } from "../hooks/cursor-hooks.js";
5
4
  export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
6
- export declare const DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
5
+ export declare const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
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"];
8
8
  export type AgentHookPath = (typeof AGENT_HOOK_PATHS)[number];
9
9
  export type AgentHookRegistrationStatus = "healthy" | "missing" | "drifted";
@@ -1,12 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { assertDepositContained } from "../deposit/contain.js";
4
- import { assertCursorApplyPatchMatchersDisjoint, CURSOR_APPLY_PATCH_ADAPTER_RELATIVE, CURSOR_APPLY_PATCH_ADAPTER_SOURCE, CURSOR_GENERIC_WRITE_HOOK_MATCHER, cursorApplyPatchAdapterEntry, DEFT_CURSOR_ADAPTER_COMMAND_MARKER, } from "../hooks/cursor-hooks.js";
5
4
  import { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
6
5
  import { isHostHookDepositEnabled, loadHostHooksPolicyFromProject, } from "../policy/host-hooks.js";
7
- export { CURSOR_GENERIC_WRITE_HOOK_MATCHER, DEFT_CURSOR_ADAPTER_COMMAND_MARKER, } from "../hooks/cursor-hooks.js";
8
6
  export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
9
- export const DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
7
+ export const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
8
+ export const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
10
9
  export const AGENT_HOOK_PATHS = [
11
10
  ".claude/settings.json",
12
11
  ".grok/hooks/deft.json",
@@ -64,17 +63,21 @@ function nestedCommands(value) {
64
63
  });
65
64
  }
66
65
  function isManagedNestedGroup(value) {
67
- return nestedCommands(value).some((value) => value.includes(DEFT_HOOK_COMMAND_MARKER));
66
+ return nestedCommands(value).some((command) => command.includes(DEFT_HOOK_COMMAND_MARKER) ||
67
+ command.includes(LEGACY_DEFT_HOOK_COMMAND_MARKER));
68
68
  }
69
69
  function isManagedNestedGroupForHost(value, host) {
70
- return nestedCommands(value).some((command) => command.includes(DEFT_HOOK_COMMAND_MARKER) && command.includes(`--host ${host}`));
70
+ return nestedCommands(value).some((command) => (command.includes(DEFT_HOOK_COMMAND_MARKER) ||
71
+ command.includes(LEGACY_DEFT_HOOK_COMMAND_MARKER)) &&
72
+ command.includes(`--host ${host}`));
71
73
  }
72
74
  function isManagedCursorEntry(value) {
73
75
  const entry = object(value);
74
76
  if (typeof entry?.command !== "string")
75
77
  return false;
76
78
  return (entry.command.includes(DEFT_HOOK_COMMAND_MARKER) ||
77
- entry.command.includes(DEFT_CURSOR_ADAPTER_COMMAND_MARKER));
79
+ entry.command.includes(LEGACY_DEFT_HOOK_COMMAND_MARKER) ||
80
+ entry.command.includes("deft-cursor-hook-adapter.mjs"));
78
81
  }
79
82
  function isManagedCursorEntryForHost(value) {
80
83
  return isManagedCursorEntry(value);
@@ -142,7 +145,6 @@ function stripManagedCursorConfig(config, path) {
142
145
  return { ...config, version: 1, hooks: nextHooks };
143
146
  }
144
147
  function mergeCursorConfig(config, path) {
145
- assertCursorApplyPatchMatchersDisjoint();
146
148
  const hooks = hooksObject(config, path);
147
149
  const session = eventArray(hooks, "sessionStart", path).filter((entry) => !isManagedCursorEntry(entry));
148
150
  const preTool = eventArray(hooks, "preToolUse", path).filter((entry) => !isManagedCursorEntry(entry));
@@ -150,10 +152,9 @@ function mergeCursorConfig(config, path) {
150
152
  hooks.sessionStart = [...session, { command: command("cursor", "session.start"), timeout: 5 }];
151
153
  hooks.preToolUse = [
152
154
  ...preTool,
153
- cursorApplyPatchAdapterEntry(),
154
155
  {
155
156
  command: command("cursor", "tool.before"),
156
- matcher: CURSOR_GENERIC_WRITE_HOOK_MATCHER,
157
+ matcher: DIRECT_WRITE_HOOK_MATCHER,
157
158
  failClosed: true,
158
159
  timeout: 5,
159
160
  },
@@ -167,15 +168,6 @@ function mergeCursorConfig(config, path) {
167
168
  hooks.preCompact = [...preCompact, { command: command("cursor", "session.compact"), timeout: 5 }];
168
169
  return { ...config, version: 1, hooks };
169
170
  }
170
- function writeTextIfChanged(path, contents) {
171
- if (existsSync(path) && readFileSync(path, "utf8") === contents)
172
- return false;
173
- mkdirSync(dirname(path), { recursive: true });
174
- const temporary = `${path}.deft-${process.pid}.tmp`;
175
- writeFileSync(temporary, contents, "utf8");
176
- renameSync(temporary, path);
177
- return true;
178
- }
179
171
  function writeJsonIfChanged(path, payload) {
180
172
  const next = `${JSON.stringify(payload, null, 2)}\n`;
181
173
  if (existsSync(path) && readFileSync(path, "utf8") === next)
@@ -246,19 +238,12 @@ export function writeAgentHookDeposit(projectRoot, io = { printf: () => undefine
246
238
  changedPaths.push(item.path);
247
239
  }
248
240
  }
249
- const adapterAbsolute = join(projectRoot, CURSOR_APPLY_PATCH_ADAPTER_RELATIVE);
241
+ const adapterAbsolute = join(projectRoot, ".cursor/hooks/deft-cursor-hook-adapter.mjs");
250
242
  assertDepositContained(projectRoot, adapterAbsolute);
251
- if (isHostHookDepositEnabled("cursor", hostHooksPolicy)) {
252
- if (writeTextIfChanged(adapterAbsolute, CURSOR_APPLY_PATCH_ADAPTER_SOURCE)) {
253
- if (!changedPaths.includes(AGENT_HOOK_PATHS[2])) {
254
- changedPaths.push(AGENT_HOOK_PATHS[2]);
255
- }
256
- }
257
- }
258
- else if (existsSync(adapterAbsolute)) {
243
+ if (existsSync(adapterAbsolute)) {
259
244
  rmSync(adapterAbsolute, { force: true });
260
- if (!strippedPaths.includes(AGENT_HOOK_PATHS[2])) {
261
- strippedPaths.push(AGENT_HOOK_PATHS[2]);
245
+ if (!changedPaths.includes(AGENT_HOOK_PATHS[2])) {
246
+ changedPaths.push(AGENT_HOOK_PATHS[2]);
262
247
  }
263
248
  }
264
249
  if (changedPaths.length > 0) {
@@ -309,18 +294,11 @@ function hasCursorRegistration(config) {
309
294
  const session = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : [];
310
295
  const preTool = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
311
296
  const preCompact = Array.isArray(hooks.preCompact) ? hooks.preCompact : [];
312
- const adapter = cursorApplyPatchAdapterEntry();
313
297
  return (session.some((entry) => object(entry)?.command === command("cursor", "session.start")) &&
314
298
  preTool.some((entry) => {
315
299
  const hook = object(entry);
316
300
  return (hook?.command === command("cursor", "tool.before") &&
317
- hook.matcher === CURSOR_GENERIC_WRITE_HOOK_MATCHER &&
318
- hook.failClosed === true);
319
- }) &&
320
- preTool.some((entry) => {
321
- const hook = object(entry);
322
- return (hook?.command === adapter.command &&
323
- hook.matcher === adapter.matcher &&
301
+ hook.matcher === DIRECT_WRITE_HOOK_MATCHER &&
324
302
  hook.failClosed === true);
325
303
  }) &&
326
304
  preTool.some((entry) => {
@@ -57,6 +57,28 @@ export declare function stageFrameworkPaths(projectDir: string, paths: readonly
57
57
  * Refs #2347.
58
58
  */
59
59
  export declare const STRAY_DEPOSIT_FRAMEWORK_PATHS: readonly ["packages"];
60
+ /**
61
+ * Deposit-local files generated by init/update that are intentionally absent
62
+ * from `@deftai/directive-content` (#2804).
63
+ */
64
+ export declare const DEPOSIT_GENERATED_METADATA_PATHS: readonly ["VERSION"];
65
+ export declare function isDepositGeneratedMetadata(relPath: string): boolean;
66
+ /**
67
+ * List deposit-relative paths that are absent from the installed content package,
68
+ * excluding generated deposit metadata such as `VERSION` (#2804).
69
+ */
70
+ export declare function findPackageAbsentDepositPathsSync(deftDir: string, contentRoot: string): string[];
71
+ export declare function findPackageAbsentDepositPaths(deftDir: string, contentRoot: string): Promise<string[]>;
72
+ export interface PrunePackageAbsentDepositPathsResult {
73
+ readonly pruned: string[];
74
+ readonly prunedDirs: string[];
75
+ }
76
+ /**
77
+ * Remove deposit files not shipped by `@deftai/directive-content`, preserving
78
+ * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
79
+ * hard-coded `packages/` prune (#2347).
80
+ */
81
+ export declare function prunePackageAbsentDepositPaths(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
60
82
  export interface PruneStrayDepositPathsResult {
61
83
  readonly pruned: string[];
62
84
  }
@@ -5,9 +5,9 @@
5
5
  * Refs #1576, #1453, #1430.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
9
- import { rm, stat } from "node:fs/promises";
10
- import { join, relative } from "node:path";
8
+ import { existsSync, readdirSync, rmSync } from "node:fs";
9
+ import { readdir, rm, stat } from "node:fs/promises";
10
+ import { dirname, join, relative } from "node:path";
11
11
  import { gitPorcelain } from "../story-ready/git.js";
12
12
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
13
13
  export const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
@@ -24,7 +24,6 @@ export function installerManagedMatchers() {
24
24
  { exact: ".claude/settings.json" },
25
25
  { exact: ".grok/hooks/deft.json" },
26
26
  { exact: ".cursor/hooks.json" },
27
- { exact: ".cursor/hooks/deft-cursor-hook-adapter.mjs" },
28
27
  { exact: ".codex/hooks.json" },
29
28
  { exact: ".gitattributes" },
30
29
  { exact: ".gitignore" },
@@ -146,6 +145,139 @@ export function stageFrameworkPaths(projectDir, paths, seams = {}) {
146
145
  * Refs #2347.
147
146
  */
148
147
  export const STRAY_DEPOSIT_FRAMEWORK_PATHS = ["packages"];
148
+ /**
149
+ * Deposit-local files generated by init/update that are intentionally absent
150
+ * from `@deftai/directive-content` (#2804).
151
+ */
152
+ export const DEPOSIT_GENERATED_METADATA_PATHS = ["VERSION"];
153
+ export function isDepositGeneratedMetadata(relPath) {
154
+ const normalized = relPath.replace(/\\/g, "/");
155
+ return DEPOSIT_GENERATED_METADATA_PATHS.includes(normalized);
156
+ }
157
+ function normalizeRelativePath(relPath) {
158
+ return relPath.replace(/\\/g, "/");
159
+ }
160
+ function listRelativeFilePathsSync(root) {
161
+ if (!existsSync(root))
162
+ return [];
163
+ const results = [];
164
+ const walk = (dir, prefix) => {
165
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
166
+ const rel = normalizeRelativePath(prefix ? `${prefix}/${entry.name}` : entry.name);
167
+ const abs = join(dir, entry.name);
168
+ if (entry.isSymbolicLink())
169
+ continue;
170
+ if (entry.isDirectory()) {
171
+ walk(abs, rel);
172
+ }
173
+ else if (entry.isFile()) {
174
+ results.push(rel);
175
+ }
176
+ }
177
+ };
178
+ walk(root, "");
179
+ return results;
180
+ }
181
+ async function listRelativeFilePaths(root) {
182
+ if (!existsSync(root))
183
+ return [];
184
+ const results = [];
185
+ const walk = async (dir, prefix) => {
186
+ const entries = await readdir(dir, { withFileTypes: true });
187
+ for (const entry of entries) {
188
+ const rel = normalizeRelativePath(prefix ? `${prefix}/${entry.name}` : entry.name);
189
+ const abs = join(dir, entry.name);
190
+ if (entry.isSymbolicLink())
191
+ continue;
192
+ if (entry.isDirectory()) {
193
+ await walk(abs, rel);
194
+ }
195
+ else if (entry.isFile()) {
196
+ results.push(rel);
197
+ }
198
+ }
199
+ };
200
+ await walk(root, "");
201
+ return results;
202
+ }
203
+ function contentDirectoryPaths(contentFiles) {
204
+ const dirs = new Set();
205
+ for (const file of contentFiles) {
206
+ let dir = dirname(file);
207
+ while (dir && dir !== ".") {
208
+ dirs.add(normalizeRelativePath(dir));
209
+ const parent = dirname(dir);
210
+ if (parent === dir)
211
+ break;
212
+ dir = parent;
213
+ }
214
+ }
215
+ return dirs;
216
+ }
217
+ /**
218
+ * List deposit-relative paths that are absent from the installed content package,
219
+ * excluding generated deposit metadata such as `VERSION` (#2804).
220
+ */
221
+ export function findPackageAbsentDepositPathsSync(deftDir, contentRoot) {
222
+ const depositFiles = listRelativeFilePathsSync(deftDir);
223
+ const contentFiles = new Set(listRelativeFilePathsSync(contentRoot));
224
+ return depositFiles
225
+ .filter((rel) => !contentFiles.has(rel) && !isDepositGeneratedMetadata(rel))
226
+ .sort();
227
+ }
228
+ export async function findPackageAbsentDepositPaths(deftDir, contentRoot) {
229
+ const depositFiles = await listRelativeFilePaths(deftDir);
230
+ const contentFiles = new Set(await listRelativeFilePaths(contentRoot));
231
+ return depositFiles
232
+ .filter((rel) => !contentFiles.has(rel) && !isDepositGeneratedMetadata(rel))
233
+ .sort();
234
+ }
235
+ async function pruneEmptyParentsForFile(deftDir, contentDirs, relFile) {
236
+ const prunedDirs = [];
237
+ let rel = dirname(relFile);
238
+ while (rel && rel !== ".") {
239
+ const normalized = normalizeRelativePath(rel);
240
+ if (contentDirs.has(normalized))
241
+ break;
242
+ const abs = join(deftDir, rel);
243
+ try {
244
+ if (!existsSync(abs) || readdirSync(abs).length !== 0)
245
+ break;
246
+ rmSync(abs, { recursive: false, force: true });
247
+ prunedDirs.push(normalized);
248
+ }
249
+ catch {
250
+ break;
251
+ }
252
+ rel = dirname(rel);
253
+ }
254
+ return prunedDirs;
255
+ }
256
+ /**
257
+ * Remove deposit files not shipped by `@deftai/directive-content`, preserving
258
+ * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
259
+ * hard-coded `packages/` prune (#2347).
260
+ */
261
+ export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
262
+ const absent = await findPackageAbsentDepositPaths(deftDir, contentRoot);
263
+ const contentDirs = contentDirectoryPaths(listRelativeFilePathsSync(contentRoot));
264
+ const pruned = [];
265
+ const prunedDirs = [];
266
+ for (const rel of absent) {
267
+ try {
268
+ rmSync(join(deftDir, rel), { force: true });
269
+ pruned.push(rel);
270
+ prunedDirs.push(...(await pruneEmptyParentsForFile(deftDir, contentDirs, rel)));
271
+ }
272
+ catch (cause) {
273
+ io.printf(`Warning: could not prune .deft/core/${rel}: ${String(cause)}\n`);
274
+ }
275
+ }
276
+ if (pruned.length > 0) {
277
+ io.printf(`Pruned ${pruned.length} package-absent deposit file(s) not shipped by @deftai/directive-content (#2804).\n`);
278
+ }
279
+ return { pruned, prunedDirs };
280
+ }
149
281
  /**
150
282
  * Remove framework-source subdirectories from `.deft/core/` that are not
151
283
  * present in the deposited content package (#2347). Silently skips any path
@@ -25,7 +25,7 @@
25
25
  */
26
26
  /** Non-zero exit code for a use-time legacy-layout refusal (needs-action). */
27
27
  export declare const LEGACY_LAYOUT_REFUSED_EXIT_CODE = 2;
28
- export type LegacyLayoutKind = "orphan-deft-version" | "legacy-deft-prefixed" | "git-clone-or-submodule" | "pre-v0.27-sentinel-agents-md";
28
+ export type LegacyLayoutKind = "orphan-deft-version" | "legacy-deft-prefixed" | "git-clone-or-submodule" | "pre-v0.27-sentinel-agents-md" | "dual-layout";
29
29
  export interface LegacyLayoutDetection {
30
30
  readonly legacy: boolean;
31
31
  readonly kind: LegacyLayoutKind | null;
@@ -37,6 +37,12 @@ export interface LegacyDetectSeams {
37
37
  readonly isDir?: (p: string) => boolean;
38
38
  readonly readText?: (p: string) => string | null;
39
39
  }
40
+ /**
41
+ * Detect a post-bridge dual-layout: canonical `.deft/core/` plus a leftover
42
+ * framework-marked `deft/` tree. Doctor-only -- the npm deposit path treats
43
+ * `.deft/core/` as canonical and must not refuse here (#2805 / #2804 wiring).
44
+ */
45
+ export declare function detectDualLayout(projectDir: string, seams?: LegacyDetectSeams): LegacyLayoutDetection | null;
40
46
  /**
41
47
  * Classify the on-disk layout at `projectDir`. Returns `legacy: false` for the
42
48
  * canonical `.deft/core/` vendored layout and for a greenfield project (so init
@@ -58,4 +64,22 @@ export declare function buildLegacyRefusalMessage(command: "init" | "update", de
58
64
  export declare function buildLegacyRefusalJson(command: "init" | "update", projectDir: string, detection: LegacyLayoutDetection): Record<string, unknown>;
59
65
  /** One-line doctor signpost carrying the stable URL (no baked version/command). */
60
66
  export declare function legacyLayoutSignpostLine(detection: LegacyLayoutDetection): string;
67
+ /** Doctor / npm signpost for the post-bridge dual-layout state (#2805). */
68
+ export declare function dualLayoutSignpostLine(detection: LegacyLayoutDetection): string;
69
+ export interface LegacyDeftCleanupSeams {
70
+ readonly gitPorcelain?: (projectRoot: string) => string | null;
71
+ readonly runGitRm?: (projectDir: string, relPath: string) => void;
72
+ readonly removeDir?: (absPath: string) => void;
73
+ }
74
+ export type LegacyDeftCleanupAction = "removed" | "staged" | "refused" | "skipped";
75
+ export interface LegacyDeftCleanupResult {
76
+ readonly ok: boolean;
77
+ readonly action: LegacyDeftCleanupAction;
78
+ readonly detail: string;
79
+ }
80
+ /**
81
+ * Remove a clean tracked legacy `deft/` tree after bridge migration (#2805).
82
+ * Refuses when the tree has local modifications or untracked files.
83
+ */
84
+ export declare function tryCleanupLegacyDeftTree(projectDir: string, seams?: LegacyDeftCleanupSeams): LegacyDeftCleanupResult;
61
85
  //# sourceMappingURL=legacy-detect.d.ts.map
@@ -23,11 +23,13 @@
23
23
  *
24
24
  * Refs #1912, #1669.
25
25
  */
26
- import { statSync } from "node:fs";
26
+ import { execFileSync } from "node:child_process";
27
+ import { rmSync, statSync } from "node:fs";
27
28
  import { join } from "node:path";
28
29
  import { GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL } from "../doctor/constants.js";
29
30
  import { extractManagedSection, parseInstallRootFromAgentsMd } from "../doctor/manifest.js";
30
31
  import { readTextSafe } from "../doctor/paths.js";
32
+ import { gitPorcelain } from "../story-ready/git.js";
31
33
  /** Non-zero exit code for a use-time legacy-layout refusal (needs-action). */
32
34
  export const LEGACY_LAYOUT_REFUSED_EXIT_CODE = 2;
33
35
  const NOT_LEGACY = {
@@ -53,6 +55,16 @@ function defaultIsDir(p) {
53
55
  }
54
56
  }
55
57
  /** Does `.gitmodules` reference the deft framework at a deft install path? */
58
+ const LEGACY_DEFT_DIR = "deft";
59
+ function isFrameworkDeftDir(projectDir, isDir, isFile) {
60
+ const deftDir = join(projectDir, LEGACY_DEFT_DIR);
61
+ const deftMarkers = [
62
+ join(deftDir, "VERSION"),
63
+ join(deftDir, "main.md"),
64
+ join(deftDir, "Taskfile.yml"),
65
+ ];
66
+ return isDir(deftDir) && (deftMarkers.some((m) => isFile(m)) || isDir(join(deftDir, "skills")));
67
+ }
56
68
  function gitmodulesReferencesFramework(text) {
57
69
  const normalized = text.replace(/\r\n/g, "\n").toLowerCase();
58
70
  if (normalized.includes("deftai/directive")) {
@@ -62,6 +74,27 @@ function gitmodulesReferencesFramework(text) {
62
74
  // when the url host differs (mirror, fork). Match `path = deft` / `path = .deft`.
63
75
  return /(^|\n)\s*path\s*=\s*\.?deft(\/|\s|$)/.test(normalized);
64
76
  }
77
+ const DUAL_LAYOUT_DETAIL = "Found both the canonical .deft/core/ deposit and a leftover legacy deft/ " +
78
+ "framework tree -- remove deft/ so .deft/core/ is the sole framework root.";
79
+ /**
80
+ * Detect a post-bridge dual-layout: canonical `.deft/core/` plus a leftover
81
+ * framework-marked `deft/` tree. Doctor-only -- the npm deposit path treats
82
+ * `.deft/core/` as canonical and must not refuse here (#2805 / #2804 wiring).
83
+ */
84
+ export function detectDualLayout(projectDir, seams = {}) {
85
+ const isFile = seams.isFile ?? defaultIsFile;
86
+ const isDir = seams.isDir ?? defaultIsDir;
87
+ const hasCanonicalCore = isDir(join(projectDir, ".deft", "core"));
88
+ if (!hasCanonicalCore || !isFrameworkDeftDir(projectDir, isDir, isFile)) {
89
+ return null;
90
+ }
91
+ return {
92
+ legacy: true,
93
+ kind: "dual-layout",
94
+ detail: DUAL_LAYOUT_DETAIL,
95
+ evidence: [".deft/core/", "deft/"],
96
+ };
97
+ }
65
98
  /**
66
99
  * Classify the on-disk layout at `projectDir`. Returns `legacy: false` for the
67
100
  * canonical `.deft/core/` vendored layout and for a greenfield project (so init
@@ -71,10 +104,11 @@ export function detectLegacyLayout(projectDir, seams = {}) {
71
104
  const isFile = seams.isFile ?? defaultIsFile;
72
105
  const isDir = seams.isDir ?? defaultIsDir;
73
106
  const readText = seams.readText ?? readTextSafe;
107
+ const hasCanonicalCore = isDir(join(projectDir, ".deft", "core"));
108
+ const deftDirIsFramework = isFrameworkDeftDir(projectDir, isDir, isFile);
74
109
  // Canonical vendored layout present -> the npm path owns it; not legacy. Any
75
- // AGENTS.md / manifest drift here is repaired by the healthy refresh path,
76
- // not by refusing.
77
- if (isDir(join(projectDir, ".deft", "core"))) {
110
+ // leftover deft/ tree is a dual-layout hygiene issue surfaced by Doctor only.
111
+ if (hasCanonicalCore) {
78
112
  return NOT_LEGACY;
79
113
  }
80
114
  // Orphan `.deft/VERSION` (the old pre-`.deft/core/` manifest location) with no
@@ -92,13 +126,7 @@ export function detectLegacyLayout(projectDir, seams = {}) {
92
126
  // framework markers. Distinguish a git-clone / submodule deposit (a `.git`
93
127
  // inside `deft/`, or a `.gitmodules` pointing at the framework) from a plain
94
128
  // legacy-prefixed vendored copy.
95
- const deftDir = join(projectDir, "deft");
96
- const deftMarkers = [
97
- join(deftDir, "VERSION"),
98
- join(deftDir, "main.md"),
99
- join(deftDir, "Taskfile.yml"),
100
- ];
101
- const deftDirIsFramework = isDir(deftDir) && (deftMarkers.some((m) => isFile(m)) || isDir(join(deftDir, "skills")));
129
+ const deftDir = join(projectDir, LEGACY_DEFT_DIR);
102
130
  if (deftDirIsFramework) {
103
131
  const submoduleGit = isFile(join(deftDir, ".git")) || isDir(join(deftDir, ".git"));
104
132
  if (submoduleGit) {
@@ -215,9 +243,113 @@ export function buildLegacyRefusalJson(command, projectDir, detection) {
215
243
  }
216
244
  /** One-line doctor signpost carrying the stable URL (no baked version/command). */
217
245
  export function legacyLayoutSignpostLine(detection) {
246
+ if (detection.kind === "dual-layout") {
247
+ return dualLayoutSignpostLine(detection);
248
+ }
218
249
  return (`Legacy Deft layout detected (${detection.kind ?? "unknown"}): ${detection.detail} ` +
219
250
  "Run the frozen Go bridge installer to migrate to .deft/core/, then use the npm " +
220
251
  `CLI (\`npx @deftai/directive update\`). See ${UPGRADING_DOC_URL} ` +
221
252
  `(frozen bridge: ${GO_BRIDGE_RELEASES_URL}).`);
222
253
  }
254
+ /** Doctor / npm signpost for the post-bridge dual-layout state (#2805). */
255
+ export function dualLayoutSignpostLine(detection) {
256
+ return (`Dual Deft layout detected (${detection.kind ?? "dual-layout"}): ${detection.detail} ` +
257
+ "Remove the legacy deft/ tree so .deft/core/ is the sole framework root " +
258
+ "(when clean: `git rm -r deft/`; reconcile local edits first if `git status deft/` is dirty). " +
259
+ `See ${UPGRADING_DOC_URL}.`);
260
+ }
261
+ function porcelainEntryPath(line) {
262
+ let path = line.slice(3).trim().replace(/\\/g, "/");
263
+ if (path.startsWith('"') && path.endsWith('"')) {
264
+ path = path.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
265
+ }
266
+ return path;
267
+ }
268
+ function porcelainTouchesLegacyDeft(porcelain) {
269
+ const touched = [];
270
+ for (const line of porcelain.split("\n")) {
271
+ if (!line.trim())
272
+ continue;
273
+ const path = porcelainEntryPath(line);
274
+ if (path === LEGACY_DEFT_DIR || path.startsWith(`${LEGACY_DEFT_DIR}/`)) {
275
+ touched.push(path);
276
+ }
277
+ }
278
+ return touched;
279
+ }
280
+ /**
281
+ * Remove a clean tracked legacy `deft/` tree after bridge migration (#2805).
282
+ * Refuses when the tree has local modifications or untracked files.
283
+ */
284
+ export function tryCleanupLegacyDeftTree(projectDir, seams = {}) {
285
+ const detection = detectDualLayout(projectDir);
286
+ if (detection === null) {
287
+ return {
288
+ ok: true,
289
+ action: "skipped",
290
+ detail: "No dual-layout legacy deft/ tree to remove.",
291
+ };
292
+ }
293
+ const readPorcelain = seams.gitPorcelain ?? gitPorcelain;
294
+ const porcelain = readPorcelain(projectDir);
295
+ if (porcelain === null) {
296
+ return {
297
+ ok: false,
298
+ action: "refused",
299
+ detail: "Cannot determine whether the legacy deft/ tree is clean (git unavailable). " +
300
+ "Inspect `git status deft/` and remove the tree manually once clean.",
301
+ };
302
+ }
303
+ const dirtyPaths = porcelainTouchesLegacyDeft(porcelain);
304
+ if (dirtyPaths.length > 0) {
305
+ return {
306
+ ok: false,
307
+ action: "refused",
308
+ detail: "Refusing to remove the legacy deft/ tree because it has local modifications or " +
309
+ "untracked files. Reconcile or commit those changes, then re-run " +
310
+ "`npx @deftai/directive update`, or remove deft/ manually once clean. " +
311
+ `Dirty paths: ${JSON.stringify(dirtyPaths.slice(0, 5))}` +
312
+ (dirtyPaths.length > 5 ? ", …" : "") +
313
+ ".",
314
+ };
315
+ }
316
+ const deftDir = join(projectDir, LEGACY_DEFT_DIR);
317
+ const removeDir = seams.removeDir ?? ((p) => rmSync(p, { recursive: true, force: true }));
318
+ const runGitRm = seams.runGitRm ??
319
+ ((root, relPath) => {
320
+ execFileSync("git", ["rm", "-r", "--ignore-unmatch", relPath], {
321
+ cwd: root,
322
+ encoding: "utf8",
323
+ stdio: ["ignore", "pipe", "pipe"],
324
+ });
325
+ });
326
+ try {
327
+ runGitRm(projectDir, LEGACY_DEFT_DIR);
328
+ removeDir(deftDir);
329
+ return {
330
+ ok: true,
331
+ action: "staged",
332
+ detail: "Removed the legacy deft/ framework tree and staged its deletion; " +
333
+ ".deft/core/ is now the sole framework root.",
334
+ };
335
+ }
336
+ catch {
337
+ try {
338
+ removeDir(deftDir);
339
+ return {
340
+ ok: true,
341
+ action: "removed",
342
+ detail: "Removed the legacy deft/ framework tree from disk; stage the deletion with " +
343
+ "`git add -u deft/` if it was tracked.",
344
+ };
345
+ }
346
+ catch (cause) {
347
+ return {
348
+ ok: false,
349
+ action: "refused",
350
+ detail: `Could not remove the legacy deft/ tree: ${String(cause)}`,
351
+ };
352
+ }
353
+ }
354
+ }
223
355
  //# sourceMappingURL=legacy-detect.js.map