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
@@ -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 { verifySchematicSymbols } from '../kicad/symlib.js';
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',
@@ -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')
@@ -202,7 +273,7 @@ program
202
273
  .command('do')
203
274
  .description('the core loop: propose, edit, verify, propagate, commit')
204
275
  .argument('<request>', 'the change request in natural language')
205
- .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
276
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code | compat:<id> (or a provider-specific model id)')
206
277
  .option('--max-turns <n>', 'turn budget for this run')
207
278
  .option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
208
279
  .option('--dry-run', 'propose the diff, write nothing')
@@ -327,7 +398,7 @@ program
327
398
  .command('create')
328
399
  .description('Mode A: full pipeline from a product brief to the output package')
329
400
  .requiredOption('--brief <file>', 'product brief (markdown)')
330
- .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
401
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code | compat:<id> (or a provider-specific model id)')
331
402
  .option('--interactive', 're-enable the human gates (spec approval, pre-export)')
332
403
  .action(async (opts: { brief: string; model?: string; interactive?: boolean }) => {
333
404
  const repo = repoOf(program.opts());
@@ -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
  }