copperhead 0.8.1 → 0.10.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 (105) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +13 -5
  3. package/dist/agent/filetools.js +24 -1
  4. package/dist/agent/filetools.js.map +1 -1
  5. package/dist/agent/ledger.js +24 -0
  6. package/dist/agent/ledger.js.map +1 -1
  7. package/dist/agent/loop.js +67 -62
  8. package/dist/agent/loop.js.map +1 -1
  9. package/dist/agent/prompts.js +4 -3
  10. package/dist/agent/prompts.js.map +1 -1
  11. package/dist/agent/providers/openai.js +28 -6
  12. package/dist/agent/providers/openai.js.map +1 -1
  13. package/dist/agent/providers/tool-protocol.js +21 -0
  14. package/dist/agent/providers/tool-protocol.js.map +1 -1
  15. package/dist/agent/recovery.js +95 -1
  16. package/dist/agent/recovery.js.map +1 -1
  17. package/dist/agent/response-cache.js +18 -2
  18. package/dist/agent/response-cache.js.map +1 -1
  19. package/dist/agent/tools.js +185 -1
  20. package/dist/agent/tools.js.map +1 -1
  21. package/dist/agent/transcript.js +2 -0
  22. package/dist/agent/transcript.js.map +1 -1
  23. package/dist/cli.js +77 -2
  24. package/dist/cli.js.map +1 -1
  25. package/dist/commands/check.js +33 -1
  26. package/dist/commands/check.js.map +1 -1
  27. package/dist/commands/create.js +282 -26
  28. package/dist/commands/create.js.map +1 -1
  29. package/dist/commands/doctor.js +211 -11
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/config.js +61 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/kicad/bootstrap.js +24 -3
  34. package/dist/kicad/bootstrap.js.map +1 -1
  35. package/dist/kicad/cli.js +7 -26
  36. package/dist/kicad/cli.js.map +1 -1
  37. package/dist/kicad/dossier.js +207 -0
  38. package/dist/kicad/dossier.js.map +1 -0
  39. package/dist/kicad/draft/draft.js +132 -0
  40. package/dist/kicad/draft/draft.js.map +1 -0
  41. package/dist/kicad/draft/engine.js +2389 -0
  42. package/dist/kicad/draft/engine.js.map +1 -0
  43. package/dist/kicad/draft/ir.js +368 -0
  44. package/dist/kicad/draft/ir.js.map +1 -0
  45. package/dist/kicad/draft/symsource.js +490 -0
  46. package/dist/kicad/draft/symsource.js.map +1 -0
  47. package/dist/kicad/emit.js +181 -0
  48. package/dist/kicad/emit.js.map +1 -0
  49. package/dist/kicad/fab.js +13 -0
  50. package/dist/kicad/fab.js.map +1 -1
  51. package/dist/kicad/legibility.js +561 -0
  52. package/dist/kicad/legibility.js.map +1 -0
  53. package/dist/kicad/score.js +261 -0
  54. package/dist/kicad/score.js.map +1 -0
  55. package/dist/kicad/sexp.js +262 -10
  56. package/dist/kicad/sexp.js.map +1 -1
  57. package/dist/kicad/symlib.js +346 -16
  58. package/dist/kicad/symlib.js.map +1 -1
  59. package/dist/memory/bom-table.js +108 -34
  60. package/dist/memory/bom-table.js.map +1 -1
  61. package/dist/memory/scaffold.js +6 -0
  62. package/dist/memory/scaffold.js.map +1 -1
  63. package/dist/openspec/cli.js +2 -1
  64. package/dist/openspec/cli.js.map +1 -1
  65. package/dist/util/preflight.js +17 -0
  66. package/dist/util/preflight.js.map +1 -1
  67. package/dist/util/redact.js +12 -2
  68. package/dist/util/redact.js.map +1 -1
  69. package/package.json +9 -7
  70. package/src/agent/filetools.ts +26 -1
  71. package/src/agent/ledger.ts +24 -0
  72. package/src/agent/loop.ts +88 -65
  73. package/src/agent/prompts.ts +4 -3
  74. package/src/agent/providers/openai.ts +38 -4
  75. package/src/agent/providers/tool-protocol.ts +22 -0
  76. package/src/agent/recovery.ts +94 -1
  77. package/src/agent/response-cache.ts +17 -1
  78. package/src/agent/tools.ts +189 -1
  79. package/src/agent/transcript.ts +6 -0
  80. package/src/cli.ts +73 -2
  81. package/src/commands/check.ts +51 -1
  82. package/src/commands/create.ts +278 -22
  83. package/src/commands/doctor.ts +219 -12
  84. package/src/config.ts +107 -2
  85. package/src/kicad/bootstrap.ts +24 -3
  86. package/src/kicad/cli.ts +6 -19
  87. package/src/kicad/dossier.ts +217 -0
  88. package/src/kicad/draft/draft.ts +171 -0
  89. package/src/kicad/draft/engine.ts +2466 -0
  90. package/src/kicad/draft/ir.ts +416 -0
  91. package/src/kicad/draft/symsource.ts +535 -0
  92. package/src/kicad/emit.ts +236 -0
  93. package/src/kicad/fab.ts +15 -0
  94. package/src/kicad/legibility.ts +646 -0
  95. package/src/kicad/score.ts +323 -0
  96. package/src/kicad/sexp.ts +339 -10
  97. package/src/kicad/symlib.ts +364 -18
  98. package/src/memory/bom-table.ts +119 -31
  99. package/src/memory/scaffold.ts +6 -0
  100. package/src/openspec/cli.ts +3 -2
  101. package/src/util/preflight.ts +18 -0
  102. package/src/util/redact.ts +12 -2
  103. package/dist/memory/synap.js +0 -152
  104. package/dist/memory/synap.js.map +0 -1
  105. package/src/memory/synap.ts +0 -217
