copperhead 0.4.0 → 0.6.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 (42) hide show
  1. package/README.md +51 -1
  2. package/dist/agent/loop.js +44 -4
  3. package/dist/agent/loop.js.map +1 -1
  4. package/dist/agent/providers/claude-code.js +286 -0
  5. package/dist/agent/providers/claude-code.js.map +1 -0
  6. package/dist/agent/providers/codex.js +292 -0
  7. package/dist/agent/providers/codex.js.map +1 -0
  8. package/dist/agent/providers/openai.js +30 -10
  9. package/dist/agent/providers/openai.js.map +1 -1
  10. package/dist/cli.js +47 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/create.js +16 -0
  13. package/dist/commands/create.js.map +1 -1
  14. package/dist/commands/export.js +90 -0
  15. package/dist/commands/export.js.map +1 -0
  16. package/dist/config.js +17 -7
  17. package/dist/config.js.map +1 -1
  18. package/dist/kicad/bom-export.js +240 -0
  19. package/dist/kicad/bom-export.js.map +1 -0
  20. package/dist/kicad/fab.js +94 -0
  21. package/dist/kicad/fab.js.map +1 -0
  22. package/dist/memory/bom-table.js +61 -0
  23. package/dist/memory/bom-table.js.map +1 -0
  24. package/dist/memory/drift.js +1 -17
  25. package/dist/memory/drift.js.map +1 -1
  26. package/dist/memory/scaffold.js +2 -1
  27. package/dist/memory/scaffold.js.map +1 -1
  28. package/package.json +17 -2
  29. package/src/agent/loop.ts +49 -4
  30. package/src/agent/providers/claude-code.ts +367 -0
  31. package/src/agent/providers/codex.ts +339 -0
  32. package/src/agent/providers/openai.ts +33 -16
  33. package/src/agent/types.ts +2 -0
  34. package/src/cli.ts +52 -2
  35. package/src/commands/create.ts +15 -0
  36. package/src/commands/export.ts +117 -0
  37. package/src/config.ts +23 -7
  38. package/src/kicad/bom-export.ts +321 -0
  39. package/src/kicad/fab.ts +121 -0
  40. package/src/memory/bom-table.ts +78 -0
  41. package/src/memory/drift.ts +1 -22
  42. package/src/memory/scaffold.ts +2 -1
package/src/config.ts CHANGED
@@ -14,6 +14,12 @@ export interface CopperheadConfig {
14
14
  budgets: Record<string, number>;
15
15
  /** Content hashes of generated docs, for init idempotency (AC-1.4). */
16
16
  generatedHashes?: Record<string, string>;
17
+ /**
18
+ * How the repo was bootstrapped. `"create"` marks a Mode A pipeline repo
19
+ * (fab gate requires DEVPLAN.md). Written by `copperhead create`; absent on
20
+ * init-only / hand-maintained repos.
21
+ */
22
+ origin?: 'create' | 'init';
17
23
  }
18
24
 
19
25
  export const CONFIG_DIR = '.copperhead';
@@ -51,6 +57,7 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
51
57
  maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
52
58
  budgets: raw.budgets ?? {},
53
59
  ...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
60
+ ...(raw.origin === 'create' || raw.origin === 'init' ? { origin: raw.origin } : {}),
54
61
  };
55
62
  }
56
63
 
