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/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,47 @@ 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
+ - One model backend: a locally installed, ChatGPT-authenticated [Codex CLI](https://learn.chatgpt.com/docs/codex/cli), or `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` in the environment. `check` 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
+
51
+ ### Use your existing Codex login
52
+
53
+ No model API key is needed when Codex CLI is already authenticated:
54
+
55
+ ```bash
56
+ npm install -g @openai/codex-sdk # optional adapter, loaded only for --model codex
57
+ codex login status
58
+ copperhead do "rename net KEY_DAH to KEY_DASH" --model codex
59
+ ```
60
+
61
+ Plain `codex` follows your Codex model configuration. Use `codex:<model-id>` for an explicit model. If the executable is not on `PATH`, set `COPPERHEAD_CODEX_PATH=/absolute/path/to/codex`.
62
+ The optional SDK also installs a compatible launcher; for a global install, use
63
+ `COPPERHEAD_CODEX_PATH="$(npm root -g)/@openai/codex/bin/codex.js"` as a fallback.
64
+
65
+ Codex's read-only sandbox prevents native writes but does not confine native reads. Copperhead instructs Codex not to use its own filesystem tools, but the CLI can technically read files such as `.env`. Codex also keeps session logs under `~/.codex/sessions/`; those files are outside Copperhead's transcript-redaction boundary and should be protected according to your local data-retention policy.
66
+
16
67
  ## How it works
17
68
 
18
69
  It's a loop, and it looks a lot like pair-programming, except the codebase is a circuit board.
@@ -51,21 +102,28 @@ Nothing is a black box: decisions land in an append-only `docs/DECISIONS.md`, ev
51
102
  - **Not a new editor.** No walled garden; your KiCad install remains the editor.
52
103
  - **Not the engineer of record.** A human signs off; the agent never claims a design is fab-ready beyond "ERC/DRC clean".
53
104
 
54
- ## Install
105
+ ## Simple demo
106
+
107
+ From a checkout of this repo, run the smallest create-pipeline demo with the USB-C power breakout brief:
55
108
 
56
109
  ```bash
57
- npm install -g copperhead # or: npx copperhead check
110
+ npm run demo:simple
58
111
  ```
59
112
 
60
- ### Requirements
113
+ The script creates or resumes a git repo at `demo-runs/usb-c-breakout/` and runs:
61
114
 
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
115
+ ```bash
116
+ copperhead create --brief examples/simple/usb-c-breakout.md
117
+ ```
118
+
119
+ Pass normal create flags after `--`, for example `npm run demo:simple -- --model claude`.
120
+ 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.
121
+
122
+ More briefs, including medium and hard tiers, live in [examples/](examples/).
65
123
 
66
124
  ## Maturity
67
125
 
68
- Honest read of where v0.1 stands, so you can calibrate before pointing this at a board you care about:
126
+ Honest read of where the current release stands, so you can calibrate before pointing this at a board you care about:
69
127
 
70
128
  - **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
129
  - **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,8 +133,13 @@ Honest read of where v0.1 stands, so you can calibrate before pointing this at a
75
133
 
76
134
  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
135
 
136
+ ## Contributing
137
+
138
+ 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.
139
+
78
140
  ## Project layout
79
141
 
142
+ - [`AGENTS.md`](AGENTS.md): repository instructions loaded automatically by Codex and compatible coding agents; [`CLAUDE.md`](CLAUDE.md) provides the corresponding Claude Code guidance
80
143
  - [`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/))
81
144
  - [`test/`](test/): offline suite plus [`fixtures/`](test/fixtures/), a tiny known-good KiCad project
82
145
  - [`openspec/specs/SPEC.md`](openspec/specs/SPEC.md): the full technical specification, including binary acceptance criteria
@@ -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,17 +3,36 @@ 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';
16
- export function makeProvider(model) {
18
+ import { CodexProvider } from './providers/codex.js';
19
+ import { openSynapMemory } from '../memory/synap.js';
20
+ export async function makeProvider(model) {
21
+ if (model === 'codex' || model.startsWith('codex:')) {
22
+ const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
23
+ if (codexModel === '')
24
+ throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
25
+ const { Codex } = await import('@openai/codex-sdk').catch((err) => {
26
+ throw new Error('Codex provider requires the optional @openai/codex-sdk package; install it alongside Copperhead before using --model codex', { cause: err });
27
+ });
28
+ return new CodexProvider({
29
+ ...(codexModel ? { model: codexModel } : {}),
30
+ client: new Codex({
31
+ // Use the user's installed CLI and its saved login rather than a model API key.
32
+ codexPathOverride: process.env.COPPERHEAD_CODEX_PATH || 'codex',
33
+ }),
34
+ });
35
+ }
17
36
  if (model === 'claude' || model.startsWith('claude')) {
18
37
  return new AnthropicProvider(model === 'claude' ? undefined : model);
19
38
  }
@@ -56,17 +75,36 @@ async function appendChangelog(repoRoot, config, entry) {
56
75
  lines.splice(insertAt, 0, ...block.split('\n').slice(1), '');
57
76
  await writeFile(p, lines.join('\n'), 'utf8');
58
77
  }
78
+ /**
79
+ * Owns the Synap session for one run. The bridge is a subprocess, so the
80
+ * shutdown in `finally` is what lets the CLI exit; without it the process
81
+ * hangs after a successful run.
82
+ */
59
83
  export async function runAgentLoop(opts) {
60
- const log = opts.log ?? ((l) => console.log(l));
84
+ const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
85
+ const providers = new Set();
86
+ try {
87
+ return await runWithMemory(opts, memory, providers);
88
+ }
89
+ finally {
90
+ for (const provider of providers) {
91
+ try {
92
+ await provider.close?.();
93
+ }
94
+ catch (err) {
95
+ opts.log?.(`warning: ${provider.name} provider cleanup failed (${err.message})`);
96
+ }
97
+ }
98
+ await memory?.close();
99
+ }
100
+ }
101
+ async function runWithMemory(opts, memory, providers) {
102
+ const r = opts.renderer ?? plainRenderer(opts.log ?? ((l) => console.log(l)));
103
+ const log = (l) => r.log(l);
61
104
  const repoRoot = opts.repoRoot;
62
105
  const config = await loadConfig(repoRoot);
63
106
  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
- }
107
+ await gitPreflight(repoRoot, { allowDirty: opts.allowDirty ?? false });
70
108
  const snap = await snapshot(repoRoot);
71
109
  const transcript = new Transcript(repoRoot);
72
110
  await transcript.init();
@@ -88,21 +126,123 @@ export async function runAgentLoop(opts) {
88
126
  repairCycles: 0,
89
127
  finishRequest: null,
90
128
  };
91
- let provider = makeProvider(opts.model);
129
+ let provider = opts.provider ?? (await makeProvider(opts.model));
130
+ providers.add(provider);
131
+ // Deterministic, LLM-free metadata block: collected once, rendered onto all
132
+ // three surfaces (run-start event, summary ## Environment, CLI header) so
133
+ // they can never disagree (design D1, AC-8.1/8.4).
134
+ const startMs = Date.now();
135
+ const meta = await collectRunMeta({
136
+ repoRoot,
137
+ config,
138
+ maxTurns,
139
+ runId: path.basename(transcript.dir),
140
+ request: opts.request,
141
+ model: opts.model,
142
+ provider: provider.name,
143
+ interactive: opts.interactive ?? false,
144
+ input: opts.meta,
145
+ });
146
+ for (const line of renderCliHeader(meta))
147
+ log(line);
148
+ // Revisit obligations deferred while their artifact didn't exist re-open now
149
+ // if it does (must run before loadConstraints so the prompt sees the updated
150
+ // registry). They land in this run's fresh ledger, so finish gates on them.
151
+ const reopened = await reopenDeferredAffects(repoRoot, config, (key, item) => ctx.ledger.add('affects-revisit', `${key} affects ${item}`, key));
152
+ if (reopened.length) {
153
+ await transcript.event('deferred-affects-reopened', { reopened });
154
+ log(`re-opened ${reopened.length} deferred constraint revisit obligation(s)`);
155
+ }
92
156
  const constraints = await loadConstraints(repoRoot);
93
- const system = await buildSystemPrompt(repoRoot, config, constraints);
157
+ let basePrompt = await buildSystemPrompt(repoRoot, config, constraints);
158
+ if (reopened.length) {
159
+ basePrompt += [
160
+ '',
161
+ '',
162
+ '## Reopened constraint revisits',
163
+ '',
164
+ 'These constraints were recorded before their target artifact existed; the artifact now exists.',
165
+ 'Revisit each against the design and close it with resolve_affected (batch the calls):',
166
+ ...reopened.map((r) => `- ${r.key} affects ${r.item}`),
167
+ ].join('\n');
168
+ }
169
+ // Cross-run memory is appended after the repo's own docs and constraints so
170
+ // that the in-repo sources of truth are what the model reads first.
171
+ const recalled = memory ? await memory.recall(opts.request) : null;
172
+ if (recalled) {
173
+ await transcript.event('synap-recall', { chars: recalled.length });
174
+ log('recalled prior context from Synap memory');
175
+ }
176
+ const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
94
177
  const messages = [
95
178
  { role: 'system', content: system },
96
179
  { role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
97
180
  ];
98
- await transcript.event('run-start', { request: opts.request, model: opts.model, provider: provider.name });
181
+ await transcript.event('run-start', meta);
182
+ /**
183
+ * A memory write that fails is reported rather than swallowed, but it does
184
+ * not change the run's outcome: discarding a verified commit because a
185
+ * third-party write failed would be the worse trade.
186
+ */
187
+ const remember = async (run) => {
188
+ if (!memory)
189
+ return;
190
+ try {
191
+ await memory.record(run);
192
+ await transcript.event('synap-record', { outcome: run.outcome });
193
+ }
194
+ catch (err) {
195
+ const message = err.message;
196
+ log(`warning: Synap memory write failed (${message}); this run was not recorded`);
197
+ await transcript.event('synap-record-failed', { error: message });
198
+ }
199
+ };
99
200
  let tokensIn = 0;
100
201
  let tokensOut = 0;
202
+ let turnsUsed = 0;
203
+ const perTurn = [];
101
204
  let plan = null;
102
205
  let nudges = 0;
103
- const fail = async (reason) => {
104
- await transcript.event('run-failed', { reason });
105
- await restore(repoRoot, snap);
206
+ const stats = (exitPath) => ({
207
+ exitPath,
208
+ turnsUsed,
209
+ maxTurns,
210
+ repairCyclesUsed: ctx.repairCycles,
211
+ maxRepairCycles: config.maxRepairCycles,
212
+ tokensIn,
213
+ tokensOut,
214
+ perTurn,
215
+ durationMs: Date.now() - startMs,
216
+ });
217
+ /** One outcome line, printed last at every terminal branch (AC-8.5). */
218
+ const outcomeLine = (s, extra) => [
219
+ s.exitPath,
220
+ ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'failing'}` : 'ERC not run',
221
+ ...(ctx.lastDrc ? [`DRC ${ctx.lastDrc.ok ? 'clean' : 'failing'}`] : []),
222
+ ...(extra ? [extra] : []),
223
+ fmtDuration(s.durationMs),
224
+ `${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
225
+ ].join(' · ');
226
+ const fail = async (reason, exitPath) => {
227
+ await transcript.event('run-failed', { reason, exitPath });
228
+ // Preserve the touched work as a stash entry before the rollback destroys
229
+ // it, so a budget-exhaustion (or any) failure is recoverable (issue #15).
230
+ const preserved = await preserveFailedRun(repoRoot, ctx.runId);
231
+ if (preserved)
232
+ await transcript.event('work-preserved', { stash: preserved });
233
+ // The rollback itself can fail (git in a bad state). That must not become
234
+ // an unhandled throw that skips run-end and summary.md — the summary is
235
+ // most valuable exactly when the tree is left in an unknown state.
236
+ let restoreError = null;
237
+ try {
238
+ await restore(repoRoot, snap);
239
+ }
240
+ catch (err) {
241
+ restoreError = err.message;
242
+ await transcript.event('restore-failed', { error: restoreError });
243
+ }
244
+ const runStats = stats(exitPath);
245
+ await transcript.event('run-end', runStats);
106
246
  const summaryPath = await transcript.writeSummary({
107
247
  request: opts.request,
108
248
  changeId: ctx.changeId,
@@ -115,22 +255,66 @@ export async function runAgentLoop(opts) {
115
255
  tokensOut,
116
256
  outcome: 'failure',
117
257
  openObligations: ctx.ledger.isClear ? null : ctx.ledger.describe(),
118
- detail: reason,
258
+ 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,
259
+ env: meta,
260
+ stats: runStats,
119
261
  });
120
262
  log(`run failed: ${reason}`);
121
- log(`working tree restored to pre-run snapshot`);
263
+ if (restoreError) {
264
+ log(`WARNING: rollback failed (${restoreError}); the working tree may be in a partial state`);
265
+ }
266
+ else {
267
+ log(`working tree restored to pre-run snapshot`);
268
+ }
269
+ if (preserved) {
270
+ 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\``);
271
+ }
122
272
  log(`transcript: ${transcript.jsonlPath}`);