@@ -1,10 +1,21 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { execa } from 'execa';
3
4
  import { existsSync } from 'node:fs';
4
5
  import path from 'node:path';
5
- import { DEFAULTS, loadConfig, resolveModel, type CopperheadConfig } from '../config.js';
6
+ import {
7
+ DEFAULTS,
8
+ DEFAULT_API_KEY_ENV,
9
+ isLocalEndpoint,
10
+ loadConfig,
11
+ resolveCompatSettings,
12
+ resolveModel,
13
+ type CompatSettings,
14
+ type CopperheadConfig,
15
+ } from '../config.js';
6
16
  import { kicadCliVersion } from '../kicad/cli.js';
7
17
  import { redactSecrets } from '../util/redact.js';
18
+ import { isNotFoundError } from '../util/preflight.js';
8
19
 
9
20
  const execFileP = promisify(execFile);
10
21
 
@@ -15,7 +26,7 @@ const execFileP = promisify(execFile);
15
26
  * at the model provider). Each probe fails soft: a missing tool is a reported
16
27
  * `fail`, never a thrown error, so `doctor` still prints the rest of the report.
17
28
  */
18
- export type DoctorStatus = 'ok' | 'fail' | 'info';
29
+ export type DoctorStatus = 'ok' | 'fail' | 'warn' | 'info';
19
30
 
20
31
  export interface DoctorCheck {
21
32
  name: string;
@@ -35,6 +46,7 @@ export interface DoctorDeps {
35
46
  nodeVersion: string;
36
47
  kicadVersion: () => Promise<string>;
37
48
  gitVersion: () => Promise<string>;
49
+ openspecVersion: () => Promise<string>;
38
50
  env: NodeJS.ProcessEnv;
39
51
  }
40
52
 
@@ -45,6 +57,15 @@ function defaultDeps(): DoctorDeps {
45
57
  // `git --version` prints "git version 2.34.1"; keep only the number, the
46
58
  // report already labels the row "git".
47
59
  gitVersion: async () => (await execFileP('git', ['--version'])).stdout.trim().replace(/^git version\s+/, ''),
60
+ /**
61
+ * Probes `openspec --version` via execa, the same probe shape as the real
62
+ * call site (src/openspec/cli.ts): execa resolves Windows .cmd/.bat shims
63
+ * via cross-spawn without a shell, so a missing binary still yields ENOENT
64
+ * (what isNotFoundError expects) on every platform, instead of a
65
+ * shell-reported exit 127/"not found".
66
+ * @returns the trimmed stdout of `openspec --version` (e.g. "1.8.0").
67
+ */
68
+ openspecVersion: async () => (await execa('openspec', ['--version'])).stdout.trim(),
48
69
  env: process.env,
49
70
  };
50
71
  }
@@ -90,6 +111,41 @@ async function gitCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
90
111
  }
91
112
  }
92
113
 
