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
package/src/agent/loop.ts CHANGED
@@ -4,16 +4,30 @@ 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 { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
20
+
21
+ /** What the user sees at the moment they decide whether to keep going. */
22
+ export interface BudgetExhaustedStats {
23
+ /** The run's original turn budget, before any extensions. */
24
+ maxTurns: number;
25
+ turnsUsed: number;
26
+ tokensIn: number;
27
+ tokensOut: number;
28
+ filesTouched: string[];
29
+ openObligations: number;
30
+ }
17
31
 
18
32
  export interface RunOptions {
19
33
  repoRoot: string;
@@ -24,13 +38,25 @@ export interface RunOptions {
24
38
  dryRun?: boolean;
25
39
  interactive?: boolean;
26
40
  confirm?: (q: string) => Promise<boolean>;
41
+ /**
42
+ * Called when the turn budget runs out. Returns the number of extra turns to
43
+ * grant (0 fails the run as before). Absent means non-interactive: fail.
44
+ */
45
+ onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
27
46
  /** Extra prompt appended for pipeline stages (Mode A). */
28
47
  stagePrompt?: string;
48
+ /** Test seam: bypass makeProvider. */
49
+ provider?: Provider;
29
50
  log?: (line: string) => void;
51
+ /** Progress renderer; defaults to a plain line renderer over `log`. */
52
+ renderer?: ProgressRenderer;
53
+ /** Caller-known run identity for the metadata block (design D2). */
54
+ meta?: RunMetaInput;
30
55
  }
31
56
 
32
57
  export interface RunResult {
33
58
  outcome: 'success' | 'refused' | 'failure';
59
+ exitPath: ExitPath;
34
60
  summary: string;
35
61
  transcriptDir: string;
36
62
  filesTouched: string[];
@@ -84,18 +110,28 @@ async function appendChangelog(
84
110
  await writeFile(p, lines.join('\n'), 'utf8');
85
111
  }
86
112
 
113
+ /**
114
+ * Owns the Synap session for one run. The bridge is a subprocess, so the
115
+ * shutdown in `finally` is what lets the CLI exit; without it the process
116
+ * hangs after a successful run.
117
+ */
87
118
  export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
88
- const log = opts.log ?? ((l: string) => console.log(l));
119
+ const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
120
+ try {
121
+ return await runWithMemory(opts, memory);
122
+ } finally {
123
+ await memory?.close();
124
+ }
125
+ }
126
+
127
+ async function runWithMemory(opts: RunOptions, memory: SynapMemory | null): Promise<RunResult> {
128
+ const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
129
+ const log = (l: string): void => r.log(l);
89
130
  const repoRoot = opts.repoRoot;
90
131
  const config = await loadConfig(repoRoot);
91
132
  const maxTurns = opts.maxTurns ?? config.maxTurns;
92
133
 
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
- }
134
+ await gitPreflight(repoRoot, { allowDirty: opts.allowDirty ?? false });
99
135
  const snap = await snapshot(repoRoot);
100
136
 
101
137
  const transcript = new Transcript(repoRoot);
@@ -119,23 +155,126 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
119
155
  finishRequest: null,
120
156
  };
121
157
 
122
- let provider = makeProvider(opts.model);
158
+ let provider = opts.provider ?? makeProvider(opts.model);
159
+
160
+ // Deterministic, LLM-free metadata block: collected once, rendered onto all
161
+ // three surfaces (run-start event, summary ## Environment, CLI header) so
162
+ // they can never disagree (design D1, AC-8.1/8.4).
163
+ const startMs = Date.now();
164
+ const meta: RunMeta = await collectRunMeta({
165
+ repoRoot,
166
+ config,
167
+ maxTurns,
168
+ runId: path.basename(transcript.dir),
169
+ request: opts.request,
170
+ model: opts.model,
171
+ provider: provider.name,
172
+ interactive: opts.interactive ?? false,
173
+ input: opts.meta,
174
+ });
175
+ for (const line of renderCliHeader(meta)) log(line);
176
+ // Revisit obligations deferred while their artifact didn't exist re-open now
177
+ // if it does (must run before loadConstraints so the prompt sees the updated
178
+ // registry). They land in this run's fresh ledger, so finish gates on them.
179
+ const reopened = await reopenDeferredAffects(repoRoot, config, (key, item) =>
180
+ ctx.ledger.add('affects-revisit', `${key} affects ${item}`, key),
181
+ );
182
+ if (reopened.length) {
183
+ await transcript.event('deferred-affects-reopened', { reopened });
184
+ log(`re-opened ${reopened.length} deferred constraint revisit obligation(s)`);
185
+ }
123
186
  const constraints = await loadConstraints(repoRoot);
124
- const system = await buildSystemPrompt(repoRoot, config, constraints);
187
+ let basePrompt = await buildSystemPrompt(repoRoot, config, constraints);
188
+ if (reopened.length) {
189
+ basePrompt += [
190
+ '',
191
+ '',
192
+ '## Reopened constraint revisits',
193
+ '',
194
+ 'These constraints were recorded before their target artifact existed; the artifact now exists.',
195
+ 'Revisit each against the design and close it with resolve_affected (batch the calls):',
196
+ ...reopened.map((r) => `- ${r.key} affects ${r.item}`),
197
+ ].join('\n');
198
+ }
199
+ // Cross-run memory is appended after the repo's own docs and constraints so
200
+ // that the in-repo sources of truth are what the model reads first.
201
+ const recalled = memory ? await memory.recall(opts.request) : null;
202
+ if (recalled) {
203
+ await transcript.event('synap-recall', { chars: recalled.length });
204
+ log('recalled prior context from Synap memory');
205
+ }
206
+ const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
125
207
  const messages: Msg[] = [
126
208
  { role: 'system', content: system },
127
209
  { role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
128
210
  ];
129
- await transcript.event('run-start', { request: opts.request, model: opts.model, provider: provider.name });
211
+ await transcript.event('run-start', meta);
212
+
213
+ /**
214
+ * A memory write that fails is reported rather than swallowed, but it does
215
+ * not change the run's outcome: discarding a verified commit because a
216
+ * third-party write failed would be the worse trade.
217
+ */
218
+ const remember = async (run: RunRecord): Promise<void> => {
219
+ if (!memory) return;
220
+ try {
221
+ await memory.record(run);
222
+ await transcript.event('synap-record', { outcome: run.outcome });
223
+ } catch (err) {
224
+ const message = (err as Error).message;
225
+ log(`warning: Synap memory write failed (${message}); this run was not recorded`);
226
+ await transcript.event('synap-record-failed', { error: message });
227
+ }
228
+ };
130
229
 
131
230
  let tokensIn = 0;
132
231
  let tokensOut = 0;
232
+ let turnsUsed = 0;
233
+ const perTurn: { turn: number; in: number; out: number }[] = [];
133
234
  let plan: string | null = null;
134
235
  let nudges = 0;
135
236
 
136
- const fail = async (reason: string): Promise<RunResult> => {
137
- await transcript.event('run-failed', { reason });
138
- await restore(repoRoot, snap);
237
+ const stats = (exitPath: ExitPath): RunStats => ({
238
+ exitPath,
239
+ turnsUsed,
240
+ maxTurns,
241
+ repairCyclesUsed: ctx.repairCycles,
242
+ maxRepairCycles: config.maxRepairCycles,
243
+ tokensIn,
244
+ tokensOut,
245
+ perTurn,
246
+ durationMs: Date.now() - startMs,
247
+ });
248
+
249
+ /** One outcome line, printed last at every terminal branch (AC-8.5). */
250
+ const outcomeLine = (s: RunStats, extra?: string | null): string =>
251
+ [
252
+ s.exitPath,
253
+ ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'failing'}` : 'ERC not run',
254
+ ...(ctx.lastDrc ? [`DRC ${ctx.lastDrc.ok ? 'clean' : 'failing'}`] : []),
255
+ ...(extra ? [extra] : []),
256
+ fmtDuration(s.durationMs),
257
+ `${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
258
+ ].join(' · ');
259
+
260
+ const fail = async (reason: string, exitPath: ExitPath): Promise<RunResult> => {
261
+ await transcript.event('run-failed', { reason, exitPath });
262
+ // Preserve the touched work as a stash entry before the rollback destroys
263
+ // it, so a budget-exhaustion (or any) failure is recoverable (issue #15).
264
+ const preserved = await preserveFailedRun(repoRoot, ctx.runId);
265
+ if (preserved) await transcript.event('work-preserved', { stash: preserved });
266
+ // The rollback itself can fail (git in a bad state). That must not become
267
+ // an unhandled throw that skips run-end and summary.md — the summary is
268
+ // most valuable exactly when the tree is left in an unknown state.
269
+ let restoreError: string | null = null;
270
+ try {
271
+ await restore(repoRoot, snap);
272
+ } catch (err) {
273
+ restoreError = (err as Error).message;
274
+ await transcript.event('restore-failed', { error: restoreError });
275
+ }
276
+ const runStats = stats(exitPath);
277
+ await transcript.event('run-end', runStats);
139
278
  const summaryPath = await transcript.writeSummary({
140
279
  request: opts.request,
141
280
  changeId: ctx.changeId,
@@ -148,14 +287,27 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
148
287
  tokensOut,
149
288
  outcome: 'failure',
150
289
  openObligations: ctx.ledger.isClear ? null : ctx.ledger.describe(),
151
- detail: reason,
290
+ 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,
291
+ env: meta,
292
+ stats: runStats,
152
293
  });
153
294
  log(`run failed: ${reason}`);
154
- log(`working tree restored to pre-run snapshot`);
295
+ if (restoreError) {
296
+ log(`WARNING: rollback failed (${restoreError}); the working tree may be in a partial state`);
297
+ } else {
298
+ log(`working tree restored to pre-run snapshot`);
299
+ }
300
+ if (preserved) {
301
+ log(
302
+ `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\``,
303
+ );
304
+ }
155
305
  log(`transcript: ${transcript.jsonlPath}`);
156
306
  log(`summary: ${summaryPath}`);
307
+ r.finish(outcomeLine(runStats));
157
308
  return {
158
309
  outcome: 'failure',
310
+ exitPath,
159
311
  summary: reason,
160
312
  transcriptDir: transcript.dir,
161
313
  filesTouched: [],
@@ -163,8 +315,38 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
163
315
  };
164
316
  };
165
317
 
166
- for (let turn = 0; turn < maxTurns; turn++) {
318
+ let budget = maxTurns;
319
+ for (let turn = 0; ; turn++) {
320
+ if (turn >= budget) {
321
+ // Budget exhausted. In an attended run this is a user decision made with
322
+ // the cost visible, not an unconditional rollback (issue #15).
323
+ const exhaustStats: BudgetExhaustedStats = {
324
+ maxTurns,
325
+ turnsUsed: turn,
326
+ tokensIn,
327
+ tokensOut,
328
+ filesTouched: [...ctx.filesTouched],
329
+ openObligations: ctx.ledger.openObligations.length,
330
+ };
331
+ let extra = 0;
332
+ if (opts.onBudgetExhausted) {
333
+ try {
334
+ extra = Math.floor(await opts.onBudgetExhausted(exhaustStats));
335
+ } catch {
336
+ // A broken prompt (stdin closed mid-question, dying terminal) must
337
+ // read as "declined" and take the preserve-and-restore path below,
338
+ // not propagate past it and skip the rollback entirely.
339
+ extra = 0;
340
+ }
341
+ }
342
+ if (!Number.isFinite(extra) || extra <= 0) break;
343
+ budget += extra;
344
+ await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
345
+ log(`turn budget extended by ${extra} (now ${budget})`);
346
+ }
167
347
  const tools = availableTools(ctx).map((t) => t.schema);
348
+ r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
349
+ r.status('thinking');
168
350
  let res: Turn;
169
351
  try {
170
352
  res = await withRetry(() => provider.chat(messages, tools), {
@@ -181,10 +363,14 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
181
363
  continue;
182
364
  }
183
365
  }
184
- return fail(`provider error: ${(err as Error).message}`);
366
+ return fail(`provider error: ${(err as Error).message}`, 'provider-error');
367
+ } finally {
368
+ r.status(null);
185
369
  }
370
+ turnsUsed = turn + 1;
186
371
  tokensIn += res.usage.inputTokens;
187
372
  tokensOut += res.usage.outputTokens;
373
+ perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
188
374
  await transcript.event('assistant', { text: res.text, toolCalls: res.toolCalls });
189
375
 
190
376
  if (res.text) {
@@ -194,31 +380,36 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
194
380
  messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
195
381
 
196
382
  if (!res.toolCalls.length) {
197
- if (nudges++ >= 2) return fail('model stopped calling tools without finishing');
383
+ // Only *consecutive* tool-less turns are a stall. Providers emit the
384
+ // occasional empty completion mid-run (observed live: three empties
385
+ // spread across 31 productive turns); a cumulative counter turns those
386
+ // into a full rollback of an otherwise-converging run.
387
+ if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
198
388
  messages.push({
199
389
  role: 'user',
200
390
  content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
201
391
  });
202
392
  continue;
203
393
  }
394
+ nudges = 0;
204
395
 
205
396
  for (const call of res.toolCalls) {
206
397
  const result = await dispatchTool(ctx, call.name, call.args);
207
398
  await transcript.event('tool', { name: call.name, args: call.args, result });
208
- log(` [${call.name}] ${result.split('\n')[0]}`);
399
+ r.toolResult(call.name, result.split('\n')[0] ?? '');
209
400
  messages.push({ role: 'tool', toolCallId: call.id, content: result });
210
401
  }
211
402
 
212
403
  if (ctx.repairCycles > config.maxRepairCycles) {
213
- return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`);
404
+ return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`, 'repair-cycles-exhausted');
214
405
  }
215
406
 
216
- const remaining = maxTurns - turn - 1;
407
+ const remaining = budget - turn - 1;
217
408
  if (remaining === 5 && !ctx.finishRequest) {
218
409
  messages.push({
219
410
  role: 'user',
220
411
  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.',
412
+ '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
413
  });
223
414
  }
224
415
 
@@ -228,6 +419,8 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
228
419
  if (outcome === 'refuse') {
229
420
  await restore(repoRoot, snap);
230
421
  await transcript.event('run-refused', { summary });
422
+ const runStats = stats('refused');
423
+ await transcript.event('run-end', runStats);
231
424
  await transcript.writeSummary({
232
425
  request: opts.request,
233
426
  changeId: ctx.changeId,
@@ -241,9 +434,30 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
241
434
  outcome: 'aborted',
242
435
  openObligations: null,
243
436
  detail: `REFUSED: ${summary}`,
437
+ env: meta,
438
+ stats: runStats,
439
+ });
440
+ // Refusals are the most valuable thing to remember: they encode a budget
441
+ // or constraint that this user's designs keep running into.
442
+ await remember({
443
+ request: opts.request,
444
+ outcome: 'refused',
445
+ summary,
446
+ changeId: ctx.changeId,
447
+ filesTouched: [],
448
+ decisions: ctx.decisions,
449
+ verification: 'n/a (refused before verification)',
244
450
  });
245
451
  log(`refused: ${summary}`);
246
- return { outcome: 'refused', summary, transcriptDir: transcript.dir, filesTouched: [], commit: null };
452
+ r.finish(outcomeLine(runStats));
453
+ return {
454
+ outcome: 'refused',
455
+ exitPath: 'refused',
456
+ summary,
457
+ transcriptDir: transcript.dir,
458
+ filesTouched: [],
459
+ commit: null,
460
+ };
247
461
  }
248
462
 
249
463
  const verification = [
@@ -262,6 +476,8 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
262
476
  log(diff || '(no diff)');
263
477
  if (untracked) log(`new files:\n${untracked}`);
264
478
  await restore(repoRoot, snap);
479
+ const runStats = stats('done');
480
+ await transcript.event('run-end', runStats);
265
481
  await transcript.writeSummary({
266
482
  request: opts.request,
267
483
  changeId: ctx.changeId,
@@ -275,8 +491,18 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
275
491
  outcome: 'success',
276
492
  openObligations: null,
277
493
  detail: 'dry run: changes reverted',
494
+ env: meta,
495
+ stats: runStats,
278
496
  });
279
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit: null };
497
+ r.finish(outcomeLine(runStats, 'dry run: changes reverted'));
498
+ return {
499
+ outcome: 'success',
500
+ exitPath: 'done',
501
+ summary,
502
+ transcriptDir: transcript.dir,
503
+ filesTouched: files,
504
+ commit: null,
505
+ };
280
506
  }
281
507
 
282
508
  await appendChangelog(repoRoot, config, {
@@ -288,15 +514,33 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
288
514
  ctx.ledger.clear('changelog');
289
515
 
290
516
  const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
291
- const commit = await commitAll(repoRoot, commitMsg);
517
+ // A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
518
+ // must land in summary.md as an outcome, not escape as a stack trace
519
+ // (AC-8.6): roll back per the snapshot contract and report commit-failed.
520
+ let commit: string;
521
+ try {
522
+ commit = await commitAll(repoRoot, commitMsg);
523
+ } catch (err) {
524
+ return fail(`commit failed: ${(err as Error).message}`, 'commit-failed');
525
+ }
292
526
  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}`);
527
+ // The verified commit already exists; discarding it because archive
528
+ // housekeeping failed would be the worse trade, so this is a warning.
529
+ try {
530
+ const arch = await openspecArchive(repoRoot, ctx.changeId);
531
+ await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
532
+ if (arch.ok && (await isDirty(repoRoot))) {
533
+ await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
534
+ }
535
+ } catch (err) {
536
+ const message = (err as Error).message;
537
+ log(`warning: openspec archive failed (${message}); the run commit itself succeeded`);
538
+ await transcript.event('openspec-archive-failed', { changeId: ctx.changeId, error: message });
297
539
  }
298
540
  }
299
541
  await transcript.event('run-committed', { commit, files });
542
+ const runStats = stats('done');
543
+ await transcript.event('run-end', runStats);
300
544
  await transcript.writeSummary({
301
545
  request: opts.request,
302
546
  changeId: ctx.changeId,
@@ -309,12 +553,34 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
309
553
  tokensOut,
310
554
  outcome: 'success',
311
555
  openObligations: null,
556
+ env: meta,
557
+ stats: runStats,
558
+ });
559
+ await remember({
560
+ request: opts.request,
561
+ outcome: 'success',
562
+ summary,
563
+ changeId: ctx.changeId,
564
+ filesTouched: files,
565
+ decisions: ctx.decisions,
566
+ verification,
312
567
  });
313
568
  log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
314
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit };
569
+ r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
570
+ return {
571
+ outcome: 'success',
572
+ exitPath: 'done',
573
+ summary,
574
+ transcriptDir: transcript.dir,
575
+ filesTouched: files,
576
+ commit,
577
+ };
315
578
  }
316
579
  }
317
580
 
318
581
  const filesAfter = await changedFiles(repoRoot, snap.head);
319
- return fail(`turn budget exhausted (${maxTurns} turns, ${filesAfter.length} files touched but unverified)`);
582
+ return fail(
583
+ `turn budget exhausted (${budget} turns, ${filesAfter.length} files touched but unverified)`,
584
+ 'turn-budget-exhausted',
585
+ );
320
586
  }
@@ -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
  }