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.
Files changed (67) hide show
  1. package/NOTICE +5 -0
  2. package/README.md +72 -9
  3. package/dist/agent/ledger.js +7 -0
  4. package/dist/agent/ledger.js.map +1 -1
  5. package/dist/agent/loop.js +303 -34
  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/providers/codex.js +292 -0
  12. package/dist/agent/providers/codex.js.map +1 -0
  13. package/dist/agent/render.js +170 -0
  14. package/dist/agent/render.js.map +1 -0
  15. package/dist/agent/runmeta.js +124 -0
  16. package/dist/agent/runmeta.js.map +1 -0
  17. package/dist/agent/tools.js +117 -16
  18. package/dist/agent/tools.js.map +1 -1
  19. package/dist/agent/transcript.js +23 -0
  20. package/dist/agent/transcript.js.map +1 -1
  21. package/dist/cli.js +47 -11
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/check.js +9 -2
  24. package/dist/commands/check.js.map +1 -1
  25. package/dist/commands/create.js +57 -3
  26. package/dist/commands/create.js.map +1 -1
  27. package/dist/commands/sync.js +3 -1
  28. package/dist/commands/sync.js.map +1 -1
  29. package/dist/config.js +16 -8
  30. package/dist/config.js.map +1 -1
  31. package/dist/kicad/cli.js +58 -8
  32. package/dist/kicad/cli.js.map +1 -1
  33. package/dist/memory/constraints.js +63 -3
  34. package/dist/memory/constraints.js.map +1 -1
  35. package/dist/memory/drift.js +31 -0
  36. package/dist/memory/drift.js.map +1 -1
  37. package/dist/memory/scaffold.js +2 -1
  38. package/dist/memory/scaffold.js.map +1 -1
  39. package/dist/memory/synap.js +152 -0
  40. package/dist/memory/synap.js.map +1 -0
  41. package/dist/util/git.js +125 -4
  42. package/dist/util/git.js.map +1 -1
  43. package/dist/util/preflight.js +24 -0
  44. package/dist/util/preflight.js.map +1 -0
  45. package/package.json +21 -6
  46. package/src/agent/ledger.ts +9 -1
  47. package/src/agent/loop.ts +333 -35
  48. package/src/agent/prompts.ts +3 -1
  49. package/src/agent/providers/anthropic.ts +40 -16
  50. package/src/agent/providers/codex.ts +339 -0
  51. package/src/agent/render.ts +194 -0
  52. package/src/agent/runmeta.ts +198 -0
  53. package/src/agent/tools.ts +119 -15
  54. package/src/agent/transcript.ts +49 -0
  55. package/src/agent/types.ts +1 -0
  56. package/src/cli.ts +51 -12
  57. package/src/commands/check.ts +9 -3
  58. package/src/commands/create.ts +61 -4
  59. package/src/commands/sync.ts +5 -0
  60. package/src/config.ts +29 -9
  61. package/src/kicad/cli.ts +60 -9
  62. package/src/memory/constraints.ts +90 -3
  63. package/src/memory/drift.ts +32 -0
  64. package/src/memory/scaffold.ts +2 -1
  65. package/src/memory/synap.ts +217 -0
  66. package/src/util/git.ts +134 -4
  67. package/src/util/preflight.ts +22 -0
package/src/agent/loop.ts CHANGED
@@ -4,16 +4,31 @@ import { execa } from 'execa';
4
4
  import type { Msg, Provider, Turn } from './types.js';
5
5
  import { availableTools, dispatchTool, type RunContext } from './tools.js';
6
6
  import { buildSystemPrompt } from './prompts.js';
7
- import { loadConstraints } from '../memory/constraints.js';
7
+ import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
8
8
  import { loadConfig, type CopperheadConfig } from '../config.js';
9
- import { Transcript } from './transcript.js';
9
+ import { Transcript, type ExitPath, type RunStats } from './transcript.js';
10
+ import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
11
+ import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
10
12
  import { ObligationsLedger } from './ledger.js';
11
- import { isDirty, isGitRepo, snapshot, restore, commitAll, changedFiles } from '../util/git.js';
13
+ import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
12
14
  import { withRetry, isRateLimit } from '../util/retry.js';