114
+ /**
115
+ * Report whether the `openspec` CLI is reachable, needed for `validate_change`
116
+ * and the `create` pipeline. Fails soft like `kicadCheck`/`gitCheck`: a
117
+ * missing or erroring probe is returned as a `fail` check, never thrown.
118
+ * @param probe resolves the openspec version string, or rejects if the CLI
119
+ * can't be run (e.g. not found on PATH).
120
+ * @returns an `ok` check with the version on success; a `fail` check with an
121
+ * install hint when `probe` rejects with a not-found error (per
122
+ * `isNotFoundError`), or a `fail` check with the flattened error message
123
+ * otherwise.
124
+ */
125
+ async function openspecCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
126
+ try {
127
+ return { name: 'openspec', status: 'ok', detail: await probe() };
128
+ } catch (err) {
129
+ if (isNotFoundError(err)) {
130
+ return {
131
+ name: 'openspec',
132
+ status: 'fail',
133
+ detail: 'not found on PATH',
134
+ hint: 'npm i -g @fission-ai/openspec; validate_change and the create pipeline need it.',
135
+ };
136
+ }
137
+ // Collapse embedded newlines/whitespace from a raw shell/subprocess error:
138
+ // formatDoctor's column layout assumes a single-line detail, and wrapWords
139
+ // splits on plain spaces only.
140
+ const rawMessage = (err as Error).message || String(err);
141
+ return {
142
+ name: 'openspec',
143
+ status: 'fail',
144
+ detail: rawMessage.replace(/\s+/g, ' ').trim(),
145
+ };
146
+ }
147
+ }
148
+
93
149
  /**
94
150
  * Map a resolved model to the credential its provider needs, mirroring
95
151
  * makeProvider's prefix routing (agent/loop.ts). Presence-only: it checks that a
@@ -97,7 +153,139 @@ async function gitCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
97
153
  * Saved-login providers (codex, claude-code) need no key and can't be verified
98
154
  * offline, so they report `info` (which does not block `ok`).
99
155
  */
