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.
- package/README.md +34 -1
- package/dist/agent/loop.js +130 -15
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +2 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +466 -0
- package/dist/agent/providers/claude-code.js.map +1 -0
- package/dist/agent/providers/openai.js +30 -10
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/recovery.js +148 -0
- package/dist/agent/recovery.js.map +1 -0
- package/dist/agent/render.js +17 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/response-cache.js +81 -0
- package/dist/agent/response-cache.js.map +1 -0
- package/dist/agent/tools.js +61 -4
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +47 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +486 -35
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/export.js +90 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/config.js +33 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/bom-export.js +240 -0
- package/dist/kicad/bom-export.js.map +1 -0
- package/dist/kicad/bootstrap.js +166 -0
- package/dist/kicad/bootstrap.js.map +1 -0
- package/dist/kicad/fab.js +94 -0
- package/dist/kicad/fab.js.map +1 -0
- package/dist/kicad/spice.js +306 -0
- package/dist/kicad/spice.js.map +1 -0
- package/dist/kicad/symlib.js +228 -0
- package/dist/kicad/symlib.js.map +1 -0
- package/dist/memory/bom-table.js +232 -0
- package/dist/memory/bom-table.js.map +1 -0
- package/dist/memory/drift.js +33 -27
- package/dist/memory/drift.js.map +1 -1
- package/dist/util/git.js +37 -1
- package/dist/util/git.js.map +1 -1
- package/dist/util/preflight.js +37 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/retry.js +23 -0
- package/dist/util/retry.js.map +1 -1
- package/dist/util/tmp.js +119 -0
- package/dist/util/tmp.js.map +1 -0
- package/package.json +6 -2
- package/src/agent/loop.ts +148 -15
- package/src/agent/prompts.ts +2 -1
- package/src/agent/providers/claude-code.ts +550 -0
- package/src/agent/providers/openai.ts +33 -16
- package/src/agent/recovery.ts +162 -0
- package/src/agent/render.ts +28 -1
- package/src/agent/response-cache.ts +80 -0
- package/src/agent/tools.ts +62 -4
- package/src/agent/transcript.ts +1 -0
- package/src/agent/types.ts +18 -0
- package/src/cli.ts +52 -2
- package/src/commands/create.ts +543 -38
- package/src/commands/export.ts +117 -0
- package/src/config.ts +54 -6
- package/src/kicad/bom-export.ts +321 -0
- package/src/kicad/bootstrap.ts +181 -0
- package/src/kicad/fab.ts +121 -0
- package/src/kicad/spice.ts +399 -0
- package/src/kicad/symlib.ts +248 -0
- package/src/memory/bom-table.ts +249 -0
- package/src/memory/drift.ts +42 -32
- package/src/util/git.ts +37 -1
- package/src/util/preflight.ts +44 -0
- package/src/util/retry.ts +29 -0
- package/src/util/tmp.ts +113 -0
package/src/commands/create.ts
CHANGED
|
@@ -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
|
|
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) =>
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
opts
|
|
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
|
}
|