copperhead 0.3.0 → 0.5.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 (67) hide show
  1. package/NOTICE +5 -0
  2. package/README.md +72 -9
  3. package/dist/agent/ledger.js +7 -0
  4. package/dist/agent/ledger.js.map +1 -1
  5. package/dist/agent/loop.js +303 -34
  6. package/dist/agent/loop.js.map +1 -1
  7. package/dist/agent/prompts.js +3 -1
  8. package/dist/agent/prompts.js.map +1 -1
  9. package/dist/agent/providers/anthropic.js +28 -13
  10. package/dist/agent/providers/anthropic.js.map +1 -1
  11. package/dist/agent/providers/codex.js +292 -0
  12. package/dist/agent/providers/codex.js.map +1 -0
  13. package/dist/agent/render.js +170 -0
  14. package/dist/agent/render.js.map +1 -0
  15. package/dist/agent/runmeta.js +124 -0
  16. package/dist/agent/runmeta.js.map +1 -0
  17. package/dist/agent/tools.js +117 -16
  18. package/dist/agent/tools.js.map +1 -1
  19. package/dist/agent/transcript.js +23 -0
  20. package/dist/agent/transcript.js.map +1 -1
  21. package/dist/cli.js +47 -11
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/check.js +9 -2
  24. package/dist/commands/check.js.map +1 -1
  25. package/dist/commands/create.js +57 -3
  26. package/dist/commands/create.js.map +1 -1
  27. package/dist/commands/sync.js +3 -1
  28. package/dist/commands/sync.js.map +1 -1
  29. package/dist/config.js +16 -8
  30. package/dist/config.js.map +1 -1
  31. package/dist/kicad/cli.js +58 -8
  32. package/dist/kicad/cli.js.map +1 -1
  33. package/dist/memory/constraints.js +63 -3
  34. package/dist/memory/constraints.js.map +1 -1
  35. package/dist/memory/drift.js +31 -0
  36. package/dist/memory/drift.js.map +1 -1
  37. package/dist/memory/scaffold.js +2 -1
  38. package/dist/memory/scaffold.js.map +1 -1
  39. package/dist/memory/synap.js +152 -0
  40. package/dist/memory/synap.js.map +1 -0
  41. package/dist/util/git.js +125 -4
  42. package/dist/util/git.js.map +1 -1
  43. package/dist/util/preflight.js +24 -0
  44. package/dist/util/preflight.js.map +1 -0
  45. package/package.json +21 -6
  46. package/src/agent/ledger.ts +9 -1
  47. package/src/agent/loop.ts +333 -35
  48. package/src/agent/prompts.ts +3 -1
  49. package/src/agent/providers/anthropic.ts +40 -16
  50. package/src/agent/providers/codex.ts +339 -0
  51. package/src/agent/render.ts +194 -0
  52. package/src/agent/runmeta.ts +198 -0
  53. package/src/agent/tools.ts +119 -15
  54. package/src/agent/transcript.ts +49 -0
  55. package/src/agent/types.ts +1 -0
  56. package/src/cli.ts +51 -12
  57. package/src/commands/check.ts +9 -3
  58. package/src/commands/create.ts +61 -4
  59. package/src/commands/sync.ts +5 -0
  60. package/src/config.ts +29 -9
  61. package/src/kicad/cli.ts +60 -9
  62. package/src/memory/constraints.ts +90 -3
  63. package/src/memory/drift.ts +32 -0
  64. package/src/memory/scaffold.ts +2 -1
  65. package/src/memory/synap.ts +217 -0
  66. package/src/util/git.ts +134 -4
  67. package/src/util/preflight.ts +22 -0
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { runErc, runDrc } from '../kicad/cli.js';
5
5
  import { formatViolations, type CheckReport } from '../kicad/report.js';
6
- import { checkDrift, type DriftMismatch } from '../memory/drift.js';
6
+ import { checkDrift, emptySchematicWarning, type DriftMismatch } from '../memory/drift.js';
7
7
  import { loadConstraints, checkForbiddenPins, type ConstraintViolation } from '../memory/constraints.js';
