@slowcook-ai/cli 0.19.0-alpha.8 → 0.19.1

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 (194) hide show
  1. package/AGENTS.md +240 -0
  2. package/REPORTING.md +193 -0
  3. package/dist/cli.js +78 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/brand/index.d.ts +26 -0
  6. package/dist/commands/brand/index.d.ts.map +1 -0
  7. package/dist/commands/brand/index.js +257 -0
  8. package/dist/commands/brand/index.js.map +1 -0
  9. package/dist/commands/brew/agent.d.ts +7 -2
  10. package/dist/commands/brew/agent.d.ts.map +1 -1
  11. package/dist/commands/brew/agent.js +9 -0
  12. package/dist/commands/brew/agent.js.map +1 -1
  13. package/dist/commands/brew/halt.d.ts +26 -0
  14. package/dist/commands/brew/halt.d.ts.map +1 -1
  15. package/dist/commands/brew/halt.js.map +1 -1
  16. package/dist/commands/brew/index.d.ts.map +1 -1
  17. package/dist/commands/brew/index.js +142 -18
  18. package/dist/commands/brew/index.js.map +1 -1
  19. package/dist/commands/brew/pair-navigator.d.ts +119 -0
  20. package/dist/commands/brew/pair-navigator.d.ts.map +1 -0
  21. package/dist/commands/brew/pair-navigator.js +187 -0
  22. package/dist/commands/brew/pair-navigator.js.map +1 -0
  23. package/dist/commands/budget/index.d.ts +2 -0
  24. package/dist/commands/budget/index.d.ts.map +1 -0
  25. package/dist/commands/budget/index.js +252 -0
  26. package/dist/commands/budget/index.js.map +1 -0
  27. package/dist/commands/chef/drift-fix.d.ts +33 -4
  28. package/dist/commands/chef/drift-fix.d.ts.map +1 -1
  29. package/dist/commands/chef/drift-fix.js +417 -29
  30. package/dist/commands/chef/drift-fix.js.map +1 -1
  31. package/dist/commands/chef/index.js +13 -3
  32. package/dist/commands/chef/index.js.map +1 -1
  33. package/dist/commands/chef/orchestrate.d.ts +1 -1
  34. package/dist/commands/chef/orchestrate.d.ts.map +1 -1
  35. package/dist/commands/chef/orchestrate.js +32 -9
  36. package/dist/commands/chef/orchestrate.js.map +1 -1
  37. package/dist/commands/dev-env/config.d.ts +57 -0
  38. package/dist/commands/dev-env/config.d.ts.map +1 -0
  39. package/dist/commands/dev-env/config.js +96 -0
  40. package/dist/commands/dev-env/config.js.map +1 -0
  41. package/dist/commands/dev-env/index.d.ts +27 -0
  42. package/dist/commands/dev-env/index.d.ts.map +1 -0
  43. package/dist/commands/dev-env/index.js +226 -0
  44. package/dist/commands/dev-env/index.js.map +1 -0
  45. package/dist/commands/dev-env/init.d.ts +28 -0
  46. package/dist/commands/dev-env/init.d.ts.map +1 -0
  47. package/dist/commands/dev-env/init.js +135 -0
  48. package/dist/commands/dev-env/init.js.map +1 -0
  49. package/dist/commands/docs/index.d.ts +16 -0
  50. package/dist/commands/docs/index.d.ts.map +1 -0
  51. package/dist/commands/docs/index.js +127 -0
  52. package/dist/commands/docs/index.js.map +1 -0
  53. package/dist/commands/eval/index.d.ts +54 -0
  54. package/dist/commands/eval/index.d.ts.map +1 -0
  55. package/dist/commands/eval/index.js +294 -0
  56. package/dist/commands/eval/index.js.map +1 -0
  57. package/dist/commands/extract/index.d.ts.map +1 -1
  58. package/dist/commands/extract/index.js +23 -1
  59. package/dist/commands/extract/index.js.map +1 -1
  60. package/dist/commands/garnish/index.d.ts +56 -0
  61. package/dist/commands/garnish/index.d.ts.map +1 -0
  62. package/dist/commands/garnish/index.js +281 -0
  63. package/dist/commands/garnish/index.js.map +1 -0
  64. package/dist/commands/garnish/trailer.d.ts +79 -0
  65. package/dist/commands/garnish/trailer.d.ts.map +1 -0
  66. package/dist/commands/garnish/trailer.js +118 -0
  67. package/dist/commands/garnish/trailer.js.map +1 -0
  68. package/dist/commands/init/index.d.ts.map +1 -1
  69. package/dist/commands/init/index.js +33 -0
  70. package/dist/commands/init/index.js.map +1 -1
  71. package/dist/commands/init/mock-vite.d.ts +54 -0
  72. package/dist/commands/init/mock-vite.d.ts.map +1 -0
  73. package/dist/commands/init/mock-vite.js +613 -0
  74. package/dist/commands/init/mock-vite.js.map +1 -0
  75. package/dist/commands/init/mock.d.ts +6 -0
  76. package/dist/commands/init/mock.d.ts.map +1 -1
  77. package/dist/commands/init/mock.js +20 -4
  78. package/dist/commands/init/mock.js.map +1 -1
  79. package/dist/commands/init/plan.d.ts +26 -1
  80. package/dist/commands/init/plan.d.ts.map +1 -1
  81. package/dist/commands/init/plan.js +41 -3
  82. package/dist/commands/init/plan.js.map +1 -1
  83. package/dist/commands/init/templates.d.ts.map +1 -1
  84. package/dist/commands/init/templates.js +12 -4
  85. package/dist/commands/init/templates.js.map +1 -1
  86. package/dist/commands/knowledge-add.d.ts +52 -0
  87. package/dist/commands/knowledge-add.d.ts.map +1 -0
  88. package/dist/commands/knowledge-add.js +232 -0
  89. package/dist/commands/knowledge-add.js.map +1 -0
  90. package/dist/commands/map/emit-typeorm.d.ts +117 -0
  91. package/dist/commands/map/emit-typeorm.d.ts.map +1 -0
  92. package/dist/commands/map/emit-typeorm.js +341 -0
  93. package/dist/commands/map/emit-typeorm.js.map +1 -0
  94. package/dist/commands/map/index.d.ts +18 -0
  95. package/dist/commands/map/index.d.ts.map +1 -1
  96. package/dist/commands/map/index.js +28 -0
  97. package/dist/commands/map/index.js.map +1 -1
  98. package/dist/commands/plate/agent.d.ts +7 -0
  99. package/dist/commands/plate/agent.d.ts.map +1 -1
  100. package/dist/commands/plate/agent.js +1 -1
  101. package/dist/commands/plate/agent.js.map +1 -1
  102. package/dist/commands/plate/index.d.ts.map +1 -1
  103. package/dist/commands/plate/index.js +6 -0
  104. package/dist/commands/plate/index.js.map +1 -1
  105. package/dist/commands/recon/index.d.ts +16 -3
  106. package/dist/commands/recon/index.d.ts.map +1 -1
  107. package/dist/commands/recon/index.js +267 -16
  108. package/dist/commands/recon/index.js.map +1 -1
  109. package/dist/commands/recon/migration-gate.d.ts +59 -0
  110. package/dist/commands/recon/migration-gate.d.ts.map +1 -0
  111. package/dist/commands/recon/migration-gate.js +131 -0
  112. package/dist/commands/recon/migration-gate.js.map +1 -0
  113. package/dist/commands/recon/reuse.d.ts +32 -0
  114. package/dist/commands/recon/reuse.d.ts.map +1 -1
  115. package/dist/commands/recon/reuse.js +66 -0
  116. package/dist/commands/recon/reuse.js.map +1 -1
  117. package/dist/commands/recon/stale-stubs.d.ts +65 -0
  118. package/dist/commands/recon/stale-stubs.d.ts.map +1 -0
  119. package/dist/commands/recon/stale-stubs.js +84 -0
  120. package/dist/commands/recon/stale-stubs.js.map +1 -0
  121. package/dist/commands/refine/agent.d.ts +23 -0
  122. package/dist/commands/refine/agent.d.ts.map +1 -1
  123. package/dist/commands/refine/agent.js +245 -15
  124. package/dist/commands/refine/agent.js.map +1 -1
  125. package/dist/commands/refine/brownfield-answer.d.ts +81 -0
  126. package/dist/commands/refine/brownfield-answer.d.ts.map +1 -0
  127. package/dist/commands/refine/brownfield-answer.js +231 -0
  128. package/dist/commands/refine/brownfield-answer.js.map +1 -0
  129. package/dist/commands/refine/context.d.ts +27 -1
  130. package/dist/commands/refine/context.d.ts.map +1 -1
  131. package/dist/commands/refine/context.js +438 -6
  132. package/dist/commands/refine/context.js.map +1 -1
  133. package/dist/commands/refine/git-attention.d.ts +123 -0
  134. package/dist/commands/refine/git-attention.d.ts.map +1 -0
  135. package/dist/commands/refine/git-attention.js +378 -0
  136. package/dist/commands/refine/git-attention.js.map +1 -0
  137. package/dist/commands/refine/history-index.d.ts +66 -0
  138. package/dist/commands/refine/history-index.d.ts.map +1 -1
  139. package/dist/commands/refine/history-index.js +195 -8
  140. package/dist/commands/refine/history-index.js.map +1 -1
  141. package/dist/commands/refine/index.d.ts.map +1 -1
  142. package/dist/commands/refine/index.js +105 -18
  143. package/dist/commands/refine/index.js.map +1 -1
  144. package/dist/commands/refine/multifurcate.d.ts +129 -0
  145. package/dist/commands/refine/multifurcate.d.ts.map +1 -0
  146. package/dist/commands/refine/multifurcate.js +247 -0
  147. package/dist/commands/refine/multifurcate.js.map +1 -0
  148. package/dist/commands/refine/proposals-synth.d.ts +50 -1
  149. package/dist/commands/refine/proposals-synth.d.ts.map +1 -1
  150. package/dist/commands/refine/proposals-synth.js +199 -35
  151. package/dist/commands/refine/proposals-synth.js.map +1 -1
  152. package/dist/commands/refine/spec-yaml.d.ts +214 -1210
  153. package/dist/commands/refine/spec-yaml.d.ts.map +1 -1
  154. package/dist/commands/refine/spec-yaml.js +10 -0
  155. package/dist/commands/refine/spec-yaml.js.map +1 -1
  156. package/dist/commands/refresh-knowledge.d.ts +139 -0
  157. package/dist/commands/refresh-knowledge.d.ts.map +1 -0
  158. package/dist/commands/refresh-knowledge.js +1029 -0
  159. package/dist/commands/refresh-knowledge.js.map +1 -0
  160. package/dist/commands/run-mock/index.d.ts.map +1 -1
  161. package/dist/commands/run-mock/index.js +135 -22
  162. package/dist/commands/run-mock/index.js.map +1 -1
  163. package/dist/commands/testgen/agent.d.ts +13 -0
  164. package/dist/commands/testgen/agent.d.ts.map +1 -1
  165. package/dist/commands/testgen/agent.js +137 -11
  166. package/dist/commands/testgen/agent.js.map +1 -1
  167. package/dist/commands/upsert-agent-docs.d.ts +48 -0
  168. package/dist/commands/upsert-agent-docs.d.ts.map +1 -0
  169. package/dist/commands/upsert-agent-docs.js +298 -0
  170. package/dist/commands/upsert-agent-docs.js.map +1 -0
  171. package/dist/commands/vibe/agent.d.ts +7 -0
  172. package/dist/commands/vibe/agent.d.ts.map +1 -1
  173. package/dist/commands/vibe/agent.js +2 -2
  174. package/dist/commands/vibe/agent.js.map +1 -1
  175. package/dist/commands/vibe/index.d.ts.map +1 -1
  176. package/dist/commands/vibe/index.js +7 -1
  177. package/dist/commands/vibe/index.js.map +1 -1
  178. package/dist/cost-store.d.ts +52 -0
  179. package/dist/cost-store.d.ts.map +1 -0
  180. package/dist/cost-store.js +108 -0
  181. package/dist/cost-store.js.map +1 -0
  182. package/dist/lib/budget.d.ts +73 -0
  183. package/dist/lib/budget.d.ts.map +1 -0
  184. package/dist/lib/budget.js +225 -0
  185. package/dist/lib/budget.js.map +1 -0
  186. package/dist/lib/mock-shape.d.ts +29 -0
  187. package/dist/lib/mock-shape.d.ts.map +1 -0
  188. package/dist/lib/mock-shape.js +77 -0
  189. package/dist/lib/mock-shape.js.map +1 -0
  190. package/dist/lib/read-only.d.ts +22 -0
  191. package/dist/lib/read-only.d.ts.map +1 -0
  192. package/dist/lib/read-only.js +34 -0
  193. package/dist/lib/read-only.js.map +1 -0
  194. package/package.json +17 -12
