copperhead 0.3.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/LICENSE +201 -0
- package/README.md +87 -0
- package/dist/agent/filetools.js +118 -0
- package/dist/agent/filetools.js.map +1 -0
- package/dist/agent/ledger.js +43 -0
- package/dist/agent/ledger.js.map +1 -0
- package/dist/agent/loop.js +279 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompts.js +47 -0
- package/dist/agent/prompts.js.map +1 -0
- package/dist/agent/providers/anthropic.js +78 -0
- package/dist/agent/providers/anthropic.js.map +1 -0
- package/dist/agent/providers/openai.js +74 -0
- package/dist/agent/providers/openai.js.map +1 -0
- package/dist/agent/tools.js +439 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/agent/transcript.js +60 -0
- package/dist/agent/transcript.js.map +1 -0
- package/dist/agent/types.js +2 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli.js +185 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.js +64 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/create.js +91 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/sync.js +144 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/kicad/cli.js +91 -0
- package/dist/kicad/cli.js.map +1 -0
- package/dist/kicad/report.js +48 -0
- package/dist/kicad/report.js.map +1 -0
- package/dist/kicad/sexp.js +291 -0
- package/dist/kicad/sexp.js.map +1 -0
- package/dist/memory/constraints.js +43 -0
- package/dist/memory/constraints.js.map +1 -0
- package/dist/memory/drift.js +81 -0
- package/dist/memory/drift.js.map +1 -0
- package/dist/memory/scaffold.js +215 -0
- package/dist/memory/scaffold.js.map +1 -0
- package/dist/openspec/cli.js +31 -0
- package/dist/openspec/cli.js.map +1 -0
- package/dist/util/env.js +68 -0
- package/dist/util/env.js.map +1 -0
- package/dist/util/git.js +53 -0
- package/dist/util/git.js.map +1 -0
- package/dist/util/paths.js +25 -0
- package/dist/util/paths.js.map +1 -0
- package/dist/util/redact.js +21 -0
- package/dist/util/redact.js.map +1 -0
- package/dist/util/retry.js +27 -0
- package/dist/util/retry.js.map +1 -0
- package/package.json +73 -0
- package/src/agent/filetools.ts +136 -0
- package/src/agent/ledger.ts +71 -0
- package/src/agent/loop.ts +320 -0
- package/src/agent/prompts.ts +58 -0
- package/src/agent/providers/anthropic.ts +81 -0
- package/src/agent/providers/openai.ts +80 -0
- package/src/agent/tools.ts +481 -0
- package/src/agent/transcript.ts +78 -0
- package/src/agent/types.ts +32 -0
- package/src/cli.ts +188 -0
- package/src/commands/check.ts +85 -0
- package/src/commands/create.ts +125 -0
- package/src/commands/sync.ts +182 -0
- package/src/config.ts +77 -0
- package/src/kicad/cli.ts +106 -0
- package/src/kicad/report.ts +81 -0
- package/src/kicad/sexp.ts +327 -0
- package/src/memory/constraints.ts +73 -0
- package/src/memory/drift.ts +97 -0
- package/src/memory/scaffold.ts +241 -0
- package/src/openspec/cli.ts +43 -0
- package/src/util/env.ts +65 -0
- package/src/util/git.ts +63 -0
- package/src/util/paths.ts +25 -0
- package/src/util/redact.ts +20 -0
- package/src/util/retry.ts +33 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveInRepo, isKicadFile } from '../util/paths.js';
|
|
5
|
+
|
|
6
|
+
export async function toolReadFile(
|
|
7
|
+
repoRoot: string,
|
|
8
|
+
p: string,
|
|
9
|
+
startLine?: number,
|
|
10
|
+
endLine?: number,
|
|
11
|
+
): Promise<string> {
|
|
12
|
+
const abs = resolveInRepo(repoRoot, p);
|
|
13
|
+
const text = await readFile(abs, 'utf8');
|
|
14
|
+
if (startLine === undefined) return text;
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const from = Math.max(1, startLine);
|
|
17
|
+
const to = Math.min(lines.length, endLine ?? lines.length);
|
|
18
|
+
return lines
|
|
19
|
+
.slice(from - 1, to)
|
|
20
|
+
.map((l, i) => `${from + i}: ${l}`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** New files only; refuses to overwrite anything and to create KiCad files (SPEC §4.2). */
|
|
25
|
+
export async function toolWriteFile(repoRoot: string, p: string, content: string): Promise<string> {
|
|
26
|
+
const abs = resolveInRepo(repoRoot, p);
|
|
27
|
+
if (isKicadFile(abs)) {
|
|
28
|
+
throw new Error(`write_file refuses KiCad files (${p}); use edit_file with anchors instead`);
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(abs)) {
|
|
31
|
+
throw new Error(`write_file refuses to overwrite existing file ${p}; use edit_file`);
|
|
32
|
+
}
|
|
33
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
34
|
+
await writeFile(abs, content, 'utf8');
|
|
35
|
+
return `wrote ${p}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Exact-match anchored replace; fails with an actionable error unless unique.
|
|
40
|
+
* `replaceAll` replaces every occurrence (the rename case, AC-3.1) while still
|
|
41
|
+
* being a surgical text edit on the s-expression source.
|
|
42
|
+
*/
|
|
43
|
+
export async function toolEditFile(
|
|
44
|
+
repoRoot: string,
|
|
45
|
+
p: string,
|
|
46
|
+
oldString: string,
|
|
47
|
+
newString: string,
|
|
48
|
+
replaceAll = false,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const abs = resolveInRepo(repoRoot, p);
|
|
51
|
+
const text = await readFile(abs, 'utf8');
|
|
52
|
+
const first = text.indexOf(oldString);
|
|
53
|
+
if (first === -1) {
|
|
54
|
+
throw new Error(`edit_file: anchor not found in ${p}; re-read the file and use an exact excerpt`);
|
|
55
|
+
}
|
|
56
|
+
const count = text.split(oldString).length - 1;
|
|
57
|
+
if (replaceAll) {
|
|
58
|
+
await writeFile(abs, text.split(oldString).join(newString), 'utf8');
|
|
59
|
+
return `edited ${p} (${count} occurrence(s) replaced)`;
|
|
60
|
+
}
|
|
61
|
+
if (count > 1) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`edit_file: anchor matched ${count} times in ${p}; widen the anchor with surrounding lines until it is unique, or pass replace_all: true to replace every occurrence`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
await writeFile(abs, text.slice(0, first) + newString + text.slice(first + oldString.length), 'utf8');
|
|
67
|
+
return `edited ${p}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SearchMatch {
|
|
71
|
+
file: string;
|
|
72
|
+
line: number;
|
|
73
|
+
text: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.copperhead']);
|
|
77
|
+
|
|
78
|
+
export function globToRegex(glob: string): RegExp {
|
|
79
|
+
let out = '';
|
|
80
|
+
for (let i = 0; i < glob.length; i++) {
|
|
81
|
+
if (glob.startsWith('**/', i)) {
|
|
82
|
+
out += '(?:.*/)?'; // zero or more directories
|
|
83
|
+
i += 2;
|
|
84
|
+
} else if (glob.startsWith('**', i)) {
|
|
85
|
+
out += '.*';
|
|
86
|
+
i += 1;
|
|
87
|
+
} else if (glob[i] === '*') {
|
|
88
|
+
out += '[^/]*';
|
|
89
|
+
} else if (glob[i] === '?') {
|
|
90
|
+
out += '.';
|
|
91
|
+
} else {
|
|
92
|
+
out += glob[i]!.replace(/[.+^${}()|[\]\\]/, '\\$&');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return new RegExp(`^${out}$`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
/** ripgrep-style regex search implemented natively (no rg dependency). */
|
|
100
|
+
export async function toolSearch(
|
|
101
|
+
repoRoot: string,
|
|
102
|
+
pattern: string,
|
|
103
|
+
glob?: string,
|
|
104
|
+
maxMatches = 200,
|
|
105
|
+
): Promise<SearchMatch[]> {
|
|
106
|
+
const re = new RegExp(pattern);
|
|
107
|
+
const globRe = glob ? globToRegex(glob) : null;
|
|
108
|
+
const matches: SearchMatch[] = [];
|
|
109
|
+
async function walk(dir: string): Promise<void> {
|
|
110
|
+
if (matches.length >= maxMatches) return;
|
|
111
|
+
for (const entry of await readdir(dir)) {
|
|
112
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
113
|
+
const abs = path.join(dir, entry);
|
|
114
|
+
const rel = path.relative(repoRoot, abs);
|
|
115
|
+
const st = await stat(abs);
|
|
116
|
+
if (st.isDirectory()) {
|
|
117
|
+
await walk(abs);
|
|
118
|
+
} else if (st.size < 5_000_000 && (!globRe || globRe.test(rel))) {
|
|
119
|
+
let text: string;
|
|
120
|
+
try {
|
|
121
|
+
text = await readFile(abs, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (text.includes('\u0000')) continue; // binary
|
|
126
|
+
const lines = text.split('\n');
|
|
127
|
+
for (let i = 0; i < lines.length && matches.length < maxMatches; i++) {
|
|
128
|
+
if (re.test(lines[i]!)) matches.push({ file: rel, line: i + 1, text: lines[i]!.trim() });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (matches.length >= maxMatches) return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await walk(repoRoot);
|
|
135
|
+
return matches;
|
|
136
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync-obligations ledger (design D13). Deterministic post-tool-call hooks
|
|
3
|
+
* record obligations; the commit gate refuses while any is open. This is what
|
|
4
|
+
* turns "keep everything in sync" into a mechanical gate instead of a prompt.
|
|
5
|
+
*/
|
|
6
|
+
export type ObligationKind =
|
|
7
|
+
| 'erc'
|
|
8
|
+
| 'drc'
|
|
9
|
+
| 'drift'
|
|
10
|
+
| 'changelog'
|
|
11
|
+
| 'decision-log'
|
|
12
|
+
| 'constraint-dual-write'
|
|
13
|
+
| 'affects-revisit';
|
|
14
|
+
|
|
15
|
+
export interface Obligation {
|
|
16
|
+
kind: ObligationKind;
|
|
17
|
+
detail: string;
|
|
18
|
+
openedBy: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ObligationsLedger {
|
|
22
|
+
private open: Obligation[] = [];
|
|
23
|
+
|
|
24
|
+
add(kind: ObligationKind, detail: string, openedBy: string): void {
|
|
25
|
+
if (!this.open.some((o) => o.kind === kind && o.detail === detail)) {
|
|
26
|
+
this.open.push({ kind, detail, openedBy });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear(kind: ObligationKind, detail?: string): void {
|
|
31
|
+
this.open = this.open.filter(
|
|
32
|
+
(o) => !(o.kind === kind && (detail === undefined || o.detail === detail)),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A KiCad edit re-opens verification obligations even if previously cleared. */
|
|
37
|
+
onKicadEdit(file: string): void {
|
|
38
|
+
this.add('erc', 'ERC must pass after schematic edits', file);
|
|
39
|
+
if (file.endsWith('.kicad_pcb')) this.add('drc', 'DRC must pass after board edits', file);
|
|
40
|
+
this.add('drift', 'check_drift must run clean after KiCad edits', file);
|
|
41
|
+
this.add('changelog', 'CHANGELOG.md entry for this run', file);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onDocEdit(file: string): void {
|
|
45
|
+
this.add('drift', 'check_drift must run clean after doc edits', file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onConstraintChange(constraintKey: string, affects: string[]): void {
|
|
49
|
+
this.add('constraint-dual-write', constraintKey, constraintKey);
|
|
50
|
+
for (const item of affects) {
|
|
51
|
+
this.add('affects-revisit', `${constraintKey} affects ${item}`, constraintKey);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onDecision(summary: string): void {
|
|
56
|
+
this.add('decision-log', summary, 'decision');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get openObligations(): readonly Obligation[] {
|
|
60
|
+
return this.open;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get isClear(): boolean {
|
|
64
|
+
return this.open.length === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe(): string {
|
|
68
|
+
if (this.isClear) return 'all sync obligations satisfied';
|
|
69
|
+
return this.open.map((o) => `- [${o.kind}] ${o.detail} (opened by ${o.openedBy})`).join('\n');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import type { Msg, Provider, Turn } from './types.js';
|
|
5
|
+
import { availableTools, dispatchTool, type RunContext } from './tools.js';
|
|
6
|
+
import { buildSystemPrompt } from './prompts.js';
|
|
7
|
+
import { loadConstraints } from '../memory/constraints.js';
|
|
8
|
+
import { loadConfig, type CopperheadConfig } from '../config.js';
|
|
9
|
+
import { Transcript } from './transcript.js';
|
|
10
|
+
import { ObligationsLedger } from './ledger.js';
|
|
11
|
+
import { isDirty, isGitRepo, snapshot, restore, commitAll, changedFiles } from '../util/git.js';
|
|
12
|
+
import { withRetry, isRateLimit } from '../util/retry.js';
|
|
13
|
+
import { openspecArchive } from '../openspec/cli.js';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { OpenAIProvider } from './providers/openai.js';
|
|
16
|
+
import { AnthropicProvider } from './providers/anthropic.js';
|
|
17
|
+
|
|
18
|
+
export interface RunOptions {
|
|
19
|
+
repoRoot: string;
|
|
20
|
+
request: string;
|
|
21
|
+
model: string;
|
|
22
|
+
maxTurns?: number;
|
|
23
|
+
allowDirty?: boolean;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
interactive?: boolean;
|
|
26
|
+
confirm?: (q: string) => Promise<boolean>;
|
|
27
|
+
/** Extra prompt appended for pipeline stages (Mode A). */
|
|
28
|
+
stagePrompt?: string;
|
|
29
|
+
log?: (line: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunResult {
|
|
33
|
+
outcome: 'success' | 'refused' | 'failure';
|
|
34
|
+
summary: string;
|
|
35
|
+
transcriptDir: string;
|
|
36
|
+
filesTouched: string[];
|
|
37
|
+
commit: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function makeProvider(model: string): Provider {
|
|
41
|
+
if (model === 'claude' || model.startsWith('claude')) {
|
|
42
|
+
return new AnthropicProvider(model === 'claude' ? undefined : model);
|
|
43
|
+
}
|
|
44
|
+
return new OpenAIProvider(model === 'gpt-5' ? undefined : model);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function otherProvider(current: Provider): Provider | null {
|
|
48
|
+
if (current.name === 'openai' && process.env.ANTHROPIC_API_KEY) return new AnthropicProvider();
|
|
49
|
+
if (current.name === 'anthropic' && process.env.OPENAI_API_KEY) return new OpenAIProvider();
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function appendChangelog(
|
|
54
|
+
repoRoot: string,
|
|
55
|
+
config: CopperheadConfig,
|
|
56
|
+
entry: { changeId: string | null; request: string; files: string[]; verification: string },
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const p = path.join(repoRoot, config.docs, 'CHANGELOG.md');
|
|
59
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
60
|
+
const block = [
|
|
61
|
+
``,
|
|
62
|
+
`## ${date} — ${entry.request}`,
|
|
63
|
+
``,
|
|
64
|
+
`- Change: ${entry.changeId ?? 'n/a'}`,
|
|
65
|
+
`- Files: ${entry.files.join(', ') || '(none)'}`,
|
|
66
|
+
`- Verification: ${entry.verification}`,
|
|
67
|
+
].join('\n');
|
|
68
|
+
let text: string;
|
|
69
|
+
try {
|
|
70
|
+
text = await readFile(p, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
text = '# Design changelog\n\nAppend-only, newest first. One entry per committed copperhead run.\n';
|
|
73
|
+
}
|
|
74
|
+
// newest first: insert right after the header block (first blank line after content start)
|
|
75
|
+
const lines = text.split('\n');
|
|
76
|
+
let insertAt = lines.length;
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
if (lines[i]!.startsWith('## ')) {
|
|
79
|
+
insertAt = i;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lines.splice(insertAt, 0, ...block.split('\n').slice(1), '');
|
|
84
|
+
await writeFile(p, lines.join('\n'), 'utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
88
|
+
const log = opts.log ?? ((l: string) => console.log(l));
|
|
89
|
+
const repoRoot = opts.repoRoot;
|
|
90
|
+
const config = await loadConfig(repoRoot);
|
|
91
|
+
const maxTurns = opts.maxTurns ?? config.maxTurns;
|
|
92
|
+
|
|
93
|
+
if (!(await isGitRepo(repoRoot))) {
|
|
94
|
+
throw new Error('not a git repository; copperhead requires git for snapshots and rollback');
|
|
95
|
+
}
|
|
96
|
+
if ((await isDirty(repoRoot)) && !opts.allowDirty) {
|
|
97
|
+
throw new Error('working tree is dirty; commit your changes or pass --allow-dirty (snapshots via git stash create)');
|
|
98
|
+
}
|
|
99
|
+
const snap = await snapshot(repoRoot);
|
|
100
|
+
|
|
101
|
+
const transcript = new Transcript(repoRoot);
|
|
102
|
+
await transcript.init();
|
|
103
|
+
const ctx: RunContext = {
|
|
104
|
+
repoRoot,
|
|
105
|
+
config,
|
|
106
|
+
transcript,
|
|
107
|
+
ledger: new ObligationsLedger(),
|
|
108
|
+
runId: path.basename(transcript.dir),
|
|
109
|
+
interactive: opts.interactive ?? false,
|
|
110
|
+
confirm: opts.confirm ?? (async () => true),
|
|
111
|
+
editsUnlocked: false,
|
|
112
|
+
changeId: null,
|
|
113
|
+
proposalValidated: false,
|
|
114
|
+
filesTouched: new Set(),
|
|
115
|
+
decisions: [],
|
|
116
|
+
lastErc: null,
|
|
117
|
+
lastDrc: null,
|
|
118
|
+
repairCycles: 0,
|
|
119
|
+
finishRequest: null,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
let provider = makeProvider(opts.model);
|
|
123
|
+
const constraints = await loadConstraints(repoRoot);
|
|
124
|
+
const system = await buildSystemPrompt(repoRoot, config, constraints);
|
|
125
|
+
const messages: Msg[] = [
|
|
126
|
+
{ role: 'system', content: system },
|
|
127
|
+
{ role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
|
|
128
|
+
];
|
|
129
|
+
await transcript.event('run-start', { request: opts.request, model: opts.model, provider: provider.name });
|
|
130
|
+
|
|
131
|
+
let tokensIn = 0;
|
|
132
|
+
let tokensOut = 0;
|
|
133
|
+
let plan: string | null = null;
|
|
134
|
+
let nudges = 0;
|
|
135
|
+
|
|
136
|
+
const fail = async (reason: string): Promise<RunResult> => {
|
|
137
|
+
await transcript.event('run-failed', { reason });
|
|
138
|
+
await restore(repoRoot, snap);
|
|
139
|
+
const summaryPath = await transcript.writeSummary({
|
|
140
|
+
request: opts.request,
|
|
141
|
+
changeId: ctx.changeId,
|
|
142
|
+
plan,
|
|
143
|
+
filesTouched: [...ctx.filesTouched],
|
|
144
|
+
ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : `${ctx.lastErc.violations.length} violations`) : null,
|
|
145
|
+
drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : `${ctx.lastDrc.violations.length} violations`) : null,
|
|
146
|
+
decisions: ctx.decisions,
|
|
147
|
+
tokensIn,
|
|
148
|
+
tokensOut,
|
|
149
|
+
outcome: 'failure',
|
|
150
|
+
openObligations: ctx.ledger.isClear ? null : ctx.ledger.describe(),
|
|
151
|
+
detail: reason,
|
|
152
|
+
});
|
|
153
|
+
log(`run failed: ${reason}`);
|
|
154
|
+
log(`working tree restored to pre-run snapshot`);
|
|
155
|
+
log(`transcript: ${transcript.jsonlPath}`);
|
|
156
|
+
log(`summary: ${summaryPath}`);
|
|
157
|
+
return {
|
|
158
|
+
outcome: 'failure',
|
|
159
|
+
summary: reason,
|
|
160
|
+
transcriptDir: transcript.dir,
|
|
161
|
+
filesTouched: [],
|
|
162
|
+
commit: null,
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
167
|
+
const tools = availableTools(ctx).map((t) => t.schema);
|
|
168
|
+
let res: Turn;
|
|
169
|
+
try {
|
|
170
|
+
res = await withRetry(() => provider.chat(messages, tools), {
|
|
171
|
+
onRetry: (attempt) => log(`rate limited; retry ${attempt}`),
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (isRateLimit(err)) {
|
|
175
|
+
const fallback = otherProvider(provider);
|
|
176
|
+
if (fallback) {
|
|
177
|
+
log(`failing over ${provider.name} → ${fallback.name}`);
|
|
178
|
+
await transcript.event('provider-failover', { from: provider.name, to: fallback.name });
|
|
179
|
+
provider = fallback;
|
|
180
|
+
turn--;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return fail(`provider error: ${(err as Error).message}`);
|
|
185
|
+
}
|
|
186
|
+
tokensIn += res.usage.inputTokens;
|
|
187
|
+
tokensOut += res.usage.outputTokens;
|
|
188
|
+
await transcript.event('assistant', { text: res.text, toolCalls: res.toolCalls });
|
|
189
|
+
|
|
190
|
+
if (res.text) {
|
|
191
|
+
if (!plan) plan = res.text;
|
|
192
|
+
log(res.text);
|
|
193
|
+
}
|
|
194
|
+
messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
|
|
195
|
+
|
|
196
|
+
if (!res.toolCalls.length) {
|
|
197
|
+
if (nudges++ >= 2) return fail('model stopped calling tools without finishing');
|
|
198
|
+
messages.push({
|
|
199
|
+
role: 'user',
|
|
200
|
+
content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const call of res.toolCalls) {
|
|
206
|
+
const result = await dispatchTool(ctx, call.name, call.args);
|
|
207
|
+
await transcript.event('tool', { name: call.name, args: call.args, result });
|
|
208
|
+
log(` [${call.name}] ${result.split('\n')[0]}`);
|
|
209
|
+
messages.push({ role: 'tool', toolCallId: call.id, content: result });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (ctx.repairCycles > config.maxRepairCycles) {
|
|
213
|
+
return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const remaining = maxTurns - turn - 1;
|
|
217
|
+
if (remaining === 5 && !ctx.finishRequest) {
|
|
218
|
+
messages.push({
|
|
219
|
+
role: 'user',
|
|
220
|
+
content:
|
|
221
|
+
'Only 5 turns remain. Converge now: finish the minimal correct edit set, run run_erc (and run_drc if the board changed), run check_drift, then call finish.',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (ctx.finishRequest) {
|
|
226
|
+
const { outcome, summary } = ctx.finishRequest;
|
|
227
|
+
const files = [...ctx.filesTouched];
|
|
228
|
+
if (outcome === 'refuse') {
|
|
229
|
+
await restore(repoRoot, snap);
|
|
230
|
+
await transcript.event('run-refused', { summary });
|
|
231
|
+
await transcript.writeSummary({
|
|
232
|
+
request: opts.request,
|
|
233
|
+
changeId: ctx.changeId,
|
|
234
|
+
plan,
|
|
235
|
+
filesTouched: [],
|
|
236
|
+
ercResult: null,
|
|
237
|
+
drcResult: null,
|
|
238
|
+
decisions: ctx.decisions,
|
|
239
|
+
tokensIn,
|
|
240
|
+
tokensOut,
|
|
241
|
+
outcome: 'aborted',
|
|
242
|
+
openObligations: null,
|
|
243
|
+
detail: `REFUSED: ${summary}`,
|
|
244
|
+
});
|
|
245
|
+
log(`refused: ${summary}`);
|
|
246
|
+
return { outcome: 'refused', summary, transcriptDir: transcript.dir, filesTouched: [], commit: null };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const verification = [
|
|
250
|
+
ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'FAILING'}` : 'ERC not required',
|
|
251
|
+
ctx.lastDrc ? `DRC ${ctx.lastDrc.ok ? 'clean' : 'FAILING'}` : null,
|
|
252
|
+
]
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.join(', ');
|
|
255
|
+
|
|
256
|
+
if (opts.dryRun) {
|
|
257
|
+
const { stdout: diff } = await execa('git', ['diff'], { cwd: repoRoot });
|
|
258
|
+
const { stdout: untracked } = await execa('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
259
|
+
cwd: repoRoot,
|
|
260
|
+
});
|
|
261
|
+
log('--- dry run: proposed diff ---');
|
|
262
|
+
log(diff || '(no diff)');
|
|
263
|
+
if (untracked) log(`new files:\n${untracked}`);
|
|
264
|
+
await restore(repoRoot, snap);
|
|
265
|
+
await transcript.writeSummary({
|
|
266
|
+
request: opts.request,
|
|
267
|
+
changeId: ctx.changeId,
|
|
268
|
+
plan,
|
|
269
|
+
filesTouched: files,
|
|
270
|
+
ercResult: verification,
|
|
271
|
+
drcResult: null,
|
|
272
|
+
decisions: ctx.decisions,
|
|
273
|
+
tokensIn,
|
|
274
|
+
tokensOut,
|
|
275
|
+
outcome: 'success',
|
|
276
|
+
openObligations: null,
|
|
277
|
+
detail: 'dry run: changes reverted',
|
|
278
|
+
});
|
|
279
|
+
return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit: null };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await appendChangelog(repoRoot, config, {
|
|
283
|
+
changeId: ctx.changeId,
|
|
284
|
+
request: opts.request,
|
|
285
|
+
files,
|
|
286
|
+
verification,
|
|
287
|
+
});
|
|
288
|
+
ctx.ledger.clear('changelog');
|
|
289
|
+
|
|
290
|
+
const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
|
|
291
|
+
const commit = await commitAll(repoRoot, commitMsg);
|
|
292
|
+
if (ctx.changeId && existsSync(path.join(repoRoot, 'openspec', 'config.yaml'))) {
|
|
293
|
+
const arch = await openspecArchive(repoRoot, ctx.changeId);
|
|
294
|
+
await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
|
|
295
|
+
if (arch.ok && (await isDirty(repoRoot))) {
|
|
296
|
+
await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await transcript.event('run-committed', { commit, files });
|
|
300
|
+
await transcript.writeSummary({
|
|
301
|
+
request: opts.request,
|
|
302
|
+
changeId: ctx.changeId,
|
|
303
|
+
plan,
|
|
304
|
+
filesTouched: files,
|
|
305
|
+
ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : 'FAILING') : 'not run',
|
|
306
|
+
drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : 'FAILING') : 'not run',
|
|
307
|
+
decisions: ctx.decisions,
|
|
308
|
+
tokensIn,
|
|
309
|
+
tokensOut,
|
|
310
|
+
outcome: 'success',
|
|
311
|
+
openObligations: null,
|
|
312
|
+
});
|
|
313
|
+
log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
|
|
314
|
+
return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const filesAfter = await changedFiles(repoRoot, snap.head);
|
|
319
|
+
return fail(`turn budget exhausted (${maxTurns} turns, ${filesAfter.length} files touched but unverified)`);
|
|
320
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { CopperheadConfig } from '../config.js';
|
|
5
|
+
import type { ConstraintRegistry } from '../memory/constraints.js';
|
|
6
|
+
|
|
7
|
+
/** SPEC §4.3 — verbatim requirements. */
|
|
8
|
+
const SYSTEM_RULES = `You are a hardware design agent working on real KiCad source files. Edit s-expressions surgically; never regenerate a whole file.
|
|
9
|
+
You cannot edit any file until your change proposal validates. Write the proposal first; the edit tools appear only after it passes.
|
|
10
|
+
The design docs are the memory. Read them before proposing anything; update them with everything you change.
|
|
11
|
+
Hold ALL constraints simultaneously: electrical budgets (e.g. sleep current), voltage ranges, package availability, strapping pins, RTC-capability, antenna keepouts. A part that satisfies the obvious constraint but violates a budget is a bug.
|
|
12
|
+
Check the MCU strapping table before assigning any pin. Check quiescent/leakage current of every part against the power budget in SPEC.md.
|
|
13
|
+
Nothing is done until ERC (and DRC when applicable) passes. Read the violation report; do not guess.
|
|
14
|
+
Write a one-line rationale next to every decision. If you remove a part, record why the absence is intentional (a missing pullup can look like a mistake).
|
|
15
|
+
If a request would violate a documented budget or constraint, stop and say so — do not silently comply.`;
|
|
16
|
+
|
|
17
|
+
const WORKFLOW = `Workflow for every run:
|
|
18
|
+
1. The design docs are already loaded below. Plan: state in one short block what will change, which files are affected, which constraints are at risk.
|
|
19
|
+
2. Call propose_change with a change id (kebab-case), why, what changes, and tasks. Then call validate_change. Edit tools (edit_file, write_file) unlock only after validation passes.
|
|
20
|
+
3. Make the edits. Use the exact same net names and refdes everywhere. For .kicad_sch/.kicad_pcb use edit_file with unique anchors from the actual file text (read the file first). For renaming a net or refdes across a file, one edit_file call with replace_all: true beats many small edits.
|
|
21
|
+
4. Run run_erc after schematic edits (and run_drc after board edits). If violations: read them, fix, re-run.
|
|
22
|
+
5. Run check_drift; update any doc that references a changed value/part/pin in the same run.
|
|
23
|
+
6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
|
|
24
|
+
7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again.`;
|
|
25
|
+
|
|
26
|
+
export async function buildSystemPrompt(
|
|
27
|
+
repoRoot: string,
|
|
28
|
+
config: CopperheadConfig,
|
|
29
|
+
constraints: ConstraintRegistry,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const parts = [SYSTEM_RULES, '', WORKFLOW];
|
|
32
|
+
|
|
33
|
+
if (Object.keys(config.budgets).length) {
|
|
34
|
+
parts.push('', '## Hard budgets (from .copperhead/config.json — treat as constraints)', '');
|
|
35
|
+
for (const [k, v] of Object.entries(config.budgets)) parts.push(`- ${k}: ${v}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Object.keys(constraints).length) {
|
|
39
|
+
parts.push('', '## Constraint registry (.copperhead/constraints.json)', '', '```json');
|
|
40
|
+
parts.push(JSON.stringify(constraints, null, 2), '```');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const docsDir = path.join(repoRoot, config.docs);
|
|
44
|
+
if (existsSync(docsDir)) {
|
|
45
|
+
const files = (await readdir(docsDir)).filter((f) => f.endsWith('.md')).sort();
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
const text = await readFile(path.join(docsDir, f), 'utf8');
|
|
48
|
+
parts.push('', `## docs/${f}`, '', text);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (config.schematic) {
|
|
53
|
+
parts.push('', `## KiCad files`, '', `- schematic: ${config.schematic}`);
|
|
54
|
+
if (config.board) parts.push(`- board: ${config.board}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return parts.join('\n');
|
|
58
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
|
|
2
|
+
|
|
3
|
+
type AnthropicContent =
|
|
4
|
+
| { type: 'text'; text: string }
|
|
5
|
+
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
|
6
|
+
| { type: 'tool_result'; tool_use_id: string; content: string };
|
|
7
|
+
|
|
8
|
+
export class AnthropicProvider implements Provider {
|
|
9
|
+
readonly name = 'anthropic';
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly model = 'claude-sonnet-5',
|
|
13
|
+
private readonly apiKey = process.env.ANTHROPIC_API_KEY,
|
|
14
|
+
) {
|
|
15
|
+
if (!this.apiKey) throw new Error('ANTHROPIC_API_KEY is not set');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
|
|
19
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
20
|
+
const client = new Anthropic({ apiKey: this.apiKey });
|
|
21
|
+
|
|
22
|
+
const system = messages
|
|
23
|
+
.filter((m) => m.role === 'system')
|
|
24
|
+
.map((m) => m.content)
|
|
25
|
+
.join('\n\n');
|
|
26
|
+
|
|
27
|
+
const conv: { role: 'user' | 'assistant'; content: AnthropicContent[] | string }[] = [];
|
|
28
|
+
for (const m of messages) {
|
|
29
|
+
if (m.role === 'system') continue;
|
|
30
|
+
if (m.role === 'user') {
|
|
31
|
+
conv.push({ role: 'user', content: m.content });
|
|
32
|
+
} else if (m.role === 'assistant') {
|
|
33
|
+
const content: AnthropicContent[] = [];
|
|
34
|
+
if (m.content) content.push({ type: 'text', text: m.content });
|
|
35
|
+
for (const t of m.toolCalls ?? []) {
|
|
36
|
+
content.push({ type: 'tool_use', id: t.id, name: t.name, input: t.args });
|
|
37
|
+
}
|
|
38
|
+
if (content.length) conv.push({ role: 'assistant', content });
|
|
39
|
+
} else {
|
|
40
|
+
// tool results are user-role content blocks in the Anthropic API
|
|
41
|
+
const prev = conv[conv.length - 1];
|
|
42
|
+
const block: AnthropicContent = { type: 'tool_result', tool_use_id: m.toolCallId, content: m.content };
|
|
43
|
+
if (prev && prev.role === 'user' && Array.isArray(prev.content)) {
|
|
44
|
+
prev.content.push(block);
|
|
45
|
+
} else {
|
|
46
|
+
conv.push({ role: 'user', content: [block] });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const res = await client.messages.create({
|
|
52
|
+
model: this.model,
|
|
53
|
+
max_tokens: opts.maxTokens ?? 8192,
|
|
54
|
+
...(system ? { system } : {}),
|
|
55
|
+
messages: conv as never,
|
|
56
|
+
...(tools.length
|
|
57
|
+
? {
|
|
58
|
+
tools: tools.map((t) => ({
|
|
59
|
+
name: t.name,
|
|
60
|
+
description: t.description,
|
|
61
|
+
input_schema: t.parameters as never,
|
|
62
|
+
})),
|
|
63
|
+
}
|
|
64
|
+
: {}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let text: string | null = null;
|
|
68
|
+
const toolCalls = [];
|
|
69
|
+
for (const block of res.content) {
|
|
70
|
+
if (block.type === 'text') text = (text ?? '') + block.text;
|
|
71
|
+
if (block.type === 'tool_use') {
|
|
72
|
+
toolCalls.push({ id: block.id, name: block.name, args: block.input as Record<string, unknown> });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
toolCalls,
|
|
78
|
+
usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|