copperhead 0.6.0 → 0.8.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/README.md +36 -4
- package/dist/agent/animate.js +76 -0
- package/dist/agent/animate.js.map +1 -0
- package/dist/agent/box.js +89 -0
- package/dist/agent/box.js.map +1 -0
- package/dist/agent/dock-renderer.js +173 -0
- package/dist/agent/dock-renderer.js.map +1 -0
- package/dist/agent/logo.js +21 -0
- package/dist/agent/logo.js.map +1 -0
- package/dist/agent/loop.js +130 -18
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +2 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +85 -116
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/cursor.js +317 -0
- package/dist/agent/providers/cursor.js.map +1 -0
- package/dist/agent/providers/tool-protocol.js +205 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -0
- package/dist/agent/recovery.js +148 -0
- package/dist/agent/recovery.js.map +1 -0
- package/dist/agent/render.js +44 -12
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/response-cache.js +81 -0
- package/dist/agent/response-cache.js.map +1 -0
- package/dist/agent/runmeta.js +4 -5
- package/dist/agent/runmeta.js.map +1 -1
- package/dist/agent/theme.js +84 -0
- package/dist/agent/theme.js.map +1 -0
- package/dist/agent/tools.js +61 -4
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +134 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +482 -38
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/demo.js +146 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.js +240 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/repl-inspect.js +342 -0
- package/dist/commands/repl-inspect.js.map +1 -0
- package/dist/commands/repl.js +618 -0
- package/dist/commands/repl.js.map +1 -0
- package/dist/config.js +24 -2
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +166 -0
- package/dist/kicad/bootstrap.js.map +1 -0
- package/dist/kicad/cli.js +126 -6
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/spice.js +306 -0
- package/dist/kicad/spice.js.map +1 -0
- package/dist/kicad/symlib.js +228 -0
- package/dist/kicad/symlib.js.map +1 -0
- package/dist/memory/bom-table.js +193 -22
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/drift.js +33 -11
- package/dist/memory/drift.js.map +1 -1
- package/dist/util/cli-args.js +35 -0
- package/dist/util/cli-args.js.map +1 -0
- package/dist/util/dock.js +155 -0
- package/dist/util/dock.js.map +1 -0
- package/dist/util/git.js +165 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/live-prompt.js +542 -0
- package/dist/util/live-prompt.js.map +1 -0
- package/dist/util/paths.js +9 -0
- package/dist/util/paths.js.map +1 -1
- package/dist/util/preflight.js +37 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/retry.js +23 -0
- package/dist/util/retry.js.map +1 -1
- package/dist/util/select.js +172 -0
- package/dist/util/select.js.map +1 -0
- package/dist/util/tmp.js +119 -0
- package/dist/util/tmp.js.map +1 -0
- package/package.json +3 -2
- package/src/agent/animate.ts +90 -0
- package/src/agent/box.ts +99 -0
- package/src/agent/dock-renderer.ts +181 -0
- package/src/agent/logo.ts +23 -0
- package/src/agent/loop.ts +148 -18
- package/src/agent/prompts.ts +2 -1
- package/src/agent/providers/claude-code.ts +91 -122
- package/src/agent/providers/cursor.ts +364 -0
- package/src/agent/providers/tool-protocol.ts +212 -0
- package/src/agent/recovery.ts +162 -0
- package/src/agent/render.ts +56 -12
- package/src/agent/response-cache.ts +80 -0
- package/src/agent/runmeta.ts +6 -7
- package/src/agent/theme.ts +91 -0
- package/src/agent/tools.ts +62 -4
- package/src/agent/transcript.ts +1 -0
- package/src/agent/types.ts +17 -0
- package/src/cli.ts +139 -15
- package/src/commands/create.ts +581 -40
- package/src/commands/demo.ts +184 -0
- package/src/commands/doctor.ts +289 -0
- package/src/commands/repl-inspect.ts +353 -0
- package/src/commands/repl.ts +685 -0
- package/src/config.ts +40 -3
- package/src/kicad/bootstrap.ts +181 -0
- package/src/kicad/cli.ts +132 -7
- package/src/kicad/spice.ts +399 -0
- package/src/kicad/symlib.ts +248 -0
- package/src/layout/claude-ui-layout.md +72 -0
- package/src/layout/repl-ui-layout.md +139 -0
- package/src/memory/bom-table.ts +191 -20
- package/src/memory/drift.ts +42 -11
- package/src/util/cli-args.ts +42 -0
- package/src/util/dock.ts +161 -0
- package/src/util/git.ts +176 -4
- package/src/util/live-prompt.ts +595 -0
- package/src/util/paths.ts +10 -0
- package/src/util/preflight.ts +44 -0
- package/src/util/retry.ts +29 -0
- package/src/util/select.ts +192 -0
- package/src/util/tmp.ts +113 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtle interactive-TTY chrome: muted hierarchy with a copper accent.
|
|
3
|
+
* Plain / --json / piped output must stay free of SGR (AC-8.9), so helpers
|
|
4
|
+
* no-op unless color has been explicitly enabled for this process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ESC = '\x1b[';
|
|
8
|
+
const RESET = `${ESC}0m`;
|
|
9
|
+
|
|
10
|
+
/** True after makeRenderer selects the interactive path. */
|
|
11
|
+
let colorEnabled = false;
|
|
12
|
+
|
|
13
|
+
export function setColorEnabled(on: boolean): void {
|
|
14
|
+
colorEnabled = on;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isColorEnabled(): boolean {
|
|
18
|
+
return colorEnabled;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function paint(code: string, s: string): string {
|
|
22
|
+
if (!colorEnabled || s === '') return s;
|
|
23
|
+
return `${ESC}${code}m${s}${RESET}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? '');
|
|
27
|
+
|
|
28
|
+
/** Secondary metadata — soft gray #999999 (truecolor), SGR 90 fallback. */
|
|
29
|
+
export const dim = (s: string): string => paint(TRUECOLOR ? '38;2;153;153;153' : '90', s);
|
|
30
|
+
/** Rules/separators — one step darker than dim: #888888. */
|
|
31
|
+
export const ruleDim = (s: string): string => paint(TRUECOLOR ? '38;2;136;136;136' : '90', s);
|
|
32
|
+
/** Primary content — bright white (typed input, key values). */
|
|
33
|
+
export const bright = (s: string): string => paint('97', s);
|
|
34
|
+
|
|
35
|
+
/** Exact brand copper #b87333 where truecolor is available; warm 256 fallback. */
|
|
36
|
+
const COPPER_SGR = TRUECOLOR ? '38;2;184;115;51' : '38;5;173';
|
|
37
|
+
/** Light copper tint (brand accent-high #eec9a5) — menu hover. */
|
|
38
|
+
export const copperLight = (s: string): string =>
|
|
39
|
+
paint(TRUECOLOR ? '38;2;238;201;165' : '38;5;223', s);
|
|
40
|
+
/** Title emphasis — bold in the terminal's default foreground (theme-adaptive). */
|
|
41
|
+
export const bold = (s: string): string => paint('1', s);
|
|
42
|
+
/** Brand / active accent — exact copper #b87333 (truecolor), 256-color 173 fallback. */
|
|
43
|
+
export const copper = (s: string): string => paint(COPPER_SGR, s);
|
|
44
|
+
/** Success — PCB green. */
|
|
45
|
+
export const ok = (s: string): string => paint('32', s);
|
|
46
|
+
/** Busy / caution — amber. */
|
|
47
|
+
export const warn = (s: string): string => paint('33', s);
|
|
48
|
+
/** Failure. */
|
|
49
|
+
export const err = (s: string): string => paint('31', s);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Style a create-pipeline stage line. Keeps the `stage <name>:` prefix so
|
|
53
|
+
* existing log greps and operator muscle memory still work.
|
|
54
|
+
*/
|
|
55
|
+
export function stageLine(name: string, detail: string, kind: 'info' | 'ok' | 'warn' | 'err' = 'info'): string {
|
|
56
|
+
const label = dim(`stage ${name}:`);
|
|
57
|
+
const body =
|
|
58
|
+
kind === 'ok' ? ok(detail) : kind === 'warn' ? warn(detail) : kind === 'err' ? err(detail) : detail;
|
|
59
|
+
return `${label} ${body}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tool-result scrollback line: short glyph + name + first line of result. */
|
|
63
|
+
export function toolLine(name: string, firstLine: string): string {
|
|
64
|
+
const clean = /\b(clean|ok|pass(?:ed)?|success|done)\b/i.test(firstLine) && !/\b(fail|error|violat)/i.test(firstLine);
|
|
65
|
+
const glyph = clean ? ok('✓') : copper('▸');
|
|
66
|
+
return ` ${glyph} ${dim(name)} ${firstLine}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Color the final outcome line from its exit-path token. */
|
|
70
|
+
export function styleOutcome(line: string): string {
|
|
71
|
+
if (!colorEnabled) return line;
|
|
72
|
+
const head = line.split(' · ')[0] ?? line;
|
|
73
|
+
const rest = line.slice(head.length);
|
|
74
|
+
if (head === 'done') return ok(head) + dim(rest);
|
|
75
|
+
if (/refus|fail|error|exhaust|stall/i.test(head)) return err(head) + dim(rest);
|
|
76
|
+
return copper(head) + dim(rest);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Dim secondary segments of the two-line CLI header (brand stays copper). */
|
|
80
|
+
export function styleHeaderLines(lines: string[]): string[] {
|
|
81
|
+
if (!colorEnabled) return lines;
|
|
82
|
+
return lines.map((line, i) => {
|
|
83
|
+
if (i === 0) {
|
|
84
|
+
// copperhead vX · rest…
|
|
85
|
+
const m = line.match(/^(copperhead v\S+)(.*)$/);
|
|
86
|
+
if (!m) return dim(line);
|
|
87
|
+
return copper(m[1]!) + dim(m[2]!);
|
|
88
|
+
}
|
|
89
|
+
return dim(line);
|
|
90
|
+
});
|
|
91
|
+
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveInRepo, isKicadFile } from '../util/paths.js';
|
|
|
6
6
|
import { runErc, runDrc, exportSvg, exportFab, kicadLoadError, isProbeableKicadFile } from '../kicad/cli.js';
|
|
7
7
|
import { formatViolations, type CheckReport } from '../kicad/report.js';
|
|
8
8
|
import { listSymbols, listNets } from '../kicad/sexp.js';
|
|
9
|
+
import { verifySchematicSymbols } from '../kicad/symlib.js';
|
|
9
10
|
import { checkDrift } from '../memory/drift.js';
|
|
10
11
|
import { saveConstraint, classifyAffectsTarget, affectsTargetExists } from '../memory/constraints.js';
|
|
11
12
|
import { openspecValidate } from '../openspec/cli.js';
|
|
@@ -52,6 +53,24 @@ const str = (args: Record<string, unknown>, key: string): string => {
|
|
|
52
53
|
return v;
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
// U+FFFD (the Unicode replacement character) is what a byte sequence becomes
|
|
57
|
+
// when UTF-8 decoding fails — most often a multibyte glyph (Ω, µ, ±, °) split
|
|
58
|
+
// across a streaming chunk boundary and decoded per-chunk upstream in the
|
|
59
|
+
// provider SDK (I2). It never appears in a legitimately authored PCB doc, so
|
|
60
|
+
// its presence in a content-bearing tool arg means the value arrived corrupted.
|
|
61
|
+
// Reject the call before it lands on disk so the model re-emits; the corruption
|
|
62
|
+
// is nondeterministic (it depends on where a chunk boundary fell), so the retry
|
|
63
|
+
// almost always comes through clean — far cheaper than shipping a mangled value
|
|
64
|
+
// like "5.1kΩ" → "5.1k�" into DECISIONS.md and only noticing on review.
|
|
65
|
+
const REPLACEMENT_CHAR = '�';
|
|
66
|
+
export function corruptionError(fields: Record<string, unknown>): string | null {
|
|
67
|
+
const bad = Object.entries(fields)
|
|
68
|
+
.filter(([, v]) => typeof v === 'string' && v.includes(REPLACEMENT_CHAR))
|
|
69
|
+
.map(([k]) => k);
|
|
70
|
+
if (!bad.length) return null;
|
|
71
|
+
return `rejected: the ${bad.join(', ')} value contains U+FFFD (�), the replacement character that signals a UTF-8 decoding error — a special character (e.g. Ω, µ, ±, °) was likely mangled in transit. Re-send this exact call with the intended character written correctly, or spell it in ASCII (e.g. "ohm", "uF", "+/-", "deg").`;
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
function markTouched(ctx: RunContext, rel: string): void {
|
|
56
75
|
ctx.filesTouched.add(rel);
|
|
57
76
|
if (isKicadFile(rel)) {
|
|
@@ -202,7 +221,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
202
221
|
schema: {
|
|
203
222
|
name: 'edit_file',
|
|
204
223
|
description:
|
|
205
|
-
'Exact-match anchored replace in an existing file. The anchor must be unique; widen it with surrounding lines if not. For renames, pass replace_all: true to replace every occurrence in one call.',
|
|
224
|
+
'Exact-match anchored replace in an existing file. Requires a validated change proposal first (call propose_change then validate_change to unlock edits; both may be in the same reply, before this call). The anchor must be unique; widen it with surrounding lines if not. For renames, pass replace_all: true to replace every occurrence in one call.',
|
|
206
225
|
parameters: {
|
|
207
226
|
type: 'object',
|
|
208
227
|
properties: {
|
|
@@ -216,6 +235,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
216
235
|
},
|
|
217
236
|
requiresUnlock: true,
|
|
218
237
|
handler: async (ctx, args) => {
|
|
238
|
+
const corrupt = corruptionError({ new_string: args.new_string });
|
|
239
|
+
if (corrupt) return corrupt;
|
|
219
240
|
const rel = str(args, 'path');
|
|
220
241
|
const abs = resolveInRepo(ctx.repoRoot, rel);
|
|
221
242
|
// Text edits can corrupt an s-expression file in ways the editor cannot
|
|
@@ -256,7 +277,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
256
277
|
{
|
|
257
278
|
schema: {
|
|
258
279
|
name: 'write_file',
|
|
259
|
-
description:
|
|
280
|
+
description:
|
|
281
|
+
'Create a new file (docs, outputs). Requires a validated change proposal first (propose_change then validate_change to unlock edits). Refuses to overwrite anything or to create KiCad files.',
|
|
260
282
|
parameters: {
|
|
261
283
|
type: 'object',
|
|
262
284
|
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
@@ -265,6 +287,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
265
287
|
},
|
|
266
288
|
requiresUnlock: true,
|
|
267
289
|
handler: async (ctx, args) => {
|
|
290
|
+
const corrupt = corruptionError({ content: args.content });
|
|
291
|
+
if (corrupt) return corrupt;
|
|
268
292
|
const rel = str(args, 'path');
|
|
269
293
|
const res = await toolWriteFile(ctx.repoRoot, rel, args.content as string);
|
|
270
294
|
markTouched(ctx, rel);
|
|
@@ -281,11 +305,43 @@ export const TOOLS: ToolDef[] = [
|
|
|
281
305
|
handler: async (ctx) => {
|
|
282
306
|
if (!ctx.config.schematic)
|
|
283
307
|
return 'no schematic configured; ERC does not apply yet — skip it until a schematic exists and is set in .copperhead/config.json';
|
|
284
|
-
const
|
|
308
|
+
const schPath = path.join(ctx.repoRoot, ctx.config.schematic);
|
|
309
|
+
const report = await runErc(schPath);
|
|
285
310
|
ctx.lastErc = report;
|
|
286
311
|
if (report.ok) ctx.ledger.clear('erc');
|
|
287
312
|
else ctx.repairCycles++;
|
|
288
|
-
|
|
313
|
+
const out = formatViolations(report);
|
|
314
|
+
// A zero-symbol schematic passes ERC with 0 violations — a false green
|
|
315
|
+
// (3.2) that lets a premature finish look verified (an empty sheet also
|
|
316
|
+
// passes drift). The stage contract already requires symbols>0, but a bare
|
|
317
|
+
// "ERC clean" on the empty starting sheet still misleads the model, so warn
|
|
318
|
+
// here too: no gate should read as satisfied by the empty starting state.
|
|
319
|
+
if (report.ok && !(await listSymbols(schPath)).length) {
|
|
320
|
+
return `${out}\nwarning: ERC is clean but the schematic has ZERO symbols — an empty sheet always passes ERC, so this is NOT a verified design. Capture the parts from BOM.md (and re-run run_erc) before calling finish.`;
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
schema: {
|
|
327
|
+
name: 'verify_symbols',
|
|
328
|
+
description:
|
|
329
|
+
"Cross-check every lib_symbols entry in the schematic against the KiCad symbol library installed on this machine. Reports pins that diverge from the real part (wrong count, name, or electrical type) and lib_ids that do not exist in the current KiCad version (with the closest real names). ERC cannot catch these — a symbol whose lib_id claims to be a canonical part but whose pins are wrong passes ERC while being wrong. Run this after capturing symbols and reconcile every finding.",
|
|
330
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
331
|
+
},
|
|
332
|
+
requiresUnlock: false,
|
|
333
|
+
handler: async (ctx) => {
|
|
334
|
+
if (!ctx.config.schematic)
|
|
335
|
+
return 'no schematic configured; verify_symbols does not apply yet';
|
|
336
|
+
const { findings, checked, skipped } = await verifySchematicSymbols(
|
|
337
|
+
path.join(ctx.repoRoot, ctx.config.schematic),
|
|
338
|
+
);
|
|
339
|
+
if (!findings.length) {
|
|
340
|
+
return `verify_symbols: ${checked} symbol(s) match the installed KiCad library. No divergences.`;
|
|
341
|
+
}
|
|
342
|
+
const lines = findings.map((f) => ` - [${f.kind}] ${f.detail}`);
|
|
343
|
+
const mismatches = findings.filter((f) => f.kind !== 'no-library').length;
|
|
344
|
+
return `verify_symbols: ${checked} verified, ${skipped} unverifiable (library not installed), ${mismatches} issue(s) to reconcile:\n${lines.join('\n')}`;
|
|
289
345
|
},
|
|
290
346
|
},
|
|
291
347
|
{
|
|
@@ -514,6 +570,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
514
570
|
},
|
|
515
571
|
requiresUnlock: true,
|
|
516
572
|
handler: async (ctx, args) => {
|
|
573
|
+
const corrupt = corruptionError({ decision: args.decision, rationale: args.rationale, affects: args.affects });
|
|
574
|
+
if (corrupt) return corrupt;
|
|
517
575
|
const decision = str(args, 'decision');
|
|
518
576
|
const rationale = str(args, 'rationale');
|
|
519
577
|
const affects = (args.affects as string | undefined) ?? '';
|
package/src/agent/transcript.ts
CHANGED
package/src/agent/types.ts
CHANGED
|
@@ -21,10 +21,27 @@ export interface Turn {
|
|
|
21
21
|
text: string | null;
|
|
22
22
|
toolCalls: ToolCall[];
|
|
23
23
|
usage: { inputTokens: number; outputTokens: number };
|
|
24
|
+
/**
|
|
25
|
+
* A one-line steer for a turn that produced NO tool call but clearly *intended*
|
|
26
|
+
* one — e.g. a fenced ```json block that names a real tool yet fails to parse
|
|
27
|
+
* (unbalanced braces). The loop surfaces it in place of the generic
|
|
28
|
+
* "continue using tools" nudge so the model fixes the malformed call instead of
|
|
29
|
+
* misreading the silence as a broken tool (#I10). Providers that can't detect
|
|
30
|
+
* a near-miss simply never set it.
|
|
31
|
+
*/
|
|
32
|
+
nudge?: string;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface ChatOpts {
|
|
27
36
|
maxTokens?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Liveness callback for the loop's heartbeat (5.1). A streaming provider calls
|
|
39
|
+
* it as output arrives, passing the cumulative streamed-output length in chars,
|
|
40
|
+
* so a slow turn can be told apart from a hung one. Providers that don't stream
|
|
41
|
+
* simply never call it (the heartbeat still reports elapsed time). Never used
|
|
42
|
+
* for billing — real token usage is reported once, on the returned Turn.
|
|
43
|
+
*/
|
|
44
|
+
onStream?: (streamedChars: number) => void;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
export interface Provider {
|
package/src/cli.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import path from 'node:path';
|
|
4
3
|
import { createRequire } from 'node:module';
|
|
5
4
|
import { createInterface } from 'node:readline/promises';
|
|
6
|
-
import { loadConfig, resolveModel } from './config.js';
|
|
5
|
+
import { loadConfig, resolveModel, type ModelSource } from './config.js';
|
|
6
|
+
import { pickModel } from './util/select.js';
|
|
7
7
|
import { runInit, InitError } from './memory/scaffold.js';
|
|
8
8
|
import { runCheck } from './commands/check.js';
|
|
9
|
+
import { runDoctor, formatDoctor } from './commands/doctor.js';
|
|
9
10
|
import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
|
|
10
11
|
import { runCreate } from './commands/create.js';
|
|
12
|
+
import { runDemo, demoTourText } from './commands/demo.js';
|
|
13
|
+
import { runRepl } from './commands/repl.js';
|
|
11
14
|
import {
|
|
12
15
|
runExportBom,
|
|
13
16
|
parseSupplier,
|
|
@@ -20,6 +23,7 @@ import { runAgentLoop, type BudgetExhaustedStats } from './agent/loop.js';
|
|
|
20
23
|
import { makeRenderer } from './agent/render.js';
|
|
21
24
|
import { kicadCliVersion } from './kicad/cli.js';
|
|
22
25
|
import { loadEnvFile } from './util/env.js';
|
|
26
|
+
import { budgetExtraTurns, budgetPromptText, parseMaxTurns, repoOf } from './util/cli-args.js';
|
|
23
27
|
|
|
24
28
|
// Read .env from the working directory before any command resolves a model or a
|
|
25
29
|
// provider. Loaded here rather than per-command so `check` behaves identically,
|
|
@@ -35,8 +39,6 @@ const { version } = createRequire(import.meta.url)('../package.json') as { versi
|
|
|
35
39
|
|
|
36
40
|
const program = new Command();
|
|
37
41
|
|
|
38
|
-
const repoOf = (opts: { repo?: string }): string => path.resolve(opts.repo ?? process.cwd());
|
|
39
|
-
|
|
40
42
|
async function confirmTty(question: string): Promise<boolean> {
|
|
41
43
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
44
|
const answer = await rl.question(`${question} [y/N] `);
|
|
@@ -50,14 +52,8 @@ async function confirmTty(question: string): Promise<boolean> {
|
|
|
50
52
|
*/
|
|
51
53
|
function budgetContinuePrompt(): ((stats: BudgetExhaustedStats) => Promise<number>) | undefined {
|
|
52
54
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
|
|
53
|
-
return async (stats) =>
|
|
54
|
-
|
|
55
|
-
// same increment instead of escalating with the extended turn count.
|
|
56
|
-
const extra = Math.ceil(stats.maxTurns / 2);
|
|
57
|
-
const k = (n: number) => `${(n / 1000).toFixed(1)}k`;
|
|
58
|
-
const q = `Turn budget exhausted (${stats.turnsUsed} turns, ${k(stats.tokensIn)} in / ${k(stats.tokensOut)} out, ${stats.filesTouched.length} file(s) touched, ${stats.openObligations} open obligation(s)). Continue with ${extra} more turns?`;
|
|
59
|
-
return (await confirmTty(q)) ? extra : 0;
|
|
60
|
-
};
|
|
55
|
+
return async (stats) =>
|
|
56
|
+
(await confirmTty(budgetPromptText(stats))) ? budgetExtraTurns(stats) : 0;
|
|
61
57
|
}
|
|
62
58
|
|
|
63
59
|
program
|
|
@@ -71,6 +67,68 @@ program
|
|
|
71
67
|
const rendererOf = () =>
|
|
72
68
|
makeRenderer({ json: Boolean(program.opts().json), plain: Boolean(program.opts().plain) });
|
|
73
69
|
|
|
70
|
+
program
|
|
71
|
+
.command('repl', { isDefault: true })
|
|
72
|
+
.description('interactive agent shell (default when no command is given)')
|
|
73
|
+
.argument('[request...]', 'optional first change request before the prompt loop')
|
|
74
|
+
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
75
|
+
.option('--max-turns <n>', 'turn budget per request')
|
|
76
|
+
.option('--allow-dirty', 'let turns run on a dirty working tree')
|
|
77
|
+
.option('--interactive', 'pause for approval after each proposal validates')
|
|
78
|
+
.action(
|
|
79
|
+
async (
|
|
80
|
+
requestParts: string[],
|
|
81
|
+
opts: { model?: string; maxTurns?: string; allowDirty?: boolean; interactive?: boolean },
|
|
82
|
+
) => {
|
|
83
|
+
const repo = repoOf(program.opts());
|
|
84
|
+
if (program.opts().json) {
|
|
85
|
+
console.error(
|
|
86
|
+
'copperhead: --json is not supported with the interactive shell. Use `copperhead do "<request>" --json`.',
|
|
87
|
+
);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const kicadVer = await kicadCliVersion();
|
|
92
|
+
const config = await loadConfig(repo);
|
|
93
|
+
const renderer = rendererOf();
|
|
94
|
+
let model: string;
|
|
95
|
+
let source: ModelSource;
|
|
96
|
+
try {
|
|
97
|
+
({ model, source } = resolveModel(opts.model, config));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// No model anywhere (flag, COPPERHEAD_MODEL, config, .env keys):
|
|
100
|
+
// on a TTY, offer an interactive pick instead of refusing to start.
|
|
101
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw err;
|
|
102
|
+
console.log('No model configured for this session, pick one:');
|
|
103
|
+
const chosen = await pickModel();
|
|
104
|
+
if (!chosen) throw err;
|
|
105
|
+
model = chosen;
|
|
106
|
+
source = 'picker';
|
|
107
|
+
}
|
|
108
|
+
const continuePrompt = budgetContinuePrompt();
|
|
109
|
+
const seed = requestParts.length ? requestParts.join(' ') : undefined;
|
|
110
|
+
const res = await runRepl({
|
|
111
|
+
repoRoot: repo,
|
|
112
|
+
model,
|
|
113
|
+
modelSource: source,
|
|
114
|
+
version,
|
|
115
|
+
kicadCliVersion: kicadVer,
|
|
116
|
+
...(opts.maxTurns ? { maxTurns: parseMaxTurns(opts.maxTurns) } : {}),
|
|
117
|
+
allowDirty: opts.allowDirty ?? false,
|
|
118
|
+
interactive: opts.interactive ?? false,
|
|
119
|
+
...(seed ? { seed } : {}),
|
|
120
|
+
confirm: confirmTty,
|
|
121
|
+
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
|
|
122
|
+
renderer,
|
|
123
|
+
});
|
|
124
|
+
process.exit(res.ok ? 0 : 1);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error((err as Error).message);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
74
132
|
program
|
|
75
133
|
.command('init')
|
|
76
134
|
.description('scaffold docs/ from an existing schematic; idempotent')
|
|
@@ -121,11 +179,30 @@ program
|
|
|
121
179
|
.description('ERC + DRC + doc-drift + spec validation; no LLM calls; CI-safe')
|
|
122
180
|
.action(checkAction);
|
|
123
181
|
|
|
182
|
+
program
|
|
183
|
+
.command('doctor')
|
|
184
|
+
.description('env preflight: kicad-cli, git, node, and the model provider credential; no LLM, no network')
|
|
185
|
+
.option('--model <model>', 'model to check the provider credential for (default: resolved like a run)')
|
|
186
|
+
.action(async (opts: { model?: string }) => {
|
|
187
|
+
const repo = repoOf(program.opts());
|
|
188
|
+
// Unlike other commands, doctor never gates on kicad-cli being present:
|
|
189
|
+
// runDoctor probes it and reports a failure instead of throwing, so a user
|
|
190
|
+
// with a missing tool still gets the full report.
|
|
191
|
+
const report = await runDoctor({ repoRoot: repo, model: opts.model });
|
|
192
|
+
if (program.opts().json) console.log(JSON.stringify(report, null, 2));
|
|
193
|
+
else {
|
|
194
|
+
const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
195
|
+
// || not ??: some non-interactive ptys report columns as 0.
|
|
196
|
+
for (const line of formatDoctor(report, process.stdout.columns || 80, color)) console.log(line);
|
|
197
|
+
}
|
|
198
|
+
process.exit(report.ok ? 0 : 1);
|
|
199
|
+
});
|
|
200
|
+
|
|
124
201
|
program
|
|
125
202
|
.command('do')
|
|
126
203
|
.description('the core loop: propose, edit, verify, propagate, commit')
|
|
127
204
|
.argument('<request>', 'the change request in natural language')
|
|
128
|
-
.option('--model <model>', 'codex | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
205
|
+
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
129
206
|
.option('--max-turns <n>', 'turn budget for this run')
|
|
130
207
|
.option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
|
|
131
208
|
.option('--dry-run', 'propose the diff, write nothing')
|
|
@@ -145,7 +222,7 @@ program
|
|
|
145
222
|
repoRoot: repo,
|
|
146
223
|
request,
|
|
147
224
|
model,
|
|
148
|
-
...(opts.maxTurns ? { maxTurns:
|
|
225
|
+
...(opts.maxTurns ? { maxTurns: parseMaxTurns(opts.maxTurns) } : {}),
|
|
149
226
|
allowDirty: opts.allowDirty ?? false,
|
|
150
227
|
dryRun: opts.dryRun ?? false,
|
|
151
228
|
interactive: opts.interactive ?? false,
|
|
@@ -199,11 +276,58 @@ program
|
|
|
199
276
|
}
|
|
200
277
|
});
|
|
201
278
|
|
|
279
|
+
program
|
|
280
|
+
.command('demo')
|
|
281
|
+
.description('tour of what copperhead does, or run the USB-C breakout create pipeline')
|
|
282
|
+
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
283
|
+
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
|
|
284
|
+
.option('--dir <path>', 'demo repo directory (default: demo-runs/usb-c-breakout)')
|
|
285
|
+
.option('--tour', 'print the overview only; do not run the pipeline')
|
|
286
|
+
.action(async (opts: { model?: string; interactive?: boolean; dir?: string; tour?: boolean }) => {
|
|
287
|
+
if (opts.tour) {
|
|
288
|
+
const { setColorEnabled } = await import('./agent/theme.js');
|
|
289
|
+
if (program.opts().json) {
|
|
290
|
+
// --json is a contract, not a suggestion: a script that passes it
|
|
291
|
+
// unconditionally must never get prose back. Plain lines, no SGR.
|
|
292
|
+
setColorEnabled(false);
|
|
293
|
+
console.log(JSON.stringify({ tour: demoTourText().split('\n') }, null, 2));
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
// Color on for attended TTY tours even without a renderer.
|
|
297
|
+
setColorEnabled(Boolean(process.stdout.isTTY) && !program.opts().plain && !process.env.NO_COLOR);
|
|
298
|
+
console.log(demoTourText());
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const kicadVer = await kicadCliVersion();
|
|
303
|
+
// Resolve model from the caller's cwd config / env / flag; the demo repo
|
|
304
|
+
// is scaffolded next and typically has no model of its own yet.
|
|
305
|
+
const config = await loadConfig(repoOf(program.opts()));
|
|
306
|
+
const { model, source } = resolveModel(opts.model, config);
|
|
307
|
+
const continuePrompt = budgetContinuePrompt();
|
|
308
|
+
const res = await runDemo({
|
|
309
|
+
model,
|
|
310
|
+
modelSource: source,
|
|
311
|
+
version,
|
|
312
|
+
kicadCliVersion: kicadVer,
|
|
313
|
+
interactive: opts.interactive ?? false,
|
|
314
|
+
...(opts.dir ? { demoDir: opts.dir } : {}),
|
|
315
|
+
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
|
|
316
|
+
log: (s) => console.log(s),
|
|
317
|
+
renderer: rendererOf(),
|
|
318
|
+
});
|
|
319
|
+
process.exit(res.ok ? 0 : 1);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error((err as Error).message);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
202
326
|
program
|
|
203
327
|
.command('create')
|
|
204
328
|
.description('Mode A: full pipeline from a product brief to the output package')
|
|
205
329
|
.requiredOption('--brief <file>', 'product brief (markdown)')
|
|
206
|
-
.option('--model <model>', 'codex | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
330
|
+
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
207
331
|
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
|
|
208
332
|
.action(async (opts: { brief: string; model?: string; interactive?: boolean }) => {
|
|
209
333
|
const repo = repoOf(program.opts());
|