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.
- package/NOTICE +5 -0
- package/README.md +55 -9
- package/dist/agent/ledger.js +7 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +275 -33
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +3 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/anthropic.js +28 -13
- package/dist/agent/providers/anthropic.js.map +1 -1
- package/dist/agent/render.js +170 -0
- package/dist/agent/render.js.map +1 -0
- package/dist/agent/runmeta.js +124 -0
- package/dist/agent/runmeta.js.map +1 -0
- package/dist/agent/tools.js +117 -16
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +23 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +45 -9
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +9 -2
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +57 -3
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +11 -5
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +58 -8
- package/dist/kicad/cli.js.map +1 -1
- package/dist/memory/constraints.js +63 -3
- package/dist/memory/constraints.js.map +1 -1
- package/dist/memory/drift.js +31 -0
- package/dist/memory/drift.js.map +1 -1
- package/dist/memory/synap.js +152 -0
- package/dist/memory/synap.js.map +1 -0
- package/dist/util/git.js +125 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/preflight.js +24 -0
- package/dist/util/preflight.js.map +1 -0
- package/package.json +10 -6
- package/src/agent/ledger.ts +9 -1
- package/src/agent/loop.ts +300 -34
- package/src/agent/prompts.ts +3 -1
- package/src/agent/providers/anthropic.ts +40 -16
- package/src/agent/render.ts +194 -0
- package/src/agent/runmeta.ts +198 -0
- package/src/agent/tools.ts +119 -15
- package/src/agent/transcript.ts +49 -0
- package/src/cli.ts +49 -10
- package/src/commands/check.ts +9 -3
- package/src/commands/create.ts +61 -4
- package/src/commands/sync.ts +5 -0
- package/src/config.ts +24 -6
- package/src/kicad/cli.ts +60 -9
- package/src/memory/constraints.ts +90 -3
- package/src/memory/drift.ts +32 -0
- package/src/memory/synap.ts +217 -0
- package/src/util/git.ts +134 -4
- package/src/util/preflight.ts +22 -0
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
|
94
|
+
npm run demo:simple
|
|
58
95
|
```
|
|
59
96
|
|
|
60
|
-
|
|
97
|
+
The script creates or resumes a git repo at `demo-runs/usb-c-breakout/` and runs:
|
|
61
98
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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/))
|
package/dist/agent/ledger.js
CHANGED
|
@@ -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) {
|
package/dist/agent/ledger.js.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/agent/loop.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
await
|
|
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
|
-
|
|
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 (${
|
|
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
|