copperhead 0.7.0 → 0.8.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 (79) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +15 -5
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/providers/claude-code.js +1 -212
  13. package/dist/agent/providers/claude-code.js.map +1 -1
  14. package/dist/agent/providers/cursor.js +317 -0
  15. package/dist/agent/providers/cursor.js.map +1 -0
  16. package/dist/agent/providers/tool-protocol.js +205 -0
  17. package/dist/agent/providers/tool-protocol.js.map +1 -0
  18. package/dist/agent/render.js +32 -15
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/runmeta.js +4 -5
  21. package/dist/agent/runmeta.js.map +1 -1
  22. package/dist/agent/theme.js +84 -0
  23. package/dist/agent/theme.js.map +1 -0
  24. package/dist/cli.js +134 -13
  25. package/dist/cli.js.map +1 -1
  26. package/dist/commands/create.js +41 -32
  27. package/dist/commands/create.js.map +1 -1
  28. package/dist/commands/demo.js +146 -0
  29. package/dist/commands/demo.js.map +1 -0
  30. package/dist/commands/doctor.js +240 -0
  31. package/dist/commands/doctor.js.map +1 -0
  32. package/dist/commands/repl-inspect.js +342 -0
  33. package/dist/commands/repl-inspect.js.map +1 -0
  34. package/dist/commands/repl.js +618 -0
  35. package/dist/commands/repl.js.map +1 -0
  36. package/dist/config.js +5 -2
  37. package/dist/config.js.map +1 -1
  38. package/dist/kicad/cli.js +126 -6
  39. package/dist/kicad/cli.js.map +1 -1
  40. package/dist/util/cli-args.js +35 -0
  41. package/dist/util/cli-args.js.map +1 -0
  42. package/dist/util/dock.js +155 -0
  43. package/dist/util/dock.js.map +1 -0
  44. package/dist/util/git.js +129 -4
  45. package/dist/util/git.js.map +1 -1
  46. package/dist/util/live-prompt.js +542 -0
  47. package/dist/util/live-prompt.js.map +1 -0
  48. package/dist/util/paths.js +9 -0
  49. package/dist/util/paths.js.map +1 -1
  50. package/dist/util/select.js +172 -0
  51. package/dist/util/select.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/agent/animate.ts +90 -0
  54. package/src/agent/box.ts +99 -0
  55. package/src/agent/dock-renderer.ts +181 -0
  56. package/src/agent/logo.ts +23 -0
  57. package/src/agent/loop.ts +15 -5
  58. package/src/agent/providers/claude-code.ts +2 -216
  59. package/src/agent/providers/cursor.ts +364 -0
  60. package/src/agent/providers/tool-protocol.ts +212 -0
  61. package/src/agent/render.ts +33 -16
  62. package/src/agent/runmeta.ts +6 -7
  63. package/src/agent/theme.ts +91 -0
  64. package/src/cli.ts +139 -15
  65. package/src/commands/create.ts +81 -30
  66. package/src/commands/demo.ts +184 -0
  67. package/src/commands/doctor.ts +289 -0
  68. package/src/commands/repl-inspect.ts +353 -0
  69. package/src/commands/repl.ts +685 -0
  70. package/src/config.ts +6 -3
  71. package/src/kicad/cli.ts +132 -7
  72. package/src/layout/claude-ui-layout.md +72 -0
  73. package/src/layout/repl-ui-layout.md +139 -0
  74. package/src/util/cli-args.ts +42 -0
  75. package/src/util/dock.ts +161 -0
  76. package/src/util/git.ts +140 -4
  77. package/src/util/live-prompt.ts +595 -0
  78. package/src/util/paths.ts +10 -0
  79. package/src/util/select.ts +192 -0
