copperhead 0.6.0 → 0.8.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 (118) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +130 -18
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/prompts.js +2 -1
  13. package/dist/agent/prompts.js.map +1 -1
  14. package/dist/agent/providers/claude-code.js +85 -116
  15. package/dist/agent/providers/claude-code.js.map +1 -1
  16. package/dist/agent/providers/cursor.js +317 -0
  17. package/dist/agent/providers/cursor.js.map +1 -0
  18. package/dist/agent/providers/tool-protocol.js +205 -0
  19. package/dist/agent/providers/tool-protocol.js.map +1 -0
  20. package/dist/agent/recovery.js +148 -0
  21. package/dist/agent/recovery.js.map +1 -0
  22. package/dist/agent/render.js +44 -12
  23. package/dist/agent/render.js.map +1 -1
  24. package/dist/agent/response-cache.js +81 -0
  25. package/dist/agent/response-cache.js.map +1 -0
  26. package/dist/agent/runmeta.js +4 -5
  27. package/dist/agent/runmeta.js.map +1 -1
  28. package/dist/agent/theme.js +84 -0
  29. package/dist/agent/theme.js.map +1 -0
  30. package/dist/agent/tools.js +61 -4
  31. package/dist/agent/tools.js.map +1 -1
  32. package/dist/agent/transcript.js.map +1 -1
  33. package/dist/cli.js +134 -13
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/create.js +482 -38
  36. package/dist/commands/create.js.map +1 -1
  37. package/dist/commands/demo.js +146 -0
  38. package/dist/commands/demo.js.map +1 -0
  39. package/dist/commands/doctor.js +240 -0
  40. package/dist/commands/doctor.js.map +1 -0
  41. package/dist/commands/repl-inspect.js +342 -0
  42. package/dist/commands/repl-inspect.js.map +1 -0
  43. package/dist/commands/repl.js +618 -0
  44. package/dist/commands/repl.js.map +1 -0
  45. package/dist/config.js +24 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/kicad/bootstrap.js +166 -0
  48. package/dist/kicad/bootstrap.js.map +1 -0
  49. package/dist/kicad/cli.js +126 -6
  50. package/dist/kicad/cli.js.map +1 -1
  51. package/dist/kicad/spice.js +306 -0
  52. package/dist/kicad/spice.js.map +1 -0
  53. package/dist/kicad/symlib.js +228 -0
  54. package/dist/kicad/symlib.js.map +1 -0
  55. package/dist/memory/bom-table.js +193 -22
  56. package/dist/memory/bom-table.js.map +1 -1
  57. package/dist/memory/drift.js +33 -11
  58. package/dist/memory/drift.js.map +1 -1
  59. package/dist/util/cli-args.js +35 -0
  60. package/dist/util/cli-args.js.map +1 -0
  61. package/dist/util/dock.js +155 -0
  62. package/dist/util/dock.js.map +1 -0
  63. package/dist/util/git.js +165 -4
  64. package/dist/util/git.js.map +1 -1
  65. package/dist/util/live-prompt.js +542 -0
  66. package/dist/util/live-prompt.js.map +1 -0
  67. package/dist/util/paths.js +9 -0
  68. package/dist/util/paths.js.map +1 -1
  69. package/dist/util/preflight.js +37 -0
  70. package/dist/util/preflight.js.map +1 -1
  71. package/dist/util/retry.js +23 -0
  72. package/dist/util/retry.js.map +1 -1
  73. package/dist/util/select.js +172 -0
  74. package/dist/util/select.js.map +1 -0
  75. package/dist/util/tmp.js +119 -0
  76. package/dist/util/tmp.js.map +1 -0
  77. package/package.json +3 -2
  78. package/src/agent/animate.ts +90 -0
  79. package/src/agent/box.ts +99 -0
  80. package/src/agent/dock-renderer.ts +181 -0
  81. package/src/agent/logo.ts +23 -0
  82. package/src/agent/loop.ts +148 -18
  83. package/src/agent/prompts.ts +2 -1
  84. package/src/agent/providers/claude-code.ts +91 -122
  85. package/src/agent/providers/cursor.ts +364 -0
  86. package/src/agent/providers/tool-protocol.ts +212 -0
  87. package/src/agent/recovery.ts +162 -0
  88. package/src/agent/render.ts +56 -12
  89. package/src/agent/response-cache.ts +80 -0
  90. package/src/agent/runmeta.ts +6 -7
  91. package/src/agent/theme.ts +91 -0
  92. package/src/agent/tools.ts +62 -4
  93. package/src/agent/transcript.ts +1 -0
  94. package/src/agent/types.ts +17 -0
  95. package/src/cli.ts +139 -15
  96. package/src/commands/create.ts +581 -40
  97. package/src/commands/demo.ts +184 -0
  98. package/src/commands/doctor.ts +289 -0
  99. package/src/commands/repl-inspect.ts +353 -0
  100. package/src/commands/repl.ts +685 -0
  101. package/src/config.ts +40 -3
  102. package/src/kicad/bootstrap.ts +181 -0
  103. package/src/kicad/cli.ts +132 -7
  104. package/src/kicad/spice.ts +399 -0
  105. package/src/kicad/symlib.ts +248 -0
  106. package/src/layout/claude-ui-layout.md +72 -0
  107. package/src/layout/repl-ui-layout.md +139 -0
  108. package/src/memory/bom-table.ts +191 -20
  109. package/src/memory/drift.ts +42 -11
  110. package/src/util/cli-args.ts +42 -0
  111. package/src/util/dock.ts +161 -0
  112. package/src/util/git.ts +176 -4
  113. package/src/util/live-prompt.ts +595 -0
  114. package/src/util/paths.ts +10 -0
  115. package/src/util/preflight.ts +44 -0
  116. package/src/util/retry.ts +29 -0
  117. package/src/util/select.ts +192 -0
  118. package/src/util/tmp.ts +113 -0