8
8
  import { pinNets } from '../kicad/sexp.js';
9
9
  import { openspecValidate } from '../openspec/cli.js';
@@ -16,7 +16,7 @@ export interface CheckResult {
16
16
  ok: boolean;
17
17
  erc: { ok: boolean; violations: number } | null;
18
18
  drc: { ok: boolean; violations: number } | null;
19
- drift: { ok: boolean; mismatches: DriftMismatch[] };
19
+ drift: { ok: boolean; mismatches: DriftMismatch[]; warning?: string };
20
20
  openspec: { ok: boolean; detail: string } | null;
21
21
  constraints: { ok: boolean; violations: ConstraintViolation[] };
22
22
  }
@@ -41,9 +41,15 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
41
41
  }
42
42
 
43
43
  let drift: DriftMismatch[] = [];
44
+ let driftWarning: string | null = null;
44
45
  if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) {
45
46
  drift = await checkDrift(repoRoot, config.docs, config.schematic);
46
47
  log(drift.length === 0 ? 'drift ✓' : drift.map((m) => `drift: ${m.doc} claims "${m.claim}" but actual is "${m.actual}"`).join('\n'));
48
+ // Informational, never a failure: the zero-symbol drift exemption is for
49
+ // bootstrap, but an established repo that lost its schematic content
50
+ // deserves a visible note rather than a silent green.
51
+ driftWarning = await emptySchematicWarning(repoRoot, config.docs, config.schematic);
52
+ if (driftWarning) log(`drift warning: ${driftWarning}`);
47
53
  }
48
54
 
49
55
  let openspec: { ok: boolean; detail: string } | null = null;
@@ -78,7 +84,7 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
78
84
  ok,
79
85
  erc: erc ? { ok: erc.ok, violations: erc.violations.length } : null,
80
86
  drc: drc ? { ok: drc.ok, violations: drc.violations.length } : null,
81
- drift: { ok: drift.length === 0, mismatches: drift },
87
+ drift: { ok: drift.length === 0, mismatches: drift, ...(driftWarning ? { warning: driftWarning } : {}) },
82
88
  openspec,
83
89
  constraints: { ok: constraintViolations.length === 0, violations: constraintViolations },
84
90
  };
@@ -1,8 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
4
5
  import { loadConfig } from '../config.js';
5
- import { runAgentLoop } from '../agent/loop.js';
6
+ import { listSymbols } from '../kicad/sexp.js';
7
+ import { checkDrift } from '../memory/drift.js';
8
+ import { runAgentLoop, type BudgetExhaustedStats } from '../agent/loop.js';
9
+ import type { RunMetaInput } from '../agent/runmeta.js';
10
+ import type { ProgressRenderer } from '../agent/render.js';
6
11
  import { openspecInit } from '../openspec/cli.js';
7
12
  import { runCheck } from './check.js';
8
13
 
@@ -51,14 +56,36 @@ export const STAGES: Stage[] = [
51
56
  name: 'schematic',
52
57
  isComplete: async (root) => {
53
58
  const config = await loadConfig(root);
54
- return !!config.schematic && existsSync(path.join(root, config.schematic));
59
+ if (!config.schematic) return false;
60
+ const p = path.join(root, config.schematic);
61
+ if (!existsSync(p)) return false;
62
+ // Mere file existence is not completion: bootstrapping leaves a blank
63
+ // sheet on disk (a hand-scaffolded project, or the future fix for #19),
64
+ // and skipping this stage over a blank sheet cascades — layout and
65
+ // outputs then run against nothing. The stage's contract is "build the
66
+ // schematic from BOM.md", so completion means symbols exist AND the
67
+ // BOM/PINOUT tables agree with them (drift-clean); anything less keeps
68
+ // the stage active on the next resume so partial capture continues.
69
+ if (!(await listSymbols(p)).length) return false;
70
+ return (await checkDrift(root, config.docs, config.schematic)).length === 0;
55
71
  },
56
72
  prompt: () =>
57
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.',
58
74
  },