@@ -14,6 +14,7 @@ import { diagnoseStageFailure, transcriptExcerpt, withTimeout, type StageDiagnos
14
14
  import type { Provider } from '../agent/types.js';
15
15
  import type { RunMetaInput } from '../agent/runmeta.js';
16
16
  import { fmtDuration, fmtTokens, type ProgressRenderer } from '../agent/render.js';
17
+ import { copper, dim, ok, stageLine, warn } from '../agent/theme.js';
17
18
  import { openspecInit } from '../openspec/cli.js';
18
19
  import { sweepStaleTempDirs, pruneHistoryDir } from '../util/tmp.js';
19
20
  import { assertDiskSpace, DEFAULT_MIN_FREE_BYTES } from '../util/preflight.js';
@@ -161,7 +162,7 @@ export interface CreateOptions {
161
162
  async function emitJlcpcbAfterOutputs(stageName: string, opts: CreateOptions): Promise<void> {
162
163
  if (stageName !== 'outputs') return;
163
164
  const out = await emitCreateJlcpcbBom(opts.repoRoot);
164
- if (out) opts.log(`stage outputs: emitted ${out} (JLCPCB assembly BOM)`);
165
+ if (out) opts.log(stageLine('outputs', `emitted ${out} (JLCPCB assembly BOM)`, 'ok'));
165
166
  }
166
167
 
167
168
  /** Stages whose output is a KiCad file worth rendering to an image (5.4). */
@@ -203,16 +204,26 @@ async function commitResumedStage(opts: CreateOptions, config: CopperheadConfig,
203
204
  const foreign = dirty.filter((f) => !isManagedPath(f, config));
204
205
  if (foreign.length) {
205
206
  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`,
207
+ stageLine(
208
+ stageName,
209
+ `already-complete work is uncommitted, but the tree also has non-copperhead changes ` +
210
+ `(${foreign.slice(0, 3).join(', ')}${foreign.length > 3 ? ', …' : ''}); leaving it uncommitted so nothing of yours is swept up`,
211
+ 'warn',
212
+ ),
208
213
  );
209
214
  return;
210
215
  }
211
216
  try {
212
217
  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})`);
218
+ opts.log(
219
+ stageLine(
220
+ stageName,
221
+ `committed already-complete work ${sha.slice(0, 10)} so a later rollback cannot wipe it (2.4)`,
222
+ 'ok',
223
+ ),
224
+ );
225
+ } catch (e) {
226
+ opts.log(stageLine(stageName, `could not commit resumed work (${(e as Error).message})`, 'err'));
216
227
  }
217
228
  }
218
229
 
@@ -243,12 +254,18 @@ async function renderStageArtifacts(opts: CreateOptions, stageName: string, tran
243
254
  try {
244
255
  await exportSvg(kind, path.join(opts.repoRoot, file), artifactsDir);
245
256
  rendered++;
246
- } catch (err) {
247
- opts.log(`stage ${stageName}: could not render ${kind} SVG (${(err as Error).message})`);
257
+ } catch (e) {
258
+ opts.log(stageLine(stageName, `could not render ${kind} SVG (${(e as Error).message})`, 'warn'));
248
259
  }
249
260
  }
250
261
  if (rendered) {
251
- opts.log(`stage ${stageName}: rendered ${rendered} SVG artifact(s) into ${path.relative(opts.repoRoot, artifactsDir)}/`);
262
+ opts.log(
263
+ stageLine(
264
+ stageName,
265
+ `rendered ${rendered} SVG artifact(s) into ${path.relative(opts.repoRoot, artifactsDir)}/`,
266
+ 'ok',
267
+ ),
268
+ );
252
269
  }
253
270
  }
254
271
 
