@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.
@@ -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