@deftai/directive-core 0.83.0 → 0.84.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 (42) hide show
  1. package/dist/cache/main.js +36 -2
  2. package/dist/cache/task-cache/constants.d.ts +4 -0
  3. package/dist/cache/task-cache/constants.js +4 -0
  4. package/dist/cache/task-cache/executor.d.ts +9 -0
  5. package/dist/cache/task-cache/executor.js +51 -0
  6. package/dist/cache/task-cache/hash.d.ts +17 -0
  7. package/dist/cache/task-cache/hash.js +92 -0
  8. package/dist/cache/task-cache/index.d.ts +14 -0
  9. package/dist/cache/task-cache/index.js +15 -0
  10. package/dist/cache/task-cache/lint.d.ts +4 -0
  11. package/dist/cache/task-cache/lint.js +55 -0
  12. package/dist/cache/task-cache/registry.d.ts +7 -0
  13. package/dist/cache/task-cache/registry.js +67 -0
  14. package/dist/cache/task-cache/store.d.ts +10 -0
  15. package/dist/cache/task-cache/store.js +48 -0
  16. package/dist/cache/task-cache/types.d.ts +48 -0
  17. package/dist/cache/task-cache/types.js +3 -0
  18. package/dist/check/cached-orchestrator.d.ts +16 -0
  19. package/dist/check/cached-orchestrator.js +76 -0
  20. package/dist/check/context.d.ts +30 -0
  21. package/dist/check/context.js +28 -0
  22. package/dist/check/gate-lists.d.ts +18 -0
  23. package/dist/check/gate-lists.js +68 -0
  24. package/dist/check/index.d.ts +4 -1
  25. package/dist/check/index.js +3 -0
  26. package/dist/check/orchestrator.d.ts +3 -45
  27. package/dist/check/orchestrator.js +8 -46
  28. package/dist/check/runner-detect.d.ts +20 -0
  29. package/dist/check/runner-detect.js +131 -0
  30. package/dist/eval/readback.js +6 -1
  31. package/dist/hooks/dispatcher.d.ts +2 -0
  32. package/dist/hooks/dispatcher.js +48 -6
  33. package/dist/init-deposit/gitignore.js +1 -0
  34. package/dist/scope/decompose.js +9 -3
  35. package/dist/session/git.d.ts +2 -0
  36. package/dist/session/git.js +14 -0
  37. package/dist/session/verify-session-ritual.js +39 -5
  38. package/dist/swarm/routing-set-cli.js +16 -5
  39. package/dist/swarm/routing.d.ts +1 -1
  40. package/dist/swarm/routing.js +3 -1
  41. package/dist/value/readback.js +6 -1
  42. package/package.json +7 -3
