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
package/src/config.ts CHANGED
@@ -2,9 +2,32 @@ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
+ /**
6
+ * Optional `legibility` block: checker thresholds and per-family severity
7
+ * overrides (`off` disables a family). Unknown keys and invalid values are
8
+ * ignored by the checker's own sanitizer, so a config typo cannot crash a run.
9
+ */
10
+ export interface LegibilityUserConfig {
11
+ thresholds?: {
12
+ gridPitch?: number;
13
+ minPitch?: number;
14
+ utilization?: number;
15
+ maxWireLength?: number;
16
+ familyCap?: number;
17
+ };
18
+ severity?: Record<string, 'error' | 'advisory' | 'off'>;
19
+ /** Scorer tuning: per-metric weights and the known-good composite floor. */
20
+ score?: {
21
+ weights?: Record<string, number>;
22
+ floor?: number;
23
+ };
24
+ }
25
+
5
26
  export interface CopperheadConfig {
6
27
  schematic: string | null;
7
28
  board: string | null;
29
+ /** Schematic legibility checker thresholds and severity overrides. */
30
+ legibility?: LegibilityUserConfig;
8
31
  docs: string;
9
32
  model: string | null;
10
33
  maxTurns: number;
@@ -12,9 +35,20 @@ export interface CopperheadConfig {
12
35
  stageMaxTurns?: Record<string, number>;
13
36
  maxRepairCycles: number;
14
37
  budgets: Record<string, number>;
15
- /** Per-turn watchdog (ms). A provider turn exceeding this is aborted and
16
- * retried, so a hung call can't stall the run forever. <=0 disables it. */
38
+ /** Per-turn inactivity watchdog (ms). A provider turn that goes this long
39
+ * without a response or any streamed progress is treated as hung: aborted and
40
+ * retried, so a hung call can't stall the run forever. A streaming provider
41
+ * restarts it on every progress event, so a long turn that keeps producing
42
+ * output is not killed; a provider that reports no progress gets it as a
43
+ * whole-turn deadline. <=0 disables it. */
17
44
  turnTimeoutMs: number;
45
+ /** Hard cap (ms) on one provider turn that is producing output, however much
46
+ * progress it reports. A turn that hits it is too large, not hung, so it fails
47
+ * without a retry: resending the identical request would only run into the cap
48
+ * again. It is never shorter than turnTimeoutMs, and a turn that has reported
49
+ * no progress is judged by turnTimeoutMs alone. <=0 disables it; when unset it
50
+ * defaults to 3600000, or to disabled when turnTimeoutMs is disabled. */
51
+ turnMaxMs: number;
18
52
  /** How often (ms) to emit a liveness heartbeat while a provider turn is in
19
53
  * flight, so a slow large-output turn is distinguishable from a hung one
20
54
  * (5.1). Fires only after the first interval, so quick turns stay silent.
@@ -56,13 +90,17 @@ export const DEFAULTS: Omit<CopperheadConfig, 'schematic' | 'board'> = {
56
90
  maxTurns: 40,
57
91
  maxRepairCycles: 5,
58
92
  budgets: {},
59
- // 10 min. A single large capture turn (a full lib_symbols + instances edit,
60
- // ~40k output tokens) on the claude-code provider legitimately runs several
61
- // minutes; the old 5-min deadline killed those mid-flight and, because the
62
- // watchdog budget is spent per stage, could fail a stage that was only slow,
63
- // not hung. 10 min clears the largest observed turns while still catching a
64
- // genuinely stuck subprocess.
93
+ // 10 min without a response or progress. A single large capture turn (a full
94
+ // lib_symbols + instances edit, ~40k output tokens) on the claude-code
95
+ // provider legitimately runs several minutes; the old 5-min whole-turn
96
+ // deadline killed those mid-flight. Some turns run past 10 min too, so the
97
+ // deadline restarts on every streamed progress event: it bounds a silent
98
+ // stretch, not the turn's length. A provider that cannot stream still gets it
99
+ // as a whole-turn deadline, so no turn that finished in time before times out now.
65
100
  turnTimeoutMs: 600000,
101
+ // 60 min: the backstop for a turn that keeps streaming. Far past the largest
102
+ // observed turns, while a runaway generation still ends in bounded time.
103
+ turnMaxMs: 3600000,
66
104
  // 30s: within one interval an operator knows a turn is alive, and a full
67
105
  // 10-min turn emits ~20 lines — enough to distinguish slow from hung without
68
106
  // flooding the log. Quick turns (< 30s) emit nothing.
@@ -96,6 +134,15 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
96
134
  maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
97
135
  budgets: raw.budgets ?? {},
98
136
  turnTimeoutMs: typeof raw.turnTimeoutMs === 'number' ? raw.turnTimeoutMs : DEFAULTS.turnTimeoutMs,
137
+ // A repo that switched the turn watchdog off gets no default cap either:
138
+ // before the cap existed that meant no deadline at all, and a default must
139
+ // not quietly bring one back. An explicit turnMaxMs still applies.
140
+ turnMaxMs:
141
+ typeof raw.turnMaxMs === 'number'
142
+ ? raw.turnMaxMs
143
+ : typeof raw.turnTimeoutMs === 'number' && raw.turnTimeoutMs <= 0
144
+ ? 0
145
+ : DEFAULTS.turnMaxMs,
99
146
  heartbeatMs: typeof raw.heartbeatMs === 'number' ? raw.heartbeatMs : DEFAULTS.heartbeatMs,
100
147
  maxStageRetries:
101
148
  Number.isInteger(raw.maxStageRetries) && (raw.maxStageRetries as number) >= 0
@@ -106,6 +153,7 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
106
153
  ...(typeof raw.apiKeyEnv === 'string' && raw.apiKeyEnv.trim() ? { apiKeyEnv: raw.apiKeyEnv.trim() } : {}),
107
154
  ...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
108
155
  ...(raw.origin === 'create' || raw.origin === 'init' ? { origin: raw.origin } : {}),
156
+ ...(raw.legibility && typeof raw.legibility === 'object' ? { legibility: raw.legibility } : {}),
109
157
  };
110
158
  }
111
159
 
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { writeFile } from 'node:fs/promises';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { createHash } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
6
+ import { CREATE_ORIGIN } from './fab.js';
6
7
 
7
8
  /**
8
9
  * The create pipeline starts from a brief with no KiCad files, but the agent
@@ -37,8 +38,8 @@ function uuidFrom(seed: string): string {
37
38
  function emptySchematic(rootUuid: string): string {
38
39
  return `(kicad_sch
39
40
  (version 20231120)
40
- (generator "eeschema")
41
- (generator_version "8.0")
41
+ (generator "copperhead-draft")
42
+ (generator_version "0")
42
43
  (uuid "${rootUuid}")
43
44
  (paper "A4")
44
45
  (lib_symbols)
@@ -147,9 +148,25 @@ function projectFile(slug: string, rootUuid: string): string {
147
148
  }
148
149
 
149
150
  async function persist(repoRoot: string, config: CopperheadConfig): Promise<void> {
151
+ await mkdir(path.dirname(configPath(repoRoot)), { recursive: true });
150
152
  await writeFile(configPath(repoRoot), JSON.stringify(config, null, 2) + '\n', 'utf8');
151
153
  }
152
154
 
155
+ /**
156
+ * Stamp the config as create-produced (`origin: "create"`). The marker is what
157
+ * scopes the legibility finish gate (`isCreateProducedRepo` feeds the
158
+ * obligations ledger) and the fab release gate — a gate hung on a marker
159
+ * nothing writes is silently inert, so `runCreate` stamps it up front and
160
+ * `bootstrapKicadProject` re-stamps on every (re-)scaffold, covering the
161
+ * rollback path that deletes an uncommitted config.
162
+ */
163
+ export async function markCreateOrigin(repoRoot: string): Promise<void> {
164
+ const config = await loadConfig(repoRoot);
165
+ if (config.origin === CREATE_ORIGIN) return;
166
+ config.origin = CREATE_ORIGIN;
167
+ await persist(repoRoot, config);
168
+ }
169
+
153
170
  /**
154
171
  * Ensure a KiCad project exists and is wired into config. No-op (returns null)
155
172
  * when config already points at a schematic on disk. If project files exist but
@@ -160,6 +177,10 @@ async function persist(repoRoot: string, config: CopperheadConfig): Promise<void
160
177
  export async function bootstrapKicadProject(repoRoot: string, brief: string): Promise<string | null> {
161
178
  const config = await loadConfig(repoRoot);
162
179
  if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) return null;
180
+ // Only `create` scaffolds through here, so the repo is create-produced by
181
+ // definition; stamping on every scaffold keeps the marker alive across the
182
+ // rollback-then-rescaffold path (git clean deletes an uncommitted config).
183
+ config.origin = CREATE_ORIGIN;
163
184
 
164
185
  const slug = projectSlug(brief);
165
186
  const schRel = `${slug}.kicad_sch`;
package/src/kicad/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execa, ExecaError } from 'execa';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
@@ -7,14 +7,29 @@ import { normalizeReport, type CheckReport } from './report.js';
7
7
  import { PreflightError, isNotFoundError } from '../util/preflight.js';
8
8
 
9
9
  export class KicadCliMissingError extends PreflightError {
10
- constructor() {
10
+ constructor(platform = process.platform) {
11
+ const isWin = platform === 'win32';
12
+ const isMac = platform === 'darwin';
13
+
14
+ const locationHint = isWin
15
+ ? 'ensure kicad-cli.exe is on PATH (typically in C:\\Program Files\\KiCad\\<version>\\bin)'
16
+ : isMac
17
+ ? 'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)'
18
+ : 'ensure the kicad-cli binary is on PATH (typically /usr/bin/kicad-cli)';
19
+
20
+ const envHint = isWin
21
+ ? 'or set COPPERHEAD_KICAD_CLI="C:\\Program Files\\KiCad\\10.0\\bin\\kicad-cli.exe"'
22
+ : isMac
23
+ ? 'or set COPPERHEAD_KICAD_CLI=/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli'
24
+ : 'or set COPPERHEAD_KICAD_CLI=/usr/bin/kicad-cli';
25
+
11
26
  super(
12
27
  'kicad-cli not found on PATH',
13
28
  'copperhead verifies every mutation with kicad-cli ERC/DRC; without it no edit can be checked, so no run can start',
14
29
  [
15
30
  'install KiCad ≥ 8: https://www.kicad.org/download/',
16
- 'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)',
17
- 'or set COPPERHEAD_KICAD_CLI=/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
31
+ locationHint,
32
+ envHint,
18
33
  'confirm with "kicad-cli version", then rerun',
19
34
  ],
20
35
  );
@@ -28,15 +43,32 @@ export class KicadCliMissingError extends PreflightError {
28
43
  * seen and rejected, so telling the user to set it would be nonsense.
29
44
  */
30
45
  export class KicadCliBadOverrideError extends PreflightError {
31
- constructor(configured: string) {
46
+ constructor(configured: string, platform = process.platform) {
47
+ const isWin = platform === 'win32';
48
+ const isMac = platform === 'darwin';
49
+
50
+ const checkHint = isWin
51
+ ? `check the path in PowerShell: Test-Path "${configured}"`
52
+ : `check the path: ls -l "${configured}"`;
53
+
54
+ const locationHint = isWin
55
+ ? 'on Windows the binary typically lives at C:\\Program Files\\KiCad\\<version>\\bin\\kicad-cli.exe'
56
+ : isMac
57
+ ? 'on macOS the binary lives at /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli'
58
+ : 'on Linux the binary typically lives at /usr/bin/kicad-cli';
59
+
60
+ const confirmHint = isWin
61
+ ? 'confirm with "& $env:COPPERHEAD_KICAD_CLI version", then rerun'
62
+ : 'confirm with "$COPPERHEAD_KICAD_CLI" version, then rerun';
63
+
32
64
  super(
33
65
  `COPPERHEAD_KICAD_CLI points to a path that does not exist: ${configured}`,
34
66
  'the override wins over PATH, so falling back silently would run a different binary than the one you named and make the failure impossible to diagnose',
35
67
  [
36
- `check the path: ls -l "${configured}"`,
37
- 'on macOS the binary lives at /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
68
+ checkHint,
69
+ locationHint,
38
70
  'or unset COPPERHEAD_KICAD_CLI to fall back to PATH',
39
- 'confirm with "$COPPERHEAD_KICAD_CLI version", then rerun',
71
+ confirmHint,
40
72
  ],
41
73
  );
42
74
  this.name = 'KicadCliBadOverrideError';
@@ -44,20 +76,89 @@ export class KicadCliBadOverrideError extends PreflightError {
44
76
  }
45
77
 
46
78
  /** Well-known install locations when `kicad-cli` is not on PATH (macOS app bundle). */
47
- const FALLBACK_BINARIES = [
79
+ const MACOS_FALLBACK_BINARIES = [
48
80
  '/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
49
81
  '/Applications/KiCad-10.0/KiCad.app/Contents/MacOS/kicad-cli',
50
82
  '/Applications/KiCad-9.0/KiCad.app/Contents/MacOS/kicad-cli',
51
83
  '/Applications/KiCad-8.0/KiCad.app/Contents/MacOS/kicad-cli',
52
84
  ];
53
85
 
54
- let fallbackBinaries: readonly string[] = FALLBACK_BINARIES;
86
+ /**
87
+ * Minimum KiCad major version accepted for ERC/DRC and candidate discovery.
88
+ * Re-used by doctor.ts to ensure CLI discovery and environment preflight
89
+ * enforce the same version floor.
90
+ */
91
+ export const MIN_KICAD_MAJOR = 8;
92
+
93
+ const DEFAULT_WIN_ROOTS = [
94
+ process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'KiCad') : 'C:/Program Files/KiCad',
95
+ 'C:/Program Files/KiCad',
96
+ 'C:/Program Files (x86)/KiCad',
97
+ ];
98
+
99
+ /**
100
+ * Return default fallback candidate paths for macOS and Windows standard KiCad installations.
101
+ * On Windows, version-numbered directories (e.g. 10.0, 9.0, 8.0) under the KiCad root
102
+ * are probed in descending numerical order, mirroring symlib.ts.
103
+ */
104
+ export function defaultFallbackBinaries(winRoots: readonly string[] = DEFAULT_WIN_ROOTS): string[] {
105
+ const candidates: string[] = [...MACOS_FALLBACK_BINARIES];
106
+ const seenRoots = new Set<string>();
107
+
108
+ for (const winRoot of winRoots) {
109
+ const normalized = path.normalize(winRoot).toLowerCase();
110
+ if (seenRoots.has(normalized)) continue;
111
+ seenRoots.add(normalized);
112
+
113
+ if (!existsSync(winRoot)) continue;
114
+ try {
115
+ const entries = readdirSync(winRoot, { withFileTypes: true });
116
+ const versions = entries
117
+ .filter((e) => e.isDirectory())
118
+ .map((e) => e.name)
119
+ // Only include versions that meet the minimum supported KiCad major.
120
+ // This prevents a stale KiCad 7.x install from silently becoming the
121
+ // ERC/DRC binary while doctor correctly reports FAIL for it.
122
+ .filter((name) => {
123
+ const major = Number(name.split('.')[0]);
124
+ return Number.isFinite(major) && major >= MIN_KICAD_MAJOR;
125
+ })
126
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
127
+
128
+ for (const version of versions) {
129
+ candidates.push(
130
+ path.join(winRoot, version, 'bin', 'kicad-cli.exe'),
131
+ path.join(winRoot, version, 'bin', 'kicad-cli.cmd'),
132
+ path.join(winRoot, version, 'bin', 'kicad-cli.bat'),
133
+ path.join(winRoot, version, 'bin', 'kicad-cli'),
134
+ );
135
+ }
136
+ } catch {
137
+ // not readable / not a directory; versioned candidates unavailable, but
138
+ // the unversioned bin/ fallback below is still worth probing.
139
+ }
140
+ // Unversioned fallback: pushed unconditionally so a readdirSync failure
141
+ // (e.g. ENOTDIR) on the root itself doesn't also suppress this probe.
142
+ candidates.push(
143
+ path.join(winRoot, 'bin', 'kicad-cli.exe'),
144
+ path.join(winRoot, 'bin', 'kicad-cli.cmd'),
145
+ path.join(winRoot, 'bin', 'kicad-cli.bat'),
146
+ path.join(winRoot, 'bin', 'kicad-cli'),
147
+ );
148
+ }
149
+
150
+ return candidates;
151
+ }
152
+
153
+ let fallbackBinariesOverride: readonly string[] | null = null;
154
+ let fallbackWinRootsOverride: readonly string[] | null = null;
55
155
 
56
156
  let cachedBinary: string | null | undefined;
57
157
 
58
158
  /**
59
159
  * Resolve the kicad-cli executable: `COPPERHEAD_KICAD_CLI` > PATH name
60
- * (`kicad-cli`). On PATH miss, `runKicad` falls back to macOS KiCad.app paths.
160
+ * (`kicad-cli`). On PATH miss, `runKicad` falls back to macOS KiCad.app paths
161
+ * and Windows standard installation paths.
61
162
  */
62
163
  export function resolveKicadCli(): string {
63
164
  if (cachedBinary !== undefined) {
@@ -88,7 +189,7 @@ function envOverride(): string | null {
88
189
  }
89
190
 
90
191
  /**
91
- * After ENOENT on PATH, retry with a known macOS install path.
192
+ * After ENOENT on PATH, retry with known install paths (macOS app bundles, Windows standard paths).
92
193
  *
93
194
  * Deliberately does not re-read `COPPERHEAD_KICAD_CLI`: this is reached only
94
195
  * when resolveKicadCli() cached the bare PATH name, which in turn happens only
@@ -96,7 +197,9 @@ function envOverride(): string | null {
96
197
  * override re-check here could never fire.
97
198
  */
98
199
  function fallbackAfterMissing(): string {
99
- for (const candidate of fallbackBinaries) {
200
+ const candidates =
201
+ fallbackBinariesOverride ?? defaultFallbackBinaries(fallbackWinRootsOverride ?? undefined);
202
+ for (const candidate of candidates) {
100
203
  if (existsSync(candidate)) {
101
204
  cachedBinary = candidate;
102
205
  return candidate;
@@ -133,13 +236,21 @@ export function resetKicadCliCache(): void {
133
236
  }
134
237
 
135
238
  /**
136
- * Test helper: point the app-bundle probe at fixture paths, or call with no
137
- * argument to restore the real list. Without this the fallback branch is only
138
- * exercisable on a macOS host that happens to have KiCad installed, which
139
- * makes the outcome depend on the developer's machine.
239
+ * Test helper: point the app-bundle / binary probe at fixture paths, or call with no
240
+ * argument to restore the default dynamic list.
140
241
  */
141
242
  export function setKicadFallbackBinaries(paths?: readonly string[]): void {
142
- fallbackBinaries = paths ?? FALLBACK_BINARIES;
243
+ fallbackBinariesOverride = paths ?? null;
244
+ fallbackWinRootsOverride = null;
245
+ }
246
+
247
+ /**
248
+ * Test helper: point the Windows installation discovery probe at fixture roots, or call
249
+ * with no argument to restore the default search roots.
250
+ */
251
+ export function setKicadFallbackWinRoots(roots?: readonly string[]): void {
252
+ fallbackWinRootsOverride = roots ?? null;
253
+ fallbackBinariesOverride = null;
143
254
  }
144
255
 
145
256
  export async function kicadCliVersion(): Promise<string> {
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The stage-4 entry pin dossier: every BOM part resolved against the installed
3
+ * KiCad libraries, rendered as a prompt block, so the schematic agent starts
4
+ * with the pin facts it would otherwise spend turns reconstructing.
5
+ *
6
+ * The BOM is frozen when stage 4 starts, which makes this computable before the
7
+ * first agent turn — the same insight `symbolAvailabilityFacts` applies at
8
+ * recovery time, moved to entry. Like that block, coverage is stated rather
9
+ * than implied: parts past the size cap are named as NOT INCLUDED, parts a
10
+ * probe error skipped are named as UNRESOLVED, and parts whose only name is
11
+ * too short to search are named as NOT SEARCHED — never silently dropped,
12
+ * because absence from the dossier must never read as absence from the
13
+ * libraries.
14
+ *
15
+ * Advisory only. It changes prompt content, not gates: a missing BOM, an
16
+ * unreadable library, or any error degrades to an empty string and the stage
17
+ * runs exactly as it did before this block existed.
18
+ */
19
+
20
+ import { parseBomTable } from '../memory/bom-table.js';
21
+ import {
22
+ resolveLibrarySymbol,
23
+ searchInstalledSymbols,
24
+ listInstalledLibraries,
25
+ comparePinNumbers,
26
+ type LibPin,
27
+ } from './symlib.js';
28
+
29
+ /** R/C/L refdes (with optional multi-part suffix like R12A) draw from their
30
+ * canonical `Device:*` symbols; a two-pin table per resistor is noise. */
31
+ const PASSIVE_REFDES = /^[RCL]\d+[A-Za-z]?$/i;
32
+
33
+ /** `1=PE2/bidirectional 2(passive) …` — name omitted when the library leaves
34
+ * the pin unnamed (`~` or empty), since `1=~/passive` reads as line noise. */
35
+ function pinTable(pins: LibPin[]): string {
36
+ return [...pins]
37
+ .sort((a, b) => comparePinNumbers(a.number, b.number))
38
+ .map((p) => {
39
+ const name = p.name === '~' ? '' : p.name;
40
+ return name ? `${p.number}=${name}/${p.type}` : `${p.number}(${p.type})`;
41
+ })
42
+ .join(' ');
43
+ }
44
+
45
+ /** Render `prefix + as many names as fit + suffix` within `budget` chars; the
46
+ * tail that does not fit becomes "…and N more". The budget is a hard bound:
47
+ * when even the minimal form exceeds it, the string is truncated outright, so
48
+ * the trailer can never blow the size cap it exists to disclose. */
49
+ function boundedList(prefix: string, names: string[], suffix: string, budget: number): string {
50
+ let line = '';
51
+ let shown = 0;
52
+ for (let i = 0; i < names.length; i++) {
53
+ const sep = shown === 0 ? '' : '; ';
54
+ const tail = `…and ${names.length - i} more`;
55
+ const candidate = `${line}${sep}${names[i]}`;
56
+ // Reserve room for the worst-case continuation marker after this name.
57
+ if (prefix.length + candidate.length + tail.length + 2 + suffix.length > budget) break;
58
+ line = candidate;
59
+ shown++;
60
+ }
61
+ const rest = names.length - shown;
62
+ const full =
63
+ rest === 0
64
+ ? `${prefix}${line}${suffix}`
65
+ : `${prefix}${line ? `${line}; ` : ''}…and ${rest} more${suffix}`;
66
+ if (full.length <= budget) return full;
67
+ return full.slice(0, Math.max(1, budget - 1)) + '…';
68
+ }
69
+
70
+ export interface DossierOptions {
71
+ /** candidate lib_ids fetched per part */
72
+ searchCap?: number;
73
+ /** bound on the complete rendered block, disclosure lines included */
74
+ maxChars?: number;
75
+ }
76
+
77
+ /**
78
+ * Render the dossier block from BOM markdown. Returns `''` when there is
79
+ * nothing to say (no rows survive the passive filter, no search dirs, or any
80
+ * error) — the caller injects nothing rather than an empty heading.
81
+ */
82
+ export async function bomSymbolDossier(
83
+ bomMd: string,
84
+ dirs: string[],
85
+ opts: DossierOptions = {},
86
+ ): Promise<string> {
87
+ const searchCap = opts.searchCap ?? 3;
88
+ const maxChars = opts.maxChars ?? 24_000;
89
+ try {
90
+ if (!dirs.length) return '';
91
+ // No readable library at all must yield no dossier, not a page of
92
+ // NO-INSTALLED-SYMBOL lines: a false absence claim in a machine-verified
93
+ // block is the exact failure mode this file exists to prevent (I15).
94
+ if (!(await listInstalledLibraries(dirs)).size) return '';
95
+ // Group refdes by primary query so a part used five times renders once.
96
+ // The MPN is the stronger name when present; the stage-3 scaffold's
97
+ // UNVERIFIED flag word is not part of it. The Value is kept as a fallback
98
+ // query, searched only when the MPN finds nothing — a bogus MPN over a
99
+ // resolvable Value must not read as NO INSTALLED SYMBOL.
100
+ const byQuery = new Map<string, { refs: string[]; fallback?: string }>();
101
+ const unsearchable: string[] = [];
102
+ for (const row of parseBomTable(bomMd)) {
103
+ if (PASSIVE_REFDES.test(row.refdes)) continue;
104
+ const mpn = (row.mpn ?? '').replace(/^UNVERIFIED[:\s]*/i, '').trim();
105
+ const value = (row.value ?? '').trim();
106
+ const query = mpn || value;
107
+ if (query.length < 3) {
108
+ // Disclosed, not dropped: a crystal named "8M" was never searched, and
109
+ // silence here would read as "checked" under a machine-verified label.
110
+ unsearchable.push(`${row.refdes} (${query || 'no name'})`);
111
+ continue;
112
+ }
113
+ const entry = byQuery.get(query) ?? { refs: [] };
114
+ entry.refs.push(row.refdes);
115
+ if (mpn && value.length >= 3 && value !== mpn) entry.fallback = value;
116
+ byQuery.set(query, entry);
117
+ }
118
+ if (!byQuery.size && !unsearchable.length) return '';
119
+
120
+ const lines: string[] = [];
121
+ const overflow: string[] = [];
122
+ const errored: string[] = [];
123
+ // Reserve room for the disclosure trailers up front, so the complete
124
+ // rendered block — disclosures included — stays within maxChars.
125
+ const TRAILER_BUDGET = Math.min(1200, Math.floor(maxChars / 4));
126
+ const bodyBudget = maxChars - TRAILER_BUDGET;
127
+ let spent = 0;
128
+ // Once a line fails to fit, later parts skip their library scans entirely:
129
+ // the cap bounds the work, not just the rendered output. (Lines have a
130
+ // floor of ~60 chars, so a later part fitting where this one did not is
131
+ // rare enough not to pay a full scan hoping for it.)
132
+ let bodyFull = false;
133
+ for (const [query, { refs, fallback }] of byQuery) {
134
+ const who = `${refs.join(', ')} (${query})`;
135
+ if (bodyFull) {
136
+ overflow.push(who);
137
+ continue;
138
+ }
139
+ let line: string;
140
+ try {
141
+ let hits = await searchInstalledSymbols(query, dirs, searchCap);
142
+ let matchedBy = '';
143
+ if (!hits.length && fallback) {
144
+ hits = await searchInstalledSymbols(fallback, dirs, searchCap);
145
+ if (hits.length) matchedBy = ` (matched by Value "${fallback}")`;
146
+ }
147
+ const top = hits[0];
148
+ if (!top) {
149
+ line = `- ${who}: NO INSTALLED SYMBOL matches — not capturable as named; substitute a part whose symbol exists (search_symbols to find one)`;
150
+ } else {
151
+ const r = await resolveLibrarySymbol(top, dirs);
152
+ if (r.status !== 'ok') {
153
+ // A hit that fails to re-resolve is a library race or parse quirk;
154
+ // report the candidates without claiming pins we could not read.
155
+ line = `- ${who}: candidates ${hits.join(', ')} — pins unreadable here; confirm with symbol_pins`;
156
+ } else {
157
+ const multi = r.units >= 2 ? ` — MULTI-UNIT (${r.units} units): the drafting engine places each unit separately under this refdes` : '';
158
+ const also = hits.length > 1 ? `\n also installed: ${hits.slice(1).join(', ')}` : '';
159
+ line = `- ${who}: ${top}${matchedBy} — ${r.pins.length} pin(s): ${pinTable(r.pins)}${multi}${also}`;
160
+ }
161
+ }
162
+ } catch {
163
+ errored.push(who); // one failed probe must not sink the block
164
+ continue;
165
+ }
166
+ if (spent + line.length > bodyBudget) {
167
+ overflow.push(who);
168
+ bodyFull = true;
169
+ continue;
170
+ }
171
+ spent += line.length;
172
+ lines.push(line);
173
+ }
174
+ if (!lines.length && !overflow.length && !errored.length && !unsearchable.length) return '';
175
+ // Distinct disclosures: a probe error is not a size decision, an
176
+ // unsearchable name is neither, and labeling any of them "size cap" would
177
+ // misreport why coverage is missing. Each trailer shares the reserved
178
+ // budget so the complete block stays within maxChars.
179
+ const trailers: [string, string[], string][] = [];
180
+ if (errored.length) {
181
+ trailers.push([
182
+ '- UNRESOLVED (probe error): ',
183
+ errored,
184
+ ' — the probe failed for these; an error says nothing about availability, call symbol_pins for each.',
185
+ ]);
186
+ }
187
+ if (unsearchable.length) {
188
+ trailers.push([
189
+ '- NOT SEARCHED (name shorter than 3 chars): ',
190
+ unsearchable,
191
+ ' — too short to search reliably; nothing here says whether these resolve. Give them an MPN, or verify by hand.',
192
+ ]);
193
+ }
194
+ if (overflow.length) {
195
+ trailers.push([
196
+ `- NOT INCLUDED (size cap ${maxChars} chars): `,
197
+ overflow,
198
+ ' — call symbol_pins for each; nothing above says whether these resolve.',
199
+ ]);
200
+ }
201
+ // Allocate from what is actually left of maxChars (body lines plus the
202
+ // newlines join() will add), splitting the remainder across the trailers
203
+ // still to render — a fixed per-trailer share of the reserve could exceed
204
+ // the whole-block bound when the shares themselves cannot fit.
205
+ let used = lines.reduce((a, l) => a + l.length + 1, 0);
206
+ for (let i = 0; i < trailers.length; i++) {
207
+ const [prefix, names, suffix] = trailers[i]!;
208
+ const budget = Math.floor((maxChars - used) / (trailers.length - i));
209
+ const rendered = boundedList(prefix, names, suffix, budget);
210
+ lines.push(rendered);
211
+ used += rendered.length + 1;
212
+ }
213
+ return lines.join('\n');
214
+ } catch {
215
+ return '';
216
+ }
217
+ }