@@ -0,0 +1,72 @@
1
+ # Claude Code UI layout (measured reference)
2
+
3
+ Captured from the real `claude` CLI (v2.1.220) under a pty + VT102 emulator at
4
+ 100x30, with per-cell SGR attributes extracted, so every color below is
5
+ measured, not eyeballed. This is the reference to diff
6
+ [repl-ui-layout.md](repl-ui-layout.md) against when editing copperhead's
7
+ chrome.
8
+
9
+ ## State 1: idle prompt
10
+
11
+ ```text
12
+ 2| ▐▛███▜▌ Claude Code v2.1.220 <- logo[#d77757] name[bold, default fg] version[#999999]
13
+ 3|▝▜█████▛▘ Fable 5 with high effort · Claude Max <- logo[#d77757] model+plan[#999999]
14
+ 4| ▘▘ ▝▝ ~/Github/chouhan-industries/copperhead <- logo[#d77757] cwd[#999999]
15
+ 5|
16
+ 6| Tackle your toughest work with Opus 5. ... <- notice body[default fg]
17
+ 7| +1 more · /status <- [#999999]
18
+ ..| <- content region, output scrolls here
19
+ 25| ● high · /effort <- meta right-aligned, all [#999999]
20
+ 26|───────────────────────────────────────────────────── <- rule[#888888], full width
21
+ 27|❯ Try "create a util logging.py that..." <- ❯ + nbsp[default], placeholder[faint], caret = real cursor
22
+ 28|───────────────────────────────────────────────────── <- rule[#888888], full width
23
+ 29| ⚠ Transcript saving is off · ... <- warning line (only when present)
24
+ 30| ⏸ manual mode on · ? for shortcuts <- status left[#999999]
25
+ ```
26
+
27
+ ## State 2: slash menu open (upward overlay, input row fixed at 27)
28
+
29
+ ```text
30
+ 22| /pr-review Review a copperhead pull request... <- hovered: label+desc[#b1b9f9], no inverse video
31
+ 23| spec workflow. Use when the user... <- description wraps to a second row
32
+ 24| /review Review a GitHub pull request; ... <- unhovered: [#999999]
33
+ 26|─────────────────────────────────────────────────────
34
+ 27|❯ / <- typed filter[default fg]
35
+ 28|─────────────────────────────────────────────────────
36
+ 30| ⏸ manual mode on <- status shrinks while menu is open
37
+ ```
38
+
39
+ ## State 3: Ctrl+C pressed once
40
+
41
+ ```text
42
+ 30| Press Ctrl-C again to exit <- [#999999], replaces the status line
43
+ ```
44
+
45
+ ## Measured color palette
46
+
47
+ | Role | Value | Notes |
48
+ | --------------------------- | ----------------- | --------------------------------------- |
49
+ | Accent (logo) | `#d77757` | truecolor; the only saturated color |
50
+ | Title (`Claude Code`) | bold, default fg | not white-forced: follows the theme |
51
+ | Secondary text | `#999999` | truecolor gray, softer than SGR 90 |
52
+ | Rules | `#888888` | one step darker than secondary text |
53
+ | Menu hover | `#b1b9f9` | periwinkle, hover is a color change |
54
+ | Placeholder | faint (SGR 2) | not fg-colored, uses the faint attr |
55
+ | Caret | terminal cursor | real cursor, not a synthetic inverse |
56
+
57
+ ## Structural notes vs copperhead's current implementation
58
+
59
+ 1. Grays: adopted. copperhead's `dim`/`ruleDim` are truecolor
60
+ `#999999`/`#888888` (with an SGR 90 fallback when truecolor is off).
61
+ 2. Menu hover: adopted as a color change. Claude recolors the row with
62
+ periwinkle `#b1b9f9`; copperhead recolors with `copperLight` (#eec9a5)
63
+ instead of inverse video.
64
+ 3. Hovered menu descriptions: adopted. Both wrap to a second row.
65
+ 4. Caret: adopted. copperhead parks the real terminal cursor in the input
66
+ row instead of drawing a synthetic inverse block.
67
+ 5. The name `Claude Code` is bold in the terminal's default foreground, so it
68
+ adapts to light/dark themes, it is not hard-coded white.
69
+ 6. There is a non-breaking space after `❯` in the prompt.
70
+
71
+ To adopt any of these in copperhead, edit the corresponding line in
72
+ [repl-ui-layout.md](repl-ui-layout.md) and paste it back.
@@ -0,0 +1,139 @@
1
+ # REPL UI layout spec
2
+
3
+ Captured from a real run (pty + VT102 emulator, 100x30). This file is the
4
+ editing surface for the interactive shell's chrome: change any line below,
5
+ paste it back, and the implementation follows. Every visual token maps to one
6
+ function in `src/agent/theme.ts`, so color changes are one-line edits.
7
+
8
+ ## State 1: idle prompt
9
+
10
+ ```text
11
+ 1| <- blank
12
+ 2| ▄▟▙▄ copperhead v0.7.0 <- mark[copper] name[bold] version[dim]
13
+ 3| ███ ███ claude via flag · kicad-cli 9.0.4 <- mark[copper] meta[dim]
14
+ 4| ▀▜▛▀ ~/Github/chouhan-industries/copperhead <- mark[copper] cwd[dim]
15
+ 5| <- blank
16
+ 6| ▎ New repository? <- bar[copper] title[copper]
17
+ 7| ▎ `copperhead init` scaffolds docs/ from an existing schematic <- bar[copper] body[default]
18
+ 8| ▎ `copperhead demo` runs the USB-C breakout create pipeline <- bar[copper] body[default]
19
+ 9| ▎ Docs: https://docs.copperhead.sh <- bar[copper] body[copper]
20
+ 10| <- content region: echoes + agent
21
+ ..| ❯ rename net KEY_DAH to KEY_DASH <- output scroll here, oldest
22
+ ..| ▸ run_erc clean — 0 violations <- scrolls off the top
23
+ 26| ● claude · main* <- meta right-aligned: dot[copper] text[dim]
24
+ 27|────────────────────────────────────────────────────────────────────── <- rule[dim], full width
25
+ 28|❯ Try "add reverse-polarity protection on VIN" <- prompt[copper]+nbsp, caret = real cursor, placeholder[dim] typed[bright]
26
+ 29|────────────────────────────────────────────────────────────────────── <- rule[dim], full width
27
+ 30| / for commands · pgup history · ctrl+c twice to quit In copperhead <- left[dim] right[dim]
28
+ ```
29
+
30
+ ## State 2: slash menu open (overlays upward, input row never moves)
31
+
32
+ ```text
33
+ 16| ❯ /demo what copperhead does + how to try it <- hovered: ❯[copper] label+desc[copperLight], desc wraps to a 2nd row
34
+ 17| /examples example change-request prompts <- label[default] desc[default]
35
+ ..| ...up to 10 items...
36
+ 26| ↓ 10 more <- overflow marker[dim]
37
+ 27|────────────────────────────────────────────────────────────────────── <- rule[dim]
38
+ 28|❯ / <- typed filter[bright]
39
+ 29|────────────────────────────────────────────────────────────────────── <- rule[dim]
40
+ 30| / for commands · pgup history · ctrl+c twice to quit In copperhead
41
+ ```
42
+
43
+ ## State 3: agent turn running (observability row pinned in the dock)
44
+
45
+ ```text
46
+ 26| ● claude · main* <- meta stays
47
+ 27|──────────────────────────────────────────────────────────────────────
48
+ 28|⠹ Reflowing... turn 2/40 · 1.2k in / 300 out · 12s · ERC <- word left[copper], stats right[dim] busy[warn]
49
+ 29|──────────────────────────────────────────────────────────────────────
50
+ 30| ctrl+c interrupts the run · output above scrolls into history
51
+ ```
52
+
53
+ The working word is a PCB term (Routing, Etching, Reflowing, Soldering,
54
+ Drilling, Plating, Probing, Fluxing, Tinning, Laminating, Silkscreening,
55
+ Panelizing), one per turn, with animated dots: Claude Code's working verbs,
56
+ board-shop edition; on rotation the old word crossfades char by char
57
+ through `_` slots, dots included. Durable output (tool lines, turn markers, the outcome)
58
+ scrolls in the content region; the observability row never moves. Between
59
+ submit and the first turn a passive `… working` row shows briefly.
60
+
61
+ First Ctrl+C at the prompt: input clears, row 30 becomes `press ctrl+c again to exit` [warn].
62
+
63
+ History: every content line is kept in a session buffer (cap 5000). PgUp at
64
+ the prompt scrolls the content region back through it (arrow keys / mouse
65
+ wheel line-scroll too at an empty prompt), PgDn scrolls forward,
66
+ any other key snaps back to the live tail; row 30 shows
67
+ `history ↑N · pgup/pgdn scroll · any key returns` while scrolled. The same
68
+ lines are mirrored by default to `.copperhead/runs/repl-<timestamp>.log`
69
+ (plain text: SGR stripped, `sk-` keys redacted per AC-4.1); the path is
70
+ printed when the session ends. Injected loggers (tests/embeds) disable the
71
+ file sink.
72
+
73
+ Startup: the full screen loads instantly (banner, callout, input dock), then
74
+ the mark pulses in place twice over rows 2-4 (dot, thin ring, thick
75
+ ring, full via) while the prompt is already usable. First run in a repo (no
76
+ `.copperhead/` yet) uses slow timing (110ms/frame) and shows the New
77
+ repository callout; later runs pulse fast (45ms/frame) and hide it.
78
+
79
+ ## Color tokens (src/agent/theme.ts)
80
+
81
+ | Token | SGR | Current value | Used for |
82
+ | ------------- | ------------------ | ------------------------- | -------------------------------------- |
83
+ | `copper` | `38;2;184;115;51` | #b87333 (brand: #b87333) | mark, prompt ❯, callout bar, meta dot |
84
+ | `copperLight` | `38;2;238;201;165` | #eec9a5 (accent-high) | hovered menu row |
85
+ | `bold` | `1` | bold, default fg | `copperhead` name (theme-adaptive) |
86
+ | `bright` | `97` | white | typed input text |
87
+ | `dim` | `38;2;153;153;153` | #999999 (SGR 90 fallback) | hints, placeholder, version, paths |
88
+ | `ruleDim` | `38;2;136;136;136` | #888888 (SGR 90 fallback) | input-area rules |
89
+ | `ok` | `32` | green | success lines (`check: all green`) |
90
+ | `warn` | `33` | amber | ctrl+c hint, cautions |
91
+ | `err` | `31` | red | failures |
92
+
93
+ Note: `copper` is the exact brand #b87333 on truecolor terminals
94
+ (COLORTERM=truecolor/24bit); terminals without truecolor fall back to
95
+ 256-color 173. See [claude-ui-layout.md](claude-ui-layout.md) for the measured
96
+ Claude Code reference palette to diff against.
97
+
98
+ ## Region -> source map
99
+
100
+ | Region | Source |
101
+ | --------------------- | ----------------------------------------------- |
102
+ | Banner + callout | `banner()` in `src/commands/repl.ts` |
103
+ | Meta line, status bar | `ask()` options in `src/commands/repl.ts` |
104
+ | Input rows, menu | `src/util/live-prompt.ts` (`renderDock`) |
105
+ | Rules, callout, bars | `src/agent/box.ts` |
106
+ | Screen ownership | `src/util/dock.ts` (alt screen + DECSTBM fence) |
107
+
108
+ To iterate: edit the annotated lines above (text, alignment, or `[token]`
109
+ tags), paste the block back, and the code gets updated to match. Verify with
110
+ `npm run demo:ui` (see below).
111
+
112
+ ## Demo recording (npm run demo:ui)
113
+
114
+ `npm run demo:ui` from the repo root runs `scripts/ui-demo.ts` via tsx: the
115
+ real REPL UI with a canned agent run, no build step, no API key, no repo
116
+ mutations. Same script every take, so recordings are reproducible.
117
+
118
+ Standard take:
119
+
120
+ 1. Let the banner settle and the via mark pulse
121
+ 2. Type `/`, hover a few commands with the arrow keys, Esc to dismiss
122
+ 3. Type `rename net KEY_DAH to KEY_DASH`, Enter, let the mock run play
123
+ (~17s, four sections: propose, edit, verify, remember, each closed by a
124
+ summary line; the pinned observability row animates and the working word
125
+ morphs at each section)
126
+ 4. `/check` for the mock green ERC/DRC/drift pass
127
+ 5. Ctrl+C twice to exit
128
+
129
+ The demo uses the current directory as the repo. A directory that already has
130
+ `.copperhead/` gets the fast mark pulse and no callout; for the full
131
+ first-run intro (slow pulse + New repository callout), run from a fresh
132
+ directory:
133
+
134
+ ```bash
135
+ cd $(mktemp -d) && node <repo>/node_modules/.bin/tsx <repo>/scripts/ui-demo.ts
136
+ ```
137
+
138
+ The mark itself can be regenerated from the website logo geometry at any
139
+ size with `node scripts/gen-logo.mjs <rows>`.
@@ -34,7 +34,161 @@ export interface TableRow {
34
34
  * Refdes or Pin column, so one check covers both doc types. */
35
35
  export const isHeader = (row: TableRow): boolean =>
36
36
  row.cells.some((c) => /^(refdes|pin)$/i.test(c));
37
-
37
+
38
+ /**
39
+ * Data rows of the CANONICAL table(s) only — those introduced by a Refdes/Pin
40
+ * header row (`isHeader`). BOM.md and PINOUT.md legitimately carry supporting
41
+ * tables (a quiescent-current roll-up, a net-meaning legend); their rows are
42
+ * NOT parts/pins and must never be compared against the schematic. The flat
43
+ * `parseMarkdownTables(md).filter(!isHeader)` does exactly that — it merges
44
+ * every table's rows — so a second table's first cell gets read as a refdes and
45
+ * flagged "not in schematic", which pushes the agent to degrade good docs into
46
+ * bullet lists just to appease the drift gate.
47
+ *
48
+ * This groups lines into tables (a run of pipe-rows, ended by any non-pipe
49
+ * line), keeps only the groups whose first row is a Refdes/Pin header, and
50
+ * returns those groups' data rows (header dropped). A table with no recognized
51
+ * header — including a bare data-only block — is ignored, preserving the
52
+ * fixed-column contract (design D9) while letting docs hold extra tables.
53
+ */
54
+ export function parseCanonicalRows(md: string): TableRow[] {
55
+ return parseCanonicalTables(md).flatMap((t) => t.rows);
56
+ }
57
+
58
+ /**
59
+ * Like parseCanonicalRows, but keeps each kept table's header row so a caller
60
+ * can resolve columns by *name* instead of a fixed position. PINOUT.md's
61
+ * column count is not fixed in practice: the scaffold writes
62
+ * `Refdes | Pin | Name | Net | Notes`, but a hand- or LLM-authored table may
63
+ * legitimately drop the optional Name/Notes columns and write
64
+ * `Refdes | Pin | Net`. A fixed positional net index then reads the wrong cell
65
+ * and reports every pin as net "NC" against a doc that is in fact correct —
66
+ * a false drift the agent cannot diagnose (the doc plainly shows the net), so
67
+ * it loops on finish forever. Resolving by header name fixes that.
68
+ */
69
+ export function parseCanonicalTables(md: string): Array<{ header: TableRow; rows: TableRow[] }> {
70
+ const groups: TableRow[][] = [];
71
+ let current: TableRow[] | null = null;
72
+ for (const line of md.split('\n')) {
73
+ const t = line.trim();
74
+ if (!t.startsWith('|')) {
75
+ current = null; // a blank or prose line terminates the current table
76
+ continue;
77
+ }
78
+ const cells = t
79
+ .split('|')
80
+ .slice(1, -1)
81
+ .map((c) => c.trim());
82
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row: stays within the table
83
+ if (!current) {
84
+ current = [];
85
+ groups.push(current);
86
+ }
87
+ current.push({ cells });
88
+ }
89
+ const tables: Array<{ header: TableRow; rows: TableRow[] }> = [];
90
+ for (const g of groups) {
91
+ if (g.length && isHeader(g[0]!)) tables.push({ header: g[0]!, rows: g.slice(1) });
92
+ }
93
+ return tables;
94
+ }
95
+
96
+ /**
97
+ * PINOUT.md pin assignments, resolved by column *name* and tolerant of the
98
+ * optional Name/Notes columns (see parseCanonicalTables). Only the canonical
99
+ * table that carries both a Pin and a Net header is read; a supporting table
100
+ * (e.g. a `Net | Role` legend) is ignored. Net names are compared bare, so the
101
+ * common `` `VBUS` `` markdown-backtick styling is stripped — the schematic
102
+ * stores plain net names, and a backtick-only difference is not real drift.
103
+ */
104
+ export function parsePinoutRows(md: string): Array<{ ref: string; pin: string; net: string }> {
105
+ const out: Array<{ ref: string; pin: string; net: string }> = [];
106
+ const strip = (s: string | undefined): string => (s ?? '').replace(/`/g, '').trim();
107
+ for (const { header, rows } of parseCanonicalTables(md)) {
108
+ const col = (re: RegExp): number => header.cells.findIndex((c) => re.test(c));
109
+ const refI = col(/^refdes$/i);
110
+ const pinI = col(/^pin$/i);
111
+ const netI = col(/^net$/i);
112
+ if (pinI < 0 || netI < 0) continue; // not the pin-assignment table
113
+ for (const row of rows) {
114
+ out.push({
115
+ ref: refI >= 0 ? strip(row.cells[refI]) : '',
116
+ pin: strip(row.cells[pinI]),
117
+ net: strip(row.cells[netI]),
118
+ });
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Fold the semantically-identical encodings that the model and KiCad render
126
+ * differently, so a value that differs only in *encoding* is not flagged as
127
+ * drift (#I11). A design that reached ERC-clean once churned for turns on
128
+ * `Ihold≥3A` vs `Ihold>=3A` and `0.1"` vs `0.1in` — byte differences with zero
129
+ * electrical meaning. Folded here: ≥/>=, ≤/<=, Ω/ohm(s), µ/μ/u, smart quotes,
130
+ * and the inch mark (`"` / `″` / a trailing `in`/`inch` after a number). NFKC
131
+ * first collapses width/compatibility variants; the explicit rules cover the
132
+ * cases NFKC leaves alone (≥, smart quotes, the ohm/inch words).
133
+ */
134
+ export function foldEncodings(s: string | undefined): string {
135
+ if (!s) return '';
136
+ return s
137
+ .normalize('NFKC')
138
+ .replace(/≥/g, '>=')
139
+ .replace(/≤/g, '<=')
140
+ .replace(/[ΩΩ]/g, 'ohm') // ohm sign U+2126 / greek capital omega U+03A9
141
+ .replace(/\bohms\b/gi, 'ohm')
142
+ .replace(/[µμ]/g, 'u') // micro sign U+00B5 / greek small mu U+03BC
143
+ .replace(/[“”″]/g, '"') // smart double quotes and double-prime → "
144
+ .replace(/[‘’′]/g, "'") // smart single quotes and prime → '
145
+ .replace(/(?<=[\d.])\s*(?:inches|inch|in)\b/gi, '"') // 0.1in / 0.1 inch → 0.1"
146
+ .trim();
147
+ }
148
+
149
+ /**
150
+ * Value-cell equality key: `foldEncodings` plus case- and whitespace-folding.
151
+ * Used to compare BOM.md value/footprint cells against schematic symbol values
152
+ * so an encoding/case/spacing-only difference is not reported as drift.
153
+ */
154
+ export function normalizeValue(s: string | undefined): string {
155
+ return foldEncodings(s).replace(/\s+/g, '').toLowerCase();
156
+ }
157
+
158
+ /**
159
+ * Footprint equality key: like `normalizeValue` but WITHOUT case-folding (F6).
160
+ * A footprint is a KiCad library reference (`Resistor_SMD:R_0603_1608Metric`)
161
+ * whose casing is significant — `R_0603` and `r_0603` are not the same library
162
+ * id — so lowercasing it would hide a real footprint difference. Encoding and
163
+ * spacing are still folded (a stray space or unicode variant is not real drift).
164
+ */
165
+ export function normalizeFootprint(s: string | undefined): string {
166
+ return foldEncodings(s).replace(/\s+/g, '');
167
+ }
168
+
169
+ /**
170
+ * Which of the canonical pin-assignment columns PINOUT.md actually provides.
171
+ * `checkDrift` uses this to emit ONE explicit "no Net column" message when the
172
+ * doc omits the column entirely, instead of silently checking nothing (a
173
+ * correct doc then looks unverified) or — the old positional bug (#I12) —
174
+ * reading the wrong cell and reporting every pin as a false `NC` mismatch.
175
+ * `hasTable` is false when the doc has no Refdes/Pin-headed table at all.
176
+ */
177
+ export function pinoutColumnReport(md: string): { hasTable: boolean; pin: boolean; net: boolean; refdes: boolean } {
178
+ let hasTable = false;
179
+ let pin = false;
180
+ let net = false;
181
+ let refdes = false;
182
+ for (const { header } of parseCanonicalTables(md)) {
183
+ hasTable = true;
184
+ const has = (re: RegExp): boolean => header.cells.some((c) => re.test(c));
185
+ if (has(/^pin$/i)) pin = true;
186
+ if (has(/^net$/i)) net = true;
187
+ if (has(/^refdes$/i)) refdes = true;
188
+ }
189
+ return { hasTable, pin, net, refdes };
190
+ }
191
+
38
192
  /**
39
193
  * A typed BOM.md data row, per the fixed column contract that `init` writes
40
194
  * (Refdes | Value | Footprint | MPN | Rationale — see scaffold.ts's
@@ -51,28 +205,45 @@ export interface TableRow {
51
205
  }
52
206
 
53
207
  /**
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).
208
+ * Parses BOM.md's data rows into typed rows. Columns are resolved by header
209
+ * *name* (Refdes/Value/Footprint/MPN), falling back to the canonical position
210
+ * when a header is absent the same header-name discipline `parsePinoutRows`
211
+ * uses (#I12), so a doc that reorders or drops an optional column is still read
212
+ * correctly instead of silently shifting every cell. Rows without a refdes are
213
+ * dropped rather than thrown on: a hand-edited doc with a ragged or partial
214
+ * table shouldn't crash `check` or `export bom`, it should just be skipped
215
+ * (drift/export callers report the gaps that matter against the schematic).
59
216
  */
60
217
  export function parseBomTable(md: string): BomRow[] {
61
- const rows = parseMarkdownTables(md).filter((r) => !isHeader(r));
62
218
  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
- });
219
+ for (const { header, rows } of parseCanonicalTables(md)) {
220
+ // Resolve by header name; -1 means "not found", so fall back to the
221
+ // canonical index for that column (Refdes 0, Value 1, Footprint 2, MPN 3).
222
+ const col = (re: RegExp, fallback: number): number => {
223
+ const i = header.cells.findIndex((c) => re.test(c));
224
+ return i >= 0 ? i : fallback;
225
+ };
226
+ const refI = col(/^refdes$/i, 0);
227
+ const valI = col(/^value$/i, 1);
228
+ const fpI = col(/^footprint$/i, 2);
229
+ const mpnI = col(/^mpn$/i, 3);
230
+ for (const row of rows) {
231
+ const refdes = row.cells[refI];
232
+ if (!refdes) continue;
233
+ const value = row.cells[valI];
234
+ const footprint = row.cells[fpI];
235
+ const mpn = row.cells[mpnI];
236
+ const flags: string[] = [];
237
+ if (mpn === 'UNVERIFIED') flags.push('UNVERIFIED');
238
+ else if (!mpn) flags.push('MISSING_MPN');
239
+ out.push({
240
+ refdes,
241
+ value: value || undefined,
242
+ footprint: footprint || undefined,
243
+ mpn: mpn || undefined,
244
+ flags,
245
+ });
246
+ }
76
247
  }
77
248
  return out;
78
249
  }
@@ -2,7 +2,14 @@ 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
+ import {
6
+ parseCanonicalRows,
7
+ parseBomTable,
8
+ parsePinoutRows,
9
+ pinoutColumnReport,
10
+ normalizeValue,
11
+ normalizeFootprint,
12
+ } from './bom-table.js';
6
13
 
7
14
  /**
8
15
  * Doc-vs-schematic drift check (AC-2.3). BOM.md and PINOUT.md use fixed table
@@ -31,8 +38,7 @@ export async function emptySchematicWarning(
31
38
  if (symbols.length) return null;
32
39
  const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
33
40
  if (!existsSync(bomPath)) return null;
34
- const refs = parseMarkdownTables(await readFile(bomPath, 'utf8'))
35
- .filter((r) => !isHeader(r))
41
+ const refs = parseCanonicalRows(await readFile(bomPath, 'utf8'))
36
42
  .map((r) => r.cells[0])
37
43
  .filter(Boolean);
38
44
  if (!refs.length) return null;
@@ -54,10 +60,11 @@ export async function checkDrift(repoRoot: string, docsDir: string, schematic: s
54
60
 
55
61
  const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
56
62
  if (existsSync(bomPath)) {
57
- const rows = parseMarkdownTables(await readFile(bomPath, 'utf8')).filter((r) => !isHeader(r));
63
+ // Resolve BOM columns by header name via the shared parseBomTable (F5), so
64
+ // the drift reader and `export bom` never disagree on a reordered table.
65
+ const rows = parseBomTable(await readFile(bomPath, 'utf8'));
58
66
  const seen = new Set<string>();
59
- for (const row of rows) {
60
- const [ref, value, footprint] = row.cells;
67
+ for (const { refdes: ref, value, footprint } of rows) {
61
68
  if (!ref) continue;
62
69
  seen.add(ref);
63
70
  const sym = byRef.get(ref);
@@ -65,10 +72,18 @@ export async function checkDrift(repoRoot: string, docsDir: string, schematic: s
65
72
  mismatches.push({ doc: 'BOM.md', claim: `${ref} exists`, actual: `${ref} not in schematic` });
66
73
  continue;
67
74
  }
68
- if (value !== undefined && value !== sym.value) {
75
+ // Compare on the semantic value, not the byte-exact string: `Ihold≥3A` and
76
+ // `Ihold>=3A` are the same value written two ways (#I11). Raw `!==` flagged
77
+ // them as drift and whack-a-moled the agent across finish attempts.
78
+ if (value !== undefined && normalizeValue(value) !== normalizeValue(sym.value)) {
69
79
  mismatches.push({ doc: 'BOM.md', claim: `${ref} value ${value}`, actual: `${ref} value ${sym.value}` });
70
80
  }
71
- if (footprint !== undefined && footprint !== '' && footprint !== sym.footprint) {
81
+ // Footprint compare folds encoding/spacing but keeps case (F6): a footprint
82
+ // library id is case-sensitive, so lowercasing would hide a real mismatch.
83
+ if (
84
+ footprint !== undefined &&
85
+ normalizeFootprint(footprint) !== normalizeFootprint(sym.footprint)
86
+ ) {
72
87
  mismatches.push({
73
88
  doc: 'BOM.md',
74
89
  claim: `${ref} footprint ${footprint}`,
@@ -85,11 +100,27 @@ export async function checkDrift(repoRoot: string, docsDir: string, schematic: s
85
100
 
86
101
  const pinoutPath = path.join(repoRoot, docsDir, 'PINOUT.md');
87
102
  if (existsSync(pinoutPath)) {
103
+ const pinoutMd = await readFile(pinoutPath, 'utf8');
88
104
  const nets = await pinNets(schPath);
89
105
  const netOf = new Map(nets.map((p) => [`${p.ref}:${p.pinNumber}`, p.net]));
90
- const rows = parseMarkdownTables(await readFile(pinoutPath, 'utf8')).filter((r) => !isHeader(r));
91
- for (const row of rows) {
92
- const [ref, pinNumber, , net] = row.cells;
106
+ // If the doc has a Refdes/Pin table but no Net column, say so once and
107
+ // explicitly, rather than silently checking nothing (a correct doc then
108
+ // reads as unverified) the counterpart to the old positional bug that
109
+ // reported every pin as a false NC (#I12). This tells the model what to fix
110
+ // (add the column) instead of leaving it guessing why nets aren't verified.
111
+ const cols = pinoutColumnReport(pinoutMd);
112
+ if (cols.hasTable && !cols.net) {
113
+ mismatches.push({
114
+ doc: 'PINOUT.md',
115
+ claim: 'the pin table has a Net column (expected header: Refdes | Pin | Net)',
116
+ actual: 'no Net column in the pin table, so pin-to-net assignments cannot be checked; add a Net column',
117
+ });
118
+ }
119
+ // Resolve columns by header name, not position: the PINOUT table may be
120
+ // `Refdes | Pin | Net` or the scaffold's `Refdes | Pin | Name | Net | Notes`.
121
+ // A fixed net index read every pin as "NC" on the 3-column form (#I12).
122
+ const rows = parsePinoutRows(pinoutMd);
123
+ for (const { ref, pin: pinNumber, net } of rows) {
93
124
  if (!ref || !pinNumber) continue;
94
125
  const k = `${ref}:${pinNumber}`;
95
126
  if (!netOf.has(k)) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure argument helpers for the CLI surface. They live here rather than in
3
+ * cli.ts because that module wires up commander and parses argv at import
4
+ * time, which makes it untestable in-process: importing it runs the program.
5
+ * Anything with a branch worth pinning belongs on this side of the line.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import type { BudgetExhaustedStats } from '../agent/loop.js';
10
+
11
+ /** Resolve `--repo` against the working directory; absolute paths pass through. */
12
+ export function repoOf(opts: { repo?: string }): string {
13
+ return path.resolve(opts.repo ?? process.cwd());
14
+ }
15
+
16
+ /** `--max-turns` accepts only a positive integer; "5oops" and "NaN" refuse to start. */
17
+ export function parseMaxTurns(raw: string): number {
18
+ const n = Number(raw);
19
+ if (!Number.isInteger(n) || n <= 0) {
20
+ throw new Error(`--max-turns must be a positive integer, got "${raw}"`);
21
+ }
22
+ return n;
23
+ }
24
+
25
+ /**
26
+ * Turns offered when the budget runs out: ceil of the ORIGINAL budget (design
27
+ * D1), so repeat extensions offer the same increment instead of escalating
28
+ * with the already-extended turn count.
29
+ */
30
+ export function budgetExtraTurns(stats: Pick<BudgetExhaustedStats, 'maxTurns'>): number {
31
+ return Math.ceil(stats.maxTurns / 2);
32
+ }
33
+
34
+ /** The attended "continue?" question, with the cost of the run so far spelled out. */
35
+ export function budgetPromptText(stats: BudgetExhaustedStats): string {
36
+ const k = (n: number): string => `${(n / 1000).toFixed(1)}k`;
37
+ return (
38
+ `Turn budget exhausted (${stats.turnsUsed} turns, ${k(stats.tokensIn)} in / ${k(stats.tokensOut)} out, ` +
39
+ `${stats.filesTouched.length} file(s) touched, ${stats.openObligations} open obligation(s)). ` +
40
+ `Continue with ${budgetExtraTurns(stats)} more turns?`
41
+ );
42
+ }