@@ -70,18 +77,27 @@ export interface ResolvedModel {
70
77
  * Accepted values (same set for `--model`, COPPERHEAD_MODEL, and `model` in
71
78
  * .copperhead/config.json):
72
79
  *
73
- * - `claude` : the Anthropic provider on its default model.
74
- * - `claude-*`: any Anthropic model id, passed through verbatim, e.g.
80
+ * - `claude-code` : the Claude Code saved-login provider on its default
81
+ * model. Needs NO API key it reuses the logged-in Claude
82
+ * Code CLI / CLAUDE_CODE_OAUTH_TOKEN via the Agent SDK.
83
+ * - `claude-code:<id>`: the same provider on a specific model id.
84
+ * - `claude` : the Anthropic API provider on its default model.
85
+ * - `claude-*`: any Anthropic API model id, passed through verbatim, e.g.
75
86
  * `claude-opus-4-5`. Anything starting with `claude` routes here.
87
+ * - `codex` : the locally installed Codex CLI using its saved ChatGPT login.
88
+ * - `codex:*` : Codex CLI with an explicit model id, e.g. `codex:gpt-5.6`.
76
89
  * - `gpt-5` : the OpenAI provider on its default model.
77
90
  * - anything else: sent to the OpenAI provider verbatim as a model id, e.g.
78
91
  * `gpt-5-mini` or `o3`.
79
92
  *
80
93
  * Routing is prefix-based, not a fixed list (see makeProvider in agent/loop.ts),
81
- * so a model released after this build still works without a code change. The
82
- * cost is that a typo like `claud-sonnet-5` silently routes to OpenAI and fails
83
- * there. The chosen provider must have its key set: ANTHROPIC_API_KEY for
84
- * `claude*`, OPENAI_API_KEY otherwise.
94
+ * matched top to bottom: `claude-code`/`claude-code:<id>` is checked BEFORE the
95
+ * `claude*` prefix, so it is never captured by the Anthropic API route. A model
96
+ * released after this build still works without a code change. The cost is that
97
+ * a typo like `claud-sonnet-5` silently routes to OpenAI and fails there.
98
+ * Anthropic and direct OpenAI providers require their API keys; `codex` requires
99
+ * a locally installed and authenticated Codex CLI, and `claude-code` requires a
100
+ * Claude Code login (CLAUDE_CODE_OAUTH_TOKEN); neither needs a model API key.
85
101
  */
86
102
  export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env): ResolvedModel {
87
103
  if (flag) return { model: flag, source: 'flag' };
@@ -90,6 +106,6 @@ export function resolveModel(flag: string | undefined, config: CopperheadConfig,
90
106
  if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
91
107
  if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
92
108
  throw new Error(
93
- 'no model configured: pass --model, set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
109
+ 'no model configured: pass --model codex (uses your local Codex login), set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
94
110
  );
95
111
  }
@@ -0,0 +1,321 @@
1
+ import { parseMarkdownTables, type TableRow } from '../memory/bom-table.js';
2
+
3
+ /**
4
+ * Supplier-format BOM export (capability supplier-bom-export). Deterministic,
5
+ * LLM-free, network-free: a pure transformation of BOM.md into files a supplier
6
+ * accepts without hand-editing. BOM.md is the sole input (design D1) — it is
7
+ * already drift-checked against the schematic, so exports inherit that
8
+ * consistency guarantee.
9
+ */
10
+
11
+ export type Supplier = 'jlcpcb' | 'digikey' | 'mouser';
12
+
13
+ export const SUPPLIERS: readonly Supplier[] = ['jlcpcb', 'digikey', 'mouser'];
14
+
15
+ /** CLI defaults for the quantity flags, shared so the "ignored for jlcpcb"
16
+ * note fires only when the user actually set a non-default value. */
17
+ export const DEFAULT_BOARDS = 1;
18
+ export const DEFAULT_SPARES = 10;
19
+
20
+ export function isSupplier(s: string): s is Supplier {
21
+ return (SUPPLIERS as readonly string[]).includes(s);
22
+ }
23
+
24
+ export interface BomRow {
25
+ refdes: string;
26
+ value: string;
27
+ footprint: string;
28
+ /** MPN column value as written (may be a placeholder like "UNVERIFIED"). */
29
+ mpn: string;
30
+ manufacturer: string;
31
+ /** LCSC part number when a column carries it, else ''. */
32
+ lcsc: string;
33
+ /** True when any cell carries the standalone token UNVERIFIED. */
34
+ unverified: boolean;
35
+ /** True when the MPN column carries an orderable part number (not a placeholder). */
36
+ hasMpn: boolean;
37
+ }
38
+
39
+ // Header aliases → canonical field. Matched after normalizing a header cell to
40
+ // lowercase alphanumerics, so "LCSC Part #" and "lcsc_part" both hit `lcsc`.
41
+ const HEADER_ALIASES: Record<string, keyof Pick<BomRow, 'refdes' | 'value' | 'footprint' | 'mpn' | 'manufacturer' | 'lcsc'>> = {
42
+ refdes: 'refdes',
43
+ ref: 'refdes',
44
+ designator: 'refdes',
45
+ reference: 'refdes',
46
+ value: 'value',
47
+ comment: 'value',
48
+ val: 'value',
49
+ footprint: 'footprint',
50
+ package: 'footprint',
51
+ mpn: 'mpn',
52
+ manufacturerpartnumber: 'mpn',
53
+ mfrpartnumber: 'mpn',
54
+ mfrpart: 'mpn',
55
+ partnumber: 'mpn',
56
+ manufacturer: 'manufacturer',
57
+ mfr: 'manufacturer',
58
+ lcsc: 'lcsc',
59
+ lcscpart: 'lcsc',
60
+ lcscpartnumber: 'lcsc',
61
+ };
62
+
63
+ const norm = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
64
+
65
+ // MPN cells that mean "no orderable part number yet". `UNVERIFIED` is the
66
+ // init/scaffold placeholder (src/memory/scaffold.ts writes it into the MPN
67
+ // column for every extracted symbol); the rest are common human shorthand.
68
+ const MPN_PLACEHOLDERS = new Set(['', 'unverified', 'tbd', 'todo', 'tbc', 'na', 'none', '-', '—', '?']);
69
+
70
+ const isMpnPlaceholder = (mpn: string): boolean => MPN_PLACEHOLDERS.has(mpn.trim().toLowerCase());
71
+
72
+ const UNVERIFIED_RE = /\bUNVERIFIED\b/i;
73
+
74
+ /**
75
+ * Parse BOM.md into rows by column header, tolerating extra/reordered columns.
76
+ * Only the header row and data rows of the first parts table are used; a table
77
+ * without a recognizable Refdes header yields no rows.
78
+ *
79
+ * NOTE: the drift gate this exporter runs behind (checkDrift in
80
+ * ../memory/drift.ts) reads Refdes|Value|Footprint *by position*, not by header.
81
+ * So while this parser tolerates reordering those base columns, reordering them
82
+ * makes checkDrift compare the wrong cells and the export refuses with a bogus
83
+ * drift message. Keep the base three columns first and in order; only append.
84
+ */
85
+ export function parseBom(md: string): BomRow[] {
86
+ const tableRows = parseMarkdownTables(md);
87
+ const headerIdx = tableRows.findIndex((r) => r.cells.some((c) => HEADER_ALIASES[norm(c)] === 'refdes'));
88
+ const header = headerIdx === -1 ? undefined : tableRows[headerIdx];
89
+ if (!header) return [];
90
+ const col: Partial<Record<keyof BomRow, number>> = {};
91
+ header.cells.forEach((c, i) => {
92
+ const field = HEADER_ALIASES[norm(c)];
93
+ // First occurrence wins, so a stray later column never shadows the real one.
94
+ if (field && col[field] === undefined) col[field] = i;
95
+ });
96
+
97
+ const at = (row: TableRow, field: keyof BomRow): string => {
98
+ const i = col[field];
99
+ return i === undefined ? '' : (row.cells[i] ?? '').trim();
100
+ };
101
+
102
+ const rows: BomRow[] = [];
103
+ for (const row of tableRows.slice(headerIdx + 1)) {
104
+ const refdes = at(row, 'refdes');
105
+ if (!refdes) continue; // blank line / stray row
106
+ const mpn = at(row, 'mpn');
107
+ rows.push({
108
+ refdes,
109
+ value: at(row, 'value'),
110
+ footprint: at(row, 'footprint'),
111
+ mpn,
112
+ manufacturer: at(row, 'manufacturer'),
113
+ lcsc: at(row, 'lcsc'),
114
+ unverified: row.cells.some((c) => UNVERIFIED_RE.test(c)),
115
+ hasMpn: !isMpnPlaceholder(mpn),
116
+ });
117
+ }
118
+ return rows;
119
+ }
120
+
121
+ /**
122
+ * Passive footprint classifier (design D4): the library item after the `:` in a
123
+ * KiCad footprint id starts with `R_`, `C_`, or `L_` for the passive classes
124
+ * that lose parts to handling. Bare footprint names (no library) are matched
125
+ * too, so `R_0603` and `Resistor_SMD:R_0603_1608Metric` both classify.
126
+ */
127
+ export function isPassiveFootprint(footprint: string): boolean {
128
+ const item = footprint.includes(':') ? footprint.slice(footprint.lastIndexOf(':') + 1) : footprint;
129
+ return /^[RCL]_/.test(item.trim());
130
+ }
131
+
132
+ /**
133
+ * Order quantity for one BOM line (requirement "Quantity arithmetic"):
134
+ * `ceil(perBoardCount × boards × (1 + spares/100))`, raised to
135
+ * `perBoardCount × boards + 2` for passive lines when the percentage yields
136
+ * less — losing two 0402s to tweezers is the norm and percentage-only spares
137
+ * under-order low-count passive lines (design D4).
138
+ */
139
+ export function orderQuantity(
140
+ perBoardCount: number,
141
+ boards: number,
142
+ sparesPercent: number,
143
+ isPassive: boolean,
144
+ ): number {
145
+ const base = perBoardCount * boards;
146
+ // `base * (100 + spares) / 100` keeps the multiply in whole units before the
147
+ // divide, and the epsilon absorbs IEEE-754 dust so an exact result like 110
148
+ // does not ceil to 111 (100 × 1.1 is 110.00000000000001 in float). The dust is
149
+ // ~1e-13; 1e-9 is far below any real fractional quantity, so genuine fractions
150
+ // (44.5 → 45) are unaffected.
151
+ const withSpares = Math.ceil((base * (100 + sparesPercent)) / 100 - 1e-9);
152
+ return isPassive ? Math.max(withSpares, base + 2) : withSpares;
153
+ }
154
+
155
+ /** Natural refdes ordering: R2 before R10, and R* before U*. */
156
+ function naturalCompare(a: string, b: string): number {
157
+ const pa = a.match(/^([A-Za-z]*)(\d*)/);
158
+ const pb = b.match(/^([A-Za-z]*)(\d*)/);
159
+ const alpha = (pa?.[1] ?? '').localeCompare(pb?.[1] ?? '');
160
+ if (alpha !== 0) return alpha;
161
+ const na = pa?.[2] ? parseInt(pa[2], 10) : 0;
162
+ const nb = pb?.[2] ? parseInt(pb[2], 10) : 0;
163
+ if (na !== nb) return na - nb;
164
+ return a.localeCompare(b);
165
+ }
166
+
167
+ /** RFC-4180 field quoting: quote when the field holds a comma, quote, or newline. */
168
+ function csvField(value: string): string {
169
+ return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
170
+ }
171
+
172
+ const csvRow = (fields: string[]): string => fields.map(csvField).join(',');
173
+
174
+ export interface ExportOptions {
175
+ boards: number;
176
+ spares: number;
177
+ includeUnverified: boolean;
178
+ }
179
+
180
+ export interface ExportResult {
181
+ /** The supplier CSV, ending in a newline. */
182
+ csv: string;
183
+ /** Rows that made it into the file, in emit order. */
184
+ included: BomRow[];
185
+ /** Rows excluded, with the reason, for the warnings footer. */
186
+ excluded: { row: BomRow; reason: string }[];
187
+ /** Human-readable warning/notice lines (stderr + --json). */
188
+ warnings: string[];
189
+ }
190
+
191
+ interface Line {
192
+ rows: BomRow[];
193
+ /** Representative row (first, in refdes order) for value/footprint/mpn/etc. */
194
+ head: BomRow;
195
+ designators: string[];
196
+ }
197
+
198
+ /**
199
+ * Split rows into included/excluded by the ordering rules (requirement
200
+ * "Unorderable rows are excluded and reported"): MPN-less rows are always
201
+ * excluded; UNVERIFIED rows are excluded unless `includeUnverified`, and even
202
+ * then only when they carry a real MPN.
203
+ */
204
+ function partition(
205
+ rows: BomRow[],
206
+ includeUnverified: boolean,
207
+ ): { included: BomRow[]; excluded: ExportResult['excluded'] } {
208
+ const included: BomRow[] = [];
209
+ const excluded: ExportResult['excluded'] = [];
210
+ for (const row of rows) {
211
+ if (!row.hasMpn) {
212
+ excluded.push({ row, reason: 'no MPN' });
213
+ } else if (row.unverified && !includeUnverified) {
214
+ excluded.push({ row, reason: 'UNVERIFIED' });
215
+ } else {
216
+ included.push(row);
217
+ }
218
+ }
219
+ return { included, excluded };
220
+ }
221
+
222
+ function groupBy(rows: BomRow[], key: (r: BomRow) => string): Line[] {
223
+ const map = new Map<string, BomRow[]>();
224
+ for (const r of rows) {
225
+ const k = key(r);
226
+ const arr = map.get(k);
227
+ if (arr) arr.push(r);
228
+ else map.set(k, [r]);
229
+ }
230
+ const lines: Line[] = [];
231
+ for (const groupRows of map.values()) {
232
+ const sorted = [...groupRows].sort((a, b) => naturalCompare(a.refdes, b.refdes));
233
+ // Groups are never empty (a key exists because a row produced it).
234
+ lines.push({ rows: sorted, head: sorted[0]!, designators: sorted.map((r) => r.refdes) });
235
+ }
236
+ // Deterministic line order: by the first designator of each line.
237
+ return lines.sort((a, b) => naturalCompare(a.designators[0]!, b.designators[0]!));
238
+ }
239
+
240
+ function buildWarnings(
241
+ supplier: Supplier,
242
+ included: BomRow[],
243
+ excluded: ExportResult['excluded'],
244
+ opts: ExportOptions,
245
+ ): string[] {
246
+ const { includeUnverified } = opts;
247
+ const warnings: string[] = [];
248
+ for (const { row, reason } of excluded) {
249
+ const hint =
250
+ reason === 'no MPN'
251
+ ? 'add an MPN in BOM.md — unorderable without one'
252
+ : 'verify against the datasheet or re-run with --include-unverified';
253
+ warnings.push(`EXCLUDED (${reason}): ${row.refdes} (${row.value || 'no value'}) — ${hint}`);
254
+ }
255
+ if (includeUnverified) {
256
+ for (const row of included) {
257
+ if (row.unverified) {
258
+ warnings.push(`INCLUDED but UNVERIFIED (--include-unverified): ${row.refdes} (${row.mpn}) — confirm before ordering`);
259
+ }
260
+ }
261
+ }
262
+ if (supplier === 'jlcpcb') {
263
+ // The JLCPCB assembly format has no quantity column — quantity is set from
264
+ // the board count entered at upload — so --boards/--spares never reach this
265
+ // file. Say so when the user supplied a non-default value, or they may order
266
+ // the wrong count expecting the flags to have taken effect.
267
+ if (opts.boards !== DEFAULT_BOARDS || opts.spares !== DEFAULT_SPARES) {
268
+ warnings.push(
269
+ 'NOTE: --boards/--spares are ignored for jlcpcb — quantity is set from the board count you enter at JLCPCB upload',
270
+ );
271
+ }
272
+ const blank = included.filter((r) => !r.lcsc).map((r) => r.refdes);
273
+ if (blank.length) {
274
+ warnings.push(
275
+ `NOTE: no LCSC part # for ${blank.join(', ')} — JLCPCB accepts the upload but needs manual matching for these`,
276
+ );
277
+ }
278
+ }
279
+ return warnings;
280
+ }
281
+
282
+ function emitJlcpcb(lines: Line[]): string {
283
+ // JLCPCB assembly-service BOM: one line per Comment+Footprint+LCSC, designators
284
+ // grouped. Quantity is derived by JLCPCB from the designator count × the board
285
+ // count entered at upload, so there is no quantity column here (design/proposal).
286
+ const header = 'Comment,Designator,Footprint,LCSC Part #';
287
+ const body = lines.map((l) =>
288
+ csvRow([l.head.value, l.designators.join(','), l.head.footprint, l.head.lcsc]),
289
+ );
290
+ return [header, ...body].join('\n') + '\n';
291
+ }
292
+
293
+ function emitCart(lines: Line[], opts: ExportOptions, mpnHeader: string): string {
294
+ // DigiKey / Mouser cart upload: one line per MPN with a computed order
295
+ // quantity and the designators as the customer reference.
296
+ const header = `${mpnHeader},Manufacturer,Quantity,Customer Reference`;
297
+ const body = lines.map((l) => {
298
+ const qty = orderQuantity(l.designators.length, opts.boards, opts.spares, isPassiveFootprint(l.head.footprint));
299
+ return csvRow([l.head.mpn, l.head.manufacturer, String(qty), l.designators.join(',')]);
300
+ });
301
+ return [header, ...body].join('\n') + '\n';
302
+ }
303
+
304
+ /**
305
+ * Build the supplier CSV plus its warnings from parsed BOM rows. Pure: no I/O,
306
+ * so the emitters are golden-file testable in isolation (design D5).
307
+ */
308
+ export function buildExport(rows: BomRow[], supplier: Supplier, opts: ExportOptions): ExportResult {
309
+ const { included, excluded } = partition(rows, opts.includeUnverified);
310
+ const lines =
311
+ supplier === 'jlcpcb'
312
+ ? groupBy(included, (r) => `${r.value}${r.footprint}${r.lcsc}`)
313
+ : groupBy(included, (r) => r.mpn);
314
+
315
+ let csv: string;
316
+ if (supplier === 'jlcpcb') csv = emitJlcpcb(lines);
317
+ else if (supplier === 'digikey') csv = emitCart(lines, opts, 'Manufacturer Part Number');
318
+ else csv = emitCart(lines, opts, 'Mfr. Part Number');
319
+
320
+ return { csv, included, excluded, warnings: buildWarnings(supplier, included, excluded, opts) };
321
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Fab release gate checks (OpenSpec: add-fab-release-gate).
3
+ * Documentation presence is pure over file contents: LLM-free, network-free.
4
+ */
5
+
6
+ /** Violation shape shared by the fab JSON report (`claim` / `actual` / optional location). */
7
+ export interface FabViolation {
8
+ claim: string;
9
+ actual: string;
10
+ location?: string;
11
+ }
12
+
13
+ export interface FabCheckResult {
14
+ status: 'pass' | 'warn' | 'fail';
15
+ violations: FabViolation[];
16
+ }
17
+
18
+ export const DRAFT_QUALITY_HEADING = '## Draft quality';
19
+
20
+ /** Config marker: repos produced by `copperhead create` set `origin` to `"create"`. */
21
+ export const CREATE_ORIGIN = 'create';
22
+
23
+ /**
24
+ * True when `.copperhead/config.json` marks the repo as create-produced.
25
+ * Accepts the raw parsed object (or a loaded config that retained `origin`).
26
+ */
27
+ export function isCreateProducedRepo(config: unknown): boolean {
28
+ if (config === null || typeof config !== 'object') return false;
29
+ return (config as { origin?: unknown }).origin === CREATE_ORIGIN;
30
+ }
31
+
32
+ /**
33
+ * Body of `## Draft quality` through the next `##` heading, or `null` if the
34
+ * heading is absent. HTML comments and whitespace alone do not count as filled
35
+ * (init scaffolds the empty heading + a placeholder comment).
36
+ */
37
+ export function draftQualitySection(layoutMd: string): string | null {
38
+ const lines = layoutMd.split(/\r?\n/);
39
+ let start = -1;
40
+ for (let i = 0; i < lines.length; i++) {
41
+ if (lines[i]!.trim() === DRAFT_QUALITY_HEADING) {
42
+ start = i + 1;
43
+ break;
44
+ }
45
+ }
46
+ if (start < 0) return null;
47
+
48
+ const body: string[] = [];
49
+ for (let i = start; i < lines.length; i++) {
50
+ const line = lines[i]!;
51
+ if (/^##\s/.test(line.trim())) break;
52
+ body.push(line);
53
+ }
54
+ return body.join('\n');
55
+ }
56
+
57
+ export function isFilledDraftQuality(sectionBody: string): boolean {
58
+ const withoutComments = sectionBody.replace(/<!--[\s\S]*?-->/g, '');
59
+ return withoutComments.trim().length > 0;
60
+ }
61
+
62
+ export interface DocumentationPresenceInput {
63
+ /** Full LAYOUT.md text, or `null` when the file is missing. */
64
+ layoutMd: string | null;
65
+ /** Whether docs/DEVPLAN.md exists on disk. */
66
+ devplanExists: boolean;
67
+ /** From {@link isCreateProducedRepo}; only create repos require DEVPLAN.md. */
68
+ isCreateRepo: boolean;
69
+ /** Docs directory name used in violation locations (default `docs`). */
70
+ docsDir?: string;
71
+ }
72
+
73
+ /**
74
+ * Documentation-presence check for `check --fab` (task 1.5):
75
+ * - LAYOUT.md must have a filled `## Draft quality` section
76
+ * - create-produced repos must also have DEVPLAN.md
77
+ *
78
+ * Failures use the drift-report voice: file as location, claim `"release-ready"`.
79
+ */
80
+ export function checkDocumentationPresence(input: DocumentationPresenceInput): FabCheckResult {
81
+ const docs = (input.docsDir ?? 'docs').replace(/[/\\]+$/, '');
82
+ const violations: FabViolation[] = [];
83
+ const layoutLoc = `${docs}/LAYOUT.md`;
84
+ const claim = 'release-ready';
85
+
86
+ if (input.layoutMd === null) {
87
+ violations.push({
88
+ claim,
89
+ actual: 'LAYOUT.md missing',
90
+ location: layoutLoc,
91
+ });
92
+ } else {
93
+ const section = draftQualitySection(input.layoutMd);
94
+ if (section === null) {
95
+ violations.push({
96
+ claim,
97
+ actual: `missing ${DRAFT_QUALITY_HEADING} section`,
98
+ location: layoutLoc,
99
+ });
100
+ } else if (!isFilledDraftQuality(section)) {
101
+ violations.push({
102
+ claim,
103
+ actual: `${DRAFT_QUALITY_HEADING} section is empty`,
104
+ location: layoutLoc,
105
+ });
106
+ }
107
+ }
108
+
109
+ if (input.isCreateRepo && !input.devplanExists) {
110
+ violations.push({
111
+ claim,
112
+ actual: 'missing',
113
+ location: `${docs}/DEVPLAN.md`,
114
+ });
115
+ }
116
+
117
+ return {
118
+ status: violations.length === 0 ? 'pass' : 'fail',
119
+ violations,
120
+ };
121
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared markdown-table parsing for BOM.md and PINOUT.md (design D9's
3
+ * fixed-column contract). Originally lived inline in drift.ts; pulled out so
4
+ * the supplier BOM export work (add-supplier-bom-export) can parse BOM.md
5
+ * once, the same way, instead of duplicating this.
6
+ */
7
+
8
+ export interface TableRow {
9
+ cells: string[];
10
+ }
11
+
12
+ /**
13
+ * Parses every markdown pipe-table row out of a document, across however
14
+ * many tables the file contains, skipping separator rows (e.g. `|---|---|`).
15
+ * Malformed lines (stray `|` outside a real table) just become a row with
16
+ * whatever cells they split into — this function never throws.
17
+ */
18
+ export function parseMarkdownTables(md: string): TableRow[] {
19
+ const rows: TableRow[] = [];
20
+ for (const line of md.split('\n')) {
21
+ const t = line.trim();
22
+ if (!t.startsWith('|')) continue;
23
+ const cells = t
24
+ .split('|')
25
+ .slice(1, -1)
26
+ .map((c) => c.trim());
27
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
28
+ rows.push({ cells });
29
+ }
30
+ return rows;
31
+ }
32
+
33
+ /** True for a table's header row. BOM.md and PINOUT.md both lead with a
34
+ * Refdes or Pin column, so one check covers both doc types. */
35
+ export const isHeader = (row: TableRow): boolean =>
36
+ row.cells.some((c) => /^(refdes|pin)$/i.test(c));
37
+
38
+ /**
39
+ * A typed BOM.md data row, per the fixed column contract that `init` writes
40
+ * (Refdes | Value | Footprint | MPN | Rationale — see scaffold.ts's
41
+ * `bomTable`). `flags` currently only ever contains `UNVERIFIED` (the MPN
42
+ * column literally says so) or `MISSING_MPN` (no MPN column value at all);
43
+ * more may be added as the export/fab-gate work grows.
44
+ */
45
+ export interface BomRow {
46
+ refdes: string;
47
+ value?: string;
48
+ footprint?: string;
49
+ mpn?: string;
50
+ flags: string[];
51
+ }
52
+
53
+ /**
54
+ * Parses BOM.md's data rows into typed rows. Rows without a refdes in
55
+ * column 1 are dropped rather than thrown on: a hand-edited doc with a
56
+ * ragged or partial table shouldn't crash `check` or `export bom`, it
57
+ * should just be skipped (drift/export callers report the gaps that
58
+ * matter through their own comparisons against the schematic).
59
+ */
60
+ export function parseBomTable(md: string): BomRow[] {
61
+ const rows = parseMarkdownTables(md).filter((r) => !isHeader(r));
62
+ const out: BomRow[] = [];
63
+ for (const row of rows) {
64
+ const [refdes, value, footprint, mpn] = row.cells;
65
+ if (!refdes) continue;
66
+ const flags: string[] = [];
67
+ if (mpn === 'UNVERIFIED') flags.push('UNVERIFIED');
68
+ else if (!mpn) flags.push('MISSING_MPN');
69
+ out.push({
70
+ refdes,
71
+ value: value || undefined,
72
+ footprint: footprint || undefined,
73
+ mpn: mpn || undefined,
74
+ flags,
75
+ });
76
+ }
77
+ return out;
78
+ }
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { listSymbols, pinNets, type SchematicSymbol } from '../kicad/sexp.js';
5
+ import { parseMarkdownTables, isHeader } from './bom-table.js';
5
6
 
6
7
  /**
7
8
  * Doc-vs-schematic drift check (AC-2.3). BOM.md and PINOUT.md use fixed table
@@ -13,28 +14,6 @@ export interface DriftMismatch {
13
14
  actual: string;
14
15
  }
15
16
 
16
- export interface TableRow {
17
- cells: string[];
18
- }
19
-
20
- export function parseMarkdownTables(md: string): TableRow[] {
21
- const rows: TableRow[] = [];
22
- for (const line of md.split('\n')) {
23
- const t = line.trim();
24
- if (!t.startsWith('|')) continue;
25
- const cells = t
26
- .split('|')
27
- .slice(1, -1)
28
- .map((c) => c.trim());
29
- if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
30
- rows.push({ cells });
31
- }
32
- return rows;
33
- }
34
-
35
- const isHeader = (row: TableRow): boolean =>
36
- row.cells.some((c) => /^(refdes|pin)$/i.test(c));
37
-
38
17
  /**
39
18
  * The zero-symbol carve-out in checkDrift is right for the create pipeline,
40
19
  * but it would let `check` silently pass an established repo whose schematic
@@ -113,7 +113,8 @@ Generated by \`copperhead init\`; regenerated on re-runs (do not hand-edit).
113
113
 
114
114
  - \`schematic\` / \`board\`: repo-relative paths to the KiCad files copperhead operates on (currently: ${config.schematic ?? 'none'} / ${config.board ?? 'none'})
115
115
  - \`docs\`: design docs directory (docs-as-memory), default \`docs/\`
116
- - \`model\`: default model (\`gpt-5\` or \`claude\`); overridden by \`--model\` and \`COPPERHEAD_MODEL\`
116
+ - \`model\`: default provider/model (\`codex\`, \`gpt-5\`, or \`claude\`); overridden by \`--model\` and \`COPPERHEAD_MODEL\`
117
+ - local Codex uses the saved \`codex login\`; set \`COPPERHEAD_CODEX_PATH\` only when the executable is not on \`PATH\`
117
118
  - \`maxTurns\`: agent loop turn budget per run (default 40)
118
119
  - \`maxRepairCycles\`: ERC/DRC repair attempts before rollback (default 5)
119
120
  - \`budgets\`: free-form hard constraints (e.g. \`"sleep_current_uA": 25\`); surfaced verbatim into every run's system prompt