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.
Files changed (60) hide show
  1. package/NOTICE +5 -0
  2. package/README.md +55 -9
  3. package/dist/agent/ledger.js +7 -0
  4. package/dist/agent/ledger.js.map +1 -1
  5. package/dist/agent/loop.js +275 -33
  6. package/dist/agent/loop.js.map +1 -1
  7. package/dist/agent/prompts.js +3 -1
  8. package/dist/agent/prompts.js.map +1 -1
  9. package/dist/agent/providers/anthropic.js +28 -13
  10. package/dist/agent/providers/anthropic.js.map +1 -1
  11. package/dist/agent/render.js +170 -0
  12. package/dist/agent/render.js.map +1 -0
  13. package/dist/agent/runmeta.js +124 -0
  14. package/dist/agent/runmeta.js.map +1 -0
  15. package/dist/agent/tools.js +117 -16
  16. package/dist/agent/tools.js.map +1 -1
  17. package/dist/agent/transcript.js +23 -0
  18. package/dist/agent/transcript.js.map +1 -1
  19. package/dist/cli.js +45 -9
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/check.js +9 -2
  22. package/dist/commands/check.js.map +1 -1
  23. package/dist/commands/create.js +57 -3
  24. package/dist/commands/create.js.map +1 -1
  25. package/dist/commands/sync.js +3 -1
  26. package/dist/commands/sync.js.map +1 -1
  27. package/dist/config.js +11 -5
  28. package/dist/config.js.map +1 -1
  29. package/dist/kicad/cli.js +58 -8
  30. package/dist/kicad/cli.js.map +1 -1
  31. package/dist/memory/constraints.js +63 -3
  32. package/dist/memory/constraints.js.map +1 -1
  33. package/dist/memory/drift.js +31 -0
  34. package/dist/memory/drift.js.map +1 -1
  35. package/dist/memory/synap.js +152 -0
  36. package/dist/memory/synap.js.map +1 -0
  37. package/dist/util/git.js +125 -4
  38. package/dist/util/git.js.map +1 -1
  39. package/dist/util/preflight.js +24 -0
  40. package/dist/util/preflight.js.map +1 -0
  41. package/package.json +10 -6
  42. package/src/agent/ledger.ts +9 -1
  43. package/src/agent/loop.ts +300 -34
  44. package/src/agent/prompts.ts +3 -1
  45. package/src/agent/providers/anthropic.ts +40 -16
  46. package/src/agent/render.ts +194 -0
  47. package/src/agent/runmeta.ts +198 -0
  48. package/src/agent/tools.ts +119 -15
  49. package/src/agent/transcript.ts +49 -0
  50. package/src/cli.ts +49 -10
  51. package/src/commands/check.ts +9 -3
  52. package/src/commands/create.ts +61 -4
  53. package/src/commands/sync.ts +5 -0
  54. package/src/config.ts +24 -6
  55. package/src/kicad/cli.ts +60 -9
  56. package/src/memory/constraints.ts +90 -3
  57. package/src/memory/drift.ts +32 -0
  58. package/src/memory/synap.ts +217 -0
  59. package/src/util/git.ts +134 -4
  60. 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
+ }
@@ -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 matches = await toolSearch(ctx.repoRoot, str(args, 'pattern'), args.glob as string | undefined);
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) return 'no schematic configured';
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) return 'no board configured';
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
- if (!ctx.config.schematic) return 'no schematic configured';
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, affects);
422
+ ctx.ledger.onConstraintChange(key, openNow);
369
423
  ctx.ledger.clear('constraint-dual-write', key);
370
- return `constraint ${key} recorded; revisit obligations opened for: ${affects.join(', ') || '(none)'}`;
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 an affects-revisit obligation: state whether the affected item changed or why no change is needed.',
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: ['constraint_key', 'item', 'resolution'],
459
+ required: [],
386
460
  },
387
461
  },
388
462
  requiresUnlock: true,
389
463
  handler: async (ctx, args) => {
390
- const detail = `${str(args, 'constraint_key')} affects ${str(args, 'item')}`;
391
- ctx.ledger.clear('affects-revisit', detail);
392
- ctx.decisions.push(`[affects] ${detail}: ${str(args, 'resolution')}`);
393
- return `resolved: ${detail}`;
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
  {