copperhead 0.5.0 → 0.7.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 (74) hide show
  1. package/README.md +34 -1
  2. package/dist/agent/loop.js +130 -15
  3. package/dist/agent/loop.js.map +1 -1
  4. package/dist/agent/prompts.js +2 -1
  5. package/dist/agent/prompts.js.map +1 -1
  6. package/dist/agent/providers/claude-code.js +466 -0
  7. package/dist/agent/providers/claude-code.js.map +1 -0
  8. package/dist/agent/providers/openai.js +30 -10
  9. package/dist/agent/providers/openai.js.map +1 -1
  10. package/dist/agent/recovery.js +148 -0
  11. package/dist/agent/recovery.js.map +1 -0
  12. package/dist/agent/render.js +17 -2
  13. package/dist/agent/render.js.map +1 -1
  14. package/dist/agent/response-cache.js +81 -0
  15. package/dist/agent/response-cache.js.map +1 -0
  16. package/dist/agent/tools.js +61 -4
  17. package/dist/agent/tools.js.map +1 -1
  18. package/dist/agent/transcript.js.map +1 -1
  19. package/dist/cli.js +47 -2
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/create.js +486 -35
  22. package/dist/commands/create.js.map +1 -1
  23. package/dist/commands/export.js +90 -0
  24. package/dist/commands/export.js.map +1 -0
  25. package/dist/config.js +33 -6
  26. package/dist/config.js.map +1 -1
  27. package/dist/kicad/bom-export.js +240 -0
  28. package/dist/kicad/bom-export.js.map +1 -0
  29. package/dist/kicad/bootstrap.js +166 -0
  30. package/dist/kicad/bootstrap.js.map +1 -0
  31. package/dist/kicad/fab.js +94 -0
  32. package/dist/kicad/fab.js.map +1 -0
  33. package/dist/kicad/spice.js +306 -0
  34. package/dist/kicad/spice.js.map +1 -0
  35. package/dist/kicad/symlib.js +228 -0
  36. package/dist/kicad/symlib.js.map +1 -0
  37. package/dist/memory/bom-table.js +232 -0
  38. package/dist/memory/bom-table.js.map +1 -0
  39. package/dist/memory/drift.js +33 -27
  40. package/dist/memory/drift.js.map +1 -1
  41. package/dist/util/git.js +37 -1
  42. package/dist/util/git.js.map +1 -1
  43. package/dist/util/preflight.js +37 -0
  44. package/dist/util/preflight.js.map +1 -1
  45. package/dist/util/retry.js +23 -0
  46. package/dist/util/retry.js.map +1 -1
  47. package/dist/util/tmp.js +119 -0
  48. package/dist/util/tmp.js.map +1 -0
  49. package/package.json +6 -2
  50. package/src/agent/loop.ts +148 -15
  51. package/src/agent/prompts.ts +2 -1
  52. package/src/agent/providers/claude-code.ts +550 -0
  53. package/src/agent/providers/openai.ts +33 -16
  54. package/src/agent/recovery.ts +162 -0
  55. package/src/agent/render.ts +28 -1
  56. package/src/agent/response-cache.ts +80 -0
  57. package/src/agent/tools.ts +62 -4
  58. package/src/agent/transcript.ts +1 -0
  59. package/src/agent/types.ts +18 -0
  60. package/src/cli.ts +52 -2
  61. package/src/commands/create.ts +543 -38
  62. package/src/commands/export.ts +117 -0
  63. package/src/config.ts +54 -6
  64. package/src/kicad/bom-export.ts +321 -0
  65. package/src/kicad/bootstrap.ts +181 -0
  66. package/src/kicad/fab.ts +121 -0
  67. package/src/kicad/spice.ts +399 -0
  68. package/src/kicad/symlib.ts +248 -0
  69. package/src/memory/bom-table.ts +249 -0
  70. package/src/memory/drift.ts +42 -32
  71. package/src/util/git.ts +37 -1
  72. package/src/util/preflight.ts +44 -0
  73. package/src/util/retry.ts +29 -0
  74. package/src/util/tmp.ts +113 -0
@@ -1,15 +1,24 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
- import { readFile } from 'node:fs/promises';
3
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { loadConfig } from '../config.js';
6
+ import { bootstrapKicadProject } from '../kicad/bootstrap.js';
7
+ import { exportSvg, runErc } from '../kicad/cli.js';
6
8
  import { listSymbols } from '../kicad/sexp.js';
9
+ import { isDirty, commitAll, changedFiles } from '../util/git.js';
10
+ import type { CopperheadConfig } from '../config.js';
7
11
  import { checkDrift } from '../memory/drift.js';
8
- import { runAgentLoop, type BudgetExhaustedStats } from '../agent/loop.js';
12
+ import { runAgentLoop, makeProvider, type BudgetExhaustedStats } from '../agent/loop.js';
13
+ import { diagnoseStageFailure, transcriptExcerpt, withTimeout, type StageDiagnosis } from '../agent/recovery.js';
14
+ import type { Provider } from '../agent/types.js';
9
15
  import type { RunMetaInput } from '../agent/runmeta.js';
10
- import type { ProgressRenderer } from '../agent/render.js';
16
+ import { fmtDuration, fmtTokens, type ProgressRenderer } from '../agent/render.js';
11
17
  import { openspecInit } from '../openspec/cli.js';
