@deftai/directive-core 0.68.1 → 0.69.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 (58) hide show
  1. package/dist/doctor/constants.d.ts +9 -0
  2. package/dist/doctor/constants.js +11 -0
  3. package/dist/doctor/main.d.ts +47 -1
  4. package/dist/doctor/main.js +352 -2
  5. package/dist/doctor/payload-staleness.d.ts +8 -0
  6. package/dist/doctor/payload-staleness.js +11 -8
  7. package/dist/doctor/types.d.ts +48 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/init-deposit/gitignore.d.ts +18 -0
  11. package/dist/init-deposit/gitignore.js +94 -15
  12. package/dist/init-deposit/headless-manifest.d.ts +87 -0
  13. package/dist/init-deposit/headless-manifest.js +261 -0
  14. package/dist/init-deposit/index.d.ts +3 -0
  15. package/dist/init-deposit/index.js +3 -0
  16. package/dist/init-deposit/init-dispatch.d.ts +90 -0
  17. package/dist/init-deposit/init-dispatch.js +170 -0
  18. package/dist/init-deposit/refresh.d.ts +79 -2
  19. package/dist/init-deposit/refresh.js +177 -5
  20. package/dist/init-deposit/scaffold.d.ts +25 -0
  21. package/dist/init-deposit/scaffold.js +57 -0
  22. package/dist/init-deposit/untrack-core.d.ts +84 -0
  23. package/dist/init-deposit/untrack-core.js +150 -0
  24. package/dist/resolution/classify.d.ts +41 -0
  25. package/dist/resolution/classify.js +138 -0
  26. package/dist/resolution/engine-ladder.d.ts +98 -0
  27. package/dist/resolution/engine-ladder.js +185 -0
  28. package/dist/resolution/index.d.ts +19 -0
  29. package/dist/resolution/index.js +19 -0
  30. package/dist/resolution/integrity.d.ts +48 -0
  31. package/dist/resolution/integrity.js +80 -0
  32. package/dist/resolution/package-manager.d.ts +77 -0
  33. package/dist/resolution/package-manager.js +103 -0
  34. package/dist/resolution/pin.d.ts +73 -0
  35. package/dist/resolution/pin.js +169 -0
  36. package/dist/resolution/plan.d.ts +66 -0
  37. package/dist/resolution/plan.js +219 -0
  38. package/dist/resolution/skew-policy.d.ts +46 -0
  39. package/dist/resolution/skew-policy.js +120 -0
  40. package/dist/session/session-start.d.ts +2 -0
  41. package/dist/session/session-start.js +28 -1
  42. package/dist/user-config/index.d.ts +2 -0
  43. package/dist/user-config/index.js +2 -0
  44. package/dist/user-config/resolve-user-md.d.ts +76 -0
  45. package/dist/user-config/resolve-user-md.js +120 -0
  46. package/dist/xbrief-migrate/constants.d.ts +10 -0
  47. package/dist/xbrief-migrate/constants.js +19 -0
  48. package/dist/xbrief-migrate/detect.d.ts +34 -0
  49. package/dist/xbrief-migrate/detect.js +33 -0
  50. package/dist/xbrief-migrate/fs-helpers.d.ts +13 -0
  51. package/dist/xbrief-migrate/fs-helpers.js +46 -1
  52. package/dist/xbrief-migrate/index.d.ts +4 -2
  53. package/dist/xbrief-migrate/index.js +4 -2
  54. package/dist/xbrief-migrate/migrate-project.d.ts +23 -1
  55. package/dist/xbrief-migrate/migrate-project.js +97 -8
  56. package/dist/xbrief-migrate/signpost.d.ts +7 -1
  57. package/dist/xbrief-migrate/signpost.js +18 -3
  58. package/package.json +7 -3
@@ -4,6 +4,7 @@ import { MIGRATE_COMPLETION_NUDGE, shouldEmitMigrateNudge } from "../init-deposi
4
4
  import { disclosureLine } from "../policy/disclosure.js";
5
5
  import { resolvePolicy } from "../policy/resolve.js";
6
6
  import { runDefaultMode } from "../triage/welcome/default-mode.js";
7
+ import { resolveUserMdPath } from "../user-config/resolve-user-md.js";
7
8
  import { verifyRequiredTools } from "../verify-env/verify-tools.js";
8
9
  import { defaultGitRunner, gitHead, worktreePath } from "./git.js";
9
10
  import { newRitualStatePayload, ritualStatePath, ritualStep, writeRitualState, } from "./ritual-sentinel.js";
