@deftai/directive-core 0.79.3 → 0.79.4

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.
@@ -0,0 +1,49 @@
1
+ import { type CoverageMetric, type CoverageTotals, summarizeCoverageFinal } from "../vitest-runner/coverage-debt.js";
2
+ import { type CoverageThresholds } from "./thresholds.js";
3
+ export declare const DEFAULT_MIN_HEADROOM_PP = 0.3;
4
+ export declare const DEFAULT_LOWEST_MODULE_LIMIT = 10;
5
+ export interface ModuleCoverage {
6
+ readonly path: string;
7
+ readonly lines: number;
8
+ readonly functions: number;
9
+ readonly branches: number;
10
+ readonly statements: number;
11
+ }
12
+ export interface UncoveredBranchSample {
13
+ readonly path: string;
14
+ readonly line: number;
15
+ readonly branchId: string;
16
+ readonly type: string;
17
+ }
18
+ export interface CoverageHotspotsReport {
19
+ readonly ok: boolean;
20
+ readonly global: CoverageTotals;
21
+ readonly thresholds: CoverageThresholds;
22
+ readonly headroomPp: Record<CoverageMetric, number>;
23
+ readonly minHeadroomPp: number;
24
+ readonly failReasons: readonly string[];
25
+ readonly lowestModules: readonly ModuleCoverage[];
26
+ readonly uncoveredBranches: readonly UncoveredBranchSample[];
27
+ readonly pathFilter: readonly string[];
28
+ readonly coverageReportPath: string;
29
+ }
30
+ export interface CoverageHotspotsResult {
31
+ readonly exitCode: 0 | 1 | 2;
32
+ readonly report: CoverageHotspotsReport | null;
33
+ readonly message: string;
34
+ }
35
+ export interface CoverageHotspotsOptions {
36
+ readonly projectRoot: string;
37
+ readonly coverageDir?: string;
38
+ readonly minHeadroomPp?: number;
39
+ readonly baseRef?: string | null;
40
+ readonly pathFilter?: readonly string[] | null;
41
+ readonly useDiffPaths?: boolean;
42
+ readonly lowestModuleLimit?: number;
43
+ }
44
+ export declare function evaluateCoverageHotspots(options: CoverageHotspotsOptions): CoverageHotspotsResult;
45
+ export declare function formatJsonReport(report: CoverageHotspotsReport): string;
46
+ export declare function formatTextReport(report: CoverageHotspotsReport, failed: boolean): string;
47
+ /** Re-export for tests that build synthetic reports. */
48
+ export { summarizeCoverageFinal };
49
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1,262 @@
1
+ import * as childProcess from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { summarizeCoverageFinal, } from "../vitest-runner/coverage-debt.js";
5
+ import { readProjectCoverageThresholds } from "./thresholds.js";
6
+ export const DEFAULT_MIN_HEADROOM_PP = 0.3;
7
+ export const DEFAULT_LOWEST_MODULE_LIMIT = 10;
8
+ class GitCommandError extends Error {
9
+ }
10
+ function runGit(args, projectRoot) {
11
+ try {
12
+ return childProcess.execFileSync("git", args, {
13
+ cwd: projectRoot,
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ });
17
+ }
18
+ catch (err) {
19
+ const e = err;
20
+ const stderr = String(e.stderr ?? e.message ?? err).trim();
21
+ throw new GitCommandError(`coverage-hotspots: git ${args.join(" ")} failed: ${stderr}`);
22
+ }
23
+ }
24
+ function splitLines(stdout) {
25
+ return stdout
26
+ .split("\n")
27
+ .map((line) => line.trim())
28
+ .filter((line) => line.length > 0);
29
+ }
30
+ function resolveBaseRef(projectRoot, override) {
31
+ if (override && override.trim().length > 0)
32
+ return override.trim();
33
+ for (const candidate of ["origin/master", "origin/main", "master", "main", "HEAD~1"]) {
34
+ try {
35
+ runGit(["rev-parse", "--verify", "-q", candidate], projectRoot);
36
+ return candidate;
37
+ }
38
+ catch {
39
+ // try next
40
+ }
41
+ }
42
+ return "HEAD~1";
43
+ }
44
+ function diffPaths(projectRoot, baseRef) {
45
+ return splitLines(runGit(["diff", "--name-only", baseRef, "HEAD"], projectRoot));
46
+ }
47
+ function pct(covered, total) {
48
+ return total === 0 ? 100 : (covered / total) * 100;
49
+ }
50
+ function summarizeFileCoverage(relPath, file) {
51
+ let stmtTotal = 0;
52
+ let stmtCovered = 0;
53
+ let fnTotal = 0;
54
+ let fnCovered = 0;
55
+ let branchTotal = 0;
56
+ let branchCovered = 0;
57
+ if (file.s) {
58
+ for (const hits of Object.values(file.s)) {
59
+ stmtTotal += 1;
60
+ if (hits > 0)
61
+ stmtCovered += 1;
62
+ }
63
+ }
64
+ if (file.f) {
65
+ for (const hits of Object.values(file.f)) {
66
+ fnTotal += 1;
67
+ if (hits > 0)
68
+ fnCovered += 1;
69
+ }
70
+ }
71
+ if (file.b) {
72
+ for (const paths of Object.values(file.b)) {
73
+ for (const hits of paths) {
74
+ branchTotal += 1;
75
+ if (hits > 0)
76
+ branchCovered += 1;
77
+ }
78
+ }
79
+ }
80
+ const statements = pct(stmtCovered, stmtTotal);
81
+ const functions = pct(fnCovered, fnTotal);
82
+ const branches = pct(branchCovered, branchTotal);
83
+ return {
84
+ path: relPath,
85
+ statements,
86
+ functions,
87
+ branches,
88
+ lines: statements,
89
+ };
90
+ }
91
+ function normalizeReportPath(projectRoot, rawKey, file) {
92
+ if (file.path && file.path.length > 0) {
93
+ const abs = file.path;
94
+ if (abs.startsWith(projectRoot)) {
95
+ return relative(projectRoot, abs).replace(/\\/g, "/");
96
+ }
97
+ return abs.replace(/\\/g, "/");
98
+ }
99
+ return rawKey.replace(/\\/g, "/");
100
+ }
101
+ function pathMatchesFilter(relPath, filters) {
102
+ if (filters.length === 0)
103
+ return true;
104
+ // Substring only — do not feed CLI/argv filters into RegExp (CodeQL js/regex-injection).
105
+ return filters.some((pattern) => relPath.includes(pattern));
106
+ }
107
+ function collectUncoveredBranches(relPath, file) {
108
+ if (!file.b)
109
+ return [];
110
+ const samples = [];
111
+ for (const [branchId, hits] of Object.entries(file.b)) {
112
+ if (!hits.some((count) => count === 0))
113
+ continue;
114
+ const meta = file.branchMap?.[branchId];
115
+ const line = meta?.line ?? meta?.loc?.start?.line ?? 0;
116
+ samples.push({
117
+ path: relPath,
118
+ line,
119
+ branchId,
120
+ type: meta?.type ?? "branch",
121
+ });
122
+ }
123
+ return samples;
124
+ }
125
+ function computeHeadroom(totals, thresholds) {
126
+ return {
127
+ lines: totals.lines - thresholds.lines,
128
+ functions: totals.functions - thresholds.functions,
129
+ branches: totals.branches - thresholds.branches,
130
+ statements: totals.statements - thresholds.statements,
131
+ };
132
+ }
133
+ function evaluatePassFail(totals, thresholds, headroomPp, minHeadroomPp) {
134
+ const reasons = [];
135
+ if (totals.branches + 1e-9 < thresholds.branches) {
136
+ reasons.push(`branches ${totals.branches.toFixed(2)}% below project floor ${thresholds.branches}%`);
137
+ }
138
+ if (headroomPp.branches + 1e-9 < minHeadroomPp) {
139
+ reasons.push(`branch headroom ${headroomPp.branches.toFixed(2)}pp below minimum ${minHeadroomPp}pp`);
140
+ }
141
+ return reasons;
142
+ }
143
+ function configError(message) {
144
+ return { exitCode: 2, report: null, message };
145
+ }
146
+ export function evaluateCoverageHotspots(options) {
147
+ const projectRoot = options.projectRoot;
148
+ const coverageDir = options.coverageDir ?? join(projectRoot, "coverage");
149
+ const minHeadroomPp = options.minHeadroomPp ?? DEFAULT_MIN_HEADROOM_PP;
150
+ const lowestModuleLimit = options.lowestModuleLimit ?? DEFAULT_LOWEST_MODULE_LIMIT;
151
+ const useDiffPaths = options.useDiffPaths ?? true;
152
+ const reportPath = join(coverageDir, "coverage-final.json");
153
+ if (!existsSync(reportPath)) {
154
+ return configError(`coverage-hotspots: coverage report missing at ${reportPath}\n` +
155
+ " Run tests with coverage first (e.g. vitest run --coverage or task test:coverage).");
156
+ }
157
+ let rawReport;
158
+ try {
159
+ rawReport = JSON.parse(readFileSync(reportPath, "utf8"));
160
+ }
161
+ catch (err) {
162
+ return configError(`coverage-hotspots: unreadable coverage report at ${reportPath}: ${String(err.message)}`);
163
+ }
164
+ const totals = summarizeCoverageFinal(rawReport);
165
+ const thresholds = readProjectCoverageThresholds(projectRoot);
166
+ const headroomPp = computeHeadroom(totals, thresholds);
167
+ let pathFilter = options.pathFilter ?? [];
168
+ if (pathFilter.length === 0 && useDiffPaths) {
169
+ try {
170
+ const baseRef = resolveBaseRef(projectRoot, options.baseRef);
171
+ pathFilter = diffPaths(projectRoot, baseRef);
172
+ }
173
+ catch (err) {
174
+ const message = err instanceof GitCommandError
175
+ ? `${err.message}\n Recovery: pass explicit --path filters or run inside a git repo.`
176
+ : `coverage-hotspots: ${String(err.message)}`;
177
+ return configError(message);
178
+ }
179
+ }
180
+ const modules = [];
181
+ const uncoveredBranches = [];
182
+ for (const [rawKey, file] of Object.entries(rawReport)) {
183
+ const relPath = normalizeReportPath(projectRoot, rawKey, file);
184
+ modules.push(summarizeFileCoverage(relPath, file));
185
+ if (pathMatchesFilter(relPath, pathFilter)) {
186
+ uncoveredBranches.push(...collectUncoveredBranches(relPath, file));
187
+ }
188
+ }
189
+ modules.sort((a, b) => a.branches - b.branches || a.path.localeCompare(b.path));
190
+ const lowestModules = modules.slice(0, lowestModuleLimit);
191
+ uncoveredBranches.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.branchId.localeCompare(b.branchId));
192
+ const failReasons = evaluatePassFail(totals, thresholds, headroomPp, minHeadroomPp);
193
+ const report = {
194
+ ok: failReasons.length === 0,
195
+ global: totals,
196
+ thresholds,
197
+ headroomPp,
198
+ minHeadroomPp,
199
+ failReasons,
200
+ lowestModules,
201
+ uncoveredBranches,
202
+ pathFilter,
203
+ coverageReportPath: reportPath,
204
+ };
205
+ if (failReasons.length > 0) {
206
+ return {
207
+ exitCode: 1,
208
+ report,
209
+ message: formatTextReport(report, true),
210
+ };
211
+ }
212
+ return {
213
+ exitCode: 0,
214
+ report,
215
+ message: formatTextReport(report, false),
216
+ };
217
+ }
218
+ export function formatJsonReport(report) {
219
+ return `${JSON.stringify(report, null, 2)}\n`;
220
+ }
221
+ function metricLine(label, value, threshold, headroom) {
222
+ return ` ${label.padEnd(12)} ${value.toFixed(2)}% (floor ${threshold}% | headroom ${headroom.toFixed(2)}pp)`;
223
+ }
224
+ export function formatTextReport(report, failed) {
225
+ const lines = [];
226
+ lines.push(failed ? "coverage-hotspots: FAIL" : "coverage-hotspots: OK");
227
+ lines.push(`report: ${report.coverageReportPath}`);
228
+ lines.push("global:");
229
+ lines.push(metricLine("branches", report.global.branches, report.thresholds.branches, report.headroomPp.branches));
230
+ lines.push(metricLine("lines", report.global.lines, report.thresholds.lines, report.headroomPp.lines));
231
+ lines.push(metricLine("functions", report.global.functions, report.thresholds.functions, report.headroomPp.functions));
232
+ lines.push(metricLine("statements", report.global.statements, report.thresholds.statements, report.headroomPp.statements));
233
+ lines.push(`min branch headroom: ${report.minHeadroomPp}pp`);
234
+ if (report.pathFilter.length > 0) {
235
+ lines.push(`path filter (${report.pathFilter.length}): ${report.pathFilter.slice(0, 8).join(", ")}${report.pathFilter.length > 8 ? ", ..." : ""}`);
236
+ }
237
+ if (report.failReasons.length > 0) {
238
+ lines.push("fail reasons:");
239
+ for (const reason of report.failReasons) {
240
+ lines.push(` - ${reason}`);
241
+ }
242
+ }
243
+ if (report.lowestModules.length > 0) {
244
+ lines.push("lowest modules (branches):");
245
+ for (const mod of report.lowestModules.slice(0, 5)) {
246
+ lines.push(` - ${mod.path}: branches ${mod.branches.toFixed(2)}%`);
247
+ }
248
+ }
249
+ if (report.uncoveredBranches.length > 0) {
250
+ lines.push("uncovered branch samples:");
251
+ for (const sample of report.uncoveredBranches.slice(0, 12)) {
252
+ lines.push(` - ${sample.path}:${sample.line} (${sample.type} #${sample.branchId})`);
253
+ }
254
+ if (report.uncoveredBranches.length > 12) {
255
+ lines.push(` ... ${report.uncoveredBranches.length - 12} more`);
256
+ }
257
+ }
258
+ return `${lines.join("\n")}\n`;
259
+ }
260
+ /** Re-export for tests that build synthetic reports. */
261
+ export { summarizeCoverageFinal };
262
+ //# sourceMappingURL=evaluate.js.map
@@ -0,0 +1,3 @@
1
+ export * from "./evaluate.js";
2
+ export * from "./thresholds.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,3 @@
1
+ export * from "./evaluate.js";
2
+ export * from "./thresholds.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,5 @@
1
+ import { type CoverageMetric } from "../vitest-runner/coverage-debt.js";
2
+ export type CoverageThresholds = Record<CoverageMetric, number>;
3
+ /** Best-effort read of vitest coverage thresholds; falls back to framework 85% defaults. */
4
+ export declare function readProjectCoverageThresholds(projectRoot: string): CoverageThresholds;
5
+ //# sourceMappingURL=thresholds.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { COVERAGE_GOAL } from "../vitest-runner/coverage-debt.js";
4
+ const VITEST_CONFIG_CANDIDATES = [
5
+ "vitest.config.ts",
6
+ "vitest.config.mts",
7
+ "vitest.config.js",
8
+ "vitest.config.cjs",
9
+ "vitest.config.mjs",
10
+ ];
11
+ const METRIC_PATTERN = /(?:^|[\n\r{,])\s*(branches|lines|functions|statements)\s*:\s*(\d+(?:\.\d+)?)/g;
12
+ function parseMetricBlock(text, anchor) {
13
+ const match = anchor.exec(text);
14
+ if (match?.index === undefined)
15
+ return {};
16
+ const slice = text.slice(match.index, match.index + 800);
17
+ const out = {};
18
+ for (const metricMatch of slice.matchAll(METRIC_PATTERN)) {
19
+ const metric = metricMatch[1];
20
+ const value = metricMatch[2];
21
+ if (metric === undefined || value === undefined)
22
+ continue;
23
+ out[metric] = Number.parseFloat(value);
24
+ }
25
+ return out;
26
+ }
27
+ /** Best-effort read of vitest coverage thresholds; falls back to framework 85% defaults. */
28
+ export function readProjectCoverageThresholds(projectRoot) {
29
+ for (const name of VITEST_CONFIG_CANDIDATES) {
30
+ const path = join(projectRoot, name);
31
+ if (!existsSync(path))
32
+ continue;
33
+ const text = readFileSync(path, "utf8");
34
+ const fromCoverageThresholds = parseMetricBlock(text, /coverageThresholds\s*=\s*\{/);
35
+ const fromNested = parseMetricBlock(text, /thresholds\s*:\s*\{/);
36
+ const merged = { ...fromNested, ...fromCoverageThresholds };
37
+ if (Object.keys(merged).length > 0) {
38
+ return {
39
+ lines: merged.lines ?? COVERAGE_GOAL.lines,
40
+ functions: merged.functions ?? COVERAGE_GOAL.functions,
41
+ branches: merged.branches ?? COVERAGE_GOAL.branches,
42
+ statements: merged.statements ?? COVERAGE_GOAL.statements,
43
+ };
44
+ }
45
+ }
46
+ return { ...COVERAGE_GOAL };
47
+ }
48
+ //# sourceMappingURL=thresholds.js.map
@@ -17,11 +17,17 @@ export interface HookDecision {
17
17
  readonly message: string;
18
18
  readonly scopePath: string | null;
19
19
  }
20
+ /** Stdin parse metadata from hook-dispatch; absent when callers supply payload directly. */
21
+ export interface HookPayloadContext {
22
+ readonly stdinEmpty?: boolean;
23
+ readonly parseFailed?: boolean;
24
+ }
20
25
  export interface HookDispatchInput {
21
26
  readonly host: HookHost;
22
27
  readonly event: HookEvent;
23
28
  readonly projectRoot: string;
24
29
  readonly payload: unknown;
30
+ readonly payloadContext?: HookPayloadContext;
25
31
  }
26
32
  export interface HookPolicySeams {
27
33
  readonly inspectRitual?: (projectRoot: string) => VerifyResult;
@@ -32,6 +38,7 @@ export interface HookPolicySeams {
32
38
  stderr: string;
33
39
  };
34
40
  }
41
+ export declare function hookPayloadTopLevelKeys(payload: unknown): string[];
35
42
  export declare function hookToolName(payload: unknown, host?: HookHost): string | null;
36
43
  /**
37
44
  * Best-effort write-target path from host PreToolUse payloads (#2625).
@@ -29,7 +29,12 @@ function fieldString(input, key) {
29
29
  return null;
30
30
  }
31
31
  function toolInputRecord(payload) {
32
- return record(payload.tool_input) ?? record(payload.toolInput) ?? record(payload.input);
32
+ const toolCall = record(payload.tool_call) ?? record(payload.toolCall);
33
+ return (record(payload.tool_input) ??
34
+ record(payload.toolInput) ??
35
+ record(payload.input) ??
36
+ record(payload.arguments) ??
37
+ (toolCall !== null ? record(toolCall.arguments) : null));
33
38
  }
34
39
  /**
35
40
  * Cursor preToolUse payloads sometimes omit `tool_name` even when the hook matcher
@@ -65,19 +70,50 @@ function inferCursorDirectWriteToolName(payload) {
65
70
  return "Write";
66
71
  return null;
67
72
  }
73
+ export function hookPayloadTopLevelKeys(payload) {
74
+ const input = record(payload);
75
+ if (input === null)
76
+ return [];
77
+ return Object.keys(input).sort();
78
+ }
68
79
  export function hookToolName(payload, host) {
69
80
  const input = record(payload);
70
81
  if (input === null)
71
82
  return null;
72
- const direct = fieldString(input, "tool_name") ?? fieldString(input, "toolName") ?? fieldString(input, "tool");
83
+ const toolObject = record(input.tool);
84
+ const toolCall = record(input.tool_call) ?? record(input.toolCall);
85
+ // OpenAI-style nestings are host-agnostic; checked before Cursor-only inference.
86
+ const direct = fieldString(input, "tool_name") ??
87
+ fieldString(input, "toolName") ??
88
+ fieldString(input, "tool") ??
89
+ (toolObject !== null ? fieldString(toolObject, "name") : null) ??
90
+ (toolCall !== null ? fieldString(toolCall, "name") : null);
73
91
  if (direct !== null)
74
92
  return direct;
75
93
  if (host === "cursor")
76
94
  return inferCursorDirectWriteToolName(input);
77
95
  return null;
78
96
  }
79
- function missingToolNameMessage(host) {
97
+ function missingToolNameMessage(input) {
98
+ const { host, payload, context } = input;
80
99
  if (host === "cursor") {
100
+ if (context?.stdinEmpty) {
101
+ return ("Directive denied this Cursor preToolUse event because the host sent an empty payload " +
102
+ "(stdin was empty — not a session ritual or scope failure). " +
103
+ "If write tools should pass, update Directive or report the payload shape from Cursor.");
104
+ }
105
+ if (context?.parseFailed) {
106
+ return ("Directive denied this Cursor preToolUse event because the host payload was not valid JSON " +
107
+ "(host-integration mismatch — not a session ritual or scope failure). " +
108
+ "If write tools should pass, update Directive or report the payload shape from Cursor.");
109
+ }
110
+ const keys = hookPayloadTopLevelKeys(payload);
111
+ if (keys.length > 0) {
112
+ return ("Directive denied this Cursor preToolUse event because the host payload omitted a " +
113
+ "recognizable tool name (host-integration mismatch — not a session ritual or scope failure). " +
114
+ `Top-level payload keys: ${keys.join(", ")}. ` +
115
+ "If write tools should pass, update Directive or report the payload shape from Cursor.");
116
+ }
81
117
  return ("Directive denied this Cursor preToolUse event because the host payload omitted a " +
82
118
  "recognizable tool name (host-integration mismatch — not a session ritual or scope failure). " +
83
119
  "If write tools should pass, update Directive or report the payload shape from Cursor.");
@@ -189,7 +225,11 @@ export function decideHook(input, seams = {}) {
189
225
  }
190
226
  const toolName = hookToolName(input.payload, input.host);
191
227
  if (toolName === null) {
192
- return deny(input, "invalid-input", null, missingToolNameMessage(input.host));
228
+ return deny(input, "invalid-input", null, missingToolNameMessage({
229
+ host: input.host,
230
+ payload: input.payload,
231
+ context: input.payloadContext,
232
+ }));
193
233
  }
194
234
  if (!isDirectWriteTool(toolName)) {
195
235
  return {
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export * as branch from "./branch/index.js";
13
13
  export * as cache from "./cache/index.js";
14
14
  export * as capacity from "./capacity/index.js";
15
15
  export * as codebase from "./codebase/index.js";
16
+ export * from "./coverage-hotspots/index.js";
16
17
  export * as doctor from "./doctor/index.js";
17
18
  export * from "./encoding/index.js";
18
19
  export * as evalCrud from "./eval/crud-telemetry.js";
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export * as branch from "./branch/index.js";
13
13
  export * as cache from "./cache/index.js";
14
14
  export * as capacity from "./capacity/index.js";
15
15
  export * as codebase from "./codebase/index.js";
16
+ export * from "./coverage-hotspots/index.js";
16
17
  export * as doctor from "./doctor/index.js";
17
18
  export * from "./encoding/index.js";
18
19
  export * as evalCrud from "./eval/crud-telemetry.js";
@@ -5,6 +5,13 @@
5
5
  * freshness. A current `.deft/core/VERSION` therefore cannot short-circuit
6
6
  * these repairs.
7
7
  */
8
+ /** Rewrite legacy vbrief/.eval description paths for xbrief/schemas projection (#2670). */
9
+ export declare function rewriteProjectedSchemaContent(content: string): string;
10
+ /**
11
+ * Fail closed when a projected xbrief/schemas file still cites vbrief/.eval/.
12
+ * Upstream vbrief/schemas/ may keep legacy paths; consumer copies must not (#2670).
13
+ */
14
+ export declare function assertProjectedSchemaDescriptionsRooted(projectDir: string, destinationDir: string): void;
8
15
  /**
9
16
  * Synchronize framework-owned xBRIEF schemas while preserving unknown consumer
10
17
  * files. The obsolete v0.6 root schema is the only destination-only file this
@@ -13,6 +13,10 @@ import { DEV_FALLBACK } from "../platform/constants.js";
13
13
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
14
14
  const OBSOLETE_CORE_SCHEMA = "vbrief-core.schema.json";
15
15
  const CURRENT_CORE_SCHEMA = "xbrief-core-0.8.schema.json";
16
+ /** Legacy lifecycle eval prefix in framework vbrief/schemas source copies. */
17
+ const LEGACY_EVAL_PATH_PREFIX = "vbrief/.eval/";
18
+ /** Consumer xbrief/schemas projection must root description paths here (#2670). */
19
+ const XBRIEF_EVAL_PATH_PREFIX = "xbrief/.eval/";
16
20
  function normalizeVersion(version) {
17
21
  return version.trim().replace(/^v/, "");
18
22
  }
@@ -39,6 +43,28 @@ function collectSchemaFiles(root, dir = root, files = []) {
39
43
  }
40
44
  return files;
41
45
  }
46
+ /** Rewrite legacy vbrief/.eval description paths for xbrief/schemas projection (#2670). */
47
+ export function rewriteProjectedSchemaContent(content) {
48
+ return content.includes(LEGACY_EVAL_PATH_PREFIX)
49
+ ? content.replaceAll(LEGACY_EVAL_PATH_PREFIX, XBRIEF_EVAL_PATH_PREFIX)
50
+ : content;
51
+ }
52
+ /**
53
+ * Fail closed when a projected xbrief/schemas file still cites vbrief/.eval/.
54
+ * Upstream vbrief/schemas/ may keep legacy paths; consumer copies must not (#2670).
55
+ */
56
+ export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationDir) {
57
+ assertProjectionContained(projectDir, destinationDir);
58
+ if (!isDirectory(destinationDir))
59
+ return;
60
+ for (const rel of collectSchemaFiles(destinationDir)) {
61
+ const full = join(destinationDir, rel);
62
+ const text = readFileSync(full, "utf8");
63
+ if (text.includes(LEGACY_EVAL_PATH_PREFIX)) {
64
+ throw new Error(`projected xbrief schema still cites ${LEGACY_EVAL_PATH_PREFIX}: ${join(MIGRATED_ARTIFACT_DIR, "schemas", rel)}`);
65
+ }
66
+ }
67
+ }
42
68
  function writeFileIfChanged(projectDir, target, content) {
43
69
  assertProjectionContained(projectDir, target);
44
70
  const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
@@ -73,7 +99,8 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
73
99
  continue;
74
100
  const source = join(sourceDir, rel);
75
101
  const destination = join(destinationDir, rel);
76
- changed = writeFileIfChanged(projectDir, destination, readFileSync(source)) || changed;
102
+ const projected = rewriteProjectedSchemaContent(readFileSync(source, "utf8"));
103
+ changed = writeFileIfChanged(projectDir, destination, projected) || changed;
77
104
  }
78
105
  const obsoleteDestination = join(destinationDir, OBSOLETE_CORE_SCHEMA);
79
106
  assertProjectionContained(projectDir, obsoleteDestination);
@@ -81,6 +108,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
81
108
  rmSync(obsoleteDestination, { force: true });
82
109
  changed = true;
83
110
  }
