Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { readFile, writeFile, mkdir, readdir, chmod } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { listSymbols, pinNets, type SchematicSymbol, type PinNet } from '../kicad/sexp.js';
import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
export class InitError extends Error {}
export interface InitResult {
created: string[];
skipped: string[];
refused: string[];
}
const sha = (s: string): string => createHash('sha256').update(s).digest('hex');
async function findKicadFiles(searchRoot: string): Promise<{ sch: string | null; pcb: string | null }> {
let sch: string | null = null;
let pcb: string | null = null;
async function walk(dir: string): Promise<void> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(abs);
else if (entry.name.endsWith('.kicad_sch') && !sch) sch = abs;
else if (entry.name.endsWith('.kicad_pcb') && !pcb) pcb = abs;
}
}
await walk(searchRoot);
return { sch, pcb };
}
function bomTable(symbols: SchematicSymbol[]): string {
const rows = symbols.map(
(s) => `| ${s.ref} | ${s.value} | ${s.footprint} | UNVERIFIED | extracted from schematic by copperhead init |`,
);
return ['| Refdes | Value | Footprint | MPN | Rationale |', '|---|---|---|---|---|', ...rows].join('\n');
}
function pinoutTable(pins: PinNet[]): string {
const rows = pins.map((p) => `| ${p.ref} | ${p.pinNumber} | ${p.pinName} | ${p.net ?? ''} | |`);
return ['| Refdes | Pin | Name | Net | Notes |', '|---|---|---|---|---|', ...rows].join('\n');
}
function generateDocs(
projectName: string,
symbols: SchematicSymbol[],
pins: PinNet[],
): Record<string, string> {
const mcuRefs = new Set(symbols.filter((s) => s.ref.startsWith('U')).map((s) => s.ref));
const mcuPins = pins.filter((p) => mcuRefs.has(p.ref));
return {
'SPEC.md': `# ${projectName} — Specification
What the device is, top-level constraints and budgets.
## Budgets
<!-- Add hard budgets here; copperhead treats them as constraints, e.g. -->
<!-- - sleep_current_uA: 25 -->
## Assumptions
<!-- Decisions the agent made without explicit direction are flagged ASSUMED here -->
`,
'BOM.md': `# Bill of Materials
Every part: refdes, MPN, value, package, and WHY it was chosen. MPNs the agent
introduces without a datasheet check are flagged UNVERIFIED.
Value holds the component value only ("4.7uF", "1M", "500mAh Li-Po") — the
schematic stage draws it as the symbol's Value field, so a description there
collides with neighbouring symbols and fails the legibility gate. Prose belongs
in Rationale. One row per refdes; a grouped row ("C5-C8") matches no symbol.
${bomTable(symbols)}
`,
'PINOUT.md': `# Pinout
Pin assignment extracted from the schematic. Check strapping/RTC notes before
reassigning pins.
${pinoutTable(mcuPins.length ? mcuPins : pins)}
`,
'SUBSYSTEMS.md': `# Subsystems
Per-sheet values and reasoning (regulator, charger, RF, ...).
${[...new Set(symbols.map((s) => s.sheet))].map((sheet) => `## Sheet ${sheet}\n\n${symbols.filter((s) => s.sheet === sheet).map((s) => `- ${s.ref}: ${s.value}`).join('\n')}`).join('\n\n')}
`,
'LAYOUT.md': `# Layout intent
Placement and routing intent: keepouts, pours, ESD placement.
## Draft quality
<!-- copperhead writes an honest assessment here after any layout pass -->
`,
'DECISIONS.md': `# Decision log
Append-only. Every non-trivial agent decision lands here: date, run id,
decision, rationale, and what it affects. Entries are never rewritten.
`,
'CHANGELOG.md': `# Design changelog
Append-only, newest first. One entry per committed copperhead run.
`,
};
}
function copperheadReadme(config: CopperheadConfig): string {
return `# .copperhead — copperhead working memory
Generated by \`copperhead init\`; regenerated on re-runs (do not hand-edit).
## config.json
- \`schematic\` / \`board\`: repo-relative paths to the KiCad files copperhead operates on (currently: ${config.schematic ?? 'none'} / ${config.board ?? 'none'})
- \`docs\`: design docs directory (docs-as-memory), default \`docs/\`
- \`model\`: default provider/model (\`codex\`, \`gpt-5\`, or \`claude\`); overridden by \`--model\` and \`COPPERHEAD_MODEL\`
- local Codex uses the saved \`codex login\`; set \`COPPERHEAD_CODEX_PATH\` only when the executable is not on \`PATH\`
- \`maxTurns\`: agent loop turn budget per run (default 40)
- \`maxRepairCycles\`: ERC/DRC repair attempts before rollback (default 5)
- \`budgets\`: free-form hard constraints (e.g. \`"sleep_current_uA": 25\`); surfaced verbatim into every run's system prompt
- \`generatedHashes\`: content hashes of generated docs, used to detect hand edits on re-init
- \`legibility\` (optional): schematic legibility checker tuning. \`legibility.thresholds\` accepts \`gridPitch\` (default 1.27mm), \`minPitch\` (2.54mm edge-to-edge between symbol bodies), \`utilization\` (0.5 of the usable frame), \`maxWireLength\` (50.8mm), and \`familyCap\` (10 findings per family per sheet). \`legibility.severity.<kind>\` overrides any check family to \`error\`, \`advisory\`, or \`off\` (disables it). Absent keys use the defaults; \`check\` reports findings without failing, the \`create\` schematic stage gates on error-severity findings. \`legibility.score.weights.<metric>\` and \`legibility.score.floor\` tune the quantitative scorer.
## constraints.json
Machine-readable constraint registry. Each key maps to \`{ min/max/forbidden/value, source, affects[] }\`.
\`source\` points at the doc or spec that states the constraint; \`affects\` lists the
refdes/nets/zones to revisit when the constraint changes. Kept in sync with the
docs in the same tool turn (dual write).
## runs/
One directory per \`do\`/\`create\`/\`sync\` run: \`transcript.jsonl\` (full audit
trail) and \`summary.md\` (human-readable: request, plan, files touched,
verification results, decisions, open obligations). Both redact secrets at
write time. This directory is gitignored.
`;
}
const HOOK_MARKER = '# installed by copperhead init';
async function installPreCommitHook(repoRoot: string): Promise<string | null> {
const hooksDir = path.join(repoRoot, '.git', 'hooks');
if (!existsSync(hooksDir)) return null;
const hookPath = path.join(hooksDir, 'pre-commit');
if (existsSync(hookPath)) {
const existing = await readFile(hookPath, 'utf8');
if (existing.includes(HOOK_MARKER)) return null; // idempotent
return null; // never clobber a user's own hook
}
const script = `#!/bin/sh
${HOOK_MARKER}
# Hand edits that desync docs, constraints, or the schematic fail at commit time.
exec copperhead check
`;
await writeFile(hookPath, script, 'utf8');
await chmod(hookPath, 0o755);
return hookPath;
}
export interface InitOptions {
repoRoot: string;
searchPath?: string;
force?: boolean;
installHooks?: boolean;
}
export async function runInit(opts: InitOptions): Promise<InitResult> {
const { repoRoot } = opts;
const searchRoot = path.resolve(repoRoot, opts.searchPath ?? '.');
const { sch, pcb } = await findKicadFiles(searchRoot);
if (!sch) {
throw new InitError(
`no .kicad_sch found under ${path.relative(repoRoot, searchRoot) || '.'} — point copperhead at a KiCad project (copperhead init --path <dir>)`,
);
}
const config = await loadConfig(repoRoot);
config.schematic = path.relative(repoRoot, sch);
config.board = pcb ? path.relative(repoRoot, pcb) : null;
const symbols = await listSymbols(sch);
const pins = await pinNets(sch);
const projectName = path.basename(sch, '.kicad_sch');
const docs = generateDocs(projectName, symbols, pins);
const docsDir = path.join(repoRoot, config.docs);
await mkdir(docsDir, { recursive: true });
const result: InitResult = { created: [], skipped: [], refused: [] };
const hashes: Record<string, string> = { ...(config.generatedHashes ?? {}) };
const appendOnly = new Set(['DECISIONS.md', 'CHANGELOG.md']);
for (const [name, content] of Object.entries(docs)) {
const p = path.join(docsDir, name);
const rel = path.join(config.docs, name);
if (!existsSync(p)) {
await writeFile(p, content, 'utf8');
hashes[name] = sha(content);
result.created.push(rel);
continue;
}
if (appendOnly.has(name)) {
result.skipped.push(rel); // append-only docs are never regenerated
continue;
}
const current = await readFile(p, 'utf8');
if (hashes[name] === sha(current)) {
if (current === content) {
result.skipped.push(rel);
} else if (opts.force) {
await writeFile(p, content, 'utf8');
hashes[name] = sha(content);
result.created.push(rel);
} else {
// schematic changed since generation but doc untouched: safe to refresh
await writeFile(p, content, 'utf8');
hashes[name] = sha(content);
result.created.push(rel);
}
} else if (opts.force) {
await writeFile(p, content, 'utf8');
hashes[name] = sha(content);
result.created.push(rel);
} else {
result.refused.push(rel);
}
}
config.generatedHashes = hashes;
const cfgPath = configPath(repoRoot);
await mkdir(path.dirname(cfgPath), { recursive: true });
await writeFile(cfgPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
// generated documentation, always refreshed (design D12)
await writeFile(path.join(repoRoot, '.copperhead', 'README.md'), copperheadReadme(config), 'utf8');
if (opts.installHooks !== false) {
const hook = await installPreCommitHook(repoRoot);
if (hook) result.created.push(path.relative(repoRoot, hook));
}
return result;
}
ff4fe56b2This file is pinned to the selected release. Download the original to open it in your design tool.