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.
- package/NOTICE +1 -1
- package/README.md +13 -5
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +67 -62
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/openai.js +28 -6
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +95 -1
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/response-cache.js +18 -2
- package/dist/agent/response-cache.js.map +1 -1
- package/dist/agent/tools.js +185 -1
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +77 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +282 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +211 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +61 -4
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/cli.js +7 -26
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +132 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +2389 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +490 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +181 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +561 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +261 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +262 -10
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/memory/bom-table.js +108 -34
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +6 -0
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/openspec/cli.js +2 -1
- package/dist/openspec/cli.js.map +1 -1
- package/dist/util/preflight.js +17 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/redact.js +12 -2
- package/dist/util/redact.js.map +1 -1
- package/package.json +9 -7
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +88 -65
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/openai.ts +38 -4
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/response-cache.ts +17 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +73 -2
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +278 -22
- package/src/commands/doctor.ts +219 -12
- package/src/config.ts +107 -2
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +6 -19
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +171 -0
- package/src/kicad/draft/engine.ts +2466 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +535 -0
- package/src/kicad/emit.ts +236 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +646 -0
- package/src/kicad/score.ts +323 -0
- package/src/kicad/sexp.ts +339 -10
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +119 -31
- package/src/memory/scaffold.ts +6 -0
- package/src/openspec/cli.ts +3 -2
- package/src/util/preflight.ts +18 -0
- package/src/util/redact.ts +12 -2
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
package/src/commands/doctor.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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:
|
|
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
|
-
|
|
144
|
-
|
|
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
|
+
}
|
package/src/kicad/bootstrap.ts
CHANGED
|
@@ -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 "
|
|
41
|
-
(generator_version "
|
|
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
|
|
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 (
|
|
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
|
-
|
|
147
|
-
|
|
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;
|