@@ -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.
@@ -25,15 +33,72 @@ import { execSync } from "node:child_process";
25
33
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
26
34
  import { dirname, join } from "node:path";
27
35
  import { AnthropicClient, CHEF_SYSTEM, buildChefPrompt, } from "@slowcook-ai/llm-anthropic";
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
+ */
28
85
  const FROZEN_PATH_PATTERNS = [
29
- /^tests\//,
30
- /^vitest\.config\.(ts|mjs|js)$/,
86
+ /^tests\/integration\//,
87
+ /^tests\/schema\//,
88
+ /^tests\/acceptance\//,
31
89
  /^\.brewing\/code-map\.(json|md|target\.md)$/,
32
90
  /^\.brewing\/recon-result\.json$/,
33
91
  /^\.brewing\/history-index\.json$/,
34
92
  /^\.brewing\/auto-gen\//,
35
93
  ];
36
- 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;
37
102
  return FROZEN_PATH_PATTERNS.some((re) => re.test(path));
38
103
  }
39
104
  /**
@@ -98,12 +163,27 @@ export function collectImportedSourceFiles(testContents) {
98
163
  // (we exclude scoped packages by requiring the next char after "@"
99
164
  // to be "/", which rules out "@scope/pkg" forms).
100
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;
101
172
  for (const [testFile, content] of Object.entries(testContents)) {
102
173
  const sources = new Set();
103
174
  let m;
104
175
  while ((m = importRe.exec(content)) !== null) {
105
176
  sources.add(m[1]);
106
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
+ }
107
187
  out[testFile] = [...sources];
108
188
  }
109
189
  return out;
@@ -132,6 +212,13 @@ export function resolveImportToFile(importPath, testFile, repoRoot, exists) {
132
212
  baseDir = dirname(join(repoRoot, testFile));
133
213
  rest = importPath;
134
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
+ }
135
222
  else {
136
223
  return null;
137
224
  }
@@ -238,7 +325,7 @@ Options:
238
325
  audit comment on the PR (not the source issue).
239
326
 
240
327
  Requires: ANTHROPIC_API_KEY in env. Run from consumer repo root.
241
- 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).
242
329
  `);
243
330
  }
244
331
  function loadHistoryIndex(repoRoot) {
@@ -485,6 +572,66 @@ function pushChefEditsToPrBranch(repoRoot, prBranch, localRef) {
485
572
  return false;
486
573
  }
487
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
+ }
488
635
  function commitChefEdits(repoRoot, summary, edits) {
489
636
  // Stage ONLY the files chef edited (never `git add -A` — that
490
637
  // accidentally captures workflow-side clones like `_slowcook/` etc.
@@ -511,8 +658,13 @@ function commitChefEdits(repoRoot, summary, edits) {
511
658
  }
512
659
  catch { /* ledger dir may not exist if first move ran into early error */ }
