copperhead 0.3.0 → 0.4.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.
- package/NOTICE +5 -0
- package/README.md +55 -9
- package/dist/agent/ledger.js +7 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +275 -33
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +3 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/anthropic.js +28 -13
- package/dist/agent/providers/anthropic.js.map +1 -1
- package/dist/agent/render.js +170 -0
- package/dist/agent/render.js.map +1 -0
- package/dist/agent/runmeta.js +124 -0
- package/dist/agent/runmeta.js.map +1 -0
- package/dist/agent/tools.js +117 -16
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +23 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +45 -9
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +9 -2
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +57 -3
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +11 -5
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +58 -8
- package/dist/kicad/cli.js.map +1 -1
- package/dist/memory/constraints.js +63 -3
- package/dist/memory/constraints.js.map +1 -1
- package/dist/memory/drift.js +31 -0
- package/dist/memory/drift.js.map +1 -1
- package/dist/memory/synap.js +152 -0
- package/dist/memory/synap.js.map +1 -0
- package/dist/util/git.js +125 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/preflight.js +24 -0
- package/dist/util/preflight.js.map +1 -0
- package/package.json +10 -6
- package/src/agent/ledger.ts +9 -1
- package/src/agent/loop.ts +300 -34
- package/src/agent/prompts.ts +3 -1
- package/src/agent/providers/anthropic.ts +40 -16
- package/src/agent/render.ts +194 -0
- package/src/agent/runmeta.ts +198 -0
- package/src/agent/tools.ts +119 -15
- package/src/agent/transcript.ts +49 -0
- package/src/cli.ts +49 -10
- package/src/commands/check.ts +9 -3
- package/src/commands/create.ts +61 -4
- package/src/commands/sync.ts +5 -0
- package/src/config.ts +24 -6
- package/src/kicad/cli.ts +60 -9
- package/src/memory/constraints.ts +90 -3
- package/src/memory/drift.ts +32 -0
- package/src/memory/synap.ts +217 -0
- package/src/util/git.ts +134 -4
- package/src/util/preflight.ts +22 -0
package/src/agent/transcript.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import { appendFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { redactSecrets } from '../util/redact.js';
|
|
4
|
+
import { renderEnvironmentSection, type RunMeta } from './runmeta.js';
|
|
5
|
+
import { fmtDuration, fmtTokens } from './render.js';
|
|
6
|
+
|
|
7
|
+
/** How a run terminated — the single most-queried triage fact (AC-8.5). */
|
|
8
|
+
export type ExitPath =
|
|
9
|
+
| 'done'
|
|
10
|
+
| 'refused'
|
|
11
|
+
| 'turn-budget-exhausted'
|
|
12
|
+
| 'repair-cycles-exhausted'
|
|
13
|
+
| 'commit-failed'
|
|
14
|
+
| 'provider-error'
|
|
15
|
+
| 'stalled';
|
|
16
|
+
|
|
17
|
+
/** Post-run addenda recorded at every terminal branch (AC-8.5). */
|
|
18
|
+
export interface RunStats {
|
|
19
|
+
exitPath: ExitPath;
|
|
20
|
+
turnsUsed: number;
|
|
21
|
+
maxTurns: number;
|
|
22
|
+
repairCyclesUsed: number;
|
|
23
|
+
maxRepairCycles: number;
|
|
24
|
+
tokensIn: number;
|
|
25
|
+
tokensOut: number;
|
|
26
|
+
perTurn: { turn: number; in: number; out: number }[];
|
|
27
|
+
durationMs: number;
|
|
28
|
+
}
|
|
4
29
|
|
|
5
30
|
export interface RunSummaryData {
|
|
6
31
|
request: string;
|
|
@@ -15,6 +40,23 @@ export interface RunSummaryData {
|
|
|
15
40
|
outcome: 'success' | 'failure' | 'aborted';
|
|
16
41
|
openObligations: string | null;
|
|
17
42
|
detail?: string;
|
|
43
|
+
env?: RunMeta;
|
|
44
|
+
stats?: RunStats;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderRunStats(s: RunStats): string[] {
|
|
48
|
+
return [
|
|
49
|
+
`## Run stats`,
|
|
50
|
+
``,
|
|
51
|
+
`- **Exit path:** ${s.exitPath}`,
|
|
52
|
+
`- **Turns:** ${s.turnsUsed} / ${s.maxTurns}`,
|
|
53
|
+
`- **Repair cycles:** ${s.repairCyclesUsed} / ${s.maxRepairCycles}`,
|
|
54
|
+
`- **Tokens:** ${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
|
|
55
|
+
`- **Duration:** ${fmtDuration(s.durationMs)}`,
|
|
56
|
+
...(s.perTurn.length
|
|
57
|
+
? [`- **Per turn:** ${s.perTurn.map((t) => `${t.turn}: ${t.in}/${t.out}`).join(' · ')}`]
|
|
58
|
+
: []),
|
|
59
|
+
];
|
|
18
60
|
}
|
|
19
61
|
|
|
20
62
|
/**
|
|
@@ -38,6 +80,10 @@ export class Transcript {
|
|
|
38
80
|
|
|
39
81
|
async event(type: string, data: unknown): Promise<void> {
|
|
40
82
|
const line = redactSecrets(JSON.stringify({ ts: new Date().toISOString(), type, data }));
|
|
83
|
+
// The audit trail must survive anything that happens to the working tree
|
|
84
|
+
// mid-run (a rollback path once deleted this directory); losing an event
|
|
85
|
+
// is acceptable, crashing the run to report one is not.
|
|
86
|
+
await mkdir(this.dir, { recursive: true });
|
|
41
87
|
await appendFile(this.jsonlPath, line + '\n', 'utf8');
|
|
42
88
|
}
|
|
43
89
|
|
|
@@ -50,6 +96,8 @@ export class Transcript {
|
|
|
50
96
|
`- **OpenSpec change:** ${s.changeId ?? 'n/a'}`,
|
|
51
97
|
`- **Tokens:** ${s.tokensIn} in / ${s.tokensOut} out`,
|
|
52
98
|
``,
|
|
99
|
+
...(s.env ? [...renderEnvironmentSection(s.env), ``] : []),
|
|
100
|
+
...(s.stats ? [...renderRunStats(s.stats), ``] : []),
|
|
53
101
|
`## Plan`,
|
|
54
102
|
``,
|
|
55
103
|
s.plan ?? '(no plan recorded)',
|
|
@@ -72,6 +120,7 @@ export class Transcript {
|
|
|
72
120
|
}
|
|
73
121
|
if (s.detail) lines.push('', '## Detail', '', s.detail);
|
|
74
122
|
const out = path.join(this.dir, 'summary.md');
|
|
123
|
+
await mkdir(this.dir, { recursive: true });
|
|
75
124
|
await writeFile(out, redactSecrets(lines.join('\n') + '\n'), 'utf8');
|
|
76
125
|
return out;
|
|
77
126
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import { createInterface } from 'node:readline/promises';
|
|
5
6
|
import { loadConfig, resolveModel } from './config.js';
|
|
6
7
|
import { runInit, InitError } from './memory/scaffold.js';
|
|
7
8
|
import { runCheck } from './commands/check.js';
|
|
8
9
|
import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
|
|
9
10
|
import { runCreate } from './commands/create.js';
|
|
10
|
-
import { runAgentLoop } from './agent/loop.js';
|
|
11
|
+
import { runAgentLoop, type BudgetExhaustedStats } from './agent/loop.js';
|
|
12
|
+
import { makeRenderer } from './agent/render.js';
|
|
11
13
|
import { kicadCliVersion } from './kicad/cli.js';
|
|
12
14
|
import { loadEnvFile } from './util/env.js';
|
|
13
15
|
|
|
@@ -17,6 +19,12 @@ import { loadEnvFile } from './util/env.js';
|
|
|
17
19
|
// A real environment variable always beats the file.
|
|
18
20
|
loadEnvFile(process.cwd());
|
|
19
21
|
|
|
22
|
+
// Single source of truth for the version. Both src/cli.ts (via tsx) and
|
|
23
|
+
// dist/cli.js sit one level below the package root, so the path holds either
|
|
24
|
+
// way, and a release can never ship a version string that disagrees with the
|
|
25
|
+
// package it was published as.
|
|
26
|
+
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
|
|
27
|
+
|
|
20
28
|
const program = new Command();
|
|
21
29
|
|
|
22
30
|
const repoOf = (opts: { repo?: string }): string => path.resolve(opts.repo ?? process.cwd());
|
|
@@ -28,12 +36,32 @@ async function confirmTty(question: string): Promise<boolean> {
|
|
|
28
36
|
return /^y(es)?$/i.test(answer.trim());
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Attended runs get a decision point instead of a rollback when the turn
|
|
41
|
+
* budget runs out (issue #15). Non-TTY (CI, pipes) keeps fail-and-restore.
|
|
42
|
+
*/
|
|
43
|
+
function budgetContinuePrompt(): ((stats: BudgetExhaustedStats) => Promise<number>) | undefined {
|
|
44
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
|
|
45
|
+
return async (stats) => {
|
|
46
|
+
// ceil of the ORIGINAL budget (design D1), so repeat extensions offer the
|
|
47
|
+
// same increment instead of escalating with the extended turn count.
|
|
48
|
+
const extra = Math.ceil(stats.maxTurns / 2);
|
|
49
|
+
const k = (n: number) => `${(n / 1000).toFixed(1)}k`;
|
|
50
|
+
const q = `Turn budget exhausted (${stats.turnsUsed} turns, ${k(stats.tokensIn)} in / ${k(stats.tokensOut)} out, ${stats.filesTouched.length} file(s) touched, ${stats.openObligations} open obligation(s)). Continue with ${extra} more turns?`;
|
|
51
|
+
return (await confirmTty(q)) ? extra : 0;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
program
|
|
32
56
|
.name('copperhead')
|
|
33
57
|
.description('Cursor for circuit boards: an AI agent for real KiCad repositories')
|
|
34
|
-
.version(
|
|
58
|
+
.version(version)
|
|
35
59
|
.option('--repo <path>', 'target repository (default: cwd)')
|
|
36
|
-
.option('--json', 'machine-readable output')
|
|
60
|
+
.option('--json', 'machine-readable output')
|
|
61
|
+
.option('--plain', 'plain log-style output (no interactive status line)');
|
|
62
|
+
|
|
63
|
+
const rendererOf = () =>
|
|
64
|
+
makeRenderer({ json: Boolean(program.opts().json), plain: Boolean(program.opts().plain) });
|
|
37
65
|
|
|
38
66
|
program
|
|
39
67
|
.command('init')
|
|
@@ -101,9 +129,10 @@ program
|
|
|
101
129
|
) => {
|
|
102
130
|
const repo = repoOf(program.opts());
|
|
103
131
|
try {
|
|
104
|
-
await kicadCliVersion();
|
|
132
|
+
const kicadVer = await kicadCliVersion();
|
|
105
133
|
const config = await loadConfig(repo);
|
|
106
|
-
const model = resolveModel(opts.model, config);
|
|
134
|
+
const { model, source } = resolveModel(opts.model, config);
|
|
135
|
+
const continuePrompt = budgetContinuePrompt();
|
|
107
136
|
const res = await runAgentLoop({
|
|
108
137
|
repoRoot: repo,
|
|
109
138
|
request,
|
|
@@ -113,6 +142,9 @@ program
|
|
|
113
142
|
dryRun: opts.dryRun ?? false,
|
|
114
143
|
interactive: opts.interactive ?? false,
|
|
115
144
|
confirm: confirmTty,
|
|
145
|
+
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
|
|
146
|
+
renderer: rendererOf(),
|
|
147
|
+
meta: { command: 'do', modelSource: source, version, kicadCliVersion: kicadVer },
|
|
116
148
|
});
|
|
117
149
|
if (program.opts().json) console.log(JSON.stringify(res, null, 2));
|
|
118
150
|
process.exit(res.outcome === 'failure' ? 1 : 0);
|
|
@@ -131,7 +163,7 @@ program
|
|
|
131
163
|
.action(async (opts: { model?: string; dryRun?: boolean }) => {
|
|
132
164
|
const repo = repoOf(program.opts());
|
|
133
165
|
try {
|
|
134
|
-
await kicadCliVersion();
|
|
166
|
+
const kicadVer = await kicadCliVersion();
|
|
135
167
|
const report = await syncVerify(repo);
|
|
136
168
|
const json = Boolean(program.opts().json);
|
|
137
169
|
if (json) console.log(JSON.stringify(report, null, 2));
|
|
@@ -147,8 +179,11 @@ program
|
|
|
147
179
|
process.exit(0);
|
|
148
180
|
}
|
|
149
181
|
const config = await loadConfig(repo);
|
|
150
|
-
const model = resolveModel(opts.model, config);
|
|
151
|
-
const res = await syncResolve(repo, report, model, json ? () => {} : (s) => console.log(s)
|
|
182
|
+
const { model, source } = resolveModel(opts.model, config);
|
|
183
|
+
const res = await syncResolve(repo, report, model, json ? () => {} : (s) => console.log(s), {
|
|
184
|
+
renderer: rendererOf(),
|
|
185
|
+
meta: { command: 'sync', modelSource: source, version, kicadCliVersion: kicadVer },
|
|
186
|
+
});
|
|
152
187
|
process.exit(res.ok ? 0 : 1);
|
|
153
188
|
} catch (err) {
|
|
154
189
|
console.error((err as Error).message);
|
|
@@ -165,15 +200,19 @@ program
|
|
|
165
200
|
.action(async (opts: { brief: string; model?: string; interactive?: boolean }) => {
|
|
166
201
|
const repo = repoOf(program.opts());
|
|
167
202
|
try {
|
|
168
|
-
await kicadCliVersion();
|
|
203
|
+
const kicadVer = await kicadCliVersion();
|
|
169
204
|
const config = await loadConfig(repo);
|
|
170
|
-
const model = resolveModel(opts.model, config);
|
|
205
|
+
const { model, source } = resolveModel(opts.model, config);
|
|
206
|
+
const continuePrompt = budgetContinuePrompt();
|
|
171
207
|
const res = await runCreate({
|
|
172
208
|
repoRoot: repo,
|
|
173
209
|
briefPath: opts.brief,
|
|
174
210
|
model,
|
|
175
211
|
interactive: opts.interactive ?? false,
|
|
212
|
+
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
|
|
176
213
|
log: (s) => console.log(s),
|
|
214
|
+
renderer: rendererOf(),
|
|
215
|
+
meta: { command: 'create', modelSource: source, version, kicadCliVersion: kicadVer },
|
|
177
216
|
});
|
|
178
217
|
process.exit(res.ok ? 0 : 1);
|
|
179
218
|
} catch (err) {
|
package/src/commands/check.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { loadConfig } from '../config.js';
|
|
4
4
|
import { runErc, runDrc } from '../kicad/cli.js';
|
|
5
5
|
import { formatViolations, type CheckReport } from '../kicad/report.js';
|
|
6
|
-
import { checkDrift, type DriftMismatch } from '../memory/drift.js';
|
|
6
|
+
import { checkDrift, emptySchematicWarning, type DriftMismatch } from '../memory/drift.js';
|
|
7
7
|
import { loadConstraints, checkForbiddenPins, type ConstraintViolation } from '../memory/constraints.js';
|
|
8
8
|
import { pinNets } from '../kicad/sexp.js';
|
|
9
9
|
import { openspecValidate } from '../openspec/cli.js';
|
|
@@ -16,7 +16,7 @@ export interface CheckResult {
|
|
|
16
16
|
ok: boolean;
|
|
17
17
|
erc: { ok: boolean; violations: number } | null;
|
|
18
18
|
drc: { ok: boolean; violations: number } | null;
|
|
19
|
-
drift: { ok: boolean; mismatches: DriftMismatch[] };
|
|
19
|
+
drift: { ok: boolean; mismatches: DriftMismatch[]; warning?: string };
|
|
20
20
|
openspec: { ok: boolean; detail: string } | null;
|
|
21
21
|
constraints: { ok: boolean; violations: ConstraintViolation[] };
|
|
22
22
|
}
|
|
@@ -41,9 +41,15 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
let drift: DriftMismatch[] = [];
|
|
44
|
+
let driftWarning: string | null = null;
|
|
44
45
|
if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) {
|
|
45
46
|
drift = await checkDrift(repoRoot, config.docs, config.schematic);
|
|
46
47
|
log(drift.length === 0 ? 'drift ✓' : drift.map((m) => `drift: ${m.doc} claims "${m.claim}" but actual is "${m.actual}"`).join('\n'));
|
|
48
|
+
// Informational, never a failure: the zero-symbol drift exemption is for
|
|
49
|
+
// bootstrap, but an established repo that lost its schematic content
|
|
50
|
+
// deserves a visible note rather than a silent green.
|
|
51
|
+
driftWarning = await emptySchematicWarning(repoRoot, config.docs, config.schematic);
|
|
52
|
+
if (driftWarning) log(`drift warning: ${driftWarning}`);
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
let openspec: { ok: boolean; detail: string } | null = null;
|
|
@@ -78,7 +84,7 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
|
|
|
78
84
|
ok,
|
|
79
85
|
erc: erc ? { ok: erc.ok, violations: erc.violations.length } : null,
|
|
80
86
|
drc: drc ? { ok: drc.ok, violations: drc.violations.length } : null,
|
|
81
|
-
drift: { ok: drift.length === 0, mismatches: drift },
|
|
87
|
+
drift: { ok: drift.length === 0, mismatches: drift, ...(driftWarning ? { warning: driftWarning } : {}) },
|
|
82
88
|
openspec,
|
|
83
89
|
constraints: { ok: constraintViolations.length === 0, violations: constraintViolations },
|
|
84
90
|
};
|
package/src/commands/create.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { loadConfig } from '../config.js';
|
|
5
|
-
import {
|
|
6
|
+
import { listSymbols } from '../kicad/sexp.js';
|
|
7
|
+
import { checkDrift } from '../memory/drift.js';
|
|
8
|
+
import { runAgentLoop, type BudgetExhaustedStats } from '../agent/loop.js';
|
|
9
|
+
import type { RunMetaInput } from '../agent/runmeta.js';
|
|
10
|
+
import type { ProgressRenderer } from '../agent/render.js';
|
|
6
11
|
import { openspecInit } from '../openspec/cli.js';
|
|
7
12
|
import { runCheck } from './check.js';
|
|
8
13
|
|
|
@@ -51,14 +56,36 @@ export const STAGES: Stage[] = [
|
|
|
51
56
|
name: 'schematic',
|
|
52
57
|
isComplete: async (root) => {
|
|
53
58
|
const config = await loadConfig(root);
|
|
54
|
-
|
|
59
|
+
if (!config.schematic) return false;
|
|
60
|
+
const p = path.join(root, config.schematic);
|
|
61
|
+
if (!existsSync(p)) return false;
|
|
62
|
+
// Mere file existence is not completion: bootstrapping leaves a blank
|
|
63
|
+
// sheet on disk (a hand-scaffolded project, or the future fix for #19),
|
|
64
|
+
// and skipping this stage over a blank sheet cascades — layout and
|
|
65
|
+
// outputs then run against nothing. The stage's contract is "build the
|
|
66
|
+
// schematic from BOM.md", so completion means symbols exist AND the
|
|
67
|
+
// BOM/PINOUT tables agree with them (drift-clean); anything less keeps
|
|
68
|
+
// the stage active on the next resume so partial capture continues.
|
|
69
|
+
if (!(await listSymbols(p)).length) return false;
|
|
70
|
+
return (await checkDrift(root, config.docs, config.schematic)).length === 0;
|
|
55
71
|
},
|
|
56
72
|
prompt: () =>
|
|
57
73
|
'Stage 4: schematic. Build the schematic sheet by sheet from BOM.md and SUBSYSTEMS.md. After each sheet, run run_erc and fix violations before moving on. Same net names and refdes everywhere. Update PINOUT.md as you assign pins; check the strapping table first.',
|
|
58
74
|
},
|
|
59
75
|
{
|
|
60
76
|
name: 'layout-draft',
|
|
61
|
-
isComplete: (root, docs) =>
|
|
77
|
+
isComplete: async (root, docs) => {
|
|
78
|
+
// The LAYOUT.md marker alone is not enough: `copperhead init` scaffolds
|
|
79
|
+
// LAYOUT.md with the literal "## Draft quality" heading, so an init-ed
|
|
80
|
+
// repo would skip this stage without a single footprint placed. Require
|
|
81
|
+
// a board with at least one footprint on it as well.
|
|
82
|
+
const config = await loadConfig(root);
|
|
83
|
+
if (!config.board) return false;
|
|
84
|
+
const p = path.join(root, config.board);
|
|
85
|
+
if (!existsSync(p)) return false;
|
|
86
|
+
if (!(await readFile(p, 'utf8')).includes('(footprint')) return false;
|
|
87
|
+
return docHasContent(root, path.join(docs, 'LAYOUT.md'), '## Draft quality');
|
|
88
|
+
},
|
|
62
89
|
prompt: () =>
|
|
63
90
|
'Stage 5: first-draft layout. Rule-driven placement written as real coordinates: connectors on edges, decoupling at IC pins, ESD at connectors, keepouts honored. Route power and short critical nets; leave the rest as ratsnest. Every routed net must pass run_drc. Then write the "## Draft quality" section in LAYOUT.md: exactly what is fine and what a human or specialist tool should redo. Non-optimal is acceptable; unlabeled non-optimal is not.',
|
|
64
91
|
},
|
|
@@ -87,22 +114,31 @@ export interface CreateOptions {
|
|
|
87
114
|
briefPath: string;
|
|
88
115
|
model: string;
|
|
89
116
|
interactive?: boolean;
|
|
117
|
+
/** Forwarded to each stage's run (attended continue-on-exhaustion prompt). */
|
|
118
|
+
onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
|
|
90
119
|
log: (s: string) => void;
|
|
120
|
+
renderer?: ProgressRenderer;
|
|
121
|
+
/** Command-level metadata; stage and brief identity are filled in per stage. */
|
|
122
|
+
meta?: Omit<RunMetaInput, 'stage' | 'brief'>;
|
|
91
123
|
}
|
|
92
124
|
|
|
93
125
|
export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; completed: string[] }> {
|
|
94
126
|
const brief = await readFile(path.resolve(opts.briefPath), 'utf8');
|
|
127
|
+
// Hashed from the content already in hand: a brief edited mid-pipeline shows
|
|
128
|
+
// up as a different sha256 in the next stage's metadata (AC-8.1).
|
|
129
|
+
const briefMeta = { path: opts.briefPath, sha256: createHash('sha256').update(brief).digest('hex') };
|
|
95
130
|
const config = await loadConfig(opts.repoRoot);
|
|
96
131
|
await openspecInit(opts.repoRoot);
|
|
97
132
|
const completed: string[] = [];
|
|
98
133
|
|
|
99
|
-
for (const stage of STAGES) {
|
|
134
|
+
for (const [i, stage] of STAGES.entries()) {
|
|
100
135
|
if (await stage.isComplete(opts.repoRoot, config.docs)) {
|
|
101
136
|
opts.log(`stage ${stage.name}: already complete (resuming past it)`);
|
|
102
137
|
completed.push(stage.name);
|
|
103
138
|
continue;
|
|
104
139
|
}
|
|
105
140
|
opts.log(`stage ${stage.name}: running`);
|
|
141
|
+
const stageTurns = config.stageMaxTurns?.[stage.name];
|
|
106
142
|
const res = await runAgentLoop({
|
|
107
143
|
repoRoot: opts.repoRoot,
|
|
108
144
|
model: opts.model,
|
|
@@ -110,12 +146,33 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
110
146
|
stagePrompt: stage.prompt(brief),
|
|
111
147
|
interactive: opts.interactive ?? false,
|
|
112
148
|
allowDirty: true, // stages build on each other's uncommitted state within the pipeline
|
|
149
|
+
...(stageTurns !== undefined ? { maxTurns: stageTurns } : {}),
|
|
150
|
+
...(opts.onBudgetExhausted ? { onBudgetExhausted: opts.onBudgetExhausted } : {}),
|
|
113
151
|
log: opts.log,
|
|
152
|
+
...(opts.renderer ? { renderer: opts.renderer } : {}),
|
|
153
|
+
meta: {
|
|
154
|
+
...opts.meta,
|
|
155
|
+
command: 'create',
|
|
156
|
+
stage: { name: stage.name, index: i + 1, total: STAGES.length },
|
|
157
|
+
brief: briefMeta,
|
|
158
|
+
},
|
|
114
159
|
});
|
|
115
160
|
if (res.outcome !== 'success') {
|
|
116
161
|
opts.log(`stage ${stage.name} did not complete (${res.outcome}); re-run copperhead create to resume here`);
|
|
117
162
|
return { ok: false, completed };
|
|
118
163
|
}
|
|
164
|
+
// A successful run is not the same as a completed stage: an agent can
|
|
165
|
+
// finish "done" with all gates green having only planned the work (seen
|
|
166
|
+
// with the schematic stage: one header edit, ERC "clean" on an empty
|
|
167
|
+
// sheet). Advancing anyway lets every later stage run against a design
|
|
168
|
+
// that isn't there, so hold the pipeline until this stage's repo-state
|
|
169
|
+
// contract is actually met.
|
|
170
|
+
if (!(await stage.isComplete(opts.repoRoot, config.docs))) {
|
|
171
|
+
opts.log(
|
|
172
|
+
`stage ${stage.name}: run succeeded but the stage contract is not met yet (partial work committed); re-run copperhead create to continue this stage`,
|
|
173
|
+
);
|
|
174
|
+
return { ok: false, completed };
|
|
175
|
+
}
|
|
119
176
|
completed.push(stage.name);
|
|
120
177
|
}
|
|
121
178
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { loadConstraints, checkForbiddenPins } from '../memory/constraints.js';
|
|
|
7
7
|
import { pinNets } from '../kicad/sexp.js';
|
|
8
8
|
import { openspecValidate } from '../openspec/cli.js';
|
|
9
9
|
import { runAgentLoop } from '../agent/loop.js';
|
|
10
|
+
import type { RunMetaInput } from '../agent/runmeta.js';
|
|
11
|
+
import type { ProgressRenderer } from '../agent/render.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* `copperhead sync` (design D14): deterministic verify phase, then an optional
|
|
@@ -169,6 +171,7 @@ export async function syncResolve(
|
|
|
169
171
|
report: SyncReport,
|
|
170
172
|
model: string,
|
|
171
173
|
log: (s: string) => void,
|
|
174
|
+
extras?: { renderer?: ProgressRenderer; meta?: RunMetaInput },
|
|
172
175
|
): Promise<{ ok: boolean }> {
|
|
173
176
|
const reportText = formatSyncReport(report);
|
|
174
177
|
const res = await runAgentLoop({
|
|
@@ -177,6 +180,8 @@ export async function syncResolve(
|
|
|
177
180
|
request: 'resolve design-state inconsistencies found by copperhead sync',
|
|
178
181
|
stagePrompt: `You are resolving drift found by the deterministic sync verifier. The inconsistency report is below. Truth precedence: the KiCad files are ground truth for as-built facts (fix the docs to match); openspec specs and SPEC.md budgets are ground truth for requirements. Do NOT touch anything listed as a requirement violation; those are for the human. Apply each proposed resolution, verify, and finish.\n\n${reportText}`,
|
|
179
182
|
log,
|
|
183
|
+
...(extras?.renderer ? { renderer: extras.renderer } : {}),
|
|
184
|
+
...(extras?.meta ? { meta: extras.meta } : {}),
|
|
180
185
|
});
|
|
181
186
|
return { ok: res.outcome === 'success' };
|
|
182
187
|
}
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface CopperheadConfig {
|
|
|
8
8
|
docs: string;
|
|
9
9
|
model: string | null;
|
|
10
10
|
maxTurns: number;
|
|
11
|
+
/** Per-stage overrides for the create pipeline, keyed by stage name. */
|
|
12
|
+
stageMaxTurns?: Record<string, number>;
|
|
11
13
|
maxRepairCycles: number;
|
|
12
14
|
budgets: Record<string, number>;
|
|
13
15
|
/** Content hashes of generated docs, for init idempotency (AC-1.4). */
|
|
@@ -34,20 +36,36 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
|
|
|
34
36
|
return { schematic: null, board: null, ...DEFAULTS };
|
|
35
37
|
}
|
|
36
38
|
const raw = JSON.parse(await readFile(p, 'utf8')) as Partial<CopperheadConfig>;
|
|
39
|
+
// A zero/negative/non-integer stage budget would exhaust the stage on turn 0;
|
|
40
|
+
// drop such entries rather than let a config typo stall the pipeline.
|
|
41
|
+
const stageMaxTurns = Object.fromEntries(
|
|
42
|
+
Object.entries(raw.stageMaxTurns ?? {}).filter(([, v]) => Number.isInteger(v) && v > 0),
|
|
43
|
+
);
|
|
37
44
|
return {
|
|
38
45
|
schematic: raw.schematic ?? null,
|
|
39
46
|
board: raw.board ?? null,
|
|
40
47
|
docs: raw.docs ?? DEFAULTS.docs,
|
|
41
48
|
model: raw.model ?? null,
|
|
42
49
|
maxTurns: raw.maxTurns ?? DEFAULTS.maxTurns,
|
|
50
|
+
...(Object.keys(stageMaxTurns).length ? { stageMaxTurns } : {}),
|
|
43
51
|
maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
|
|
44
52
|
budgets: raw.budgets ?? {},
|
|
45
53
|
...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
|
|
46
54
|
};
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/** Which level of the model-selection precedence chain won. */
|
|
58
|
+
export type ModelSource = 'flag' | 'env' | 'config' | 'openai-key' | 'anthropic-key';
|
|
59
|
+
|
|
60
|
+
export interface ResolvedModel {
|
|
61
|
+
model: string;
|
|
62
|
+
source: ModelSource;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
/**
|
|
50
66
|
* Model selection precedence: flag > COPPERHEAD_MODEL > config > available key.
|
|
67
|
+
* The winning source is returned alongside the model so run metadata can
|
|
68
|
+
* record why a run used the model it did (AC-8.1/8.2).
|
|
51
69
|
*
|
|
52
70
|
* Accepted values (same set for `--model`, COPPERHEAD_MODEL, and `model` in
|
|
53
71
|
* .copperhead/config.json):
|
|
@@ -65,12 +83,12 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
|
|
|
65
83
|
* there. The chosen provider must have its key set: ANTHROPIC_API_KEY for
|
|
66
84
|
* `claude*`, OPENAI_API_KEY otherwise.
|
|
67
85
|
*/
|
|
68
|
-
export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env):
|
|
69
|
-
if (flag) return flag;
|
|
70
|
-
if (env.COPPERHEAD_MODEL) return env.COPPERHEAD_MODEL;
|
|
71
|
-
if (config.model) return config.model;
|
|
72
|
-
if (env.OPENAI_API_KEY) return 'gpt-5';
|
|
73
|
-
if (env.ANTHROPIC_API_KEY) return 'claude';
|
|
86
|
+
export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env): ResolvedModel {
|
|
87
|
+
if (flag) return { model: flag, source: 'flag' };
|
|
88
|
+
if (env.COPPERHEAD_MODEL) return { model: env.COPPERHEAD_MODEL, source: 'env' };
|
|
89
|
+
if (config.model) return { model: config.model, source: 'config' };
|
|
90
|
+
if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
|
|
91
|
+
if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
|
|
74
92
|
throw new Error(
|
|
75
93
|
'no model configured: pass --model, set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
|
|
76
94
|
);
|
package/src/kicad/cli.ts
CHANGED
|
@@ -3,11 +3,18 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { normalizeReport, type CheckReport } from './report.js';
|
|
6
|
+
import { PreflightError } from '../util/preflight.js';
|
|
6
7
|
|
|
7
|
-
export class KicadCliMissingError extends
|
|
8
|
+
export class KicadCliMissingError extends PreflightError {
|
|
8
9
|
constructor() {
|
|
9
10
|
super(
|
|
10
|
-
'kicad-cli not found on PATH
|
|
11
|
+
'kicad-cli not found on PATH',
|
|
12
|
+
'copperhead verifies every mutation with kicad-cli ERC/DRC; without it no edit can be checked, so no run can start',
|
|
13
|
+
[
|
|
14
|
+
'install KiCad ≥ 8: https://www.kicad.org/download/',
|
|
15
|
+
'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)',
|
|
16
|
+
'confirm with "kicad-cli version", then rerun',
|
|
17
|
+
],
|
|
11
18
|
);
|
|
12
19
|
this.name = 'KicadCliMissingError';
|
|
13
20
|
}
|
|
@@ -32,16 +39,27 @@ async function runCheck(
|
|
|
32
39
|
const out = path.join(dir, `${kind}.json`);
|
|
33
40
|
const sub = kind === 'erc' ? ['sch', 'erc'] : ['pcb', 'drc'];
|
|
34
41
|
try {
|
|
35
|
-
await execa(
|
|
42
|
+
const res = await execa(
|
|
36
43
|
'kicad-cli',
|
|
37
44
|
[...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
|
|
38
45
|
{ reject: false },
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
);
|
|
47
|
+
if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
|
|
48
|
+
throw new KicadCliMissingError();
|
|
49
|
+
}
|
|
50
|
+
let raw: unknown;
|
|
51
|
+
try {
|
|
52
|
+
raw = JSON.parse(await readFile(out, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
// No report on disk means kicad-cli bailed before checking — usually the
|
|
55
|
+
// design file itself failed to load (syntax/schema corruption). The
|
|
56
|
+
// raw readFile ENOENT told the agent nothing actionable; kicad-cli's
|
|
57
|
+
// own output at least names the failure.
|
|
58
|
+
const detail = [res.stderr, res.stdout].filter(Boolean).join('\n').trim();
|
|
59
|
+
throw new Error(
|
|
60
|
+
`kicad-cli ${kind} produced no report — the ${kind === 'erc' ? 'schematic' : 'board'} file likely fails to load in KiCad. kicad-cli output: ${detail || '(none)'}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
45
63
|
return normalizeReport(raw, kind);
|
|
46
64
|
} finally {
|
|
47
65
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -52,6 +70,39 @@ export function runErc(schPath: string): Promise<CheckReport> {
|
|
|
52
70
|
return runCheck('erc', schPath);
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Cheap loadability probe for a KiCad file: asks kicad-cli for a throwaway
|
|
75
|
+
* export and reports the failure text if the file won't load. Text edits on
|
|
76
|
+
* s-expression sources can silently corrupt the file; catching that at edit
|
|
77
|
+
* time (with KiCad's own error) beats an opaque failure at ERC/DRC time.
|
|
78
|
+
* Returns null when the file loads.
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* Only schematics and boards have a cheap standalone load probe. Project
|
|
82
|
+
* files and symbol/footprint libraries do not: feeding them to a sch/pcb
|
|
83
|
+
* export "probe" would reject perfectly good files.
|
|
84
|
+
*/
|
|
85
|
+
export function isProbeableKicadFile(p: string): boolean {
|
|
86
|
+
return /\.kicad_(sch|pcb)$/.test(p);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function kicadLoadError(filePath: string): Promise<string | null> {
|
|
90
|
+
if (!isProbeableKicadFile(filePath)) return null;
|
|
91
|
+
const isSch = filePath.endsWith('.kicad_sch');
|
|
92
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'copperhead-validate-'));
|
|
93
|
+
const args = isSch
|
|
94
|
+
? ['sch', 'export', 'netlist', '--output', path.join(dir, 'probe.net'), filePath]
|
|
95
|
+
: ['pcb', 'export', 'pos', '--output', path.join(dir, 'probe.pos'), filePath];
|
|
96
|
+
try {
|
|
97
|
+
const res = await execa('kicad-cli', args, { reject: false });
|
|
98
|
+
if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
|
|
99
|
+
if (res.exitCode === 0) return null;
|
|
100
|
+
return [res.stderr, res.stdout].filter(Boolean).join('\n').trim() || `kicad-cli exited ${res.exitCode}`;
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
55
106
|
export function runDrc(pcbPath: string): Promise<CheckReport> {
|
|
56
107
|
return runCheck('drc', pcbPath);
|
|
57
108
|
}
|