@@ -194,10 +195,30 @@ export function runSessionStart(projectRoot, options = {}) {
194
195
  const quickSteps = recordDeferredSteps(QUICK_STEPS, deferrals, instant);
195
196
  const gatedSteps = recordDeferredSteps(GATED_STEPS, deferrals, instant);
196
197
  const lines = [];
198
+ // Resolve USER.md via the shared first-hit-wins resolver so the alignment
199
+ // step finds preferences automatically in mismatched / headless sandboxes
200
+ // with zero manual DEFT_USER_PATH (#2271 / #2124). Never throws: an absent
201
+ // USER.md degrades to a clear diagnostic below.
202
+ const resolveUserMd = options.resolveUserMd ?? ((root) => resolveUserMdPath({ projectRoot: root }));
203
+ const userMd = resolveUserMd(projectRoot);
197
204
  if (!quickSteps.alignment) {
198
205
  const message = "Deft Directive active -- AGENTS.md loaded.";
199
- quickSteps.alignment = ritualStep({ ok: true, ts: instant, message });
206
+ // Sanitize newlines on the data-derived path/diagnostic before they land in
207
+ // the ritual-step message / terminal output (matches doctor's CWE-116
208
+ // handling); DEFT_USER_PATH is only trimmed, so an embedded newline could
209
+ // otherwise survive into multi-line output.
210
+ const safePath = userMd.path.replace(/\r?\n/g, " ");
211
+ const safeDiagnostic = userMd.diagnostic.replace(/\r?\n/g, " ");
212
+ const userMdLine = userMd.found
213
+ ? `USER.md resolved (${userMd.rung}): ${safePath}`
214
+ : safeDiagnostic;
215
+ quickSteps.alignment = ritualStep({
216
+ ok: true,
217
+ ts: instant,
218
+ message: `${message} ${userMdLine}`,
219
+ });
200
220
  lines.push(message);
221
+ lines.push(userMdLine);
201
222
  }
202
223
  if (!quickSteps.branch_policy) {
203
224
  const result = resolvePolicy(projectRoot);
@@ -287,6 +308,12 @@ export function runSessionStart(projectRoot, options = {}) {
287
308
  state_path: statePath,
288
309
  quick_steps: quickSteps,
289
310
  gated_steps: gatedSteps,
311
+ user_md: {
312
+ path: userMd.path,
313
+ rung: userMd.rung,
314
+ found: userMd.found,
315
+ diagnostic: userMd.diagnostic,
316
+ },
290
317
  message: code === 0 ? "session ritual recorded" : "session ritual failed",
291
318
  };
292
319
  return { code, payload: resultPayload, lines };
@@ -0,0 +1,2 @@
1
+ export * from "./resolve-user-md.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export * from "./resolve-user-md.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared USER.md resolver (#2271).
3
+ *
4
+ * Resolves the USER.md user-config file across an explicit first-hit-wins
5
+ * search order so session-start, the CLI bootstrap, and doctor all share a
6
+ * single source of truth for "where does USER.md live?" — and so it resolves
7
+ * automatically in mismatched / headless sandboxes where `$HOME` is not a
8
+ * persistent mount (the #2124 Cowork gap) with zero manual `DEFT_USER_PATH`.
9
+ *
10
+ * Search order (first hit wins):
11
+ * 1. `DEFT_USER_PATH` — explicit override, highest precedence, points at the
12
+ * USER.md file directly. Always wins as the resolved path whether or not
13
+ * the file exists yet (presence is reported separately via `found`).
14
+ * 2. Workspace-local bridged config (`<projectRoot>/.deft/USER.md`) — a
15
+ * persistent location inside the workspace that survives a non-persistent
16
+ * `$HOME`.
17
+ * 3. Bridged platform config dir (`%APPDATA%\deft\USER.md` on Windows,
18
+ * `~/.config/deft/USER.md` elsewhere) — the historical default location.
19
+ * 4. Sensible default (the rung-3 path) + a `no USER.md found; using
20
+ * defaults` diagnostic. This branch NEVER throws.
21
+ */
22
+ /** Exact diagnostic phrase emitted when USER.md is absent on every rung. */
23
+ export declare const NO_USER_MD_DIAGNOSTIC = "no USER.md found; using defaults";
24
+ /** The USER.md filename joined onto every candidate config directory. */
25
+ export declare const USER_MD_FILENAME = "USER.md";
26
+ /**
27
+ * Workspace-local config directory (relative to the project root). This is the
28
+ * bridged, persistent location inside the workspace that keeps preferences
29
+ * reachable even when the platform config dir under `$HOME` is not mounted.
30
+ */
31
+ export declare const WORKSPACE_LOCAL_CONFIG_DIR = ".deft";
32
+ /** Which search rung produced the resolved path. */
33
+ export type UserMdRung = "env-override" | "workspace-local" | "platform-config" | "default";
34
+ export interface ResolveUserMdResult {
35
+ /**
36
+ * The resolved USER.md path. For rungs 2-3 this is always an existing file.
37
+ * For rung 1 (`env-override`) it is the override path whether or not the file
38
+ * exists yet (check `found`). For the `default` rung it is the sensible
39
+ * default location (which does not exist).
40
+ */
41
+ readonly path: string;
42
+ /** Which search rung matched. */
43
+ readonly rung: UserMdRung;
44
+ /** True when a USER.md file actually exists at `path`. */
45
+ readonly found: boolean;
46
+ /** One-line human-readable diagnostic describing the resolution boundary. */
47
+ readonly diagnostic: string;
48
+ /** Candidate paths inspected, in search order (for diagnostics / tests). */
49
+ readonly searched: readonly string[];
50
+ }
51
+ export interface ResolveUserMdOptions {
52
+ /** Workspace root for the workspace-local rung. Defaults to `process.cwd()`. */
53
+ readonly projectRoot?: string;
54
+ /** Environment source (reads `DEFT_USER_PATH` / `APPDATA`). Defaults to `process.env`. */
55
+ readonly env?: NodeJS.ProcessEnv;
56
+ /** Platform id for the platform-config rung. Defaults to `process.platform`. */
57
+ readonly platform?: NodeJS.Platform;
58
+ /** Home directory for the platform-config rung. Defaults to `os.homedir()`. */
59
+ readonly homeDir?: string;
60
+ /** File-existence probe. Defaults to an `existsSync` + `isFile` check. */
61
+ readonly fileExists?: (path: string) => boolean;
62
+ }
63
+ /**
64
+ * Resolve the platform config directory (`%APPDATA%\deft` / `~/.config/deft`).
65
+ * Mirrors `init-deposit`'s `userConfigDir()` platform branch but takes the
66
+ * platform / env / home as injectable inputs and deliberately does NOT honor
67
+ * `DEFT_USER_PATH` — that override is rung 1, handled by the caller.
68
+ */
69
+ export declare function platformUserConfigDir(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, homeDir: string): string;
70
+ /**
71
+ * Resolve the USER.md path across the first-hit-wins search order. Never
72
+ * throws: an absent USER.md degrades to the sensible default location with a
73
+ * clear `no USER.md found; using defaults` diagnostic.
74
+ */
75
+ export declare function resolveUserMdPath(options?: ResolveUserMdOptions): ResolveUserMdResult;
76
+ //# sourceMappingURL=resolve-user-md.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared USER.md resolver (#2271).
3
+ *
4
+ * Resolves the USER.md user-config file across an explicit first-hit-wins
5
+ * search order so session-start, the CLI bootstrap, and doctor all share a
6
+ * single source of truth for "where does USER.md live?" — and so it resolves
7
+ * automatically in mismatched / headless sandboxes where `$HOME` is not a
8
+ * persistent mount (the #2124 Cowork gap) with zero manual `DEFT_USER_PATH`.
9
+ *
10
+ * Search order (first hit wins):
11
+ * 1. `DEFT_USER_PATH` — explicit override, highest precedence, points at the
12
+ * USER.md file directly. Always wins as the resolved path whether or not
13
+ * the file exists yet (presence is reported separately via `found`).
14
+ * 2. Workspace-local bridged config (`<projectRoot>/.deft/USER.md`) — a
15
+ * persistent location inside the workspace that survives a non-persistent
16
+ * `$HOME`.
17
+ * 3. Bridged platform config dir (`%APPDATA%\deft\USER.md` on Windows,
18
+ * `~/.config/deft/USER.md` elsewhere) — the historical default location.
19
+ * 4. Sensible default (the rung-3 path) + a `no USER.md found; using
20
+ * defaults` diagnostic. This branch NEVER throws.
21
+ */
22
+ import { statSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join, resolve } from "node:path";
25
+ /** Exact diagnostic phrase emitted when USER.md is absent on every rung. */
26
+ export const NO_USER_MD_DIAGNOSTIC = "no USER.md found; using defaults";
27
+ /** The USER.md filename joined onto every candidate config directory. */
28
+ export const USER_MD_FILENAME = "USER.md";
29
+ /**
30
+ * Workspace-local config directory (relative to the project root). This is the
31
+ * bridged, persistent location inside the workspace that keeps preferences
32
+ * reachable even when the platform config dir under `$HOME` is not mounted.
33
+ */
34
+ export const WORKSPACE_LOCAL_CONFIG_DIR = ".deft";
35
+ function defaultFileExists(path) {
36
+ try {
37
+ return statSync(path).isFile();
38
+ }
39
+ catch {
40
+ // Any stat error (ENOENT, permission, ...) means "not a resolvable USER.md
41
+ // file". Deliberately NOT falling back to existsSync, which cannot tell a
42
+ // directory from a file and would report a dir at this path as a found file.
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Resolve the platform config directory (`%APPDATA%\deft` / `~/.config/deft`).
48
+ * Mirrors `init-deposit`'s `userConfigDir()` platform branch but takes the
49
+ * platform / env / home as injectable inputs and deliberately does NOT honor
50
+ * `DEFT_USER_PATH` — that override is rung 1, handled by the caller.
51
+ */
52
+ export function platformUserConfigDir(platform, env, homeDir) {
53
+ if (platform === "win32") {
54
+ const appData = env.APPDATA?.trim();
55
+ return appData ? join(appData, "deft") : join(homeDir, "AppData", "Roaming", "deft");
56
+ }
57
+ return join(homeDir, ".config", "deft");
58
+ }
59
+ /**
60
+ * Resolve the USER.md path across the first-hit-wins search order. Never
61
+ * throws: an absent USER.md degrades to the sensible default location with a
62
+ * clear `no USER.md found; using defaults` diagnostic.
63
+ */
64
+ export function resolveUserMdPath(options = {}) {
65
+ const env = options.env ?? process.env;
66
+ const platform = options.platform ?? process.platform;
67
+ const homeDir = options.homeDir ?? homedir();
68
+ const projectRoot = options.projectRoot ?? process.cwd();
69
+ const fileExists = options.fileExists ?? defaultFileExists;
70
+ const searched = [];
71
+ // Rung 1: DEFT_USER_PATH override (full file path); always wins.
72
+ const override = env.DEFT_USER_PATH?.trim();
73
+ if (override) {
74
+ const overridePath = resolve(override);
75
+ searched.push(overridePath);
76
+ const found = fileExists(overridePath);
77
+ return {
78
+ path: overridePath,
79
+ rung: "env-override",
80
+ found,
81
+ diagnostic: found
82
+ ? `USER.md resolved from DEFT_USER_PATH: ${overridePath}`
83
+ : `USER.md path from DEFT_USER_PATH does not exist yet: ${overridePath}`,
84
+ searched,
85
+ };
86
+ }
87
+ // Rung 2: workspace-local bridged config.
88
+ const workspacePath = resolve(join(projectRoot, WORKSPACE_LOCAL_CONFIG_DIR, USER_MD_FILENAME));
89
+ searched.push(workspacePath);
90
+ if (fileExists(workspacePath)) {
91
+ return {
92
+ path: workspacePath,
93
+ rung: "workspace-local",
94
+ found: true,
95
+ diagnostic: `USER.md resolved from workspace-local config: ${workspacePath}`,
96
+ searched,
97
+ };
98
+ }
99
+ // Rung 3: bridged platform config dir.
100
+ const platformPath = join(platformUserConfigDir(platform, env, homeDir), USER_MD_FILENAME);
101
+ searched.push(platformPath);
102
+ if (fileExists(platformPath)) {
103
+ return {
104
+ path: platformPath,
105
+ rung: "platform-config",
106
+ found: true,
107
+ diagnostic: `USER.md resolved from platform config dir: ${platformPath}`,
108
+ searched,
109
+ };
110
+ }
111
+ // Rung 4: sensible default + diagnostic (never throws).
112
+ return {
113
+ path: platformPath,
114
+ rung: "default",
115
+ found: false,
116
+ diagnostic: `${NO_USER_MD_DIAGNOSTIC} (searched: ${searched.join(", ")})`,
117
+ searched,
118
+ };
119
+ }
120
+ //# sourceMappingURL=resolve-user-md.js.map
@@ -10,4 +10,14 @@ export declare const LEGACY_INFO_ROOT_KEY: "vBRIEFInfo";
10
10
  export declare const MIGRATED_INFO_ROOT_KEY: "xBRIEFInfo";
11
11
  /** Item-only status value added in xBRIEF v0.8. */
12
12
  export declare const V08_ITEM_STATUS_AUTO: "auto";
13
+ /**
14
+ * Filename of the explicit deprecation marker written into a legacy `vbrief/`
15
+ * root that is retained for read-compatibility after convergence (#2270). Its
16
+ * presence is what tells `doctor` the folder is NOT an active source of truth.
17
+ */
18
+ export declare const VBRIEF_DEPRECATION_MARKER_FILENAME: "DEPRECATED.md";
19
+ /** Sentinel line identifying a deft-written `vbrief/` deprecation marker (#2270). */
20
+ export declare const VBRIEF_DEPRECATION_MARKER_SENTINEL: "<!-- deft:vbrief-deprecated -->";
21
+ /** Body written into the retained legacy `vbrief/` deprecation marker (#2270). */
22
+ export declare const VBRIEF_DEPRECATION_MARKER_BODY: "<!-- deft:vbrief-deprecated -->\n# Deprecated: legacy `vbrief/` root\n\nThis project has migrated to the `xbrief/` lifecycle layout. This `vbrief/`\ndirectory is retained only for read-compatibility and is **not** an active\nsource of truth. Do not add new scope work here — use `xbrief/` instead.\n\nOnce you no longer need read-compatibility with the legacy layout, delete this\n`vbrief/` directory. Re-running `deft migrate:xbrief` re-checks convergence.\n";
13
23
  //# sourceMappingURL=constants.d.ts.map
@@ -11,4 +11,23 @@ export const LEGACY_INFO_ROOT_KEY = "vBRIEFInfo";
11
11
  export const MIGRATED_INFO_ROOT_KEY = "xBRIEFInfo";
12
12
  /** Item-only status value added in xBRIEF v0.8. */
13
13
  export const V08_ITEM_STATUS_AUTO = "auto";
14
+ /**
15
+ * Filename of the explicit deprecation marker written into a legacy `vbrief/`
16
+ * root that is retained for read-compatibility after convergence (#2270). Its
17
+ * presence is what tells `doctor` the folder is NOT an active source of truth.
18
+ */
19
+ export const VBRIEF_DEPRECATION_MARKER_FILENAME = "DEPRECATED.md";
20
+ /** Sentinel line identifying a deft-written `vbrief/` deprecation marker (#2270). */
21
+ export const VBRIEF_DEPRECATION_MARKER_SENTINEL = "<!-- deft:vbrief-deprecated -->";
22
+ /** Body written into the retained legacy `vbrief/` deprecation marker (#2270). */
23
+ export const VBRIEF_DEPRECATION_MARKER_BODY = `${VBRIEF_DEPRECATION_MARKER_SENTINEL}
24
+ # Deprecated: legacy \`vbrief/\` root
25
+
26
+ This project has migrated to the \`xbrief/\` lifecycle layout. This \`vbrief/\`
27
+ directory is retained only for read-compatibility and is **not** an active
28
+ source of truth. Do not add new scope work here — use \`xbrief/\` instead.
29
+
30
+ Once you no longer need read-compatibility with the legacy layout, delete this
31
+ \`vbrief/\` directory. Re-running \`deft migrate:xbrief\` re-checks convergence.
32
+ `;
14
33
  //# sourceMappingURL=constants.js.map
@@ -3,9 +3,43 @@ export interface LegacyVbriefLayoutDetection {
3
3
  legacyLayout: boolean;
4
4
  reasons: string[];
5
5
  }
6
+ /**
7
+ * Discriminated convergence state of the `vbrief/` + `xbrief/` lifecycle roots
8
+ * (#2270). Distinguishes the ambiguous dual-empty-root case that `migrate:xbrief`
9
+ * must converge from the states that are already unambiguous.
10
+ *
11
+ * - `none` — neither root carries content (nothing to converge).
12
+ * - `xbrief-only` — canonical `xbrief/` with content and no `vbrief/`.
13
+ * - `xbrief-marker` — `vbrief/` retained behind an explicit deprecation marker.
14
+ * - `empty-vbrief` — a stray, fully-migrated empty `vbrief/` (the ambiguous root
15
+ * to clean up: remove it, or mark it when read-compat retention is requested).
16
+ * - `legacy-only` — `vbrief/` holds content and there is no canonical `xbrief/`;
17
+ * a full migration is required.
18
+ * - `dual-populated` — both roots hold content with no marker; converge by
19
+ * marking the legacy tree deprecated (never destructively merged).
20
+ */
21
+ export type XbriefConvergenceState = "none" | "xbrief-only" | "xbrief-marker" | "empty-vbrief" | "legacy-only" | "dual-populated";
22
+ /** Structured convergence probe of the two lifecycle roots (#2270). */
23
+ export interface XbriefConvergenceDetection {
24
+ state: XbriefConvergenceState;
25
+ vbriefPresent: boolean;
26
+ vbriefEmpty: boolean;
27
+ vbriefHasMarker: boolean;
28
+ xbriefPresent: boolean;
29
+ xbriefHasContent: boolean;
30
+ }
6
31
  /**
7
32
  * Detect whether `projectRoot` still uses the legacy vbrief on-disk layout.
8
33
  * Mirrors the structured-reasons pattern from `detectPreCutover` (#793).
9
34
  */
10
35
  export declare function detectLegacyVbriefLayout(projectRoot: string): LegacyVbriefLayoutDetection;
36
+ /**
37
+ * Classify the convergence state of the `vbrief/` and `xbrief/` lifecycle roots
38
+ * so the orchestrator can converge to a single unambiguous root (#2270). Unlike
39
+ * {@link detectLegacyVbriefLayout} (which only asks "is there anything legacy?"),
40
+ * this probe distinguishes the ambiguous dual-empty-root case from an already
41
+ * unambiguous layout, and never falls back to a `run migrate:xbrief` dead end
42
+ * for a `vbrief/` that is merely an empty leftover.
43
+ */
44
+ export declare function detectXbriefConvergence(projectRoot: string): XbriefConvergenceDetection;
11
45
  //# sourceMappingURL=detect.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { LEGACY_ARTIFACT_DIR, LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, LEGACY_VBRIEF_VERSION, MIGRATED_ARTIFACT_DIR, VBRIEF_REFERENCE_PREFIX, } from "./constants.js";
4
+ import { hasVbriefDeprecationMarker, isEffectivelyEmptyDir } from "./fs-helpers.js";
4
5
  function isDirectory(path) {
5
6
  try {
6
7
  return statSync(path).isDirectory();
@@ -78,4 +79,36 @@ export function detectLegacyVbriefLayout(projectRoot) {
78
79
  const reasonList = [...reasons];
79
80
  return { legacyLayout: reasonList.length > 0, reasons: reasonList };
80
81
  }
82
+ /**
83
+ * Classify the convergence state of the `vbrief/` and `xbrief/` lifecycle roots
84
+ * so the orchestrator can converge to a single unambiguous root (#2270). Unlike
85
+ * {@link detectLegacyVbriefLayout} (which only asks "is there anything legacy?"),
86
+ * this probe distinguishes the ambiguous dual-empty-root case from an already
87
+ * unambiguous layout, and never falls back to a `run migrate:xbrief` dead end
88
+ * for a `vbrief/` that is merely an empty leftover.
89
+ */
90
+ export function detectXbriefConvergence(projectRoot) {
91
+ const legacyDir = join(projectRoot, LEGACY_ARTIFACT_DIR);
92
+ const migratedDir = join(projectRoot, MIGRATED_ARTIFACT_DIR);
93
+ const vbriefPresent = isDirectory(legacyDir);
94
+ const vbriefHasMarker = vbriefPresent && hasVbriefDeprecationMarker(legacyDir);
95
+ const vbriefEmpty = vbriefPresent && !vbriefHasMarker && isEffectivelyEmptyDir(legacyDir);
96
+ const xbriefPresent = isDirectory(migratedDir);
97
+ const xbriefHasContent = xbriefPresent && !isEffectivelyEmptyDir(migratedDir);
98
+ let state;
99
+ if (vbriefHasMarker) {
100
+ state = "xbrief-marker";
101
+ }
102
+ else if (!vbriefPresent) {
103
+ state = xbriefHasContent ? "xbrief-only" : "none";
104
+ }
105
+ else if (vbriefEmpty) {
106
+ state = "empty-vbrief";
107
+ }
108
+ else {
109
+ // vbrief/ holds real content and carries no marker.
110
+ state = xbriefHasContent ? "dual-populated" : "legacy-only";
111
+ }
112
+ return { state, vbriefPresent, vbriefEmpty, vbriefHasMarker, xbriefPresent, xbriefHasContent };
113
+ }
81
114
  //# sourceMappingURL=detect.js.map
@@ -1,3 +1,16 @@
1
1
  /** True when `path` exists and is a directory; false on any stat error. */
2
2
  export declare function isDirectory(path: string): boolean;
3
+ /**
4
+ * True when `dir` holds no meaningful content at any depth — a "fully migrated"
5
+ * lifecycle root. Empty subdirectories and VCS placeholder files (`.gitkeep`,
6
+ * `.keep`) are ignored, so a scaffolded-but-empty `vbrief/proposed/` tree still
7
+ * counts as empty. A missing directory is trivially empty (#2270).
8
+ */
9
+ export declare function isEffectivelyEmptyDir(dir: string): boolean;
10
+ /**
11
+ * True when `dir` carries a deft-written deprecation marker identifying it as a
12
+ * retained-for-read-compat legacy root rather than an active source of truth
13
+ * (#2270). Matched on the sentinel so an unrelated `DEPRECATED.md` is ignored.
14
+ */
15
+ export declare function hasVbriefDeprecationMarker(dir: string): boolean;
3
16
  //# sourceMappingURL=fs-helpers.d.ts.map
@@ -1,4 +1,6 @@
1
- import { statSync } from "node:fs";
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { VBRIEF_DEPRECATION_MARKER_FILENAME, VBRIEF_DEPRECATION_MARKER_SENTINEL, } from "./constants.js";
2
4
  /** True when `path` exists and is a directory; false on any stat error. */
3
5
  export function isDirectory(path) {
4
6
  try {
@@ -8,4 +10,47 @@ export function isDirectory(path) {
8
10
  return false;
9
11
  }
10
12
  }
13
+ /** Placeholder files that do not count as real content when judging emptiness. */
14
+ const PLACEHOLDER_FILES = new Set([".gitkeep", ".keep"]);
15
+ /**
16
+ * True when `dir` holds no meaningful content at any depth — a "fully migrated"
17
+ * lifecycle root. Empty subdirectories and VCS placeholder files (`.gitkeep`,
18
+ * `.keep`) are ignored, so a scaffolded-but-empty `vbrief/proposed/` tree still
19
+ * counts as empty. A missing directory is trivially empty (#2270).
20
+ */
21
+ export function isEffectivelyEmptyDir(dir) {
22
+ if (!isDirectory(dir)) {
23
+ return true;
24
+ }
25
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
26
+ if (entry.isFile()) {
27
+ if (PLACEHOLDER_FILES.has(entry.name)) {
28
+ continue;
29
+ }
30
+ return false;
31
+ }
32
+ // A symlink (to a file or dir) is real content: `isFile`/`isDirectory` both
33
+ // report false for symlink dirents, so treat it explicitly as non-empty.
34
+ if (entry.isSymbolicLink()) {
35
+ return false;
36
+ }
37
+ if (entry.isDirectory() && !isEffectivelyEmptyDir(join(dir, entry.name))) {
38
+ return false;
39
+ }
40
+ }
41
+ return true;
42
+ }
43
+ /**
44
+ * True when `dir` carries a deft-written deprecation marker identifying it as a
45
+ * retained-for-read-compat legacy root rather than an active source of truth
46
+ * (#2270). Matched on the sentinel so an unrelated `DEPRECATED.md` is ignored.
47
+ */
48
+ export function hasVbriefDeprecationMarker(dir) {
49
+ try {
50
+ return readFileSync(join(dir, VBRIEF_DEPRECATION_MARKER_FILENAME), "utf8").includes(VBRIEF_DEPRECATION_MARKER_SENTINEL);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
11
56
  //# sourceMappingURL=fs-helpers.js.map
@@ -1,7 +1,9 @@
1
1
  export { detectStaleUnmanagedHeader, type HeaderPatchOutcome, type HeaderRewriteResult, type HeaderTokenReplacement, LEGACY_HEADER_TOKENS, patchAgentsMdHeader, renderHeaderPatchSummary, renderStaleHeaderLine, rewriteUnmanagedHeaderTokens, type StaleHeaderDetection, } from "./agents-header.js";
2
- export { detectLegacyVbriefLayout, type LegacyVbriefLayoutDetection, } from "./detect.js";
2
+ export { VBRIEF_DEPRECATION_MARKER_BODY, VBRIEF_DEPRECATION_MARKER_FILENAME, VBRIEF_DEPRECATION_MARKER_SENTINEL, } from "./constants.js";
3
+ export { detectLegacyVbriefLayout, detectXbriefConvergence, type LegacyVbriefLayoutDetection, type XbriefConvergenceDetection, type XbriefConvergenceState, } from "./detect.js";
3
4
  export { BUILTIN_ALLOW_LIST, type DriftEvaluateOptions, type DriftEvaluateResult, type DriftFinding, type DriftScanMode, evaluateXbriefDrift, LEGACY_REFERENCE_PREFIX, scanCorpusToken, } from "./drift-gate.js";
4
- export { emitXbriefMigration, runXbriefMigration, runXbriefMigrationCli, type XbriefMigrationArgs, type XbriefMigrationIo, type XbriefMigrationOutcome, } from "./migrate-project.js";
5
+ export { hasVbriefDeprecationMarker, isDirectory, isEffectivelyEmptyDir, } from "./fs-helpers.js";
6
+ export { convergeLegacyVbriefRoot, emitXbriefMigration, runXbriefMigration, runXbriefMigrationCli, type VbriefConvergeAction, type XbriefMigrationArgs, type XbriefMigrationIo, type XbriefMigrationOutcome, } from "./migrate-project.js";
5
7
  export { renderXbriefMigrationLine, xbriefMigrationGuidance } from "./signpost.js";
6
8
  export { assertFeatureEmissionAllowed, assertLayoutAwareWritePath, FeatureEmissionRejectedError, type JsonObject, type JsonValue, readDeclaredArtifactVersion, resolveLayoutAwareRelativePath, rewriteEmbeddedTokens, SplitLayoutRejectedError, TransformError, transformArtifactV06ToV08, transformArtifactV06ToV08Transactional, } from "./transforms.js";
7
9
  export { isPatchOnlyUpgrade, parseSemverPrefix } from "./version.js";
@@ -1,7 +1,9 @@
1
1
  export { detectStaleUnmanagedHeader, LEGACY_HEADER_TOKENS, patchAgentsMdHeader, renderHeaderPatchSummary, renderStaleHeaderLine, rewriteUnmanagedHeaderTokens, } from "./agents-header.js";
2
- export { detectLegacyVbriefLayout, } from "./detect.js";
2
+ export { VBRIEF_DEPRECATION_MARKER_BODY, VBRIEF_DEPRECATION_MARKER_FILENAME, VBRIEF_DEPRECATION_MARKER_SENTINEL, } from "./constants.js";
3
+ export { detectLegacyVbriefLayout, detectXbriefConvergence, } from "./detect.js";
3
4
  export { BUILTIN_ALLOW_LIST, evaluateXbriefDrift, LEGACY_REFERENCE_PREFIX, scanCorpusToken, } from "./drift-gate.js";
4
- export { emitXbriefMigration, runXbriefMigration, runXbriefMigrationCli, } from "./migrate-project.js";
5
+ export { hasVbriefDeprecationMarker, isDirectory, isEffectivelyEmptyDir, } from "./fs-helpers.js";
6
+ export { convergeLegacyVbriefRoot, emitXbriefMigration, runXbriefMigration, runXbriefMigrationCli, } from "./migrate-project.js";
5
7
  export { renderXbriefMigrationLine, xbriefMigrationGuidance } from "./signpost.js";
6
8
  export { assertFeatureEmissionAllowed, assertLayoutAwareWritePath, FeatureEmissionRejectedError, readDeclaredArtifactVersion, resolveLayoutAwareRelativePath, rewriteEmbeddedTokens, SplitLayoutRejectedError, TransformError, transformArtifactV06ToV08, transformArtifactV06ToV08Transactional, } from "./transforms.js";
7
9
  export { isPatchOnlyUpgrade, parseSemverPrefix } from "./version.js";
@@ -2,11 +2,18 @@ export interface XbriefMigrationArgs {
2
2
  readonly projectRoot: string;
3
3
  readonly frameworkRoot?: string;
4
4
  readonly force?: boolean;
5
+ /**
6
+ * Retain a fully-migrated `vbrief/` for read-compatibility behind an explicit
7
+ * deprecation marker instead of removing it (#2270). Default: remove.
8
+ */
9
+ readonly keepLegacy?: boolean;
5
10
  }
6
11
  export interface XbriefMigrationIo {
7
12
  writeOut: (text: string) => void;
8
13
  writeErr: (text: string) => void;
9
14
  }
15
+ /** How the legacy `vbrief/` root was converged to an unambiguous state (#2270). */
16
+ export type VbriefConvergeAction = "removed" | "marker";
10
17
  export type XbriefMigrationOutcome = {
11
18
  readonly kind: "noop";
12
19
  readonly message: string;
@@ -17,11 +24,26 @@ export type XbriefMigrationOutcome = {
17
24
  readonly kind: "migrated";
18
25
  readonly backupDir: string;
19
26
  readonly files: number;
27
+ } | {
28
+ readonly kind: "converged";
29
+ readonly action: VbriefConvergeAction;
30
+ readonly already: boolean;
31
+ readonly message: string;
20
32
  } | {
21
33
  readonly kind: "config";
22
34
  readonly message: string;
23
35
  };
24
- /** Core orchestrator for the consumer xbrief rename (#2110). */
36
+ /**
37
+ * Converge a leftover legacy `vbrief/` root to an unambiguous state (#2270).
38
+ * An effectively-empty tree is removed by default (single canonical root); a
39
+ * tree with real content — or an empty tree when `retain` is requested — is
40
+ * kept for read-compat behind an explicit deprecation marker. Idempotent: a
41
+ * missing dir or an already-marked dir is a no-op.
42
+ */
43
+ export declare function convergeLegacyVbriefRoot(projectRoot: string, options: {
44
+ retain: boolean;
45
+ }): VbriefConvergeAction;
46
+ /** Core orchestrator for the consumer xbrief rename (#2110) + convergence (#2270). */
25
47
  export declare function runXbriefMigration(args: XbriefMigrationArgs, _io: XbriefMigrationIo): XbriefMigrationOutcome;
26
48
  /** Map a migration outcome to a process exit code and emit operator guidance. */
27
49
  export declare function emitXbriefMigration(outcome: XbriefMigrationOutcome, io: XbriefMigrationIo, options?: {