copperhead 0.3.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/LICENSE +201 -0
- package/README.md +87 -0
- package/dist/agent/filetools.js +118 -0
- package/dist/agent/filetools.js.map +1 -0
- package/dist/agent/ledger.js +43 -0
- package/dist/agent/ledger.js.map +1 -0
- package/dist/agent/loop.js +279 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompts.js +47 -0
- package/dist/agent/prompts.js.map +1 -0
- package/dist/agent/providers/anthropic.js +78 -0
- package/dist/agent/providers/anthropic.js.map +1 -0
- package/dist/agent/providers/openai.js +74 -0
- package/dist/agent/providers/openai.js.map +1 -0
- package/dist/agent/tools.js +439 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/agent/transcript.js +60 -0
- package/dist/agent/transcript.js.map +1 -0
- package/dist/agent/types.js +2 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli.js +185 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.js +64 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/create.js +91 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/sync.js +144 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/kicad/cli.js +91 -0
- package/dist/kicad/cli.js.map +1 -0
- package/dist/kicad/report.js +48 -0
- package/dist/kicad/report.js.map +1 -0
- package/dist/kicad/sexp.js +291 -0
- package/dist/kicad/sexp.js.map +1 -0
- package/dist/memory/constraints.js +43 -0
- package/dist/memory/constraints.js.map +1 -0
- package/dist/memory/drift.js +81 -0
- package/dist/memory/drift.js.map +1 -0
- package/dist/memory/scaffold.js +215 -0
- package/dist/memory/scaffold.js.map +1 -0
- package/dist/openspec/cli.js +31 -0
- package/dist/openspec/cli.js.map +1 -0
- package/dist/util/env.js +68 -0
- package/dist/util/env.js.map +1 -0
- package/dist/util/git.js +53 -0
- package/dist/util/git.js.map +1 -0
- package/dist/util/paths.js +25 -0
- package/dist/util/paths.js.map +1 -0
- package/dist/util/redact.js +21 -0
- package/dist/util/redact.js.map +1 -0
- package/dist/util/retry.js +27 -0
- package/dist/util/retry.js.map +1 -0
- package/package.json +73 -0
- package/src/agent/filetools.ts +136 -0
- package/src/agent/ledger.ts +71 -0
- package/src/agent/loop.ts +320 -0
- package/src/agent/prompts.ts +58 -0
- package/src/agent/providers/anthropic.ts +81 -0
- package/src/agent/providers/openai.ts +80 -0
- package/src/agent/tools.ts +481 -0
- package/src/agent/transcript.ts +78 -0
- package/src/agent/types.ts +32 -0
- package/src/cli.ts +188 -0
- package/src/commands/check.ts +85 -0
- package/src/commands/create.ts +125 -0
- package/src/commands/sync.ts +182 -0
- package/src/config.ts +77 -0
- package/src/kicad/cli.ts +106 -0
- package/src/kicad/report.ts +81 -0
- package/src/kicad/sexp.ts +327 -0
- package/src/memory/constraints.ts +73 -0
- package/src/memory/drift.ts +97 -0
- package/src/memory/scaffold.ts +241 -0
- package/src/openspec/cli.ts +43 -0
- package/src/util/env.ts +65 -0
- package/src/util/git.ts +63 -0
- package/src/util/paths.ts +25 -0
- package/src/util/redact.ts +20 -0
- package/src/util/retry.ts +33 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir, chmod } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { listSymbols, pinNets, type SchematicSymbol, type PinNet } from '../kicad/sexp.js';
|
|
6
|
+
import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
|
|
7
|
+
|
|
8
|
+
export class InitError extends Error {}
|
|
9
|
+
|
|
10
|
+
export interface InitResult {
|
|
11
|
+
created: string[];
|
|
12
|
+
skipped: string[];
|
|
13
|
+
refused: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sha = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
17
|
+
|
|
18
|
+
async function findKicadFiles(searchRoot: string): Promise<{ sch: string | null; pcb: string | null }> {
|
|
19
|
+
let sch: string | null = null;
|
|
20
|
+
let pcb: string | null = null;
|
|
21
|
+
async function walk(dir: string): Promise<void> {
|
|
22
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
23
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
24
|
+
const abs = path.join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) await walk(abs);
|
|
26
|
+
else if (entry.name.endsWith('.kicad_sch') && !sch) sch = abs;
|
|
27
|
+
else if (entry.name.endsWith('.kicad_pcb') && !pcb) pcb = abs;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await walk(searchRoot);
|
|
31
|
+
return { sch, pcb };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bomTable(symbols: SchematicSymbol[]): string {
|
|
35
|
+
const rows = symbols.map(
|
|
36
|
+
(s) => `| ${s.ref} | ${s.value} | ${s.footprint} | UNVERIFIED | extracted from schematic by copperhead init |`,
|
|
37
|
+
);
|
|
38
|
+
return ['| Refdes | Value | Footprint | MPN | Rationale |', '|---|---|---|---|---|', ...rows].join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pinoutTable(pins: PinNet[]): string {
|
|
42
|
+
const rows = pins.map((p) => `| ${p.ref} | ${p.pinNumber} | ${p.pinName} | ${p.net ?? ''} | |`);
|
|
43
|
+
return ['| Refdes | Pin | Name | Net | Notes |', '|---|---|---|---|---|', ...rows].join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function generateDocs(
|
|
47
|
+
projectName: string,
|
|
48
|
+
symbols: SchematicSymbol[],
|
|
49
|
+
pins: PinNet[],
|
|
50
|
+
): Record<string, string> {
|
|
51
|
+
const mcuRefs = new Set(symbols.filter((s) => s.ref.startsWith('U')).map((s) => s.ref));
|
|
52
|
+
const mcuPins = pins.filter((p) => mcuRefs.has(p.ref));
|
|
53
|
+
return {
|
|
54
|
+
'SPEC.md': `# ${projectName} — Specification
|
|
55
|
+
|
|
56
|
+
What the device is, top-level constraints and budgets.
|
|
57
|
+
|
|
58
|
+
## Budgets
|
|
59
|
+
|
|
60
|
+
<!-- Add hard budgets here; copperhead treats them as constraints, e.g. -->
|
|
61
|
+
<!-- - sleep_current_uA: 25 -->
|
|
62
|
+
|
|
63
|
+
## Assumptions
|
|
64
|
+
|
|
65
|
+
<!-- Decisions the agent made without explicit direction are flagged ASSUMED here -->
|
|
66
|
+
`,
|
|
67
|
+
'BOM.md': `# Bill of Materials
|
|
68
|
+
|
|
69
|
+
Every part: refdes, MPN, value, package, and WHY it was chosen. MPNs the agent
|
|
70
|
+
introduces without a datasheet check are flagged UNVERIFIED.
|
|
71
|
+
|
|
72
|
+
${bomTable(symbols)}
|
|
73
|
+
`,
|
|
74
|
+
'PINOUT.md': `# Pinout
|
|
75
|
+
|
|
76
|
+
Pin assignment extracted from the schematic. Check strapping/RTC notes before
|
|
77
|
+
reassigning pins.
|
|
78
|
+
|
|
79
|
+
${pinoutTable(mcuPins.length ? mcuPins : pins)}
|
|
80
|
+
`,
|
|
81
|
+
'SUBSYSTEMS.md': `# Subsystems
|
|
82
|
+
|
|
83
|
+
Per-sheet values and reasoning (regulator, charger, RF, ...).
|
|
84
|
+
|
|
85
|
+
${[...new Set(symbols.map((s) => s.sheet))].map((sheet) => `## Sheet ${sheet}\n\n${symbols.filter((s) => s.sheet === sheet).map((s) => `- ${s.ref}: ${s.value}`).join('\n')}`).join('\n\n')}
|
|
86
|
+
`,
|
|
87
|
+
'LAYOUT.md': `# Layout intent
|
|
88
|
+
|
|
89
|
+
Placement and routing intent: keepouts, pours, ESD placement.
|
|
90
|
+
|
|
91
|
+
## Draft quality
|
|
92
|
+
|
|
93
|
+
<!-- copperhead writes an honest assessment here after any layout pass -->
|
|
94
|
+
`,
|
|
95
|
+
'DECISIONS.md': `# Decision log
|
|
96
|
+
|
|
97
|
+
Append-only. Every non-trivial agent decision lands here: date, run id,
|
|
98
|
+
decision, rationale, and what it affects. Entries are never rewritten.
|
|
99
|
+
`,
|
|
100
|
+
'CHANGELOG.md': `# Design changelog
|
|
101
|
+
|
|
102
|
+
Append-only, newest first. One entry per committed copperhead run.
|
|
103
|
+
`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function copperheadReadme(config: CopperheadConfig): string {
|
|
108
|
+
return `# .copperhead — copperhead working memory
|
|
109
|
+
|
|
110
|
+
Generated by \`copperhead init\`; regenerated on re-runs (do not hand-edit).
|
|
111
|
+
|
|
112
|
+
## config.json
|
|
113
|
+
|
|
114
|
+
- \`schematic\` / \`board\`: repo-relative paths to the KiCad files copperhead operates on (currently: ${config.schematic ?? 'none'} / ${config.board ?? 'none'})
|
|
115
|
+
- \`docs\`: design docs directory (docs-as-memory), default \`docs/\`
|
|
116
|
+
- \`model\`: default model (\`gpt-5\` or \`claude\`); overridden by \`--model\` and \`COPPERHEAD_MODEL\`
|
|
117
|
+
- \`maxTurns\`: agent loop turn budget per run (default 40)
|
|
118
|
+
- \`maxRepairCycles\`: ERC/DRC repair attempts before rollback (default 5)
|
|
119
|
+
- \`budgets\`: free-form hard constraints (e.g. \`"sleep_current_uA": 25\`); surfaced verbatim into every run's system prompt
|
|
120
|
+
- \`generatedHashes\`: content hashes of generated docs, used to detect hand edits on re-init
|
|
121
|
+
|
|
122
|
+
## constraints.json
|
|
123
|
+
|
|
124
|
+
Machine-readable constraint registry. Each key maps to \`{ min/max/forbidden/value, source, affects[] }\`.
|
|
125
|
+
\`source\` points at the doc or spec that states the constraint; \`affects\` lists the
|
|
126
|
+
refdes/nets/zones to revisit when the constraint changes. Kept in sync with the
|
|
127
|
+
docs in the same tool turn (dual write).
|
|
128
|
+
|
|
129
|
+
## runs/
|
|
130
|
+
|
|
131
|
+
One directory per \`do\`/\`create\`/\`sync\` run: \`transcript.jsonl\` (full audit
|
|
132
|
+
trail) and \`summary.md\` (human-readable: request, plan, files touched,
|
|
133
|
+
verification results, decisions, open obligations). Both redact secrets at
|
|
134
|
+
write time. This directory is gitignored.
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const HOOK_MARKER = '# installed by copperhead init';
|
|
139
|
+
|
|
140
|
+
async function installPreCommitHook(repoRoot: string): Promise<string | null> {
|
|
141
|
+
const hooksDir = path.join(repoRoot, '.git', 'hooks');
|
|
142
|
+
if (!existsSync(hooksDir)) return null;
|
|
143
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
144
|
+
if (existsSync(hookPath)) {
|
|
145
|
+
const existing = await readFile(hookPath, 'utf8');
|
|
146
|
+
if (existing.includes(HOOK_MARKER)) return null; // idempotent
|
|
147
|
+
return null; // never clobber a user's own hook
|
|
148
|
+
}
|
|
149
|
+
const script = `#!/bin/sh
|
|
150
|
+
${HOOK_MARKER}
|
|
151
|
+
# Hand edits that desync docs, constraints, or the schematic fail at commit time.
|
|
152
|
+
exec copperhead check
|
|
153
|
+
`;
|
|
154
|
+
await writeFile(hookPath, script, 'utf8');
|
|
155
|
+
await chmod(hookPath, 0o755);
|
|
156
|
+
return hookPath;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface InitOptions {
|
|
160
|
+
repoRoot: string;
|
|
161
|
+
searchPath?: string;
|
|
162
|
+
force?: boolean;
|
|
163
|
+
installHooks?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function runInit(opts: InitOptions): Promise<InitResult> {
|
|
167
|
+
const { repoRoot } = opts;
|
|
168
|
+
const searchRoot = path.resolve(repoRoot, opts.searchPath ?? '.');
|
|
169
|
+
const { sch, pcb } = await findKicadFiles(searchRoot);
|
|
170
|
+
if (!sch) {
|
|
171
|
+
throw new InitError(
|
|
172
|
+
`no .kicad_sch found under ${path.relative(repoRoot, searchRoot) || '.'} — point copperhead at a KiCad project (copperhead init --path <dir>)`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const config = await loadConfig(repoRoot);
|
|
177
|
+
config.schematic = path.relative(repoRoot, sch);
|
|
178
|
+
config.board = pcb ? path.relative(repoRoot, pcb) : null;
|
|
179
|
+
|
|
180
|
+
const symbols = await listSymbols(sch);
|
|
181
|
+
const pins = await pinNets(sch);
|
|
182
|
+
const projectName = path.basename(sch, '.kicad_sch');
|
|
183
|
+
const docs = generateDocs(projectName, symbols, pins);
|
|
184
|
+
|
|
185
|
+
const docsDir = path.join(repoRoot, config.docs);
|
|
186
|
+
await mkdir(docsDir, { recursive: true });
|
|
187
|
+
|
|
188
|
+
const result: InitResult = { created: [], skipped: [], refused: [] };
|
|
189
|
+
const hashes: Record<string, string> = { ...(config.generatedHashes ?? {}) };
|
|
190
|
+
const appendOnly = new Set(['DECISIONS.md', 'CHANGELOG.md']);
|
|
191
|
+
|
|
192
|
+
for (const [name, content] of Object.entries(docs)) {
|
|
193
|
+
const p = path.join(docsDir, name);
|
|
194
|
+
const rel = path.join(config.docs, name);
|
|
195
|
+
if (!existsSync(p)) {
|
|
196
|
+
await writeFile(p, content, 'utf8');
|
|
197
|
+
hashes[name] = sha(content);
|
|
198
|
+
result.created.push(rel);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (appendOnly.has(name)) {
|
|
202
|
+
result.skipped.push(rel); // append-only docs are never regenerated
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const current = await readFile(p, 'utf8');
|
|
206
|
+
if (hashes[name] === sha(current)) {
|
|
207
|
+
if (current === content) {
|
|
208
|
+
result.skipped.push(rel);
|
|
209
|
+
} else if (opts.force) {
|
|
210
|
+
await writeFile(p, content, 'utf8');
|
|
211
|
+
hashes[name] = sha(content);
|
|
212
|
+
result.created.push(rel);
|
|
213
|
+
} else {
|
|
214
|
+
// schematic changed since generation but doc untouched: safe to refresh
|
|
215
|
+
await writeFile(p, content, 'utf8');
|
|
216
|
+
hashes[name] = sha(content);
|
|
217
|
+
result.created.push(rel);
|
|
218
|
+
}
|
|
219
|
+
} else if (opts.force) {
|
|
220
|
+
await writeFile(p, content, 'utf8');
|
|
221
|
+
hashes[name] = sha(content);
|
|
222
|
+
result.created.push(rel);
|
|
223
|
+
} else {
|
|
224
|
+
result.refused.push(rel);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
config.generatedHashes = hashes;
|
|
229
|
+
const cfgPath = configPath(repoRoot);
|
|
230
|
+
await mkdir(path.dirname(cfgPath), { recursive: true });
|
|
231
|
+
await writeFile(cfgPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
232
|
+
// generated documentation, always refreshed (design D12)
|
|
233
|
+
await writeFile(path.join(repoRoot, '.copperhead', 'README.md'), copperheadReadme(config), 'utf8');
|
|
234
|
+
|
|
235
|
+
if (opts.installHooks !== false) {
|
|
236
|
+
const hook = await installPreCommitHook(repoRoot);
|
|
237
|
+
if (hook) result.created.push(path.relative(repoRoot, hook));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OpenSpec is driven as a subprocess, same pattern as kicad-cli (SPEC §2.6).
|
|
7
|
+
* Never user-triggered; copperhead owns the propose → validate → archive flow.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface OpenSpecResult {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
output: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function openspec(repo: string, args: string[]): Promise<OpenSpecResult> {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout, stderr } = await execa('openspec', args, { cwd: repo });
|
|
18
|
+
return { ok: true, output: [stdout, stderr].filter(Boolean).join('\n') };
|
|
19
|
+
} catch (err) {
|
|
20
|
+
const e = err as { stdout?: string; stderr?: string; code?: string; message: string };
|
|
21
|
+
if (e.code === 'ENOENT') {
|
|
22
|
+
return { ok: false, output: 'openspec CLI not found on PATH (npm i -g @fission-ai/openspec)' };
|
|
23
|
+
}
|
|
24
|
+
return { ok: false, output: [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasOpenSpec(repo: string): boolean {
|
|
29
|
+
return existsSync(path.join(repo, 'openspec'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function openspecInit(repo: string): Promise<OpenSpecResult> {
|
|
33
|
+
if (hasOpenSpec(repo)) return { ok: true, output: 'openspec/ already present' };
|
|
34
|
+
return openspec(repo, ['init', '--no-interactive']);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openspecValidate(repo: string, changeId?: string): Promise<OpenSpecResult> {
|
|
38
|
+
return openspec(repo, changeId ? ['validate', changeId] : ['validate']);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function openspecArchive(repo: string, changeId: string): Promise<OpenSpecResult> {
|
|
42
|
+
return openspec(repo, ['archive', changeId, '--yes']);
|
|
43
|
+
}
|
package/src/util/env.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal .env loader.
|
|
3
|
+
*
|
|
4
|
+
* Keys are env-var-only by contract (AC-4.1), and they still are: this reads a
|
|
5
|
+
* gitignored .env into process.env at startup and nothing else ever persists
|
|
6
|
+
* them. No dependency, because dotenv's extra features (interpolation, multi-
|
|
7
|
+
* file precedence) are all things we would have to reason about at key-handling
|
|
8
|
+
* time.
|
|
9
|
+
*
|
|
10
|
+
* The real environment always wins. A .env file is a convenience for local runs,
|
|
11
|
+
* so it must never quietly override what CI or a shell export already set.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/** KEY=value, optional `export ` prefix, optional matched quotes. */
|
|
17
|
+
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
|
|
18
|
+
|
|
19
|
+
export function parseEnv(text: string): Record<string, string> {
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
for (const raw of text.split('\n')) {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (!line || line.startsWith('#')) continue;
|
|
24
|
+
const m = LINE.exec(line);
|
|
25
|
+
if (!m) continue;
|
|
26
|
+
const key = m[1]!;
|
|
27
|
+
let value = m[2]!.trim();
|
|
28
|
+
const quoted =
|
|
29
|
+
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
|
|
30
|
+
(value.startsWith("'") && value.endsWith("'") && value.length > 1);
|
|
31
|
+
if (quoted) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
} else {
|
|
34
|
+
// Only strip trailing comments from unquoted values; a `#` inside quotes
|
|
35
|
+
// is part of the secret.
|
|
36
|
+
const hash = value.indexOf(' #');
|
|
37
|
+
if (hash !== -1) value = value.slice(0, hash).trim();
|
|
38
|
+
}
|
|
39
|
+
out[key] = value;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load `<dir>/.env` into process.env without overriding existing values.
|
|
46
|
+
* Returns the names (never the values) of the variables it set, for logging.
|
|
47
|
+
*/
|
|
48
|
+
export function loadEnvFile(dir: string): string[] {
|
|
49
|
+
const p = path.join(dir, '.env');
|
|
50
|
+
if (!existsSync(p)) return [];
|
|
51
|
+
let parsed: Record<string, string>;
|
|
52
|
+
try {
|
|
53
|
+
parsed = parseEnv(readFileSync(p, 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const applied: string[] = [];
|
|
58
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
59
|
+
if (process.env[key] === undefined) {
|
|
60
|
+
process.env[key] = value;
|
|
61
|
+
applied.push(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return applied;
|
|
65
|
+
}
|
package/src/util/git.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
|
|
3
|
+
export interface GitSnapshot {
|
|
4
|
+
head: string;
|
|
5
|
+
stash: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function git(repo: string, args: string[]): Promise<string> {
|
|
9
|
+
const { stdout } = await execa('git', args, { cwd: repo });
|
|
10
|
+
return stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function isGitRepo(repo: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await git(repo, ['rev-parse', '--git-dir']);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function isDirty(repo: string): Promise<boolean> {
|
|
23
|
+
const status = await git(repo, ['status', '--porcelain']);
|
|
24
|
+
return status.length > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Snapshot the working tree before a run. On a clean tree HEAD is enough;
|
|
29
|
+
* with --allow-dirty we keep a `git stash create` object so uncommitted work
|
|
30
|
+
* survives a rollback (SPEC §7).
|
|
31
|
+
*/
|
|
32
|
+
export async function snapshot(repo: string): Promise<GitSnapshot> {
|
|
33
|
+
const head = await git(repo, ['rev-parse', 'HEAD']);
|
|
34
|
+
let stash: string | null = null;
|
|
35
|
+
if (await isDirty(repo)) {
|
|
36
|
+
stash = (await git(repo, ['stash', 'create'])) || null;
|
|
37
|
+
}
|
|
38
|
+
return { head, stash };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hard-restore the working tree to a snapshot (AC-3.6). The run audit trail
|
|
43
|
+
* (.copperhead/runs/) survives rollback: it is the evidence of what failed.
|
|
44
|
+
*/
|
|
45
|
+
export async function restore(repo: string, snap: GitSnapshot): Promise<void> {
|
|
46
|
+
await git(repo, ['reset', '--hard', snap.head]);
|
|
47
|
+
await git(repo, ['clean', '-fd', '-e', '.copperhead/runs']);
|
|
48
|
+
if (snap.stash) {
|
|
49
|
+
await git(repo, ['stash', 'apply', snap.stash]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function commitAll(repo: string, message: string): Promise<string> {
|
|
54
|
+
await git(repo, ['add', '-A']);
|
|
55
|
+
await git(repo, ['commit', '-m', message]);
|
|
56
|
+
return git(repo, ['rev-parse', 'HEAD']);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function changedFiles(repo: string, sinceHead: string): Promise<string[]> {
|
|
60
|
+
const tracked = await git(repo, ['diff', '--name-only', sinceHead]);
|
|
61
|
+
const untracked = await git(repo, ['ls-files', '--others', '--exclude-standard']);
|
|
62
|
+
return [...new Set([...tracked.split('\n'), ...untracked.split('\n')])].filter(Boolean);
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export class SandboxError extends Error {
|
|
4
|
+
constructor(public readonly attempted: string) {
|
|
5
|
+
super(`path escapes repo root: ${attempted}`);
|
|
6
|
+
this.name = 'SandboxError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a repo-relative path and reject anything that escapes the repo root
|
|
12
|
+
* (AC-4.2). All file tools must go through this.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveInRepo(repoRoot: string, p: string): string {
|
|
15
|
+
const abs = path.resolve(repoRoot, p);
|
|
16
|
+
const root = path.resolve(repoRoot);
|
|
17
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
18
|
+
throw new SandboxError(p);
|
|
19
|
+
}
|
|
20
|
+
return abs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isKicadFile(p: string): boolean {
|
|
24
|
+
return /\.(kicad_sch|kicad_pcb|kicad_pro|kicad_sym|kicad_mod)$/.test(p);
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-time secret redaction for transcripts and summaries (AC-4.1).
|
|
3
|
+
* Patterns are deliberately broad: losing a few characters of log fidelity
|
|
4
|
+
* beats leaking a key.
|
|
5
|
+
*/
|
|
6
|
+
const PATTERNS: RegExp[] = [
|
|
7
|
+
/sk-[A-Za-z0-9_-]+/g,
|
|
8
|
+
/Bearer\s+[A-Za-z0-9._-]{16,}/g,
|
|
9
|
+
// Registry and forge tokens: a transcript that quotes a publish command or a
|
|
10
|
+
// failing CI log can carry these just as easily as a model API key.
|
|
11
|
+
/npm_[A-Za-z0-9]{36,}/g,
|
|
12
|
+
/gh[pousr]_[A-Za-z0-9]{36,}/g,
|
|
13
|
+
/github_pat_[A-Za-z0-9_]{22,}/g,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function redactSecrets(text: string): string {
|
|
17
|
+
let out = text;
|
|
18
|
+
for (const re of PATTERNS) out = out.replace(re, '[REDACTED]');
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RetryOpts {
|
|
2
|
+
retries?: number;
|
|
3
|
+
baseMs?: number;
|
|
4
|
+
isRetryable?: (err: unknown) => boolean;
|
|
5
|
+
onRetry?: (attempt: number, err: unknown) => void;
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isRateLimit(err: unknown): boolean {
|
|
10
|
+
const status = (err as { status?: number; statusCode?: number })?.status
|
|
11
|
+
?? (err as { statusCode?: number })?.statusCode;
|
|
12
|
+
return status === 429;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Exponential backoff ×N for rate limits (SPEC §4.5). */
|
|
16
|
+
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOpts = {}): Promise<T> {
|
|
17
|
+
const retries = opts.retries ?? 3;
|
|
18
|
+
const baseMs = opts.baseMs ?? 1000;
|
|
19
|
+
const isRetryable = opts.isRetryable ?? isRateLimit;
|
|
20
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
21
|
+
let lastErr: unknown;
|
|
22
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
} catch (err) {
|
|
26
|
+
lastErr = err;
|
|
27
|
+
if (!isRetryable(err) || attempt === retries) throw err;
|
|
28
|
+
opts.onRetry?.(attempt + 1, err);
|
|
29
|
+
await sleep(baseMs * 2 ** attempt);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw lastErr;
|
|
33
|
+
}
|