111
+ assertProjectedSchemaDescriptionsRooted(projectDir, destinationDir);
84
112
  return changed;
85
113
  }
86
114
  function syncBareVersionMarkerWithPolicy(projectDir, version, allowRootFallback) {
@@ -1,5 +1,3 @@
1
- import type { FetchAllReportImpl } from "../cache/fetch.js";
2
- import type { CacheModule } from "../triage/bootstrap/types.js";
3
- /** Adapt TS cache reports to the bootstrap `FetchAllReport` shape. */
4
- export declare function bootstrapCacheModule(cacheFetchAll: (options: Parameters<typeof import("../cache/fetch.js").cacheFetchAll>[0]) => FetchAllReportImpl): CacheModule;
1
+ /** Re-export bootstrap TS cache adapter (canonical home: triage/bootstrap). */
2
+ export { bootstrapCacheModule } from "../triage/bootstrap/cache-module.js";
5
3
  //# sourceMappingURL=bootstrap-cache-module.d.ts.map
@@ -1,21 +1,3 @@
1
- /** Adapt TS cache reports to the bootstrap `FetchAllReport` shape. */
2
- export function bootstrapCacheModule(cacheFetchAll) {
3
- return {
4
- cacheFetchAll(kwargs) {
5
- const report = cacheFetchAll({
6
- source: kwargs.source,
7
- repo: kwargs.repo,
8
- batchSize: kwargs.batchSize,
9
- delayMs: kwargs.delayMs,
10
- cacheRoot: kwargs.cacheRoot,
11
- });
12
- return Promise.resolve({
13
- succeeded: report.issuesWritten,
14
- failed: report.issuesFailed,
15
- skipped: report.alreadyFresh,
16
- summaryLine: (source, repo) => report.summaryLine(source, repo),
17
- });
18
- },
19
- };
20
- }
1
+ /** Re-export bootstrap TS cache adapter (canonical home: triage/bootstrap). */
2
+ export { bootstrapCacheModule } from "../triage/bootstrap/cache-module.js";
21
3
  //# sourceMappingURL=bootstrap-cache-module.js.map
@@ -2,6 +2,13 @@ import { classifyMonitorOutcome, parseMonitorPayload } from "./classify.js";
2
2
  import { EXIT_CONFIG_ERROR, EXIT_MERGED, EXIT_TIMEOUT_OR_ESCALATION } from "./constants.js";
3
3
  import { makeResult } from "./result.js";
4
4
  import { runGhMerge, runMonitor, runProtectedCheck } from "./wrappers.js";
5
+ /** Node module-not-found / missing script — not a protected-issue overlap (#2667). */
6
+ function isProtectedCheckConfigFailure(stderr) {
7
+ const tail = stderr.trim();
8
+ return (tail.includes("MODULE_NOT_FOUND") ||
9
+ tail.includes("Cannot find module") ||
10
+ tail.includes("protected-check script not found:"));
11
+ }
5
12
  /** Run protected-check -> wait -> merge cascade (#1369). */
6
13
  export function waitMergeableAndMerge(prNumber, repo, options) {
7
14
  const protectedFn = options.protectedFn ?? runProtectedCheck;
@@ -17,7 +24,7 @@ export function waitMergeableAndMerge(prNumber, repo, options) {
17
24
  stderr: prcStderr,
18
25
  protected: [...protectedIssues],
19
26
  };
20
- if (prcRc === 1) {
27
+ if (prcRc === 1 && !isProtectedCheckConfigFailure(prcStderr)) {
21
28
  return makeResult({
22
29
  prNumber,
23
30
  repo,
@@ -5,7 +5,11 @@ export function classifyMonitorOutcome(monitorReturncode, monitorPayload) {
5
5
  return ["clean", EXIT_MERGED];
6
6
  }
7
7
  if (monitorReturncode === 1) {
8
- return ["cap-reached", EXIT_TIMEOUT_OR_ESCALATION];
8
+ // Bare exit 1 (MODULE_NOT_FOUND / missing script) is not budget exhaustion (#2673).
9
+ if (monitorPayload.monitor_result === "CAP-REACHED") {
10
+ return ["cap-reached", EXIT_TIMEOUT_OR_ESCALATION];
11
+ }
12
+ return ["config-error", EXIT_CONFIG_ERROR];
9
13
  }
10
14
  if (monitorReturncode === 2) {
11
15
  return ["config-error", EXIT_CONFIG_ERROR];
@@ -56,14 +56,17 @@ export function cliScriptPath(name) {
56
56
  const candidates = [];
57
57
  try {
58
58
  const require = createRequire(import.meta.url);
59
- const pkgJson = require.resolve("@deftai/directive/package.json");
60
- candidates.push(resolve(dirname(pkgJson), "dist", script));
59
+ // package.json is not an exported subpath under @deftai/directive exports (#2667).
60
+ const mainEntry = require.resolve("@deftai/directive");
61
+ candidates.push(resolve(dirname(mainEntry), script));
61
62
  }
62
63
  catch {
63
64
  // Core does not declare a runtime dependency on the CLI package; ignore.
64
65
  }
65
66
  // Monorepo: packages/core/dist/pr-wait-mergeable -> packages/cli/dist
66
67
  candidates.push(resolve(here, "../../../cli/dist", script));
68
+ // npm hoisted: .../@deftai/directive-core/dist/... -> .../@deftai/directive/dist
69
+ candidates.push(resolve(here, "../../../directive/dist", script));
67
70
  // npm nested: .../directive/node_modules/@deftai/directive-core/dist/... -> .../directive/dist
68
71
  candidates.push(resolve(here, "../../../../dist", script));
69
72
  for (const candidate of candidates) {
@@ -75,9 +78,13 @@ export function cliScriptPath(name) {
75
78
  }
76
79
  /** Invoke pr-protected-issues CLI and return (returncode, stdout, stderr). */
77
80
  export function runProtectedCheck(prNumber, repo, protectedIssues, options = {}) {
81
+ const scriptPath = cliScriptPath("pr-protected-issues");
82
+ if (!existsSync(scriptPath)) {
83
+ return [2, "", `protected-check script not found: ${scriptPath}`];
84
+ }
78
85
  const node = options.nodeExecutable ?? process.execPath;
79
86
  const cmd = [
80
- cliScriptPath("pr-protected-issues"),
87
+ scriptPath,
81
88
  String(prNumber),
82
89
  "--protected",
83
90
  protectedIssues.map(String).join(","),
@@ -92,10 +99,14 @@ export function runProtectedCheck(prNumber, repo, protectedIssues, options = {})
92
99
  }
93
100
  /** Invoke pr-monitor CLI with --json and return (returncode, stdout, stderr). */
94
101
  export function runMonitor(prNumber, repo, capMinutes, options = {}) {
102
+ const scriptPath = cliScriptPath("pr-monitor");
103
+ if (!existsSync(scriptPath)) {
104
+ return [2, "", `monitor script not found: ${scriptPath}`];
105
+ }
95
106
  const node = options.nodeExecutable ?? process.execPath;
96
107
  const timeoutSec = options.timeout ?? capMinutes * 60 + 60;
97
108
  const cmd = [
98
- cliScriptPath("pr-monitor"),
109
+ scriptPath,
99
110
  String(prNumber),
100
111
  "--repo",
101
112
  repo,
@@ -17,6 +17,11 @@ export declare const VERDICT_NEW_P0_P1 = "NEW_P0_P1";
17
17
  export declare const VERDICT_ERRORED = "ERRORED";
18
18
  export declare const VERDICT_STALL = "STALL";
19
19
  export declare const VERDICT_TIMEOUT = "TIMEOUT";
20
+ /**
21
+ * Greptile side of the clean gate is satisfied on HEAD but required CI is red
22
+ * (#2688). Exit 2 — fail-loud toward a CI fix loop instead of idle-polling.
23
+ */
24
+ export declare const VERDICT_CI_BLOCKED = "CI_BLOCKED";
20
25
  /** --one-shot only: a single probe with no terminal verdict yet. */
21
26
  export declare const VERDICT_PENDING = "PENDING";
22
27
  /** External/config fault mid-probe (unresolvable repo/HEAD, gh unavailable). */
@@ -35,4 +40,9 @@ export declare const WATCH_HELP: string;
35
40
  * whole 30-minute budget (#1039 STALL terminal).
36
41
  */
37
42
  export declare const DEFAULT_STALL_THRESHOLD = 3;
43
+ /**
44
+ * Consecutive polls with clean_gate_holdout=ci_failures (Greptile otherwise
45
+ * satisfied) before CI_BLOCKED (#2688). Same default as SHA STALL.
46
+ */
47
+ export declare const DEFAULT_CI_BLOCKED_THRESHOLD = 3;
38
48
  //# sourceMappingURL=constants.d.ts.map
@@ -17,6 +17,11 @@ export const VERDICT_NEW_P0_P1 = "NEW_P0_P1";
17
17
  export const VERDICT_ERRORED = "ERRORED";
18
18
  export const VERDICT_STALL = "STALL";
19
19
  export const VERDICT_TIMEOUT = "TIMEOUT";
20
+ /**
21
+ * Greptile side of the clean gate is satisfied on HEAD but required CI is red
22
+ * (#2688). Exit 2 — fail-loud toward a CI fix loop instead of idle-polling.
23
+ */
24
+ export const VERDICT_CI_BLOCKED = "CI_BLOCKED";
20
25
  /** --one-shot only: a single probe with no terminal verdict yet. */
21
26
  export const VERDICT_PENDING = "PENDING";
22
27
  /** External/config fault mid-probe (unresolvable repo/HEAD, gh unavailable). */
@@ -49,7 +54,7 @@ export const WATCH_HELP = "usage: task pr:watch -- <pr_number> [options]\n" +
49
54
  "exit codes:\n" +
50
55
  " 0 CLEAN SHA-matched review, confidence > 3, no P0/P1, CI green\n" +
51
56
  " 1 NEW_P0_P1 Blocking findings on the current (SHA-matched) review\n" +
52
- " 2 ERRORED | STALL | TIMEOUT | config / usage error\n";
57
+ " 2 ERRORED | STALL | TIMEOUT | CI_BLOCKED | config / usage error\n";
53
58
  /**
54
59
  * Consecutive polls with a review PRESENT but stuck on a stale (non-HEAD)
55
60
  * commit before the loop surfaces STALL instead of waiting the full cap. Keeps
@@ -57,4 +62,9 @@ export const WATCH_HELP = "usage: task pr:watch -- <pr_number> [options]\n" +
57
62
  * whole 30-minute budget (#1039 STALL terminal).
58
63
  */
59
64
  export const DEFAULT_STALL_THRESHOLD = 3;
65
+ /**
66
+ * Consecutive polls with clean_gate_holdout=ci_failures (Greptile otherwise
67
+ * satisfied) before CI_BLOCKED (#2688). Same default as SHA STALL.
68
+ */
69
+ export const DEFAULT_CI_BLOCKED_THRESHOLD = 3;
60
70
  //# sourceMappingURL=constants.js.map
@@ -151,6 +151,7 @@ export function watchResultToJson(result) {
151
151
  p1_count: p.p1Count,
152
152
  errored: p.errored,
153
153
  ci_failures: p.ciFailures,
154
+ ci_failed_checks: [...p.ciFailedChecks],
154
155
  is_clean: p.isClean,
155
156
  clean_gate_holdout: p.cleanGateHoldout,
156
157
  elapsed_seconds: result.elapsedSeconds,
@@ -171,6 +172,9 @@ export function printWatchHuman(result) {
171
172
  lines.push(` Findings: P0=${p.p0Count} P1=${p.p1Count}`);
172
173
  lines.push(` Errored sentinel: ${p.errored}`);
173
174
  lines.push(` CI failures: ${p.ciFailures}`);
175
+ if (p.ciFailedChecks.length > 0) {
176
+ lines.push(` Failed checks: ${p.ciFailedChecks.join("; ")}`);
177
+ }
174
178
  if (p.cleanGateHoldout !== null) {
175
179
  lines.push(` Clean-gate holdout: ${p.cleanGateHoldout}`);
176
180
  }
@@ -14,6 +14,7 @@ function errorProbe(headSha, message) {
14
14
  hasBlocking: false,
15
15
  errored: false,
16
16
  ciFailures: 0,
17
+ ciFailedChecks: [],
17
18
  terminalCheckRun: false,
18
19
  isClean: false,
19
20
  cleanGateHoldout: null,
@@ -61,12 +62,14 @@ export function probeOnce(prNumber, repoArg, runGh = defaultRunGh) {
61
62
  // ci_failures=0 / terminal so the Greptile verdict drives; the merge button
62
63
  // still owns the hard CI gate via pr:merge-ready (#796).
63
64
  let ciFailures = 0;
65
+ let ciFailedChecks = [];
64
66
  let terminalCheckRun = true;
65
67
  if (repo !== null) {
66
68
  const check = fetchCheckRunsRest(headSha, repo, runGh);
67
69
  if (check.summary !== null) {
68
70
  const ci = evaluateCiGate(check.checkRuns, {});
69
- ciFailures = ci.summary.failed_required.length;
71
+ ciFailedChecks = ci.summary.failed_required;
72
+ ciFailures = ciFailedChecks.length;
70
73
  terminalCheckRun = ci.summary.pending_required.length === 0;
71
74
  }
72
75
  }
@@ -91,6 +94,7 @@ export function probeOnce(prNumber, repoArg, runGh = defaultRunGh) {
91
94
  hasBlocking: findings.has_blocking,
92
95
  errored,
93
96
  ciFailures,
97
+ ciFailedChecks,
94
98
  terminalCheckRun,
95
99
  isClean,
96
100
  cleanGateHoldout,
@@ -13,6 +13,8 @@ export interface WatchProbe {
13
13
  readonly hasBlocking: boolean;
14
14
  readonly errored: boolean;
15
15
  readonly ciFailures: number;
16
+ /** Failed required check identities (name + conclusion), when CI was probed. */
17
+ readonly ciFailedChecks: readonly string[];
16
18
  /** All required CI check-runs have a terminal conclusion (none pending). */
17
19
  readonly terminalCheckRun: boolean;
18
20
  readonly isClean: boolean;
@@ -1,5 +1,5 @@
1
1
  import { defaultRunGh } from "../pr-merge-readiness/gh.js";
2
- import { DEFAULT_MAX_WAIT_MINUTES, DEFAULT_POLL_SECONDS, DEFAULT_STALL_THRESHOLD, EXIT_CLEAN, EXIT_NEW_P0_P1, EXIT_TERMINAL_ERROR, VERDICT_CLEAN, VERDICT_CONFIG, VERDICT_ERRORED, VERDICT_NEW_P0_P1, VERDICT_PENDING, VERDICT_STALL, VERDICT_TIMEOUT, } from "./constants.js";
2
+ import { DEFAULT_CI_BLOCKED_THRESHOLD, DEFAULT_MAX_WAIT_MINUTES, DEFAULT_POLL_SECONDS, DEFAULT_STALL_THRESHOLD, EXIT_CLEAN, EXIT_NEW_P0_P1, EXIT_TERMINAL_ERROR, VERDICT_CI_BLOCKED, VERDICT_CLEAN, VERDICT_CONFIG, VERDICT_ERRORED, VERDICT_NEW_P0_P1, VERDICT_PENDING, VERDICT_STALL, VERDICT_TIMEOUT, } from "./constants.js";
3
3
  import { probeOnce } from "./probe.js";
4
4
  const systemMonotonicClock = {
5
5
  now() {
@@ -52,6 +52,7 @@ export function watch(prNumber, repo, options = {}) {
52
52
  const startedAt = clockFn.now();
53
53
  let lastProbe = null;
54
54
  let stallStreak = 0;
55
+ let ciBlockedStreak = 0;
55
56
  const build = (verdict, exitCode, probe, poll) => ({
56
57
  verdict,
57
58
  exitCode,
@@ -79,6 +80,20 @@ export function watch(prNumber, repo, options = {}) {
79
80
  if (probe.errored) {
80
81
  return build(VERDICT_ERRORED, EXIT_TERMINAL_ERROR, probe, poll);
81
82
  }
83
+ // #2688: Greptile side satisfied on HEAD but CI red — fail loud toward a
84
+ // fix loop instead of burning max-wait-minutes on idle Greptile polls.
85
+ if (probe.cleanGateHoldout === "ci_failures") {
86
+ ciBlockedStreak += 1;
87
+ }
88
+ else {
89
+ ciBlockedStreak = 0;
90
+ }
91
+ if (oneShot && probe.cleanGateHoldout === "ci_failures") {
92
+ return build(VERDICT_CI_BLOCKED, EXIT_TERMINAL_ERROR, probe, poll);
93
+ }
94
+ if (!oneShot && ciBlockedStreak >= DEFAULT_CI_BLOCKED_THRESHOLD) {
95
+ return build(VERDICT_CI_BLOCKED, EXIT_TERMINAL_ERROR, probe, poll);
96
+ }
82
97
  // Stall = a review IS present but stuck on a non-HEAD commit; surface it
83
98
  // rather than burning the whole cap waiting for a re-review that isn't coming.
84
99
  if (probe.found && !probe.shaMatch) {
@@ -112,6 +127,7 @@ export function watch(prNumber, repo, options = {}) {
112
127
  hasBlocking: false,
113
128
  errored: false,
114
129
  ciFailures: 0,
130
+ ciFailedChecks: [],
115
131
  terminalCheckRun: false,
116
132
  isClean: false,
117
133
  cleanGateHoldout: null,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, relative, resolve } from "node:path";
3
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
3
4
  import { resolveLifecycleRoot } from "../layout/resolve.js";
4
5
  import { append, canonicalLogPath, newDecisionId, readAll } from "./audit-log.js";
5
6
  import { REVERSIBLE_ACTIONS, TERMINAL_ACTIONS } from "./constants.js";
@@ -157,7 +158,7 @@ function inversePlan(entry, logEntries) {
157
158
  }
158
159
  return null;
159
160
  }
160
- function moveAndFlip(srcFile, destFolder, newStatus, timestamp) {
161
+ function moveAndFlip(srcFile, destFolder, newStatus, timestamp, projectRoot) {
161
162
  if (!existsSync(srcFile)) {
162
163
  return [false, `File not found: ${srcFile}`, null];
163
164
  }
@@ -172,6 +173,15 @@ function moveAndFlip(srcFile, destFolder, newStatus, timestamp) {
172
173
  if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
173
174
  return [false, `Missing or invalid 'plan' object in ${srcFile}`, null];
174
175
  }
176
+ try {
177
+ assertWriteTargetSafe(projectRoot, srcFile);
178
+ }
179
+ catch (err) {
180
+ if (err instanceof ProjectionContainmentError) {
181
+ return [false, err.message, null];
182
+ }
183
+ throw err;
184
+ }
175
185
  const planObj = plan;
176
186
  planObj.status = newStatus;
177
187
  planObj.updated = timestamp;
@@ -262,7 +272,7 @@ export function undoOne(entry, projectRoot, options = {}) {
262
272
  });
263
273
  return { ok: true, message: msg, auditEntry: preview };
264
274
  }
265
- const [ok, fsMsg, destPath] = moveAndFlip(srcPath, destFolderPath, plan.newStatus, timestamp);
275
+ const [ok, fsMsg, destPath] = moveAndFlip(srcPath, destFolderPath, plan.newStatus, timestamp, projectRoot);
266
276
  if (!ok || destPath === null) {
267
277
  return { ok: false, message: fsMsg, auditEntry: null };
268
278
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * TypeScript default cache module for triage:bootstrap (#2684).
3
+ *
4
+ * npm / packaged consumers never ship scripts/cache.py; bootstrap must use the
5
+ * same cacheFetchAll path as empty-cache auto-populate (#2575).
6
+ */
7
+ import { type FetchAllReportImpl } from "../../cache/fetch.js";
8
+ import type { CacheModule } from "./types.js";
9
+ export type CacheFetchAllFn = (options: {
10
+ source: string;
11
+ repo: string;
12
+ batchSize?: number;
13
+ delayMs?: number;
14
+ state?: string;
15
+ limit?: number;
16
+ labels?: readonly string[];
17
+ author?: string | null;
18
+ cacheRoot?: string;
19
+ force?: boolean;
20
+ }) => FetchAllReportImpl;
21
+ /** Adapt TS cache reports to the bootstrap `FetchAllReport` shape. */
22
+ export declare function bootstrapCacheModule(fetchAll?: CacheFetchAllFn): CacheModule;
23
+ /** Default module for packaged and source checkouts — never gates on Python. */
24
+ export declare function loadDefaultCacheModule(): CacheModule;
25
+ //# sourceMappingURL=cache-module.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * TypeScript default cache module for triage:bootstrap (#2684).
3
+ *
4
+ * npm / packaged consumers never ship scripts/cache.py; bootstrap must use the
5
+ * same cacheFetchAll path as empty-cache auto-populate (#2575).
6
+ */
7
+ import { cacheFetchAll } from "../../cache/fetch.js";
8
+ /** Adapt TS cache reports to the bootstrap `FetchAllReport` shape. */
9
+ export function bootstrapCacheModule(fetchAll = cacheFetchAll) {
10
+ return {
11
+ cacheFetchAll(kwargs) {
12
+ const report = fetchAll({
13
+ source: kwargs.source,
14
+ repo: kwargs.repo,
15
+ cacheRoot: kwargs.cacheRoot,
16
+ force: true,
17
+ ...(kwargs.batchSize !== undefined ? { batchSize: kwargs.batchSize } : {}),
18
+ ...(kwargs.delayMs !== undefined ? { delayMs: kwargs.delayMs } : {}),
19
+ ...(kwargs.state !== undefined ? { state: kwargs.state } : {}),
20
+ ...(kwargs.limit !== undefined ? { limit: kwargs.limit } : {}),
21
+ ...(kwargs.labels !== undefined && kwargs.labels.length > 0
22
+ ? { labels: kwargs.labels }
23
+ : {}),
24
+ ...(kwargs.author !== undefined ? { author: kwargs.author } : {}),
25
+ });
26
+ return Promise.resolve({
27
+ succeeded: report.issuesWritten,
28
+ failed: report.issuesFailed,
29
+ skipped: report.alreadyFresh,
30
+ summaryLine: (source, repo) => report.summaryLine(source, repo),
31
+ });
32
+ },
33
+ };
34
+ }
35
+ /** Default module for packaged and source checkouts — never gates on Python. */
36
+ export function loadDefaultCacheModule() {
37
+ return bootstrapCacheModule();
38
+ }
39
+ //# sourceMappingURL=cache-module.js.map
@@ -1,4 +1,5 @@
1
1
  import type { BootstrapResult, CacheModule, RunBootstrapOptions, StepOutcome } from "./types.js";
2
+ export * from "./cache-module.js";
2
3
  export * from "./gitignore.js";
3
4
  export * from "./types.js";
4
5
  export declare const CACHE_DIR_NAME = ".deft-cache";
@@ -1,13 +1,13 @@
1
- import { execFile, execFileSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
- import { promisify } from "node:util";
6
5
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../../layout/resolve.js";
7
- import { SUBPROCESS_MAX_BUFFER } from "../../subprocess/max-buffer.js";
8
6
  import { resolveCandidatesLogPath } from "../cache-path.js";
7
+ import { loadDefaultCacheModule } from "./cache-module.js";
9
8
  import { stepEnsureGitignoreEntry, stepEnsureGitignoreEvalEntries, stepSeedCandidatesLog, } from "./gitignore.js";
10
9
  import { PROGRESS_DEFAULT } from "./types.js";
10
+ export * from "./cache-module.js";
11
11
  export * from "./gitignore.js";
12
12
  export * from "./types.js";
13
13
  export const CACHE_DIR_NAME = ".deft-cache";
@@ -21,7 +21,6 @@ const TOTAL_STEPS = 5;
21
21
  const GIT_ORIGIN_RE = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com[:/])([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?\s*$/;
22
22
  const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
23
  const RUNNER_UNSET = Symbol("runner-unset");
24
- const execFileAsync = promisify(execFile);
25
24
  function defaultWhich(name) {
26
25
  const locator = process.platform === "win32" ? "where" : "which";
27
26
  try {
@@ -126,81 +125,6 @@ export async function runWithTimeout(func, timeoutS) {
126
125
  error: boxError,
127
126
  };
128
127
  }
129
- function resolveDeftRoot(explicit) {
130
- if (explicit !== undefined && explicit.length > 0)
131
- return resolve(explicit);
132
- if (process.env.DEFT_ROOT !== undefined && process.env.DEFT_ROOT.length > 0) {
133
- return resolve(process.env.DEFT_ROOT);
134
- }
135
- return resolve(process.cwd());
136
- }
137
- function cacheModulePresent(deftRoot) {
138
- return existsSync(join(deftRoot, "scripts", "cache.py"));
139
- }
140
- async function invokePythonCacheFetchAll(deftRoot, kwargs) {
141
- const payload = JSON.stringify({
142
- source: kwargs.source,
143
- repo: kwargs.repo,
144
- cache_root: kwargs.cacheRoot,
145
- batch_size: kwargs.batchSize ?? null,
146
- delay_ms: kwargs.delayMs ?? null,
147
- state: kwargs.state ?? null,
148
- limit: kwargs.limit ?? null,
149
- labels: kwargs.labels ?? null,
150
- author: kwargs.author ?? null,
151
- });
152
- const script = `
153
- import json, sys
154
- sys.path.insert(0, ${JSON.stringify(join(deftRoot, "scripts"))})
155
- from cache import cache_fetch_all
156
- raw = json.loads(sys.argv[1])
157
- kwargs = {
158
- "source": raw["source"],
159
- "repo": raw["repo"],
160
- "cache_root": raw["cache_root"],
161
- }
162
- for key in ("batch_size", "delay_ms", "state", "limit", "author"):
163
- if raw[key] is not None:
164
- kwargs[key] = raw[key]
165
- if raw["labels"]:
166
- kwargs["labels"] = tuple(raw["labels"])
167
- report = cache_fetch_all(**kwargs)
168
- out = {
169
- "succeeded": getattr(report, "succeeded", None),
170
- "failed": getattr(report, "failed", None),
171
- "skipped": getattr(report, "skipped", None),
172
- }
173
- summary_line = getattr(report, "summary_line", None)
174
- if callable(summary_line):
175
- try:
176
- out["summary_message"] = summary_line(source=raw["source"], repo=raw["repo"])
177
- except TypeError:
178
- pass
179
- print(json.dumps(out))
180
- `;
181
- const { stdout } = await execFileAsync("uv", ["run", "python", "-c", script, payload], {
182
- cwd: deftRoot,
183
- encoding: "utf8",
184
- maxBuffer: SUBPROCESS_MAX_BUFFER,
185
- });
186
- const parsed = JSON.parse(String(stdout).trim());
187
- const summaryMessage = parsed.summary_message;
188
- return {
189
- succeeded: parsed.succeeded,
190
- failed: parsed.failed,
191
- skipped: parsed.skipped,
192
- summaryLine: typeof summaryMessage === "string" ? () => summaryMessage : null,
193
- };
194
- }
195
- function loadDefaultCacheModule(deftRoot) {
196
- if (!cacheModulePresent(deftRoot))
197
- return null;
198
- return {
199
- cacheFetchAll(kwargs) {
200
- return invokePythonCacheFetchAll(deftRoot, kwargs);
201
- },
202
- };
203
- }
204
128
  function nowIsoDefault() {
205
129
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
206
130
  }
@@ -219,10 +143,11 @@ export async function stepPopulateCache(projectRoot, repo, options = {}) {
219
143
  if (!REPO_RE.test(effectiveRepo)) {
220
144
  return stepOutcome("populate_cache", false, `invalid --repo '${effectiveRepo}'`, {}, "repo must be 'owner/name' (alphanumerics, '.', '_', '-' only)");
221
145
  }
222
- const deftRoot = resolveDeftRoot(options.deftRoot);
223
- const cacheMod = options.cacheModule ?? loadDefaultCacheModule(deftRoot);
146
+ // #2684: default is TypeScript cacheFetchAll — never gate on scripts/cache.py.
147
+ // Explicit null still skips populate (tests / callers that inject empty cache).
148
+ const cacheMod = options.cacheModule !== undefined ? options.cacheModule : loadDefaultCacheModule();
224
149
  if (cacheMod === null) {
225
- return stepOutcome("populate_cache", true, "deferred (scripts/cache.py not present on this branch; re-run after rebase to populate via task cache:fetch-all)", { deferred: "cache-module-missing", repo: effectiveRepo });
150
+ return stepOutcome("populate_cache", true, "deferred (cache module explicitly disabled; pass a cacheModule or omit the override to populate via cache:fetch-all)", { deferred: "cache-module-disabled", repo: effectiveRepo });
226
151
  }
227
152
  const kwargs = {
228
153
  source: CACHE_SOURCE,
@@ -1,14 +1,3 @@
1
- /**
2
- * Bounded, ordered set of legacy crossover tokens rewritten in the UNMANAGED
3
- * region of a consumer AGENTS.md after `migrate:xbrief` (#2154 / Option A).
4
- *
5
- * Each entry is a mechanical path / verb literal — NOT freeform prose. The
6
- * casing-only `vBRIEF format` product-description token from the issue table is
7
- * intentionally excluded so freeform prose survives untouched. The tokens are
8
- * disjoint substrings (`.vbrief.json` has no trailing slash, `vbrief:preflight`
9
- * uses a colon, `vbrief/` requires a slash), so replacement order does not
10
- * change the result and a second pass is a guaranteed no-op (idempotent).
11
- */
12
1
  export declare const LEGACY_HEADER_TOKENS: ReadonlyArray<{
13
2
  readonly legacy: string;
14
3
  readonly migrated: string;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { assertProjectionContained } from "../fs/projection-containment.js";
3
4
  import { iterManagedSections } from "../platform/agents-md.js";
4
5
  import { MIGRATED_ARTIFACT_DIR } from "./constants.js";
5
6
  import { isDirectory } from "./fs-helpers.js";
@@ -14,6 +15,12 @@ import { isDirectory } from "./fs-helpers.js";
14
15
  * uses a colon, `vbrief/` requires a slash), so replacement order does not
15
16
  * change the result and a second pass is a guaranteed no-op (idempotent).
16
17
  */
18
+ /** Refuse migrate header writes that escape via repo-controlled symlinks (#2668). */
19
+ function projectionTarget(projectDir, ...relSegments) {
20
+ const target = join(projectDir, ...relSegments);
21
+ assertProjectionContained(projectDir, target);
22
+ return target;
23
+ }
17
24
  export const LEGACY_HEADER_TOKENS = [
18
25
  { legacy: ".vbrief.json", migrated: ".xbrief.json" },
19
26
  { legacy: "vbrief:preflight", migrated: "xbrief:preflight" },
@@ -94,6 +101,7 @@ export function patchAgentsMdHeader(projectRoot, seams = {}) {
94
101
  }
95
102
  });
96
103
  const writeText = seams.writeText ?? ((path, text) => writeFileSync(path, text, "utf8"));
104
+ const usingWriteSeam = seams.writeText !== undefined;
97
105
  const existing = readText(agentsPath);
98
106
  if (existing === null) {
99
107
  return { kind: "absent", path: agentsPath, replacements: [] };
@@ -103,7 +111,8 @@ export function patchAgentsMdHeader(projectRoot, seams = {}) {
103
111
  return { kind: "clean", path: agentsPath, replacements: [] };
104
112
  }
105
113
  try {
106
- writeText(agentsPath, result.content);
114
+ const writePath = usingWriteSeam ? agentsPath : projectionTarget(projectRoot, "AGENTS.md");
115
+ writeText(writePath, result.content);
107
116
  }
108
117
  catch (err) {
109
118
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.79.3",
3
+ "version": "0.79.4",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -309,8 +309,8 @@
309
309
  "provenance": true
310
310
  },
311
311
  "dependencies": {
312
- "@deftai/directive-content": "^0.79.3",
313
- "@deftai/directive-types": "^0.79.3",
312
+ "@deftai/directive-content": "^0.79.4",
313
+ "@deftai/directive-types": "^0.79.4",
314
314
  "archiver": "^8.0.0"
315
315
  },
316
316
  "scripts": {