59
75
  {
60
76
  name: 'layout-draft',
61
- isComplete: (root, docs) => docHasContent(root, path.join(docs, 'LAYOUT.md'), '## Draft quality'),
77
+ isComplete: async (root, docs) => {
78
+ // The LAYOUT.md marker alone is not enough: `copperhead init` scaffolds
79
+ // LAYOUT.md with the literal "## Draft quality" heading, so an init-ed
80
+ // repo would skip this stage without a single footprint placed. Require
81
+ // a board with at least one footprint on it as well.
82
+ const config = await loadConfig(root);
83
+ if (!config.board) return false;
84
+ const p = path.join(root, config.board);
85
+ if (!existsSync(p)) return false;
86
+ if (!(await readFile(p, 'utf8')).includes('(footprint')) return false;
87
+ return docHasContent(root, path.join(docs, 'LAYOUT.md'), '## Draft quality');
88
+ },
62
89
  prompt: () =>
63
90
  'Stage 5: first-draft layout. Rule-driven placement written as real coordinates: connectors on edges, decoupling at IC pins, ESD at connectors, keepouts honored. Route power and short critical nets; leave the rest as ratsnest. Every routed net must pass run_drc. Then write the "## Draft quality" section in LAYOUT.md: exactly what is fine and what a human or specialist tool should redo. Non-optimal is acceptable; unlabeled non-optimal is not.',
64
91
  },
@@ -87,22 +114,31 @@ export interface CreateOptions {
87
114
  briefPath: string;
88
115
  model: string;
89
116
  interactive?: boolean;
117
+ /** Forwarded to each stage's run (attended continue-on-exhaustion prompt). */
118
+ onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
90
119
  log: (s: string) => void;
120
+ renderer?: ProgressRenderer;
121
+ /** Command-level metadata; stage and brief identity are filled in per stage. */
122
+ meta?: Omit<RunMetaInput, 'stage' | 'brief'>;
91
123
  }
92
124
 
93
125
  export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; completed: string[] }> {
94
126
  const brief = await readFile(path.resolve(opts.briefPath), 'utf8');
127
+ // Hashed from the content already in hand: a brief edited mid-pipeline shows
128
+ // up as a different sha256 in the next stage's metadata (AC-8.1).
129
+ const briefMeta = { path: opts.briefPath, sha256: createHash('sha256').update(brief).digest('hex') };
95
130
  const config = await loadConfig(opts.repoRoot);
96
131
  await openspecInit(opts.repoRoot);
97
132
  const completed: string[] = [];
98
133
 
99
- for (const stage of STAGES) {
134
+ for (const [i, stage] of STAGES.entries()) {
100
135
  if (await stage.isComplete(opts.repoRoot, config.docs)) {
101
136
  opts.log(`stage ${stage.name}: already complete (resuming past it)`);
102
137
  completed.push(stage.name);
103
138
  continue;
104
139
  }
105
140
  opts.log(`stage ${stage.name}: running`);
141
+ const stageTurns = config.stageMaxTurns?.[stage.name];
106
142
  const res = await runAgentLoop({
107
143
  repoRoot: opts.repoRoot,
108
144
  model: opts.model,
@@ -110,12 +146,33 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
110
146
  stagePrompt: stage.prompt(brief),
111
147
  interactive: opts.interactive ?? false,
112
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 } : {}),
113
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
+ },
114
159
  });
115
160
  if (res.outcome !== 'success') {
116
161
  opts.log(`stage ${stage.name} did not complete (${res.outcome}); re-run copperhead create to resume here`);
117
162
  return { ok: false, completed };
118
163
  }
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
+ );
174
+ return { ok: false, completed };
175
+ }
119
176
  completed.push(stage.name);
120
177
  }
121
178
 
@@ -7,6 +7,8 @@ import { loadConstraints, checkForbiddenPins } from '../memory/constraints.js';
7
7
  import { pinNets } from '../kicad/sexp.js';