513
660
  // Empty commit guard: if nothing staged, skip.
514
- const status = execSync(`git -C "${repoRoot}" status --porcelain --cached`, { encoding: "utf8" }).trim();
515
- 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)
516
668
  return { sha: null, pushed: false };
517
669
  // Write commit message to file (handle quotes cleanly).
518
670
  const msgFile = "/tmp/chef-drift-commit-msg.txt";
@@ -553,9 +705,14 @@ function buildAuditCommentBody(args) {
553
705
  lines.push("");
554
706
  }
555
707
  lines.push(`**Cost:** $${move.cost_usd.toFixed(2)} (cumulative: $${args.ledger.cumulative_cost_usd.toFixed(2)})`);
708
+ // 0.19.0-α.12 — slowcook:cost HTML marker for downstream aggregation
709
+ // (`gh issue view N | grep slowcook:cost`). Format matches vibe / plate /
710
+ // brew / refine / testgen.
711
+ lines.push("");
712
+ lines.push(`<!-- slowcook:cost agent=chef-drift usd=${move.cost_usd.toFixed(4)} cumulative_usd=${args.ledger.cumulative_cost_usd.toFixed(4)} decision=${move.decision} trigger=${move.trigger_kind} story=${args.ledger.story_id} move=${move.n} cli=${args.cliVersion} -->`);
556
713
  return lines.join("\n");