@@ -0,0 +1,28 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ /** True when `path` is the directive framework source checkout root. */
4
+ export function isFrameworkRepoRoot(path) {
5
+ const root = resolve(path);
6
+ return (existsSync(join(root, "packages", "cli", "package.json")) &&
7
+ existsSync(join(root, "biome.json")) &&
8
+ existsSync(join(root, "Taskfile.yml")));
9
+ }
10
+ /** Return true when running in the framework's own source checkout (#1519). */
11
+ export function isFrameworkSourceContext(frameworkRoot, projectRoot) {
12
+ const fw = resolve(frameworkRoot);
13
+ const pr = resolve(projectRoot);
14
+ if (fw === pr) {
15
+ return true;
16
+ }
17
+ if (!isFrameworkRepoRoot(fw)) {
18
+ return false;
19
+ }
20
+ return pr.startsWith(`${fw}${sep}`);
21
+ }
22
+ /** Select the Taskfile target for the given context. */
23
+ export function resolveCheckTarget(frameworkRoot, projectRoot) {
24
+ return isFrameworkSourceContext(frameworkRoot, projectRoot)
25
+ ? "check:framework-source"
26
+ : "check:consumer";
27
+ }
28
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1,18 @@
1
+ /** Gate execution order mirrors Taskfile.yml check targets (#1713 dogfood). */
2
+ /**
3
+ * A check gate is either a bare Taskfile task name, or a public task plus CLI
4
+ * args (after `--`). Framework-only shims that are `internal: true` in
5
+ * Taskfile.yml cannot be shelled by name under go-task 3.50 (#2791) — use the
6
+ * public surface with the shim's flags instead.
7
+ */
8
+ export type CheckGateSpec = string | {
9
+ readonly task: string;
10
+ readonly args?: readonly string[];
11
+ };
12
+ export declare function checkGateId(spec: CheckGateSpec): string;
13
+ /** Args for `task … --taskfile <path>` (optional `--` + public CLI flags). */
14
+ export declare function checkGateSpawnArgs(spec: CheckGateSpec, taskfilePath: string): string[];
15
+ export declare const FRAMEWORK_CHECK_GATES: readonly CheckGateSpec[];
16
+ export declare const CONSUMER_CHECK_GATES: readonly CheckGateSpec[];
17
+ export declare function gatesForCheckTarget(target: string): readonly CheckGateSpec[];
18
+ //# sourceMappingURL=gate-lists.d.ts.map
@@ -0,0 +1,68 @@
1
+ /** Gate execution order mirrors Taskfile.yml check targets (#1713 dogfood). */
2
+ export function checkGateId(spec) {
3
+ return typeof spec === "string" ? spec : spec.task;
4
+ }
5
+ /** Args for `task … --taskfile <path>` (optional `--` + public CLI flags). */
6
+ export function checkGateSpawnArgs(spec, taskfilePath) {
7
+ const task = checkGateId(spec);
8
+ const extra = typeof spec === "string" ? undefined : spec.args;
9
+ if (extra !== undefined && extra.length > 0) {
10
+ return [task, "--taskfile", taskfilePath, "--", ...extra];
11
+ }
12
+ return [task, "--taskfile", taskfilePath];
13
+ }
14
+ export const FRAMEWORK_CHECK_GATES = [
15
+ "ts:check-lane",
16
+ "toolchain:check",
17
+ "verify:stubs",
18
+ "verify:links",
19
+ "verify:rule-ownership",
20
+ "verify:biome-config",
21
+ "verify:content-manifest",
22
+ "verify:skill-external-fetch-gate",
23
+ "verify:contract-drift",
24
+ "verify:cursor-tier1",
25
+ "verify:go-freeze",
26
+ "verify:bridge-drift",
27
+ "verify:branch",
28
+ "verify:encoding",
29
+ "verify:forward-coverage",
30
+ "verify:vbrief-conformance",
31
+ "verify:destructive-gh-verbs",
32
+ "verify:scm-boundary",
33
+ "verify:xbrief-drift",
34
+ "verify:no-task-runtime",
35
+ "verify:cache-fresh",
36
+ "verify:pack-drift",
37
+ // Public surface for Taskfile verify-wip-cap-framework-self-check (#1124 / #2791)
38
+ { task: "verify:wip-cap", args: ["--allow-over-cap"] },
39
+ "verify:orphan-active",
40
+ "verify:agents-md-budget",
41
+ // Public surfaces for internal eval-relocation framework shims (#2791)
42
+ { task: "verify:eval-health-relocation", args: ["--base-ref", "origin/master"] },
43
+ { task: "verify:eval-triggers-relocation", args: ["--base-ref", "origin/master"] },
44
+ "vbrief:validate",
45
+ "codebase:validate-structure",
46
+ "verify:codebase-map-fresh",
47
+ "verify-strategy-output",
48
+ ];
49
+ export const CONSUMER_CHECK_GATES = [
50
+ "doctor",
51
+ "toolchain:check-consumer",
52
+ "verify:branch",
53
+ "verify:cache-fresh",
54
+ "verify:wip-cap",
55
+ "verify:orphan-active",
56
+ "vbrief:validate",
57
+ "verify-strategy-output",
58
+ ];
59
+ export function gatesForCheckTarget(target) {
60
+ if (target === "check:framework-source") {
61
+ return FRAMEWORK_CHECK_GATES;
62
+ }
63
+ if (target === "check:consumer") {
64
+ return CONSUMER_CHECK_GATES;
65
+ }
66
+ return [];
67
+ }
68
+ //# sourceMappingURL=gate-lists.js.map
@@ -1,3 +1,6 @@
1
- export type { CheckOrchestratorSeams } from "./orchestrator.js";
1
+ export { dispatchCachedTaskCheck } from "./cached-orchestrator.js";
2
+ export { type CheckGateSpec, CONSUMER_CHECK_GATES, checkGateId, checkGateSpawnArgs, FRAMEWORK_CHECK_GATES, gatesForCheckTarget, } from "./gate-lists.js";
3
+ export type { CheckOrchestratorOptions, CheckOrchestratorSeams } from "./orchestrator.js";
2
4
  export { dispatchTaskCheck, isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget, } from "./orchestrator.js";
