Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
54e85dd7fThis 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;
}