copperhead 0.9.0 → 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 +6 -6
- 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 +29 -58
- 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/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/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 +75 -0
- 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 +177 -25
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +50 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +1 -0
- 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/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 +239 -6
- 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 +75 -39
- 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/util/redact.js +6 -0
- 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 +28 -61
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +71 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +179 -20
- package/src/commands/doctor.ts +51 -3
- package/src/config.ts +24 -0
- package/src/kicad/bootstrap.ts +24 -3
- 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 +315 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +6 -0
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
package/src/agent/recovery.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import type { Msg, Provider } from './types.js';
|
|
5
|
+
import { resolveLibrarySymbol, searchInstalledSymbols, symbolSearchDirs, listInstalledLibraries } from '../kicad/symlib.js';
|
|
5
6
|
|
|
6
7
|
/** Thrown when a single provider turn blows past its watchdog deadline. */
|
|
7
8
|
export class TurnTimeoutError extends Error {
|
|
@@ -116,6 +117,91 @@ export async function transcriptExcerpt(transcriptDir: string, maxChars = 4000):
|
|
|
116
117
|
return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Deterministically re-probe every lib_id named in a failure narrative against
|
|
122
|
+
* the installed libraries, so the diagnostician judges symbol-availability
|
|
123
|
+
* claims from machine facts instead of the agent's prose. An agent that has
|
|
124
|
+
* been dead-ended by wrong library nicknames concludes — and records — that
|
|
125
|
+
* whole libraries are absent when they are installed; a refusal built on that
|
|
126
|
+
* premise reads exactly like a genuine environmental gap, and the one thing
|
|
127
|
+
* that distinguishes them is re-checking the named lib_ids, which costs no
|
|
128
|
+
* LLM turn. Never throws; on any probe error it reports what it could.
|
|
129
|
+
*/
|
|
130
|
+
export async function symbolAvailabilityFacts(text: string, dirs?: string[], cap = 8): Promise<string> {
|
|
131
|
+
const ids: string[] = [];
|
|
132
|
+
// A library nickname is its `.kicad_sym` filename stem, so it can carry `-`
|
|
133
|
+
// and `.` as well as `_` (`Custom-Parts`, `MyCorp.RF`) — a nickname the regex
|
|
134
|
+
// truncates is probed as the wrong lib_id and reported absent, which is the
|
|
135
|
+
// false negative this whole fact block exists to prevent. Separators are
|
|
136
|
+
// interior only, so a trailing sentence period is not swallowed.
|
|
137
|
+
for (const m of text.matchAll(/\b([A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?):([A-Za-z0-9][A-Za-z0-9_.+-]*)/g)) {
|
|
138
|
+
const lib = m[1]!;
|
|
139
|
+
const name = m[2]!;
|
|
140
|
+
// Require letters on both sides: drops file:line refs ("create.ts:311"),
|
|
141
|
+
// times and bare numbers. Engine-generated power symbols are not library
|
|
142
|
+
// facts.
|
|
143
|
+
if (!/[A-Za-z]/.test(lib) || !/[A-Za-z]/.test(name) || lib === 'copperhead_power') continue;
|
|
144
|
+
const libId = `${lib}:${name}`;
|
|
145
|
+
if (!ids.includes(libId)) ids.push(libId);
|
|
146
|
+
}
|
|
147
|
+
if (!ids.length) return '';
|
|
148
|
+
// Collection is unbounded but probing is capped, so a probe-heavy transcript
|
|
149
|
+
// stays cheap. The overflow is named rather than dropped: the supervisor is
|
|
150
|
+
// told these facts are ground truth, and silently probing 8 of 30 lib_ids
|
|
151
|
+
// would let it read "unprobed" as "absent".
|
|
152
|
+
const probed = ids.slice(0, cap);
|
|
153
|
+
const unprobed = ids.slice(cap);
|
|
154
|
+
let searchDirs: string[];
|
|
155
|
+
try {
|
|
156
|
+
searchDirs = dirs ?? (await symbolSearchDirs());
|
|
157
|
+
} catch {
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
if (!searchDirs.length) return '';
|
|
161
|
+
// A directory with no readable library means nothing was checked: emitting
|
|
162
|
+
// "not installed" lines as ground truth from that state is the exact false
|
|
163
|
+
// absence this block exists to prevent (same guard as bomSymbolDossier).
|
|
164
|
+
if (!(await listInstalledLibraries(searchDirs)).size) return '';
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (const libId of probed) {
|
|
167
|
+
const name = libId.slice(libId.indexOf(':') + 1);
|
|
168
|
+
try {
|
|
169
|
+
const r = await resolveLibrarySymbol(libId, searchDirs);
|
|
170
|
+
if (r.status === 'ok') {
|
|
171
|
+
lines.push(`- ${libId}: RESOLVES on this machine (${r.pins.length} pins)`);
|
|
172
|
+
} else if (r.status === 'found-elsewhere') {
|
|
173
|
+
// The resolver already located the part under another lib_id; saying
|
|
174
|
+
// "not installed" here would be the exact false absence claim this
|
|
175
|
+
// block exists to prevent.
|
|
176
|
+
lines.push(`- ${libId}: not at that lib_id, but installed as: ${r.libIds.slice(0, 4).join(', ')}`);
|
|
177
|
+
} else {
|
|
178
|
+
const elsewhere = await searchInstalledSymbols(name, searchDirs, 4);
|
|
179
|
+
const inThat =
|
|
180
|
+
r.status === 'no-symbol' && r.candidates.length
|
|
181
|
+
? ` (closest in that library: ${r.candidates.slice(0, 4).join(', ')})`
|
|
182
|
+
: '';
|
|
183
|
+
const where = elsewhere.length
|
|
184
|
+
? `; installed as: ${elsewhere.join(', ')}`
|
|
185
|
+
: `; no installed symbol matches "${name}" in any library`;
|
|
186
|
+
lines.push(
|
|
187
|
+
r.status === 'no-symbol'
|
|
188
|
+
? `- ${libId}: not in that library${inThat}${where}`
|
|
189
|
+
: `- ${libId}: no library of that nickname is installed${where}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// a single unreadable library must not sink the fact block
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!lines.length) return '';
|
|
197
|
+
if (unprobed.length) {
|
|
198
|
+
lines.push(
|
|
199
|
+
`- NOT RE-PROBED (probe limit ${cap}): ${unprobed.join(', ')} — these were named in the text but not checked, so nothing above says whether they exist.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
119
205
|
/**
|
|
120
206
|
* Ask the model whether a failed/incomplete stage is worth retrying, and if so
|
|
121
207
|
* how. Uses a fresh, tool-less provider turn (the same saved-login backend the
|
|
@@ -132,6 +218,9 @@ export async function diagnoseStageFailure(
|
|
|
132
218
|
excerpt: string;
|
|
133
219
|
attempt: number;
|
|
134
220
|
maxAttempts: number;
|
|
221
|
+
/** Deterministic re-probe results for lib_ids named in the failure/excerpt
|
|
222
|
+
* (`symbolAvailabilityFacts`); authoritative over the transcript's claims. */
|
|
223
|
+
symbolFacts?: string;
|
|
135
224
|
},
|
|
136
225
|
): Promise<StageDiagnosis> {
|
|
137
226
|
const system =
|
|
@@ -145,10 +234,14 @@ export async function diagnoseStageFailure(
|
|
|
145
234
|
`Failure: ${input.failure}\n` +
|
|
146
235
|
`This was attempt ${input.attempt} of ${input.maxAttempts}.\n\n` +
|
|
147
236
|
`Recent transcript (most recent last):\n${input.excerpt}\n\n` +
|
|
237
|
+
(input.symbolFacts
|
|
238
|
+
? `Machine-verified symbol facts — a deterministic re-probe of the lib_ids named above, run just now against this machine's installed KiCad libraries. Each line reported below is ground truth and overrides anything the transcript claims about that symbol's availability. Coverage may be partial: a lib_id listed as NOT RE-PROBED, or absent from this block entirely, is unknown, never confirmed absent.\n${input.symbolFacts}\n\n`
|
|
239
|
+
: '') +
|
|
148
240
|
'Reply with ONLY a JSON object, no prose:\n' +
|
|
149
241
|
'{"verdict":"retry"|"abort","reason":"<one sentence>","guidance":"<if retry: concrete, specific instructions to prepend to the next attempt so it avoids this failure; otherwise empty>"}\n' +
|
|
150
242
|
'- "retry" if the failure looks transient or fixable with clearer instructions (a dropped or locked tool call, an empty/no-op edit, a skipped step, a timeout, a formatting slip).\n' +
|
|
151
|
-
'- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt)
|
|
243
|
+
'- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt).\n' +
|
|
244
|
+
'- an agent\'s claim that a symbol or library is absent is NOT evidence: agents dead-ended by wrong library nicknames routinely conclude whole libraries are missing. If the machine-verified facts contradict the failure\'s premise (a cited-absent lib_id RESOLVES, or the part is installed under another library), the verdict is "retry", with guidance quoting the correct lib_ids.';
|
|
152
245
|
const messages: Msg[] = [
|
|
153
246
|
{ role: 'system', content: system },
|
|
154
247
|
{ role: 'user', content: user },
|
package/src/agent/tools.ts
CHANGED
|
@@ -6,12 +6,16 @@ import { resolveInRepo, isKicadFile } from '../util/paths.js';
|
|
|
6
6
|
import { runErc, runDrc, exportSvg, exportFab, kicadLoadError, isProbeableKicadFile } from '../kicad/cli.js';
|
|
7
7
|
import { formatViolations, type CheckReport } from '../kicad/report.js';
|
|
8
8
|
import { listSymbols, listNets } from '../kicad/sexp.js';
|
|
9
|
-
import {
|
|
9
|
+
import { checkLegibility, formatLegibility } from '../kicad/legibility.js';
|
|
10
|
+
import { scoreSchematic, formatScore } from '../kicad/score.js';
|
|
11
|
+
import { draftSchematic, defaultIntentPath, formatSchematicDraftReport } from '../kicad/draft/draft.js';
|
|
12
|
+
import { verifySchematicSymbols, searchInstalledSymbols, symbolSearchDirs, resolveLibrarySymbol, comparePinNumbers } from '../kicad/symlib.js';
|
|
10
13
|
import { checkDrift } from '../memory/drift.js';
|
|
11
14
|
import { saveConstraint, classifyAffectsTarget, affectsTargetExists } from '../memory/constraints.js';
|
|
12
15
|
import { openspecValidate } from '../openspec/cli.js';
|
|
13
16
|
import { existsSync } from 'node:fs';
|
|
14
17
|
import type { CopperheadConfig } from '../config.js';
|
|
18
|
+
import { isEngineAuthoredSchematic } from '../kicad/fab.js';
|
|
15
19
|
import { ObligationsLedger } from './ledger.js';
|
|
16
20
|
import type { Transcript } from './transcript.js';
|
|
17
21
|
|
|
@@ -36,6 +40,10 @@ export interface RunContext {
|
|
|
36
40
|
decisions: string[];
|
|
37
41
|
lastErc: CheckReport | null;
|
|
38
42
|
lastDrc: CheckReport | null;
|
|
43
|
+
/** Last check_legibility counts; feeds the run summary's verification section. */
|
|
44
|
+
lastLegibility: { error: number; advisory: number } | null;
|
|
45
|
+
/** Last score composite (AC-16.21); recorded in the run summary. */
|
|
46
|
+
lastScore: number | null;
|
|
39
47
|
repairCycles: number;
|
|
40
48
|
finishRequest: FinishRequest | null;
|
|
41
49
|
}
|
|
@@ -239,6 +247,17 @@ export const TOOLS: ToolDef[] = [
|
|
|
239
247
|
if (corrupt) return corrupt;
|
|
240
248
|
const rel = str(args, 'path');
|
|
241
249
|
const abs = resolveInRepo(ctx.repoRoot, rel);
|
|
250
|
+
// Engine-drafted sheets are regenerated wholesale from the IR: a hand
|
|
251
|
+
// edit would be destroyed by the next re-draft and would break the
|
|
252
|
+
// byte-identical staleness check. Geometry repairs go through the IR
|
|
253
|
+
// (design D5). Hand-drawn schematics never carry the draft generator
|
|
254
|
+
// marker, so `do` on existing repos is untouched by this guard.
|
|
255
|
+
if (rel.endsWith('.kicad_sch') && existsSync(abs)) {
|
|
256
|
+
const head = (await readFile(abs, 'utf8')).slice(0, 400);
|
|
257
|
+
if (isEngineAuthoredSchematic(head)) {
|
|
258
|
+
return `refused: ${rel} is engine-drafted from ${defaultIntentPath(rel)}. Revise the intent (edit_file on the intent JSON) and call draft_schematic to regenerate the sheet; direct geometry edits would be lost on the next re-draft.`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
242
261
|
// Text edits can corrupt an s-expression file in ways the editor cannot
|
|
243
262
|
// see; a corrupted file then fails every later ERC/DRC with an opaque
|
|
244
263
|
// error. Validate loadability with KiCad itself and roll the edit back
|
|
@@ -322,6 +341,78 @@ export const TOOLS: ToolDef[] = [
|
|
|
322
341
|
return out;
|
|
323
342
|
},
|
|
324
343
|
},
|
|
344
|
+
{
|
|
345
|
+
schema: {
|
|
346
|
+
name: 'search_symbols',
|
|
347
|
+
description:
|
|
348
|
+
'Search EVERY installed KiCad symbol library for a part or symbol name; returns matching lib_ids as Lib:Name, exact matches first. Library nicknames rarely follow from the part number (TPS61165DBV is in Driver_LED, AudioJack3 in Connector_Audio, INA226 in Sensor_Energy), so a failed single-library probe proves nothing about availability — use this before concluding a part has no symbol, and before committing any active part to the BOM: a part is only drawable if its symbol appears here.',
|
|
349
|
+
parameters: {
|
|
350
|
+
type: 'object',
|
|
351
|
+
properties: {
|
|
352
|
+
query: { type: 'string', description: 'part or symbol name, e.g. "TLV320AIC3204" or "AudioJack3"' },
|
|
353
|
+
},
|
|
354
|
+
required: ['query'],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
requiresUnlock: false,
|
|
358
|
+
handler: async (_ctx, args) => {
|
|
359
|
+
const query = str(args, 'query');
|
|
360
|
+
const dirs = await symbolSearchDirs();
|
|
361
|
+
if (!dirs.length) return 'no installed KiCad symbol library directories found on this machine';
|
|
362
|
+
const hits = await searchInstalledSymbols(query, dirs);
|
|
363
|
+
if (!hits.length) {
|
|
364
|
+
return `no installed symbol matches "${query}" (searched every library in: ${dirs.join(', ')}). The part is not capturable on this machine as named — choose a part whose symbol exists, or a same-family variant that does.`;
|
|
365
|
+
}
|
|
366
|
+
return `installed symbols matching "${query}":\n${hits.map((h) => ` - ${h}`).join('\n')}`;
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
schema: {
|
|
371
|
+
name: 'symbol_pins',
|
|
372
|
+
description:
|
|
373
|
+
'Return the REAL pins (number, name, electrical type) of an installed KiCad symbol by lib_id, following extends links, plus its unit count — the authoritative source for REF.PIN endpoints, instead of guessing pins or reading .kicad_sym files. Warns when the symbol is multi-unit, which the drafting engine refuses. On a miss it lists the closest names in that library and where the symbol actually lives, so one call answers both "what are the pins" and "which lib_id is right".',
|
|
374
|
+
parameters: {
|
|
375
|
+
type: 'object',
|
|
376
|
+
properties: {
|
|
377
|
+
lib_id: { type: 'string', description: 'full library identifier, e.g. "Device:R" or "Audio:TLV320AIC3100"' },
|
|
378
|
+
},
|
|
379
|
+
required: ['lib_id'],
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
requiresUnlock: false,
|
|
383
|
+
handler: async (_ctx, args) => {
|
|
384
|
+
const libId = str(args, 'lib_id');
|
|
385
|
+
const name = libId.includes(':') ? libId.slice(libId.indexOf(':') + 1) : libId;
|
|
386
|
+
const dirs = await symbolSearchDirs();
|
|
387
|
+
if (!dirs.length) {
|
|
388
|
+
return `cannot verify "${libId}": no installed KiCad symbol library directories were found on this machine, so nothing can be resolved or ruled out. Install the KiCad symbol libraries (or set KICAD_SYMBOL_DIR), or choose a part you can verify another way.`;
|
|
389
|
+
}
|
|
390
|
+
const r = await resolveLibrarySymbol(libId, dirs);
|
|
391
|
+
if (r.status === 'ok') {
|
|
392
|
+
const pins = [...r.pins]
|
|
393
|
+
.sort((a, b) => comparePinNumbers(a.number, b.number))
|
|
394
|
+
.map((p) => ` ${p.number}: ${p.name === '~' || !p.name ? '(unnamed)' : p.name} · ${p.type}`);
|
|
395
|
+
const multi =
|
|
396
|
+
r.units >= 2
|
|
397
|
+
? `\nNOTE: this symbol defines ${r.units} units; the drafting engine places each unit separately under one refdes (U1A/U1B), and net endpoints keep plain package pin numbers.`
|
|
398
|
+
: '';
|
|
399
|
+
return `${libId} — ${r.pins.length} pin(s), ${r.units} unit(s):\n${pins.join('\n')}${multi}`;
|
|
400
|
+
}
|
|
401
|
+
if (r.status === 'found-elsewhere') {
|
|
402
|
+
return `"${libId}" does not resolve, but the symbol is installed as: ${r.libIds.join(', ')} — use one of these lib_ids (and call symbol_pins on it for the pin table).`;
|
|
403
|
+
}
|
|
404
|
+
const elsewhere = await searchInstalledSymbols(name, dirs, 6);
|
|
405
|
+
const where = elsewhere.length
|
|
406
|
+
? `\ninstalled as: ${elsewhere.join(', ')}`
|
|
407
|
+
: `\nno installed symbol matches "${name}" in any library — the part is not capturable as named.`;
|
|
408
|
+
if (r.status === 'no-symbol') {
|
|
409
|
+
const close = r.candidates.length ? `\nclosest in that library: ${r.candidates.join(', ')}` : '';
|
|
410
|
+
return `"${libId}" does not exist in that library.${close}${where}`;
|
|
411
|
+
}
|
|
412
|
+
const lib = libId.includes(':') ? libId.slice(0, libId.indexOf(':')) : libId;
|
|
413
|
+
return `no library named "${lib}" is installed.${where}`;
|
|
414
|
+
},
|
|
415
|
+
},
|
|
325
416
|
{
|
|
326
417
|
schema: {
|
|
327
418
|
name: 'verify_symbols',
|
|
@@ -344,6 +435,103 @@ export const TOOLS: ToolDef[] = [
|
|
|
344
435
|
return `verify_symbols: ${checked} verified, ${skipped} unverifiable (library not installed), ${mismatches} issue(s) to reconcile:\n${lines.join('\n')}`;
|
|
345
436
|
},
|
|
346
437
|
},
|
|
438
|
+
{
|
|
439
|
+
schema: {
|
|
440
|
+
name: 'draft_schematic',
|
|
441
|
+
description:
|
|
442
|
+
'Regenerate the schematic deterministically from the netlist-intent IR (schematic.intent.json beside the schematic). Pass intent_json to write a new IR first, or omit it to re-draft the existing file. The engine computes ALL geometry (placement, wires, labels, power symbols, group boxes); never author coordinates. The report embeds the legibility findings and score for the fresh sheet. A failed validation leaves the previous schematic untouched.',
|
|
443
|
+
parameters: {
|
|
444
|
+
type: 'object',
|
|
445
|
+
properties: {
|
|
446
|
+
intent_json: { type: 'string', description: 'full IR document as JSON text (optional: omit to re-draft the current IR)' },
|
|
447
|
+
},
|
|
448
|
+
required: [],
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
requiresUnlock: true,
|
|
452
|
+
handler: async (ctx, args) => {
|
|
453
|
+
if (!ctx.config.schematic) return 'no schematic configured; set one in .copperhead/config.json first';
|
|
454
|
+
const intentRel = defaultIntentPath(ctx.config.schematic);
|
|
455
|
+
if (typeof args.intent_json === 'string' && args.intent_json.trim()) {
|
|
456
|
+
const corrupt = corruptionError({ intent_json: args.intent_json });
|
|
457
|
+
if (corrupt) return corrupt;
|
|
458
|
+
try {
|
|
459
|
+
JSON.parse(args.intent_json);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return `intent_json is not valid JSON (${(e as Error).message}); nothing written`;
|
|
462
|
+
}
|
|
463
|
+
await writeFile(resolveInRepo(ctx.repoRoot, intentRel), args.intent_json, 'utf8');
|
|
464
|
+
ctx.filesTouched.add(intentRel);
|
|
465
|
+
}
|
|
466
|
+
const res = await draftSchematic({
|
|
467
|
+
repoRoot: ctx.repoRoot,
|
|
468
|
+
schematic: ctx.config.schematic,
|
|
469
|
+
intentPath: intentRel,
|
|
470
|
+
docsDir: ctx.config.docs,
|
|
471
|
+
});
|
|
472
|
+
if (!res.ok) return res.message;
|
|
473
|
+
markTouched(ctx, ctx.config.schematic);
|
|
474
|
+
// embed the checker and score in the draft report (design D5): a
|
|
475
|
+
// draft-check-score iteration costs one tool call, and the embedded
|
|
476
|
+
// checker result drives the ledger obligation exactly like check_legibility
|
|
477
|
+
const docsAbs = path.join(ctx.repoRoot, ctx.config.docs);
|
|
478
|
+
const leg = await checkLegibility(res.schematicPath, {
|
|
479
|
+
docsDir: docsAbs,
|
|
480
|
+
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
481
|
+
});
|
|
482
|
+
ctx.lastLegibility = leg.counts;
|
|
483
|
+
ctx.ledger.onLegibilityResult(leg.counts.error);
|
|
484
|
+
const score = await scoreSchematic(res.schematicPath, {
|
|
485
|
+
docsDir: docsAbs,
|
|
486
|
+
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
487
|
+
});
|
|
488
|
+
ctx.lastScore = score.composite;
|
|
489
|
+
return [formatSchematicDraftReport(res.report), formatLegibility(leg), formatScore(score)].join('\n');
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
schema: {
|
|
494
|
+
name: 'score_schematic',
|
|
495
|
+
description:
|
|
496
|
+
'Deterministic quantitative legibility score for the schematic: composite 0-100 with the per-metric breakdown (crossings, bends, alignment, spacing, symmetry, balance, …). Error-severity legibility findings cap the composite. Advisory: informs, never gates by itself.',
|
|
497
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
498
|
+
},
|
|
499
|
+
requiresUnlock: false,
|
|
500
|
+
handler: async (ctx) => {
|
|
501
|
+
if (!ctx.config.schematic) return 'no schematic configured; score_schematic does not apply yet';
|
|
502
|
+
const report = await scoreSchematic(path.join(ctx.repoRoot, ctx.config.schematic), {
|
|
503
|
+
docsDir: path.join(ctx.repoRoot, ctx.config.docs),
|
|
504
|
+
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
505
|
+
});
|
|
506
|
+
ctx.lastScore = report.composite;
|
|
507
|
+
return formatScore(report);
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
schema: {
|
|
512
|
+
name: 'check_legibility',
|
|
513
|
+
description:
|
|
514
|
+
'Run the deterministic legibility checker against the schematic: group boxes and captions, symbol/text collisions, grid alignment, frame and title-block use. Returns numbered findings with coordinates and the concrete fix, or "no findings". Error-severity findings must be reconciled before finish; clears the legibility obligation when clean.',
|
|
515
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
516
|
+
},
|
|
517
|
+
requiresUnlock: false,
|
|
518
|
+
handler: async (ctx) => {
|
|
519
|
+
// Mirrors check_drift's vacuous path: with no schematic configured there is
|
|
520
|
+
// nothing to be illegible, and leaving the obligation open would deadlock
|
|
521
|
+
// any stage that edited a stray .kicad_sch without config wiring.
|
|
522
|
+
if (!ctx.config.schematic) {
|
|
523
|
+
ctx.ledger.clear('legibility');
|
|
524
|
+
return 'no schematic configured; legibility does not apply yet';
|
|
525
|
+
}
|
|
526
|
+
const report = await checkLegibility(path.join(ctx.repoRoot, ctx.config.schematic), {
|
|
527
|
+
docsDir: path.join(ctx.repoRoot, ctx.config.docs),
|
|
528
|
+
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
529
|
+
});
|
|
530
|
+
ctx.lastLegibility = report.counts;
|
|
531
|
+
ctx.ledger.onLegibilityResult(report.counts.error);
|
|
532
|
+
return formatLegibility(report);
|
|
533
|
+
},
|
|
534
|
+
},
|
|
347
535
|
{
|
|
348
536
|
schema: {
|
|
349
537
|
name: 'run_drc',
|
package/src/agent/transcript.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface RunSummaryData {
|
|
|
35
35
|
filesTouched: string[];
|
|
36
36
|
ercResult: string | null;
|
|
37
37
|
drcResult: string | null;
|
|
38
|
+
/** e.g. "0 error, 3 advisory finding(s)"; null when the checker never ran. */
|
|
39
|
+
legibilityResult?: string | null;
|
|
40
|
+
/** e.g. "87.5/100"; null when the scorer never ran (AC-16.21). */
|
|
41
|
+
scoreResult?: string | null;
|
|
38
42
|
decisions: string[];
|
|
39
43
|
tokensIn: number;
|
|
40
44
|
tokensOut: number;
|
|
@@ -111,6 +115,8 @@ export class Transcript {
|
|
|
111
115
|
``,
|
|
112
116
|
`- ERC: ${s.ercResult ?? 'not run'}`,
|
|
113
117
|
`- DRC: ${s.drcResult ?? 'not run'}`,
|
|
118
|
+
`- legibility: ${s.legibilityResult ?? 'not run'}`,
|
|
119
|
+
`- score: ${s.scoreResult ?? 'not run'}`,
|
|
114
120
|
``,
|
|
115
121
|
`## Decisions`,
|
|
116
122
|
``,
|
package/src/cli.ts
CHANGED
|
@@ -179,6 +179,77 @@ program
|
|
|
179
179
|
.description('ERC + DRC + doc-drift + spec validation; no LLM calls; CI-safe')
|
|
180
180
|
.action(checkAction);
|
|
181
181
|
|
|
182
|
+
// `draft` and `score` are command groups taking the artifact as a noun
|
|
183
|
+
// (`draft schematic` today, `draft pcb` when layout drafting exists), so the
|
|
184
|
+
// verb alone never has to guess what it applies to.
|
|
185
|
+
const draftGroup = program
|
|
186
|
+
.command('draft')
|
|
187
|
+
.description('deterministically draft an artifact from its declared intent; no LLM, no network');
|
|
188
|
+
draftGroup
|
|
189
|
+
.command('schematic')
|
|
190
|
+
.description('draft the schematic from schematic.intent.json')
|
|
191
|
+
.option('--intent <path>', 'repo-relative intent file (default: schematic.intent.json beside the schematic)')
|
|
192
|
+
.action(async (opts: { intent?: string }) => {
|
|
193
|
+
const repo = repoOf(program.opts());
|
|
194
|
+
const json = Boolean(program.opts().json);
|
|
195
|
+
try {
|
|
196
|
+
const { loadConfig } = await import('./config.js');
|
|
197
|
+
const { draftSchematic, defaultIntentPath, formatSchematicDraftReport } = await import('./kicad/draft/draft.js');
|
|
198
|
+
const config = await loadConfig(repo);
|
|
199
|
+
if (!config.schematic) {
|
|
200
|
+
console.error('no schematic configured in .copperhead/config.json');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
const res = await draftSchematic({
|
|
204
|
+
repoRoot: repo,
|
|
205
|
+
schematic: config.schematic,
|
|
206
|
+
intentPath: opts.intent ?? defaultIntentPath(config.schematic),
|
|
207
|
+
docsDir: config.docs,
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
if (json) console.log(JSON.stringify({ ok: false, findings: res.findings }, null, 2));
|
|
211
|
+
else console.error(res.message);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
if (json) console.log(JSON.stringify({ ok: true, report: res.report }, null, 2));
|
|
215
|
+
else console.log(formatSchematicDraftReport(res.report));
|
|
216
|
+
process.exit(0);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error((err as Error).message);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const scoreGroup = program
|
|
224
|
+
.command('score')
|
|
225
|
+
.description('quantitative quality score for an artifact; advisory exit code; no LLM, no network');
|
|
226
|
+
scoreGroup
|
|
227
|
+
.command('schematic')
|
|
228
|
+
.description('legibility and layout score for the schematic')
|
|
229
|
+
.action(async () => {
|
|
230
|
+
const repo = repoOf(program.opts());
|
|
231
|
+
const json = Boolean(program.opts().json);
|
|
232
|
+
try {
|
|
233
|
+
const { loadConfig } = await import('./config.js');
|
|
234
|
+
const { scoreSchematic, formatScore } = await import('./kicad/score.js');
|
|
235
|
+
const path = await import('node:path');
|
|
236
|
+
const config = await loadConfig(repo);
|
|
237
|
+
if (!config.schematic) {
|
|
238
|
+
console.error('no schematic configured in .copperhead/config.json');
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const report = await scoreSchematic(path.join(repo, config.schematic), {
|
|
242
|
+
docsDir: path.join(repo, config.docs),
|
|
243
|
+
...(config.legibility ? { config: config.legibility } : {}),
|
|
244
|
+
});
|
|
245
|
+
console.log(json ? JSON.stringify(report, null, 2) : formatScore(report));
|
|
246
|
+
process.exit(0); // the exit code never depends on the composite (AC-16.26 family)
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error((err as Error).message);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
182
253
|
program
|
|
183
254
|
.command('doctor')
|
|
184
255
|
.description('env preflight: kicad-cli, git, node, and the model provider credential; no LLM, no network')
|
package/src/commands/check.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { runErc, runDrc } from '../kicad/cli.js';
|
|
|
5
5
|
import { formatViolations, type CheckReport } from '../kicad/report.js';
|
|
6
6
|
import { checkDrift, emptySchematicWarning, type DriftMismatch } from '../memory/drift.js';
|
|
7
7
|
import { loadConstraints, checkForbiddenPins, type ConstraintViolation } from '../memory/constraints.js';
|
|
8
|
-
import { pinNets } from '../kicad/sexp.js';
|
|
8
|
+
import { pinNets, readSheetGeometry } from '../kicad/sexp.js';
|
|
9
|
+
import { scoreFromGeometry, type ScoreReport } from '../kicad/score.js';
|
|
10
|
+
import { checkLegibility, formatLegibility, LEGIBILITY_FAMILIES, type LegibilityFinding } from '../kicad/legibility.js';
|
|
9
11
|
import { openspecValidate } from '../openspec/cli.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -19,6 +21,20 @@ export interface CheckResult {
|
|
|
19
21
|
drift: { ok: boolean; mismatches: DriftMismatch[]; warning?: string };
|
|
20
22
|
openspec: { ok: boolean; detail: string } | null;
|
|
21
23
|
constraints: { ok: boolean; violations: ConstraintViolation[] };
|
|
24
|
+
/**
|
|
25
|
+
* Advisory at every severity (design C6): findings inform, the exit code
|
|
26
|
+
* never depends on them, so existing repos gain information, not failures.
|
|
27
|
+
* Always present — all families skipped when no schematic is configured.
|
|
28
|
+
*/
|
|
29
|
+
legibility: {
|
|
30
|
+
findings: LegibilityFinding[];
|
|
31
|
+
counts: { error: number; advisory: number };
|
|
32
|
+
skipped: { family: string; reason: string }[];
|
|
33
|
+
disabled: string[];
|
|
34
|
+
suppressed: { family: string; sheet: string; count: number }[];
|
|
35
|
+
/** Advisory quantitative score; null when no schematic is configured. */
|
|
36
|
+
score: ScoreReport | null;
|
|
37
|
+
};
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
export async function runCheck(repoRoot: string, log: (s: string) => void): Promise<CheckResult> {
|
|
@@ -59,6 +75,39 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
|
|
|
59
75
|
log(res.ok ? 'openspec ✓' : `openspec: ${res.output}`);
|
|
60
76
|
}
|
|
61
77
|
|
|
78
|
+
let legibility: CheckResult['legibility'];
|
|
79
|
+
if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) {
|
|
80
|
+
const report = await checkLegibility(path.join(repoRoot, config.schematic), {
|
|
81
|
+
docsDir: path.join(repoRoot, config.docs),
|
|
82
|
+
...(config.legibility ? { config: config.legibility } : {}),
|
|
83
|
+
});
|
|
84
|
+
const score = scoreFromGeometry(
|
|
85
|
+
await readSheetGeometry(path.join(repoRoot, config.schematic)),
|
|
86
|
+
report,
|
|
87
|
+
config.legibility,
|
|
88
|
+
);
|
|
89
|
+
legibility = {
|
|
90
|
+
findings: report.findings,
|
|
91
|
+
counts: report.counts,
|
|
92
|
+
skipped: report.skipped,
|
|
93
|
+
disabled: report.disabled,
|
|
94
|
+
suppressed: report.suppressed,
|
|
95
|
+
score,
|
|
96
|
+
};
|
|
97
|
+
log(formatLegibility(report));
|
|
98
|
+
log(`legibility score: ${score.composite}/100${score.cap ? ` (capped: ${score.cap.reason})` : ''}`);
|
|
99
|
+
} else {
|
|
100
|
+
legibility = {
|
|
101
|
+
findings: [],
|
|
102
|
+
counts: { error: 0, advisory: 0 },
|
|
103
|
+
skipped: LEGIBILITY_FAMILIES.map((family) => ({ family, reason: 'no schematic configured' })),
|
|
104
|
+
disabled: [],
|
|
105
|
+
suppressed: [],
|
|
106
|
+
score: null,
|
|
107
|
+
};
|
|
108
|
+
log('legibility skipped (no schematic configured)');
|
|
109
|
+
}
|
|
110
|
+
|
|
62
111
|
let constraintViolations: ConstraintViolation[] = [];
|
|
63
112
|
if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) {
|
|
64
113
|
const registry = await loadConstraints(repoRoot);
|
|
@@ -87,5 +136,6 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
|
|
|
87
136
|
drift: { ok: drift.length === 0, mismatches: drift, ...(driftWarning ? { warning: driftWarning } : {}) },
|
|
88
137
|
openspec,
|
|
89
138
|
constraints: { ok: constraintViolations.length === 0, violations: constraintViolations },
|
|
139
|
+
legibility,
|
|
90
140
|
};
|
|
91
141
|
}
|