5
+ export { detectTestRunner, type RunnerDetectResult, runnerDetectionTable, type TestRunnerKind, } from "./runner-detect.js";
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,5 @@
1
+ export { dispatchCachedTaskCheck } from "./cached-orchestrator.js";
2
+ export { CONSUMER_CHECK_GATES, checkGateId, checkGateSpawnArgs, FRAMEWORK_CHECK_GATES, gatesForCheckTarget, } from "./gate-lists.js";
1
3
  export { dispatchTaskCheck, isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget, } from "./orchestrator.js";
4
+ export { detectTestRunner, runnerDetectionTable, } from "./runner-detect.js";
2
5
  //# sourceMappingURL=index.js.map
@@ -11,51 +11,9 @@
11
11
  * 1 -- one or more gates failed
12
12
  * 2 -- config error (missing args, task spawn error, etc.)
13
13
  */
14
- /** Seams for test isolation (allow injecting a custom task runner). */
15
- export interface CheckOrchestratorSeams {
16
- /** Override the `task` binary path (default: "task"). */
17
- readonly taskBin?: string;
18
- /** Override the spawnSync implementation for unit testing. */
19
- readonly spawnFn?: (cmd: string, args: string[], opts: {
20
- cwd: string;
21
- stdio: string;
22
- env?: NodeJS.ProcessEnv;
23
- timeoutMs?: number;
24
- }) => {
25
- status: number | null;
26
- signal?: NodeJS.Signals | null;
27
- error?: Error;
28
- };
29
- /** Child-process environment (default: process.env). */
30
- readonly env?: NodeJS.ProcessEnv;
31
- /** Wall-clock spawn timeout in milliseconds (default: none). */
32
- readonly timeoutMs?: number;
33
- }
34
- /**
35
- * True when `path` is the directive framework source checkout root (not a
36
- * vendored `.deft/core` content deposit). Used to distinguish a maintainer
37
- * running `task check` from a subdirectory (#2220) from a consumer install.
38
- */
39
- export declare function isFrameworkRepoRoot(path: string): boolean;
40
- /**
41
- * Return true when running in the framework's own source checkout (#1519).
42
- *
43
- * Mirrors `is_framework_source_context` from _project_context.py with one
44
- * extension (#2220): when the Taskfile lives at the framework repo root,
45
- * `task check` may be invoked from a subdirectory (`USER_WORKING_DIR` !=
46
- * `TASKFILE_DIR`). Those invocations must still route to
47
- * `check:framework-source` so the biome lane runs. We do NOT resolve
48
- * symlinks on the framework root -- a consumer project may symlink
49
- * `.deft/core` to a local framework checkout and should still run the
50
- * consumer-safe gate (the deposit path lacks `packages/cli`).
51
- */
52
- export declare function isFrameworkSourceContext(frameworkRoot: string, projectRoot: string): boolean;
53
- /**
54
- * Select the Taskfile target for the given context.
55
- *
56
- * Mirrors _project_context.py::dispatch_task_check target selection.
57
- */
58
- export declare function resolveCheckTarget(frameworkRoot: string, projectRoot: string): string;
14
+ import { type CheckOrchestratorSeams } from "./context.js";
15
+ export type { CheckOrchestratorOptions, CheckOrchestratorSeams } from "./context.js";
16
+ export { isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget } from "./context.js";
59
17
  /**
60
18
  * Dispatch to the context-appropriate `task check` aggregate target.
61
19
  *
@@ -12,52 +12,10 @@
12
12
  * 2 -- config error (missing args, task spawn error, etc.)
13
13
  */
14
14
  import { spawnSync } from "node:child_process";
