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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live progress rendering for agent-loop runs (design D7). Two modes chosen
|
|
3
|
+
* once at startup: interactive (TTY, no --json/--plain) pins a status line to
|
|
4
|
+
* the bottom of the terminal and redraws it in place; plain emits line-oriented
|
|
5
|
+
* output with zero ANSI escapes — the mode CI, pipes, and tests see.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ProgressRenderer {
|
|
9
|
+
log(line: string): void;
|
|
10
|
+
/** Called at the start of each turn with cumulative token totals so far. */
|
|
11
|
+
turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void;
|
|
12
|
+
toolResult(name: string, firstLine: string): void;
|
|
13
|
+
/** Busy text while a provider call is in flight; null when idle. */
|
|
14
|
+
status(text: string | null): void;
|
|
15
|
+
/** Final outcome line; replaces the status line in interactive mode. */
|
|
16
|
+
finish(line: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compact token count: 850 -> "850", 12300 -> "12.3k". */
|
|
20
|
+
export function fmtTokens(n: number): string {
|
|
21
|
+
return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Compact duration: 42s, 1m32s, 1h04m. */
|
|
25
|
+
export function fmtDuration(ms: number): string {
|
|
26
|
+
const s = Math.round(ms / 1000);
|
|
27
|
+
if (s < 60) return `${s}s`;
|
|
28
|
+
const m = Math.floor(s / 60);
|
|
29
|
+
if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
|
|
30
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function turnMarker(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): string {
|
|
34
|
+
return `[turn ${turn}/${maxTurns} · ${fmtTokens(tokensIn)} in / ${fmtTokens(tokensOut)} out]`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Wrap a bare log function into the plain (non-interactive) renderer. */
|
|
38
|
+
export function plainRenderer(log: (line: string) => void): ProgressRenderer {
|
|
39
|
+
return {
|
|
40
|
+
log,
|
|
41
|
+
turnStart: (turn, maxTurns, tokensIn, tokensOut) => log(turnMarker(turn, maxTurns, tokensIn, tokensOut)),
|
|
42
|
+
toolResult: (name, firstLine) => log(` [${name}] ${firstLine}`),
|
|
43
|
+
status: () => {},
|
|
44
|
+
finish: (line) => log(line),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
49
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
50
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
51
|
+
const CLEAR_LINE = '\r\x1b[2K';
|
|
52
|
+
|
|
53
|
+
/** Minimal writable surface so tests can drive a fake TTY. */
|
|
54
|
+
export interface TtyLike {
|
|
55
|
+
write(chunk: string): unknown;
|
|
56
|
+
columns?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The interactive renderer. Everything printed goes above the status line
|
|
61
|
+
* (clear -> print -> redraw) so the scrollback stays a complete log; only the
|
|
62
|
+
* status line itself is ever redrawn in place (AC-8.8).
|
|
63
|
+
*/
|
|
64
|
+
export class InteractiveRenderer implements ProgressRenderer {
|
|
65
|
+
private readonly out: TtyLike;
|
|
66
|
+
private startMs = Date.now();
|
|
67
|
+
private turn = 0;
|
|
68
|
+
private maxTurns = 0;
|
|
69
|
+
private tokensIn = 0;
|
|
70
|
+
private tokensOut = 0;
|
|
71
|
+
private busy: string | null = null;
|
|
72
|
+
private frame = 0;
|
|
73
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
74
|
+
private statusShown = false;
|
|
75
|
+
/**
|
|
76
|
+
* True between runs: no status line is owned and log lines pass straight
|
|
77
|
+
* through. finish() suspends rather than destroys, because a multi-stage
|
|
78
|
+
* `create` pipeline reuses one renderer across its stages; the next
|
|
79
|
+
* turnStart() re-arms it.
|
|
80
|
+
*/
|
|
81
|
+
private idle = true;
|
|
82
|
+
private readonly cleanup = (): void => this.teardown();
|
|
83
|
+
private readonly onSigint = (): void => {
|
|
84
|
+
this.teardown();
|
|
85
|
+
process.exit(130);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
constructor(out: TtyLike = process.stdout) {
|
|
89
|
+
this.out = out;
|
|
90
|
+
process.on('exit', this.cleanup);
|
|
91
|
+
process.on('SIGINT', this.onSigint);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private statusText(): string {
|
|
95
|
+
const parts = [
|
|
96
|
+
`turn ${this.turn}/${this.maxTurns}`,
|
|
97
|
+
`${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`,
|
|
98
|
+
fmtDuration(Date.now() - this.startMs),
|
|
99
|
+
];
|
|
100
|
+
if (this.busy) parts.push(this.busy);
|
|
101
|
+
const spinner = this.busy ? FRAMES[this.frame % FRAMES.length] : '·';
|
|
102
|
+
const line = `${spinner} ${parts.join(' · ')}`;
|
|
103
|
+
const width = this.out.columns ?? 80;
|
|
104
|
+
return line.length > width ? line.slice(0, width - 1) : line;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private redraw(): void {
|
|
108
|
+
if (this.idle) return;
|
|
109
|
+
if (!this.statusShown) {
|
|
110
|
+
this.out.write(HIDE_CURSOR);
|
|
111
|
+
this.statusShown = true;
|
|
112
|
+
}
|
|
113
|
+
this.out.write(CLEAR_LINE + this.statusText());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private ensureTimer(): void {
|
|
117
|
+
if (this.timer) return;
|
|
118
|
+
this.timer = setInterval(() => {
|
|
119
|
+
this.frame++;
|
|
120
|
+
this.redraw();
|
|
121
|
+
}, 80);
|
|
122
|
+
this.timer.unref?.();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Print above the status line: clear it, write, redraw it. */
|
|
126
|
+
log(line: string): void {
|
|
127
|
+
if (this.statusShown) this.out.write(CLEAR_LINE);
|
|
128
|
+
this.out.write(line + '\n');
|
|
129
|
+
this.redraw();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void {
|
|
133
|
+
if (this.idle) {
|
|
134
|
+
this.idle = false;
|
|
135
|
+
this.startMs = Date.now(); // elapsed time is per run, not per renderer
|
|
136
|
+
}
|
|
137
|
+
this.turn = turn;
|
|
138
|
+
this.maxTurns = maxTurns;
|
|
139
|
+
this.tokensIn = tokensIn;
|
|
140
|
+
this.tokensOut = tokensOut;
|
|
141
|
+
this.ensureTimer();
|
|
142
|
+
this.redraw();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
toolResult(name: string, firstLine: string): void {
|
|
146
|
+
this.log(` [${name}] ${firstLine}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
status(text: string | null): void {
|
|
150
|
+
this.busy = text;
|
|
151
|
+
if (text && !this.idle) this.ensureTimer();
|
|
152
|
+
this.redraw();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
finish(line: string): void {
|
|
156
|
+
if (this.statusShown) this.out.write(CLEAR_LINE);
|
|
157
|
+
this.out.write(line + '\n');
|
|
158
|
+
this.suspend();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Release the status line (stop the spinner, restore the cursor) but stay usable. */
|
|
162
|
+
private suspend(): void {
|
|
163
|
+
if (this.timer) {
|
|
164
|
+
clearInterval(this.timer);
|
|
165
|
+
this.timer = null;
|
|
166
|
+
}
|
|
167
|
+
if (this.statusShown) {
|
|
168
|
+
this.out.write(CLEAR_LINE + SHOW_CURSOR);
|
|
169
|
+
this.statusShown = false;
|
|
170
|
+
}
|
|
171
|
+
this.busy = null;
|
|
172
|
+
this.frame = 0;
|
|
173
|
+
this.idle = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Process is going away (exit/SIGINT): suspend and drop the listeners. */
|
|
177
|
+
private teardown(): void {
|
|
178
|
+
this.suspend();
|
|
179
|
+
process.removeListener('exit', this.cleanup);
|
|
180
|
+
process.removeListener('SIGINT', this.onSigint);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Pick the renderer for a CLI invocation: interactive only on a real TTY with
|
|
186
|
+
* neither --json nor --plain (AC-8.8/8.9); plain mode is the safe fallback.
|
|
187
|
+
* Under --json, progress goes to stderr so stdout stays machine-parseable
|
|
188
|
+
* (AC-2.4): the only thing a --json invocation writes to stdout is its JSON.
|
|
189
|
+
*/
|
|
190
|
+
export function makeRenderer(opts: { json: boolean; plain: boolean }): ProgressRenderer {
|
|
191
|
+
if (opts.json) return plainRenderer((line) => console.error(line));
|
|
192
|
+
if (!opts.plain && process.stdout.isTTY) return new InteractiveRenderer();
|
|
193
|
+
return plainRenderer((line) => console.log(line));
|
|
194
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
5
|
+
import { loadConstraints } from '../memory/constraints.js';
|
|
6
|
+
import { kicadCliVersion } from '../kicad/cli.js';
|
|
7
|
+
import { branchName, headCommit, uncommittedCount } from '../util/git.js';
|
|
8
|
+
import type { CopperheadConfig, ModelSource } from '../config.js';
|
|
9
|
+
|
|
10
|
+
/** Caller-supplied run identity: facts the loop cannot probe for itself. */
|
|
11
|
+
export interface RunMetaInput {
|
|
12
|
+
command?: 'do' | 'create' | 'sync';
|
|
13
|
+
modelSource?: ModelSource;
|
|
14
|
+
version?: string;
|
|
15
|
+
kicadCliVersion?: string;
|
|
16
|
+
stage?: { name: string; index: number; total: number };
|
|
17
|
+
brief?: { path: string; sha256: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Everything a run needs to be self-describing (AC-8.1). Collected once,
|
|
22
|
+
* rendered onto three surfaces: run-start event, summary.md ## Environment,
|
|
23
|
+
* and the live CLI header. Probe failures are nulls, never errors (AC-8.3).
|
|
24
|
+
*/
|
|
25
|
+
export interface RunMeta {
|
|
26
|
+
request: string;
|
|
27
|
+
model: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
modelSource: ModelSource | null;
|
|
30
|
+
runId: string;
|
|
31
|
+
startedAt: string;
|
|
32
|
+
command: 'do' | 'create' | 'sync' | null;
|
|
33
|
+
interactive: boolean;
|
|
34
|
+
stage: { name: string; index: number; total: number } | null;
|
|
35
|
+
brief: { path: string; sha256: string } | null;
|
|
36
|
+
versions: {
|
|
37
|
+
copperhead: string | null;
|
|
38
|
+
installPath: string | null;
|
|
39
|
+
kicadCli: string | null;
|
|
40
|
+
node: string;
|
|
41
|
+
platform: string;
|
|
42
|
+
};
|
|
43
|
+
config: {
|
|
44
|
+
schematic: string | null;
|
|
45
|
+
board: string | null;
|
|
46
|
+
docs: string;
|
|
47
|
+
maxTurns: number;
|
|
48
|
+
maxRepairCycles: number;
|
|
49
|
+
budgets: Record<string, number>;
|
|
50
|
+
};
|
|
51
|
+
git: {
|
|
52
|
+
commit: string | null;
|
|
53
|
+
branch: string | null;
|
|
54
|
+
dirty: boolean | null;
|
|
55
|
+
uncommittedFiles: number | null;
|
|
56
|
+
preCommitHookInstalled: boolean | null;
|
|
57
|
+
};
|
|
58
|
+
openConstraints: number | null;
|
|
59
|
+
priorRuns: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A metadata probe must never fail the run it describes (design D4). */
|
|
63
|
+
async function probe<T>(fn: () => Promise<T> | T): Promise<T | null> {
|
|
64
|
+
try {
|
|
65
|
+
return await fn();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function packageRoot(): string {
|
|
72
|
+
// src/agent/ and dist/agent/ both sit two levels below the package root.
|
|
73
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ownVersion(): string {
|
|
77
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
|
|
78
|
+
return version;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CollectRunMetaOptions {
|
|
82
|
+
repoRoot: string;
|
|
83
|
+
config: CopperheadConfig;
|
|
84
|
+
/** Effective turn budget for this run (flag override already applied). */
|
|
85
|
+
maxTurns: number;
|
|
86
|
+
runId: string;
|
|
87
|
+
request: string;
|
|
88
|
+
model: string;
|
|
89
|
+
provider: string;
|
|
90
|
+
interactive: boolean;
|
|
91
|
+
input?: RunMetaInput | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function collectRunMeta(opts: CollectRunMetaOptions): Promise<RunMeta> {
|
|
95
|
+
const { repoRoot, config, input } = opts;
|
|
96
|
+
const [copperhead, kicadCli, commit, branch, uncommitted, hook, openConstraints, priorRuns] = await Promise.all([
|
|
97
|
+
probe(() => input?.version ?? ownVersion()),
|
|
98
|
+
probe(() => input?.kicadCliVersion ?? kicadCliVersion()),
|
|
99
|
+
probe(() => headCommit(repoRoot)),
|
|
100
|
+
probe(() => branchName(repoRoot)),
|
|
101
|
+
probe(() => uncommittedCount(repoRoot)),
|
|
102
|
+
probe(async () => {
|
|
103
|
+
const hookText = await readFile(path.join(repoRoot, '.git', 'hooks', 'pre-commit'), 'utf8');
|
|
104
|
+
return hookText.includes('copperhead');
|
|
105
|
+
}).then((v) => v ?? false),
|
|
106
|
+
probe(async () => Object.keys(await loadConstraints(repoRoot)).length),
|
|
107
|
+
probe(async () => {
|
|
108
|
+
const entries = await readdir(path.join(repoRoot, '.copperhead', 'runs'));
|
|
109
|
+
return entries.filter((e) => e !== opts.runId).length;
|
|
110
|
+
}).then((v) => v ?? 0),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
request: opts.request,
|
|
115
|
+
model: opts.model,
|
|
116
|
+
provider: opts.provider,
|
|
117
|
+
modelSource: input?.modelSource ?? null,
|
|
118
|
+
runId: opts.runId,
|
|
119
|
+
startedAt: new Date().toISOString(),
|
|
120
|
+
command: input?.command ?? null,
|
|
121
|
+
interactive: opts.interactive,
|
|
122
|
+
stage: input?.stage ?? null,
|
|
123
|
+
brief: input?.brief ?? null,
|
|
124
|
+
versions: {
|
|
125
|
+
copperhead,
|
|
126
|
+
installPath: await probe(packageRoot),
|
|
127
|
+
kicadCli,
|
|
128
|
+
node: process.version,
|
|
129
|
+
platform: `${process.platform}-${process.arch}`,
|
|
130
|
+
},
|
|
131
|
+
config: {
|
|
132
|
+
schematic: config.schematic,
|
|
133
|
+
board: config.board,
|
|
134
|
+
docs: config.docs,
|
|
135
|
+
maxTurns: opts.maxTurns,
|
|
136
|
+
maxRepairCycles: config.maxRepairCycles,
|
|
137
|
+
budgets: config.budgets,
|
|
138
|
+
},
|
|
139
|
+
git: {
|
|
140
|
+
commit,
|
|
141
|
+
branch,
|
|
142
|
+
dirty: uncommitted === null ? null : uncommitted > 0,
|
|
143
|
+
uncommittedFiles: uncommitted,
|
|
144
|
+
preCommitHookInstalled: hook,
|
|
145
|
+
},
|
|
146
|
+
openConstraints,
|
|
147
|
+
priorRuns,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const unk = (v: string | null | undefined): string => v ?? 'unknown';
|
|
152
|
+
|
|
153
|
+
/** ≤ 2 lines, printed before the first turn (AC-8.4). */
|
|
154
|
+
export function renderCliHeader(meta: RunMeta): string[] {
|
|
155
|
+
const v = meta.versions;
|
|
156
|
+
const line1 = [
|
|
157
|
+
`copperhead v${unk(v.copperhead)}${v.installPath ? ` (${v.installPath})` : ''}`,
|
|
158
|
+
`kicad-cli ${unk(v.kicadCli)}`,
|
|
159
|
+
`node ${v.node}`,
|
|
160
|
+
v.platform,
|
|
161
|
+
].join(' · ');
|
|
162
|
+
|
|
163
|
+
const repoState =
|
|
164
|
+
meta.git.dirty === null
|
|
165
|
+
? 'unknown'
|
|
166
|
+
: meta.git.dirty
|
|
167
|
+
? `dirty(${meta.git.uncommittedFiles})`
|
|
168
|
+
: 'clean';
|
|
169
|
+
const line2 = [
|
|
170
|
+
`run ${meta.runId}`,
|
|
171
|
+
unk(meta.command),
|
|
172
|
+
...(meta.stage ? [`stage ${meta.stage.name} (${meta.stage.index}/${meta.stage.total})`] : []),
|
|
173
|
+
`model ${meta.model} (${meta.provider}, via ${unk(meta.modelSource)})`,
|
|
174
|
+
`turns ≤${meta.config.maxTurns}`,
|
|
175
|
+
`repo ${unk(meta.git.branch)}@${meta.git.commit?.slice(0, 7) ?? 'unknown'} ${repoState}`,
|
|
176
|
+
].join(' · ');
|
|
177
|
+
return [line1, line2];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The `## Environment` section of summary.md; values mirror the run-start event (AC-8.4). */
|
|
181
|
+
export function renderEnvironmentSection(meta: RunMeta): string[] {
|
|
182
|
+
const v = meta.versions;
|
|
183
|
+
const c = meta.config;
|
|
184
|
+
const g = meta.git;
|
|
185
|
+
return [
|
|
186
|
+
`## Environment`,
|
|
187
|
+
``,
|
|
188
|
+
`- **Run:** ${meta.runId} · ${unk(meta.command)} · started ${meta.startedAt} · ${meta.interactive ? 'interactive' : 'autonomous'}`,
|
|
189
|
+
...(meta.stage ? [`- **Stage:** ${meta.stage.name} (${meta.stage.index}/${meta.stage.total})`] : []),
|
|
190
|
+
...(meta.brief ? [`- **Brief:** ${meta.brief.path} (sha256 ${meta.brief.sha256.slice(0, 12)}…)`] : []),
|
|
191
|
+
`- **Model:** ${meta.model} (${meta.provider}, via ${unk(meta.modelSource)})`,
|
|
192
|
+
`- **copperhead:** v${unk(v.copperhead)}${v.installPath ? ` at ${v.installPath}` : ''}`,
|
|
193
|
+
`- **Tooling:** kicad-cli ${unk(v.kicadCli)} · node ${v.node} · ${v.platform}`,
|
|
194
|
+
`- **Config:** schematic ${c.schematic ?? 'null'} · board ${c.board ?? 'null'} · docs ${c.docs} · maxTurns ${c.maxTurns} · maxRepairCycles ${c.maxRepairCycles} · budgets ${JSON.stringify(c.budgets)}`,
|
|
195
|
+
`- **Repo:** ${unk(g.branch)}@${g.commit ?? 'unknown'} · ${g.dirty === null ? 'unknown' : g.dirty ? `dirty (${g.uncommittedFiles} uncommitted)` : 'clean'} · pre-commit hook ${g.preCommitHookInstalled === null ? 'unknown' : g.preCommitHookInstalled ? 'installed' : 'absent'}`,
|
|
196
|
+
`- **Memory:** ${meta.openConstraints ?? 'unknown'} open constraint(s) · ${meta.priorRuns ?? 'unknown'} prior run(s)`,
|
|
197
|
+
];
|
|
198
|
+
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
2
|
+
import { writeFile, mkdir, appendFile, readFile } from 'node:fs/promises';
|
|
3
3
|
import type { ToolSchema } from './types.js';
|
|
4
4
|
import { toolReadFile, toolWriteFile, toolEditFile, toolSearch } from './filetools.js';
|
|
5
5
|
import { resolveInRepo, isKicadFile } from '../util/paths.js';
|
|
6
|
-
import { runErc, runDrc, exportSvg, exportFab } from '../kicad/cli.js';
|
|
6
|
+
import { runErc, runDrc, exportSvg, exportFab, kicadLoadError, isProbeableKicadFile } from '../kicad/cli.js';
|
|
7
7
|
import { formatViolations, type CheckReport } from '../kicad/report.js';
|
|
8
8
|
import { listSymbols, listNets } from '../kicad/sexp.js';
|
|
9
9
|
import { checkDrift } from '../memory/drift.js';
|
|
10
|
-
import { saveConstraint } from '../memory/constraints.js';
|
|
10
|
+
import { saveConstraint, classifyAffectsTarget, affectsTargetExists } from '../memory/constraints.js';
|
|
11
11
|
import { openspecValidate } from '../openspec/cli.js';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import type { CopperheadConfig } from '../config.js';
|
|
@@ -99,7 +99,11 @@ export const TOOLS: ToolDef[] = [
|
|
|
99
99
|
},
|
|
100
100
|
requiresUnlock: false,
|
|
101
101
|
handler: async (ctx, args) => {
|
|
102
|
-
const
|
|
102
|
+
const pattern = args.pattern;
|
|
103
|
+
if (typeof pattern !== 'string' || pattern.trim() === '') {
|
|
104
|
+
return 'error: search requires a non-empty regex in "pattern" (narrow by file with "glob", e.g. {"pattern": "GPIO", "glob": "**/*.md"}); to list files, use a broad pattern like "." with a glob';
|
|
105
|
+
}
|
|
106
|
+
const matches = await toolSearch(ctx.repoRoot, pattern, args.glob as string | undefined);
|
|
103
107
|
if (!matches.length) return 'no matches';
|
|
104
108
|
return matches.map((m) => `${m.file}:${m.line}: ${m.text}`).join('\n');
|
|
105
109
|
},
|
|
@@ -213,6 +217,14 @@ export const TOOLS: ToolDef[] = [
|
|
|
213
217
|
requiresUnlock: true,
|
|
214
218
|
handler: async (ctx, args) => {
|
|
215
219
|
const rel = str(args, 'path');
|
|
220
|
+
const abs = resolveInRepo(ctx.repoRoot, rel);
|
|
221
|
+
// Text edits can corrupt an s-expression file in ways the editor cannot
|
|
222
|
+
// see; a corrupted file then fails every later ERC/DRC with an opaque
|
|
223
|
+
// error. Validate loadability with KiCad itself and roll the edit back
|
|
224
|
+
// rather than letting the file drift unusable. Only schematics and
|
|
225
|
+
// boards are probeable; .kicad_pro/.kicad_sym/.kicad_mod edits must not
|
|
226
|
+
// be probed (a sch/pcb probe rejects them wholesale).
|
|
227
|
+
const before = isProbeableKicadFile(rel) ? await readFile(abs, 'utf8') : null;
|
|
216
228
|
const res = await toolEditFile(
|
|
217
229
|
ctx.repoRoot,
|
|
218
230
|
rel,
|
|
@@ -220,6 +232,23 @@ export const TOOLS: ToolDef[] = [
|
|
|
220
232
|
args.new_string as string,
|
|
221
233
|
args.replace_all === true,
|
|
222
234
|
);
|
|
235
|
+
if (before !== null) {
|
|
236
|
+
const loadErr = await kicadLoadError(abs);
|
|
237
|
+
if (loadErr) {
|
|
238
|
+
const after = await readFile(abs, 'utf8');
|
|
239
|
+
await writeFile(abs, before, 'utf8');
|
|
240
|
+
if (await kicadLoadError(abs)) {
|
|
241
|
+
// The file was already unloadable before this edit. Reverting
|
|
242
|
+
// would deadlock incremental repair (every partial fix undone
|
|
243
|
+
// unless one edit fixes the whole file), so keep the edit and
|
|
244
|
+
// keep the pressure on with the probe output.
|
|
245
|
+
await writeFile(abs, after, 'utf8');
|
|
246
|
+
markTouched(ctx, rel);
|
|
247
|
+
return `${res}\nnote: ${rel} was already unloadable before this edit, so the edit is KEPT. Keep repairing until it loads. kicad-cli says:\n${loadErr}`;
|
|
248
|
+
}
|
|
249
|
+
return `edit REVERTED: it would make ${rel} unloadable in KiCad. kicad-cli says:\n${loadErr}\nRe-read the surrounding file text and make a smaller, syntactically complete edit.`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
223
252
|
markTouched(ctx, rel);
|
|
224
253
|
return res;
|
|
225
254
|
},
|
|
@@ -250,7 +279,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
250
279
|
},
|
|
251
280
|
requiresUnlock: false,
|
|
252
281
|
handler: async (ctx) => {
|
|
253
|
-
if (!ctx.config.schematic)
|
|
282
|
+
if (!ctx.config.schematic)
|
|
283
|
+
return 'no schematic configured; ERC does not apply yet — skip it until a schematic exists and is set in .copperhead/config.json';
|
|
254
284
|
const report = await runErc(path.join(ctx.repoRoot, ctx.config.schematic));
|
|
255
285
|
ctx.lastErc = report;
|
|
256
286
|
if (report.ok) ctx.ledger.clear('erc');
|
|
@@ -266,7 +296,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
266
296
|
},
|
|
267
297
|
requiresUnlock: false,
|
|
268
298
|
handler: async (ctx) => {
|
|
269
|
-
if (!ctx.config.board)
|
|
299
|
+
if (!ctx.config.board)
|
|
300
|
+
return 'no board configured; DRC does not apply yet — skip it until a board exists and is set in .copperhead/config.json';
|
|
270
301
|
const report = await runDrc(path.join(ctx.repoRoot, ctx.config.board));
|
|
271
302
|
ctx.lastDrc = report;
|
|
272
303
|
if (report.ok) ctx.ledger.clear('drc');
|
|
@@ -325,7 +356,16 @@ export const TOOLS: ToolDef[] = [
|
|
|
325
356
|
},
|
|
326
357
|
requiresUnlock: false,
|
|
327
358
|
handler: async (ctx) => {
|
|
328
|
-
|
|
359
|
+
// No schematic yet means there is nothing for the docs to drift against,
|
|
360
|
+
// so the obligation is vacuously satisfied and must be cleared. Returning
|
|
361
|
+
// without clearing deadlocks every docs-only stage of the create pipeline
|
|
362
|
+
// (spec-seed, architecture, part-selection all run before the schematic
|
|
363
|
+
// exists): a doc edit opens the drift obligation, this is the only tool
|
|
364
|
+
// that clears it, and finish refuses while any obligation is open.
|
|
365
|
+
if (!ctx.config.schematic) {
|
|
366
|
+
ctx.ledger.clear('drift');
|
|
367
|
+
return 'no schematic configured; drift vacuously clean';
|
|
368
|
+
}
|
|
329
369
|
const mismatches = await checkDrift(ctx.repoRoot, ctx.config.docs, ctx.config.schematic);
|
|
330
370
|
if (!mismatches.length) {
|
|
331
371
|
ctx.ledger.clear('drift');
|
|
@@ -357,6 +397,19 @@ export const TOOLS: ToolDef[] = [
|
|
|
357
397
|
handler: async (ctx, args) => {
|
|
358
398
|
const key = str(args, 'key');
|
|
359
399
|
const affects = (args.affects as string[]) ?? [];
|
|
400
|
+
// An affects item whose target artifact is not built yet (no schematic or
|
|
401
|
+
// board configured, no BOM.md) has nothing to revisit; opening an
|
|
402
|
+
// obligation now only forces a ceremonial "not yet created" resolution.
|
|
403
|
+
// Defer it in the registry instead — reopenDeferredAffects re-opens it at
|
|
404
|
+
// the start of the first run where the artifact exists, which is when the
|
|
405
|
+
// revisit actually means something.
|
|
406
|
+
const deferred: string[] = [];
|
|
407
|
+
const openNow: string[] = [];
|
|
408
|
+
for (const item of affects) {
|
|
409
|
+
const target = classifyAffectsTarget(item);
|
|
410
|
+
if (target && !affectsTargetExists(target, ctx.repoRoot, ctx.config)) deferred.push(item);
|
|
411
|
+
else openNow.push(item);
|
|
412
|
+
}
|
|
360
413
|
await saveConstraint(ctx.repoRoot, key, {
|
|
361
414
|
...(args.min !== undefined ? { min: args.min as number } : {}),
|
|
362
415
|
...(args.max !== undefined ? { max: args.max as number } : {}),
|
|
@@ -364,33 +417,84 @@ export const TOOLS: ToolDef[] = [
|
|
|
364
417
|
...(args.value !== undefined ? { value: args.value as string } : {}),
|
|
365
418
|
source: str(args, 'source'),
|
|
366
419
|
affects,
|
|
420
|
+
...(deferred.length ? { deferred } : {}),
|
|
367
421
|
});
|
|
368
|
-
ctx.ledger.onConstraintChange(key,
|
|
422
|
+
ctx.ledger.onConstraintChange(key, openNow);
|
|
369
423
|
ctx.ledger.clear('constraint-dual-write', key);
|
|
370
|
-
|
|
424
|
+
const parts = [`constraint ${key} recorded`];
|
|
425
|
+
parts.push(`revisit obligations opened for: ${openNow.join(', ') || '(none)'}`);
|
|
426
|
+
if (deferred.length) {
|
|
427
|
+
parts.push(
|
|
428
|
+
`deferred until the target artifact exists (no resolve_affected needed now): ${deferred.join(', ')}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return parts.join('; ');
|
|
371
432
|
},
|
|
372
433
|
},
|
|
373
434
|
{
|
|
374
435
|
schema: {
|
|
375
436
|
name: 'resolve_affected',
|
|
376
437
|
description:
|
|
377
|
-
'Explicitly resolve
|
|
438
|
+
'Explicitly resolve affects-revisit obligations: state whether each affected item changed or why no change is needed. Pass resolutions[] to clear many in one call, or the single constraint_key/item/resolution form.',
|
|
378
439
|
parameters: {
|
|
379
440
|
type: 'object',
|
|
380
441
|
properties: {
|
|
381
442
|
constraint_key: { type: 'string' },
|
|
382
443
|
item: { type: 'string' },
|
|
383
444
|
resolution: { type: 'string', description: '"changed: ..." or "no change needed: <reason>"' },
|
|
445
|
+
resolutions: {
|
|
446
|
+
type: 'array',
|
|
447
|
+
description: 'batch form: resolve many obligations in one call',
|
|
448
|
+
items: {
|
|
449
|
+
type: 'object',
|
|
450
|
+
properties: {
|
|
451
|
+
constraint_key: { type: 'string' },
|
|
452
|
+
item: { type: 'string' },
|
|
453
|
+
resolution: { type: 'string' },
|
|
454
|
+
},
|
|
455
|
+
required: ['constraint_key', 'item', 'resolution'],
|
|
456
|
+
},
|
|
457
|
+
},
|
|
384
458
|
},
|
|
385
|
-
required: [
|
|
459
|
+
required: [],
|
|
386
460
|
},
|
|
387
461
|
},
|
|
388
462
|
requiresUnlock: true,
|
|
389
463
|
handler: async (ctx, args) => {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
464
|
+
// An item that matches nothing must not read as success: the model would
|
|
465
|
+
// move on believing the obligation closed, and only find out at finish.
|
|
466
|
+
const resolveOne = (constraintKey: string, item: string, resolution: string): string => {
|
|
467
|
+
const detail = `${constraintKey} affects ${item}`;
|
|
468
|
+
if (!ctx.ledger.clear('affects-revisit', detail)) {
|
|
469
|
+
const open = ctx.ledger.openOfKind('affects-revisit');
|
|
470
|
+
if (!open.length) return `error: no open affects-revisit obligation matches "${detail}"`;
|
|
471
|
+
return [
|
|
472
|
+
`error: no open affects-revisit obligation matches "${detail}".`,
|
|
473
|
+
'Match these exactly:',
|
|
474
|
+
...open.map((o) => ` - ${o.detail}`),
|
|
475
|
+
].join('\n');
|
|
476
|
+
}
|
|
477
|
+
ctx.decisions.push(`[affects] ${detail}: ${resolution}`);
|
|
478
|
+
return `resolved: ${detail}`;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const batch = args.resolutions;
|
|
482
|
+
if (Array.isArray(batch) && batch.length) {
|
|
483
|
+
// Entries resolve independently: one bad key must not waste the call.
|
|
484
|
+
return batch
|
|
485
|
+
.map((entry, i) => {
|
|
486
|
+
const e = entry as Record<string, unknown>;
|
|
487
|
+
if (typeof e?.constraint_key !== 'string' || typeof e?.item !== 'string' || typeof e?.resolution !== 'string') {
|
|
488
|
+
return `error: resolutions[${i}] needs string constraint_key, item, and resolution`;
|
|
489
|
+
}
|
|
490
|
+
return resolveOne(e.constraint_key, e.item, e.resolution);
|
|
491
|
+
})
|
|
492
|
+
.join('\n');
|
|
493
|
+
}
|
|
494
|
+
if (typeof args.constraint_key === 'string' && typeof args.item === 'string' && typeof args.resolution === 'string') {
|
|
495
|
+
return resolveOne(args.constraint_key, args.item, args.resolution);
|
|
496
|
+
}
|
|
497
|
+
return 'error: pass either resolutions: [{constraint_key, item, resolution}, ...] or the single form constraint_key + item + resolution';
|
|
394
498
|
},
|
|
395
499
|
},
|
|
396
500
|
{
|