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/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ copperhead
2
+ Copyright 2026 Chouhan Industries
3
+
4
+ This product includes software developed at Chouhan Industries
5
+ (https://github.com/chouhanindustries/copperhead).
package/README.md CHANGED
@@ -1,8 +1,18 @@
1
- # copperhead
1
+ <p align="center">
2
+ <a href="https://copperhead.sh"><img src="https://raw.githubusercontent.com/chouhanindustries/copperhead/main/docs/branding/lockup-transparent.png" alt="copperhead" width="440"></a>
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/copperhead"><img src="https://img.shields.io/npm/v/copperhead?color=b87333" alt="npm"></a>
7
+ <a href="https://github.com/chouhanindustries/copperhead/actions/workflows/ci.yml"><img src="https://github.com/chouhanindustries/copperhead/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/copperhead?color=15181c" alt="license"></a>
9
+ </p>
2
10
 
3
11
  **Cursor for circuit boards.** An AI agent that designs, documents, and validates real PCBs from a prompt, working directly on existing KiCad repositories.
4
12
 
5
- > **Status: early (v0.1).** Phase 1 is implemented and the CLI runs. The [technical specification](openspec/specs/SPEC.md) is the source of truth; expect the surface to move before 1.0.
13
+ > **Status: early.** Phase 1 is implemented and the CLI runs. The [technical specification](openspec/specs/SPEC.md) is the source of truth; expect the surface to move before 1.0.
14
+
15
+ Full documentation lives at [docs.copperhead.sh](https://docs.copperhead.sh).
6
16
 
7
17
  ## What it is
8
18
 
@@ -13,6 +23,31 @@ An AI product-development agent for hardware: from a product brief to manufactur
13
23
 
14
24
  It reads and edits real `.kicad_sch` / `.kicad_pcb` files (s-expression text), maintains markdown design docs as memory, propagates every change across all artifacts that reference it, and verifies its own work by running `kicad-cli` ERC/DRC until the checks pass.
15
25
 
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install -g copperhead # or: npx copperhead check
30
+ ```
31
+
32
+ ### Requirements
33
+
34
+ - Node.js ≥ 20
35
+ - [KiCad](https://www.kicad.org/) ≥ 8 with `kicad-cli` on PATH
36
+ - `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the environment (env var only, never a config file), except for `check`, which never calls an LLM
37
+
38
+ ## Quick start
39
+
40
+ In an existing KiCad repository:
41
+
42
+ ```bash
43
+ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY
44
+ copperhead init # scaffold docs/ from the schematic; idempotent
45
+ copperhead do "add reverse-polarity protection on VIN"
46
+ copperhead check # ERC + DRC + doc drift; no LLM, CI-safe
47
+ ```
48
+
49
+ Starting from nothing instead? Write a product brief and run `copperhead create --brief brief.md`. The [examples/](examples/) directory has ready-made briefs sorted by difficulty, plus a note on which one is designed to fail.
50
+
16
51
  ## How it works
17
52
 
18
53
  It's a loop, and it looks a lot like pair-programming, except the codebase is a circuit board.
@@ -51,21 +86,28 @@ Nothing is a black box: decisions land in an append-only `docs/DECISIONS.md`, ev
51
86
  - **Not a new editor.** No walled garden; your KiCad install remains the editor.
52
87
  - **Not the engineer of record.** A human signs off; the agent never claims a design is fab-ready beyond "ERC/DRC clean".
53
88
 
54
- ## Install
89
+ ## Simple demo
90
+
91
+ From a checkout of this repo, run the smallest create-pipeline demo with the USB-C power breakout brief:
55
92
 
56
93
  ```bash
57
- npm install -g copperhead # or: npx copperhead check
94
+ npm run demo:simple
58
95
  ```
59
96
 
60
- ### Requirements
97
+ The script creates or resumes a git repo at `demo-runs/usb-c-breakout/` and runs:
61
98
 
62
- - Node.js ≥ 20
63
- - [KiCad](https://www.kicad.org/) 8 with `kicad-cli` on PATH
64
- - An OpenAI or Anthropic API key (env var only), except for `check`, which never calls an LLM
99
+ ```bash
100
+ copperhead create --brief examples/simple/usb-c-breakout.md
101
+ ```
102
+
103
+ Pass normal create flags after `--`, for example `npm run demo:simple -- --model claude`.
104
+ If a stage fails, rerun the same command; the demo repo keeps a baseline commit and ignores run transcripts so retries start from the last committed design state.
105
+
106
+ More briefs, including medium and hard tiers, live in [examples/](examples/).
65
107
 
66
108
  ## Maturity
67
109
 
68
- Honest read of where v0.1 stands, so you can calibrate before pointing this at a board you care about:
110
+ Honest read of where the current release stands, so you can calibrate before pointing this at a board you care about:
69
111
 
70
112
  - **Solid.** `init` and `check`/`verify` are deterministic, LLM-free, and covered by the offline test suite against a real KiCad fixture: scaffolding, ERC/DRC, the s-expression reader, drift detection, and fab export all run green in CI.
71
113
  - **Implemented, not yet proven.** The agent loop (`do`, `sync --resolve`, `create`) is complete and structurally gated, but its acceptance tests need a live model and have not been observed passing end to end. Expect rough edges.
@@ -75,6 +117,10 @@ Honest read of where v0.1 stands, so you can calibrate before pointing this at a
75
117
 
76
118
  The entire tool (agent core, prompts, tools) is public under Apache-2.0, built on an open stack (KiCad, kicad-cli, [OpenSpec](https://github.com/Fission-AI/OpenSpec)). Everything it produces is plain markdown and JSON in your own repo: no proprietary formats, no lock-in. This is a Chouhan Industries project, and the same commitment that puts every hardware schematic in public applies to the tool that designs them.
77
119
 
120
+ ## Contributing
121
+
122
+ Contributions are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) for setup and workflow. Note that your first pull request requires signing the [Contributor License Agreement](.github/cla/CLA.md); a bot posts instructions on the PR and signing is a one-time comment.
123
+
78
124
  ## Project layout
79
125
 
80
126
  - [`src/`](src/): CLI ([`cli.ts`](src/cli.ts), [`commands/`](src/commands/)), the provider-agnostic agent loop ([`agent/`](src/agent/)), the `kicad-cli` wrapper and s-expression reader ([`kicad/`](src/kicad/)), and doc/constraint memory ([`memory/`](src/memory/))
@@ -5,8 +5,15 @@ export class ObligationsLedger {
5
5
  this.open.push({ kind, detail, openedBy });
6
6
  }
7
7
  }
8
+ /** Returns true if at least one obligation was actually removed. */
8
9
  clear(kind, detail) {
10
+ const before = this.open.length;
9
11
  this.open = this.open.filter((o) => !(o.kind === kind && (detail === undefined || o.detail === detail)));
12
+ return this.open.length < before;
13
+ }
14
+ /** Open obligations of one kind, for building corrective tool errors. */
15
+ openOfKind(kind) {
16
+ return this.open.filter((o) => o.kind === kind);
10
17
  }
11
18
  /** A KiCad edit re-opens verification obligations even if previously cleared. */
12
19
  onKicadEdit(file) {
@@ -1 +1 @@
1
- {"version":3,"file":"ledger.js","sourceRoot":"","sources":["../../src/agent/ledger.ts"],"names":[],"mappings":"AAoBA,MAAM,OAAO,iBAAiB;IACpB,IAAI,GAAiB,EAAE,CAAC;IAEhC,GAAG,CAAC,IAAoB,EAAE,MAAc,EAAE,QAAgB;QACxD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,EAAE,CAAC;YACnE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC;IAED,KAAK,CAAC,IAAoB,EAAE,MAAe;QACzC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAC1B,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAC3E,CAAC;IACJ,CAAC;IAED,iFAAiF;IACjF,WAAW,CAAC,IAAY;QACtB,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,qCAAqC,EAAE,IAAI,CAAC,CAAC;QAC7D,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,iCAAiC,EAAE,IAAI,CAAC,CAAC;QAC1F,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,8CAA8C,EAAE,IAAI,CAAC,CAAC;QACxE,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,iCAAiC,EAAE,IAAI,CAAC,CAAC;IACjE,CAAC;IAED,SAAS,CAAC,IAAY;QACpB,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,4CAA4C,EAAE,IAAI,CAAC,CAAC;IACxE,CAAC;IAED,kBAAkB,CAAC,aAAqB,EAAE,OAAiB;QACzD,IAAI,CAAC,GAAG,CAAC,uBAAuB,EAAE,aAAa,EAAE,aAAa,CAAC,CAAC;QAChE,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;YAC3B,IAAI,CAAC,GAAG,CAAC,iBAAiB,EAAE,GAAG,aAAa,YAAY,IAAI,EAAE,EAAE,aAAa,CAAC,CAAC;QACjF,CAAC;IACH,CAAC;IAED,UAAU,CAAC,OAAe;QACxB,IAAI,CAAC,GAAG,CAAC,cAAc,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC;IAChD,CAAC;IAED,IAAI,eAAe;QACjB,OAAO,IAAI,CAAC,IAAI,CAAC;IACnB,CAAC;IAED,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC;IAChC,CAAC;IAED,QAAQ;QACN,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO,gCAAgC,CAAC;QAC1D,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,MAAM,eAAe,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChG,CAAC;CACF"}
1
+ {"version":3,"file":"ledger.js","sourceRoot":"","sources":["../../src/agent/ledger.ts"],"names":[],"mappings":"AAoBA,MAAM,OAAO,iBAAiB;IACpB,IAAI,GAAiB,EAAE,CAAC;IAEhC,GAAG,CAAC,IAAoB,EAAE,MAAc,EAAE,QAAgB;QACxD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,EAAE,CAAC;YACnE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC;IAED,oEAAoE;IACpE,KAAK,CAAC,IAAoB,EAAE,MAAe;QACzC,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;QAChC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAC1B,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAC3E,CAAC;QACF,OAAO,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACnC,CAAC;IAED,yEAAyE;IACzE,UAAU,CAAC,IAAoB;QAC7B,OAAO,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;IAClD,CAAC;IAED,iFAAiF;IACjF,WAAW,CAAC,IAAY;QACtB,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,qCAAqC,EAAE,IAAI,CAAC,CAAC;QAC7D,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,iCAAiC,EAAE,IAAI,CAAC,CAAC;QAC1F,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,8CAA8C,EAAE,IAAI,CAAC,CAAC;QACxE,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,iCAAiC,EAAE,IAAI,CAAC,CAAC;IACjE,CAAC;IAED,SAAS,CAAC,IAAY;QACpB,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,4CAA4C,EAAE,IAAI,CAAC,CAAC;IACxE,CAAC;IAED,kBAAkB,CAAC,aAAqB,EAAE,OAAiB;QACzD,IAAI,CAAC,GAAG,CAAC,uBAAuB,EAAE,aAAa,EAAE,aAAa,CAAC,CAAC;QAChE,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;YAC3B,IAAI,CAAC,GAAG,CAAC,iBAAiB,EAAE,GAAG,aAAa,YAAY,IAAI,EAAE,EAAE,aAAa,CAAC,CAAC;QACjF,CAAC;IACH,CAAC;IAED,UAAU,CAAC,OAAe;QACxB,IAAI,CAAC,GAAG,CAAC,cAAc,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC;IAChD,CAAC;IAED,IAAI,eAAe;QACjB,OAAO,IAAI,CAAC,IAAI,CAAC;IACnB,CAAC;IAED,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC;IAChC,CAAC;IAED,QAAQ;QACN,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO,gCAAgC,CAAC;QAC1D,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,MAAM,eAAe,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChG,CAAC;CACF"}
@@ -3,16 +3,19 @@ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { execa } from 'execa';
4
4
  import { availableTools, dispatchTool } from './tools.js';
5
5
  import { buildSystemPrompt } from './prompts.js';
6
- import { loadConstraints } from '../memory/constraints.js';
6
+ import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
7
7
  import { loadConfig } from '../config.js';
8
8
  import { Transcript } from './transcript.js';
9
+ import { collectRunMeta, renderCliHeader } from './runmeta.js';
10
+ import { plainRenderer, fmtDuration, fmtTokens } from './render.js';
9
11
  import { ObligationsLedger } from './ledger.js';
10
- import { isDirty, isGitRepo, snapshot, restore, commitAll, changedFiles } from '../util/git.js';
12
+ import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
11
13
  import { withRetry, isRateLimit } from '../util/retry.js';
12
14
  import { openspecArchive } from '../openspec/cli.js';
13
15
  import { existsSync } from 'node:fs';
14
16
  import { OpenAIProvider } from './providers/openai.js';
15
17
  import { AnthropicProvider } from './providers/anthropic.js';
18
+ import { openSynapMemory } from '../memory/synap.js';
16
19
  export function makeProvider(model) {
17
20
  if (model === 'claude' || model.startsWith('claude')) {
18
21
  return new AnthropicProvider(model === 'claude' ? undefined : model);
@@ -56,17 +59,27 @@ async function appendChangelog(repoRoot, config, entry) {
56
59
  lines.splice(insertAt, 0, ...block.split('\n').slice(1), '');
57
60
  await writeFile(p, lines.join('\n'), 'utf8');
58
61
  }
62
+ /**
63
+ * Owns the Synap session for one run. The bridge is a subprocess, so the
64
+ * shutdown in `finally` is what lets the CLI exit; without it the process
65
+ * hangs after a successful run.
66
+ */
59
67
  export async function runAgentLoop(opts) {
60
- const log = opts.log ?? ((l) => console.log(l));
68
+ const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
69
+ try {
70
+ return await runWithMemory(opts, memory);
71
+ }
72
+ finally {
73
+ await memory?.close();
74
+ }
75
+ }
76
+ async function runWithMemory(opts, memory) {
77
+ const r = opts.renderer ?? plainRenderer(opts.log ?? ((l) => console.log(l)));
78
+ const log = (l) => r.log(l);
61
79
  const repoRoot = opts.repoRoot;
62
80
  const config = await loadConfig(repoRoot);
63
81
  const maxTurns = opts.maxTurns ?? config.maxTurns;
64
- if (!(await isGitRepo(repoRoot))) {
65
- throw new Error('not a git repository; copperhead requires git for snapshots and rollback');
66
- }
67
- if ((await isDirty(repoRoot)) && !opts.allowDirty) {
68
- throw new Error('working tree is dirty; commit your changes or pass --allow-dirty (snapshots via git stash create)');
69
- }
82
+ await gitPreflight(repoRoot, { allowDirty: opts.allowDirty ?? false });
70
83
  const snap = await snapshot(repoRoot);
71
84
  const transcript = new Transcript(repoRoot);
72
85
  await transcript.init();
@@ -88,21 +101,122 @@ export async function runAgentLoop(opts) {
88
101
  repairCycles: 0,
89
102
  finishRequest: null,
90
103
  };
91
- let provider = makeProvider(opts.model);
104
+ let provider = opts.provider ?? makeProvider(opts.model);
105
+ // Deterministic, LLM-free metadata block: collected once, rendered onto all
106
+ // three surfaces (run-start event, summary ## Environment, CLI header) so
107
+ // they can never disagree (design D1, AC-8.1/8.4).
108
+ const startMs = Date.now();
109
+ const meta = await collectRunMeta({
110
+ repoRoot,
111
+ config,
112
+ maxTurns,
113
+ runId: path.basename(transcript.dir),
114
+ request: opts.request,
115
+ model: opts.model,
116
+ provider: provider.name,
117
+ interactive: opts.interactive ?? false,
118
+ input: opts.meta,
119
+ });
120
+ for (const line of renderCliHeader(meta))
121
+ log(line);
122
+ // Revisit obligations deferred while their artifact didn't exist re-open now
123
+ // if it does (must run before loadConstraints so the prompt sees the updated
124
+ // registry). They land in this run's fresh ledger, so finish gates on them.
125
+ const reopened = await reopenDeferredAffects(repoRoot, config, (key, item) => ctx.ledger.add('affects-revisit', `${key} affects ${item}`, key));
126
+ if (reopened.length) {
127
+ await transcript.event('deferred-affects-reopened', { reopened });
128
+ log(`re-opened ${reopened.length} deferred constraint revisit obligation(s)`);
129
+ }
92
130
  const constraints = await loadConstraints(repoRoot);
93
- const system = await buildSystemPrompt(repoRoot, config, constraints);
131
+ let basePrompt = await buildSystemPrompt(repoRoot, config, constraints);
132
+ if (reopened.length) {
133
+ basePrompt += [
134
+ '',
135
+ '',
136
+ '## Reopened constraint revisits',
137
+ '',
138
+ 'These constraints were recorded before their target artifact existed; the artifact now exists.',
139
+ 'Revisit each against the design and close it with resolve_affected (batch the calls):',
140
+ ...reopened.map((r) => `- ${r.key} affects ${r.item}`),
141
+ ].join('\n');
142
+ }
143
+ // Cross-run memory is appended after the repo's own docs and constraints so
144
+ // that the in-repo sources of truth are what the model reads first.
145
+ const recalled = memory ? await memory.recall(opts.request) : null;
146
+ if (recalled) {
147
+ await transcript.event('synap-recall', { chars: recalled.length });
148
+ log('recalled prior context from Synap memory');
149
+ }
150
+ const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
94
151
  const messages = [
95
152
  { role: 'system', content: system },
96
153
  { role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
97
154
  ];
98
- await transcript.event('run-start', { request: opts.request, model: opts.model, provider: provider.name });
155
+ await transcript.event('run-start', meta);
156
+ /**
157
+ * A memory write that fails is reported rather than swallowed, but it does
158
+ * not change the run's outcome: discarding a verified commit because a
159
+ * third-party write failed would be the worse trade.
160
+ */
161
+ const remember = async (run) => {
162
+ if (!memory)
163
+ return;
164
+ try {
165
+ await memory.record(run);
166
+ await transcript.event('synap-record', { outcome: run.outcome });
167
+ }
168
+ catch (err) {
169
+ const message = err.message;
170
+ log(`warning: Synap memory write failed (${message}); this run was not recorded`);
171
+ await transcript.event('synap-record-failed', { error: message });
172
+ }
173
+ };
99
174
  let tokensIn = 0;
100
175
  let tokensOut = 0;
176
+ let turnsUsed = 0;
177
+ const perTurn = [];
101
178
  let plan = null;
102
179
  let nudges = 0;
103
- const fail = async (reason) => {
104
- await transcript.event('run-failed', { reason });
105
- await restore(repoRoot, snap);
180
+ const stats = (exitPath) => ({
181
+ exitPath,
182
+ turnsUsed,
183
+ maxTurns,
184
+ repairCyclesUsed: ctx.repairCycles,
185
+ maxRepairCycles: config.maxRepairCycles,
186
+ tokensIn,
187
+ tokensOut,
188
+ perTurn,
189
+ durationMs: Date.now() - startMs,
190
+ });
191
+ /** One outcome line, printed last at every terminal branch (AC-8.5). */
192
+ const outcomeLine = (s, extra) => [
193
+ s.exitPath,
194
+ ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'failing'}` : 'ERC not run',
195
+ ...(ctx.lastDrc ? [`DRC ${ctx.lastDrc.ok ? 'clean' : 'failing'}`] : []),
196
+ ...(extra ? [extra] : []),
197
+ fmtDuration(s.durationMs),
198
+ `${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
199
+ ].join(' · ');
200
+ const fail = async (reason, exitPath) => {
201
+ await transcript.event('run-failed', { reason, exitPath });
202
+ // Preserve the touched work as a stash entry before the rollback destroys
203
+ // it, so a budget-exhaustion (or any) failure is recoverable (issue #15).
204
+ const preserved = await preserveFailedRun(repoRoot, ctx.runId);
205
+ if (preserved)
206
+ await transcript.event('work-preserved', { stash: preserved });
207
+ // The rollback itself can fail (git in a bad state). That must not become
208
+ // an unhandled throw that skips run-end and summary.md — the summary is
209
+ // most valuable exactly when the tree is left in an unknown state.
210
+ let restoreError = null;
211
+ try {
212
+ await restore(repoRoot, snap);
213
+ }
214
+ catch (err) {
215
+ restoreError = err.message;
216
+ await transcript.event('restore-failed', { error: restoreError });
217
+ }
218
+ const runStats = stats(exitPath);
219
+ await transcript.event('run-end', runStats);
106
220
  const summaryPath = await transcript.writeSummary({
107
221
  request: opts.request,
108
222
  changeId: ctx.changeId,
@@ -115,22 +229,66 @@ export async function runAgentLoop(opts) {
115
229
  tokensOut,
116
230
  outcome: 'failure',
117
231
  openObligations: ctx.ledger.isClear ? null : ctx.ledger.describe(),
118
- detail: reason,
232
+ 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,
233
+ env: meta,
234
+ stats: runStats,
119
235
  });
120
236
  log(`run failed: ${reason}`);
121
- log(`working tree restored to pre-run snapshot`);
237
+ if (restoreError) {
238
+ log(`WARNING: rollback failed (${restoreError}); the working tree may be in a partial state`);
239
+ }
240
+ else {
241
+ log(`working tree restored to pre-run snapshot`);
242
+ }
243
+ if (preserved) {
244
+ log(`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\``);
245
+ }
122
246
  log(`transcript: ${transcript.jsonlPath}`);
123
247
  log(`summary: ${summaryPath}`);
248
+ r.finish(outcomeLine(runStats));
124
249
  return {
125
250
  outcome: 'failure',
251
+ exitPath,
126
252
  summary: reason,
127
253
  transcriptDir: transcript.dir,
128
254
  filesTouched: [],
129
255
  commit: null,
130
256
  };
131
257
  };
132
- for (let turn = 0; turn < maxTurns; turn++) {
258
+ let budget = maxTurns;
259
+ for (let turn = 0;; turn++) {
260
+ if (turn >= budget) {
261
+ // Budget exhausted. In an attended run this is a user decision made with
262
+ // the cost visible, not an unconditional rollback (issue #15).
263
+ const exhaustStats = {
264
+ maxTurns,
265
+ turnsUsed: turn,
266
+ tokensIn,
267
+ tokensOut,
268
+ filesTouched: [...ctx.filesTouched],
269
+ openObligations: ctx.ledger.openObligations.length,
270
+ };
271
+ let extra = 0;
272
+ if (opts.onBudgetExhausted) {
273
+ try {
274
+ extra = Math.floor(await opts.onBudgetExhausted(exhaustStats));
275
+ }
276
+ catch {
277
+ // A broken prompt (stdin closed mid-question, dying terminal) must
278
+ // read as "declined" and take the preserve-and-restore path below,
279
+ // not propagate past it and skip the rollback entirely.
280
+ extra = 0;
281
+ }
282
+ }
283
+ if (!Number.isFinite(extra) || extra <= 0)
284
+ break;
285
+ budget += extra;
286
+ await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
287
+ log(`turn budget extended by ${extra} (now ${budget})`);
288
+ }
133
289
  const tools = availableTools(ctx).map((t) => t.schema);
290
+ r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
291
+ r.status('thinking');
134
292
  let res;
135
293
  try {
136
294
  res = await withRetry(() => provider.chat(messages, tools), {
@@ -148,10 +306,15 @@ export async function runAgentLoop(opts) {
148
306
  continue;
149
307
  }
150
308
  }
151
- return fail(`provider error: ${err.message}`);
309
+ return fail(`provider error: ${err.message}`, 'provider-error');
310
+ }
311
+ finally {
312
+ r.status(null);
152
313
  }
314
+ turnsUsed = turn + 1;
153
315
  tokensIn += res.usage.inputTokens;
154
316
  tokensOut += res.usage.outputTokens;
317
+ perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
155
318
  await transcript.event('assistant', { text: res.text, toolCalls: res.toolCalls });
156
319
  if (res.text) {
157
320
  if (!plan)
@@ -160,28 +323,33 @@ export async function runAgentLoop(opts) {
160
323
  }
161
324
  messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
162
325
  if (!res.toolCalls.length) {
326
+ // Only *consecutive* tool-less turns are a stall. Providers emit the
327
+ // occasional empty completion mid-run (observed live: three empties
328
+ // spread across 31 productive turns); a cumulative counter turns those
329
+ // into a full rollback of an otherwise-converging run.
163
330
  if (nudges++ >= 2)
164
- return fail('model stopped calling tools without finishing');
331
+ return fail('model stopped calling tools without finishing', 'stalled');
165
332
  messages.push({
166
333
  role: 'user',
167
334
  content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
168
335
  });
169
336
  continue;
170
337
  }
338
+ nudges = 0;
171
339
  for (const call of res.toolCalls) {
172
340
  const result = await dispatchTool(ctx, call.name, call.args);
173
341
  await transcript.event('tool', { name: call.name, args: call.args, result });
174
- log(` [${call.name}] ${result.split('\n')[0]}`);
342
+ r.toolResult(call.name, result.split('\n')[0] ?? '');
175
343
  messages.push({ role: 'tool', toolCallId: call.id, content: result });
176
344
  }
177
345
  if (ctx.repairCycles > config.maxRepairCycles) {
178
- return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`);
346
+ return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`, 'repair-cycles-exhausted');
179
347
  }
180
- const remaining = maxTurns - turn - 1;
348
+ const remaining = budget - turn - 1;
181
349
  if (remaining === 5 && !ctx.finishRequest) {
182
350
  messages.push({
183
351
  role: 'user',
184
- content: '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.',
352
+ content: '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.',
185
353
  });
186
354
  }
187
355
  if (ctx.finishRequest) {
@@ -190,6 +358,8 @@ export async function runAgentLoop(opts) {
190
358
  if (outcome === 'refuse') {
191
359
  await restore(repoRoot, snap);
192
360
  await transcript.event('run-refused', { summary });
361
+ const runStats = stats('refused');
362
+ await transcript.event('run-end', runStats);
193
363
  await transcript.writeSummary({
194
364
  request: opts.request,
195
365
  changeId: ctx.changeId,
@@ -203,9 +373,30 @@ export async function runAgentLoop(opts) {
203
373
  outcome: 'aborted',
204
374
  openObligations: null,
205
375
  detail: `REFUSED: ${summary}`,
376
+ env: meta,
377
+ stats: runStats,
378
+ });
379
+ // Refusals are the most valuable thing to remember: they encode a budget
380
+ // or constraint that this user's designs keep running into.
381
+ await remember({
382
+ request: opts.request,
383
+ outcome: 'refused',
384
+ summary,
385
+ changeId: ctx.changeId,
386
+ filesTouched: [],
387
+ decisions: ctx.decisions,
388
+ verification: 'n/a (refused before verification)',
206
389
  });
207
390
  log(`refused: ${summary}`);
208
- return { outcome: 'refused', summary, transcriptDir: transcript.dir, filesTouched: [], commit: null };
391
+ r.finish(outcomeLine(runStats));
392
+ return {
393
+ outcome: 'refused',
394
+ exitPath: 'refused',
395
+ summary,
396
+ transcriptDir: transcript.dir,
397
+ filesTouched: [],
398
+ commit: null,
399
+ };
209
400
  }
210
401
  const verification = [
211
402
  ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'FAILING'}` : 'ERC not required',
@@ -223,6 +414,8 @@ export async function runAgentLoop(opts) {
223
414
  if (untracked)
224
415
  log(`new files:\n${untracked}`);
225
416
  await restore(repoRoot, snap);
417
+ const runStats = stats('done');
418
+ await transcript.event('run-end', runStats);
226
419
  await transcript.writeSummary({
227
420
  request: opts.request,
228
421
  changeId: ctx.changeId,
@@ -236,8 +429,18 @@ export async function runAgentLoop(opts) {
236
429
  outcome: 'success',
237
430
  openObligations: null,
238
431
  detail: 'dry run: changes reverted',
432
+ env: meta,
433
+ stats: runStats,
239
434
  });
240
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit: null };
435
+ r.finish(outcomeLine(runStats, 'dry run: changes reverted'));
436
+ return {
437
+ outcome: 'success',
438
+ exitPath: 'done',
439
+ summary,
440
+ transcriptDir: transcript.dir,
441
+ filesTouched: files,
442
+ commit: null,
443
+ };
241
444
  }
242
445
  await appendChangelog(repoRoot, config, {
243
446
  changeId: ctx.changeId,
@@ -247,15 +450,35 @@ export async function runAgentLoop(opts) {
247
450
  });
248
451
  ctx.ledger.clear('changelog');
249
452
  const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
250
- const commit = await commitAll(repoRoot, commitMsg);
453
+ // A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
454
+ // must land in summary.md as an outcome, not escape as a stack trace
455
+ // (AC-8.6): roll back per the snapshot contract and report commit-failed.
456
+ let commit;
457
+ try {
458
+ commit = await commitAll(repoRoot, commitMsg);
459
+ }
460
+ catch (err) {
461
+ return fail(`commit failed: ${err.message}`, 'commit-failed');
462
+ }
251
463
  if (ctx.changeId && existsSync(path.join(repoRoot, 'openspec', 'config.yaml'))) {
252
- const arch = await openspecArchive(repoRoot, ctx.changeId);
253
- await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
254
- if (arch.ok && (await isDirty(repoRoot))) {
255
- await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
464
+ // The verified commit already exists; discarding it because archive
465
+ // housekeeping failed would be the worse trade, so this is a warning.
466
+ try {
467
+ const arch = await openspecArchive(repoRoot, ctx.changeId);
468
+ await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
469
+ if (arch.ok && (await isDirty(repoRoot))) {
470
+ await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
471
+ }
472
+ }
473
+ catch (err) {
474
+ const message = err.message;
475
+ log(`warning: openspec archive failed (${message}); the run commit itself succeeded`);
476
+ await transcript.event('openspec-archive-failed', { changeId: ctx.changeId, error: message });
256
477
  }
257
478
  }
258
479
  await transcript.event('run-committed', { commit, files });
480
+ const runStats = stats('done');
481
+ await transcript.event('run-end', runStats);
259
482
  await transcript.writeSummary({
260
483
  request: opts.request,
261
484
  changeId: ctx.changeId,
@@ -268,12 +491,31 @@ export async function runAgentLoop(opts) {
268
491
  tokensOut,
269
492
  outcome: 'success',
270
493
  openObligations: null,
494
+ env: meta,
495
+ stats: runStats,
496
+ });
497
+ await remember({
498
+ request: opts.request,
499
+ outcome: 'success',
500
+ summary,
501
+ changeId: ctx.changeId,
502
+ filesTouched: files,
503
+ decisions: ctx.decisions,
504
+ verification,
271
505
  });
272
506
  log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
273
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit };
507
+ r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
508
+ return {
509
+ outcome: 'success',
510
+ exitPath: 'done',
511
+ summary,
512
+ transcriptDir: transcript.dir,
513
+ filesTouched: files,
514
+ commit,
515
+ };
274
516
  }
275
517
  }
276
518
  const filesAfter = await changedFiles(repoRoot, snap.head);
277
- return fail(`turn budget exhausted (${maxTurns} turns, ${filesAfter.length} files touched but unverified)`);
519
+ return fail(`turn budget exhausted (${budget} turns, ${filesAfter.length} files touched but unverified)`, 'turn-budget-exhausted');
278
520
  }
279
521
  //# sourceMappingURL=loop.js.map