8
8
  import { openspecValidate } from '../openspec/cli.js';
9
9
  import { runAgentLoop } from '../agent/loop.js';
10
+ import type { RunMetaInput } from '../agent/runmeta.js';
11
+ import type { ProgressRenderer } from '../agent/render.js';
10
12
 
11
13
  /**
12
14
  * `copperhead sync` (design D14): deterministic verify phase, then an optional
@@ -169,6 +171,7 @@ export async function syncResolve(
169
171
  report: SyncReport,
170
172
  model: string,
171
173
  log: (s: string) => void,
174
+ extras?: { renderer?: ProgressRenderer; meta?: RunMetaInput },
172
175
  ): Promise<{ ok: boolean }> {
173
176
  const reportText = formatSyncReport(report);
174
177
  const res = await runAgentLoop({
@@ -177,6 +180,8 @@ export async function syncResolve(
177
180
  request: 'resolve design-state inconsistencies found by copperhead sync',
178
181
  stagePrompt: `You are resolving drift found by the deterministic sync verifier. The inconsistency report is below. Truth precedence: the KiCad files are ground truth for as-built facts (fix the docs to match); openspec specs and SPEC.md budgets are ground truth for requirements. Do NOT touch anything listed as a requirement violation; those are for the human. Apply each proposed resolution, verify, and finish.\n\n${reportText}`,
179
182
  log,
183
+ ...(extras?.renderer ? { renderer: extras.renderer } : {}),
184
+ ...(extras?.meta ? { meta: extras.meta } : {}),
180
185
  });
181
186
  return { ok: res.outcome === 'success' };
182
187
  }
package/src/config.ts CHANGED
@@ -8,6 +8,8 @@ export interface CopperheadConfig {
8
8
  docs: string;
9
9
  model: string | null;
10
10
  maxTurns: number;
11
+ /** Per-stage overrides for the create pipeline, keyed by stage name. */
12
+ stageMaxTurns?: Record<string, number>;
11
13
  maxRepairCycles: number;
12
14
  budgets: Record<string, number>;
13
15
  /** Content hashes of generated docs, for init idempotency (AC-1.4). */
@@ -34,20 +36,36 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
34
36
  return { schematic: null, board: null, ...DEFAULTS };
35
37
  }
36
38
  const raw = JSON.parse(await readFile(p, 'utf8')) as Partial<CopperheadConfig>;
39
+ // A zero/negative/non-integer stage budget would exhaust the stage on turn 0;
40
+ // drop such entries rather than let a config typo stall the pipeline.
41
+ const stageMaxTurns = Object.fromEntries(
42
+ Object.entries(raw.stageMaxTurns ?? {}).filter(([, v]) => Number.isInteger(v) && v > 0),
43
+ );
37
44
  return {
38
45
  schematic: raw.schematic ?? null,
39
46
  board: raw.board ?? null,
40
47
  docs: raw.docs ?? DEFAULTS.docs,
41
48
  model: raw.model ?? null,
42
49
  maxTurns: raw.maxTurns ?? DEFAULTS.maxTurns,
50
+ ...(Object.keys(stageMaxTurns).length ? { stageMaxTurns } : {}),
43
51
  maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
44
52
  budgets: raw.budgets ?? {},
45
53
  ...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
46
54
  };
47
55
  }
48
56
 