@@ -331,10 +348,14 @@ function resumeCommand(opts: CreateOptions): string {
331
348
  */
332
349
  function logResumePoint(opts: CreateOptions, stage: Stage, index: number): void {
333
350
  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
351
  opts.log(
337
- ` (${index} stage(s) already complete are detected from repo state and skipped; it resumes at ${stage.name}.)`,
352
+ warn(`⏸ stopped at stage ${index + 1}/${STAGES.length} (${stage.name}). To resume from here, run:`),
353
+ );
354
+ opts.log(copper(` ${resumeCommand(opts)}`));
355
+ opts.log(
356
+ dim(
357
+ ` (${index} stage(s) already complete are detected from repo state and skipped; it resumes at ${stage.name}.)`,
358
+ ),
338
359
  );
339
360
  }
340
361
 
@@ -380,16 +401,21 @@ function printCostTable(opts: CreateOptions, costs: StageCost[]): void {
380
401
  ` ${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
402
  const rule = ` ${'-'.repeat(w.stage)} ${'-'.repeat(w.wall)} ${'-'.repeat(w.turns)} ${'-'.repeat(w.out)} ${'-'.repeat(w.cache)}`;
382
403
  opts.log('');
383
- opts.log('Per-stage cost summary (5.2):');
384
- opts.log(line(header));
385
- opts.log(rule);
404
+ opts.log(copper('Per-stage cost summary'));
405
+ opts.log(dim(line(header)));
406
+ opts.log(dim(rule));
386
407
  for (const r of rows) opts.log(line(r));
387
408
  if (total) {
388
- opts.log(rule);
389
- opts.log(line(total));
409
+ opts.log(dim(rule));
410
+ opts.log(boldTotal(line(total)));
390
411
  }
391
412
  }
392
413
 
414
+ function boldTotal(s: string): string {
415
+ // TOTAL row: keep digits readable, accent only the label when color is on.
416
+ return s.replace(/^(\s*)TOTAL/, (_, sp: string) => `${sp}${copper('TOTAL')}`);
417
+ }
418
+
393
419
  /** Sum the cost of the stages that actually ran this invocation (resumed stages
394
420
  * cost nothing). Shared by the cumulative line and the end-of-run report (5.6). */
395
421
  function ranTotals(stageCosts: StageCost[]): {
@@ -422,8 +448,10 @@ function logCumulative(opts: CreateOptions, stageCosts: StageCost[]): void {
422
448
  const t = ranTotals(stageCosts);
423
449
  if (!t.turns && !t.wallMs) return; // nothing has actually run yet (all resumed)
424
450
  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`,
451
+ dim(
452
+ `pipeline so far: ${stageCosts.length}/${STAGES.length} stages · ${fmtDuration(t.wallMs)} · ` +
453
+ `${fmtTokens(t.tokensOut)} out tokens · ${cachePct(t.cacheHits, t.turns)}% cache hits`,
454
+ ),
427
455
  );
428
456
  }
429
457
 
@@ -533,11 +561,11 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
533
561
  // so a concurrent run's fresh dirs are never touched; best-effort, so it never
534
562
  // blocks a run (I8).
535
563
  const swept = await sweepStaleTempDirs(Date.now());
536
- if (swept.length) opts.log(`startup: reclaimed ${swept.length} stale temp dir(s) from earlier runs`);
564
+ if (swept.length) opts.log(dim(`startup: reclaimed ${swept.length} stale temp dir(s) from earlier runs`));
537
565
  // Cap the gitignored .history/ so KiCad local history cannot grow unbounded
538
566
  // across a long run and fill the disk (4.1, I8). Best-effort; keeps the newest.
539
567
  const pruned = await pruneHistoryDir(opts.repoRoot);
540
- if (pruned) opts.log(`startup: pruned ${pruned} old .history/ entrie(s) to cap local-history growth`);
568
+ if (pruned) opts.log(dim(`startup: pruned ${pruned} old .history/ entrie(s) to cap local-history growth`));
541
569
  await openspecInit(opts.repoRoot);
542
570
  const completed: string[] = [];
543
571
  const stageCosts: StageCost[] = [];
@@ -550,10 +578,12 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
550
578
  // contract can eventually be met. No-op once a project exists.
551
579
  if (stage.name === 'schematic') {
552
580
  const created = await bootstrapKicadProject(opts.repoRoot, brief);
553
- if (created) opts.log(`stage schematic: scaffolded empty KiCad project (${created} + board + project), wired into config`);
581
+ if (created) {
582
+ opts.log(stageLine('schematic', `scaffolded empty KiCad project (${created} + board + project), wired into config`));
583
+ }
554
584
  }
