@slowcook-ai/cli 0.18.0-alpha.6 → 0.18.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,712 @@
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
+ function parseArgs(argv) {
40
+ const args = {
41
+ storyId: "",
42
+ repoRoot: process.cwd(),
43
+ triggerKind: "mock_isolation_check_failed",
44
+ triggerDetail: "",
45
+ triggerRawPath: null,
46
+ navigatorHistoryPath: null,
47
+ model: "claude-sonnet-4-5-20250929",
48
+ budgetUsd: 1.0,
49
+ dryRun: false,
50
+ };
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ const next = argv[i + 1];
54
+ if (a === "--story" && next) {
55
+ args.storyId = next;
56
+ i++;
57
+ }
58
+ else if (a === "--cwd" && next) {
59
+ args.repoRoot = next;
60
+ i++;
61
+ }
62
+ else if (a === "--trigger" && next) {
63
+ args.triggerKind = next;
64
+ i++;
65
+ }
66
+ else if (a === "--trigger-detail" && next) {
67
+ args.triggerDetail = next;
68
+ i++;
69
+ }
70
+ else if (a === "--trigger-raw" && next) {
71
+ args.triggerRawPath = next;
72
+ i++;
73
+ }
74
+ else if (a === "--navigator-history" && next) {
75
+ args.navigatorHistoryPath = next;
76
+ i++;
77
+ }
78
+ else if (a === "--model" && next) {
79
+ args.model = next;
80
+ i++;
81
+ }
82
+ else if (a === "--budget-usd" && next) {
83
+ args.budgetUsd = parseFloat(next);
84
+ i++;
85
+ }
86
+ else if (a === "--dry-run") {
87
+ args.dryRun = true;
88
+ }
89
+ else if (a === "--help" || a === "-h") {
90
+ printHelp();
91
+ process.exit(0);
92
+ }
93
+ }
94
+ if (!args.storyId) {
95
+ console.error("--story <id> is required");
96
+ printHelp();
97
+ process.exit(64);
98
+ }
99
+ return args;
100
+ }
101
+ function printHelp() {
102
+ console.log(`
103
+ slowcook chef-drift — surgical drift-fixer (cli α.9 L1)
104
+
105
+ Sibling to \`slowcook chef --pr <n>\` (PR-CI-failure handler). This
106
+ variant: triggered by mock-isolation / recon / brew halt / navigator
107
+ halt-class; makes surgical edits across spec + mock + prod (NEVER tests
108
+ or vitest config); commits + posts audit comment + optionally dispatches
109
+ the next pipeline step.
110
+
111
+ Usage:
112
+ slowcook chef-drift --story <id> --trigger <kind> [options]
113
+
114
+ Options:
115
+ --cwd <path> Repo root (default: cwd).
116
+ --trigger <kind> mock_isolation_check_failed | recon_escalation |
117
+ brew_halt_class | navigator_halt_class
118
+ --trigger-detail <text> One-line summary of the failure.
119
+ --trigger-raw <path> Path to JSON file with full trigger detail.
120
+ --navigator-history <path> Path to navigator-history JSON (when applicable).
121
+ --model <id> Anthropic model id.
122
+ --budget-usd <n> Per-episode budget cap (default: 1.00).
123
+ --dry-run Print verdict; do not apply edits.
124
+
125
+ Requires: ANTHROPIC_API_KEY in env. Run from consumer repo root.
126
+ Frozen surface: tests/, vitest.config.*, .brewing/{auto-gen}/ — never edited.
127
+ `);
128
+ }
129
+ function loadHistoryIndex(repoRoot) {
130
+ const path = join(repoRoot, ".brewing/history-index.json");
131
+ if (!existsSync(path))
132
+ return {};
133
+ try {
134
+ return JSON.parse(readFileSync(path, "utf8"));
135
+ }
136
+ catch {
137
+ return {};
138
+ }
139
+ }
140
+ function loadSpec(repoRoot, storyId) {
141
+ const path = `specs/story-${storyId}.yaml`;
142
+ const abs = join(repoRoot, path);
143
+ if (!existsSync(abs))
144
+ return { specPath: path, specYaml: "" };
145
+ return { specPath: path, specYaml: readFileSync(abs, "utf8") };
146
+ }
147
+ function loadOpenPrs(repoRoot, storyId) {
148
+ const out = [];
149
+ try {
150
+ const repoSlug = execSync(`git -C "${repoRoot}" remote get-url origin | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||'`, { encoding: "utf8" }).trim();
151
+ const json = execSync(`gh pr list --repo "${repoSlug}" --search "story-${storyId}" --state open --json number,headRefName,headRefOid,title --limit 10`, { encoding: "utf8" });
152
+ const prs = JSON.parse(json);
153
+ for (const pr of prs) {
154
+ const branch = pr.headRefName;
155
+ let kind;
156
+ if (branch.includes("/spec/"))
157
+ kind = "spec";
158
+ else if (branch.includes("/mockup/"))
159
+ kind = "mockup";
160
+ else if (branch.includes("/tests/") || branch.includes("/recipe/"))
161
+ kind = "tests";
162
+ else if (branch.includes("/brew/"))
163
+ kind = "brew";
164
+ else
165
+ continue;
166
+ out.push({ kind, number: pr.number, branch, headSha: pr.headRefOid });
167
+ }
168
+ }
169
+ catch (e) {
170
+ console.warn(` warn: could not list open PRs (${e.message.slice(0, 100)})`);
171
+ }
172
+ return out;
173
+ }
174
+ function loadLedger(repoRoot, storyId) {
175
+ const path = join(repoRoot, `.brewing/chef/story-${storyId}.json`);
176
+ if (!existsSync(path)) {
177
+ return { story_id: storyId, episode: 1, moves: [], cumulative_cost_usd: 0, halt_reason: null };
178
+ }
179
+ return JSON.parse(readFileSync(path, "utf8"));
180
+ }
181
+ function saveLedger(repoRoot, ledger) {
182
+ const path = join(repoRoot, `.brewing/chef/story-${ledger.story_id}.json`);
183
+ mkdirSync(dirname(path), { recursive: true });
184
+ writeFileSync(path, JSON.stringify(ledger, null, 2), "utf8");
185
+ }
186
+ function detectCycle(ledger, plannedEdits) {
187
+ for (const prior of ledger.moves) {
188
+ for (const priorEdit of prior.edits) {
189
+ if (priorEdit.operation !== "rename" || !priorEdit.to)
190
+ continue;
191
+ const matches = plannedEdits.find((e) => e.operation === "rename" && e.file === priorEdit.to && e.to === priorEdit.file);
192
+ if (matches)
193
+ return true;
194
+ }
195
+ }
196
+ return false;
197
+ }
198
+ function applyEdit(repoRoot, edit) {
199
+ const abs = join(repoRoot, edit.file);
200
+ switch (edit.operation) {
201
+ case "rename": {
202
+ if (!edit.to)
203
+ throw new Error(`rename edit missing 'to' field for ${edit.file}`);
204
+ const toAbs = join(repoRoot, edit.to);
205
+ mkdirSync(dirname(toAbs), { recursive: true });
206
+ execSync(`git -C "${repoRoot}" mv "${edit.file}" "${edit.to}"`, { stdio: "ignore" });
207
+ // Heuristic: if file basename changed, rename default-export inside the file
208
+ const oldName = edit.file.split("/").pop().replace(/\.tsx?$/, "");
209
+ const newName = edit.to.split("/").pop().replace(/\.tsx?$/, "");
210
+ if (oldName !== newName && existsSync(toAbs)) {
211
+ let content = readFileSync(toAbs, "utf8");
212
+ content = content.replace(new RegExp(`(export default function )${oldName}\\b`, "g"), `$1${newName}`);
213
+ writeFileSync(toAbs, content, "utf8");
214
+ }
215
+ break;
216
+ }
217
+ case "create": {
218
+ if (!edit.patch)
219
+ throw new Error(`create edit missing 'patch' for ${edit.file}`);
220
+ mkdirSync(dirname(abs), { recursive: true });
221
+ writeFileSync(abs, edit.patch, "utf8");
222
+ break;
223
+ }
224
+ // Legacy 'edit' operation removed — chef must use 'search_replace'
225
+ // for content changes. The default branch below catches it via
226
+ // exhaustive-switch enforcement.
227
+ case "search_replace": {
228
+ const sr = edit.search_replace;
229
+ if (!sr || !Array.isArray(sr) || sr.length === 0) {
230
+ throw new Error(`search_replace edit missing 'search_replace' array for ${edit.file}`);
231
+ }
232
+ if (!existsSync(abs)) {
233
+ throw new Error(`search_replace target file does not exist: ${edit.file}`);
234
+ }
235
+ let content = readFileSync(abs, "utf8");
236
+ for (const pair of sr) {
237
+ if (!pair.find || pair.replace === undefined) {
238
+ throw new Error(`search_replace pair missing find or replace for ${edit.file}`);
239
+ }
240
+ // Require find to appear exactly once — guards against ambiguous matches
241
+ const occurrences = content.split(pair.find).length - 1;
242
+ if (occurrences === 0) {
243
+ throw new Error(`search_replace 'find' string not found in ${edit.file}: ${JSON.stringify(pair.find).slice(0, 120)}`);
244
+ }
245
+ if (occurrences > 1) {
246
+ throw new Error(`search_replace 'find' string matches ${occurrences}x in ${edit.file} (must be unique): ${JSON.stringify(pair.find).slice(0, 120)}`);
247
+ }
248
+ content = content.replace(pair.find, pair.replace);
249
+ }
250
+ writeFileSync(abs, content, "utf8");
251
+ break;
252
+ }
253
+ case "delete": {
254
+ execSync(`rm -f "${abs}"`);
255
+ break;
256
+ }
257
+ default: {
258
+ throw new Error(`unsupported edit operation '${edit.operation}' for ${edit.file} — use rename / search_replace / create / delete only.`);
259
+ }
260
+ }
261
+ }
262
+ function runValidation(repoRoot, command) {
263
+ // Chef may produce commands prefixed with `slowcook ...` (matches the
264
+ // documented prompt examples). On runners where slowcook isn't on
265
+ // PATH, we route through the same node binary that's running chef.
266
+ let resolved = command.trim();
267
+ if (resolved.startsWith("slowcook ")) {
268
+ const cliJs = process.argv[1] || "";
269
+ if (cliJs && existsSync(cliJs)) {
270
+ resolved = `node "${cliJs}" ${resolved.slice("slowcook ".length)}`;
271
+ }
272
+ }
273
+ try {
274
+ const output = execSync(resolved, { cwd: repoRoot, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
275
+ return { passed: true, output };
276
+ }
277
+ catch (e) {
278
+ const err = e;
279
+ const out = err.stdout || err.stderr || err.message || "";
280
+ return { passed: false, output: out };
281
+ }
282
+ }
283
+ /**
284
+ * Compare pre-move + post-move validation outputs to decide whether
285
+ * chef's iteration made progress.
286
+ *
287
+ * Returns 'progress' when post-set ⊆ pre-set AND post-set has fewer
288
+ * unique failure-lines than pre-set (i.e., chef removed at least one
289
+ * failure + introduced none).
290
+ *
291
+ * Returns 'no-change' when post-set === pre-set.
292
+ *
293
+ * Returns 'regression' when post-set has any line not in pre-set
294
+ * (chef introduced a new failure).
295
+ */
296
+ function compareValidationOutputs(pre, post) {
297
+ // Heuristic: extract lines that look like file:line refs (typical
298
+ // failure-marker shape). Compare as sets.
299
+ const extractFailureLines = (s) => {
300
+ const out = new Set();
301
+ for (const line of s.split("\n")) {
302
+ const m = line.match(/^\s*([\w./[\]()-]+\.(?:ts|tsx|js|jsx|yaml|yml|sql|md|json)):(\d+)/);
303
+ if (m)
304
+ out.add(`${m[1]}:${m[2]}`);
305
+ }
306
+ return out;
307
+ };
308
+ const preSet = extractFailureLines(pre);
309
+ const postSet = extractFailureLines(post);
310
+ const newFailures = [...postSet].filter((x) => !preSet.has(x));
311
+ if (newFailures.length > 0)
312
+ return "regression";
313
+ if (postSet.size < preSet.size)
314
+ return "progress";
315
+ return "no-change";
316
+ }
317
+ function postIssueComment(repoRoot, issueNumber, body) {
318
+ const repoSlug = execSync(`git -C "${repoRoot}" remote get-url origin | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||'`, { encoding: "utf8" }).trim();
319
+ // Try gh CLI first; fall back to curl + GITHUB_TOKEN. Runners often
320
+ // have one but not the other.
321
+ const tmp = "/tmp/chef-drift-comment.md";
322
+ writeFileSync(tmp, body, "utf8");
323
+ try {
324
+ execSync(`gh issue comment ${issueNumber} --repo "${repoSlug}" --body-file ${tmp}`, { stdio: "inherit" });
325
+ return;
326
+ }
327
+ catch {
328
+ // fall through to curl
329
+ }
330
+ const token = process.env["GITHUB_TOKEN"];
331
+ if (!token) {
332
+ console.warn(` warn: gh not installed + GITHUB_TOKEN not set; skipping audit comment on issue #${issueNumber}`);
333
+ return;
334
+ }
335
+ const payload = JSON.stringify({ body });
336
+ const payloadFile = "/tmp/chef-drift-payload.json";
337
+ writeFileSync(payloadFile, payload, "utf8");
338
+ try {
339
+ 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" });
340
+ console.log(` posted audit comment on issue #${issueNumber} (via curl)`);
341
+ }
342
+ catch (e) {
343
+ console.warn(` warn: failed to post audit comment via curl: ${e.message.slice(0, 200)}`);
344
+ }
345
+ }
346
+ function commitChefEdits(repoRoot, summary, edits) {
347
+ // Stage ONLY the files chef edited (never `git add -A` — that
348
+ // accidentally captures workflow-side clones like `_slowcook/` etc.
349
+ // that live in the consumer repo's working tree). For renames, stage
350
+ // both the old + new path so git records the rename.
351
+ try {
352
+ const pathsToAdd = new Set();
353
+ for (const e of edits) {
354
+ pathsToAdd.add(e.file);
355
+ if (e.to)
356
+ pathsToAdd.add(e.to);
357
+ }
358
+ if (pathsToAdd.size === 0)
359
+ return { sha: null, pushed: false };
360
+ for (const p of pathsToAdd) {
361
+ try {
362
+ execSync(`git -C "${repoRoot}" add "${p}"`, { stdio: "ignore" });
363
+ }
364
+ catch { /* file may have been renamed away — ignore */ }
365
+ }
366
+ // Also stage the chef ledger so it's part of the commit
367
+ try {
368
+ execSync(`git -C "${repoRoot}" add ".brewing/chef/"`, { stdio: "ignore" });
369
+ }
370
+ catch { /* ledger dir may not exist if first move ran into early error */ }
371
+ // Empty commit guard: if nothing staged, skip.
372
+ const status = execSync(`git -C "${repoRoot}" status --porcelain --cached`, { encoding: "utf8" }).trim();
373
+ if (!status)
374
+ return { sha: null, pushed: false };
375
+ // Write commit message to file (handle quotes cleanly).
376
+ const msgFile = "/tmp/chef-drift-commit-msg.txt";
377
+ writeFileSync(msgFile, `[chef] ${summary}\n\nCo-Authored-By: slowcook-chef[bot] <slowcook-chef@users.noreply.github.com>\n`, "utf8");
378
+ execSync(`git -C "${repoRoot}" -c user.name="slowcook-chef[bot]" -c user.email="slowcook-chef@users.noreply.github.com" commit -F ${msgFile}`, { stdio: "inherit" });
379
+ const sha = execSync(`git -C "${repoRoot}" rev-parse HEAD`, { encoding: "utf8" }).trim();
380
+ return { sha, pushed: false };
381
+ }
382
+ catch (e) {
383
+ console.warn(` warn: chef commit failed: ${e.message.slice(0, 200)}`);
384
+ return { sha: null, pushed: false };
385
+ }
386
+ }
387
+ function buildAuditCommentBody(args) {
388
+ const { move, verdict } = args;
389
+ const lines = [];
390
+ lines.push(`### [chef-drift] Move ${move.n} on story-${args.ledger.story_id}`);
391
+ lines.push("");
392
+ lines.push(`**Trigger:** \`${move.trigger_kind}\``);
393
+ lines.push("");
394
+ lines.push(`**Decision:** ${move.decision}`);
395
+ lines.push("");
396
+ lines.push(`**Rationale:** ${verdict.rationale}`);
397
+ lines.push("");
398
+ if (move.edits.length > 0) {
399
+ lines.push(`**Files touched:**`);
400
+ for (const e of move.edits) {
401
+ lines.push(`- \`${e.file}\` (${e.operation}${e.to ? ` → \`${e.to}\`` : ""})`);
402
+ }
403
+ lines.push("");
404
+ }
405
+ if (move.validation_command) {
406
+ lines.push(`**Validation:** \`${move.validation_command}\` → ${move.validation_result}`);
407
+ lines.push("");
408
+ }
409
+ if (move.next_dispatch) {
410
+ lines.push(`**Next:** dispatched \`${move.next_dispatch}\``);
411
+ lines.push("");
412
+ }
413
+ lines.push(`**Cost:** $${move.cost_usd.toFixed(2)} (cumulative: $${args.ledger.cumulative_cost_usd.toFixed(2)})`);
414
+ return lines.join("\n");
415
+ }
416
+ export async function chefDrift(argv, _cliVersion) {
417
+ const args = parseArgs(argv);
418
+ const apiKey = process.env["ANTHROPIC_API_KEY"];
419
+ if (!apiKey) {
420
+ console.error("ANTHROPIC_API_KEY env var is required.");
421
+ process.exit(2);
422
+ }
423
+ console.log(`slowcook chef-drift · story-${args.storyId} · trigger=${args.triggerKind}`);
424
+ const ledger = loadLedger(args.repoRoot, args.storyId);
425
+ if (ledger.cumulative_cost_usd >= args.budgetUsd) {
426
+ console.error(` episode budget exceeded ($${ledger.cumulative_cost_usd.toFixed(2)} >= $${args.budgetUsd}). Halting.`);
427
+ process.exit(1);
428
+ }
429
+ let triggerRaw = {};
430
+ if (args.triggerRawPath && existsSync(args.triggerRawPath)) {
431
+ try {
432
+ triggerRaw = JSON.parse(readFileSync(args.triggerRawPath, "utf8"));
433
+ }
434
+ catch { /* ignore */ }
435
+ }
436
+ // Enrich trigger.raw with context the chef LLM doesn't have read-tools to gather.
437
+ // For mock_isolation failures: grep ALL importers of the missing symbol so chef
438
+ // can plan a coordinated rename (not just the one file the trigger detail named).
439
+ if (args.triggerKind === "mock_isolation_check_failed") {
440
+ const importerMatch = args.triggerDetail.match(/['"]\.\/(\w+)['"]/);
441
+ if (importerMatch && importerMatch[1]) {
442
+ const symbol = importerMatch[1];
443
+ const grepImportsBySymbol = (root, sym) => {
444
+ try {
445
+ const out = execSync(`grep -rnE "from\\s+[\\\"']\\.[/.][^\\\"']*${sym}[\\\"']" "${root}" 2>/dev/null || true`, { encoding: "utf8", maxBuffer: 1024 * 1024 });
446
+ return out
447
+ .split("\n")
448
+ .filter(Boolean)
449
+ .map((line) => {
450
+ const m = line.match(/^([^:]+):(\d+):(.*)$/);
451
+ if (!m)
452
+ return null;
453
+ return { file: m[1].replace(args.repoRoot + "/", ""), line: parseInt(m[2], 10), text: m[3].trim() };
454
+ })
455
+ .filter((x) => x !== null);
456
+ }
457
+ catch {
458
+ return [];
459
+ }
460
+ };
461
+ // Step 1: find existing files in same dir with names similar to the missing symbol.
462
+ // These are the rename candidates — the file that chef likely needs to rename forward.
463
+ const candidateExisting = [];
464
+ const symbolLower = symbol.toLowerCase();
465
+ const importerFile = args.triggerDetail.match(/^([^\s]+):/)?.[1];
466
+ const stem = symbolLower.replace(/strip|page|header|badge|card|list|row|item/g, "").trim();
467
+ if (importerFile) {
468
+ const dir = dirname(join(args.repoRoot, importerFile));
469
+ if (existsSync(dir)) {
470
+ try {
471
+ const files = readdirSync(dir);
472
+ for (const f of files) {
473
+ const fLow = f.toLowerCase().replace(/\.tsx?$/, "");
474
+ if (!/\.tsx?$/.test(f))
475
+ continue;
476
+ if (fLow === symbolLower)
477
+ continue; // skip exact match (it'd already exist)
478
+ // Match if file name shares a meaningful stem with the symbol
479
+ if ((stem.length > 3 && fLow.includes(stem)) ||
480
+ fLow.includes(symbolLower.slice(0, 6)) ||
481
+ symbolLower.includes(fLow)) {
482
+ candidateExisting.push(`${dir.replace(args.repoRoot + "/", "")}/${f}`);
483
+ }
484
+ }
485
+ }
486
+ catch { /* ignore */ }
487
+ }
488
+ }
489
+ // Step 2: for the broken symbol AND every candidate-existing file's basename,
490
+ // grep all importers across mock/src + src.
491
+ const symbolsToGrep = new Set([symbol]);
492
+ for (const c of candidateExisting) {
493
+ const base = c.split("/").pop().replace(/\.tsx?$/, "");
494
+ symbolsToGrep.add(base);
495
+ }
496
+ const mockImporters = {};
497
+ const srcImporters = {};
498
+ for (const sym of symbolsToGrep) {
499
+ mockImporters[sym] = grepImportsBySymbol(join(args.repoRoot, "mock/src"), sym);
500
+ srcImporters[sym] = grepImportsBySymbol(join(args.repoRoot, "src"), sym);
501
+ }
502
+ // Read content of every importer file so chef can craft accurate
503
+ // search_replace pairs without inventing or omitting code.
504
+ const importerContents = {};
505
+ const allImporterFiles = new Set();
506
+ for (const sym of symbolsToGrep) {
507
+ for (const imp of mockImporters[sym] ?? [])
508
+ allImporterFiles.add(imp.file);
509
+ for (const imp of srcImporters[sym] ?? [])
510
+ allImporterFiles.add(imp.file);
511
+ }
512
+ for (const candidate of candidateExisting)
513
+ allImporterFiles.add(candidate);
514
+ for (const f of allImporterFiles) {
515
+ const abs = join(args.repoRoot, f);
516
+ if (existsSync(abs)) {
517
+ const content = readFileSync(abs, "utf8");
518
+ importerContents[f] = content.length > 6000 ? content.slice(0, 6000) + "\n// ... truncated" : content;
519
+ }
520
+ }
521
+ triggerRaw = {
522
+ ...triggerRaw,
523
+ symbol,
524
+ candidate_existing_files: candidateExisting,
525
+ importers_per_symbol: {
526
+ mock: mockImporters,
527
+ src: srcImporters,
528
+ },
529
+ existing_content: importerContents,
530
+ 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.",
531
+ };
532
+ const totalMockImps = Object.values(mockImporters).reduce((n, arr) => n + arr.length, 0);
533
+ const totalSrcImps = Object.values(srcImporters).reduce((n, arr) => n + arr.length, 0);
534
+ console.log(` enriched trigger: ${candidateExisting.length} candidate file(s), ${symbolsToGrep.size} symbol(s) checked, ${totalMockImps} mock importer(s), ${totalSrcImps} src importer(s) total`);
535
+ }
536
+ }
537
+ let navigatorHistory = null;
538
+ if (args.navigatorHistoryPath && existsSync(args.navigatorHistoryPath)) {
539
+ try {
540
+ navigatorHistory = JSON.parse(readFileSync(args.navigatorHistoryPath, "utf8"));
541
+ }
542
+ catch { /* ignore */ }
543
+ }
544
+ const { specPath, specYaml } = loadSpec(args.repoRoot, args.storyId);
545
+ const openPrs = loadOpenPrs(args.repoRoot, args.storyId);
546
+ const historyIndex = loadHistoryIndex(args.repoRoot);
547
+ const issueMatch = specYaml.match(/source_issue:\s*"#?(\d+)"/);
548
+ const issueNumber = issueMatch ? parseInt(issueMatch[1], 10) : 0;
549
+ const prompt = buildChefPrompt({
550
+ storyId: args.storyId,
551
+ trigger: { kind: args.triggerKind, detail: args.triggerDetail, raw: triggerRaw },
552
+ storyState: { issueNumber, specPath, specYaml, openPrs },
553
+ historyIndex,
554
+ navigatorHistory: navigatorHistory,
555
+ priorChefMoves: ledger.moves.map((m) => ({
556
+ n: m.n,
557
+ triggerKind: m.trigger_kind,
558
+ decision: m.decision,
559
+ postState: m.post_state,
560
+ })),
561
+ });
562
+ console.log(` prompt: ${prompt.length} chars · calling chef LLM (${args.model})`);
563
+ const client = new AnthropicClient(apiKey);
564
+ const resp = await client.complete({
565
+ model: args.model,
566
+ system: CHEF_SYSTEM,
567
+ messages: [{ role: "user", content: prompt }],
568
+ maxTokens: 8192,
569
+ });
570
+ console.log(` chef LLM: ${resp.usage.inputTokens}→${resp.usage.outputTokens} tok · $${resp.costUsd.toFixed(4)}`);
571
+ let verdict;
572
+ try {
573
+ const text = resp.text.trim();
574
+ const fence = text.match(/```json\s*([\s\S]*?)```/);
575
+ verdict = JSON.parse(fence ? fence[1] : text);
576
+ }
577
+ catch (e) {
578
+ console.error(` ! chef JSON parse failed: ${e.message}`);
579
+ console.error(` raw: ${resp.text.slice(0, 500)}`);
580
+ process.exit(1);
581
+ }
582
+ console.log(` chef verdict: ${verdict.kind.toUpperCase()}`);
583
+ console.log(` rationale: ${verdict.rationale}`);
584
+ // Frozen-surface guard
585
+ for (const e of verdict.edits) {
586
+ if (isFrozenPath(e.file) || (e.to && isFrozenPath(e.to))) {
587
+ console.error(` ! chef proposed an edit to frozen path: ${e.file} → ${e.to ?? ""}. Halting + escalating.`);
588
+ verdict.kind = "halt";
589
+ verdict.edits = [];
590
+ }
591
+ }
592
+ // Cycle guard
593
+ if (verdict.kind === "autonomous_fix" && detectCycle(ledger, verdict.edits)) {
594
+ console.error(` ! cycle detected: chef's planned edit is the inverse of an earlier move. Halting.`);
595
+ verdict.kind = "halt";
596
+ verdict.edits = [];
597
+ }
598
+ const moveN = ledger.moves.length + 1;
599
+ const moveEntry = {
600
+ n: moveN,
601
+ trigger_kind: args.triggerKind,
602
+ drift_signature: args.triggerDetail.slice(0, 200),
603
+ rationale: verdict.rationale,
604
+ decision: verdict.kind,
605
+ edits: verdict.edits,
606
+ validation_command: verdict.validation?.command ?? null,
607
+ validation_result: null,
608
+ next_dispatch: verdict.next_dispatch,
609
+ cost_usd: resp.costUsd,
610
+ post_state: "escalated",
611
+ timestamp: new Date().toISOString(),
612
+ };
613
+ if (verdict.kind === "autonomous_fix" && verdict.edits.length > 0) {
614
+ if (args.dryRun) {
615
+ console.log(`\n [dry-run] would apply ${verdict.edits.length} edit(s); skipping`);
616
+ moveEntry.post_state = "escalated"; // dry-run: state is unknown without applying
617
+ }
618
+ else {
619
+ console.log(`\n applying ${verdict.edits.length} edit(s)...`);
620
+ try {
621
+ // Capture PRE-move validation output as baseline. Pre-existing
622
+ // failures aren't chef's responsibility — only new ones are.
623
+ let preBaseline = null;
624
+ if (verdict.validation) {
625
+ console.log(` baseline (pre-edit): ${verdict.validation.command}`);
626
+ preBaseline = runValidation(args.repoRoot, verdict.validation.command);
627
+ console.log(` pre passed=${preBaseline.passed}`);
628
+ }
629
+ for (const e of verdict.edits)
630
+ applyEdit(args.repoRoot, e);
631
+ if (verdict.validation && preBaseline) {
632
+ console.log(` validating: ${verdict.validation.command}`);
633
+ const post = runValidation(args.repoRoot, verdict.validation.command);
634
+ const diff = compareValidationOutputs(preBaseline.output, post.output);
635
+ console.log(` post passed=${post.passed} · diff=${diff}`);
636
+ if (post.passed) {
637
+ moveEntry.validation_result = "passed";
638
+ moveEntry.post_state = "clean";
639
+ console.log(` ✓ validation cleanly passed`);
640
+ }
641
+ else if (diff === "progress") {
642
+ moveEntry.validation_result = "passed"; // partial — pre-existing unrelated failures remain
643
+ moveEntry.post_state = "clean";
644
+ console.log(` ✓ progress: chef removed failures + introduced none. Pre-existing unrelated failures remain (not chef's responsibility).`);
645
+ }
646
+ else if (diff === "no-change" && verdict.validation.must_exit_zero) {
647
+ console.error(` ! validation NO-CHANGE. Chef's edits didn't help. Reverting.`);
648
+ execSync(`git -C "${args.repoRoot}" checkout HEAD -- .`);
649
+ execSync(`git -C "${args.repoRoot}" clean -fd`);
650
+ moveEntry.validation_result = "failed";
651
+ moveEntry.post_state = "still-broken";
652
+ }
653
+ else if (diff === "regression") {
654
+ console.error(` ! validation REGRESSION: chef introduced new failures. Reverting.`);
655
+ execSync(`git -C "${args.repoRoot}" checkout HEAD -- .`);
656
+ execSync(`git -C "${args.repoRoot}" clean -fd`);
657
+ moveEntry.validation_result = "failed";
658
+ moveEntry.post_state = "still-broken";
659
+ }
660
+ else {
661
+ moveEntry.validation_result = "passed";
662
+ moveEntry.post_state = "clean";
663
+ }
664
+ }
665
+ else {
666
+ moveEntry.validation_result = "not-run";
667
+ moveEntry.post_state = "clean";
668
+ }
669
+ }
670
+ catch (e) {
671
+ console.error(` ! edit-application failed: ${e.message}. Reverting.`);
672
+ try {
673
+ execSync(`git -C "${args.repoRoot}" checkout HEAD -- .`);
674
+ }
675
+ catch { /* */ }
676
+ try {
677
+ execSync(`git -C "${args.repoRoot}" clean -fd`);
678
+ }
679
+ catch { /* */ }
680
+ moveEntry.post_state = "still-broken";
681
+ }
682
+ }
683
+ }
684
+ else if (verdict.kind === "pm_question" && verdict.pm_comment && !args.dryRun) {
685
+ console.log(`\n posting PM question to issue #${verdict.pm_comment.issue_number}`);
686
+ postIssueComment(args.repoRoot, verdict.pm_comment.issue_number, verdict.pm_comment.body);
687
+ }
688
+ // Commit chef's edits locally (workflow handles the push) when validation passed.
689
+ if (verdict.kind === "autonomous_fix" && moveEntry.post_state === "clean" && !args.dryRun) {
690
+ const summary = `move ${moveN} on story-${args.storyId} — ${args.triggerKind}: ${verdict.rationale.slice(0, 120)}`;
691
+ const result = commitChefEdits(args.repoRoot, summary, verdict.edits);
692
+ if (result.sha)
693
+ console.log(` committed: ${result.sha.slice(0, 7)}`);
694
+ }
695
+ ledger.moves.push(moveEntry);
696
+ ledger.cumulative_cost_usd += resp.costUsd;
697
+ if (!args.dryRun)
698
+ saveLedger(args.repoRoot, ledger);
699
+ if (verdict.kind === "autonomous_fix" && moveEntry.post_state === "clean" && issueNumber > 0 && !args.dryRun) {
700
+ const body = buildAuditCommentBody({ ledger, move: moveEntry, verdict });
701
+ try {
702
+ postIssueComment(args.repoRoot, issueNumber, body);
703
+ }
704
+ catch (e) {
705
+ console.warn(` warn: audit comment failed (non-fatal): ${e.message.slice(0, 200)}`);
706
+ }
707
+ }
708
+ console.log(`\n done · post_state=${moveEntry.post_state} · move=${moveN} · cum-cost=$${ledger.cumulative_cost_usd.toFixed(4)}`);
709
+ if (verdict.kind === "halt" || moveEntry.post_state === "still-broken")
710
+ process.exit(1);
711
+ }
712
+ //# sourceMappingURL=drift-fix.js.map