57
+ /** Which level of the model-selection precedence chain won. */
58
+ export type ModelSource = 'flag' | 'env' | 'config' | 'openai-key' | 'anthropic-key';
59
+
60
+ export interface ResolvedModel {
61
+ model: string;
62
+ source: ModelSource;
63
+ }
64
+
49
65
  /**
50
66
  * Model selection precedence: flag > COPPERHEAD_MODEL > config > available key.
67
+ * The winning source is returned alongside the model so run metadata can
68
+ * record why a run used the model it did (AC-8.1/8.2).
51
69
  *
52
70
  * Accepted values (same set for `--model`, COPPERHEAD_MODEL, and `model` in
53
71
  * .copperhead/config.json):
@@ -55,6 +73,8 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
55
73
  * - `claude` : the Anthropic provider on its default model.
56
74
  * - `claude-*`: any Anthropic model id, passed through verbatim, e.g.
57
75
  * `claude-opus-4-5`. Anything starting with `claude` routes here.
76
+ * - `codex` : the locally installed Codex CLI using its saved ChatGPT login.
77
+ * - `codex:*` : Codex CLI with an explicit model id, e.g. `codex:gpt-5.6`.
58
78
  * - `gpt-5` : the OpenAI provider on its default model.
59
79
  * - anything else: sent to the OpenAI provider verbatim as a model id, e.g.
60
80
  * `gpt-5-mini` or `o3`.
@@ -62,16 +82,16 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
62
82
  * Routing is prefix-based, not a fixed list (see makeProvider in agent/loop.ts),
63
83
  * so a model released after this build still works without a code change. The
64
84
  * cost is that a typo like `claud-sonnet-5` silently routes to OpenAI and fails
65
- * there. The chosen provider must have its key set: ANTHROPIC_API_KEY for
66
- * `claude*`, OPENAI_API_KEY otherwise.
85
+ * there. Anthropic and direct OpenAI providers require their API keys; `codex`
86
+ * instead requires a locally installed and authenticated Codex CLI.
67
87
  */
68
- export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env): string {
69
- if (flag) return flag;
70
- if (env.COPPERHEAD_MODEL) return env.COPPERHEAD_MODEL;
71
- if (config.model) return config.model;
72
- if (env.OPENAI_API_KEY) return 'gpt-5';
73
- if (env.ANTHROPIC_API_KEY) return 'claude';
88
+ export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env): ResolvedModel {
89
+ if (flag) return { model: flag, source: 'flag' };
90
+ if (env.COPPERHEAD_MODEL) return { model: env.COPPERHEAD_MODEL, source: 'env' };
91
+ if (config.model) return { model: config.model, source: 'config' };
92
+ if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
93
+ if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
74
94
  throw new Error(
75
- 'no model configured: pass --model, set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
95
+ 'no model configured: pass --model codex (uses your local Codex login), set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
76
96
  );
77
97
  }
package/src/kicad/cli.ts CHANGED
@@ -3,11 +3,18 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeReport, type CheckReport } from './report.js';
6
+ import { PreflightError } from '../util/preflight.js';
6
7
 
