copperhead 0.9.0 → 0.11.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
@@ -0,0 +1,416 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { SymbolSource, SymbolResolutionError, powerNetToken, type ResolvedSymbol } from './symsource.js';
5
+ import { parseBomTable, normalizeValue } from '../../memory/bom-table.js';
6
+
7
+ /**
8
+ * The netlist-intent IR (`schematic.intent.json`): the compact declarative
9
+ * input the model authors instead of geometry (design D5/D6). No coordinates,
10
+ * ever — the engine computes every position. Re-drafting the same IR fully
11
+ * regenerates the sheet.
12
+ */
13
+
14
+ export const INTENT_VERSION = 1;
15
+ export const INTENT_FILENAME = 'schematic.intent.json';
16
+
17
+ export interface IntentPart {
18
+ ref: string;
19
+ libId: string;
20
+ value: string;
21
+ footprint?: string;
22
+ /** Subsystem group (from SUBSYSTEMS.md); required for every non-power part. */
23
+ group: string;
24
+ }
25
+
26
+ export interface IntentNet {
27
+ name: string;
28
+ /** Endpoints as `"REF.PIN"` (pin number). */
29
+ pins: string[];
30
+ /** Optional class override; wins over pin-type inference (engine spec). */
31
+ kind?: 'power' | 'ground' | 'signal';
32
+ }
33
+
34
+ export interface SchematicIntent {
35
+ version: number;
36
+ parts: IntentPart[];
37
+ nets: IntentNet[];
38
+ /** Pins deliberately unconnected; emitted as `(no_connect …)` markers. */
39
+ noConnect?: string[];
40
+ hints?: {
41
+ /** Left-to-right group order override; otherwise SUBSYSTEMS.md order. */
42
+ groupOrder?: string[];
43
+ /** Pin the paper size instead of deriving it from content. */
44
+ paper?: string;
45
+ /** Title-block date. Part of the IR (not the wall clock) so identical IR
46
+ * emits identical bytes on any day (design D4). */
47
+ date?: string;
48
+ };
49
+ }
50
+
51
+ export interface IrFinding {
52
+ detail: string;
53
+ }
54
+
55
+ export interface ValidatedIntent {
56
+ intent: SchematicIntent;
57
+ symbols: Map<string, ResolvedSymbol>;
58
+ /** SUBSYSTEMS.md heading names in declaration order, or null when absent. */
59
+ docGroups: string[] | null;
60
+ }
61
+
62
+ /** `## Heading` names from a markdown file, in order. */
63
+ async function headingsOf(file: string): Promise<string[] | null> {
64
+ if (!existsSync(file)) return null;
65
+ const text = await readFile(file, 'utf8');
66
+ const names = [...text.matchAll(/^#{2,6}\s+(.+?)\s*$/gm)].map((m) => m[1]!.trim()).filter(Boolean);
67
+ return names.length ? names : null;
68
+ }
69
+
70
+ /**
71
+ * BOM.md rows as { ref, value }, or null when the file/table is absent.
72
+ *
73
+ * Goes through the shared canonical reader (`parseBomTable`) rather than
74
+ * scanning every pipe-line in the file: the old inline scan read a supporting
75
+ * table's rows (a quiescent-current roll-up, a cost summary) as parts.
76
+ * `parseBomTable` reads only the Refdes-headed canonical table(s) and resolves
77
+ * Value by header *name*, the same discipline `checkDrift` and `export bom`
78
+ * already use.
79
+ *
80
+ * One row per refdes: a cell naming several parts (`R1, R2`, `C5-C8`) is read
81
+ * verbatim and simply matches nothing, because the BOM contract is one row per
82
+ * part — the row's single Rationale cell is where that part's purpose lives.
83
+ */
84
+ async function bomRowsOf(file: string): Promise<{ ref: string; value: string }[] | null> {
85
+ if (!existsSync(file)) return null;
86
+ const rows = parseBomTable(await readFile(file, 'utf8')).map((r) => ({ ref: r.refdes, value: r.value ?? '' }));
87
+ return rows.length ? rows : null;
88
+ }
89
+
90
+ /**
91
+ * Is this BOM Value cell a component *value*, or a description?
92
+ *
93
+ * The Value cell is drawn on the sheet as the symbol's Value field, so its
94
+ * length is a layout input, not just documentation. A cell like
95
+ * `1S Li-Po cell, 500 mAh, bare leads` renders as 34 characters beside a
96
+ * battery symbol and collides with its neighbours — an error-severity
97
+ * legibility finding that `create`'s stage-4 contract refuses to pass.
98
+ *
99
+ * That is unrecoverable without this check. The IR is the agent's only lever
100
+ * on the drawn sheet (`edit_file` is refused on a drafted schematic), and the
101
+ * BOM cross-check below pins the IR value to this cell — so shortening the
102
+ * value in the IR fails validation, and leaving it fails legibility. The run
103
+ * that found this burned all three stage-4 attempts and 2h49m discovering the
104
+ * loop, twice, because the legibility findings never named BOM.md as the thing
105
+ * to change (see the deadlock issue).
106
+ *
107
+ * Heuristic, deliberately loose — it fires only on cells that are clearly
108
+ * prose, so a legitimately long value (`STM32F103C8T6`,
109
+ * `JST_PH_S2B-PH-K_1x02_P2.00mm`) is left alone:
110
+ * - a comma-separated clause list where a later clause contains a space
111
+ * (`4.7uF, X5R, 10V, 0603` is a value; `P-MOSFET, divider gate` is prose), or
112
+ * - a cell too long to draw (> MAX_DRAWN_VALUE chars) that contains a space.
113
+ * A single unbroken token over the cap (`JST_PH_S2B-PH-K_1x02_P2.00mm`,
114
+ * 28 chars) is a real part identifier with no prose to move into Rationale,
115
+ * so length alone never refuses it — a wide Value field costs legibility
116
+ * score, but a hard refusal with no escape hatch would wedge the pipeline.
117
+ */
118
+ const MAX_DRAWN_VALUE = 24;
119
+
120
+ export function looksLikeDescription(value: string): boolean {
121
+ const v = value.replace(/`/g, '').trim();
122
+ if (!v) return false;
123
+ if (v.length > MAX_DRAWN_VALUE && v.includes(' ')) return true;
124
+ const clauses = v.split(',').map((c) => c.trim());
125
+ return clauses.length > 1 && clauses.slice(1).some((c) => c.includes(' ') && /[a-z]{3}/i.test(c));
126
+ }
127
+
128
+ export function parseIntent(json: string): { intent: SchematicIntent | null; findings: IrFinding[] } {
129
+ let raw: unknown;
130
+ try {
131
+ raw = JSON.parse(json);
132
+ } catch (e) {
133
+ return { intent: null, findings: [{ detail: `intent is not valid JSON: ${(e as Error).message}` }] };
134
+ }
135
+ if (raw === null || typeof raw !== 'object') {
136
+ return { intent: null, findings: [{ detail: 'intent must be a JSON object' }] };
137
+ }
138
+ const o = raw as Record<string, unknown>;
139
+ if (o.version !== INTENT_VERSION) {
140
+ return {
141
+ intent: null,
142
+ findings: [{ detail: `unsupported intent version ${JSON.stringify(o.version)}; this engine supports version ${INTENT_VERSION}` }],
143
+ };
144
+ }
145
+ if (!Array.isArray(o.parts) || !Array.isArray(o.nets)) {
146
+ return { intent: null, findings: [{ detail: 'intent needs "parts" and "nets" arrays' }] };
147
+ }
148
+ return { intent: raw as SchematicIntent, findings: [] };
149
+ }
150
+
151
+ /**
152
+ * Validate the IR before any placement (design D6): structural checks, lib
153
+ * resolution, pin existence, group membership against SUBSYSTEMS.md, no-connect
154
+ * consistency, and the BOM.md cross-check. A failed validation means nothing is
155
+ * written; findings come back numbered in the verify_symbols shape.
156
+ */
157
+ export async function validateIntent(
158
+ intent: SchematicIntent,
159
+ symsource: SymbolSource,
160
+ docsDir: string | null,
161
+ ): Promise<{ ok: boolean; findings: IrFinding[]; validated: ValidatedIntent | null }> {
162
+ const findings: IrFinding[] = [];
163
+ const add = (detail: string): void => {
164
+ findings.push({ detail });
165
+ };
166
+
167
+ // parts: shape, duplicates, lib resolution. Field TYPES are checked here so a
168
+ // type-confused-but-valid JSON part ("group": 5) comes back as a numbered
169
+ // finding the model can act on, not a TypeError surfaced as an opaque tool
170
+ // error — the repair contract matters most exactly when the input is wrong.
171
+ const symbols = new Map<string, ResolvedSymbol>();
172
+ const partByRef = new Map<string, IntentPart>();
173
+ for (const p of intent.parts) {
174
+ if (typeof (p as unknown as { ref?: unknown })?.ref !== 'string' || !p.ref || typeof p.libId !== 'string' || !p.libId || typeof p.value !== 'string') {
175
+ add(`part ${JSON.stringify(p?.ref ?? '(missing ref)')} needs string ref, libId, and value fields`);
176
+ continue;
177
+ }
178
+ if (p.group !== undefined && typeof p.group !== 'string') {
179
+ add(`${p.ref}: "group" must be a string (a SUBSYSTEMS.md heading), got ${JSON.stringify(p.group)}`);
180
+ continue;
181
+ }
182
+ if (p.footprint !== undefined && typeof p.footprint !== 'string') {
183
+ add(`${p.ref}: "footprint" must be a string, got ${JSON.stringify(p.footprint)}`);
184
+ continue;
185
+ }
186
+ if (partByRef.has(p.ref)) {
187
+ add(`duplicate refdes ${p.ref}`);
188
+ continue;
189
+ }
190
+ partByRef.set(p.ref, p);
191
+ try {
192
+ const sym = await symsource.resolve(p.libId);
193
+ // A multi-unit symbol (an opamp, a gate pack) is placed one unit at a
194
+ // time: units share symbol-space pin coordinates, so a single placed
195
+ // instance would overlay unrelated pins on one point and silently merge
196
+ // their nets (an LM358 drafted that way fused its two outputs). Common
197
+ // (unit-0) pins are fine — KiCad draws them on every placed unit and
198
+ // the engine wires each appearance to the pin's one net. What is
199
+ // refused is a pin number repeated across NUMBERED units (an ambiguous
200
+ // endpoint), and a pin living only in a de Morgan alternate (it would
201
+ // vanish from the drawn sheet).
202
+ if (sym.multiUnit) {
203
+ const common = new Set(sym.commonUnitPins ?? []);
204
+ const owner = new Map<string, number>();
205
+ const shared = new Set<string>();
206
+ for (const u of sym.units ?? []) {
207
+ for (const pin of u.pins) {
208
+ if (common.has(pin.number)) continue;
209
+ const prev = owner.get(pin.number);
210
+ if (prev !== undefined && prev !== u.unit) shared.add(pin.number);
211
+ owner.set(pin.number, u.unit);
212
+ }
213
+ }
214
+ if (shared.size) {
215
+ add(
216
+ `${p.ref}: "${p.libId}" repeats pin(s) ${[...shared].sort().join(', ')} in more than one unit, so a ` +
217
+ `net endpoint cannot be mapped to a single placed unit; the drafting engine cannot place this symbol`,
218
+ );
219
+ continue;
220
+ }
221
+ const unmapped = [...new Set(sym.pins.map((pin) => pin.number))].filter(
222
+ (n) => !owner.has(n) && !common.has(n),
223
+ );
224
+ if (unmapped.length) {
225
+ add(
226
+ `${p.ref}: "${p.libId}" defines pin(s) ${unmapped.sort().join(', ')} outside its default-style units, ` +
227
+ `so the drafting engine cannot place them; use a variant with conventional unit structure`,
228
+ );
229
+ continue;
230
+ }
231
+ }
232
+ symbols.set(p.ref, sym);
233
+ } catch (e) {
234
+ if (e instanceof SymbolResolutionError) add(`${p.ref}: ${e.message}`);
235
+ else throw e;
236
+ }
237
+ }
238
+
239
+ // groups: exactly one per non-power part, validated against SUBSYSTEMS.md when present
240
+ const docGroups = docsDir ? await headingsOf(path.join(docsDir, 'SUBSYSTEMS.md')) : null;
241
+ for (const p of partByRef.values()) {
242
+ const sym = symbols.get(p.ref);
243
+ if (sym?.isPower) continue;
244
+ if (!p.group) {
245
+ add(`${p.ref} has no group assignment; available groups: ${docGroups?.join(', ') ?? '(SUBSYSTEMS.md absent)'}`);
246
+ } else if (docGroups && !docGroups.some((g) => g.toLowerCase() === p.group.toLowerCase())) {
247
+ add(`${p.ref} names group "${p.group}", which is not a SUBSYSTEMS.md heading; available: ${docGroups.join(', ')}`);
248
+ }
249
+ }
250
+
251
+ // nets: endpoints exist, at least two of them
252
+ const pinKey = (ref: string, pin: string): string => `${ref}.${pin}`;
253
+ const usedPins = new Set<string>();
254
+ const netNames = new Set<string>();
255
+ for (const net of intent.nets) {
256
+ if (typeof (net as unknown as { name?: unknown })?.name !== 'string' || !net.name || !Array.isArray(net.pins)) {
257
+ add(`net ${JSON.stringify(net?.name ?? '(unnamed)')} needs a string name and a pins array`);
258
+ continue;
259
+ }
260
+ // The name is drawn as label text and embedded verbatim in the generated
261
+ // power-symbol source; a quote, backslash, or control character would emit
262
+ // a structurally corrupt schematic, so it dies here as a finding instead.
263
+ if ([...net.name].some((c) => c === '"' || c === '\\' || c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) {
264
+ add(`net name ${JSON.stringify(net.name)} contains characters that cannot be drawn (quotes, backslashes, or control characters); rename the net`);
265
+ continue;
266
+ }
267
+ if (net.kind !== undefined && net.kind !== 'power' && net.kind !== 'ground' && net.kind !== 'signal') {
268
+ add(`net ${net.name}: "kind" must be "power", "ground", or "signal", got ${JSON.stringify(net.kind)}`);
269
+ }
270
+ if (netNames.has(net.name)) add(`duplicate net name ${net.name}`);
271
+ netNames.add(net.name);
272
+ if (net.pins.length < 2) add(`net ${net.name} has ${net.pins.length} endpoint(s); a net needs at least two`);
273
+ for (const ep of net.pins) {
274
+ if (typeof ep !== 'string') {
275
+ add(`net ${net.name}: endpoint ${JSON.stringify(ep)} must be a "REF.PIN" string`);
276
+ continue;
277
+ }
278
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
279
+ if (!m) {
280
+ add(`net ${net.name}: endpoint "${ep}" is not of the form REF.PIN`);
281
+ continue;
282
+ }
283
+ const [, ref, pin] = m;
284
+ const sym = symbols.get(ref!);
285
+ if (!partByRef.has(ref!)) {
286
+ add(`net ${net.name}: endpoint "${ep}" references unknown part ${ref}`);
287
+ continue;
288
+ }
289
+ if (sym && !sym.pins.some((p) => p.number === pin)) {
290
+ add(`net ${net.name}: ${ref} has no pin ${pin}; its pins are [${sym.pins.map((p) => p.number).join(', ')}]`);
291
+ }
292
+ if (usedPins.has(pinKey(ref!, pin!))) add(`pin ${ep} appears in more than one net`);
293
+ usedPins.add(pinKey(ref!, pin!));
294
+ }
295
+ }
296
+
297
+ // Two power-class nets whose names sanitize to one symbol token would share
298
+ // one generated `copperhead_power:` lib_id and one embedded pin name, so
299
+ // KiCad's netlist can quietly merge the rails (last write wins in the
300
+ // lib_symbols map). Refused here as a finding, before any placement.
301
+ const isPowerClass = (net: IntentNet): boolean => {
302
+ if (net.kind === 'power' || net.kind === 'ground') return true;
303
+ if (net.kind === 'signal') return false;
304
+ return net.pins.some((ep) => {
305
+ const m = typeof ep === 'string' ? /^([^.]+)\.(.+)$/.exec(ep) : null;
306
+ if (!m) return false;
307
+ const pin = symbols.get(m[1]!)?.pins.find((pn) => pn.number === m[2]);
308
+ return pin !== undefined && (pin.etype === 'power_in' || pin.etype === 'power_out');
309
+ });
310
+ };
311
+ const byToken = new Map<string, Set<string>>();
312
+ for (const net of intent.nets) {
313
+ if (typeof (net as unknown as { name?: unknown })?.name !== 'string' || !Array.isArray(net.pins)) continue;
314
+ if (!isPowerClass(net)) continue;
315
+ const token = powerNetToken(net.name);
316
+ const names = byToken.get(token) ?? new Set<string>();
317
+ names.add(net.name);
318
+ byToken.set(token, names);
319
+ }
320
+ for (const [token, names] of byToken) {
321
+ if (names.size > 1) {
322
+ add(
323
+ `power nets ${[...names].sort().join(' and ')} both sanitize to the symbol token "${token}", ` +
324
+ `which would give them one shared power symbol and merge the rails; rename one so the tokens differ`,
325
+ );
326
+ }
327
+ }
328
+
329
+ // no-connects: an array of REF.PIN strings; each pin exists and is not in any net
330
+ const noConnect = intent.noConnect ?? [];
331
+ if (!Array.isArray(noConnect) || noConnect.some((e) => typeof e !== 'string')) {
332
+ add(`"noConnect" must be an array of "REF.PIN" strings, got ${JSON.stringify(intent.noConnect)}`);
333
+ } else {
334
+ for (const ep of noConnect) {
335
+ const m = /^([^.]+)\.(.+)$/.exec(ep);
336
+ if (!m) {
337
+ add(`noConnect entry "${ep}" is not of the form REF.PIN`);
338
+ continue;
339
+ }
340
+ const [, ref, pin] = m;
341
+ const sym = symbols.get(ref!);
342
+ if (!partByRef.has(ref!)) add(`noConnect "${ep}" references unknown part ${ref}`);
343
+ else if (sym && !sym.pins.some((p) => p.number === pin)) add(`noConnect "${ep}": ${ref} has no pin ${pin}`);
344
+ if (usedPins.has(`${ref}.${pin}`)) add(`pin ${ep} is declared no-connect but appears in a net`);
345
+ }
346
+ }
347
+
348
+ // hints: every field the engine or emitter dereferences is type-checked here,
349
+ // so "hints": {"groupOrder": "Power"} steers back as a finding instead of a
350
+ // TypeError inside the placement pass
351
+ const hints = intent.hints;
352
+ if (hints !== undefined) {
353
+ if (hints === null || typeof hints !== 'object' || Array.isArray(hints)) {
354
+ add(`"hints" must be an object, got ${JSON.stringify(hints)}`);
355
+ } else {
356
+ if (hints.groupOrder !== undefined && (!Array.isArray(hints.groupOrder) || hints.groupOrder.some((g) => typeof g !== 'string'))) {
357
+ add(`"hints.groupOrder" must be an array of group-name strings, got ${JSON.stringify(hints.groupOrder)}`);
358
+ }
359
+ if (hints.paper !== undefined && typeof hints.paper !== 'string') {
360
+ add(`"hints.paper" must be a string (a standard sheet name like "A3"), got ${JSON.stringify(hints.paper)}`);
361
+ }
362
+ if (hints.date !== undefined && typeof hints.date !== 'string') {
363
+ add(`"hints.date" must be a string, got ${JSON.stringify(hints.date)}`);
364
+ }
365
+ }
366
+ }
367
+
368
+ // BOM cross-check: a transcription slip dies here, not at the drift gate (D6)
369
+ const bomRows = docsDir ? await bomRowsOf(path.join(docsDir, 'BOM.md')) : null;
370
+ if (bomRows) {
371
+ const bomByRef = new Map(bomRows.map((r) => [r.ref, r.value]));
372
+ for (const p of partByRef.values()) {
373
+ if (symbols.get(p.ref)?.isPower) continue;
374
+ const bomValue = bomByRef.get(p.ref);
375
+ if (bomValue === undefined) {
376
+ add(`${p.ref} is not a BOM.md row; add it to the BOM or drop it from the intent`);
377
+ continue;
378
+ }
379
+ // Drawability first, and independently of whether the two agree. Whether
380
+ // the cell can be drawn is a property of BOM.md alone, and the agent's
381
+ // first instinct on an unreadable sheet is to shorten the value in the
382
+ // intent — which makes the two differ. Reporting this only on a match
383
+ // answers that instinct with "differs from BOM.md's …" and never names the
384
+ // real problem, which is the loop this check exists to break: the fix is in
385
+ // the doc, not the intent.
386
+ if (looksLikeDescription(bomValue)) {
387
+ add(
388
+ `${p.ref}: BOM.md's Value is a description, not a component value ("${bomValue}"). ` +
389
+ `It is drawn on the sheet as ${p.ref}'s Value field, where it collides with neighbouring symbols ` +
390
+ `and fails the legibility gate — and you cannot shorten it in the intent alone, because this ` +
391
+ `cross-check requires the two to agree. Fix docs/BOM.md: put the component value in the Value column ` +
392
+ `(e.g. "500mAh Li-Po", "4.7uF", "1M") and move the prose to the Rationale column, then update the intent to match.`,
393
+ );
394
+ continue; // the value mismatch below would be noise next to this
395
+ }
396
+ // Compared through the shared value key, not raw bytes: `1 MΩ` vs `1 Mohm`
397
+ // and `4.7uF` vs `4.7 µF` are encoding differences with no electrical
398
+ // meaning. `checkDrift` has always folded them (normalizeValue); this gate
399
+ // running stricter than the gate downstream of it meant an IR that would
400
+ // pass drift could still be refused here, with no way to satisfy both.
401
+ if (normalizeValue(bomValue) !== normalizeValue(p.value)) {
402
+ add(`${p.ref} value "${p.value}" differs from BOM.md's "${bomValue}"`);
403
+ }
404
+ }
405
+ }
406
+
407
+ if (findings.length) return { ok: false, findings, validated: null };
408
+ return { ok: true, findings: [], validated: { intent, symbols, docGroups } };
409
+ }
410
+
411
+ export function formatIrFindings(findings: IrFinding[]): string {
412
+ return [
413
+ `intent validation: ${findings.length} finding(s) to reconcile:`,
414
+ ...findings.map((f, i) => ` ${i + 1}. ${f.detail}`),
415
+ ].join('\n');
416
+ }