copperhead 0.3.0 → 0.5.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 +72 -9
- package/dist/agent/ledger.js +7 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +303 -34
- 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/providers/codex.js +292 -0
- package/dist/agent/providers/codex.js.map +1 -0
- 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 +47 -11
- 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 +16 -8
- 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/scaffold.js +2 -1
- package/dist/memory/scaffold.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 +21 -6
- package/src/agent/ledger.ts +9 -1
- package/src/agent/loop.ts +333 -35
- package/src/agent/prompts.ts +3 -1
- package/src/agent/providers/anthropic.ts +40 -16
- package/src/agent/providers/codex.ts +339 -0
- 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/agent/types.ts +1 -0
- package/src/cli.ts +51 -12
- 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 +29 -9
- package/src/kicad/cli.ts +60 -9
- package/src/memory/constraints.ts +90 -3
- package/src/memory/drift.ts +32 -0
- package/src/memory/scaffold.ts +2 -1
- package/src/memory/synap.ts +217 -0
- package/src/util/git.ts +134 -4
- package/src/util/preflight.ts +22 -0
|
@@ -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
|
{
|
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/agent/types.ts
CHANGED
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')
|
|
@@ -89,7 +117,7 @@ program
|
|
|
89
117
|
.command('do')
|
|
90
118
|
.description('the core loop: propose, edit, verify, propagate, commit')
|
|
91
119
|
.argument('<request>', 'the change request in natural language')
|
|
92
|
-
.option('--model <model>', 'gpt-5 | claude (or a
|
|
120
|
+
.option('--model <model>', 'codex | gpt-5 | claude (or a provider-specific model id)')
|
|
93
121
|
.option('--max-turns <n>', 'turn budget for this run')
|
|
94
122
|
.option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
|
|
95
123
|
.option('--dry-run', 'propose the diff, write nothing')
|
|
@@ -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);
|
|
@@ -160,20 +195,24 @@ program
|
|
|
160
195
|
.command('create')
|
|
161
196
|
.description('Mode A: full pipeline from a product brief to the output package')
|
|
162
197
|
.requiredOption('--brief <file>', 'product brief (markdown)')
|
|
163
|
-
.option('--model <model>', 'gpt-5 | claude')
|
|
198
|
+
.option('--model <model>', 'codex | gpt-5 | claude')
|
|
164
199
|
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
|
|
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) {
|