555
585
  if (await stage.isComplete(opts.repoRoot, config.docs)) {
556
- opts.log(`stage ${stage.name}: already complete (resuming past it)`);
586
+ opts.log(stageLine(stage.name, 'already complete (resuming past it)', 'ok'));
557
587
  await commitResumedStage(opts, config, stage.name);
558
588
  completed.push(stage.name);
559
589
  stageCosts.push({ name: stage.name, resumed: true, wallMs: 0, turns: 0, tokensIn: 0, tokensOut: 0, cacheHits: 0 });
@@ -585,9 +615,16 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
585
615
  // whenever the project already exists.
586
616
  if (stage.name === 'schematic') {
587
617
  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`);
618
+ if (rescaffolded && attempt > 1) {
619
+ opts.log(stageLine('schematic', 're-scaffolded empty KiCad project after rollback, wired into config'));
620
+ }
589
621
  }
590
- opts.log(`stage ${stage.name}: running${attempt > 1 ? ` (attempt ${attempt}/${config.maxStageRetries + 1})` : ''}`);
622
+ opts.log(
623
+ stageLine(
624
+ stage.name,
625
+ `running${attempt > 1 ? ` (attempt ${attempt}/${config.maxStageRetries + 1})` : ''}`,
626
+ ),
627
+ );
591
628
  const res = await runAgentLoop({
592
629
  repoRoot: opts.repoRoot,
593
630
  model: opts.model,
@@ -635,12 +672,16 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
635
672
 
636
673
  if (attempt > config.maxStageRetries) {
637
674
  opts.log(
638
- `stage ${stage.name}: ${failure}; exhausted ${config.maxStageRetries} auto-retry(ies). Stopping for a human.`,
675
+ stageLine(
676
+ stage.name,
677
+ `${failure}; exhausted ${config.maxStageRetries} auto-retry(ies). Stopping for a human.`,
678
+ 'err',
679
+ ),
639
680
  );
640
681
  break;
641
682
  }
642
683
 
643
- opts.log(`stage ${stage.name}: ${failure}; asking the model whether to retry…`);
684
+ opts.log(stageLine(stage.name, `${failure}; asking the model whether to retry…`, 'warn'));
644
685
  const diagnosis = await diagnose({
645
686
  model: opts.model,
646
687
  timeoutMs: config.turnTimeoutMs,
@@ -656,9 +697,15 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
656
697
  // not under-report by omitting it.
657
698
  cost.tokensIn += diagnosis.usage?.inputTokens ?? 0;
658
699
  cost.tokensOut += diagnosis.usage?.outputTokens ?? 0;
659
- opts.log(`stage ${stage.name}: diagnosis → ${diagnosis.verdict} — ${diagnosis.reason}`);
700
+ opts.log(
701
+ stageLine(
702
+ stage.name,
703
+ `diagnosis → ${diagnosis.verdict} — ${diagnosis.reason}`,
704
+ diagnosis.verdict === 'abort' ? 'err' : 'warn',
705
+ ),
706
+ );
660
707
  if (diagnosis.verdict === 'abort') {
661
- opts.log(`stage ${stage.name}: recovery supervisor recommends stopping for a human.`);
708
+ opts.log(stageLine(stage.name, 'recovery supervisor recommends stopping for a human.', 'err'));
662
709
  break;
663
710
  }
664
711
  guidance = diagnosis.guidance ?? `The previous attempt failed: ${failure}. ${diagnosis.reason}`;
@@ -680,7 +727,11 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
680
727
  }
681
728
 
682
729
  const check = await runCheck(opts.repoRoot, opts.log);
683
- opts.log(check.ok ? 'create pipeline complete; all checks green' : 'create pipeline complete with check failures');
730
+ opts.log(
731
+ check.ok
732
+ ? ok('create pipeline complete; all checks green')
733
+ : warn('create pipeline complete with check failures'),
734
+ );
684
735
  printCostTable(opts, stageCosts);
685
736
  await writeRunReport(opts, stageCosts);
686
737
  return { ok: check.ok, completed };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * End-to-end demo: scaffold a tiny git repo and run `create` against the
3
+ * USB-C power breakout brief (same path as `npm run demo:simple`).
4
+ */
5
+
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { existsSync } from 'node:fs';
9
+ import { mkdir, writeFile, readFile, readdir, appendFile } from 'node:fs/promises';
10
+ import { execa } from 'execa';
11
+ import type { ProgressRenderer } from '../agent/render.js';
12
+ import type { RunMetaInput } from '../agent/runmeta.js';
13
+ import type { BudgetExhaustedStats } from '../agent/loop.js';
14
+ import { copper, dim, ok } from '../agent/theme.js';
15
+ import { traceRule } from '../agent/animate.js';
16
+ import { shortPath } from '../util/paths.js';
17
+ import { runCreate } from './create.js';
18
+
19
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
20
+ const DEFAULT_BRIEF = path.join(PKG_ROOT, 'examples/simple/usb-c-breakout.md');
21
+ const DEFAULT_DEMO_DIR = path.join(PKG_ROOT, 'demo-runs/usb-c-breakout');
22
+
23
+ export interface DemoOptions {
24
+ model: string;
25
+ modelSource: RunMetaInput['modelSource'];
26
+ version: string;
27
+ kicadCliVersion: string;
28
+ interactive?: boolean;
29
+ /** Override demo repo (defaults to demo-runs/usb-c-breakout or COPPERHEAD_DEMO_DIR). */
30
+ demoDir?: string;
31
+ briefPath?: string;
32
+ log?: (line: string) => void;
33
+ renderer: ProgressRenderer;
34
+ onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
35
+ }
36
+
37
+ /** Resolve the packaged USB-C brief; throws a clear error if the install is incomplete. */
38
+ export function defaultBriefPath(): string {
39
+ if (!existsSync(DEFAULT_BRIEF)) {
40
+ throw new Error(
41
+ `demo brief missing at ${DEFAULT_BRIEF}; reinstall copperhead or run from a full checkout`,
42
+ );
43
+ }
44
+ return DEFAULT_BRIEF;
45
+ }
46
+
47
+ export function defaultDemoDir(): string {
48
+ return process.env.COPPERHEAD_DEMO_DIR ?? DEFAULT_DEMO_DIR;
49
+ }
50
+
51
+ /** Marker identifying a directory scaffolded by `copperhead demo`. */
52
+ const DEMO_MARKER = path.join('.copperhead', 'demo-repo');
53
+
54
+ /**
55
+ * Prepare a clean-enough git repo for the create pipeline (git init, ignore
56
+ * runs/, baseline config + commit). Mirrors scripts/demo-simple.sh.
57
+ */
58
+ export async function scaffoldDemoRepo(demoDir: string): Promise<void> {
59
+ await mkdir(demoDir, { recursive: true });
60
+
61
+ // Fail closed before touching git: only scaffold into an empty directory
62
+ // or one this function created earlier (identified by the marker file).
63
+ // Anything else risks git-initializing and committing into a directory
64
+ // the user cares about.
65
+ const entries = await readdir(demoDir);
66
+ if (entries.length > 0 && !existsSync(path.join(demoDir, DEMO_MARKER))) {
67
+ throw new Error(
68
+ `refusing to scaffold the demo repo in ${demoDir}: the directory is not empty and was not created by copperhead demo; use an empty directory (or point COPPERHEAD_DEMO_DIR elsewhere)`,
69
+ );
70
+ }
71
+
72
+ const git = async (...args: string[]) => execa('git', args, { cwd: demoDir });
73
+
74
+ if (!existsSync(path.join(demoDir, '.git'))) {
75
+ await git('init', '-q');
76
+ }
77
+
78
+ const hasName = await execa('git', ['config', 'user.name'], { cwd: demoDir, reject: false });
79
+ if (hasName.exitCode !== 0) await git('config', 'user.name', 'copperhead demo');
80
+ const hasEmail = await execa('git', ['config', 'user.email'], { cwd: demoDir, reject: false });
81
+ if (hasEmail.exitCode !== 0) await git('config', 'user.email', 'demo@copperhead.local');
82
+
83
+ const gi = path.join(demoDir, '.gitignore');
84
+ if (!existsSync(gi)) await writeFile(gi, '', 'utf8');
85
+ const giText = await readFile(gi, 'utf8');
86
+ if (!giText.split('\n').includes('.copperhead/runs/')) {
87
+ await appendFile(gi, (giText.endsWith('\n') || giText === '' ? '' : '\n') + '.copperhead/runs/\n');
88
+ }
89
+
90
+ const cfgDir = path.join(demoDir, '.copperhead');
91
+ await mkdir(cfgDir, { recursive: true });
92
+ const marker = path.join(demoDir, DEMO_MARKER);
93
+ if (!existsSync(marker)) await writeFile(marker, 'created by copperhead demo\n', 'utf8');
94
+ const cfg = path.join(cfgDir, 'config.json');
95
+ if (!existsSync(cfg)) {
96
+ // Create stages need a larger turn budget than a single `do` edit.
97
+ await writeFile(
98
+ cfg,
99
+ `${JSON.stringify({ docs: 'docs/', maxTurns: 100, maxRepairCycles: 5 }, null, 2)}\n`,
100
+ 'utf8',
101
+ );
102
+ }
103
+
104
+ await git('add', '.gitignore', '.copperhead/config.json');
105
+ const head = await execa('git', ['rev-parse', '--verify', 'HEAD'], { cwd: demoDir, reject: false });
106
+ if (head.exitCode !== 0) {
107
+ await git('commit', '-q', '-m', 'demo: initialize repository');
108
+ } else {
109
+ const staged = await execa('git', ['diff', '--cached', '--quiet'], { cwd: demoDir, reject: false });
110
+ if (staged.exitCode !== 0) {
111
+ await git('commit', '-q', '-m', 'demo: update demo scaffolding');
112
+ }
113
+ }
114
+ }
115
+
116
+ /** What copperhead is — printed by `/demo` and `copperhead demo --tour`. */
117
+ export function demoTourText(): string {
118
+ return [
119
+ '',
120
+ copper(' What copperhead does'),
121
+ traceRule(28),
122
+ '',
123
+ dim(' Cursor for circuit boards. You describe a change; the agent edits'),
124
+ dim(' real KiCad files, keeps design docs in sync, and verifies with'),
125
+ dim(' kicad-cli ERC/DRC before anything is committed.'),
126
+ '',
127
+ copper(' The loop'),
128
+ traceRule(16),
129
+ '',
130
+ ` ${copper('1.')} ${dim('Propose')} write an OpenSpec change (edit tools stay locked until it validates)`,
131
+ ` ${copper('2.')} ${dim('Edit')} anchored edits to .kicad_sch / .kicad_pcb + docs`,
132
+ ` ${copper('3.')} ${dim('Verify')} run ERC (and DRC if the board changed); repair or roll back`,
133
+ ` ${copper('4.')} ${dim('Remember')} DECISIONS.md + CHANGELOG + a run summary next to the transcript`,
134
+ '',
135
+ copper(' Try it'),
136
+ traceRule(14),
137
+ '',
138
+ ` ${copper('copperhead demo')} ${dim('full create pipeline (USB-C breakout)')}`,
139
+ ` ${copper('copperhead')} ${dim('interactive shell — type a change request')}`,
140
+ ` ${copper('copperhead do "add an LED"')} ${dim('one-shot edit on the current repo')}`,
141
+ '',
142
+ copper(' Example prompts'),
143
+ traceRule(22),
144
+ '',
145
+ dim(' • add reverse-polarity protection on VIN'),
146
+ dim(' • rename net KEY_DAH to KEY_DASH'),
147
+ dim(' • move the key input to a different RTC-capable pin'),
148
+ '',
149
+ ].join('\n');
150
+ }
151
+
152
+ export async function runDemo(opts: DemoOptions): Promise<{ ok: boolean; demoDir: string }> {
153
+ const log = opts.log ?? ((l: string) => console.log(l));
154
+ const demoDir = path.resolve(opts.demoDir ?? defaultDemoDir());
155
+ const briefPath = path.resolve(opts.briefPath ?? defaultBriefPath());
156
+
157
+ log('');
158
+ log(` ${copper('copperhead demo')} ${dim('USB-C power breakout')}`);
159
+ log(` ${dim('repo')} ${shortPath(demoDir)}`);
160
+ log(` ${dim('brief')} ${shortPath(briefPath)}`);
161
+ log('');
162
+
163
+ await scaffoldDemoRepo(demoDir);
164
+ log(ok(' scaffold ready'));
165
+ log('');
166
+
167
+ const res = await runCreate({
168
+ repoRoot: demoDir,
169
+ briefPath,
170
+ model: opts.model,
171
+ interactive: opts.interactive ?? false,
172
+ ...(opts.onBudgetExhausted ? { onBudgetExhausted: opts.onBudgetExhausted } : {}),
173
+ log,
174
+ renderer: opts.renderer,
175
+ meta: {
176
+ command: 'create',
177
+ modelSource: opts.modelSource,
178
+ version: opts.version,
179
+ kicadCliVersion: opts.kicadCliVersion,
180
+ },
181
+ });
182
+
183
+ return { ok: res.ok, demoDir };
184
+ }