123
273
  log(`summary: ${summaryPath}`);
274
+ r.finish(outcomeLine(runStats));
124
275
  return {
125
276
  outcome: 'failure',
277
+ exitPath,
126
278
  summary: reason,
127
279
  transcriptDir: transcript.dir,
128
280
  filesTouched: [],
129
281
  commit: null,
130
282
  };
131
283
  };
132
- for (let turn = 0; turn < maxTurns; turn++) {
284
+ let budget = maxTurns;
285
+ for (let turn = 0;; turn++) {
286
+ if (turn >= budget) {
287
+ // Budget exhausted. In an attended run this is a user decision made with
288
+ // the cost visible, not an unconditional rollback (issue #15).
289
+ const exhaustStats = {
290
+ maxTurns,
291
+ turnsUsed: turn,
292
+ tokensIn,
293
+ tokensOut,
294
+ filesTouched: [...ctx.filesTouched],
295
+ openObligations: ctx.ledger.openObligations.length,
296
+ };
297
+ let extra = 0;
298
+ if (opts.onBudgetExhausted) {
299
+ try {
300
+ extra = Math.floor(await opts.onBudgetExhausted(exhaustStats));
301
+ }
302
+ catch {
303
+ // A broken prompt (stdin closed mid-question, dying terminal) must
304
+ // read as "declined" and take the preserve-and-restore path below,
305
+ // not propagate past it and skip the rollback entirely.
306
+ extra = 0;
307
+ }
308
+ }
309
+ if (!Number.isFinite(extra) || extra <= 0)
310
+ break;
311
+ budget += extra;
312
+ await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
313
+ log(`turn budget extended by ${extra} (now ${budget})`);
314
+ }
133
315
  const tools = availableTools(ctx).map((t) => t.schema);
316
+ r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
317
+ r.status('thinking');
134
318
  let res;
135
319
  try {
136
320
  res = await withRetry(() => provider.chat(messages, tools), {
@@ -144,14 +328,20 @@ export async function runAgentLoop(opts) {
144
328
  log(`failing over ${provider.name} → ${fallback.name}`);
145
329
  await transcript.event('provider-failover', { from: provider.name, to: fallback.name });
146
330
  provider = fallback;
331
+ providers.add(provider);
147
332
  turn--;
148
333
  continue;
149
334
  }
150
335
  }
151
- return fail(`provider error: ${err.message}`);
336
+ return fail(`provider error: ${err.message}`, 'provider-error');
152
337
  }
338
+ finally {
339
+ r.status(null);
340
+ }
341
+ turnsUsed = turn + 1;
153
342
  tokensIn += res.usage.inputTokens;
154
343
  tokensOut += res.usage.outputTokens;
344
+ perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
155
345
  await transcript.event('assistant', { text: res.text, toolCalls: res.toolCalls });
156
346
  if (res.text) {
157
347
  if (!plan)
@@ -160,28 +350,33 @@ export async function runAgentLoop(opts) {
160
350
  }
161
351
  messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
162
352
  if (!res.toolCalls.length) {
353
+ // Only *consecutive* tool-less turns are a stall. Providers emit the
354
+ // occasional empty completion mid-run (observed live: three empties
355
+ // spread across 31 productive turns); a cumulative counter turns those
356
+ // into a full rollback of an otherwise-converging run.
163
357
  if (nudges++ >= 2)
164
- return fail('model stopped calling tools without finishing');
358
+ return fail('model stopped calling tools without finishing', 'stalled');
165
359
  messages.push({
166
360
  role: 'user',
167
361
  content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
168
362
  });
169
363
  continue;
170
364
  }
365
+ nudges = 0;
171
366
  for (const call of res.toolCalls) {
172
367
  const result = await dispatchTool(ctx, call.name, call.args);
173
368
  await transcript.event('tool', { name: call.name, args: call.args, result });
174
- log(` [${call.name}] ${result.split('\n')[0]}`);
369
+ r.toolResult(call.name, result.split('\n')[0] ?? '');
175
370
  messages.push({ role: 'tool', toolCallId: call.id, content: result });
176
371
  }
177
372
  if (ctx.repairCycles > config.maxRepairCycles) {
178
- return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`);
373
+ return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`, 'repair-cycles-exhausted');
179
374
  }
180
- const remaining = maxTurns - turn - 1;
375
+ const remaining = budget - turn - 1;
181
376
  if (remaining === 5 && !ctx.finishRequest) {
182
377
  messages.push({
183
378
  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.',
379
+ 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
380
  });
186
381
  }
187
382
  if (ctx.finishRequest) {
@@ -190,6 +385,8 @@ export async function runAgentLoop(opts) {
190
385
  if (outcome === 'refuse') {
191
386
  await restore(repoRoot, snap);
192
387
  await transcript.event('run-refused', { summary });
388
+ const runStats = stats('refused');
389
+ await transcript.event('run-end', runStats);
193
390
  await transcript.writeSummary({
194
391
  request: opts.request,
195
392
  changeId: ctx.changeId,
@@ -203,9 +400,30 @@ export async function runAgentLoop(opts) {
203
400
  outcome: 'aborted',
204
401
  openObligations: null,
205
402
  detail: `REFUSED: ${summary}`,
403
+ env: meta,
404
+ stats: runStats,
405
+ });
406
+ // Refusals are the most valuable thing to remember: they encode a budget
407
+ // or constraint that this user's designs keep running into.
408
+ await remember({
409
+ request: opts.request,
410
+ outcome: 'refused',
411
+ summary,
412
+ changeId: ctx.changeId,
413
+ filesTouched: [],
414
+ decisions: ctx.decisions,
415
+ verification: 'n/a (refused before verification)',
206
416
  });
207
417
  log(`refused: ${summary}`);
208
- return { outcome: 'refused', summary, transcriptDir: transcript.dir, filesTouched: [], commit: null };
418
+ r.finish(outcomeLine(runStats));
419
+ return {
420
+ outcome: 'refused',
421
+ exitPath: 'refused',
422
+ summary,
423
+ transcriptDir: transcript.dir,
424
+ filesTouched: [],
425
+ commit: null,
426
+ };
209
427
  }
210
428
  const verification = [
211
429
  ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'FAILING'}` : 'ERC not required',
@@ -223,6 +441,8 @@ export async function runAgentLoop(opts) {
223
441
  if (untracked)
224
442
  log(`new files:\n${untracked}`);
225
443
  await restore(repoRoot, snap);
444
+ const runStats = stats('done');
445
+ await transcript.event('run-end', runStats);
226
446
  await transcript.writeSummary({
227
447
  request: opts.request,
228
448
  changeId: ctx.changeId,
@@ -236,8 +456,18 @@ export async function runAgentLoop(opts) {
236
456
  outcome: 'success',
237
457
  openObligations: null,
238
458
  detail: 'dry run: changes reverted',
459
+ env: meta,
460
+ stats: runStats,
239
461
  });
240
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit: null };
462
+ r.finish(outcomeLine(runStats, 'dry run: changes reverted'));
463
+ return {
464
+ outcome: 'success',
465
+ exitPath: 'done',
466
+ summary,
467
+ transcriptDir: transcript.dir,
468
+ filesTouched: files,
469
+ commit: null,
470
+ };
241
471
  }
242
472
  await appendChangelog(repoRoot, config, {
243
473
  changeId: ctx.changeId,
@@ -247,15 +477,35 @@ export async function runAgentLoop(opts) {
247
477
  });
248
478
  ctx.ledger.clear('changelog');
249
479
  const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
250
- const commit = await commitAll(repoRoot, commitMsg);
480
+ // A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
481
+ // must land in summary.md as an outcome, not escape as a stack trace
482
+ // (AC-8.6): roll back per the snapshot contract and report commit-failed.
483
+ let commit;
484
+ try {
485
+ commit = await commitAll(repoRoot, commitMsg);
486
+ }
487
+ catch (err) {
488
+ return fail(`commit failed: ${err.message}`, 'commit-failed');
489
+ }
251
490
  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}`);
491
+ // The verified commit already exists; discarding it because archive
492
+ // housekeeping failed would be the worse trade, so this is a warning.
493
+ try {
494
+ const arch = await openspecArchive(repoRoot, ctx.changeId);
495
+ await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
496
+ if (arch.ok && (await isDirty(repoRoot))) {
497
+ await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
498
+ }
499
+ }
500
+ catch (err) {
501
+ const message = err.message;
502
+ log(`warning: openspec archive failed (${message}); the run commit itself succeeded`);
503
+ await transcript.event('openspec-archive-failed', { changeId: ctx.changeId, error: message });
256
504
  }
257
505
  }
258
506
  await transcript.event('run-committed', { commit, files });
507
+ const runStats = stats('done');
508
+ await transcript.event('run-end', runStats);
259
509
  await transcript.writeSummary({
260
510
  request: opts.request,
261
511
  changeId: ctx.changeId,
@@ -268,12 +518,31 @@ export async function runAgentLoop(opts) {
268
518
  tokensOut,
269
519
  outcome: 'success',
270
520
  openObligations: null,
521
+ env: meta,
522
+ stats: runStats,
523
+ });
524
+ await remember({
525
+ request: opts.request,
526
+ outcome: 'success',
527
+ summary,
528
+ changeId: ctx.changeId,
529
+ filesTouched: files,
530
+ decisions: ctx.decisions,
531
+ verification,
271
532
  });
272
533
  log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
273
- return { outcome: 'success', summary, transcriptDir: transcript.dir, filesTouched: files, commit };
534
+ r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
535
+ return {
536
+ outcome: 'success',
537
+ exitPath: 'done',
538
+ summary,
539
+ transcriptDir: transcript.dir,
540
+ filesTouched: files,
541
+ commit,
542
+ };
274
543
  }
275
544
  }
276
545
  const filesAfter = await changedFiles(repoRoot, snap.head);
277
- return fail(`turn budget exhausted (${maxTurns} turns, ${filesAfter.length} files touched but unverified)`);
546
+ return fail(`turn budget exhausted (${budget} turns, ${filesAfter.length} files touched but unverified)`, 'turn-budget-exhausted');
278
547
  }
279
548
  //# sourceMappingURL=loop.js.map