@slowcook-ai/cli 0.19.0-alpha.41 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/AGENTS.md +2 -2
  2. package/dist/cli.js +19 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/brand/index.d.ts +3 -35
  5. package/dist/commands/brand/index.d.ts.map +1 -1
  6. package/dist/commands/brew/agent.d.ts +7 -2
  7. package/dist/commands/brew/agent.d.ts.map +1 -1
  8. package/dist/commands/brew/agent.js +9 -0
  9. package/dist/commands/brew/agent.js.map +1 -1
  10. package/dist/commands/brew/halt.d.ts +26 -0
  11. package/dist/commands/brew/halt.d.ts.map +1 -1
  12. package/dist/commands/brew/halt.js.map +1 -1
  13. package/dist/commands/brew/index.d.ts.map +1 -1
  14. package/dist/commands/brew/index.js +81 -29
  15. package/dist/commands/brew/index.js.map +1 -1
  16. package/dist/commands/brew/pair-navigator.d.ts +7 -0
  17. package/dist/commands/brew/pair-navigator.d.ts.map +1 -1
  18. package/dist/commands/brew/pair-navigator.js +4 -0
  19. package/dist/commands/brew/pair-navigator.js.map +1 -1
  20. package/dist/commands/chef/drift-fix.d.ts +32 -3
  21. package/dist/commands/chef/drift-fix.d.ts.map +1 -1
  22. package/dist/commands/chef/drift-fix.js +381 -14
  23. package/dist/commands/chef/drift-fix.js.map +1 -1
  24. package/dist/commands/dev-env/config.d.ts +18 -97
  25. package/dist/commands/dev-env/config.d.ts.map +1 -1
  26. package/dist/commands/eval/index.d.ts.map +1 -1
  27. package/dist/commands/eval/index.js +13 -2
  28. package/dist/commands/eval/index.js.map +1 -1
  29. package/dist/commands/init/index.d.ts.map +1 -1
  30. package/dist/commands/init/index.js +33 -0
  31. package/dist/commands/init/index.js.map +1 -1
  32. package/dist/commands/init/mock-vite.d.ts.map +1 -1
  33. package/dist/commands/init/mock-vite.js +2 -0
  34. package/dist/commands/init/mock-vite.js.map +1 -1
  35. package/dist/commands/knowledge-add.d.ts +52 -0
  36. package/dist/commands/knowledge-add.d.ts.map +1 -0
  37. package/dist/commands/knowledge-add.js +232 -0
  38. package/dist/commands/knowledge-add.js.map +1 -0
  39. package/dist/commands/recon/index.d.ts +14 -1
  40. package/dist/commands/recon/index.d.ts.map +1 -1
  41. package/dist/commands/recon/index.js +43 -2
  42. package/dist/commands/recon/index.js.map +1 -1
  43. package/dist/commands/refine/agent.d.ts +11 -0
  44. package/dist/commands/refine/agent.d.ts.map +1 -1
  45. package/dist/commands/refine/agent.js +72 -2
  46. package/dist/commands/refine/agent.js.map +1 -1
  47. package/dist/commands/refine/context.d.ts +27 -1
  48. package/dist/commands/refine/context.d.ts.map +1 -1
  49. package/dist/commands/refine/context.js +425 -11
  50. package/dist/commands/refine/context.js.map +1 -1
  51. package/dist/commands/refine/git-attention.d.ts +123 -0
  52. package/dist/commands/refine/git-attention.d.ts.map +1 -0
  53. package/dist/commands/refine/git-attention.js +378 -0
  54. package/dist/commands/refine/git-attention.js.map +1 -0
  55. package/dist/commands/refine/history-index.d.ts +7 -0
  56. package/dist/commands/refine/history-index.d.ts.map +1 -1
  57. package/dist/commands/refine/history-index.js.map +1 -1
  58. package/dist/commands/refine/index.d.ts.map +1 -1
  59. package/dist/commands/refine/index.js +76 -20
  60. package/dist/commands/refine/index.js.map +1 -1
  61. package/dist/commands/refine/multifurcate.d.ts +129 -0
  62. package/dist/commands/refine/multifurcate.d.ts.map +1 -0
  63. package/dist/commands/refine/multifurcate.js +247 -0
  64. package/dist/commands/refine/multifurcate.js.map +1 -0
  65. package/dist/commands/refine/spec-yaml.d.ts +211 -1231
  66. package/dist/commands/refine/spec-yaml.d.ts.map +1 -1
  67. package/dist/commands/refresh-knowledge.d.ts +126 -0
  68. package/dist/commands/refresh-knowledge.d.ts.map +1 -0
  69. package/dist/commands/refresh-knowledge.js +1010 -0
  70. package/dist/commands/refresh-knowledge.js.map +1 -0
  71. package/dist/commands/testgen/agent.d.ts +13 -0
  72. package/dist/commands/testgen/agent.d.ts.map +1 -1
  73. package/dist/commands/testgen/agent.js +103 -2
  74. package/dist/commands/testgen/agent.js.map +1 -1
  75. package/dist/commands/upsert-agent-docs.d.ts +48 -0
  76. package/dist/commands/upsert-agent-docs.d.ts.map +1 -0
  77. package/dist/commands/upsert-agent-docs.js +298 -0
  78. package/dist/commands/upsert-agent-docs.js.map +1 -0
  79. package/dist/lib/budget.d.ts +2 -52
  80. package/dist/lib/budget.d.ts.map +1 -1
  81. package/dist/lib/mock-shape.d.ts +5 -20
  82. package/dist/lib/mock-shape.d.ts.map +1 -1
  83. package/package.json +9 -6