18
+ import { sweepStaleTempDirs, pruneHistoryDir } from '../util/tmp.js';
19
+ import { assertDiskSpace, DEFAULT_MIN_FREE_BYTES } from '../util/preflight.js';
12
20
  import { runCheck } from './check.js';
21
+ import { emitCreateJlcpcbBom } from './export.js';
13
22
 
14
23
  /**
15
24
  * Mode A (`copperhead create`, SPEC §2.5): staged pipeline, each stage a
@@ -33,10 +42,22 @@ async function docHasContent(repoRoot: string, rel: string, marker: string): Pro
33
42
  return (await readFile(p, 'utf8')).includes(marker);
34
43
  }
35
44
 
45
+ // Heading-aware variant of docHasContent: matches any Markdown heading whose
46
+ // text contains `word`, ignoring heading level, leading numbering ("3."), and
47
+ // trailing decoration ("Budgets and constraints (...)"). Stage prompts don't
48
+ // dictate exact heading text, so a literal `.includes('## Budgets')` produces
49
+ // false negatives against valid docs titled e.g. "## 3. Budgets and constraints".
50
+ async function docHasHeading(repoRoot: string, rel: string, word: string): Promise<boolean> {
51
+ const p = path.join(repoRoot, rel);
52
+ if (!existsSync(p)) return false;
53
+ const re = new RegExp(`^#{1,6}\\s.*\\b${word}\\b`, 'im');
54
+ return re.test(await readFile(p, 'utf8'));
55
+ }
56
+
36
57
  export const STAGES: Stage[] = [
37
58
  {
38
59
  name: 'spec-seed',
39
- isComplete: (root, docs) => docHasContent(root, path.join(docs, 'SPEC.md'), '## Budgets'),
60
+ isComplete: (root, docs) => docHasHeading(root, path.join(docs, 'SPEC.md'), 'Budgets?'),
40
61
  prompt: (brief) =>
41
62
  `Stage 1 of the create pipeline: seed the requirements. From the product brief below, write docs/SPEC.md (what the device is, top-level constraints and budgets). Every budget you state must also be recorded with record_constraint. Anything the brief does not state: propose a sensible default and flag it ASSUMED. If an openspec/ workspace exists, also seed openspec/specs/ with per-capability requirements using Given/When/Then scenarios.\n\nBrief:\n${brief}`,
42
63
  },
@@ -67,10 +88,19 @@ export const STAGES: Stage[] = [
67
88
  // BOM/PINOUT tables agree with them (drift-clean); anything less keeps
68
89
  // the stage active on the next resume so partial capture continues.
69
90
  if (!(await listSymbols(p)).length) return false;
70
- return (await checkDrift(root, config.docs, config.schematic)).length === 0;
91
+ if ((await checkDrift(root, config.docs, config.schematic)).length !== 0) return false;
92
+ // ERC-clean is part of "done" (F2 / verification-gated-out on the resume
93
+ // path). Symbols + drift-clean can still hold on a schematic with
94
+ // unconnected pins — e.g. a run hard-killed mid-capture after BOM/PINOUT
95
+ // went clean but before ERC passed. Without this check, resume would treat
96
+ // it as complete and commitResumedStage would commit an ERC-failing
97
+ // schematic, advancing the pipeline against unverified work. Returning
98
+ // false here keeps the stage active so it re-runs, fixes ERC, and commits
99
+ // through the normal finish gate.
100
+ return (await runErc(p)).ok;
71
101
  },
72
102
  prompt: () =>
73
- 'Stage 4: schematic. Build the schematic sheet by sheet from BOM.md and SUBSYSTEMS.md. After each sheet, run run_erc and fix violations before moving on. Same net names and refdes everywhere. Update PINOUT.md as you assign pins; check the strapping table first.',
103
+ 'Stage 4: schematic. An empty KiCad project has already been scaffolded and wired into .copperhead/config.json (an empty schematic and a blank board with a default outline). Populate the existing schematic with edit_file — write_file refuses KiCad files, so add lib_symbols, symbols, and connectivity by anchored edits into the file that already exists. Work ONE part at a time, not in large blocks: add a symbol (its lib_symbols entry if new, then its placement), run run_erc, fix any violation, then move to the next part — small incremental edits keep a geometry or grid slip local instead of forcing a full-block rewrite. When you add a lib_symbols entry, use the exact canonical KiCad lib_id (e.g. Device:R, Connector:USB_C_Receptacle_USB2.0_16P) and reproduce the real part\'s pins faithfully — never invent pin numbers, names, or electrical types. Once symbols are placed, run verify_symbols and reconcile every divergence it reports (a wrong lib_id or pin set passes ERC but is still wrong); if it flags a renamed symbol, adopt the real name it suggests. Build subsystem by subsystem from BOM.md and SUBSYSTEMS.md. Same net names and refdes everywhere. Two KiCad rules the pipeline has repeatedly tripped on: (1) a net label placed on a pin only NAMES the net — it is NOT an electrical connection unless a wire actually reaches the pin; ERC will report the pin unconnected until you draw the wire. (2) Place every symbol origin and every wire endpoint on the 1.27mm (50mil) grid; an off-grid pin silently fails to connect and costs turns to diagnose. Update PINOUT.md as you assign pins; check the strapping table first.',
74
104
  },
75
105
  {
76
106
  name: 'layout-draft',
@@ -122,61 +152,536 @@ export interface CreateOptions {
122
152
  meta?: Omit<RunMetaInput, 'stage' | 'brief'>;
123
153
  }
124
154
 
155
+ /**
156
+ * Stage 6 emits the JLCPCB assembly BOM deterministically alongside the agent's
157
+ * outputs package (create-pipeline delta). Called whenever the outputs stage is
158
+ * confirmed complete — on the pass that finishes it and on any later resume — so
159
+ * the file tracks the current BOM.md.
160
+ */
161
+ async function emitJlcpcbAfterOutputs(stageName: string, opts: CreateOptions): Promise<void> {
162
+ if (stageName !== 'outputs') return;
163
+ const out = await emitCreateJlcpcbBom(opts.repoRoot);
164
+ if (out) opts.log(`stage outputs: emitted ${out} (JLCPCB assembly BOM)`);
165
+ }
166
+
167
+ /** Stages whose output is a KiCad file worth rendering to an image (5.4). */
168
+ const KICAD_STAGES = new Set(['schematic', 'layout-draft', 'outputs']);
169
+
170
+ /** True for a path copperhead itself manages inside the pipeline. Used to decide
171
+ * whether a resumed stage's uncommitted work is safe to auto-commit (2.4): only
172
+ * when the ENTIRE dirty set is copperhead's, never sweeping up a user's own WIP. */
173
+ function isManagedPath(f: string, config: CopperheadConfig): boolean {
174
+ // config.docs defaults to `docs/` (trailing slash), so normalize before
175
+ // building the prefix — otherwise the check becomes `startsWith('docs//')` and
176
+ // every doc reads as foreign, making commitResumedStage never commit its own
177
+ // work (it always bails as "non-copperhead changes").
178
+ const docsDir = config.docs.replace(/\/+$/, '');
179
+ return (
180
+ f === docsDir ||
181
+ f.startsWith(`${docsDir}/`) ||
182
+ f.startsWith('.copperhead/') ||
183
+ f.startsWith('openspec/') ||
184
+ f.startsWith('outputs/') ||
185
+ f.startsWith('firmware/') ||
186
+ f === '.gitignore' ||
187
+ /\.(kicad_sch|kicad_pcb|kicad_pro|kicad_prl)$/.test(f)
188
+ );
189
+ }
190
+
191
+ /**
192
+ * When resuming past an already-complete stage whose artifact is present but
193
+ * UNCOMMITTED (e.g. a prior invocation stopped on a session limit mid-pipeline),
194
+ * commit it now so a later stage's failure — whose rollback is `git reset --hard`
195
+ * + `git clean -fd` — cannot wipe the completed work from the tree (2.4, I13).
196
+ * Strictly gated: only when every dirty path is copperhead-managed, so a user's
197
+ * unrelated working changes are never swept into a copperhead commit; if any
198
+ * foreign path is dirty, leave the whole thing for the human and say so.
199
+ */
200
+ async function commitResumedStage(opts: CreateOptions, config: CopperheadConfig, stageName: string): Promise<void> {
201
+ if (!(await isDirty(opts.repoRoot))) return;
202
+ const dirty = await changedFiles(opts.repoRoot, 'HEAD');
203
+ const foreign = dirty.filter((f) => !isManagedPath(f, config));
204
+ if (foreign.length) {
205
+ opts.log(
206
+ `stage ${stageName}: already-complete work is uncommitted, but the tree also has non-copperhead changes ` +
207
+ `(${foreign.slice(0, 3).join(', ')}${foreign.length > 3 ? ', …' : ''}); leaving it uncommitted so nothing of yours is swept up`,
208
+ );
209
+ return;
210
+ }
211
+ try {
212
+ const sha = await commitAll(opts.repoRoot, `copperhead: resume — commit completed stage ${stageName}`);
213
+ opts.log(`stage ${stageName}: committed already-complete work ${sha.slice(0, 10)} so a later rollback cannot wipe it (2.4)`);
214
+ } catch (err) {
215
+ opts.log(`stage ${stageName}: could not commit resumed work (${(err as Error).message})`);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * After a KiCad-touching stage completes, render the current schematic and board
221
+ * to SVG in that stage's run dir (`.copperhead/runs/<id>/artifacts/`) (5.4).
222
+ * Every text/ERC/drift gate can be satisfied by a design that is visibly wrong or
223
+ * even empty, and nothing else in the run ever *looks* at the board; a per-stage
224
+ * render closes that gap cheaply and deterministically, with no extra tokens. It
225
+ * is the natural input for an optional later vision acceptance pass. Best-effort:
226
+ * a render failure is logged, never fatal — the design is already committed.
227
+ */
228
+ async function renderStageArtifacts(opts: CreateOptions, stageName: string, transcriptDir: string): Promise<void> {
229
+ if (!KICAD_STAGES.has(stageName) || !transcriptDir) return;
230
+ const config = await loadConfig(opts.repoRoot);
231
+ const targets: Array<{ kind: 'sch' | 'pcb'; file: string }> = [];
232
+ if (config.schematic && existsSync(path.join(opts.repoRoot, config.schematic))) {
233
+ targets.push({ kind: 'sch', file: config.schematic });
234
+ }
235
+ if (config.board && existsSync(path.join(opts.repoRoot, config.board))) {
236
+ targets.push({ kind: 'pcb', file: config.board });
237
+ }
238
+ if (!targets.length) return;
239
+ const artifactsDir = path.join(transcriptDir, 'artifacts');
240
+ await mkdir(artifactsDir, { recursive: true });
241
+ let rendered = 0;
242
+ for (const { kind, file } of targets) {
243
+ try {
244
+ await exportSvg(kind, path.join(opts.repoRoot, file), artifactsDir);
245
+ rendered++;
246
+ } catch (err) {
247
+ opts.log(`stage ${stageName}: could not render ${kind} SVG (${(err as Error).message})`);
248
+ }
249
+ }
250
+ if (rendered) {
251
+ opts.log(`stage ${stageName}: rendered ${rendered} SVG artifact(s) into ${path.relative(opts.repoRoot, artifactsDir)}/`);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Ask the model, on a fresh tool-less turn, whether a failed stage should be
257
+ * retried and how. Wrapped in the watchdog timeout and hardened to fail safe:
258
+ * any error or hang resolves to "abort" so recovery never itself becomes the
259
+ * thing that hangs the pipeline.
260
+ */
261
+ async function diagnose(input: {
262
+ model: string;
263
+ timeoutMs: number;
264
+ stageName: string;
265
+ stageGoal: string;
266
+ failure: string;
267
+ transcriptDir: string;
268
+ attempt: number;
269
+ maxAttempts: number;
270
+ }): Promise<StageDiagnosis> {
271
+ let provider: Provider | undefined;
272
+ try {
273
+ provider = await makeProvider(input.model);
274
+ const p = provider;
275
+ const excerpt = await transcriptExcerpt(input.transcriptDir);
276
+ return await withTimeout(
277
+ () =>
278
+ diagnoseStageFailure(p, {
279
+ stageName: input.stageName,
280
+ stageGoal: input.stageGoal,
281
+ failure: input.failure,
282
+ excerpt,
283
+ attempt: input.attempt,
284
+ maxAttempts: input.maxAttempts,
285
+ }),
286
+ input.timeoutMs,
287
+ () => p.close?.(),
288
+ );
289
+ } catch (e) {
290
+ return { verdict: 'abort', reason: `diagnosis unavailable: ${(e as Error).message}` };
291
+ } finally {
292
+ await provider?.close?.();
293
+ }
294
+ }
295
+
296
+ /** One row of the end-of-run per-stage cost summary (5.2). A `resumed` stage was
297
+ * already complete on entry (skipped past), so it has no cost of its own. */
298
+ interface StageCost {
299
+ name: string;
300
+ resumed: boolean;
301
+ wallMs: number;
302
+ turns: number;
303
+ tokensIn: number;
304
+ tokensOut: number;
305
+ cacheHits: number;
306
+ }
307
+
308
+ /** Quote a path/value for a copy-pasteable resume command (5.3). */
309
+ function shellQuote(s: string): string {
310
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
311
+ }
312
+
313
+ /** The single command that resumes this pipeline, reconstructed from the run's
314
+ * own options so the operator never has to remember the flags (5.3). */
315
+ function resumeCommand(opts: CreateOptions): string {
316
+ const parts = ['copperhead'];
317
+ const repo = path.resolve(opts.repoRoot);
318
+ if (repo !== process.cwd()) parts.push('--repo', shellQuote(repo));
319
+ // Absolute --brief so the command resolves the same from any cwd; a relative
320
+ // path would break when resumed from a different directory (F6).
321
+ parts.push('create', '--brief', shellQuote(path.resolve(opts.briefPath)), '--model', shellQuote(opts.model));
322
+ if (opts.interactive) parts.push('--interactive');
323
+ return parts.join(' ');
324
+ }
325
+
326
+ /**
327
+ * On any pipeline stop, print the exact command to resume and which stage it
328
+ * will resume at, so the operator never has to reconstruct it (5.3). Stage
329
+ * completion is inferred from repo state, so resuming is just re-running the
330
+ * same command — the earlier completed stages are skipped automatically.
331
+ */
332
+ function logResumePoint(opts: CreateOptions, stage: Stage, index: number): void {
333
+ opts.log('');
334
+ opts.log(`⏸ stopped at stage ${index + 1}/${STAGES.length} (${stage.name}). To resume from here, run:`);
335
+ opts.log(` ${resumeCommand(opts)}`);
336
+ opts.log(
337
+ ` (${index} stage(s) already complete are detected from repo state and skipped; it resumes at ${stage.name}.)`,
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Print the final per-stage cost table (5.2): stage → wall, turns, out-tokens,
343
+ * cache-hit%. Makes the expensive stages obvious at a glance and lets the effect
344
+ * of tuning be tracked across runs. Right-aligned numeric columns; resumed
345
+ * stages show "—" (they cost nothing this run).
346
+ */
347
+ function printCostTable(opts: CreateOptions, costs: StageCost[]): void {
348
+ if (!costs.length) return;
349
+ const pct = (hits: number, turns: number): string => (turns ? `${Math.round((hits / turns) * 100)}%` : '—');
350
+ const header = { stage: 'Stage', wall: 'Wall', turns: 'Turns', out: 'Out tok', cache: 'Cache' };
351
+ const rows = costs.map((c) => ({
352
+ stage: c.name,
353
+ wall: c.resumed ? '—' : fmtDuration(c.wallMs),
354
+ turns: c.resumed ? '—' : String(c.turns),
355
+ out: c.resumed ? '—' : fmtTokens(c.tokensOut),
356
+ cache: c.resumed ? '—' : pct(c.cacheHits, c.turns),
357
+ }));
358
+ const ran = costs.filter((c) => !c.resumed);
359
+ const total =
360
+ ran.length &&
361
+ ({
362
+ stage: 'TOTAL',
363
+ wall: fmtDuration(ran.reduce((a, c) => a + c.wallMs, 0)),
364
+ turns: String(ran.reduce((a, c) => a + c.turns, 0)),
365
+ out: fmtTokens(ran.reduce((a, c) => a + c.tokensOut, 0)),
366
+ cache: pct(
367
+ ran.reduce((a, c) => a + c.cacheHits, 0),
368
+ ran.reduce((a, c) => a + c.turns, 0),
369
+ ),
370
+ } as const);
371
+ const all = [header, ...rows, ...(total ? [total] : [])];
372
+ const w = {
373
+ stage: Math.max(...all.map((r) => r.stage.length)),
374
+ wall: Math.max(...all.map((r) => r.wall.length)),
375
+ turns: Math.max(...all.map((r) => r.turns.length)),
376
+ out: Math.max(...all.map((r) => r.out.length)),
377
+ cache: Math.max(...all.map((r) => r.cache.length)),
378
+ };
379
+ const line = (r: typeof header): string =>
380
+ ` ${r.stage.padEnd(w.stage)} ${r.wall.padStart(w.wall)} ${r.turns.padStart(w.turns)} ${r.out.padStart(w.out)} ${r.cache.padStart(w.cache)}`;
381
+ const rule = ` ${'-'.repeat(w.stage)} ${'-'.repeat(w.wall)} ${'-'.repeat(w.turns)} ${'-'.repeat(w.out)} ${'-'.repeat(w.cache)}`;
382
+ opts.log('');
383
+ opts.log('Per-stage cost summary (5.2):');
384
+ opts.log(line(header));
385
+ opts.log(rule);
386
+ for (const r of rows) opts.log(line(r));
387
+ if (total) {
388
+ opts.log(rule);
389
+ opts.log(line(total));
390
+ }
391
+ }
392
+
393
+ /** Sum the cost of the stages that actually ran this invocation (resumed stages
394
+ * cost nothing). Shared by the cumulative line and the end-of-run report (5.6). */
395
+ function ranTotals(stageCosts: StageCost[]): {
396
+ wallMs: number;
397
+ turns: number;
398
+ tokensIn: number;
399
+ tokensOut: number;
400
+ cacheHits: number;
401
+ } {
402
+ const ran = stageCosts.filter((c) => !c.resumed);
403
+ return {
404
+ wallMs: ran.reduce((a, c) => a + c.wallMs, 0),
405
+ turns: ran.reduce((a, c) => a + c.turns, 0),
406
+ tokensIn: ran.reduce((a, c) => a + c.tokensIn, 0),
407
+ tokensOut: ran.reduce((a, c) => a + c.tokensOut, 0),
408
+ cacheHits: ran.reduce((a, c) => a + c.cacheHits, 0),
409
+ };
410
+ }
411
+
412
+ const cachePct = (hits: number, turns: number): number => (turns ? Math.round((hits / turns) * 100) : 0);
413
+
414
+ /**
415
+ * The running whole-run total, printed at each stage's end (5.6). A create board
416
+ * is built over many invocations and each stage's `summary.md` covers only that
417
+ * stage; this line accrues the pipeline total so the operator sees the true cost
418
+ * grow instead of adding up per-stage numbers by hand. On the last stage it is
419
+ * the grand total.
420
+ */
421
+ function logCumulative(opts: CreateOptions, stageCosts: StageCost[]): void {
422
+ const t = ranTotals(stageCosts);
423
+ if (!t.turns && !t.wallMs) return; // nothing has actually run yet (all resumed)
424
+ opts.log(
425
+ `pipeline so far: ${stageCosts.length}/${STAGES.length} stages · ${fmtDuration(t.wallMs)} · ` +
426
+ `${fmtTokens(t.tokensOut)} out tokens · ${cachePct(t.cacheHits, t.turns)}% cache hits`,
427
+ );
428
+ }
429
+
430
+ /**
431
+ * Aggregate the per-stage costs into a durable end-of-run report (5.6):
432
+ * `.copperhead/runs/REPORT.md` (human) and `report.json` (machine, stable schema
433
+ * for diffing successive boards). One row per stage — wall, turns, in/out tokens,
434
+ * cache-hit%, status — plus a total row and a slowest / most-expensive callout so
435
+ * the bottleneck is obvious. This is the only artifact that makes the big token
436
+ * levers measurable *across* runs; without it, tuning is anecdote. Best-effort:
437
+ * a write failure is logged, never fatal.
438
+ */
439
+ async function writeRunReport(opts: CreateOptions, stageCosts: StageCost[]): Promise<void> {
440
+ if (!stageCosts.length) return;
441
+ const runsDir = path.join(opts.repoRoot, '.copperhead', 'runs');
442
+ const t = ranTotals(stageCosts);
443
+ const ran = stageCosts.filter((c) => !c.resumed);
444
+ const slowest = ran.length ? ran.reduce((a, b) => (b.wallMs > a.wallMs ? b : a)) : null;
445
+ const priciest = ran.length ? ran.reduce((a, b) => (b.tokensOut > a.tokensOut ? b : a)) : null;
446
+
447
+ const report = {
448
+ generatedAtMs: Date.now(),
449
+ stageCount: STAGES.length,
450
+ ran: ran.length,
451
+ resumed: stageCosts.length - ran.length,
452
+ stages: stageCosts.map((c) => ({
453
+ name: c.name,
454
+ resumed: c.resumed,
455
+ wallMs: c.wallMs,
456
+ turns: c.turns,
457
+ tokensIn: c.tokensIn,
458
+ tokensOut: c.tokensOut,
459
+ cacheHits: c.cacheHits,
460
+ cacheHitPct: c.resumed ? null : cachePct(c.cacheHits, c.turns),
461
+ })),
462
+ total: { ...t, cacheHitPct: cachePct(t.cacheHits, t.turns) },
463
+ slowestStage: slowest ? { name: slowest.name, wallMs: slowest.wallMs } : null,
464
+ mostExpensiveStage: priciest ? { name: priciest.name, tokensOut: priciest.tokensOut } : null,
465
+ };
466
+
467
+ const row = (cells: string[]): string => `| ${cells.join(' | ')} |`;
468
+ const lines = [
469
+ '# Copperhead run report',
470
+ '',
471
+ 'Per-stage cost of the create pipeline, regenerated at the end of every run.',
472
+ 'Resumed stages were already complete on entry and cost nothing this run.',
473
+ '',
474
+ row(['Stage', 'Wall', 'Turns', 'In', 'Out', 'Cache', 'Status']),
475
+ row(['---', '---:', '---:', '---:', '---:', '---:', '---']),
476
+ ...stageCosts.map((c) =>
477
+ c.resumed
478
+ ? row([c.name, '—', '—', '—', '—', '—', 'resumed'])
479
+ : row([
480
+ c.name,
481
+ fmtDuration(c.wallMs),
482
+ String(c.turns),
483
+ fmtTokens(c.tokensIn),
484
+ fmtTokens(c.tokensOut),
485
+ `${cachePct(c.cacheHits, c.turns)}%`,
486
+ 'ran',
487
+ ]),
488
+ ),
489
+ row([
490
+ '**Total**',
491
+ fmtDuration(t.wallMs),
492
+ String(t.turns),
493
+ fmtTokens(t.tokensIn),
494
+ fmtTokens(t.tokensOut),
495
+ `${cachePct(t.cacheHits, t.turns)}%`,
496
+ '',
497
+ ]),
498
+ '',
499
+ ];
500
+ if (slowest && priciest) {
501
+ lines.push(
502
+ `Slowest stage: **${slowest.name}** (${fmtDuration(slowest.wallMs)}). ` +
503
+ `Most expensive: **${priciest.name}** (${fmtTokens(priciest.tokensOut)} out tokens).`,
504
+ '',
505
+ );
506
+ }
507
+
508
+ try {
509
+ await mkdir(runsDir, { recursive: true });
510
+ await writeFile(path.join(runsDir, 'report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
511
+ await writeFile(path.join(runsDir, 'REPORT.md'), lines.join('\n'), 'utf8');
512
+ opts.log(`wrote run report: ${path.relative(opts.repoRoot, path.join(runsDir, 'REPORT.md'))} (+ report.json)`);
513
+ } catch (err) {
514
+ opts.log(`warning: could not write run report (${(err as Error).message})`);
515
+ }
516
+ }
517
+
125
518
  export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; completed: string[] }> {
126
519
  const brief = await readFile(path.resolve(opts.briefPath), 'utf8');
127
520
  // Hashed from the content already in hand: a brief edited mid-pipeline shows
128
521
  // up as a different sha256 in the next stage's metadata (AC-8.1).
129
522
  const briefMeta = { path: opts.briefPath, sha256: createHash('sha256').update(brief).digest('hex') };
130
523
  const config = await loadConfig(opts.repoRoot);
524
+ // Fail fast on a nearly-full disk (4.1): a create run writes fab outputs and
525
+ // KiCad local history and can otherwise fill the disk mid-stage, failing with
526
+ // an opaque ENOSPC only after doing expensive work. Threshold overridable via
527
+ // COPPERHEAD_MIN_FREE_MB; an unknown reading (unsupported platform) skips it.
528
+ const minFreeMb = Number(process.env.COPPERHEAD_MIN_FREE_MB);
529
+ const minFree = Number.isFinite(minFreeMb) && minFreeMb >= 0 ? minFreeMb * 1024 * 1024 : DEFAULT_MIN_FREE_BYTES;
530
+ await assertDiskSpace(opts.repoRoot, minFree);
531
+ // Reclaim scratch dirs leaked by earlier runs whose cleanup was skipped (a
532
+ // watchdog SIGKILL or hard abort bypasses the per-call `finally`). Age-gated,
533
+ // so a concurrent run's fresh dirs are never touched; best-effort, so it never
534
+ // blocks a run (I8).
535
+ const swept = await sweepStaleTempDirs(Date.now());
536
+ if (swept.length) opts.log(`startup: reclaimed ${swept.length} stale temp dir(s) from earlier runs`);
537
+ // Cap the gitignored .history/ so KiCad local history cannot grow unbounded
538
+ // across a long run and fill the disk (4.1, I8). Best-effort; keeps the newest.
539
+ const pruned = await pruneHistoryDir(opts.repoRoot);
540
+ if (pruned) opts.log(`startup: pruned ${pruned} old .history/ entrie(s) to cap local-history growth`);
131
541
  await openspecInit(opts.repoRoot);
132
542
  const completed: string[] = [];
543
+ const stageCosts: StageCost[] = [];
133
544
 
134
545
  for (const [i, stage] of STAGES.entries()) {
546
+ // The schematic stage is the first to touch KiCad files, but the agent
547
+ // cannot create them (write_file refuses KiCad files; edit_file needs an
548
+ // existing file). Scaffold a minimal empty project and wire config just
549
+ // before the stage runs, so there is a schematic to populate and the stage
550
+ // contract can eventually be met. No-op once a project exists.
551
+ if (stage.name === 'schematic') {
552
+ const created = await bootstrapKicadProject(opts.repoRoot, brief);
553
+ if (created) opts.log(`stage schematic: scaffolded empty KiCad project (${created} + board + project), wired into config`);
554
+ }
135
555
  if (await stage.isComplete(opts.repoRoot, config.docs)) {
136
556
  opts.log(`stage ${stage.name}: already complete (resuming past it)`);
557
+ await commitResumedStage(opts, config, stage.name);
137
558
  completed.push(stage.name);
559
+ stageCosts.push({ name: stage.name, resumed: true, wallMs: 0, turns: 0, tokensIn: 0, tokensOut: 0, cacheHits: 0 });
560
+ await emitJlcpcbAfterOutputs(stage.name, opts);
138
561
  continue;
139
562
  }
140
- opts.log(`stage ${stage.name}: running`);
563
+ // Auto-recovery loop: run the stage, and if it fails or ends without meeting
564
+ // its contract, ask the model to diagnose whether another attempt is likely
565
+ // to help. On "retry" the pipeline runs the stage again (with the diagnosis's
566
+ // guidance prepended); on "abort", or once the retry budget is spent, it
567
+ // stops and reports for a human — the loop keeps going by itself for the
568
+ // recoverable cases without silently spinning on the dead-end ones.
141
569
  const stageTurns = config.stageMaxTurns?.[stage.name];
142
- const res = await runAgentLoop({
143
- repoRoot: opts.repoRoot,
144
- model: opts.model,
145
- request: `create pipeline stage: ${stage.name}`,
146
- stagePrompt: stage.prompt(brief),
147
- interactive: opts.interactive ?? false,
148
- allowDirty: true, // stages build on each other's uncommitted state within the pipeline
149
- ...(stageTurns !== undefined ? { maxTurns: stageTurns } : {}),
150
- ...(opts.onBudgetExhausted ? { onBudgetExhausted: opts.onBudgetExhausted } : {}),
151
- log: opts.log,
152
- ...(opts.renderer ? { renderer: opts.renderer } : {}),
153
- meta: {
154
- ...opts.meta,
155
- command: 'create',
156
- stage: { name: stage.name, index: i + 1, total: STAGES.length },
157
- brief: briefMeta,
158
- },
159
- });
160
- if (res.outcome !== 'success') {
161
- opts.log(`stage ${stage.name} did not complete (${res.outcome}); re-run copperhead create to resume here`);
162
- return { ok: false, completed };
570
+ const basePrompt = stage.prompt(brief);
571
+ let guidance = '';
572
+ let stageDone = false;
573
+ let stageTranscriptDir = '';
574
+ // Cost accumulates across all attempts of the stage, so a stage that took a
575
+ // retry to complete shows its true total in the summary (5.2).
576
+ const stageStart = Date.now();
577
+ const cost: StageCost = { name: stage.name, resumed: false, wallMs: 0, turns: 0, tokensIn: 0, tokensOut: 0, cacheHits: 0 };
578
+ for (let attempt = 1; ; attempt++) {
579
+ // Re-scaffold before every attempt, not just once per stage. A previous
580
+ // attempt that failed at the commit gate rolls the tree back
581
+ // (restore(): `git reset --hard` + `git clean -fd`), which deletes the
582
+ // still-untracked scaffold (config.json + the empty KiCad files). Without
583
+ // this the retry would run against a missing schematic and cascade into a
584
+ // worse failure than the one being recovered from. Idempotent: a no-op
585
+ // whenever the project already exists.
586
+ if (stage.name === 'schematic') {
587
+ const rescaffolded = await bootstrapKicadProject(opts.repoRoot, brief);
588
+ if (rescaffolded && attempt > 1) opts.log(`stage schematic: re-scaffolded empty KiCad project after rollback, wired into config`);
589
+ }
590
+ opts.log(`stage ${stage.name}: running${attempt > 1 ? ` (attempt ${attempt}/${config.maxStageRetries + 1})` : ''}`);
591
+ const res = await runAgentLoop({
592
+ repoRoot: opts.repoRoot,
593
+ model: opts.model,
594
+ request: `create pipeline stage: ${stage.name}`,
595
+ stagePrompt: guidance
596
+ ? `${basePrompt}\n\n## Recovery guidance (a previous attempt did not complete this stage — do this differently)\n${guidance}`
597
+ : basePrompt,
598
+ interactive: opts.interactive ?? false,
599
+ allowDirty: true, // stages build on each other's uncommitted state within the pipeline
600
+ ...(stageTurns !== undefined ? { maxTurns: stageTurns } : {}),
601
+ ...(opts.onBudgetExhausted ? { onBudgetExhausted: opts.onBudgetExhausted } : {}),
602
+ log: opts.log,
603
+ ...(opts.renderer ? { renderer: opts.renderer } : {}),
604
+ meta: {
605
+ ...opts.meta,
606
+ command: 'create',
607
+ stage: { name: stage.name, index: i + 1, total: STAGES.length },
608
+ brief: briefMeta,
609
+ },
610
+ });
611
+
612
+ // Fold this attempt's cost in. Defensive reads: a run that dies very early
613
+ // (or a scripted test double) may omit stats — never let telemetry throw.
614
+ cost.turns += res.stats?.turnsUsed ?? 0;
615
+ cost.tokensIn += res.stats?.tokensIn ?? 0;
616
+ cost.tokensOut += res.stats?.tokensOut ?? 0;
617
+ cost.cacheHits += res.cacheHits ?? 0;
618
+ stageTranscriptDir = res.transcriptDir; // last attempt's run dir (for SVG artifacts / report)
619
+
620
+ // A successful run is not the same as a completed stage: an agent can
621
+ // finish "done" with all gates green having only planned the work (seen
622
+ // with the schematic stage: one header edit, ERC "clean" on an empty
623
+ // sheet). Advancing anyway lets every later stage run against a design
624
+ // that isn't there, so the completion contract is the real gate.
625
+ const failure =
626
+ res.outcome !== 'success'
627
+ ? `the run ended as "${res.outcome}" (${res.exitPath})`
628
+ : !(await stage.isComplete(opts.repoRoot, config.docs))
629
+ ? 'the run finished but the stage completion contract is not met — no usable artifact was produced'
630
+ : null;
631
+ if (!failure) {
632
+ stageDone = true;
633
+ break;
634
+ }
635
+
636
+ if (attempt > config.maxStageRetries) {
637
+ opts.log(
638
+ `stage ${stage.name}: ${failure}; exhausted ${config.maxStageRetries} auto-retry(ies). Stopping for a human.`,
639
+ );
640
+ break;
641
+ }
642
+
643
+ opts.log(`stage ${stage.name}: ${failure}; asking the model whether to retry…`);
644
+ const diagnosis = await diagnose({
645
+ model: opts.model,
646
+ timeoutMs: config.turnTimeoutMs,
647
+ stageName: stage.name,
648
+ stageGoal: basePrompt,
649
+ failure,
650
+ transcriptDir: res.transcriptDir,
651
+ attempt,
652
+ maxAttempts: config.maxStageRetries + 1,
653
+ });
654
+ // Fold the diagnosis call's own tokens into the stage cost (F6): it is a
655
+ // real model call made on behalf of this stage, so the cost table should
656
+ // not under-report by omitting it.
657
+ cost.tokensIn += diagnosis.usage?.inputTokens ?? 0;
658
+ cost.tokensOut += diagnosis.usage?.outputTokens ?? 0;
659
+ opts.log(`stage ${stage.name}: diagnosis → ${diagnosis.verdict} — ${diagnosis.reason}`);
660
+ if (diagnosis.verdict === 'abort') {
661
+ opts.log(`stage ${stage.name}: recovery supervisor recommends stopping for a human.`);
662
+ break;
663
+ }
664
+ guidance = diagnosis.guidance ?? `The previous attempt failed: ${failure}. ${diagnosis.reason}`;
163
665
  }
164
- // A successful run is not the same as a completed stage: an agent can
165
- // finish "done" with all gates green having only planned the work (seen
166
- // with the schematic stage: one header edit, ERC "clean" on an empty
167
- // sheet). Advancing anyway lets every later stage run against a design
168
- // that isn't there, so hold the pipeline until this stage's repo-state
169
- // contract is actually met.
170
- if (!(await stage.isComplete(opts.repoRoot, config.docs))) {
171
- opts.log(
172
- `stage ${stage.name}: run succeeded but the stage contract is not met yet (partial work committed); re-run copperhead create to continue this stage`,
173
- );
666
+
667
+ cost.wallMs = Date.now() - stageStart;
668
+ stageCosts.push(cost);
669
+
670
+ if (!stageDone) {
671
+ logResumePoint(opts, stage, i);
672
+ printCostTable(opts, stageCosts);
673
+ await writeRunReport(opts, stageCosts);
174
674
  return { ok: false, completed };
175
675
  }
176
676
  completed.push(stage.name);
677
+ await renderStageArtifacts(opts, stage.name, stageTranscriptDir);
678
+ await emitJlcpcbAfterOutputs(stage.name, opts);
679
+ logCumulative(opts, stageCosts);
177
680
  }
178
681
 
179
682
  const check = await runCheck(opts.repoRoot, opts.log);
180
683
  opts.log(check.ok ? 'create pipeline complete; all checks green' : 'create pipeline complete with check failures');
684
+ printCostTable(opts, stageCosts);
685
+ await writeRunReport(opts, stageCosts);
181
686
  return { ok: check.ok, completed };
182
687
  }