13
15
  import { openspecArchive } from '../openspec/cli.js';
14
16
  import { existsSync } from 'node:fs';
15
17
  import { OpenAIProvider } from './providers/openai.js';
16
18
  import { AnthropicProvider } from './providers/anthropic.js';
19
+ import { CodexProvider } from './providers/codex.js';
20
+ import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
21
+
22
+ /** What the user sees at the moment they decide whether to keep going. */
23
+ export interface BudgetExhaustedStats {
24
+ /** The run's original turn budget, before any extensions. */
25
+ maxTurns: number;
26
+ turnsUsed: number;
27
+ tokensIn: number;
28
+ tokensOut: number;
29
+ filesTouched: string[];
30
+ openObligations: number;
31
+ }
17
32
 
18
33
  export interface RunOptions {
19
34
  repoRoot: string;
@@ -24,20 +39,49 @@ export interface RunOptions {
24
39
  dryRun?: boolean;
25
40
  interactive?: boolean;
26
41
  confirm?: (q: string) => Promise<boolean>;
42
+ /**
43
+ * Called when the turn budget runs out. Returns the number of extra turns to
44
+ * grant (0 fails the run as before). Absent means non-interactive: fail.
45
+ */
46
+ onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
27
47
  /** Extra prompt appended for pipeline stages (Mode A). */
28
48
  stagePrompt?: string;
49
+ /** Test seam: bypass makeProvider. */
50
+ provider?: Provider;
29
51
  log?: (line: string) => void;
52
+ /** Progress renderer; defaults to a plain line renderer over `log`. */
53
+ renderer?: ProgressRenderer;
54
+ /** Caller-known run identity for the metadata block (design D2). */
55
+ meta?: RunMetaInput;
30
56
  }
31
57
 
32
58
  export interface RunResult {
33
59
  outcome: 'success' | 'refused' | 'failure';
60
+ exitPath: ExitPath;
34
61
  summary: string;
35
62
  transcriptDir: string;
36
63
  filesTouched: string[];
37
64
  commit: string | null;
38
65
  }
39
66
 
40
- export function makeProvider(model: string): Provider {
67
+ export async function makeProvider(model: string): Promise<Provider> {
68
+ if (model === 'codex' || model.startsWith('codex:')) {
69
+ const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
70
+ if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
71
+ const { Codex } = await import('@openai/codex-sdk').catch((err: unknown) => {
72
+ throw new Error(
73
+ 'Codex provider requires the optional @openai/codex-sdk package; install it alongside Copperhead before using --model codex',
74
+ { cause: err },
75
+ );
76
+ });
77
+ return new CodexProvider({
78
+ ...(codexModel ? { model: codexModel } : {}),
79
+ client: new Codex({
80
+ // Use the user's installed CLI and its saved login rather than a model API key.
81
+ codexPathOverride: process.env.COPPERHEAD_CODEX_PATH || 'codex',
82
+ }),
83
+ });
84
+ }
41
85
  if (model === 'claude' || model.startsWith('claude')) {
42
86
  return new AnthropicProvider(model === 'claude' ? undefined : model);
43
87
  }
@@ -84,18 +128,40 @@ async function appendChangelog(
84
128
  await writeFile(p, lines.join('\n'), 'utf8');
85
129
  }
86
130
 
131
+ /**
132
+ * Owns the Synap session for one run. The bridge is a subprocess, so the
133
+ * shutdown in `finally` is what lets the CLI exit; without it the process
134
+ * hangs after a successful run.
135
+ */
87
136
  export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
88
- const log = opts.log ?? ((l: string) => console.log(l));
137
+ const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
138
+ const providers = new Set<Provider>();
139
+ try {
140
+ return await runWithMemory(opts, memory, providers);
141
+ } finally {
142
+ for (const provider of providers) {
143
+ try {
144
+ await provider.close?.();
145
+ } catch (err) {
146
+ opts.log?.(`warning: ${provider.name} provider cleanup failed (${(err as Error).message})`);
147
+ }
148
+ }
149
+ await memory?.close();
150
+ }
151
+ }
152
+
153
+ async function runWithMemory(
154
+ opts: RunOptions,
155
+ memory: SynapMemory | null,
156
+ providers: Set<Provider>,
157
+ ): Promise<RunResult> {
158
+ const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
159
+ const log = (l: string): void => r.log(l);
89
160
  const repoRoot = opts.repoRoot;
90
161
  const config = await loadConfig(repoRoot);
91
162
  const maxTurns = opts.maxTurns ?? config.maxTurns;
92
163
 
93
- if (!(await isGitRepo(repoRoot))) {
94
- throw new Error('not a git repository; copperhead requires git for snapshots and rollback');
95
- }
96
- if ((await isDirty(repoRoot)) && !opts.allowDirty) {
97
- throw new Error('working tree is dirty; commit your changes or pass --allow-dirty (snapshots via git stash create)');
98
- }
164
+ await gitPreflight(repoRoot, { allowDirty: opts.allowDirty ?? false });
99
165
  const snap = await snapshot(repoRoot);
100
166
 
101
167
  const transcript = new Transcript(repoRoot);
@@ -119,23 +185,127 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
119
185
  finishRequest: null,
120
186
  };
121
187
 
122
- let provider = makeProvider(opts.model);
188
+ let provider = opts.provider ?? (await makeProvider(opts.model));
189
+ providers.add(provider);
190
+
191
+ // Deterministic, LLM-free metadata block: collected once, rendered onto all
192
+ // three surfaces (run-start event, summary ## Environment, CLI header) so
193
+ // they can never disagree (design D1, AC-8.1/8.4).
194
+ const startMs = Date.now();
195
+ const meta: RunMeta = await collectRunMeta({
196
+ repoRoot,
197
+ config,
198
+ maxTurns,
199
+ runId: path.basename(transcript.dir),
200
+ request: opts.request,
201
+ model: opts.model,
202
+ provider: provider.name,
203
+ interactive: opts.interactive ?? false,
204
+ input: opts.meta,
205
+ });
206
+ for (const line of renderCliHeader(meta)) log(line);
207
+ // Revisit obligations deferred while their artifact didn't exist re-open now
208
+ // if it does (must run before loadConstraints so the prompt sees the updated
209
+ // registry). They land in this run's fresh ledger, so finish gates on them.
210
+ const reopened = await reopenDeferredAffects(repoRoot, config, (key, item) =>
211
+ ctx.ledger.add('affects-revisit', `${key} affects ${item}`, key),
212
+ );
213
+ if (reopened.length) {
214
+ await transcript.event('deferred-affects-reopened', { reopened });
215
+ log(`re-opened ${reopened.length} deferred constraint revisit obligation(s)`);
216
+ }
123
217
  const constraints = await loadConstraints(repoRoot);
124
- const system = await buildSystemPrompt(repoRoot, config, constraints);
218
+ let basePrompt = await buildSystemPrompt(repoRoot, config, constraints);
219
+ if (reopened.length) {
220
+ basePrompt += [
221
+ '',
222
+ '',
223
+ '## Reopened constraint revisits',
224
+ '',
225
+ 'These constraints were recorded before their target artifact existed; the artifact now exists.',
226
+ 'Revisit each against the design and close it with resolve_affected (batch the calls):',
227
+ ...reopened.map((r) => `- ${r.key} affects ${r.item}`),
228
+ ].join('\n');
229
+ }
230
+ // Cross-run memory is appended after the repo's own docs and constraints so
231
+ // that the in-repo sources of truth are what the model reads first.
232
+ const recalled = memory ? await memory.recall(opts.request) : null;
233
+ if (recalled) {
234
+ await transcript.event('synap-recall', { chars: recalled.length });
235
+ log('recalled prior context from Synap memory');
236
+ }
237
+ const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
125
238
  const messages: Msg[] = [
126
239
  { role: 'system', content: system },
127
240
  { role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
128
241
  ];
129
- await transcript.event('run-start', { request: opts.request, model: opts.model, provider: provider.name });
242
+ await transcript.event('run-start', meta);
243
+
244
+ /**
245
+ * A memory write that fails is reported rather than swallowed, but it does
246
+ * not change the run's outcome: discarding a verified commit because a
247
+ * third-party write failed would be the worse trade.
248
+ */
249
+ const remember = async (run: RunRecord): Promise<void> => {
250
+ if (!memory) return;
251
+ try {
252
+ await memory.record(run);
253
+ await transcript.event('synap-record', { outcome: run.outcome });
254
+ } catch (err) {
255
+ const message = (err as Error).message;
256
+ log(`warning: Synap memory write failed (${message}); this run was not recorded`);
257
+ await transcript.event('synap-record-failed', { error: message });
258
+ }
259
+ };
130
260
 
131
261
  let tokensIn = 0;
132
262
  let tokensOut = 0;
263
+ let turnsUsed = 0;
264
+ const perTurn: { turn: number; in: number; out: number }[] = [];
133
265
  let plan: string | null = null;
134
266
  let nudges = 0;
135
267
 
136
- const fail = async (reason: string): Promise<RunResult> => {
137
- await transcript.event('run-failed', { reason });
138
- await restore(repoRoot, snap);
268
+ const stats = (exitPath: ExitPath): RunStats => ({
269
+ exitPath,
270
+ turnsUsed,
271
+ maxTurns,
272
+ repairCyclesUsed: ctx.repairCycles,
273
+ maxRepairCycles: config.maxRepairCycles,
274
+ tokensIn,
275
+ tokensOut,
276
+ perTurn,
277
+ durationMs: Date.now() - startMs,
278
+ });
279
+
280
+ /** One outcome line, printed last at every terminal branch (AC-8.5). */
281
+ const outcomeLine = (s: RunStats, extra?: string | null): string =>
282
+ [
283
+ s.exitPath,
284
+ ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'failing'}` : 'ERC not run',
285
+ ...(ctx.lastDrc ? [`DRC ${ctx.lastDrc.ok ? 'clean' : 'failing'}`] : []),
286
+ ...(extra ? [extra] : []),
287
+ fmtDuration(s.durationMs),
288
+ `${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
289
+ ].join(' · ');
290
+
291
+ const fail = async (reason: string, exitPath: ExitPath): Promise<RunResult> => {
292
+ await transcript.event('run-failed', { reason, exitPath });
293
+ // Preserve the touched work as a stash entry before the rollback destroys
294
+ // it, so a budget-exhaustion (or any) failure is recoverable (issue #15).
295
+ const preserved = await preserveFailedRun(repoRoot, ctx.runId);
296
+ if (preserved) await transcript.event('work-preserved', { stash: preserved });
297
+ // The rollback itself can fail (git in a bad state). That must not become
298
+ // an unhandled throw that skips run-end and summary.md — the summary is
299
+ // most valuable exactly when the tree is left in an unknown state.
300
+ let restoreError: string | null = null;
301
+ try {
302
+ await restore(repoRoot, snap);
303
+ } catch (err) {
304
+ restoreError = (err as Error).message;
305
+ await transcript.event('restore-failed', { error: restoreError });
306
+ }
307
+ const runStats = stats(exitPath);
308
+ await transcript.event('run-end', runStats);
139
309
  const summaryPath = await transcript.writeSummary({
140
310
  request: opts.request,
141
311
  changeId: ctx.changeId,
@@ -148,14 +318,27 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
148
318
  tokensOut,
149
319
  outcome: 'failure',
150
320
  openObligations: ctx.ledger.isClear ? null : ctx.ledger.describe(),
151
- detail: reason,
321
+ detail: restoreError ? `${reason}\n\nROLLBACK FAILED: ${restoreError} — the working tree may be in a partial state; inspect it with git status/git diff before rerunning` : reason,
322
+ env: meta,
323
+ stats: runStats,
152
324
  });
153
325
  log(`run failed: ${reason}`);
154
- log(`working tree restored to pre-run snapshot`);
326
+ if (restoreError) {
327
+ log(`WARNING: rollback failed (${restoreError}); the working tree may be in a partial state`);
328
+ } else {
329
+ log(`working tree restored to pre-run snapshot`);
330
+ }
331
+ if (preserved) {
332
+ log(
333
+ `failed work preserved: git stash entry "copperhead failed run ${ctx.runId}" (${preserved.slice(0, 10)}); recover with \`git stash apply\`, discard with \`git stash drop\``,
334
+ );
335
+ }
155
336
  log(`transcript: ${transcript.jsonlPath}`);
156
337
  log(`summary: ${summaryPath}`);
338
+ r.finish(outcomeLine(runStats));
157
339
  return {
158
340
  outcome: 'failure',
341
+ exitPath,
159
342
  summary: reason,
160
343
  transcriptDir: transcript.dir,
161
344
  filesTouched: [],
@@ -163,8 +346,38 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
163
346
  };
164
347
  };
165
348
 
166
- for (let turn = 0; turn < maxTurns; turn++) {
349
+ let budget = maxTurns;
350
+ for (let turn = 0; ; turn++) {
351
+ if (turn >= budget) {
352
+ // Budget exhausted. In an attended run this is a user decision made with
353
+ // the cost visible, not an unconditional rollback (issue #15).
354
+ const exhaustStats: BudgetExhaustedStats = {
355
+ maxTurns,
356
+ turnsUsed: turn,
357
+ tokensIn,
358
+ tokensOut,
359
+ filesTouched: [...ctx.filesTouched],
360
+ openObligations: ctx.ledger.openObligations.length,
361
+ };
362
+ let extra = 0;
363
+ if (opts.onBudgetExhausted) {
364
+ try {
365
+ extra = Math.floor(await opts.onBudgetExhausted(exhaustStats));
366
+ } catch {
367
+ // A broken prompt (stdin closed mid-question, dying terminal) must
368
+ // read as "declined" and take the preserve-and-restore path below,
369
+ // not propagate past it and skip the rollback entirely.
370
+ extra = 0;
371
+ }
372
+ }
373
+ if (!Number.isFinite(extra) || extra <= 0) break;
374
+ budget += extra;
375
+ await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
376
+ log(`turn budget extended by ${extra} (now ${budget})`);
377
+ }
167
378
  const tools = availableTools(ctx).map((t) => t.schema);
379
+ r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
380
+ r.status('thinking');
168
381
  let res: Turn;
169
382
  try {
170
383
  res = await withRetry(() => provider.chat(messages, tools), {
@@ -177,14 +390,19 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
177
390
  log(`failing over ${provider.name} → ${fallback.name}`);
178
391
  await transcript.event('provider-failover', { from: provider.name, to: fallback.name });
179
392
  provider = fallback;
393
+ providers.add(provider);
180
394
  turn--;
181
395
  continue;
182
396
  }
183
397
  }
184
- return fail(`provider error: ${(err as Error).message}`);
398
+ return fail(`provider error: ${(err as Error).message}`, 'provider-error');
399
+ } finally {
400
+ r.status(null);
185
401
  }
402
+ turnsUsed = turn + 1;
186
403
  tokensIn += res.usage.inputTokens;
187
404
  tokensOut += res.usage.outputTokens;
405
+ perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
188
406
  await transcript.event('assistant', { text: res.text, toolCalls: res.toolCalls });
189
407
 
190
408
  if (res.text) {
@@ -194,31 +412,36 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
194
412
  messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
195
413
 
196
414
  if (!res.toolCalls.length) {
197
- if (nudges++ >= 2) return fail('model stopped calling tools without finishing');
415
+ // Only *consecutive* tool-less turns are a stall. Providers emit the
416
+ // occasional empty completion mid-run (observed live: three empties
417
+ // spread across 31 productive turns); a cumulative counter turns those
418
+ // into a full rollback of an otherwise-converging run.
419
+ if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
198
420
  messages.push({
199
421
  role: 'user',
200
422
  content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
201
423
  });
202
424
  continue;
203
425
  }
426
+ nudges = 0;
204
427
 
205
428
  for (const call of res.toolCalls) {
206
429
  const result = await dispatchTool(ctx, call.name, call.args);
207
430
  await transcript.event('tool', { name: call.name, args: call.args, result });
208
- log(` [${call.name}] ${result.split('\n')[0]}`);
431
+ r.toolResult(call.name, result.split('\n')[0] ?? '');
209
432
  messages.push({ role: 'tool', toolCallId: call.id, content: result });
210
433
  }
211
434
 
212
435
  if (ctx.repairCycles > config.maxRepairCycles) {
213
- return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`);
436
+ return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`, 'repair-cycles-exhausted');
214
437
  }
215
438
 
216
- const remaining = maxTurns - turn - 1;
439
+ const remaining = budget - turn - 1;
217
440
  if (remaining === 5 && !ctx.finishRequest) {
218
441
  messages.push({
219
442
  role: 'user',
220
443
  content:
221
- 'Only 5 turns remain. Converge now: finish the minimal correct edit set, run run_erc (and run_drc if the board changed), run check_drift, then call finish.',
444
+ 'Only 5 turns remain. Converge now: finish the minimal correct edit set, run run_erc (and run_drc if the board changed), run check_drift, then call finish. Batch independent tool calls in a single response (e.g. all resolve_affected calls at once) instead of one per turn.',
222
445
  });
223
446
  }
224
447
 
@@ -228,6 +451,8 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
228
451
  if (outcome === 'refuse') {
229
452
  await restore(repoRoot, snap);
230
453
  await transcript.event('run-refused', { summary });
454
+ const runStats = stats('refused');
455
+ await transcript.event('run-end', runStats);
231
456
  await transcript.writeSummary({
232
457
  request: opts.request,
233
458
  changeId: ctx.changeId,
@@ -241,9 +466,30 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
241
466
  outcome: 'aborted',
242
467
  openObligations: null,
243
468
  detail: `REFUSED: ${summary}`,
469
+ env: meta,
470
+ stats: runStats,
471
+ });
472
+ // Refusals are the most valuable thing to remember: they encode a budget
473
+ // or constraint that this user's designs keep running into.
474
+ await remember({
475
+ request: opts.request,
476
+ outcome: 'refused',
477
+ summary,
478
+ changeId: ctx.changeId,
479
+ filesTouched: [],
480
+ decisions: ctx.decisions,
481
+ verification: 'n/a (refused before verification)',
244
482
  });
245
483
  log(`refused: ${summary}`);
246
- return { outcome: 'refused', summary, transcriptDir: transcript.dir, filesTouched: [], commit: null };
484
+ r.finish(outcomeLine(runStats));
485
+ return {
486
+ outcome: 'refused',
487
+ exitPath: 'refused',
488
+ summary,
489
+ transcriptDir: transcript.dir,
490
+ filesTouched: [],
491
+ commit: null,
492
+ };
247
493
  }
248
494
 
249
495
  const verification = [
@@ -262,6 +508,8 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
262
508
  log(diff || '(no diff)');
263
509
  if (untracked) log(`new files:\n${untracked}`);
264
510
  await restore(repoRoot, snap);
511
+ const runStats = stats('done');
512
+ await transcript.event('run-end', runStats);
265
513
  await transcript.writeSummary({
266
514
  request: opts.request,
267
515
  changeId: ctx.changeId,
@@ -275,8 +523,18 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
275
523
  outcome: 'success',
276
524
  openObligations: null,
277
525
  detail: 'dry run: changes reverted',
526
+ env: meta,
527
+ stats: runStats,
278
528
  });
279
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit: null };
529
+ r.finish(outcomeLine(runStats, 'dry run: changes reverted'));
530
+ return {
531
+ outcome: 'success',
532
+ exitPath: 'done',
533
+ summary,
534
+ transcriptDir: transcript.dir,
535
+ filesTouched: files,
536
+ commit: null,
537
+ };
280
538
  }
281
539
 
282
540
  await appendChangelog(repoRoot, config, {
@@ -288,15 +546,33 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
288
546
  ctx.ledger.clear('changelog');
289
547
 
290
548
  const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
291
- const commit = await commitAll(repoRoot, commitMsg);
549
+ // A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
550
+ // must land in summary.md as an outcome, not escape as a stack trace
551
+ // (AC-8.6): roll back per the snapshot contract and report commit-failed.
552
+ let commit: string;
553
+ try {
554
+ commit = await commitAll(repoRoot, commitMsg);
555
+ } catch (err) {
556
+ return fail(`commit failed: ${(err as Error).message}`, 'commit-failed');
557
+ }
292
558
  if (ctx.changeId && existsSync(path.join(repoRoot, 'openspec', 'config.yaml'))) {
293
- const arch = await openspecArchive(repoRoot, ctx.changeId);
294
- await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
295
- if (arch.ok && (await isDirty(repoRoot))) {
296
- await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
559
+ // The verified commit already exists; discarding it because archive
560
+ // housekeeping failed would be the worse trade, so this is a warning.
561
+ try {
562
+ const arch = await openspecArchive(repoRoot, ctx.changeId);
563
+ await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
564
+ if (arch.ok && (await isDirty(repoRoot))) {
565
+ await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
566
+ }
567
+ } catch (err) {
568
+ const message = (err as Error).message;
569
+ log(`warning: openspec archive failed (${message}); the run commit itself succeeded`);
570
+ await transcript.event('openspec-archive-failed', { changeId: ctx.changeId, error: message });
297
571
  }
298
572
  }
299
573
  await transcript.event('run-committed', { commit, files });
574
+ const runStats = stats('done');
575
+ await transcript.event('run-end', runStats);
300
576
  await transcript.writeSummary({
301
577
  request: opts.request,
302
578
  changeId: ctx.changeId,
@@ -309,12 +585,34 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
309
585
  tokensOut,
310
586
  outcome: 'success',
311
587
  openObligations: null,
588
+ env: meta,
589
+ stats: runStats,
590
+ });
591
+ await remember({
592
+ request: opts.request,
593
+ outcome: 'success',
594
+ summary,
595
+ changeId: ctx.changeId,
596
+ filesTouched: files,
597
+ decisions: ctx.decisions,
598
+ verification,
312
599
  });
313
600
  log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
314
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit };
601
+ r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
602
+ return {
603
+ outcome: 'success',
604
+ exitPath: 'done',
605
+ summary,
606
+ transcriptDir: transcript.dir,
607
+ filesTouched: files,
608
+ commit,
609
+ };
315
610
  }
316
611
  }
317
612
 
318
613
  const filesAfter = await changedFiles(repoRoot, snap.head);
319
- return fail(`turn budget exhausted (${maxTurns} turns, ${filesAfter.length} files touched but unverified)`);
614
+ return fail(
615
+ `turn budget exhausted (${budget} turns, ${filesAfter.length} files touched but unverified)`,
616
+ 'turn-budget-exhausted',
617
+ );
320
618
  }
@@ -21,7 +21,9 @@ const WORKFLOW = `Workflow for every run:
21
21
  4. Run run_erc after schematic edits (and run_drc after board edits). If violations: read them, fix, re-run.
22
22
  5. Run check_drift; update any doc that references a changed value/part/pin in the same run.
23
23
  6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
24
- 7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again.`;
24
+ 7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again.
25
+
26
+ Turns are the scarce resource, not tool calls: the run has a hard turn budget, and every tool call in one reply executes in the same turn. When calls are independent — multiple record_constraint or resolve_affected calls (use resolutions: [...] to clear a backlog in one call), several read_file calls — issue them together in a single reply instead of one per turn.`;
25
27
 
26
28
  export async function buildSystemPrompt(
27
29
  repoRoot: string,
@@ -1,9 +1,12 @@
1
1
  import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
2
2
 
3
- type AnthropicContent =
3
+ type CacheControl = { cache_control?: { type: 'ephemeral' } };
4
+ type AnthropicContent = (
4
5
  | { type: 'text'; text: string }
5
6
  | { type: 'tool_use'; id: string; name: string; input: unknown }
6
- | { type: 'tool_result'; tool_use_id: string; content: string };
7
+ | { type: 'tool_result'; tool_use_id: string; content: string }
8
+ ) &
9
+ CacheControl;
7
10
 
8
11
  export class AnthropicProvider implements Provider {
9
12
  readonly name = 'anthropic';
@@ -15,6 +18,13 @@ export class AnthropicProvider implements Provider {
15
18
  if (!this.apiKey) throw new Error('ANTHROPIC_API_KEY is not set');
16
19
  }
17
20
 
21
+ /**
22
+ * The loop resends the full conversation every turn, which is quadratic in
23
+ * input tokens. Three ephemeral cache_control breakpoints (system prompt,
24
+ * last tool definition, last block of the final message) cache the stable
25
+ * prefix plus the conversation up to the previous turn, cutting repeated
26
+ * input cost by roughly an order of magnitude on multi-turn runs.
27
+ */
18
28
  async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
19
29
  const { default: Anthropic } = await import('@anthropic-ai/sdk');
20
30
  const client = new Anthropic({ apiKey: this.apiKey });
@@ -24,11 +34,11 @@ export class AnthropicProvider implements Provider {
24
34
  .map((m) => m.content)
25
35
  .join('\n\n');
26
36
 
27
- const conv: { role: 'user' | 'assistant'; content: AnthropicContent[] | string }[] = [];
37
+ const conv: { role: 'user' | 'assistant'; content: AnthropicContent[] }[] = [];
28
38
  for (const m of messages) {
29
39
  if (m.role === 'system') continue;
30
40
  if (m.role === 'user') {
31
- conv.push({ role: 'user', content: m.content });
41
+ conv.push({ role: 'user', content: [{ type: 'text', text: m.content }] });
32
42
  } else if (m.role === 'assistant') {
33
43
  const content: AnthropicContent[] = [];
34
44
  if (m.content) content.push({ type: 'text', text: m.content });
@@ -40,7 +50,7 @@ export class AnthropicProvider implements Provider {
40
50
  // tool results are user-role content blocks in the Anthropic API
41
51
  const prev = conv[conv.length - 1];
42
52
  const block: AnthropicContent = { type: 'tool_result', tool_use_id: m.toolCallId, content: m.content };
43
- if (prev && prev.role === 'user' && Array.isArray(prev.content)) {
53
+ if (prev && prev.role === 'user') {
44
54
  prev.content.push(block);
45
55
  } else {
46
56
  conv.push({ role: 'user', content: [block] });
@@ -48,20 +58,23 @@ export class AnthropicProvider implements Provider {
48
58
  }
49
59
  }
50
60
 
61
+ const lastMsg = conv[conv.length - 1];
62
+ const lastBlock = lastMsg?.content[lastMsg.content.length - 1];
63
+ if (lastBlock) lastBlock.cache_control = { type: 'ephemeral' };
64
+
65
+ const toolDefs = tools.map((t, i) => ({
66
+ name: t.name,
67
+ description: t.description,
68
+ input_schema: t.parameters as never,
69
+ ...(i === tools.length - 1 ? { cache_control: { type: 'ephemeral' as const } } : {}),
70
+ }));
71
+
51
72
  const res = await client.messages.create({
52
73
  model: this.model,
53
74
  max_tokens: opts.maxTokens ?? 8192,
54
- ...(system ? { system } : {}),
75
+ ...(system ? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] } : {}),
55
76
  messages: conv as never,
56
- ...(tools.length
57
- ? {
58
- tools: tools.map((t) => ({
59
- name: t.name,
60
- description: t.description,
61
- input_schema: t.parameters as never,
62
- })),
63
- }
64
- : {}),
77
+ ...(tools.length ? { tools: toolDefs as never } : {}),
65
78
  });
66
79
 
67
80
  let text: string | null = null;
@@ -72,10 +85,21 @@ export class AnthropicProvider implements Provider {
72
85
  toolCalls.push({ id: block.id, name: block.name, args: block.input as Record<string, unknown> });
73
86
  }
74
87
  }
88
+ // input_tokens excludes cached tokens; sum them so the run summary stays
89
+ // honest about volume (the discount shows up on the bill, not here).
90
+ const usage = res.usage as {
91
+ input_tokens: number;
92
+ output_tokens: number;
93
+ cache_read_input_tokens?: number | null;
94
+ cache_creation_input_tokens?: number | null;
95
+ };
75
96
  return {
76
97
  text,
77
98
  toolCalls,
78
- usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
99
+ usage: {
100
+ inputTokens: usage.input_tokens + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0),
101
+ outputTokens: usage.output_tokens,
102
+ },
79
103
  };
80
104
  }
81
105
  }