@@ -8,9 +8,17 @@
8
8
  * ChefVerdict, applies edits surgically, validates, commits, posts an
9
9
  * audit comment.
10
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`).
11
+ * Frozen surface (HARD): tests/integration/, tests/schema/,
12
+ * tests/acceptance/ (spec-contract assertions) + .brewing/auto-gen/,
13
+ * .brewing/code-map.*, .brewing/recon-result.json, .brewing/history-
14
+ * index.json (slowcook-managed artifacts). If a fix requires an
15
+ * ASSERTION change → escalates to PM via pm_comment (option B =
16
+ * testgen --regenerate).
17
+ *
18
+ * NOT frozen (α.54 — chef owns test infrastructure):
19
+ * tests/helpers/, vitest.config.*, playwright.config.*, package.json
20
+ * (devDeps), tsconfig.json, setup files. Chef can fix the runner
21
+ * machinery freehand.
14
22
  *
15
23
  * Ledger at .brewing/chef/<story-id>.json tracks moves + cost. Cycle
16
24
  * detection + budget cap enforce convergence.
@@ -26,15 +34,71 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
26
34
  import { dirname, join } from "node:path";
27
35
  import { AnthropicClient, CHEF_SYSTEM, buildChefPrompt, } from "@slowcook-ai/llm-anthropic";
28
36
  import { isReadOnlyMode, logReadOnlyBanner } from "../../lib/read-only.js";
37
+ import { knowledgeAddCore } from "../knowledge-add.js";
38
+ /**
39
+ * α.67 — distil a chef rationale into a one-line curated insight.
40
+ * The rationale is typically a paragraph explaining what+why+how;
41
+ * curated/ entries should be a single class-of-problem claim
42
+ * (≤250 chars). Strategy: take the first sentence, cap, strip
43
+ * trailing whitespace. The full rationale lives in the ledger for
44
+ * deep audit.
45
+ */
46
+ function distilRationaleToClaim(rationale) {
47
+ const firstSentence = rationale.split(/(?<=[.!?])\s/)[0] ?? rationale;
48
+ const trimmed = firstSentence.trim().replace(/\s+/g, " ");
49
+ if (trimmed.length <= 250)
50
+ return trimmed;
51
+ return trimmed.slice(0, 247) + "…";
52
+ }
53
+ /**
54
+ * Paths chef MAY edit even though they "live under tests/" or look
55
+ * config-shaped. α.54: chef gains ownership of test INFRASTRUCTURE —
56
+ * runner config, helpers, setup files — because those are
57
+ * agent-fixable failures that previously had no owner.
58
+ *
59
+ * Principle: tests encode the spec CONTRACT (the assertions that
60
+ * define what "story-N done" means). Their enforcement MACHINERY
61
+ * (vitest config, helpers, setup) is not the contract. Distinguishing
62
+ * by file content/location, not by folder.
63
+ *
64
+ * Caught dogfooding delgoosh#656 — chef diagnosed a missing JSX
65
+ * transform but couldn't fix vitest.config.ts. PM, brew, testgen,
66
+ * and chef were ALL blocked from owning it. α.54 closes that gap by
67
+ * giving chef the editing rights.
68
+ */
69
+ const TEST_INFRA_ESCAPE_PATTERNS = [
70
+ /^tests\/helpers\//, // render, a11y, mock helpers
71
+ /^tests\/setup\.(ts|tsx|js)$/, // vitest globalSetup / setupFiles
72
+ /^vitest\.setup\.(ts|tsx|js)$/,
73
+ ];
74
+ /**
75
+ * Paths chef NEVER edits:
76
+ * - tests/integration/**, tests/schema/**, tests/acceptance/** —
77
+ * spec-contract assertions. If a test is wrong, the right fix is
78
+ * testgen --regenerate, not chef rewriting the assertion.
79
+ * - .brewing/auto-gen/**, code-map, history-index, recon-result —
80
+ * slowcook-managed artifacts other agents derive from.
81
+ *
82
+ * vitest.config.*, playwright.config.*, package.json, tsconfig.json
83
+ * are NOT frozen — they're infra chef can fix.
84
+ */
29
85
  const FROZEN_PATH_PATTERNS = [
30
- /^tests\//,
31
- /^vitest\.config\.(ts|mjs|js)$/,
86
+ /^tests\/integration\//,
87
+ /^tests\/schema\//,
88
+ /^tests\/acceptance\//,
32
89
  /^\.brewing\/code-map\.(json|md|target\.md)$/,
33
90
  /^\.brewing\/recon-result\.json$/,
34
91
  /^\.brewing\/history-index\.json$/,
35
92
  /^\.brewing\/auto-gen\//,
36
93
  ];
37
- function isFrozenPath(path) {
94
+ export function isFrozenPath(path) {
95
+ // α.54 — explicit escapes win first. A file under tests/helpers/
96
+ // shouldn't be caught by a generic tests/ rule (and the new
97
+ // FROZEN_PATH_PATTERNS list above is already specific to assertion
98
+ // subfolders, but keeping the escape-first check makes the policy
99
+ // intent visible at the call site for future readers).
100
+ if (TEST_INFRA_ESCAPE_PATTERNS.some((re) => re.test(path)))
101
+ return false;
38
102
  return FROZEN_PATH_PATTERNS.some((re) => re.test(path));
39
103
  }
40
104
  /**
@@ -99,12 +163,27 @@ export function collectImportedSourceFiles(testContents) {
99
163
  // (we exclude scoped packages by requiring the next char after "@"
100
164
  // to be "/", which rules out "@scope/pkg" forms).
101
165
  const importRe = /from\s+["'](\.{1,2}\/[^"']+|@\/[^"']+|~\/[^"']+)["']/g;
166
+ // α.59 — some tests do not `import` their target; the styling/shape
167
+ // presence pattern (slowcook 0.7.21+) uses `readFileSync` on a string
168
+ // literal source path. Also pick up these direct references so chef
169
+ // sees the file contents instead of guessing.
170
+ // Matches string literals like "src/foo/bar.tsx", 'src/foo', "mock/x.ts".
171
+ const literalRe = /["'](?:src|mock)\/[\w./-]+\.(?:ts|tsx|js|jsx)["']/g;
102
172
  for (const [testFile, content] of Object.entries(testContents)) {
103
173
  const sources = new Set();
104
174
  let m;
105
175
  while ((m = importRe.exec(content)) !== null) {
106
176
  sources.add(m[1]);
107
177
  }
178
+ while ((m = literalRe.exec(content)) !== null) {
179
+ // Strip the surrounding quotes; downstream resolver handles
180
+ // bare-repo-relative paths via the './' branch by anchoring on
181
+ // testFile's directory, which won't work for cross-directory
182
+ // paths like "src/components/...". So we mark these as a
183
+ // separate prefix that the resolver knows is repo-root-relative.
184
+ const literal = m[0].slice(1, -1);
185
+ sources.add("//" + literal); // sentinel for repo-root-relative
186
+ }
108
187
  out[testFile] = [...sources];
109
188
  }
110
189
  return out;
@@ -133,6 +212,13 @@ export function resolveImportToFile(importPath, testFile, repoRoot, exists) {
133
212
  baseDir = dirname(join(repoRoot, testFile));
134
213
  rest = importPath;
135
214
  }
215
+ else if (importPath.startsWith("//")) {
216
+ // α.59 — sentinel for repo-root-relative paths captured by the
217
+ // literal-path regex (collectImportedSourceFiles). Strip "//" and
218
+ // anchor at repoRoot.
219
+ baseDir = repoRoot;
220
+ rest = importPath.slice(2);
221
+ }
136
222
  else {
137
223
  return null;
138
224
  }
@@ -239,7 +325,7 @@ Options:
239
325
  audit comment on the PR (not the source issue).
240
326
 
241
327
  Requires: ANTHROPIC_API_KEY in env. Run from consumer repo root.
242
- Frozen surface: tests/, vitest.config.*, .brewing/{auto-gen}/ — never edited.
328
+ Frozen surface: tests/{integration,schema,acceptance}/ + .brewing/{auto-gen,code-map,history-index,recon-result} — never edited. Test INFRA (tests/helpers/, vitest.config.*, package.json devDeps, setup files) IS chef's to edit (α.54).
243
329
  `);