15
- import { existsSync } from "node:fs";
16
- import { join, resolve, sep } from "node:path";
17
- /**
18
- * True when `path` is the directive framework source checkout root (not a
19
- * vendored `.deft/core` content deposit). Used to distinguish a maintainer
20
- * running `task check` from a subdirectory (#2220) from a consumer install.
21
- */
22
- export function isFrameworkRepoRoot(path) {
23
- const root = resolve(path);
24
- return (existsSync(join(root, "packages", "cli", "package.json")) &&
25
- existsSync(join(root, "biome.json")) &&
26
- existsSync(join(root, "Taskfile.yml")));
27
- }
28
- /**
29
- * Return true when running in the framework's own source checkout (#1519).
30
- *
31
- * Mirrors `is_framework_source_context` from _project_context.py with one
32
- * extension (#2220): when the Taskfile lives at the framework repo root,
33
- * `task check` may be invoked from a subdirectory (`USER_WORKING_DIR` !=
34
- * `TASKFILE_DIR`). Those invocations must still route to
35
- * `check:framework-source` so the biome lane runs. We do NOT resolve
36
- * symlinks on the framework root -- a consumer project may symlink
37
- * `.deft/core` to a local framework checkout and should still run the
38
- * consumer-safe gate (the deposit path lacks `packages/cli`).
39
- */
40
- export function isFrameworkSourceContext(frameworkRoot, projectRoot) {
41
- const fw = resolve(frameworkRoot);
42
- const pr = resolve(projectRoot);
43
- if (fw === pr) {
44
- return true;
45
- }
46
- if (!isFrameworkRepoRoot(fw)) {
47
- return false;
48
- }
49
- return pr.startsWith(`${fw}${sep}`);
50
- }
51
- /**
52
- * Select the Taskfile target for the given context.
53
- *
54
- * Mirrors _project_context.py::dispatch_task_check target selection.
55
- */
56
- export function resolveCheckTarget(frameworkRoot, projectRoot) {
57
- return isFrameworkSourceContext(frameworkRoot, projectRoot)
58
- ? "check:framework-source"
59
- : "check:consumer";
60
- }
15
+ import { join, resolve } from "node:path";
16
+ import { dispatchCachedTaskCheck } from "./cached-orchestrator.js";
17
+ import { resolveCheckTarget } from "./context.js";
18
+ export { isFrameworkRepoRoot, isFrameworkSourceContext, resolveCheckTarget } from "./context.js";
61
19
  /**
62
20
  * Dispatch to the context-appropriate `task check` aggregate target.
63
21
  *
@@ -69,6 +27,10 @@ export function resolveCheckTarget(frameworkRoot, projectRoot) {
69
27
  export function dispatchTaskCheck(frameworkRoot, projectRoot, seams = {}) {
70
28
  const resolvedFramework = resolve(frameworkRoot);
71
29
  const resolvedProject = resolve(projectRoot);
30
+ const useTaskCache = seams.useTaskCache !== false && !seams.noCache;
31
+ if (useTaskCache) {
32
+ return dispatchCachedTaskCheck(resolvedFramework, resolvedProject, seams);
33
+ }
72
34
  const taskfilePath = join(resolvedFramework, "Taskfile.yml");
73
35
  const taskBin = seams.taskBin ?? "task";
74
36
  const target = resolveCheckTarget(resolvedFramework, resolvedProject);
@@ -0,0 +1,20 @@
1
+ export type TestRunnerKind = "vitest" | "jest" | "go" | "pytest" | "none";
2
+ export interface RunnerDetectResult {
3
+ readonly kind: TestRunnerKind;
4
+ readonly affectedArgs: readonly string[];
5
+ readonly source: "config" | "heuristic" | "fallback";
6
+ readonly message?: string;
7
+ }
8
+ export interface RunnerDetectOptions {
9
+ readonly projectRoot: string;
10
+ readonly override?: TestRunnerKind;
11
+ }
12
+ /** Auto-detect consumer test runner with explicit override (#1713). */
13
+ export declare function detectTestRunner(options: RunnerDetectOptions): RunnerDetectResult;
14
+ /** Human-readable detection table rows for docs. */
15
+ export declare function runnerDetectionTable(): ReadonlyArray<{
16
+ runner: TestRunnerKind;
17
+ detection: string;
18
+ affectedConvention: string;
19
+ }>;
20
+ //# sourceMappingURL=runner-detect.d.ts.map
@@ -0,0 +1,131 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { projectDefinitionPath } from "../policy/index.js";
4
+ const RUNNER_AFFECTED = {
5
+ vitest: ["--changed"],
6
+ jest: ["--onlyChanged"],
7
+ go: [],
8
+ pytest: ["--testmon"],
9
+ };
10
+ function affectedArgsFor(kind) {
11
+ if (kind === "none") {
12
+ return [];
13
+ }
14
+ return RUNNER_AFFECTED[kind];
15
+ }
16
+ function readJson(path) {
17
+ try {
18
+ return JSON.parse(readFileSync(path, "utf8"));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function detectFromPolicy(projectRoot) {
25
+ try {
26
+ const planPath = projectDefinitionPath(projectRoot);
27
+ const plan = readJson(planPath);
28
+ const raw = plan?.plan?.policy?.testRunner;
29
+ if (raw === "vitest" || raw === "jest" || raw === "go" || raw === "pytest" || raw === "none") {
30
+ return raw;
31
+ }
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ return null;
37
+ }
38
+ function detectFromHeuristics(projectRoot) {
39
+ const root = resolve(projectRoot);
40
+ if (existsSync(join(root, "go.mod"))) {
41
+ return "go";
42
+ }
43
+ const pkgPath = join(root, "package.json");
44
+ if (existsSync(pkgPath)) {
45
+ const pkg = readJson(pkgPath);
46
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
47
+ if ("vitest" in deps) {
48
+ return "vitest";
49
+ }
50
+ if ("jest" in deps || "@jest/core" in deps) {
51
+ return "jest";
52
+ }
53
+ }
54
+ if (existsSync(join(root, "pytest.ini")) ||
55
+ existsSync(join(root, "pyproject.toml")) ||
56
+ existsSync(join(root, "requirements.txt"))) {
57
+ return "pytest";
58
+ }
59
+ return null;
60
+ }
61
+ /** Auto-detect consumer test runner with explicit override (#1713). */
62
+ export function detectTestRunner(options) {
63
+ if (options.override !== undefined) {
64
+ if (options.override === "none") {
65
+ return {
66
+ kind: "none",
67
+ affectedArgs: [],
68
+ source: "config",
69
+ message: "Runner override set to full-suite-only.",
70
+ };
71
+ }
72
+ return {
73
+ kind: options.override,
74
+ affectedArgs: affectedArgsFor(options.override),
75
+ source: "config",
76
+ };
77
+ }
78
+ const fromPolicy = detectFromPolicy(options.projectRoot);
79
+ if (fromPolicy !== null) {
80
+ return {
81
+ kind: fromPolicy,
82
+ affectedArgs: affectedArgsFor(fromPolicy),
83
+ source: "config",
84
+ };
85
+ }
86
+ const fromHeuristics = detectFromHeuristics(options.projectRoot);
87
+ if (fromHeuristics !== null) {
88
+ return {
89
+ kind: fromHeuristics,
90
+ affectedArgs: affectedArgsFor(fromHeuristics),
91
+ source: "heuristic",
92
+ };
93
+ }
94
+ return {
95
+ kind: "none",
96
+ affectedArgs: [],
97
+ source: "fallback",
98
+ message: "No supported test runner detected — merge gate uses the full suite.",
99
+ };
100
+ }
101
+ /** Human-readable detection table rows for docs. */
102
+ export function runnerDetectionTable() {
103
+ return [
104
+ {
105
+ runner: "vitest",
106
+ detection: "package.json lists vitest, or plan.policy.testRunner = vitest",
107
+ affectedConvention: "vitest --changed",
108
+ },
109
+ {
110
+ runner: "jest",
111
+ detection: "package.json lists jest / @jest/core, or plan.policy.testRunner = jest",
112
+ affectedConvention: "jest --onlyChanged",
113
+ },
114
+ {
115
+ runner: "go",
116
+ detection: "go.mod present, or plan.policy.testRunner = go",
117
+ affectedConvention: "go test (native package cache)",
118
+ },
119
+ {
120
+ runner: "pytest",
121
+ detection: "pytest.ini / pyproject.toml / requirements.txt, or plan.policy.testRunner = pytest",
122
+ affectedConvention: "pytest --testmon",
123
+ },
124
+ {
125
+ runner: "none",
126
+ detection: "No match after config + heuristics",
127
+ affectedConvention: "Full suite at merge gate",
128
+ },
129
+ ];
130
+ }
131
+ //# sourceMappingURL=runner-detect.js.map
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
3
4
  import { MAX_LINE_CHARS } from "../triage/welcome/constants.js";
4
5
  import { evaluateHealth, healthHistoryPath } from "./health.js";
5
6
  /** Repeat-suppression window for the budgeted eval session nudge (#1703 / #1279 parity). */
@@ -124,10 +125,14 @@ function appendEvalReadbackHistory(projectRoot, nudgeKey, line, options = {}) {
124
125
  line,
125
126
  };
126
127
  try {
128
+ assertWriteTargetSafe(projectRoot, path);
127
129
  mkdirSync(join(path, ".."), { recursive: true });
128
130
  appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
129
131
  }
130
- catch {
132
+ catch (err) {
133
+ if (err instanceof ProjectionContainmentError) {
134
+ throw err;
135
+ }
131
136
  // observability only
132
137
  }
133
138
  }
@@ -67,6 +67,8 @@ export declare function toProjectRelativePosix(projectRoot: string, targetPath:
67
67
  * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
68
68
  */
69
69
  export declare function isProposedLifecycleWrite(projectRoot: string, targetPath: string | null): boolean;
70
+ /** Normalize hook project-root resolution on Windows (doubled drive + MSYS `/c/...`). */
71
+ export declare function normalizeHookProjectRoot(path: string): string;
70
72
  export declare function projectRootFromHookPayload(payload: unknown, fallback: string): string;
71
73
  export declare function isHookHost(value: string): value is HookHost;
72
74
  export declare function isHookEvent(value: string): value is HookEvent;
@@ -164,17 +164,59 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
164
164
  function isWindowsDriveOnlyRoot(value) {
165
165
  return /^[A-Za-z]:[/\\]?$/.test(value.trim());
166
166
  }
167
+ function hookPayloadRootCandidates(input) {
168
+ const candidates = [];
169
+ const push = (value) => {
170
+ if (typeof value === "string" && value.trim().length > 0) {
171
+ candidates.push(value.trim());
172
+ }
173
+ };
174
+ push(input.workspaceRoot);
175
+ push(input.workspace_root);
176
+ const workspaceRoots = input.workspace_roots;
177
+ if (Array.isArray(workspaceRoots)) {
178
+ for (const entry of workspaceRoots)
179
+ push(entry);
180
+ }
181
+ push(input.cwd);
182
+ return candidates;
183
+ }
184
+ /** Collapse join('C:', 'c:\\...') doubled drive prefix on Windows (#2787). */
185
+ function collapseDoubledWindowsDrivePrefix(path) {
186
+ return path.replace(/^([A-Za-z]:\\)(?=[A-Za-z]:\\)/i, "");
187
+ }
188
+ function msysPathToWin32(path) {
189
+ const match = /^\/([a-zA-Z])\/(.*)$/.exec(path.trim());
190
+ if (match === null || match[1] === undefined || match[2] === undefined)
191
+ return null;
192
+ return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, "\\")}`;
193
+ }
194
+ /** Normalize hook project-root resolution on Windows (doubled drive + MSYS `/c/...`). */
195
+ export function normalizeHookProjectRoot(path) {
196
+ if (process.platform !== "win32")
197
+ return resolve(path);
198
+ const trimmed = path.trim();
199
+ const msys = msysPathToWin32(trimmed);
200
+ let resolved = resolve(msys ?? trimmed);
201
+ const collapsed = collapseDoubledWindowsDrivePrefix(resolved);
202
+ if (collapsed !== resolved) {
203
+ resolved = resolve(collapsed);
204
+ }
205
+ if (/^[a-z]:\\/.test(resolved)) {
206
+ resolved = `${resolved.charAt(0).toUpperCase()}${resolved.slice(1)}`;
207
+ }
208
+ return resolved;
209
+ }
167
210
  export function projectRootFromHookPayload(payload, fallback) {
211
+ const fallbackResolved = normalizeHookProjectRoot(fallback);
168
212
  const input = record(payload);
169
- const fallbackResolved = resolve(fallback);
170
213
  if (input === null)
171
214
  return fallbackResolved;
172
- const workspaceRoots = input.workspace_roots;
173
- const candidate = firstString(input.workspaceRoot, input.workspace_root, Array.isArray(workspaceRoots) ? workspaceRoots[0] : null, input.cwd);
174
- if (candidate === null || isWindowsDriveOnlyRoot(candidate)) {
175
- return fallbackResolved;
215
+ const usable = hookPayloadRootCandidates(input).find((candidate) => !isWindowsDriveOnlyRoot(candidate));
216
+ if (usable !== undefined) {
217
+ return normalizeHookProjectRoot(usable);
176
218
  }
177
- return resolve(candidate);
219
+ return fallbackResolved;
178
220
  }
179
221
  export function isHookHost(value) {
180
222
  return HOOK_HOSTS.includes(value);
@@ -22,6 +22,7 @@ const DEFT_CORE_COVERING_LINES = new Set([".deft/core/", ".deft/core"]);
22
22
  */
23
23
  export const CANONICAL_GITIGNORE_BASELINE = [
24
24
  ".deft-cache/",
25
+ ".deft/cache/",
25
26
  ".deft/.cli/",
26
27
  ".deft/ritual-state.json",
27
28
  ".deft/last-session.json",
@@ -9,6 +9,7 @@
9
9
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
11
11
  import { referenceTypeMatches } from "@deftai/directive-types";
12
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
12
13
  import { hasArtifactSuffix, LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveLifecycleRoot, } from "../layout/resolve.js";
13
14
  import { referenceWithDefaultTrust, slugify } from "../vbrief-build/build.js";
14
15
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
@@ -52,7 +53,8 @@ function loadJson(path) {
52
53
  }
53
54
  return data;
54
55
  }
55
- function writeJson(path, data) {
56
+ function writeJson(projectRoot, path, data) {
57
+ assertWriteTargetSafe(projectRoot, path);
56
58
  mkdirSync(dirname(path), { recursive: true });
57
59
  writeFileSync(path, formatBriefJson(data), "utf8");
58
60
  }
@@ -871,7 +873,7 @@ export function applyDecomposition(opts) {
871
873
  }
872
874
  for (let i = 0; i < childPaths.length; i += 1) {
873
875
  // biome-ignore lint/style/noNonNullAssertion: loop bound ensures these exist
874
- writeJson(childPaths[i].target, childDocs[i]);
876
+ writeJson(projectRoot, childPaths[i].target, childDocs[i]);
875
877
  }
876
878
  let parentPlan = parent.plan;
877
879
  if (parentPlan === null || parentPlan === undefined) {
@@ -911,7 +913,7 @@ export function applyDecomposition(opts) {
911
913
  planObj.references = dedupeReferences(references
912
914
  .filter((r) => typeof r === "object" && r !== null && !Array.isArray(r))
913
915
  .map((r) => r));
914
- writeJson(parentPath, parent);
916
+ writeJson(projectRoot, parentPath, parent);
915
917
  actions.push(`UPDATE ${parentRel} references`);
916
918
  return actions;
917
919
  }
@@ -1022,6 +1024,10 @@ export function decomposeMain(argv) {
1022
1024
  process.stderr.write(`ERROR: ${err.message}\n`);
1023
1025
  return 1;
1024
1026
  }
1027
+ if (err instanceof ProjectionContainmentError) {
1028
+ process.stderr.write(`ERROR: ${err.message}\n`);
1029
+ return 2;
1030
+ }
1025
1031
  process.stderr.write(`ERROR: ${String(err)}\n`);
1026
1032
  return 1;
1027
1033
  }
@@ -10,5 +10,7 @@ export declare function gitHead(projectRoot: string, runGit?: GitRunner): {
10
10
  error: string | null;
11
11
  };
12
12
  export declare function worktreePath(projectRoot: string, runGit?: GitRunner): string;
13
+ /** True when `ancestor` is reachable from `descendant` (same commit counts). */
14
+ export declare function gitIsAncestor(projectRoot: string, ancestor: string, descendant: string, runGit?: GitRunner): boolean | null;
13
15
  export declare function detectBranch(projectRoot: string, runGit?: GitRunner): string | null;
14
16
  //# sourceMappingURL=git.d.ts.map
@@ -35,6 +35,20 @@ export function worktreePath(projectRoot, runGit = defaultGitRunner) {
35
35
  }
36
36
  return resolve(projectRoot);
37
37
  }
38
+ /** True when `ancestor` is reachable from `descendant` (same commit counts). */
39
+ export function gitIsAncestor(projectRoot, ancestor, descendant, runGit = defaultGitRunner) {
40
+ if (ancestor === descendant) {
41
+ return true;
42
+ }
43
+ const { code } = runGit(projectRoot, ["merge-base", "--is-ancestor", ancestor, descendant]);
44
+ if (code === 0) {
45
+ return true;
46
+ }
47
+ if (code === 1) {
48
+ return false;
49
+ }
50
+ return null;
51
+ }
38
52
  export function detectBranch(projectRoot, runGit = defaultGitRunner) {
39
53
  const sym = runGit(projectRoot, ["symbolic-ref", "--short", "HEAD"]);
40
54
  if (sym.code === 0 && sym.stdout.trim()) {