557
714
  }
558
- export async function chefDrift(argv, _cliVersion) {
715
+ export async function chefDrift(argv, cliVersion) {
559
716
  const args = parseArgs(argv);
560
717
  const apiKey = process.env["ANTHROPIC_API_KEY"];
561
718
  if (!apiKey) {
@@ -563,6 +720,7 @@ export async function chefDrift(argv, _cliVersion) {
563
720
  process.exit(2);
564
721
  }
565
722
  console.log(`slowcook chef-drift · story-${args.storyId} · trigger=${args.triggerKind}${args.prNumber ? ` · finisher mode (PR #${args.prNumber})` : ""}`);
723
+ logReadOnlyBanner("chef-drift");
566
724
  // L2 finisher mode: check out the PR branch BEFORE reading any
567
725
  // ledger or repo state. The brew PR's branch has its own .brewing/
568
726
  // and a different working tree shape than main.
@@ -697,8 +855,40 @@ export async function chefDrift(argv, _cliVersion) {
697
855
  // material it needs to write surgical search_replace pairs.
698
856
  if (args.triggerKind === "brew_halt_class") {
699
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 = [];
700
868
  if (typeof runnerOutput === "string" && runnerOutput.length > 0) {
701
- 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) {
702
892
  const failingTestContents = {};
703
893
  for (const f of failingFiles) {
704
894
  const abs = join(args.repoRoot, f);
@@ -725,6 +915,25 @@ export async function chefDrift(argv, _cliVersion) {
725
915
  sourceContents[projRel] = c.length > 6000 ? c.slice(0, 6000) + "\n// ... truncated" : c;
726
916
  }
727
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
+ }
728
937
  triggerRaw = {
729
938
  ...triggerRaw,
730
939
  failing_test_files: failingFiles,
@@ -732,7 +941,8 @@ export async function chefDrift(argv, _cliVersion) {
732
941
  failing_test_contents: failingTestContents,
733
942
  imported_source_files_per_test: importedSourcesMap,
734
943
  source_file_contents: sourceContents,
735
- 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.",
736
946
  };
737
947
  console.log(` brew-halt enriched: ${failingFiles.length} failing test file(s), ${Object.keys(sourceContents).length} source file(s) under test`);
738
948
  }
@@ -771,6 +981,13 @@ export async function chefDrift(argv, _cliVersion) {
771
981
  maxTokens: 8192,
772
982
  });
773
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.
774
991
  let verdict;
775
992
  try {
776
993
  const text = resp.text.trim();
@@ -778,9 +995,10 @@ export async function chefDrift(argv, _cliVersion) {
778
995
  verdict = JSON.parse(fence ? fence[1] : text);
779
996
  }
780
997
  catch (e) {
781
- console.error(` ! chef JSON parse failed: ${e.message}`);
782
- console.error(` raw: ${resp.text.slice(0, 500)}`);
783
- 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);
784
1002
  }
785
1003
  console.log(` chef verdict: ${verdict.kind.toUpperCase()}`);
786
1004
  console.log(` rationale: ${verdict.rationale}`);
@@ -890,18 +1108,66 @@ export async function chefDrift(argv, _cliVersion) {
890
1108
  }
891
1109
  // Commit chef's edits locally (workflow handles the push) when validation passed.
892
1110
  // L2 finisher mode: also push to the PR's branch directly so the PR re-runs CI.
1111
+ // SLOWCOOK_READ_ONLY=1 gates BOTH the commit AND the push so a maintainer
1112
+ // running chef-drift on a consumer's repo doesn't pollute their git history.
893
1113
  let commitSha = null;
894
1114
  if (verdict.kind === "autonomous_fix" && moveEntry.post_state === "clean" && !args.dryRun) {
895
- const summary = `move ${moveN} on story-${args.storyId} — ${args.triggerKind}: ${verdict.rationale.slice(0, 120)}`;
896
- const result = commitChefEdits(args.repoRoot, summary, verdict.edits);
897
- if (result.sha) {
898
- commitSha = result.sha;
899
- console.log(` committed: ${result.sha.slice(0, 7)}`);
1115
+ if (isReadOnlyMode()) {
1116
+ console.log(` [SLOWCOOK_READ_ONLY=1] would commit ${verdict.edits.length} edit(s) + push to PR; skipping.`);
900
1117
  }
901
- if (commitSha && prCheckout) {
902
- const pushed = pushChefEditsToPrBranch(args.repoRoot, prCheckout.branchName, prCheckout.localRef);
903
- if (pushed)
904
- console.log(` finisher: pushed ${commitSha.slice(0, 7)} → ${prCheckout.branchName}`);
1118
+ else {
1119
+ const summary = `move ${moveN} on story-${args.storyId} — ${args.triggerKind}: ${verdict.rationale.slice(0, 120)}`;
1120
+ const result = commitChefEdits(args.repoRoot, summary, verdict.edits);
1121
+ if (result.sha) {
1122
+ commitSha = result.sha;
1123
+ console.log(` committed: ${result.sha.slice(0, 7)}`);
1124
+ }
1125
+ if (commitSha && prCheckout) {
1126
+ const pushed = pushChefEditsToPrBranch(args.repoRoot, prCheckout.branchName, prCheckout.localRef);
1127
+ if (pushed)
1128
+ console.log(` finisher: pushed ${commitSha.slice(0, 7)} → ${prCheckout.branchName}`);
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
+ }
905
1171
  }
906
1172
  }
907
1173
  ledger.moves.push(moveEntry);
@@ -910,15 +1176,22 @@ export async function chefDrift(argv, _cliVersion) {
910
1176
  saveLedger(args.repoRoot, ledger);
911
1177
  // Audit comment routing: in finisher mode (--pr) write to the PR;
912
1178
  // otherwise write to the source issue (L1 behavior).
1179
+ // SLOWCOOK_READ_ONLY=1 gates the post too — maintainer-side replay
1180
+ // doesn't comment on the consumer's repo.
913
1181
  if (verdict.kind === "autonomous_fix" && moveEntry.post_state === "clean" && !args.dryRun) {
914
- const body = buildAuditCommentBody({ ledger, move: moveEntry, verdict });
1182
+ const body = buildAuditCommentBody({ ledger, move: moveEntry, verdict, cliVersion });
915
1183
  const target = args.prNumber ?? (issueNumber > 0 ? issueNumber : null);
916
1184
  if (target !== null) {
917
- try {
918
- postIssueComment(args.repoRoot, target, body);
1185
+ if (isReadOnlyMode()) {
1186
+ console.log(` [SLOWCOOK_READ_ONLY=1] would post audit comment on issue/PR #${target}; skipping.`);
919
1187
  }
920
- catch (e) {
921
- console.warn(` warn: audit comment failed (non-fatal): ${e.message.slice(0, 200)}`);
1188
+ else {
1189
+ try {
1190
+ postIssueComment(args.repoRoot, target, body);
1191
+ }
1192
+ catch (e) {
1193
+ console.warn(` warn: audit comment failed (non-fatal): ${e.message.slice(0, 200)}`);
1194
+ }
922
1195
  }
923
1196
  }
924
1197
  }
@@ -926,4 +1199,119 @@ export async function chefDrift(argv, _cliVersion) {
926
1199
  if (verdict.kind === "halt" || moveEntry.post_state === "still-broken")
927
1200
  process.exit(1);
928
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
+ }
929
1317
  //# sourceMappingURL=drift-fix.js.map