244
330
  }
245
331
  function loadHistoryIndex(repoRoot) {
@@ -486,6 +572,66 @@ function pushChefEditsToPrBranch(repoRoot, prBranch, localRef) {
486
572
  return false;
487
573
  }
488
574
  }
575
+ /**
576
+ * α.58 — brew-halt finisher path. Called when chef-drift was invoked
577
+ * by chef-on-brew-halt (workflow_run trigger), which checked out the
578
+ * brew checkpoint branch + ran chef. Chef has committed locally;
579
+ * persist by pushing the current branch back to origin + opening a PR
580
+ * (brew_branch → main) so the maintainer sees the fix as a reviewable
581
+ * change instead of losing it with the ephemeral runner.
582
+ *
583
+ * Best-effort: any failure is logged but doesn't throw — the chef
584
+ * verdict is already audited via ledger + the source-issue comment.
585
+ */
586
+ function publishChefBrewHaltFix(repoRoot, storyId, rationale) {
587
+ // Current branch is whatever the workflow checked out — chef-on-brew-halt
588
+ // switches to brew_branch from the halt artifact. Capturing it via
589
+ // `rev-parse --abbrev-ref HEAD` is more robust than carrying it through
590
+ // CLI args (which would require every consumer workflow to pass it).
591
+ const branch = execSync(`git -C "${repoRoot}" rev-parse --abbrev-ref HEAD`, { encoding: "utf8" }).trim();
592
+ if (!branch || branch === "HEAD") {
593
+ console.warn(` warn: brew-halt publish skipped — detached or empty HEAD`);
594
+ return;
595
+ }
596
+ // Push the chef commit (now at HEAD of brew_branch) back to origin.
597
+ // Set-upstream so subsequent fetches see the chef SHA too.
598
+ execSync(`git -C "${repoRoot}" push --set-upstream origin "${branch}"`, { stdio: "inherit" });
599
+ console.log(` brew-halt publish: pushed ${branch}`);
600
+ // Open a PR brew_branch → main if one doesn't already exist. gh is
601
+ // the only consumer-side dep we already assume (used by L2 finisher
602
+ // PR enrichment + audit comments). Repo slug derived from origin URL.
603
+ const repoSlug = execSync(`git -C "${repoRoot}" remote get-url origin | sed -E 's|^.*github\\.com[:/]||; s|\\.git$||'`, { encoding: "utf8" }).trim();
604
+ let existingPr = "";
605
+ try {
606
+ existingPr = execSync(`gh pr list --repo "${repoSlug}" --head "${branch}" --json number --jq '.[0].number // ""'`, { encoding: "utf8" }).trim();
607
+ }
608
+ catch { /* gh might be missing or token might be scoped narrow; let create-attempt log instead */ }
609
+ if (existingPr) {
610
+ console.log(` brew-halt publish: PR already exists (#${existingPr}) for ${branch}`);
611
+ return;
612
+ }
613
+ const title = `[chef] story-${storyId}: autonomous fix after brew halt`;
614
+ // Trim rationale to keep the body tight; full rationale lives in the
615
+ // audit comment chef posts on the source issue.
616
+ const trimmedRationale = rationale.length > 500 ? rationale.slice(0, 500) + "…" : rationale;
617
+ const bodyFile = "/tmp/chef-brew-halt-pr-body.md";
618
+ writeFileSync(bodyFile, [
619
+ `Autonomous fix from \`slowcook chef-drift\` after brew halted on story-${storyId}.`,
620
+ ``,
621
+ `**Rationale:** ${trimmedRationale}`,
622
+ ``,
623
+ `Brew halted with checkpoints committed on this branch; chef applied a surgical edit + the failing test now passes locally on the runner. This PR persists chef's edit + the brew checkpoints together for review.`,
624
+ ``,
625
+ `_Generated by \`slowcook chef-drift\` brew-halt finisher (α.58)._`,
626
+ ].join("\n"), "utf8");
627
+ try {
628
+ execSync(`gh pr create --repo "${repoSlug}" --base main --head "${branch}" --title "${title.replace(/"/g, '\\"')}" --body-file "${bodyFile}"`, { stdio: "inherit" });
629
+ console.log(` brew-halt publish: opened PR for ${branch}`);
630
+ }
631
+ catch (e) {
632
+ console.warn(` warn: brew-halt PR create failed: ${e.message.slice(0, 200)}`);
633
+ }
634
+ }
489
635
  function commitChefEdits(repoRoot, summary, edits) {
490
636
  // Stage ONLY the files chef edited (never `git add -A` — that
491
637
  // accidentally captures workflow-side clones like `_slowcook/` etc.
@@ -512,8 +658,13 @@ function commitChefEdits(repoRoot, summary, edits) {
512
658
  }
513
659
  catch { /* ledger dir may not exist if first move ran into early error */ }
514
660
  // Empty commit guard: if nothing staged, skip.
515
- const status = execSync(`git -C "${repoRoot}" status --porcelain --cached`, { encoding: "utf8" }).trim();
516
- if (!status)
661
+ // α.60 was `git status --porcelain --cached` which is not a valid
662
+ // git status invocation (--cached isn't a status flag); execSync
663
+ // threw, the outer try/catch silently absorbed it, and chef NEVER
664
+ // actually committed in any drift-fix path. `diff --cached
665
+ // --name-only` lists staged files; empty output = nothing to commit.
666
+ const staged = execSync(`git -C "${repoRoot}" diff --cached --name-only`, { encoding: "utf8" }).trim();
667
+ if (!staged)
517
668
  return { sha: null, pushed: false };
518
669
  // Write commit message to file (handle quotes cleanly).
519
670
  const msgFile = "/tmp/chef-drift-commit-msg.txt";
@@ -704,8 +855,40 @@ export async function chefDrift(argv, cliVersion) {
704
855
  // material it needs to write surgical search_replace pairs.
705
856
  if (args.triggerKind === "brew_halt_class") {
706
857
  const runnerOutput = triggerRaw["runner_output"] ?? args.triggerDetail;
858
+ // α.56 — when runner_output is absent (typical for AGENT_STALLED /
859
+ // ITERATION_CAP halts where there's no vitest failure to parse),
860
+ // derive failing-test files from iteration_diffs[].target_test_id.
861
+ // Format: "tests/path/file.test.ts > suite > name" — split on " > "
862
+ // and take part[0] as the test file path. The same enrichment then
863
+ // loads file contents + imported source files. Without this, chef
864
+ // hallucinates target file contents (seen 2026-05-26 dogfood:
865
+ // delgoosh#003 chef invented two different "stub" source bodies).
866
+ let failingFiles = [];
867
+ let failingTestNames = [];
707
868
  if (typeof runnerOutput === "string" && runnerOutput.length > 0) {
708
- const { failingFiles, failingTestNames } = parseBrewHaltOutput(runnerOutput);
869
+ const parsed = parseBrewHaltOutput(runnerOutput);
870
+ failingFiles = parsed.failingFiles;
871
+ failingTestNames = parsed.failingTestNames;
872
+ }
873
+ else if (Array.isArray(triggerRaw["iteration_diffs"])) {
874
+ const diffs = triggerRaw["iteration_diffs"];
875
+ const filesSet = new Set();
876
+ const namesSet = new Set();
877
+ for (const d of diffs) {
878
+ const tid = d?.target_test_id;
879
+ if (typeof tid !== "string" || tid.length === 0)
880
+ continue;
881
+ const parts = tid.split(" > ");
882
+ const file = parts[0].trim();
883
+ if (file)
884
+ filesSet.add(file);
885
+ if (parts.length > 1)
886
+ namesSet.add(parts.slice(1).join(" > "));
887
+ }
888
+ failingFiles = Array.from(filesSet);
889
+ failingTestNames = Array.from(namesSet);
890
+ }
891
+ if (failingFiles.length > 0) {
709
892
  const failingTestContents = {};
710
893
  for (const f of failingFiles) {
711
894
  const abs = join(args.repoRoot, f);
@@ -732,6 +915,25 @@ export async function chefDrift(argv, cliVersion) {
732
915
  sourceContents[projRel] = c.length > 6000 ? c.slice(0, 6000) + "\n// ... truncated" : c;
733
916
  }
734
917
  }
918
+ // α.59 — also load files brew itself touched across all iters.
919
+ // Even if the failing test doesn't import them, they're the live
920
+ // story state chef should reason about (e.g., the page.tsx brew
921
+ // wrote in iter 1 imports the component the styling test asserts
922
+ // on; chef needs both to make a coherent edit).
923
+ if (Array.isArray(triggerRaw["iteration_diffs"])) {
924
+ const diffs = triggerRaw["iteration_diffs"];
925
+ for (const d of diffs) {
926
+ for (const f of d?.files_touched ?? []) {
927
+ if (typeof f !== "string" || sourceContents[f])
928
+ continue;
929
+ const abs = join(args.repoRoot, f);
930
+ if (!existsSync(abs))
931
+ continue;
932
+ const c = readFileSync(abs, "utf8");
933
+ sourceContents[f] = c.length > 6000 ? c.slice(0, 6000) + "\n// ... truncated" : c;
934
+ }
935
+ }
936
+ }
735
937
  triggerRaw = {
736
938
  ...triggerRaw,
737
939
  failing_test_files: failingFiles,
@@ -739,7 +941,8 @@ export async function chefDrift(argv, cliVersion) {
739
941
  failing_test_contents: failingTestContents,
740
942
  imported_source_files_per_test: importedSourcesMap,
741
943
  source_file_contents: sourceContents,
742
- 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.",
944
+ 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.\n\n" +
945
+ "ALSO available straight from the halt JSON (already on trigger.raw): `brew_mode` (freehand | plate | auto) and `allowed_paths[]` (the runtime restriction brew enforced). If iteration_diffs show outcome=rejected-overflow, the fix is to widen brew's CLI --mode arg, NOT to edit the spec. `allowed_paths` is not a spec yaml field — see chef prompt §1.6.",
743
946
  };
744
947
  console.log(` brew-halt enriched: ${failingFiles.length} failing test file(s), ${Object.keys(sourceContents).length} source file(s) under test`);
745
948
  }
@@ -778,6 +981,13 @@ export async function chefDrift(argv, cliVersion) {
778
981
  maxTokens: 8192,
779
982
  });
780
983
  console.log(` chef LLM: ${resp.usage.inputTokens}→${resp.usage.outputTokens} tok · $${resp.costUsd.toFixed(4)}`);
984
+ // α.53 — parse-tolerant: a malformed JSON envelope (e.g. an
985
+ // unterminated string mid-rationale, observed on delgoosh#656
986
+ // story-003 chef run 26397941242) used to crash the workflow
987
+ // with exit 1, losing the entire run. Recover whatever signal we
988
+ // can (typically the rationale prose up to the truncation point)
989
+ // and escalate as a pm_question instead. The PM still gets
990
+ // actionable text; the workflow exits clean.
781
991
  let verdict;
782
992
  try {
783
993
  const text = resp.text.trim();
@@ -785,9 +995,10 @@ export async function chefDrift(argv, cliVersion) {
785
995
  verdict = JSON.parse(fence ? fence[1] : text);
786
996
  }
787
997
  catch (e) {
788
- console.error(` ! chef JSON parse failed: ${e.message}`);
789
- console.error(` raw: ${resp.text.slice(0, 500)}`);
790
- process.exit(1);
998
+ const parseErr = e.message;
999
+ console.warn(` ! chef JSON parse failed: ${parseErr}`);
1000
+ console.warn(` recovering rationale from raw text + escalating as pm_question`);
1001
+ verdict = recoverChefVerdictFromMalformedJson(resp.text, parseErr, args.storyId, issueNumber);
791
1002
  }
792
1003
  console.log(` chef verdict: ${verdict.kind.toUpperCase()}`);
793
1004
  console.log(` rationale: ${verdict.rationale}`);
@@ -916,6 +1127,47 @@ export async function chefDrift(argv, cliVersion) {
916
1127
  if (pushed)
917
1128
  console.log(` finisher: pushed ${commitSha.slice(0, 7)} → ${prCheckout.branchName}`);
918
1129
  }
1130
+ // α.58 — brew-halt mode: chef-drift was invoked by chef-on-brew-halt
1131
+ // (workflow_run-triggered). The workflow checked out brew_branch
1132
+ // (see slowcook halt artifact field `brew_branch`); chef committed
1133
+ // on top. Persist that work: push HEAD back to origin/<branch>,
1134
+ // open a PR brew_branch → main if none exists. Without this the
1135
+ // commit dies with the ephemeral runner.
1136
+ if (commitSha && !prCheckout && args.triggerKind === "brew_halt_class") {
1137
+ try {
1138
+ publishChefBrewHaltFix(args.repoRoot, args.storyId, verdict.rationale);
1139
+ }
1140
+ catch (e) {
1141
+ console.warn(` warn: chef brew-halt publish failed: ${e.message.slice(0, 200)}`);
1142
+ }
1143
+ }
1144
+ // α.67 — close the knowledge-layer loop: chef writes back to
1145
+ // curated/chef-known-fixes.md so the next chef invocation (or
1146
+ // refine reading the curated block) sees this fix as durable
1147
+ // organizational memory. Soft signal — each entry carries
1148
+ // evidence trail (PR + file + last-verified) so staleness is
1149
+ // reviewable, not auto-invalidating. Best-effort.
1150
+ if (commitSha) {
1151
+ try {
1152
+ // Distil the chef rationale into a one-line claim. The
1153
+ // rationale is often a paragraph; we want the WHAT and the
1154
+ // HOW, compactly. Take the first sentence + cap at ~250
1155
+ // chars. The full rationale + the move ledger entry are
1156
+ // already in .brewing/chef/<story>.json for deep audit.
1157
+ const claim = distilRationaleToClaim(verdict.rationale);
1158
+ const evidenceFile = verdict.edits[0]?.file;
1159
+ knowledgeAddCore({
1160
+ repoRoot: args.repoRoot,
1161
+ agent: "chef",
1162
+ topic: "chef-known-fixes",
1163
+ claim,
1164
+ evidenceFile,
1165
+ });
1166
+ }
1167
+ catch (e) {
1168
+ console.warn(` warn: knowledge-write skipped: ${e.message.slice(0, 200)}`);
1169
+ }
1170
+ }
919
1171
  }
920
1172
  }
921
1173
  ledger.moves.push(moveEntry);
@@ -947,4 +1199,119 @@ export async function chefDrift(argv, cliVersion) {
947
1199
  if (verdict.kind === "halt" || moveEntry.post_state === "still-broken")
948
1200
  process.exit(1);
949
1201
  }
1202
+ /**
1203
+ * α.53 — extract whatever signal we can from a malformed chef JSON.
1204
+ *
1205
+ * The model often returns valid prose (`"rationale": "long sentence about
1206
+ * what's wrong"`) but the JSON envelope breaks if the rationale runs past
1207
+ * Anthropic's max-tokens midstring or contains an unescaped quote.
1208
+ *
1209
+ * Strategy:
1210
+ * 1. Look for ```json fence — strip it.
1211
+ * 2. Try a quick "auto-close unterminated string" repair: count
1212
+ * unescaped quotes; if odd, append a closing quote + `}` and retry
1213
+ * JSON.parse.
1214
+ * 3. If still broken, regex-extract the `"rationale"` field value up
1215
+ * to wherever it terminates (literal quote or end of buffer).
1216
+ * 4. Build a pm_question verdict with the recovered rationale plus a
1217
+ * bookkeeping note about the parse failure. Empty edits, no
1218
+ * next-dispatch — the PM is the next step.
1219
+ */
1220
+ export function recoverChefVerdictFromMalformedJson(rawText, parseError, storyId, issueNumber) {
1221
+ const text = rawText.trim();
1222
+ const fence = text.match(/```json\s*([\s\S]*?)(?:```|$)/);
1223
+ const body = fence ? fence[1] : text;
1224
+ // Attempt 1: auto-close unterminated string + closing brace.
1225
+ const repaired = autoCloseStringAndBrace(body);
1226
+ if (repaired) {
1227
+ try {
1228
+ const v = JSON.parse(repaired);
1229
+ if (typeof v.rationale === "string") {
1230
+ return {
1231
+ rationale: v.rationale +
1232
+ "\n\n_(chef α.53: chef-drift recovered this verdict from a truncated JSON envelope. " +
1233
+ `Original parse error: ${parseError.slice(0, 120)})_`,
1234
+ kind: "pm_question",
1235
+ edits: [],
1236
+ validation: null,
1237
+ next_dispatch: null,
1238
+ pm_comment: {
1239
+ issue_number: issueNumber,
1240
+ body: "**[chef] Reasoning recovered from a truncated JSON envelope.**\n\n" +
1241
+ v.rationale +
1242
+ "\n\n---\n\n_chef α.53: the LLM produced valid reasoning but the JSON envelope was malformed " +
1243
+ "(likely a long rationale exceeded max-tokens midstring). Chef recovered the prose via auto-close repair. " +
1244
+ `Edits/validation/next-dispatch were not parseable. Original parse error: \`${parseError.slice(0, 200)}\`._`,
1245
+ },
1246
+ };
1247
+ }
1248
+ }
1249
+ catch {
1250
+ // fall through to attempt 2
1251
+ }
1252
+ }
1253
+ // Attempt 2: regex-extract just the rationale field's value.
1254
+ const rationaleMatch = body.match(/"rationale"\s*:\s*"((?:[^"\\]|\\.)*)/);
1255
+ const rationale = rationaleMatch
1256
+ ? rationaleMatch[1].replace(/\\n/g, "\n").replace(/\\"/g, '"')
1257
+ : "(chef returned a malformed JSON envelope and the rationale could not be recovered. See workflow log for raw text.)";
1258
+ return {
1259
+ rationale: rationale +
1260
+ `\n\n_(chef α.53: parse error ${parseError.slice(0, 120)} on story-${storyId} — escalating as pm_question with recovered text only)_`,
1261
+ kind: "pm_question",
1262
+ edits: [],
1263
+ validation: null,
1264
+ next_dispatch: null,
1265
+ pm_comment: {
1266
+ issue_number: issueNumber,
1267
+ body: "**[chef] Reasoning recovered from a malformed JSON envelope (partial).**\n\n" +
1268
+ rationale +
1269
+ "\n\n---\n\n_chef α.53: the LLM produced reasoning but the JSON envelope was malformed beyond auto-repair. " +
1270
+ "What you see above is regex-extracted rationale prose; edits, validation, and next-dispatch fields were not " +
1271
+ `recoverable. Original parse error: \`${parseError.slice(0, 200)}\`. Inspect the workflow log for the full raw text._`,
1272
+ },
1273
+ };
1274
+ }
1275
+ /**
1276
+ * Conservative auto-close: if the body's quotes are imbalanced (one
1277
+ * unterminated string), append `"` + a guess at how many braces are
1278
+ * still open. Returns the repaired string or null if nothing salvageable.
1279
+ */
1280
+ function autoCloseStringAndBrace(body) {
1281
+ // Count unescaped quotes outside strings is structurally hard; the
1282
+ // heuristic here: if quotes count is odd, append a closing quote.
1283
+ let inString = false;
1284
+ let escape = false;
1285
+ let openBraces = 0;
1286
+ for (const ch of body) {
1287
+ if (escape) {
1288
+ escape = false;
1289
+ continue;
1290
+ }
1291
+ if (ch === "\\") {
1292
+ escape = true;
1293
+ continue;
1294
+ }
1295
+ if (ch === '"') {
1296
+ inString = !inString;
1297
+ continue;
1298
+ }
1299
+ if (!inString) {
1300
+ if (ch === "{")
1301
+ openBraces++;
1302
+ else if (ch === "}")
1303
+ openBraces--;
1304
+ }
1305
+ }
1306
+ if (!inString && openBraces === 0)
1307
+ return null; // nothing to repair
1308
+ let repaired = body;
1309
+ if (inString)
1310
+ repaired += '"';
1311
+ while (openBraces > 0) {
1312
+ repaired += "}";
1313
+ openBraces--;
1314
+ }
1315
+ return repaired;
1316
+ }
950
1317
  //# sourceMappingURL=drift-fix.js.map