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.
- package/NOTICE +5 -0
- package/README.md +72 -9
- package/dist/agent/ledger.js +7 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +303 -34
- 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/providers/codex.js +292 -0
- package/dist/agent/providers/codex.js.map +1 -0
- 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 +47 -11
- 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 +16 -8
- 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/scaffold.js +2 -1
- package/dist/memory/scaffold.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 +21 -6
- package/src/agent/ledger.ts +9 -1
- package/src/agent/loop.ts +333 -35
- package/src/agent/prompts.ts +3 -1
- package/src/agent/providers/anthropic.ts +40 -16
- package/src/agent/providers/codex.ts +339 -0
- 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/agent/types.ts +1 -0
- package/src/cli.ts +51 -12
- 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 +29 -9
- package/src/kicad/cli.ts +60 -9
- package/src/memory/constraints.ts +90 -3
- package/src/memory/drift.ts +32 -0
- package/src/memory/scaffold.ts +2 -1
- 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,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
|
-
##
|
|
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
|
|
110
|
+
npm run demo:simple
|
|
58
111
|
```
|
|
59
112
|
|
|
60
|
-
|
|
113
|
+
The script creates or resumes a git repo at `demo-runs/usb-c-breakout/` and runs:
|
|
61
114
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
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,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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
await
|
|
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
|
-
|
|
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 (${
|
|
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
|