7
- export class KicadCliMissingError extends Error {
8
+ export class KicadCliMissingError extends PreflightError {
8
9
  constructor() {
9
10
  super(
10
- 'kicad-cli not found on PATH. Install KiCad ≥ 8 (https://www.kicad.org/download/) and ensure kicad-cli is available.',
11
+ 'kicad-cli not found on PATH',
12
+ 'copperhead verifies every mutation with kicad-cli ERC/DRC; without it no edit can be checked, so no run can start',
13
+ [
14
+ 'install KiCad ≥ 8: https://www.kicad.org/download/',
15
+ 'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)',
16
+ 'confirm with "kicad-cli version", then rerun',
17
+ ],
11
18
  );
12
19
  this.name = 'KicadCliMissingError';
13
20
  }
@@ -32,16 +39,27 @@ async function runCheck(
32
39
  const out = path.join(dir, `${kind}.json`);
33
40
  const sub = kind === 'erc' ? ['sch', 'erc'] : ['pcb', 'drc'];
34
41
  try {
35
- await execa(
42
+ const res = await execa(
36
43
  'kicad-cli',
37
44
  [...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
38
45
  { reject: false },
39
- ).then((res) => {
40
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
41
- throw new KicadCliMissingError();
42
- }
43
- });
44
- const raw = JSON.parse(await readFile(out, 'utf8'));
46
+ );
47
+ if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
48
+ throw new KicadCliMissingError();
49
+ }
50
+ let raw: unknown;
51
+ try {
52
+ raw = JSON.parse(await readFile(out, 'utf8'));
53
+ } catch {
54
+ // No report on disk means kicad-cli bailed before checking — usually the
55
+ // design file itself failed to load (syntax/schema corruption). The
56
+ // raw readFile ENOENT told the agent nothing actionable; kicad-cli's
57
+ // own output at least names the failure.
58
+ const detail = [res.stderr, res.stdout].filter(Boolean).join('\n').trim();
59
+ throw new Error(
60
+ `kicad-cli ${kind} produced no report — the ${kind === 'erc' ? 'schematic' : 'board'} file likely fails to load in KiCad. kicad-cli output: ${detail || '(none)'}`,
61
+ );
62
+ }
45
63
  return normalizeReport(raw, kind);
46
64
  } finally {
47
65
  await rm(dir, { recursive: true, force: true });
@@ -52,6 +70,39 @@ export function runErc(schPath: string): Promise<CheckReport> {
52
70
  return runCheck('erc', schPath);
53
71
  }
54
72
 
73
+ /**
74
+ * Cheap loadability probe for a KiCad file: asks kicad-cli for a throwaway
75
+ * export and reports the failure text if the file won't load. Text edits on
76
+ * s-expression sources can silently corrupt the file; catching that at edit
77
+ * time (with KiCad's own error) beats an opaque failure at ERC/DRC time.
78
+ * Returns null when the file loads.
79
+ */
80
+ /**
81
+ * Only schematics and boards have a cheap standalone load probe. Project
82
+ * files and symbol/footprint libraries do not: feeding them to a sch/pcb
83
+ * export "probe" would reject perfectly good files.
84
+ */
85
+ export function isProbeableKicadFile(p: string): boolean {
86
+ return /\.kicad_(sch|pcb)$/.test(p);
87
+ }
88
+
89
+ export async function kicadLoadError(filePath: string): Promise<string | null> {
90
+ if (!isProbeableKicadFile(filePath)) return null;
91
+ const isSch = filePath.endsWith('.kicad_sch');
92
+ const dir = await mkdtemp(path.join(tmpdir(), 'copperhead-validate-'));
93
+ const args = isSch
94
+ ? ['sch', 'export', 'netlist', '--output', path.join(dir, 'probe.net'), filePath]
95
+ : ['pcb', 'export', 'pos', '--output', path.join(dir, 'probe.pos'), filePath];
96
+ try {
97
+ const res = await execa('kicad-cli', args, { reject: false });
98
+ if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
99
+ if (res.exitCode === 0) return null;
100
+ return [res.stderr, res.stdout].filter(Boolean).join('\n').trim() || `kicad-cli exited ${res.exitCode}`;
101
+ } finally {
102
+ await rm(dir, { recursive: true, force: true });
103
+ }
104
+ }
105
+
55
106
  export function runDrc(pcbPath: string): Promise<CheckReport> {
56
107
  return runCheck('drc', pcbPath);
57
108
  }
@@ -14,6 +14,13 @@ export interface Constraint {
14
14
  value?: string | number;
15
15
  source: string;
16
16
  affects: string[];
17
+ /**
18
+ * Subset of `affects` whose target artifact (schematic/board/BOM) did not
19
+ * exist when the constraint was recorded. No revisit obligation is open for
20
+ * these; they re-open at the start of the first run where the artifact
21
+ * exists (reopenDeferredAffects), then the marker is removed.
22
+ */
23
+ deferred?: string[];
17
24
  }
18
25
 
19
26
  export type ConstraintRegistry = Record<string, Constraint>;
@@ -28,6 +35,12 @@ export async function loadConstraints(repoRoot: string): Promise<ConstraintRegis
28
35
  return JSON.parse(await readFile(p, 'utf8')) as ConstraintRegistry;
29
36
  }
30
37
 
38
+ export async function saveConstraints(repoRoot: string, registry: ConstraintRegistry): Promise<void> {
39
+ const p = constraintsPath(repoRoot);
40
+ await mkdir(path.dirname(p), { recursive: true });
41
+ await writeFile(p, JSON.stringify(registry, null, 2) + '\n', 'utf8');
42
+ }
43
+
31
44
  export async function saveConstraint(
32
45
  repoRoot: string,
33
46
  key: string,
@@ -35,12 +48,86 @@ export async function saveConstraint(
35
48
  ): Promise<ConstraintRegistry> {
36
49
  const registry = await loadConstraints(repoRoot);
37
50
  registry[key] = constraint;
38
- const p = constraintsPath(repoRoot);
39
- await mkdir(path.dirname(p), { recursive: true });
40
- await writeFile(p, JSON.stringify(registry, null, 2) + '\n', 'utf8');
51
+ await saveConstraints(repoRoot, registry);
41
52
  return registry;
42
53
  }
43
54
 
55
+ /** The build artifact an `affects` item names, when it clearly names one. */
56
+ export type AffectsTarget = 'schematic' | 'board' | 'bom';
57
+
58
+ /**
59
+ * Items that name a not-yet-built artifact are deferrable; items that name a
60
+ * specific refdes, net, or doc fact return null and open a revisit obligation
61
+ * immediately. Misclassification is cheap in both directions: an unmatched
62
+ * artifact item just costs one ceremonial resolve_affected call (the old
63
+ * behavior for everything), and a deferred item still re-opens later.
64
+ */
65
+ export function classifyAffectsTarget(item: string): AffectsTarget | null {
66
+ if (/\b(bom|part[-\s]?selection|mpn)\b/i.test(item)) return 'bom';
67
+ if (/\b(schematic|pinout|pin[-\s]?assign\w*|strapping|netlist)\b/i.test(item)) return 'schematic';
68
+ if (
69
+ /\b(layout|stackup|rout(?:e|es|ing)?|vias?|pours?|keepouts?|zones?|copper|traces?|board|pcb|mounting|thermal|silkscreen|assembly|current[-\s]?carrying)\b/i.test(
70
+ item,
71
+ )
72
+ )
73
+ return 'board';
74
+ return null;
75
+ }
76
+
77
+ interface ArtifactConfig {
78
+ schematic: string | null;
79
+ board: string | null;
80
+ docs: string;
81
+ }
82
+
83
+ export function affectsTargetExists(target: AffectsTarget, repoRoot: string, config: ArtifactConfig): boolean {
84
+ switch (target) {
85
+ case 'schematic':
86
+ return !!config.schematic;
87
+ case 'board':
88
+ return !!config.board;
89
+ case 'bom':
90
+ return existsSync(path.join(repoRoot, config.docs, 'BOM.md'));
91
+ }
92
+ }
93
+
94
+ export interface ReopenedAffects {
95
+ key: string;
96
+ item: string;
97
+ }
98
+
99
+ /**
100
+ * Run-start hook: re-open the revisit obligations that were deferred while
101
+ * their target artifact did not exist. Each re-opened item is removed from the
102
+ * registry's `deferred` marker in the same pass, so it re-opens exactly once —
103
+ * from then on it lives in the run's ledger like any other obligation.
104
+ */
105
+ export async function reopenDeferredAffects(
106
+ repoRoot: string,
107
+ config: ArtifactConfig,
108
+ openObligation: (key: string, item: string) => void,
109
+ ): Promise<ReopenedAffects[]> {
110
+ const registry = await loadConstraints(repoRoot);
111
+ const reopened: ReopenedAffects[] = [];
112
+ for (const [key, c] of Object.entries(registry)) {
113
+ if (!c.deferred?.length) continue;
114
+ const stillDeferred: string[] = [];
115
+ for (const item of c.deferred) {
116
+ const target = classifyAffectsTarget(item);
117
+ if (target && !affectsTargetExists(target, repoRoot, config)) {
118
+ stillDeferred.push(item);
119
+ continue;
120
+ }
121
+ openObligation(key, item);
122
+ reopened.push({ key, item });
123
+ }
124
+ if (stillDeferred.length) c.deferred = stillDeferred;
125
+ else delete c.deferred;
126
+ }
127
+ if (reopened.length) await saveConstraints(repoRoot, registry);
128
+ return reopened;
129
+ }
130
+
44
131
  export interface ConstraintViolation {
45
132
  key: string;
46
133
  description: string;
@@ -35,10 +35,42 @@ export function parseMarkdownTables(md: string): TableRow[] {
35
35
  const isHeader = (row: TableRow): boolean =>
36
36
  row.cells.some((c) => /^(refdes|pin)$/i.test(c));
37
37
 
38
+ /**
39
+ * The zero-symbol carve-out in checkDrift is right for the create pipeline,
40
+ * but it would let `check` silently pass an established repo whose schematic
41
+ * was emptied by accident while BOM.md still lists parts. `check` calls this
42
+ * alongside checkDrift and reports the result as a warning, not a failure:
43
+ * an empty sheet with a populated BOM is either bootstrap (fine) or an
44
+ * accident (worth a human look), and only a human can tell which.
45
+ */
46
+ export async function emptySchematicWarning(
47
+ repoRoot: string,
48
+ docsDir: string,
49
+ schematic: string,
50
+ ): Promise<string | null> {
51
+ const symbols = await listSymbols(path.join(repoRoot, schematic));
52
+ if (symbols.length) return null;
53
+ const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
54
+ if (!existsSync(bomPath)) return null;
55
+ const refs = parseMarkdownTables(await readFile(bomPath, 'utf8'))
56
+ .filter((r) => !isHeader(r))
57
+ .map((r) => r.cells[0])
58
+ .filter(Boolean);
59
+ if (!refs.length) return null;
60
+ return `schematic has zero symbols but BOM.md lists ${refs.length} refdes; if this repo is not mid-bootstrap, the schematic may have been emptied accidentally`;
61
+ }
62
+
38
63
  export async function checkDrift(repoRoot: string, docsDir: string, schematic: string): Promise<DriftMismatch[]> {
39
64
  const mismatches: DriftMismatch[] = [];
40
65
  const schPath = path.join(repoRoot, schematic);
41
66
  const symbols = await listSymbols(schPath);
67
+ // A schematic with zero symbols is the bootstrap state: during the create
68
+ // pipeline the docs legitimately lead the schematic (part-selection writes
69
+ // BOM.md before any symbol exists), so comparing against an empty sheet
70
+ // deadlocks every docs-touching stage — or worse, teaches the agent to strip
71
+ // refdes from BOM.md to appease the gate (#21). Same reasoning as the
72
+ // "no schematic configured" carve-out in the check_drift tool.
73
+ if (!symbols.length) return mismatches;
42
74
  const byRef = new Map<string, SchematicSymbol>(symbols.map((s) => [s.ref, s]));
43
75
 
44
76
  const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
@@ -113,7 +113,8 @@ Generated by \`copperhead init\`; regenerated on re-runs (do not hand-edit).
113
113
 
114
114
  - \`schematic\` / \`board\`: repo-relative paths to the KiCad files copperhead operates on (currently: ${config.schematic ?? 'none'} / ${config.board ?? 'none'})
115
115
  - \`docs\`: design docs directory (docs-as-memory), default \`docs/\`
116
- - \`model\`: default model (\`gpt-5\` or \`claude\`); overridden by \`--model\` and \`COPPERHEAD_MODEL\`
116
+ - \`model\`: default provider/model (\`codex\`, \`gpt-5\`, or \`claude\`); overridden by \`--model\` and \`COPPERHEAD_MODEL\`
117
+ - local Codex uses the saved \`codex login\`; set \`COPPERHEAD_CODEX_PATH\` only when the executable is not on \`PATH\`
117
118
  - \`maxTurns\`: agent loop turn budget per run (default 40)
118
119
  - \`maxRepairCycles\`: ERC/DRC repair attempts before rollback (default 5)
119
120
  - \`budgets\`: free-form hard constraints (e.g. \`"sleep_current_uA": 25\`); surfaced verbatim into every run's system prompt