100
- export function checkCredential(model: string, env: NodeJS.ProcessEnv): DoctorCheck {
156
+ export function checkCredential(
157
+ model: string,
158
+ env: NodeJS.ProcessEnv,
159
+ compat?: CompatSettings | undefined,
160
+ ): DoctorCheck {
161
+ // OpenAI-compatible endpoint: the credential lives in a variable the user
162
+ // names, and the endpoint is worth showing because it is the whole point of
163
+ // the route. A loopback endpoint (Ollama) needs no key at all (design D4).
164
+ if (model === 'compat' || model.startsWith('compat:')) {
165
+ const shownModel = redactSecrets(model);
166
+ const compatModel = model.startsWith('compat:') ? model.slice('compat:'.length) : undefined;
167
+ // Mirrors makeProvider (agent/loop.ts): bare `compat` has no valid default
168
+ // model, so it must fail here too, or doctor reports "ready" for a run
169
+ // that would fail on its very first turn.
170
+ if (!compatModel) {
171
+ return {
172
+ name: 'provider',
173
+ status: 'fail',
174
+ detail: `${shownModel} -> compat: ${model === 'compat:' ? 'empty' : 'missing'} model id`,
175
+ hint: 'use "compat:<model-id>"; a compatible endpoint has no default model to assume.',
176
+ };
177
+ }
178
+ const settings = compat ?? { apiKeyEnv: DEFAULT_API_KEY_ENV };
179
+ // Display only: some endpoints embed a credential in the URL itself (a
180
+ // query param, userinfo) — Gemini's compat endpoint does this with
181
+ // ?key=.... redactSecrets covers known key shapes, but a key embedded in
182
+ // a URL query isn't reliably one of them, so drop the query and userinfo
183
+ // entirely rather than pattern-matching, regardless of shape.
184
+ // isLocalEndpoint() below still runs against the raw settings.baseURL,
185
+ // never this.
186
+ const where = (() => {
187
+ if (!settings.baseURL) return '(no baseURL configured)';
188
+ try {
189
+ const u = new URL(settings.baseURL);
190
+ return `${u.origin}${u.pathname}`;
191
+ } catch {
192
+ return redactSecrets(settings.baseURL);
193
+ }
194
+ })();
195
+ if (!settings.baseURL) {
196
+ return {
197
+ name: 'provider',
198
+ status: 'fail',
199
+ detail: `${shownModel} -> compat: no endpoint configured`,
200
+ hint: 'set COPPERHEAD_BASE_URL, or "baseURL" in .copperhead/config.json.',
201
+ };
202
+ }
203
+ if (isLocalEndpoint(settings.baseURL)) {
204
+ return {
205
+ name: 'provider',
206
+ status: 'ok',
207
+ detail: `${shownModel} -> compat: ${where} (local endpoint, no key required)`,
208
+ };
209
+ }
210
+ return env[settings.apiKeyEnv]
211
+ ? { name: 'provider', status: 'ok', detail: `${shownModel} -> compat: ${where} (${settings.apiKeyEnv} set)` }
212
+ : {
213
+ name: 'provider',
214
+ status: 'fail',
215
+ detail: `${shownModel} -> compat: ${where} (${settings.apiKeyEnv} not set)`,
216
+ hint: `export ${settings.apiKeyEnv}=... for that endpoint.`,
217
+ };
218
+ }
219
+ return checkKeyedCredential(model, env);
220
+ }
221
+
222
+ /**
223
+ * Hosts whose free tier may train on submitted prompts. Keyed on hostname
224
+ * rather than model name: hostnames are stable, model and tier names rot in
225
+ * months, so the tier detail belongs in docs (design D5). Never `fail` — a
226
+ * contributor deliberately using a free tier on a non-proprietary board is not
227
+ * misconfigured, so this must not make `doctor` exit non-zero.
228
+ */
229
+ const TRAINING_RISK_HOSTS: Record<string, string> = {
230
+ 'generativelanguage.googleapis.com': "Gemini's free tier may train on submitted prompts",
231
+ 'openrouter.ai': 'OpenRouter `:free` models may route to providers that train on prompts',
232
+ };
233
+
234
+ /**
235
+ * True loopback only — unlike `isLocalEndpoint` (config.ts), this excludes
236
+ * `.local`/mDNS hostnames. `isLocalEndpoint`'s broader definition is correct
237
+ * for "does this need a credential" (many LAN-hosted servers skip auth), but
238
+ * wrong for the privacy bypass below: a request to `nas.local` genuinely
239
+ * leaves the machine onto the LAN to a different physical device, so "nothing
240
+ * leaves the machine" does not hold the way it does for real loopback.
241
+ */
242
+ function isLoopbackHost(baseURL: string): boolean {
243
+ try {
244
+ const h = new URL(baseURL).hostname.toLowerCase();
245
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /** A `warn` line when the configured endpoint's host is a documented training risk. */
252
+ export function checkPromptPrivacy(model: string, compat?: CompatSettings | undefined): DoctorCheck | null {
253
+ if (model !== 'compat' && !model.startsWith('compat:')) return null;
254
+ if (!compat?.baseURL) return null;
255
+ // A true loopback endpoint has no third party to have a policy about:
256
+ // nothing leaves the machine, so "check the provider's terms" would be
257
+ // nonsensical. A LAN host (including .local) does not get this bypass.
258
+ if (isLoopbackHost(compat.baseURL)) return null;
259
+ let host: string;
260
+ try {
261
+ host = new URL(compat.baseURL).hostname.toLowerCase();
262
+ } catch {
263
+ return null;
264
+ }
265
+ const noPolicyOnRecord = (): DoctorCheck => ({
266
+ name: 'privacy',
267
+ status: 'info',
268
+ detail: `${host}: no known training-on-prompts policy on record (copperhead cannot verify this; check the provider's terms)`,
269
+ });
270
+ const risk = Object.entries(TRAINING_RISK_HOSTS).find(([h]) => host === h || host.endsWith(`.${h}`));
271
+ if (!risk) return noPolicyOnRecord();
272
+ // OpenRouter's documented risk is specific to its `:free`-suffixed models
273
+ // (their own wording), not the host as a whole. Warning on a fully paid
274
+ // OpenRouter model would be a false positive that undermines trust in the
275
+ // other, host-wide warnings (Gemini's applies to its whole free tier).
276
+ if (risk[0] === 'openrouter.ai') {
277
+ const compatModel = model.startsWith('compat:') ? model.slice('compat:'.length) : '';
278
+ if (!compatModel.endsWith(':free')) return noPolicyOnRecord();
279
+ }
280
+ return {
281
+ name: 'privacy',
282
+ status: 'warn',
283
+ detail: `${host}: ${risk[1]}`,
284
+ hint: 'PCB designs are often proprietary. Use a paid tier or a local endpoint for confidential work.',
285
+ };
286
+ }
287
+
288
+ function checkKeyedCredential(model: string, env: NodeJS.ProcessEnv): DoctorCheck {
101
289
  // A pasted API key can end up as the model value (--model sk-..., a stray
102
290
  // COPPERHEAD_MODEL); redact it before it reaches the report, same policy as
103
291
  // transcripts (AC-4.1). Routing below still uses the raw value.
@@ -152,16 +340,20 @@ function providerCheck(
152
340
  ): DoctorCheck {
153
341
  try {
154
342
  const { model } = resolveModel(flag, config, env);
155
- return checkCredential(model, env);
343
+ return checkCredential(model, env, resolveCompatSettings(config, env));
156
344
  } catch (err) {
157
- // resolveModel throws only when nothing selects a model at all. Its message
158
- // starts with "no model configured: " already this check's detail line —
159
- // so keep only the remedy part for the hint.
345
+ // resolveModel throws for two distinct reasons: nothing selects a model at
346
+ // all ("no model configured: ..."), or two-plus credentials are present
347
+ // with nothing to break the tie ("ambiguous: ..."). Strip whichever prefix
348
+ // matched for the hint, and reflect which case it was in the detail so an
349
+ // ambiguous setup does not misreport as "nothing configured".
350
+ const message = (err as Error).message;
351
+ const ambiguous = message.startsWith('ambiguous:');
160
352
  return {
161
353
  name: 'provider',
162
354
  status: 'fail',
163
- detail: 'no model configured',
164
- hint: (err as Error).message.replace(/^no model configured:\s*/, ''),
355
+ detail: ambiguous ? 'ambiguous: multiple credentials, no model selected' : 'no model configured',
356
+ hint: message.replace(/^(no model configured|ambiguous):\s*/, ''),
165
357
  };
166
358
  }
167
359
  }
@@ -222,17 +414,32 @@ export async function runDoctor(opts: RunDoctorOptions): Promise<DoctorReport> {
222
414
  hint: 'check that it is a regular file (not a directory) and that you have permission to read it.',
223
415
  };
224
416
  }
417
+ // Resolving the model can fail (nothing configured); providerCheck reports
418
+ // that, and the compat-only checks simply do not apply in that case.
419
+ const compat = resolveCompatSettings(config, deps.env);
420
+ let resolvedModel: string | null = null;
421
+ try {
422
+ resolvedModel = resolveModel(opts.model, config, deps.env).model;
423
+ } catch {
424
+ resolvedModel = null;
425
+ }
225
426
  const checks: DoctorCheck[] = [
226
427
  nodeCheck(deps.nodeVersion),
227
428
  await kicadCheck(deps.kicadVersion),
228
429
  await gitCheck(deps.gitVersion),
430
+ await openspecCheck(deps.openspecVersion),
229
431
  providerCheck(opts.model, config, deps.env),
230
- configError ?? projectCheck(config, opts.repoRoot),
231
432
  ];
433
+ if (resolvedModel) {
434
+ const privacy = checkPromptPrivacy(resolvedModel, compat);
435
+ if (privacy) checks.push(privacy);
436
+ }
437
+ checks.push(configError ?? projectCheck(config, opts.repoRoot));
438
+ // `warn` and `info` never block: only a hard failure means "not ready".
232
439
  return { ok: checks.every((c) => c.status !== 'fail'), checks };
233
440
  }
234
441
 
235
- const TAG: Record<DoctorStatus, string> = { ok: '[ok]', fail: '[FAIL]', info: '[info]' };
442
+ const TAG: Record<DoctorStatus, string> = { ok: '[ok]', fail: '[FAIL]', warn: '[warn]', info: '[info]' };
236
443
  const TAG_COL = 2; // leading indent
237
444
  const NAME_COL = TAG_COL + 7; // widest tag "[FAIL]" + one space
238
445
  const DETAIL_COL = NAME_COL + 10; // widest name "kicad-cli" + one space
@@ -242,7 +449,7 @@ const DETAIL_COL = NAME_COL + 10; // widest name "kicad-cli" + one space
242
449
  // tests see plain text. Colored text is padded before painting — escape codes
243
450
  // have zero display width but nonzero string length, so painting first would
244
451
  // break the column math.
245
- const ANSI: Record<DoctorStatus, string> = { ok: '32', fail: '31', info: '36' };
452
+ const ANSI: Record<DoctorStatus, string> = { ok: '32', fail: '31', warn: '33', info: '36' };
246
453
  const DIM = '2';
247
454
  function paint(text: string, code: string, on: boolean): string {
248
455
  return on ? `\u001b[${code}m${text}\u001b[0m` : text;
package/src/config.ts CHANGED
@@ -2,9 +2,32 @@ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
+ /**
6
+ * Optional `legibility` block: checker thresholds and per-family severity
7
+ * overrides (`off` disables a family). Unknown keys and invalid values are
8
+ * ignored by the checker's own sanitizer, so a config typo cannot crash a run.
9
+ */
10
+ export interface LegibilityUserConfig {
11
+ thresholds?: {
12
+ gridPitch?: number;
13
+ minPitch?: number;
14
+ utilization?: number;
15
+ maxWireLength?: number;
16
+ familyCap?: number;
17
+ };
18
+ severity?: Record<string, 'error' | 'advisory' | 'off'>;
19
+ /** Scorer tuning: per-metric weights and the known-good composite floor. */
20
+ score?: {
21
+ weights?: Record<string, number>;
22
+ floor?: number;
23
+ };
24
+ }
25
+
5
26
  export interface CopperheadConfig {
6
27
  schematic: string | null;
7
28
  board: string | null;
29
+ /** Schematic legibility checker thresholds and severity overrides. */
30
+ legibility?: LegibilityUserConfig;
8
31
  docs: string;
9
32
  model: string | null;
10
33
  maxTurns: number;
@@ -26,6 +49,18 @@ export interface CopperheadConfig {
26
49
  /** Cache each turn's LLM response to disk and replay it on identical inputs,
27
50
  * so retries/restarts reuse work already paid for. Default on. */
28
51
  llmCache: boolean;
52
+ /**
53
+ * Base URL of an OpenAI-compatible endpoint (Groq, OpenRouter, Gemini's
54
+ * compat endpoint, a local Ollama). Consulted only by the `compat`
55
+ * route (design D2), so a stray value never redirects a plain `gpt-5` run.
56
+ */
57
+ baseURL?: string;
58
+ /**
59
+ * Name of the environment variable holding the compat endpoint's key, e.g.
60
+ * `GROQ_API_KEY`. The *name*, never the key itself: credentials stay in the
61
+ * environment (AC-4.1).
62
+ */
63
+ apiKeyEnv?: string;
29
64
  /** Content hashes of generated docs, for init idempotency (AC-1.4). */
30
65
  generatedHashes?: Record<string, string>;
31
66
  /**
@@ -90,8 +125,11 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
90
125
  ? (raw.maxStageRetries as number)
91
126
  : DEFAULTS.maxStageRetries,
92
127
  llmCache: raw.llmCache !== false,
128
+ ...(typeof raw.baseURL === 'string' && raw.baseURL.trim() ? { baseURL: raw.baseURL.trim() } : {}),
129
+ ...(typeof raw.apiKeyEnv === 'string' && raw.apiKeyEnv.trim() ? { apiKeyEnv: raw.apiKeyEnv.trim() } : {}),
93
130
  ...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
94
131
  ...(raw.origin === 'create' || raw.origin === 'init' ? { origin: raw.origin } : {}),
132
+ ...(raw.legibility && typeof raw.legibility === 'object' ? { legibility: raw.legibility } : {}),
95
133
  };
96
134
  }
97
135
 
@@ -140,9 +178,76 @@ export function resolveModel(flag: string | undefined, config: CopperheadConfig,
140
178
  if (flag) return { model: flag, source: 'flag' };
141
179
  if (env.COPPERHEAD_MODEL) return { model: env.COPPERHEAD_MODEL, source: 'env' };
142
180
  if (config.model) return { model: config.model, source: 'config' };
143
- if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
144
- if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
181
+ // Auto-fallback is only safe when exactly one credential is present: guessing
182
+ // is a convenience when there is nothing to guess wrong. With two or more
183
+ // keys set (a common dev setup once a compat endpoint's key sits alongside
184
+ // OPENAI_API_KEY/ANTHROPIC_API_KEY), silently favoring whichever is checked
185
+ // first can send a request to the wrong provider with no signal — including
186
+ // a paid one when a free key was what was actually intended. Refuse instead
187
+ // of guessing; the compat route itself is never a fallback candidate here,
188
+ // since it is opt-in only via an explicit `compat:` prefix (design D2).
189
+ const available: { keyVar: string; model: string; source: ModelSource }[] = [
190
+ ...(env.OPENAI_API_KEY ? [{ keyVar: 'OPENAI_API_KEY', model: 'gpt-5', source: 'openai-key' as const }] : []),
191
+ ...(env.ANTHROPIC_API_KEY ? [{ keyVar: 'ANTHROPIC_API_KEY', model: 'claude', source: 'anthropic-key' as const }] : []),
192
+ ];
193
+ if (available.length === 1) return { model: available[0]!.model, source: available[0]!.source };
194
+ if (available.length > 1) {
195
+ throw new Error(
196
+ `ambiguous: ${available.length} credentials found (${available.map((a) => a.keyVar).join(', ')}) and no model was ` +
197
+ 'selected; pass --model, set COPPERHEAD_MODEL, or set "model" in .copperhead/config.json.',
198
+ );
199
+ }
145
200
  throw new Error(
146
201
  'no model configured: pass --model, set COPPERHEAD_MODEL, or export an API key; see https://docs.copperhead.sh/reference/configuration/',
147
202
  );
148
203
  }
204
+
205
+ /** Where an OpenAI-compatible run points, and which variable holds its key. */
206
+ export interface CompatSettings {
207
+ /** Endpoint base URL; undefined means the client's own default (OpenAI). */
208
+ baseURL?: string;
209
+ /** Name of the env var holding the key. Never the key itself. */
210
+ apiKeyEnv: string;
211
+ }
212
+
213
+ /** The credential variable used when nothing else is configured. */
214
+ export const DEFAULT_API_KEY_ENV = 'OPENAI_API_KEY';
215
+
216
+ /**
217
+ * Resolve the compatible-endpoint settings: environment wins over config, the
218
+ * same direction as `resolveModel`'s chain. These are *settings*, not a
219
+ * provider selector — only the `compat` route reads them (design D1/D2), so an
220
+ * exported COPPERHEAD_BASE_URL never silently redirects a `gpt-5` run.
221
+ */
222
+ export function resolveCompatSettings(config: CopperheadConfig, env = process.env): CompatSettings {
223
+ const baseURL = env.COPPERHEAD_BASE_URL?.trim() || config.baseURL?.trim();
224
+ const apiKeyEnv = env.COPPERHEAD_API_KEY_ENV?.trim() || config.apiKeyEnv?.trim() || DEFAULT_API_KEY_ENV;
225
+ return { ...(baseURL ? { baseURL } : {}), apiKeyEnv };
226
+ }
227
+
228
+ /**
229
+ * True when a resolved model id routes through the `compat` provider (D1/D2).
230
+ * The single source of truth for that gate: `makeProvider` uses it to decide
231
+ * whether to consult `CompatSettings` at all, and the response cache (loop.ts)
232
+ * uses it to decide whether a run's cache key may depend on `baseURL` — a
233
+ * non-compat run (gpt-5, claude, ...) never reads COPPERHEAD_BASE_URL, so its
234
+ * cache key must not vary with it either, or every entry gets orphaned each
235
+ * time the endpoint used for unrelated compat testing changes.
236
+ */
237
+ export function isCompatModel(model: string): boolean {
238
+ return model === 'compat' || model.startsWith('compat:');
239
+ }
240
+
241
+ /**
242
+ * True when the endpoint is loopback, i.e. a local server such as Ollama.
243
+ * Those need no credential (design D4); a remote endpoint always does.
244
+ */
245
+ export function isLocalEndpoint(baseURL: string | undefined): boolean {
246
+ if (!baseURL) return false;
247
+ try {
248
+ const h = new URL(baseURL).hostname.toLowerCase();
249
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]' || h.endsWith('.local');
250
+ } catch {
251
+ return false; // an unparseable URL is not a local endpoint; the run fails later with a clearer error
252
+ }
253
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { writeFile } from 'node:fs/promises';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { createHash } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
6
+ import { CREATE_ORIGIN } from './fab.js';
6
7
 
7
8
  /**
8
9
  * The create pipeline starts from a brief with no KiCad files, but the agent
@@ -37,8 +38,8 @@ function uuidFrom(seed: string): string {
37
38
  function emptySchematic(rootUuid: string): string {
38
39
  return `(kicad_sch
39
40
  (version 20231120)
40
- (generator "eeschema")
41
- (generator_version "8.0")
41
+ (generator "copperhead-draft")
42
+ (generator_version "0")
42
43
  (uuid "${rootUuid}")
43
44
  (paper "A4")
44
45
  (lib_symbols)
@@ -147,9 +148,25 @@ function projectFile(slug: string, rootUuid: string): string {
147
148
  }
148
149
 
149
150
  async function persist(repoRoot: string, config: CopperheadConfig): Promise<void> {
151
+ await mkdir(path.dirname(configPath(repoRoot)), { recursive: true });
150
152
  await writeFile(configPath(repoRoot), JSON.stringify(config, null, 2) + '\n', 'utf8');
151
153
  }
152
154
 
155
+ /**
156
+ * Stamp the config as create-produced (`origin: "create"`). The marker is what
157
+ * scopes the legibility finish gate (`isCreateProducedRepo` feeds the
158
+ * obligations ledger) and the fab release gate — a gate hung on a marker
159
+ * nothing writes is silently inert, so `runCreate` stamps it up front and
160
+ * `bootstrapKicadProject` re-stamps on every (re-)scaffold, covering the
161
+ * rollback path that deletes an uncommitted config.
162
+ */
163
+ export async function markCreateOrigin(repoRoot: string): Promise<void> {
164
+ const config = await loadConfig(repoRoot);
165
+ if (config.origin === CREATE_ORIGIN) return;
166
+ config.origin = CREATE_ORIGIN;
167
+ await persist(repoRoot, config);
168
+ }
169
+
153
170
  /**
154
171
  * Ensure a KiCad project exists and is wired into config. No-op (returns null)
155
172
  * when config already points at a schematic on disk. If project files exist but
@@ -160,6 +177,10 @@ async function persist(repoRoot: string, config: CopperheadConfig): Promise<void
160
177
  export async function bootstrapKicadProject(repoRoot: string, brief: string): Promise<string | null> {
161
178
  const config = await loadConfig(repoRoot);
162
179
  if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) return null;
180
+ // Only `create` scaffolds through here, so the repo is create-produced by
181
+ // definition; stamping on every scaffold keeps the marker alive across the
182
+ // rollback-then-rescaffold path (git clean deletes an uncommitted config).
183
+ config.origin = CREATE_ORIGIN;
163
184
 
164
185
  const slug = projectSlug(brief);
165
186
  const schRel = `${slug}.kicad_sch`;
package/src/kicad/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { normalizeReport, type CheckReport } from './report.js';
7
- import { PreflightError } from '../util/preflight.js';
7
+ import { PreflightError, isNotFoundError } from '../util/preflight.js';
8
8
 
9
9
  export class KicadCliMissingError extends PreflightError {
10
10
  constructor() {
@@ -109,7 +109,7 @@ function fallbackAfterMissing(): string {
109
109
  async function runKicad(args: string[], opts?: { reject?: boolean }): Promise<Awaited<ReturnType<typeof execa>>> {
110
110
  let bin = resolveKicadCli();
111
111
  let res = await execa(bin, args, { reject: false });
112
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
112
+ if (res.failed && isNotFoundError(res)) {
113
113
  if (bin === 'kicad-cli') {
114
114
  bin = fallbackAfterMissing();
115
115
  res = await execa(bin, args, { reject: false });
@@ -117,10 +117,10 @@ async function runKicad(args: string[], opts?: { reject?: boolean }): Promise<Aw
117
117
  throw new KicadCliMissingError();
118
118
  }
119
119
  }
120
- if (opts?.reject === false) return res;
121
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
120
+ if (res.failed && isNotFoundError(res)) {
122
121
  throw new KicadCliMissingError();
123
122
  }
123
+ if (opts?.reject === false) return res;
124
124
  if (res.failed) {
125
125
  throw Object.assign(new Error(res.stderr || res.stdout || `kicad-cli exited ${res.exitCode}`), res);
126
126
  }
@@ -143,15 +143,8 @@ export function setKicadFallbackBinaries(paths?: readonly string[]): void {
143
143
  }
144
144
 
145
145
  export async function kicadCliVersion(): Promise<string> {
146
- try {
147
- const res = await runKicad(['version']);
148
- return String(res.stdout ?? '').trim();
149
- } catch (err) {
150
- if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
151
- // runKicad already maps PATH ENOENT → fallback → KicadCliMissingError
152
- if (err instanceof KicadCliMissingError) throw err;
153
- throw err;
154
- }
146
+ const res = await runKicad(['version']);
147
+ return String(res.stdout ?? '').trim();
155
148
  }
156
149
 
157
150
  async function runCheck(
@@ -167,9 +160,6 @@ async function runCheck(
167
160
  [...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
168
161
  { reject: false },
169
162
  );
170
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
171
- throw new KicadCliMissingError();
172
- }
173
163
  let raw: unknown;
174
164
  try {
175
165
  raw = JSON.parse(await readFile(out, 'utf8'));
@@ -218,7 +208,6 @@ export async function kicadLoadError(filePath: string): Promise<string | null> {
218
208
  : ['pcb', 'export', 'pos', '--output', path.join(dir, 'probe.pos'), filePath];
219
209
  try {
220
210
  const res = await runKicad(args, { reject: false });
221
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
222
211
  if (res.exitCode === 0) return null;
223
212
  return [res.stderr, res.stdout].filter(Boolean).join('\n').trim() || `kicad-cli exited ${res.exitCode}`;
224
213
  } finally {
@@ -258,7 +247,6 @@ export async function exportFab(pcbPath: string, schPath: string | null, outDir:
258
247
  result.produced.push(job.artifact);
259
248
  } catch (err) {
260
249
  if (err instanceof KicadCliMissingError) throw err;
261
- if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
262
250
  result.failed.push({ artifact: job.artifact, reason: String((err as ExecaError).stderr ?? (err as Error).message).slice(0, 200) });
263
251
  }
264
252
  }
@@ -275,7 +263,6 @@ export async function exportSvg(kind: 'sch' | 'pcb', filePath: string, outDir: s
275
263
  await runKicad(args);
276
264
  } catch (err) {
277
265
  if (err instanceof KicadCliMissingError) throw err;
278
- if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
279
266
  throw err;
280
267
  }
281
268
  return outDir;