@slowcook-ai/cli 0.18.0-alpha.6 → 0.19.0-alpha.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.
- package/README.md +1 -1
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/brew/pair-sim.d.ts +29 -0
- package/dist/commands/brew/pair-sim.d.ts.map +1 -0
- package/dist/commands/brew/pair-sim.js +595 -0
- package/dist/commands/brew/pair-sim.js.map +1 -0
- package/dist/commands/chef/drift-fix.d.ts +53 -0
- package/dist/commands/chef/drift-fix.d.ts.map +1 -0
- package/dist/commands/chef/drift-fix.js +893 -0
- package/dist/commands/chef/drift-fix.js.map +1 -0
- package/dist/commands/init/entities.d.ts +2 -0
- package/dist/commands/init/entities.d.ts.map +1 -1
- package/dist/commands/init/entities.js +41 -0
- package/dist/commands/init/entities.js.map +1 -1
- package/dist/commands/init/from-prod.js +4 -4
- package/dist/commands/init/from-prod.js.map +1 -1
- package/dist/commands/init/mock.d.ts +12 -0
- package/dist/commands/init/mock.d.ts.map +1 -1
- package/dist/commands/init/mock.js +50 -1
- package/dist/commands/init/mock.js.map +1 -1
- package/dist/commands/init/templates.js +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slowcook chef-drift` — α.9 L1 drift-fixer (cli α.9).
|
|
3
|
+
*
|
|
4
|
+
* Sibling to the existing `slowcook chef --pr <n>` (PR-CI-failure
|
|
5
|
+
* handler). This module is the SURGICAL EDITOR variant: triggered by
|
|
6
|
+
* mock-isolation / recon / brew halt / navigator halt-class. Reads the
|
|
7
|
+
* failure + history-index + PR state, calls the chef LLM to get a
|
|
8
|
+
* ChefVerdict, applies edits surgically, validates, commits, posts an
|
|
9
|
+
* audit comment.
|
|
10
|
+
*
|
|
11
|
+
* Frozen surface (HARD): never edits tests/, vitest.config.*,
|
|
12
|
+
* .brewing/{auto-gen}/. If a fix requires test edits → escalates to PM
|
|
13
|
+
* via a two-option pm_comment (option B = `testgen --regenerate`).
|
|
14
|
+
*
|
|
15
|
+
* Ledger at .brewing/chef/<story-id>.json tracks moves + cost. Cycle
|
|
16
|
+
* detection + budget cap enforce convergence.
|
|
17
|
+
*
|
|
18
|
+
* Run from consumer repo root:
|
|
19
|
+
* ANTHROPIC_API_KEY=... slowcook chef-drift \
|
|
20
|
+
* --story 018 \
|
|
21
|
+
* --trigger mock_isolation_check_failed \
|
|
22
|
+
* --trigger-detail "Relative import resolves to a non-existent file..."
|
|
23
|
+
*/
|
|
24
|
+
import { execSync } from "node:child_process";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { AnthropicClient, CHEF_SYSTEM, buildChefPrompt, } from "@slowcook-ai/llm-anthropic";
|
|
28
|
+
const FROZEN_PATH_PATTERNS = [
|
|
29
|
+
/^tests\//,
|
|
30
|
+
/^vitest\.config\.(ts|mjs|js)$/,
|
|
31
|
+
/^\.brewing\/code-map\.(json|md|target\.md)$/,
|
|
32
|
+
/^\.brewing\/recon-result\.json$/,
|
|
33
|
+
/^\.brewing\/history-index\.json$/,
|
|
34
|
+
/^\.brewing\/auto-gen\//,
|
|
35
|
+
];
|
|
36
|
+
function isFrozenPath(path) {
|
|
37
|
+
return FROZEN_PATH_PATTERNS.some((re) => re.test(path));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse a vitest-style test runner output to extract the failing test
|
|
41
|
+
* files. Brew agent halts include the runner's stdout/stderr; chef
|
|
42
|
+
* needs to know exactly which test files are red so it can grep their
|
|
43
|
+
* imports + propose surgical edits to the source files under test.
|
|
44
|
+
*
|
|
45
|
+
* Recognises:
|
|
46
|
+
* - `FAIL src/foo/bar.test.ts > description > it works`
|
|
47
|
+
* - `× src/foo/bar.test.ts > description > it works`
|
|
48
|
+
* - ` ❯ src/foo/bar.test.ts (...)` with a 'Tests fail' summary nearby
|
|
49
|
+
* - `Test Files X failed | Y passed` summary lines (signal only)
|
|
50
|
+
*
|
|
51
|
+
* Returns { failingFiles: string[]; failingTestNames: string[] }.
|
|
52
|
+
* Pure — does no IO. Exported for unit tests.
|
|
53
|
+
*/
|
|
54
|
+
export function parseBrewHaltOutput(text) {
|
|
55
|
+
const failingFiles = new Set();
|
|
56
|
+
const failingTestNames = new Set();
|
|
57
|
+
for (const rawLine of text.split("\n")) {
|
|
58
|
+
const line = rawLine.replace(/\[\d+m/g, "").trim();
|
|
59
|
+
// Match "FAIL <path>" or "× <path>" prefix forms.
|
|
60
|
+
const failMatch = line.match(/^(?:FAIL|×|✗|❯ FAIL)\s+([\w./-]+\.(?:test|spec)\.(?:ts|tsx|js|jsx))(?:\s+>\s+(.*))?$/);
|
|
61
|
+
if (failMatch) {
|
|
62
|
+
failingFiles.add(failMatch[1]);
|
|
63
|
+
if (failMatch[2])
|
|
64
|
+
failingTestNames.add(failMatch[2].trim());
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// vitest line shape: " ❯ <path> (<n> tests | <m> failed)"
|
|
68
|
+
const navMatch = line.match(/^❯\s+([\w./-]+\.(?:test|spec)\.(?:ts|tsx|js|jsx))\s+\(.*?(?:\d+\s+failed)/);
|
|
69
|
+
if (navMatch)
|
|
70
|
+
failingFiles.add(navMatch[1]);
|
|
71
|
+
// "AssertionError: ..." after a "FAIL <test name>" header form some
|
|
72
|
+
// runners emit. We track the most recent test-name candidate.
|
|
73
|
+
const testNameMatch = line.match(/^(?:FAIL|×|✗)\s+(.+?)(?:\s+\d+ms)?$/);
|
|
74
|
+
if (testNameMatch && /\s>\s/.test(testNameMatch[1])) {
|
|
75
|
+
failingTestNames.add(testNameMatch[1]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
failingFiles: [...failingFiles],
|
|
80
|
+
failingTestNames: [...failingTestNames],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Extract the set of source files (non-test) imported by each failing
|
|
85
|
+
* test file. Pure: takes a {testFile → contents} map and returns a
|
|
86
|
+
* {testFile → importedSourceFiles[]} map.
|
|
87
|
+
*
|
|
88
|
+
* Resolves only relative imports (./ or ../); skips package imports
|
|
89
|
+
* (those don't live in the consumer repo).
|
|
90
|
+
*/
|
|
91
|
+
export function collectImportedSourceFiles(testContents) {
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const [testFile, content] of Object.entries(testContents)) {
|
|
94
|
+
const sources = new Set();
|
|
95
|
+
const importRe = /from\s+["'](\.[^"']+)["']/g;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = importRe.exec(content)) !== null) {
|
|
98
|
+
const rel = m[1];
|
|
99
|
+
// Strip trailing extension if present so chef sees module paths.
|
|
100
|
+
sources.add(rel);
|
|
101
|
+
}
|
|
102
|
+
out[testFile] = [...sources];
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
const args = {
|
|
108
|
+
storyId: "",
|
|
109
|
+
repoRoot: process.cwd(),
|
|
110
|
+
triggerKind: "mock_isolation_check_failed",
|
|
111
|
+
triggerDetail: "",
|
|
112
|
+
triggerRawPath: null,
|
|
113
|
+
navigatorHistoryPath: null,
|
|
114
|
+
model: "claude-sonnet-4-5-20250929",
|
|
115
|
+
budgetUsd: 1.0,
|
|
116
|
+
dryRun: false,
|
|
117
|
+
prNumber: null,
|
|
118
|
+
};
|
|
119
|
+
for (let i = 0; i < argv.length; i++) {
|
|
120
|
+
const a = argv[i];
|
|
121
|
+
const next = argv[i + 1];
|
|
122
|
+
if (a === "--story" && next) {
|
|
123
|
+
args.storyId = next;
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
else if (a === "--cwd" && next) {
|
|
127
|
+
args.repoRoot = next;
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
else if (a === "--trigger" && next) {
|
|
131
|
+
args.triggerKind = next;
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
else if (a === "--trigger-detail" && next) {
|
|
135
|
+
args.triggerDetail = next;
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
else if (a === "--trigger-raw" && next) {
|
|
139
|
+
args.triggerRawPath = next;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
else if (a === "--navigator-history" && next) {
|
|
143
|
+
args.navigatorHistoryPath = next;
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
else if (a === "--model" && next) {
|
|
147
|
+
args.model = next;
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
150
|
+
else if (a === "--budget-usd" && next) {
|
|
151
|
+
args.budgetUsd = parseFloat(next);
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
else if (a === "--dry-run") {
|
|
155
|
+
args.dryRun = true;
|
|
156
|
+
}
|
|
157
|
+
else if (a === "--pr" && next) {
|
|
158
|
+
args.prNumber = parseInt(next, 10);
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
else if (a === "--help" || a === "-h") {
|
|
162
|
+
printHelp();
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!args.storyId) {
|
|
167
|
+
console.error("--story <id> is required");
|
|
168
|
+
printHelp();
|
|
169
|
+
process.exit(64);
|
|
170
|
+
}
|
|
171
|
+
return args;
|
|
172
|
+
}
|
|
173
|
+
function printHelp() {
|
|
174
|
+
console.log(`
|
|
175
|
+
slowcook chef-drift — surgical drift-fixer (cli α.9 L1)
|
|
176
|
+
|
|
177
|
+
Sibling to \`slowcook chef --pr <n>\` (PR-CI-failure handler). This
|
|
178
|
+
variant: triggered by mock-isolation / recon / brew halt / navigator
|
|
179
|
+
halt-class; makes surgical edits across spec + mock + prod (NEVER tests
|
|
180
|
+
or vitest config); commits + posts audit comment + optionally dispatches
|
|
181
|
+
the next pipeline step.
|
|
182
|
+
|
|
183
|
+
Usage:
|
|
184
|
+
slowcook chef-drift --story <id> --trigger <kind> [options]
|
|
185
|
+
|
|
186
|
+
Options:
|
|
187
|
+
--cwd <path> Repo root (default: cwd).
|
|
188
|
+
--trigger <kind> mock_isolation_check_failed | recon_escalation |
|
|
189
|
+
brew_halt_class | navigator_halt_class
|
|
190
|
+
--trigger-detail <text> One-line summary of the failure.
|
|
191
|
+
--trigger-raw <path> Path to JSON file with full trigger detail.
|
|
192
|
+
--navigator-history <path> Path to navigator-history JSON (when applicable).
|
|
193
|
+
--model <id> Anthropic model id.
|
|
194
|
+
--budget-usd <n> Per-episode budget cap (default: 1.00).
|
|
195
|
+
--dry-run Print verdict; do not apply edits.
|
|
196
|
+
--pr <number> L2 finisher mode — operate on a brew PR's branch
|
|
197
|
+
rather than the current branch. Chef checks out the
|
|
198
|
+
PR head, commits to it, pushes back, and writes the
|
|
199
|
+
audit comment on the PR (not the source issue).
|
|
200
|
+
|
|
201
|
+
Requires: ANTHROPIC_API_KEY in env. Run from consumer repo root.
|
|
202
|
+
Frozen surface: tests/, vitest.config.*, .brewing/{auto-gen}/ — never edited.
|
|
203
|
+
`);
|
|
204
|
+
}
|
|
205
|
+
function loadHistoryIndex(repoRoot) {
|
|
206
|
+
const path = join(repoRoot, ".brewing/history-index.json");
|
|
207
|
+
if (!existsSync(path))
|
|
208
|
+
return {};
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function loadSpec(repoRoot, storyId) {
|
|
217
|
+
const path = `specs/story-${storyId}.yaml`;
|
|
218
|
+
const abs = join(repoRoot, path);
|
|
219
|
+
if (!existsSync(abs))
|
|
220
|
+
return { specPath: path, specYaml: "" };
|
|
221
|
+
return { specPath: path, specYaml: readFileSync(abs, "utf8") };
|
|
222
|
+
}
|
|
223
|
+
function loadOpenPrs(repoRoot, storyId) {
|
|
224
|
+
const out = [];
|
|
225
|
+
try {
|
|
226
|
+
const repoSlug = execSync(`git -C "${repoRoot}" remote get-url origin | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||'`, { encoding: "utf8" }).trim();
|
|
227
|
+
const json = execSync(`gh pr list --repo "${repoSlug}" --search "story-${storyId}" --state open --json number,headRefName,headRefOid,title --limit 10`, { encoding: "utf8" });
|
|
228
|
+
const prs = JSON.parse(json);
|
|
229
|
+
for (const pr of prs) {
|
|
230
|
+
const branch = pr.headRefName;
|
|
231
|
+
let kind;
|
|
232
|
+
if (branch.includes("/spec/"))
|
|
233
|
+
kind = "spec";
|
|
234
|
+
else if (branch.includes("/mockup/"))
|
|
235
|
+
kind = "mockup";
|
|
236
|
+
else if (branch.includes("/tests/") || branch.includes("/recipe/"))
|
|
237
|
+
kind = "tests";
|
|
238
|
+
else if (branch.includes("/brew/"))
|
|
239
|
+
kind = "brew";
|
|
240
|
+
else
|
|
241
|
+
continue;
|
|
242
|
+
out.push({ kind, number: pr.number, branch, headSha: pr.headRefOid });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
console.warn(` warn: could not list open PRs (${e.message.slice(0, 100)})`);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
function loadLedger(repoRoot, storyId) {
|
|
251
|
+
const path = join(repoRoot, `.brewing/chef/story-${storyId}.json`);
|
|
252
|
+
if (!existsSync(path)) {
|
|
253
|
+
return { story_id: storyId, episode: 1, moves: [], cumulative_cost_usd: 0, halt_reason: null };
|
|
254
|
+
}
|
|
255
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
256
|
+
}
|
|
257
|
+
function saveLedger(repoRoot, ledger) {
|
|
258
|
+
const path = join(repoRoot, `.brewing/chef/story-${ledger.story_id}.json`);
|
|
259
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
260
|
+
writeFileSync(path, JSON.stringify(ledger, null, 2), "utf8");
|
|
261
|
+
}
|
|
262
|
+
function detectCycle(ledger, plannedEdits) {
|
|
263
|
+
for (const prior of ledger.moves) {
|
|
264
|
+
for (const priorEdit of prior.edits) {
|
|
265
|
+
if (priorEdit.operation !== "rename" || !priorEdit.to)
|
|
266
|
+
continue;
|
|
267
|
+
const matches = plannedEdits.find((e) => e.operation === "rename" && e.file === priorEdit.to && e.to === priorEdit.file);
|
|
268
|
+
if (matches)
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
function applyEdit(repoRoot, edit) {
|
|
275
|
+
const abs = join(repoRoot, edit.file);
|
|
276
|
+
switch (edit.operation) {
|
|
277
|
+
case "rename": {
|
|
278
|
+
if (!edit.to)
|
|
279
|
+
throw new Error(`rename edit missing 'to' field for ${edit.file}`);
|
|
280
|
+
const toAbs = join(repoRoot, edit.to);
|
|
281
|
+
mkdirSync(dirname(toAbs), { recursive: true });
|
|
282
|
+
execSync(`git -C "${repoRoot}" mv "${edit.file}" "${edit.to}"`, { stdio: "ignore" });
|
|
283
|
+
// Heuristic: if file basename changed, rename default-export inside the file
|
|
284
|
+
const oldName = edit.file.split("/").pop().replace(/\.tsx?$/, "");
|
|
285
|
+
const newName = edit.to.split("/").pop().replace(/\.tsx?$/, "");
|
|
286
|
+
if (oldName !== newName && existsSync(toAbs)) {
|
|
287
|
+
let content = readFileSync(toAbs, "utf8");
|
|
288
|
+
content = content.replace(new RegExp(`(export default function )${oldName}\\b`, "g"), `$1${newName}`);
|
|
289
|
+
writeFileSync(toAbs, content, "utf8");
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "create": {
|
|
294
|
+
if (!edit.patch)
|
|
295
|
+
throw new Error(`create edit missing 'patch' for ${edit.file}`);
|
|
296
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
297
|
+
writeFileSync(abs, edit.patch, "utf8");
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
// Legacy 'edit' operation removed — chef must use 'search_replace'
|
|
301
|
+
// for content changes. The default branch below catches it via
|
|
302
|
+
// exhaustive-switch enforcement.
|
|
303
|
+
case "search_replace": {
|
|
304
|
+
const sr = edit.search_replace;
|
|
305
|
+
if (!sr || !Array.isArray(sr) || sr.length === 0) {
|
|
306
|
+
throw new Error(`search_replace edit missing 'search_replace' array for ${edit.file}`);
|
|
307
|
+
}
|
|
308
|
+
if (!existsSync(abs)) {
|
|
309
|
+
throw new Error(`search_replace target file does not exist: ${edit.file}`);
|
|
310
|
+
}
|
|
311
|
+
let content = readFileSync(abs, "utf8");
|
|
312
|
+
for (const pair of sr) {
|
|
313
|
+
if (!pair.find || pair.replace === undefined) {
|
|
314
|
+
throw new Error(`search_replace pair missing find or replace for ${edit.file}`);
|
|
315
|
+
}
|
|
316
|
+
// Require find to appear exactly once — guards against ambiguous matches
|
|
317
|
+
const occurrences = content.split(pair.find).length - 1;
|
|
318
|
+
if (occurrences === 0) {
|
|
319
|
+
throw new Error(`search_replace 'find' string not found in ${edit.file}: ${JSON.stringify(pair.find).slice(0, 120)}`);
|
|
320
|
+
}
|
|
321
|
+
if (occurrences > 1) {
|
|
322
|
+
throw new Error(`search_replace 'find' string matches ${occurrences}x in ${edit.file} (must be unique): ${JSON.stringify(pair.find).slice(0, 120)}`);
|
|
323
|
+
}
|
|
324
|
+
content = content.replace(pair.find, pair.replace);
|
|
325
|
+
}
|
|
326
|
+
writeFileSync(abs, content, "utf8");
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "delete": {
|
|
330
|
+
execSync(`rm -f "${abs}"`);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
default: {
|
|
334
|
+
throw new Error(`unsupported edit operation '${edit.operation}' for ${edit.file} — use rename / search_replace / create / delete only.`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function runValidation(repoRoot, command) {
|
|
339
|
+
// Chef may produce commands prefixed with `slowcook ...` (matches the
|
|
340
|
+
// documented prompt examples). On runners where slowcook isn't on
|
|
341
|
+
// PATH, we route through the same node binary that's running chef.
|
|
342
|
+
let resolved = command.trim();
|
|
343
|
+
if (resolved.startsWith("slowcook ")) {
|
|
344
|
+
const cliJs = process.argv[1] || "";
|
|
345
|
+
if (cliJs && existsSync(cliJs)) {
|
|
346
|
+
resolved = `node "${cliJs}" ${resolved.slice("slowcook ".length)}`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const output = execSync(resolved, { cwd: repoRoot, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
|
351
|
+
return { passed: true, output };
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
const err = e;
|
|
355
|
+
const out = err.stdout || err.stderr || err.message || "";
|
|
356
|
+
return { passed: false, output: out };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Compare pre-move + post-move validation outputs to decide whether
|
|
361
|
+
* chef's iteration made progress.
|
|
362
|
+
*
|
|
363
|
+
* Returns 'progress' when post-set ⊆ pre-set AND post-set has fewer
|
|
364
|
+
* unique failure-lines than pre-set (i.e., chef removed at least one
|
|
365
|
+
* failure + introduced none).
|
|
366
|
+
*
|
|
367
|
+
* Returns 'no-change' when post-set === pre-set.
|
|
368
|
+
*
|
|
369
|
+
* Returns 'regression' when post-set has any line not in pre-set
|
|
370
|
+
* (chef introduced a new failure).
|
|
371
|
+
*/
|
|
372
|
+
function compareValidationOutputs(pre, post) {
|
|
373
|
+
// Heuristic: extract lines that look like file:line refs (typical
|
|
374
|
+
// failure-marker shape). Compare as sets.
|
|
375
|
+
const extractFailureLines = (s) => {
|
|
376
|
+
const out = new Set();
|
|
377
|
+
for (const line of s.split("\n")) {
|
|
378
|
+
const m = line.match(/^\s*([\w./[\]()-]+\.(?:ts|tsx|js|jsx|yaml|yml|sql|md|json)):(\d+)/);
|
|
379
|
+
if (m)
|
|
380
|
+
out.add(`${m[1]}:${m[2]}`);
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
};
|
|
384
|
+
const preSet = extractFailureLines(pre);
|
|
385
|
+
const postSet = extractFailureLines(post);
|
|
386
|
+
const newFailures = [...postSet].filter((x) => !preSet.has(x));
|
|
387
|
+
if (newFailures.length > 0)
|
|
388
|
+
return "regression";
|
|
389
|
+
if (postSet.size < preSet.size)
|
|
390
|
+
return "progress";
|
|
391
|
+
return "no-change";
|
|
392
|
+
}
|
|
393
|
+
function postIssueComment(repoRoot, issueNumber, body) {
|
|
394
|
+
const repoSlug = execSync(`git -C "${repoRoot}" remote get-url origin | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||'`, { encoding: "utf8" }).trim();
|
|
395
|
+
// Try gh CLI first; fall back to curl + GITHUB_TOKEN. Runners often
|
|
396
|
+
// have one but not the other.
|
|
397
|
+
const tmp = "/tmp/chef-drift-comment.md";
|
|
398
|
+
writeFileSync(tmp, body, "utf8");
|
|
399
|
+
try {
|
|
400
|
+
execSync(`gh issue comment ${issueNumber} --repo "${repoSlug}" --body-file ${tmp}`, { stdio: "inherit" });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// fall through to curl
|
|
405
|
+
}
|
|
406
|
+
const token = process.env["GITHUB_TOKEN"];
|
|
407
|
+
if (!token) {
|
|
408
|
+
console.warn(` warn: gh not installed + GITHUB_TOKEN not set; skipping audit comment on issue #${issueNumber}`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const payload = JSON.stringify({ body });
|
|
412
|
+
const payloadFile = "/tmp/chef-drift-payload.json";
|
|
413
|
+
writeFileSync(payloadFile, payload, "utf8");
|
|
414
|
+
try {
|
|
415
|
+
execSync(`curl -sS -f -X POST -H "Authorization: token ${token}" -H "Accept: application/vnd.github+json" --data @${payloadFile} "https://api.github.com/repos/${repoSlug}/issues/${issueNumber}/comments" >/dev/null`, { stdio: "inherit" });
|
|
416
|
+
console.log(` posted audit comment on issue #${issueNumber} (via curl)`);
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
console.warn(` warn: failed to post audit comment via curl: ${e.message.slice(0, 200)}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* L2 finisher mode — fetch the brew PR's branch + check it out.
|
|
424
|
+
* Returns the original branch name so chef can push back to it.
|
|
425
|
+
*
|
|
426
|
+
* Throws on any failure: PR-mode is opt-in via --pr, so we don't
|
|
427
|
+
* silently fall back to the current branch (would commit to main).
|
|
428
|
+
*/
|
|
429
|
+
function checkoutPrBranch(repoRoot, prNumber) {
|
|
430
|
+
const repoSlug = execSync(`git -C "${repoRoot}" remote get-url origin | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||'`, { encoding: "utf8" }).trim();
|
|
431
|
+
const prJson = execSync(`gh pr view ${prNumber} --repo "${repoSlug}" --json headRefName,headRefOid`, { encoding: "utf8" });
|
|
432
|
+
const pr = JSON.parse(prJson);
|
|
433
|
+
const localRef = `chef-finisher/pr-${prNumber}`;
|
|
434
|
+
// Fetch the PR head + create/reset the local tracking branch.
|
|
435
|
+
execSync(`git -C "${repoRoot}" fetch origin pull/${prNumber}/head:${localRef} --force`, { stdio: "inherit" });
|
|
436
|
+
execSync(`git -C "${repoRoot}" checkout ${localRef}`, { stdio: "inherit" });
|
|
437
|
+
return { branchName: pr.headRefName, localRef };
|
|
438
|
+
}
|
|
439
|
+
function pushChefEditsToPrBranch(repoRoot, prBranch, localRef) {
|
|
440
|
+
try {
|
|
441
|
+
execSync(`git -C "${repoRoot}" push origin ${localRef}:${prBranch}`, { stdio: "inherit" });
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
console.warn(` warn: push to PR branch '${prBranch}' failed: ${e.message.slice(0, 200)}`);
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function commitChefEdits(repoRoot, summary, edits) {
|
|
450
|
+
// Stage ONLY the files chef edited (never `git add -A` — that
|
|
451
|
+
// accidentally captures workflow-side clones like `_slowcook/` etc.
|
|
452
|
+
// that live in the consumer repo's working tree). For renames, stage
|
|
453
|
+
// both the old + new path so git records the rename.
|
|
454
|
+
try {
|
|
455
|
+
const pathsToAdd = new Set();
|
|
456
|
+
for (const e of edits) {
|
|
457
|
+
pathsToAdd.add(e.file);
|
|
458
|
+
if (e.to)
|
|
459
|
+
pathsToAdd.add(e.to);
|
|
460
|
+
}
|
|
461
|
+
if (pathsToAdd.size === 0)
|
|
462
|
+
return { sha: null, pushed: false };
|
|
463
|
+
for (const p of pathsToAdd) {
|
|
464
|
+
try {
|
|
465
|
+
execSync(`git -C "${repoRoot}" add "${p}"`, { stdio: "ignore" });
|
|
466
|
+
}
|
|
467
|
+
catch { /* file may have been renamed away — ignore */ }
|
|
468
|
+
}
|
|
469
|
+
// Also stage the chef ledger so it's part of the commit
|
|
470
|
+
try {
|
|
471
|
+
execSync(`git -C "${repoRoot}" add ".brewing/chef/"`, { stdio: "ignore" });
|
|
472
|
+
}
|
|
473
|
+
catch { /* ledger dir may not exist if first move ran into early error */ }
|
|
474
|
+
// Empty commit guard: if nothing staged, skip.
|
|
475
|
+
const status = execSync(`git -C "${repoRoot}" status --porcelain --cached`, { encoding: "utf8" }).trim();
|
|
476
|
+
if (!status)
|
|
477
|
+
return { sha: null, pushed: false };
|
|
478
|
+
// Write commit message to file (handle quotes cleanly).
|
|
479
|
+
const msgFile = "/tmp/chef-drift-commit-msg.txt";
|
|
480
|
+
writeFileSync(msgFile, `[chef] ${summary}\n\nCo-Authored-By: slowcook-chef[bot] <slowcook-chef@users.noreply.github.com>\n`, "utf8");
|
|
481
|
+
execSync(`git -C "${repoRoot}" -c user.name="slowcook-chef[bot]" -c user.email="slowcook-chef@users.noreply.github.com" commit -F ${msgFile}`, { stdio: "inherit" });
|
|
482
|
+
const sha = execSync(`git -C "${repoRoot}" rev-parse HEAD`, { encoding: "utf8" }).trim();
|
|
483
|
+
return { sha, pushed: false };
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
console.warn(` warn: chef commit failed: ${e.message.slice(0, 200)}`);
|
|
487
|
+
return { sha: null, pushed: false };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function buildAuditCommentBody(args) {
|
|
491
|
+
const { move, verdict } = args;
|
|
492
|
+
const lines = [];
|
|
493
|
+
lines.push(`### [chef-drift] Move ${move.n} on story-${args.ledger.story_id}`);
|
|
494
|
+
lines.push("");
|
|
495
|
+
lines.push(`**Trigger:** \`${move.trigger_kind}\``);
|
|
496
|
+
lines.push("");
|
|
497
|
+
lines.push(`**Decision:** ${move.decision}`);
|
|
498
|
+
lines.push("");
|
|
499
|
+
lines.push(`**Rationale:** ${verdict.rationale}`);
|
|
500
|
+
lines.push("");
|
|
501
|
+
if (move.edits.length > 0) {
|
|
502
|
+
lines.push(`**Files touched:**`);
|
|
503
|
+
for (const e of move.edits) {
|
|
504
|
+
lines.push(`- \`${e.file}\` (${e.operation}${e.to ? ` → \`${e.to}\`` : ""})`);
|
|
505
|
+
}
|
|
506
|
+
lines.push("");
|
|
507
|
+
}
|
|
508
|
+
if (move.validation_command) {
|
|
509
|
+
lines.push(`**Validation:** \`${move.validation_command}\` → ${move.validation_result}`);
|
|
510
|
+
lines.push("");
|
|
511
|
+
}
|
|
512
|
+
if (move.next_dispatch) {
|
|
513
|
+
lines.push(`**Next:** dispatched \`${move.next_dispatch}\``);
|
|
514
|
+
lines.push("");
|
|
515
|
+
}
|
|
516
|
+
lines.push(`**Cost:** $${move.cost_usd.toFixed(2)} (cumulative: $${args.ledger.cumulative_cost_usd.toFixed(2)})`);
|
|
517
|
+
return lines.join("\n");
|
|
518
|
+
}
|
|
519
|
+
export async function chefDrift(argv, _cliVersion) {
|
|
520
|
+
const args = parseArgs(argv);
|
|
521
|
+
const apiKey = process.env["ANTHROPIC_API_KEY"];
|
|
522
|
+
if (!apiKey) {
|
|
523
|
+
console.error("ANTHROPIC_API_KEY env var is required.");
|
|
524
|
+
process.exit(2);
|
|
525
|
+
}
|
|
526
|
+
console.log(`slowcook chef-drift · story-${args.storyId} · trigger=${args.triggerKind}${args.prNumber ? ` · finisher mode (PR #${args.prNumber})` : ""}`);
|
|
527
|
+
// L2 finisher mode: check out the PR branch BEFORE reading any
|
|
528
|
+
// ledger or repo state. The brew PR's branch has its own .brewing/
|
|
529
|
+
// and a different working tree shape than main.
|
|
530
|
+
let prCheckout = null;
|
|
531
|
+
if (args.prNumber !== null) {
|
|
532
|
+
try {
|
|
533
|
+
prCheckout = checkoutPrBranch(args.repoRoot, args.prNumber);
|
|
534
|
+
console.log(` finisher: checked out '${prCheckout.branchName}' as ${prCheckout.localRef}`);
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
console.error(` ! could not check out PR #${args.prNumber}: ${e.message.slice(0, 200)}`);
|
|
538
|
+
process.exit(2);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const ledger = loadLedger(args.repoRoot, args.storyId);
|
|
542
|
+
if (ledger.cumulative_cost_usd >= args.budgetUsd) {
|
|
543
|
+
console.error(` episode budget exceeded ($${ledger.cumulative_cost_usd.toFixed(2)} >= $${args.budgetUsd}). Halting.`);
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
let triggerRaw = {};
|
|
547
|
+
if (args.triggerRawPath && existsSync(args.triggerRawPath)) {
|
|
548
|
+
try {
|
|
549
|
+
triggerRaw = JSON.parse(readFileSync(args.triggerRawPath, "utf8"));
|
|
550
|
+
}
|
|
551
|
+
catch { /* ignore */ }
|
|
552
|
+
}
|
|
553
|
+
// Enrich trigger.raw with context the chef LLM doesn't have read-tools to gather.
|
|
554
|
+
// For mock_isolation failures: grep ALL importers of the missing symbol so chef
|
|
555
|
+
// can plan a coordinated rename (not just the one file the trigger detail named).
|
|
556
|
+
if (args.triggerKind === "mock_isolation_check_failed") {
|
|
557
|
+
const importerMatch = args.triggerDetail.match(/['"]\.\/(\w+)['"]/);
|
|
558
|
+
if (importerMatch && importerMatch[1]) {
|
|
559
|
+
const symbol = importerMatch[1];
|
|
560
|
+
const grepImportsBySymbol = (root, sym) => {
|
|
561
|
+
try {
|
|
562
|
+
const out = execSync(`grep -rnE "from\\s+[\\\"']\\.[/.][^\\\"']*${sym}[\\\"']" "${root}" 2>/dev/null || true`, { encoding: "utf8", maxBuffer: 1024 * 1024 });
|
|
563
|
+
return out
|
|
564
|
+
.split("\n")
|
|
565
|
+
.filter(Boolean)
|
|
566
|
+
.map((line) => {
|
|
567
|
+
const m = line.match(/^([^:]+):(\d+):(.*)$/);
|
|
568
|
+
if (!m)
|
|
569
|
+
return null;
|
|
570
|
+
return { file: m[1].replace(args.repoRoot + "/", ""), line: parseInt(m[2], 10), text: m[3].trim() };
|
|
571
|
+
})
|
|
572
|
+
.filter((x) => x !== null);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
// Step 1: find existing files in same dir with names similar to the missing symbol.
|
|
579
|
+
// These are the rename candidates — the file that chef likely needs to rename forward.
|
|
580
|
+
const candidateExisting = [];
|
|
581
|
+
const symbolLower = symbol.toLowerCase();
|
|
582
|
+
const importerFile = args.triggerDetail.match(/^([^\s]+):/)?.[1];
|
|
583
|
+
const stem = symbolLower.replace(/strip|page|header|badge|card|list|row|item/g, "").trim();
|
|
584
|
+
if (importerFile) {
|
|
585
|
+
const dir = dirname(join(args.repoRoot, importerFile));
|
|
586
|
+
if (existsSync(dir)) {
|
|
587
|
+
try {
|
|
588
|
+
const files = readdirSync(dir);
|
|
589
|
+
for (const f of files) {
|
|
590
|
+
const fLow = f.toLowerCase().replace(/\.tsx?$/, "");
|
|
591
|
+
if (!/\.tsx?$/.test(f))
|
|
592
|
+
continue;
|
|
593
|
+
if (fLow === symbolLower)
|
|
594
|
+
continue; // skip exact match (it'd already exist)
|
|
595
|
+
// Match if file name shares a meaningful stem with the symbol
|
|
596
|
+
if ((stem.length > 3 && fLow.includes(stem)) ||
|
|
597
|
+
fLow.includes(symbolLower.slice(0, 6)) ||
|
|
598
|
+
symbolLower.includes(fLow)) {
|
|
599
|
+
candidateExisting.push(`${dir.replace(args.repoRoot + "/", "")}/${f}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
catch { /* ignore */ }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Step 2: for the broken symbol AND every candidate-existing file's basename,
|
|
607
|
+
// grep all importers across mock/src + src.
|
|
608
|
+
const symbolsToGrep = new Set([symbol]);
|
|
609
|
+
for (const c of candidateExisting) {
|
|
610
|
+
const base = c.split("/").pop().replace(/\.tsx?$/, "");
|
|
611
|
+
symbolsToGrep.add(base);
|
|
612
|
+
}
|
|
613
|
+
const mockImporters = {};
|
|
614
|
+
const srcImporters = {};
|
|
615
|
+
for (const sym of symbolsToGrep) {
|
|
616
|
+
mockImporters[sym] = grepImportsBySymbol(join(args.repoRoot, "mock/src"), sym);
|
|
617
|
+
srcImporters[sym] = grepImportsBySymbol(join(args.repoRoot, "src"), sym);
|
|
618
|
+
}
|
|
619
|
+
// Read content of every importer file so chef can craft accurate
|
|
620
|
+
// search_replace pairs without inventing or omitting code.
|
|
621
|
+
const importerContents = {};
|
|
622
|
+
const allImporterFiles = new Set();
|
|
623
|
+
for (const sym of symbolsToGrep) {
|
|
624
|
+
for (const imp of mockImporters[sym] ?? [])
|
|
625
|
+
allImporterFiles.add(imp.file);
|
|
626
|
+
for (const imp of srcImporters[sym] ?? [])
|
|
627
|
+
allImporterFiles.add(imp.file);
|
|
628
|
+
}
|
|
629
|
+
for (const candidate of candidateExisting)
|
|
630
|
+
allImporterFiles.add(candidate);
|
|
631
|
+
for (const f of allImporterFiles) {
|
|
632
|
+
const abs = join(args.repoRoot, f);
|
|
633
|
+
if (existsSync(abs)) {
|
|
634
|
+
const content = readFileSync(abs, "utf8");
|
|
635
|
+
importerContents[f] = content.length > 6000 ? content.slice(0, 6000) + "\n// ... truncated" : content;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
triggerRaw = {
|
|
639
|
+
...triggerRaw,
|
|
640
|
+
symbol,
|
|
641
|
+
candidate_existing_files: candidateExisting,
|
|
642
|
+
importers_per_symbol: {
|
|
643
|
+
mock: mockImporters,
|
|
644
|
+
src: srcImporters,
|
|
645
|
+
},
|
|
646
|
+
existing_content: importerContents,
|
|
647
|
+
enrichment_note: "cli-precomputed (chef has no grep/read tools): importers_per_symbol shows EVERY file that imports each candidate. existing_content gives you the FULL TEXT of each importer + candidate file so you can craft accurate search_replace pairs. ALWAYS use search_replace operation for content edits — never full-content rewrites.",
|
|
648
|
+
};
|
|
649
|
+
const totalMockImps = Object.values(mockImporters).reduce((n, arr) => n + arr.length, 0);
|
|
650
|
+
const totalSrcImps = Object.values(srcImporters).reduce((n, arr) => n + arr.length, 0);
|
|
651
|
+
console.log(` enriched trigger: ${candidateExisting.length} candidate file(s), ${symbolsToGrep.size} symbol(s) checked, ${totalMockImps} mock importer(s), ${totalSrcImps} src importer(s) total`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// L2 brew-halt enrichment: parse the brew runner output (passed via
|
|
655
|
+
// --trigger-raw with a 'runner_output' field) to identify failing
|
|
656
|
+
// test files, read their contents, list the source files they import,
|
|
657
|
+
// and read those too. Chef has no read tools — must inject the
|
|
658
|
+
// material it needs to write surgical search_replace pairs.
|
|
659
|
+
if (args.triggerKind === "brew_halt_class") {
|
|
660
|
+
const runnerOutput = triggerRaw["runner_output"] ?? args.triggerDetail;
|
|
661
|
+
if (typeof runnerOutput === "string" && runnerOutput.length > 0) {
|
|
662
|
+
const { failingFiles, failingTestNames } = parseBrewHaltOutput(runnerOutput);
|
|
663
|
+
const failingTestContents = {};
|
|
664
|
+
for (const f of failingFiles) {
|
|
665
|
+
const abs = join(args.repoRoot, f);
|
|
666
|
+
if (existsSync(abs)) {
|
|
667
|
+
const c = readFileSync(abs, "utf8");
|
|
668
|
+
failingTestContents[f] = c.length > 6000 ? c.slice(0, 6000) + "\n// ... truncated" : c;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const importedSourcesMap = collectImportedSourceFiles(failingTestContents);
|
|
672
|
+
// Resolve relative imports against each test file's directory + read
|
|
673
|
+
// the source content. Only resolves relative imports — package
|
|
674
|
+
// imports aren't in-repo.
|
|
675
|
+
const sourceContents = {};
|
|
676
|
+
for (const [testFile, sources] of Object.entries(importedSourcesMap)) {
|
|
677
|
+
const testDir = dirname(testFile);
|
|
678
|
+
for (const rel of sources) {
|
|
679
|
+
for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) {
|
|
680
|
+
const candidate = join(args.repoRoot, testDir, rel + ext);
|
|
681
|
+
if (existsSync(candidate) && !candidate.endsWith("/")) {
|
|
682
|
+
const projRel = candidate.replace(args.repoRoot + "/", "");
|
|
683
|
+
if (sourceContents[projRel])
|
|
684
|
+
break;
|
|
685
|
+
const c = readFileSync(candidate, "utf8");
|
|
686
|
+
sourceContents[projRel] = c.length > 6000 ? c.slice(0, 6000) + "\n// ... truncated" : c;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
triggerRaw = {
|
|
693
|
+
...triggerRaw,
|
|
694
|
+
failing_test_files: failingFiles,
|
|
695
|
+
failing_test_names: failingTestNames,
|
|
696
|
+
failing_test_contents: failingTestContents,
|
|
697
|
+
imported_source_files_per_test: importedSourcesMap,
|
|
698
|
+
source_file_contents: sourceContents,
|
|
699
|
+
enrichment_note: "cli-precomputed (chef has no read tools): failing_test_contents shows you each red test verbatim. source_file_contents gives you the full text of every source file imported by those tests. Plan search_replace pairs against source_file_contents — never edit failing_test_contents (tests/ is frozen). If the failure can only be fixed by changing a test, return pm_question instead.",
|
|
700
|
+
};
|
|
701
|
+
console.log(` brew-halt enriched: ${failingFiles.length} failing test file(s), ${Object.keys(sourceContents).length} source file(s) under test`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
let navigatorHistory = null;
|
|
705
|
+
if (args.navigatorHistoryPath && existsSync(args.navigatorHistoryPath)) {
|
|
706
|
+
try {
|
|
707
|
+
navigatorHistory = JSON.parse(readFileSync(args.navigatorHistoryPath, "utf8"));
|
|
708
|
+
}
|
|
709
|
+
catch { /* ignore */ }
|
|
710
|
+
}
|
|
711
|
+
const { specPath, specYaml } = loadSpec(args.repoRoot, args.storyId);
|
|
712
|
+
const openPrs = loadOpenPrs(args.repoRoot, args.storyId);
|
|
713
|
+
const historyIndex = loadHistoryIndex(args.repoRoot);
|
|
714
|
+
const issueMatch = specYaml.match(/source_issue:\s*"#?(\d+)"/);
|
|
715
|
+
const issueNumber = issueMatch ? parseInt(issueMatch[1], 10) : 0;
|
|
716
|
+
const prompt = buildChefPrompt({
|
|
717
|
+
storyId: args.storyId,
|
|
718
|
+
trigger: { kind: args.triggerKind, detail: args.triggerDetail, raw: triggerRaw },
|
|
719
|
+
storyState: { issueNumber, specPath, specYaml, openPrs },
|
|
720
|
+
historyIndex,
|
|
721
|
+
navigatorHistory: navigatorHistory,
|
|
722
|
+
priorChefMoves: ledger.moves.map((m) => ({
|
|
723
|
+
n: m.n,
|
|
724
|
+
triggerKind: m.trigger_kind,
|
|
725
|
+
decision: m.decision,
|
|
726
|
+
postState: m.post_state,
|
|
727
|
+
})),
|
|
728
|
+
});
|
|
729
|
+
console.log(` prompt: ${prompt.length} chars · calling chef LLM (${args.model})`);
|
|
730
|
+
const client = new AnthropicClient(apiKey);
|
|
731
|
+
const resp = await client.complete({
|
|
732
|
+
model: args.model,
|
|
733
|
+
system: CHEF_SYSTEM,
|
|
734
|
+
messages: [{ role: "user", content: prompt }],
|
|
735
|
+
maxTokens: 8192,
|
|
736
|
+
});
|
|
737
|
+
console.log(` chef LLM: ${resp.usage.inputTokens}→${resp.usage.outputTokens} tok · $${resp.costUsd.toFixed(4)}`);
|
|
738
|
+
let verdict;
|
|
739
|
+
try {
|
|
740
|
+
const text = resp.text.trim();
|
|
741
|
+
const fence = text.match(/```json\s*([\s\S]*?)```/);
|
|
742
|
+
verdict = JSON.parse(fence ? fence[1] : text);
|
|
743
|
+
}
|
|
744
|
+
catch (e) {
|
|
745
|
+
console.error(` ! chef JSON parse failed: ${e.message}`);
|
|
746
|
+
console.error(` raw: ${resp.text.slice(0, 500)}`);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
console.log(` chef verdict: ${verdict.kind.toUpperCase()}`);
|
|
750
|
+
console.log(` rationale: ${verdict.rationale}`);
|
|
751
|
+
// Frozen-surface guard
|
|
752
|
+
for (const e of verdict.edits) {
|
|
753
|
+
if (isFrozenPath(e.file) || (e.to && isFrozenPath(e.to))) {
|
|
754
|
+
console.error(` ! chef proposed an edit to frozen path: ${e.file} → ${e.to ?? ""}. Halting + escalating.`);
|
|
755
|
+
verdict.kind = "halt";
|
|
756
|
+
verdict.edits = [];
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// Cycle guard
|
|
760
|
+
if (verdict.kind === "autonomous_fix" && detectCycle(ledger, verdict.edits)) {
|
|
761
|
+
console.error(` ! cycle detected: chef's planned edit is the inverse of an earlier move. Halting.`);
|
|
762
|
+
verdict.kind = "halt";
|
|
763
|
+
verdict.edits = [];
|
|
764
|
+
}
|
|
765
|
+
const moveN = ledger.moves.length + 1;
|
|
766
|
+
const moveEntry = {
|
|
767
|
+
n: moveN,
|
|
768
|
+
trigger_kind: args.triggerKind,
|
|
769
|
+
drift_signature: args.triggerDetail.slice(0, 200),
|
|
770
|
+
rationale: verdict.rationale,
|
|
771
|
+
decision: verdict.kind,
|
|
772
|
+
edits: verdict.edits,
|
|
773
|
+
validation_command: verdict.validation?.command ?? null,
|
|
774
|
+
validation_result: null,
|
|
775
|
+
next_dispatch: verdict.next_dispatch,
|
|
776
|
+
cost_usd: resp.costUsd,
|
|
777
|
+
post_state: "escalated",
|
|
778
|
+
timestamp: new Date().toISOString(),
|
|
779
|
+
};
|
|
780
|
+
if (verdict.kind === "autonomous_fix" && verdict.edits.length > 0) {
|
|
781
|
+
if (args.dryRun) {
|
|
782
|
+
console.log(`\n [dry-run] would apply ${verdict.edits.length} edit(s); skipping`);
|
|
783
|
+
moveEntry.post_state = "escalated"; // dry-run: state is unknown without applying
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
console.log(`\n applying ${verdict.edits.length} edit(s)...`);
|
|
787
|
+
try {
|
|
788
|
+
// Capture PRE-move validation output as baseline. Pre-existing
|
|
789
|
+
// failures aren't chef's responsibility — only new ones are.
|
|
790
|
+
let preBaseline = null;
|
|
791
|
+
if (verdict.validation) {
|
|
792
|
+
console.log(` baseline (pre-edit): ${verdict.validation.command}`);
|
|
793
|
+
preBaseline = runValidation(args.repoRoot, verdict.validation.command);
|
|
794
|
+
console.log(` pre passed=${preBaseline.passed}`);
|
|
795
|
+
}
|
|
796
|
+
for (const e of verdict.edits)
|
|
797
|
+
applyEdit(args.repoRoot, e);
|
|
798
|
+
if (verdict.validation && preBaseline) {
|
|
799
|
+
console.log(` validating: ${verdict.validation.command}`);
|
|
800
|
+
const post = runValidation(args.repoRoot, verdict.validation.command);
|
|
801
|
+
const diff = compareValidationOutputs(preBaseline.output, post.output);
|
|
802
|
+
console.log(` post passed=${post.passed} · diff=${diff}`);
|
|
803
|
+
if (post.passed) {
|
|
804
|
+
moveEntry.validation_result = "passed";
|
|
805
|
+
moveEntry.post_state = "clean";
|
|
806
|
+
console.log(` ✓ validation cleanly passed`);
|
|
807
|
+
}
|
|
808
|
+
else if (diff === "progress") {
|
|
809
|
+
moveEntry.validation_result = "passed"; // partial — pre-existing unrelated failures remain
|
|
810
|
+
moveEntry.post_state = "clean";
|
|
811
|
+
console.log(` ✓ progress: chef removed failures + introduced none. Pre-existing unrelated failures remain (not chef's responsibility).`);
|
|
812
|
+
}
|
|
813
|
+
else if (diff === "no-change" && verdict.validation.must_exit_zero) {
|
|
814
|
+
console.error(` ! validation NO-CHANGE. Chef's edits didn't help. Reverting.`);
|
|
815
|
+
execSync(`git -C "${args.repoRoot}" checkout HEAD -- .`);
|
|
816
|
+
execSync(`git -C "${args.repoRoot}" clean -fd`);
|
|
817
|
+
moveEntry.validation_result = "failed";
|
|
818
|
+
moveEntry.post_state = "still-broken";
|
|
819
|
+
}
|
|
820
|
+
else if (diff === "regression") {
|
|
821
|
+
console.error(` ! validation REGRESSION: chef introduced new failures. Reverting.`);
|
|
822
|
+
execSync(`git -C "${args.repoRoot}" checkout HEAD -- .`);
|
|
823
|
+
execSync(`git -C "${args.repoRoot}" clean -fd`);
|
|
824
|
+
moveEntry.validation_result = "failed";
|
|
825
|
+
moveEntry.post_state = "still-broken";
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
moveEntry.validation_result = "passed";
|
|
829
|
+
moveEntry.post_state = "clean";
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
moveEntry.validation_result = "not-run";
|
|
834
|
+
moveEntry.post_state = "clean";
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
catch (e) {
|
|
838
|
+
console.error(` ! edit-application failed: ${e.message}. Reverting.`);
|
|
839
|
+
try {
|
|
840
|
+
execSync(`git -C "${args.repoRoot}" checkout HEAD -- .`);
|
|
841
|
+
}
|
|
842
|
+
catch { /* */ }
|
|
843
|
+
try {
|
|
844
|
+
execSync(`git -C "${args.repoRoot}" clean -fd`);
|
|
845
|
+
}
|
|
846
|
+
catch { /* */ }
|
|
847
|
+
moveEntry.post_state = "still-broken";
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
else if (verdict.kind === "pm_question" && verdict.pm_comment && !args.dryRun) {
|
|
852
|
+
console.log(`\n posting PM question to issue #${verdict.pm_comment.issue_number}`);
|
|
853
|
+
postIssueComment(args.repoRoot, verdict.pm_comment.issue_number, verdict.pm_comment.body);
|
|
854
|
+
}
|
|
855
|
+
// Commit chef's edits locally (workflow handles the push) when validation passed.
|
|
856
|
+
// L2 finisher mode: also push to the PR's branch directly so the PR re-runs CI.
|
|
857
|
+
let commitSha = null;
|
|
858
|
+
if (verdict.kind === "autonomous_fix" && moveEntry.post_state === "clean" && !args.dryRun) {
|
|
859
|
+
const summary = `move ${moveN} on story-${args.storyId} — ${args.triggerKind}: ${verdict.rationale.slice(0, 120)}`;
|
|
860
|
+
const result = commitChefEdits(args.repoRoot, summary, verdict.edits);
|
|
861
|
+
if (result.sha) {
|
|
862
|
+
commitSha = result.sha;
|
|
863
|
+
console.log(` committed: ${result.sha.slice(0, 7)}`);
|
|
864
|
+
}
|
|
865
|
+
if (commitSha && prCheckout) {
|
|
866
|
+
const pushed = pushChefEditsToPrBranch(args.repoRoot, prCheckout.branchName, prCheckout.localRef);
|
|
867
|
+
if (pushed)
|
|
868
|
+
console.log(` finisher: pushed ${commitSha.slice(0, 7)} → ${prCheckout.branchName}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
ledger.moves.push(moveEntry);
|
|
872
|
+
ledger.cumulative_cost_usd += resp.costUsd;
|
|
873
|
+
if (!args.dryRun)
|
|
874
|
+
saveLedger(args.repoRoot, ledger);
|
|
875
|
+
// Audit comment routing: in finisher mode (--pr) write to the PR;
|
|
876
|
+
// otherwise write to the source issue (L1 behavior).
|
|
877
|
+
if (verdict.kind === "autonomous_fix" && moveEntry.post_state === "clean" && !args.dryRun) {
|
|
878
|
+
const body = buildAuditCommentBody({ ledger, move: moveEntry, verdict });
|
|
879
|
+
const target = args.prNumber ?? (issueNumber > 0 ? issueNumber : null);
|
|
880
|
+
if (target !== null) {
|
|
881
|
+
try {
|
|
882
|
+
postIssueComment(args.repoRoot, target, body);
|
|
883
|
+
}
|
|
884
|
+
catch (e) {
|
|
885
|
+
console.warn(` warn: audit comment failed (non-fatal): ${e.message.slice(0, 200)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
console.log(`\n done · post_state=${moveEntry.post_state} · move=${moveN} · cum-cost=$${ledger.cumulative_cost_usd.toFixed(4)}`);
|
|
890
|
+
if (verdict.kind === "halt" || moveEntry.post_state === "still-broken")
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
//# sourceMappingURL=drift-fix.js.map
|