copperhead 0.10.0 → 0.11.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 +41 -2
- package/dist/agent/context.js +2 -0
- package/dist/agent/context.js.map +1 -0
- package/dist/agent/dock-renderer.js +2 -2
- package/dist/agent/dock-renderer.js.map +1 -1
- package/dist/agent/envelope.js +105 -0
- package/dist/agent/envelope.js.map +1 -0
- package/dist/agent/loop.js +34 -14
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/providers/claude-code.js +17 -1
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/codex.js +84 -39
- package/dist/agent/providers/codex.js.map +1 -1
- package/dist/agent/recovery.js +91 -14
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/registry.js +49 -0
- package/dist/agent/registry.js.map +1 -0
- package/dist/agent/render.js +2 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/theme.js +10 -5
- package/dist/agent/theme.js.map +1 -1
- package/dist/agent/tools.js +99 -769
- package/dist/agent/tools.js.map +1 -1
- package/dist/capabilities/define.js +35 -0
- package/dist/capabilities/define.js.map +1 -0
- package/dist/capabilities/handlers.js +744 -0
- package/dist/capabilities/handlers.js.map +1 -0
- package/dist/capabilities/helpers.js +39 -0
- package/dist/capabilities/helpers.js.map +1 -0
- package/dist/capabilities/index.js +50 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/skills/generate-report.js +23 -0
- package/dist/capabilities/skills/generate-report.js.map +1 -0
- package/dist/cli.js +84 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +5 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +33 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skill.js +109 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +18 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/draft/draft.js +3 -0
- package/dist/kicad/draft/draft.js.map +1 -1
- package/dist/kicad/draft/engine.js +3139 -218
- package/dist/kicad/draft/engine.js.map +1 -1
- package/dist/kicad/draft/symsource.js +24 -10
- package/dist/kicad/draft/symsource.js.map +1 -1
- package/dist/kicad/emit.js +45 -6
- package/dist/kicad/emit.js.map +1 -1
- package/dist/kicad/legibility.js +51 -4
- package/dist/kicad/legibility.js.map +1 -1
- package/dist/kicad/score.js +173 -3
- package/dist/kicad/score.js.map +1 -1
- package/dist/kicad/sexp.js +32 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/scaffold.js +8 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/package.json +5 -2
- package/src/agent/context.ts +35 -0
- package/src/agent/dock-renderer.ts +3 -2
- package/src/agent/envelope.ts +124 -0
- package/src/agent/loop.ts +45 -17
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/recovery.ts +89 -12
- package/src/agent/registry.ts +58 -0
- package/src/agent/render.ts +4 -3
- package/src/agent/theme.ts +15 -5
- package/src/agent/tools.ts +124 -816
- package/src/agent/types.ts +10 -5
- package/src/capabilities/define.ts +88 -0
- package/src/capabilities/handlers.ts +769 -0
- package/src/capabilities/helpers.ts +37 -0
- package/src/capabilities/index.ts +53 -0
- package/src/capabilities/skills/generate-report.ts +25 -0
- package/src/cli.ts +84 -1
- package/src/commands/create.ts +5 -2
- package/src/commands/doctor.ts +34 -3
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +32 -8
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/draft/draft.ts +2 -0
- package/src/kicad/draft/engine.ts +3034 -226
- package/src/kicad/draft/symsource.ts +24 -10
- package/src/kicad/emit.ts +71 -7
- package/src/kicad/legibility.ts +55 -6
- package/src/kicad/score.ts +187 -8
- package/src/kicad/sexp.ts +37 -6
- package/src/mcp/server.ts +560 -0
- package/src/memory/scaffold.ts +8 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isKicadFile } from '../util/paths.js';
|
|
2
|
+
import type { RunContext } from '../agent/context.js';
|
|
3
|
+
|
|
4
|
+
export const str = (args: Record<string, unknown>, key: string): string => {
|
|
5
|
+
const v = args[key];
|
|
6
|
+
if (typeof v !== 'string' || v === '') throw new Error(`missing required string arg "${key}"`);
|
|
7
|
+
return v;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// U+FFFD (the Unicode replacement character) is what a byte sequence becomes
|
|
11
|
+
// when UTF-8 decoding fails — most often a multibyte glyph (Ω, µ, ±, °) split
|
|
12
|
+
// across a streaming chunk boundary and decoded per-chunk upstream in the
|
|
13
|
+
// provider SDK (I2). It never appears in a legitimately authored PCB doc, so
|
|
14
|
+
// its presence in a content-bearing tool arg means the value arrived corrupted.
|
|
15
|
+
// Reject the call before it lands on disk so the model re-emits; the corruption
|
|
16
|
+
// is nondeterministic (it depends on where a chunk boundary fell), so the retry
|
|
17
|
+
// almost always comes through clean — far cheaper than shipping a mangled value
|
|
18
|
+
// like "5.1kΩ" → "5.1k�" into DECISIONS.md and only noticing on review.
|
|
19
|
+
const REPLACEMENT_CHAR = '�';
|
|
20
|
+
export function corruptionError(fields: Record<string, unknown>): string | null {
|
|
21
|
+
const bad = Object.entries(fields)
|
|
22
|
+
.filter(([, v]) => typeof v === 'string' && v.includes(REPLACEMENT_CHAR))
|
|
23
|
+
.map(([k]) => k);
|
|
24
|
+
if (!bad.length) return null;
|
|
25
|
+
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").`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function markTouched(ctx: RunContext, rel: string): void {
|
|
29
|
+
ctx.filesTouched.add(rel);
|
|
30
|
+
if (isKicadFile(rel)) {
|
|
31
|
+
ctx.ledger.onKicadEdit(rel);
|
|
32
|
+
if (rel.endsWith('.kicad_sch')) ctx.lastErc = null;
|
|
33
|
+
if (rel.endsWith('.kicad_pcb')) ctx.lastDrc = null;
|
|
34
|
+
} else if (rel.endsWith('.md')) {
|
|
35
|
+
ctx.ledger.onDocEdit(rel);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { outcomeResult, textResult, type ViewHint } from '../agent/envelope.js';
|
|
2
|
+
import { defineTool, type CatalogEntry, type CatalogTool } from './define.js';
|
|
3
|
+
import { HANDLERS } from './handlers.js';
|
|
4
|
+
import generateReport from './skills/generate-report.js';
|
|
5
|
+
|
|
6
|
+
export type { CatalogEntry, CatalogSkill, CatalogTool } from './define.js';
|
|
7
|
+
export { defineTool, defineSkill } from './define.js';
|
|
8
|
+
|
|
9
|
+
const HINT: Record<string, ViewHint> = {
|
|
10
|
+
read_file: 'query',
|
|
11
|
+
search: 'query',
|
|
12
|
+
list_symbols: 'query',
|
|
13
|
+
list_nets: 'query',
|
|
14
|
+
propose_change: 'mutation',
|
|
15
|
+
validate_change: 'diagnostic',
|
|
16
|
+
edit_file: 'mutation',
|
|
17
|
+
write_file: 'mutation',
|
|
18
|
+
run_erc: 'diagnostic',
|
|
19
|
+
search_symbols: 'query',
|
|
20
|
+
symbol_pins: 'query',
|
|
21
|
+
verify_symbols: 'diagnostic',
|
|
22
|
+
draft_schematic: 'mutation',
|
|
23
|
+
score_schematic: 'diagnostic',
|
|
24
|
+
check_legibility: 'diagnostic',
|
|
25
|
+
run_drc: 'diagnostic',
|
|
26
|
+
export_svg: 'export',
|
|
27
|
+
export_outputs: 'export',
|
|
28
|
+
check_drift: 'diagnostic',
|
|
29
|
+
record_constraint: 'mutation',
|
|
30
|
+
resolve_affected: 'mutation',
|
|
31
|
+
record_decision: 'mutation',
|
|
32
|
+
finish: 'diagnostic',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function wrap(def: (typeof HANDLERS)[number]): CatalogTool {
|
|
36
|
+
const viewHint = HINT[def.schema.name];
|
|
37
|
+
if (!viewHint) throw new Error(`missing viewHint for ${def.schema.name}`);
|
|
38
|
+
return defineTool({
|
|
39
|
+
schema: def.schema,
|
|
40
|
+
version: 1,
|
|
41
|
+
viewHint,
|
|
42
|
+
gate: def.requiresUnlock ? (ctx) => ctx.editsUnlocked : () => true,
|
|
43
|
+
handler: async (ctx, args) => {
|
|
44
|
+
const result = await def.handler(ctx, args);
|
|
45
|
+
return typeof result === 'string'
|
|
46
|
+
? textResult(result, viewHint)
|
|
47
|
+
: outcomeResult(result.text, result.ok, viewHint);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tools from HANDLERS + every skill module imported below (conformance checks skills/). */
|
|
53
|
+
export const catalog: CatalogEntry[] = [...HANDLERS.map(wrap), generateReport];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineSkill } from '../define.js';
|
|
2
|
+
|
|
3
|
+
export default defineSkill({
|
|
4
|
+
schema: {
|
|
5
|
+
name: 'generate_report',
|
|
6
|
+
description:
|
|
7
|
+
'Read-only report of the current design: ERC, DRC (if a board exists), drift, nets, and an optional SVG path. Does not edit files.',
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: { scope: { type: 'string', enum: ['power', 'all'], description: 'Report scope (default all)' } },
|
|
11
|
+
required: [],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
version: 1,
|
|
15
|
+
viewHint: 'diagnostic',
|
|
16
|
+
tools: ['read_file', 'search', 'list_nets', 'run_erc', 'run_drc', 'export_svg', 'check_drift'],
|
|
17
|
+
maxTurns: 8,
|
|
18
|
+
prompt: (_ctx, args) => `You are generating a read-only design report (scope: ${args.scope === 'power' ? 'power' : 'all'}).
|
|
19
|
+
Call the available tools to gather ERC, DRC (only if a board is configured; otherwise skip), drift, and the net list. Optionally export an SVG of the schematic.
|
|
20
|
+
Do not edit any file. Do not call finish. When you have those results, stop calling tools.`,
|
|
21
|
+
isComplete: (ctx) =>
|
|
22
|
+
(!ctx.config.schematic || ctx.lastErc !== null) &&
|
|
23
|
+
(!ctx.config.board || ctx.lastDrc !== null) &&
|
|
24
|
+
(!ctx.config.schematic || ctx.lastDrift != null),
|
|
25
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -226,13 +226,19 @@ const scoreGroup = program
|
|
|
226
226
|
scoreGroup
|
|
227
227
|
.command('schematic')
|
|
228
228
|
.description('legibility and layout score for the schematic')
|
|
229
|
-
.
|
|
229
|
+
.option('--file <path>', 'score this .kicad_sch instead of the configured schematic (any sheet, no repo needed)')
|
|
230
|
+
.action(async (opts: { file?: string }) => {
|
|
230
231
|
const repo = repoOf(program.opts());
|
|
231
232
|
const json = Boolean(program.opts().json);
|
|
232
233
|
try {
|
|
233
234
|
const { loadConfig } = await import('./config.js');
|
|
234
235
|
const { scoreSchematic, formatScore } = await import('./kicad/score.js');
|
|
235
236
|
const path = await import('node:path');
|
|
237
|
+
if (opts.file) {
|
|
238
|
+
const report = await scoreSchematic(path.resolve(opts.file), { docsDir: null });
|
|
239
|
+
console.log(json ? JSON.stringify(report, null, 2) : formatScore(report));
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
236
242
|
const config = await loadConfig(repo);
|
|
237
243
|
if (!config.schematic) {
|
|
238
244
|
console.error('no schematic configured in .copperhead/config.json');
|
|
@@ -311,6 +317,60 @@ program
|
|
|
311
317
|
},
|
|
312
318
|
);
|
|
313
319
|
|
|
320
|
+
const skillCmd = program.command('skill').description('run a registered skill (nested tool loop; no git commit)');
|
|
321
|
+
|
|
322
|
+
skillCmd
|
|
323
|
+
.command('list')
|
|
324
|
+
.description('list registered skills (LLM-free, network-free)')
|
|
325
|
+
.action(async () => {
|
|
326
|
+
const repo = repoOf(program.opts());
|
|
327
|
+
try {
|
|
328
|
+
const { listSkills } = await import('./commands/skill.js');
|
|
329
|
+
const skills = await listSkills(repo);
|
|
330
|
+
if (program.opts().json) console.log(JSON.stringify(skills, null, 2));
|
|
331
|
+
else {
|
|
332
|
+
for (const s of skills) {
|
|
333
|
+
console.log(`${s.available ? '·' : '×'} ${s.name.replaceAll('_', '-')} ${s.description.split('\n')[0]}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
process.exit(0);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error((err as Error).message);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
skillCmd
|
|
344
|
+
.command('run')
|
|
345
|
+
.description('run a skill by name (generate-report)')
|
|
346
|
+
.argument('<name>', 'skill name (kebab or underscore)')
|
|
347
|
+
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code | compat:<id>')
|
|
348
|
+
.option('--scope <scope>', 'generate-report scope: power | all', 'all')
|
|
349
|
+
.action(async (name: string, opts: { model?: string; scope?: string }) => {
|
|
350
|
+
const repo = repoOf(program.opts());
|
|
351
|
+
const json = Boolean(program.opts().json);
|
|
352
|
+
// `process.exit` skips pending finally blocks, so the provider close has to
|
|
353
|
+
// finish before the exit call — hence the code is carried out, not exited on.
|
|
354
|
+
let code = 1;
|
|
355
|
+
try {
|
|
356
|
+
const { runSkillCli, providerForSkillRun } = await import('./commands/skill.js');
|
|
357
|
+
const { provider } = await providerForSkillRun(repo, opts.model);
|
|
358
|
+
const res = await runSkillCli({
|
|
359
|
+
repoRoot: repo,
|
|
360
|
+
name,
|
|
361
|
+
args: { scope: opts.scope === 'power' ? 'power' : 'all' },
|
|
362
|
+
provider,
|
|
363
|
+
json,
|
|
364
|
+
});
|
|
365
|
+
console.log(res.text);
|
|
366
|
+
code = res.code;
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.error((err as Error).message);
|
|
369
|
+
code = 1;
|
|
370
|
+
}
|
|
371
|
+
process.exit(code);
|
|
372
|
+
});
|
|
373
|
+
|
|
314
374
|
program
|
|
315
375
|
.command('sync')
|
|
316
376
|
.description('verify the whole design state for inconsistencies and resolve drift')
|
|
@@ -347,6 +407,29 @@ program
|
|
|
347
407
|
}
|
|
348
408
|
});
|
|
349
409
|
|
|
410
|
+
program
|
|
411
|
+
.command('mcp')
|
|
412
|
+
.description('EXPERIMENTAL: serve the gated pipeline to MCP hosts over stdio (unstable surface)')
|
|
413
|
+
// `--repo` is also a global flag, but a host config reads as
|
|
414
|
+
// `args: ["mcp", "--repo", "/path"]`, so the command accepts it locally too.
|
|
415
|
+
.option('--repo <path>', 'target repository (default: cwd)')
|
|
416
|
+
.action(async (opts: { repo?: string }) => {
|
|
417
|
+
const repo = repoOf(opts.repo ? { repo: opts.repo } : program.opts());
|
|
418
|
+
try {
|
|
419
|
+
// Imported lazily, like `skill`: the MCP SDK and zod are a ~150ms load
|
|
420
|
+
// that every other command — including the pre-commit `check` — would
|
|
421
|
+
// otherwise pay, and a resolution failure here would take down the whole
|
|
422
|
+
// CLI rather than just this command.
|
|
423
|
+
const { startMcpServer } = await import('./mcp/server.js');
|
|
424
|
+
// Never returns until the host closes stdio. Nothing is printed to
|
|
425
|
+
// stdout here or anywhere downstream: it carries JSON-RPC alone.
|
|
426
|
+
await startMcpServer({ repoRoot: repo });
|
|
427
|
+
} catch (err) {
|
|
428
|
+
console.error((err as Error).message);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
350
433
|
program
|
|
351
434
|
.command('demo')
|
|
352
435
|
.description('tour of what copperhead does, or run the USB-C breakout create pipeline')
|
package/src/commands/create.ts
CHANGED
|
@@ -239,7 +239,7 @@ export const STAGES: Stage[] = [
|
|
|
239
239
|
return true;
|
|
240
240
|
},
|
|
241
241
|
prompt: () =>
|
|
242
|
-
'Stage 4: schematic. An empty KiCad project has already been scaffolded and wired into .copperhead/config.json. You author INTENT, never geometry: write the netlist-intent IR and call draft_schematic — the deterministic engine computes every coordinate, wire, label, power symbol, and group box, and the sheet it draws satisfies the drafting standard by construction (captioned group boxes per SUBSYSTEMS.md subsystem, left-to-right flow, rails up and grounds down, net labels between groups, filled title block). The IR (schematic.intent.json) is JSON: {"version": 1, "parts": [{"ref", "libId", "value", "footprint", "group"}], "nets": [{"name", "pins": ["REF.PIN", …], "kind"?}], "noConnect": ["REF.PIN", …], "hints"?: {"groupOrder"?, "paper"?, "date"?}}. Build it from BOM.md (same refdes and values — validation cross-checks and refuses mismatches) and SUBSYSTEMS.md (every non-power part names one subsystem heading as its group). Use exact canonical KiCad lib_ids (e.g. Device:R) and REAL pin numbers from the library: the pin dossier below (when present) already lists every BOM part\'s installed symbol and its real pins — work from it and from symbol_pins rather than reading .kicad_sym files, and validation lists a part\'s actual pins when you name one that does not exist. Declare every deliberately unused pin in noConnect; power rails are recognized from pin types automatically (override with "kind" only when the inference is wrong — the draft report lists every net\'s resolved class). Pass the full IR as intent_json to draft_schematic; the report embeds the legibility findings and the score for the fresh sheet. To repair ANY finding (ERC, legibility, validation), fix the IR and call draft_schematic again — edit_file is refused on the drafted sheet. Text-collision findings scale with TEXT LENGTH: a net label, part value, or SUBSYSTEMS heading that is shorter draws a smaller box, so renaming a colliding net (and updating PINOUT.md) or tightening a long heading is a real repair lever; paper size and declaration order are not (placement is grid-derived). When the draft is clean run run_erc and check_drift, update PINOUT.md to match the IR\'s pin assignments, and finish.',
|
|
242
|
+
'Stage 4: schematic. An empty KiCad project has already been scaffolded and wired into .copperhead/config.json. You author INTENT, never geometry: write the netlist-intent IR and call draft_schematic — the deterministic engine computes every coordinate, wire, label, power symbol, and group box, and the sheet it draws satisfies the drafting standard by construction (captioned group boxes per SUBSYSTEMS.md subsystem, left-to-right flow, rails up and grounds down, net labels between groups, filled title block). The IR (schematic.intent.json) is JSON: {"version": 1, "parts": [{"ref", "libId", "value", "footprint", "group"}], "nets": [{"name", "pins": ["REF.PIN", …], "kind"?}], "noConnect": ["REF.PIN", …], "hints"?: {"groupOrder"?, "paper"?, "date"?}}. Build it from BOM.md (same refdes and values — validation cross-checks and refuses mismatches) and SUBSYSTEMS.md (every non-power part names one subsystem heading as its group). Use exact canonical KiCad lib_ids (e.g. Device:R) and REAL pin numbers from the library: the pin dossier below (when present) already lists every BOM part\'s installed symbol and its real pins — work from it and from symbol_pins rather than reading .kicad_sym files, and validation lists a part\'s actual pins when you name one that does not exist. Name nets as a reader expects: a bus or interface shares a prefix (I2S_BCLK, I2S_DIN, I2S_LRCLK; SPI_…; BTN_…) so the drawing colours the family together, differential pairs end in +/- or P/N, and part values carry their unit (F, H, R). Declare every deliberately unused pin in noConnect; power rails are recognized from pin types automatically (override with "kind" only when the inference is wrong — the draft report lists every net\'s resolved class). Pass the full IR as intent_json to draft_schematic; the report embeds the legibility findings and the score for the fresh sheet. To repair ANY finding (ERC, legibility, validation), fix the IR and call draft_schematic again — edit_file is refused on the drafted sheet. Text-collision findings scale with TEXT LENGTH: a net label, part value, or SUBSYSTEMS heading that is shorter draws a smaller box, so renaming a colliding net (and updating PINOUT.md) or tightening a long heading is a real repair lever; paper size and declaration order are not (placement is grid-derived). When the draft is clean run run_erc and check_drift, update PINOUT.md to match the IR\'s pin assignments, and finish.',
|
|
243
243
|
},
|
|
244
244
|
{
|
|
245
245
|
name: 'layout-draft',
|
|
@@ -905,9 +905,12 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
905
905
|
// with the schematic stage: one header edit, ERC "clean" on an empty
|
|
906
906
|
// sheet). Advancing anyway lets every later stage run against a design
|
|
907
907
|
// that isn't there, so the completion contract is the real gate.
|
|
908
|
+
// The run's own reason rides along: the diagnosis transcript excerpt holds
|
|
909
|
+
// only assistant text and tool results, so without it a hung call or a turn
|
|
910
|
+
// stopped at the hard cap reaches the diagnosis as a bare "provider-error".
|
|
908
911
|
const failure =
|
|
909
912
|
res.outcome !== 'success'
|
|
910
|
-
? `the run ended as "${res.outcome}" (${res.exitPath})`
|
|
913
|
+
? `the run ended as "${res.outcome}" (${res.exitPath})${res.summary ? `: ${res.summary}` : ''}`
|
|
911
914
|
: !(await stage.isComplete(opts.repoRoot, config.docs))
|
|
912
915
|
? await contractGapDetail(stage.name, opts.repoRoot, config)
|
|
913
916
|
: null;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type CompatSettings,
|
|
14
14
|
type CopperheadConfig,
|
|
15
15
|
} from '../config.js';
|
|
16
|
-
import { kicadCliVersion } from '../kicad/cli.js';
|
|
16
|
+
import { kicadCliVersion, MIN_KICAD_MAJOR } from '../kicad/cli.js';
|
|
17
17
|
import { redactSecrets } from '../util/redact.js';
|
|
18
18
|
import { isNotFoundError } from '../util/preflight.js';
|
|
19
19
|
|
|
@@ -85,15 +85,46 @@ function nodeCheck(version: string): DoctorCheck {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// MIN_KICAD_MAJOR (imported from kicad/cli.ts) enforces the minimum supported KiCad version for ERC/DRC.
|
|
89
|
+
// Behaviour note: kicadCheck() returns status: 'fail' (exit non-zero) for any
|
|
90
|
+
// kicad-cli version below this threshold, and for output that contains no
|
|
91
|
+
// parseable N.N version token. This is intentional user-visible behaviour and
|
|
92
|
+
// is NOT a no-op hint correction: users on KiCad 7 will see doctor fail where
|
|
93
|
+
// it previously succeeded. The gate mirrors the README's stated requirement and
|
|
94
|
+
// follows the same pattern as MIN_NODE_MAJOR above.
|
|
95
|
+
|
|
88
96
|
async function kicadCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
|
|
89
97
|
try {
|
|
90
|
-
|
|
98
|
+
const raw = await probe();
|
|
99
|
+
// Anchored match: require a version-shaped token at the start of a line so
|
|
100
|
+
// an incidental N.N elsewhere in stdout (e.g. "OpenGL 3.2 unsupported")
|
|
101
|
+
// does not shadow the real KiCad version. The `m` flag makes ^ match
|
|
102
|
+
// line-starts within a multi-line string.
|
|
103
|
+
const match = raw.match(/(?:^|\n)\s*(?:kicad-cli\s+)?v?(\d+)\.\d+/m);
|
|
104
|
+
const major = match ? Number(match[1]) : NaN;
|
|
105
|
+
if (!Number.isFinite(major)) {
|
|
106
|
+
return {
|
|
107
|
+
name: 'kicad-cli',
|
|
108
|
+
status: 'fail',
|
|
109
|
+
detail: raw || 'could not parse version',
|
|
110
|
+
hint: `copperhead needs KiCad >= ${MIN_KICAD_MAJOR}; install or configure a compatible kicad-cli.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (major < MIN_KICAD_MAJOR) {
|
|
114
|
+
return {
|
|
115
|
+
name: 'kicad-cli',
|
|
116
|
+
status: 'fail',
|
|
117
|
+
detail: `${raw} (< ${MIN_KICAD_MAJOR})`,
|
|
118
|
+
hint: `copperhead needs KiCad >= ${MIN_KICAD_MAJOR}; upgrade KiCad (https://www.kicad.org/download/).`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { name: 'kicad-cli', status: 'ok', detail: raw };
|
|
91
122
|
} catch {
|
|
92
123
|
return {
|
|
93
124
|
name: 'kicad-cli',
|
|
94
125
|
status: 'fail',
|
|
95
126
|
detail: 'not found on PATH',
|
|
96
|
-
hint: 'install KiCad >=
|
|
127
|
+
hint: 'install KiCad >= 8 (bundles kicad-cli); ERC/DRC gates need it.',
|
|
97
128
|
};
|
|
98
129
|
}
|
|
99
130
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { loadConfig, resolveModel } from '../config.js';
|
|
2
|
+
import { flatten, type ToolResult } from '../agent/envelope.js';
|
|
3
|
+
import { makeProvider } from '../agent/loop.js';
|
|
4
|
+
import { dispatchToolResult, registry, type RunContext } from '../agent/tools.js';
|
|
5
|
+
import type { Provider } from '../agent/types.js';
|
|
6
|
+
import { Transcript } from '../agent/transcript.js';
|
|
7
|
+
import { ObligationsLedger } from '../agent/ledger.js';
|
|
8
|
+
|
|
9
|
+
export class SkillCliError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'SkillCliError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function catalogNameFromCli(name: string): string {
|
|
17
|
+
return name.replace(/-/g, '_');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function listSkills(repoRoot: string): Promise<{ name: string; available: boolean; description: string }[]> {
|
|
21
|
+
const ctx = await minimalCtx(repoRoot, false);
|
|
22
|
+
const listed = new Set(registry.list(ctx).map((e) => e.name));
|
|
23
|
+
return registry.skills().map((s) => ({
|
|
24
|
+
name: s.name,
|
|
25
|
+
available: listed.has(s.name),
|
|
26
|
+
description: s.schema.description,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runSkill(opts: {
|
|
31
|
+
repoRoot: string;
|
|
32
|
+
name: string;
|
|
33
|
+
args?: Record<string, unknown>;
|
|
34
|
+
provider: Provider;
|
|
35
|
+
}): Promise<ToolResult> {
|
|
36
|
+
const catalogName = catalogNameFromCli(opts.name);
|
|
37
|
+
const entry = registry.get(catalogName);
|
|
38
|
+
if (!entry || entry.kind !== 'skill') throw new SkillCliError(`unknown skill "${opts.name}"`);
|
|
39
|
+
const ctx = await minimalCtx(opts.repoRoot);
|
|
40
|
+
if (!registry.list(ctx).some((e) => e.name === catalogName)) {
|
|
41
|
+
throw new SkillCliError(`skill "${opts.name}" is not available in this repo`);
|
|
42
|
+
}
|
|
43
|
+
return dispatchToolResult(ctx, entry.name, opts.args ?? {}, { provider: opts.provider });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function providerForSkillRun(repoRoot: string, modelFlag?: string): Promise<{ provider: Provider; model: string }> {
|
|
47
|
+
const config = await loadConfig(repoRoot);
|
|
48
|
+
try {
|
|
49
|
+
const { model } = resolveModel(modelFlag, config);
|
|
50
|
+
return { provider: await makeProvider(model), model };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const msg = (err as Error).message;
|
|
53
|
+
throw new SkillCliError(
|
|
54
|
+
msg.includes('no model') || msg.includes('API_KEY') || msg.includes('configured')
|
|
55
|
+
? `${msg} — skill run needs OPENAI_API_KEY, ANTHROPIC_API_KEY, or --model for a saved-login provider (codex, claude-code, cursor).`
|
|
56
|
+
: msg,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatSkillEnvelope(result: ToolResult, json: boolean): string {
|
|
62
|
+
if (json) return JSON.stringify(result, null, 2);
|
|
63
|
+
const body = flatten(result);
|
|
64
|
+
return result.ok ? body : `error: ${body}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One skill run plus the provider's lifecycle. A saved-login provider owns a
|
|
69
|
+
* `mkdtemp` working directory (and, on some backends, an in-flight subprocess);
|
|
70
|
+
* `runAgentLoop` closes it in a `finally` because leaving it behind orphans the
|
|
71
|
+
* process and fills the disk (I8). `skill run` has no agent loop to inherit that
|
|
72
|
+
* from, so the close lives here — around the throw path too, since an unknown or
|
|
73
|
+
* unavailable skill must not leak the provider it already built. Returns the text
|
|
74
|
+
* to print and the exit code, so the CLI's only job is `console.log` + `exit`.
|
|
75
|
+
*/
|
|
76
|
+
export async function runSkillCli(opts: {
|
|
77
|
+
repoRoot: string;
|
|
78
|
+
name: string;
|
|
79
|
+
args?: Record<string, unknown>;
|
|
80
|
+
provider: Provider;
|
|
81
|
+
json: boolean;
|
|
82
|
+
warn?: (line: string) => void;
|
|
83
|
+
}): Promise<{ text: string; code: number }> {
|
|
84
|
+
try {
|
|
85
|
+
const result = await runSkill({
|
|
86
|
+
repoRoot: opts.repoRoot,
|
|
87
|
+
name: opts.name,
|
|
88
|
+
...(opts.args ? { args: opts.args } : {}),
|
|
89
|
+
provider: opts.provider,
|
|
90
|
+
});
|
|
91
|
+
return { text: formatSkillEnvelope(result, opts.json), code: result.ok ? 0 : 1 };
|
|
92
|
+
} finally {
|
|
93
|
+
try {
|
|
94
|
+
await opts.provider.close?.();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
(opts.warn ?? ((l: string) => console.error(l)))(
|
|
97
|
+
`warning: ${opts.provider.name} provider cleanup failed (${(err as Error).message})`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function minimalCtx(repoRoot: string, initializeTranscript = true): Promise<RunContext> {
|
|
104
|
+
const transcript = new Transcript(repoRoot);
|
|
105
|
+
if (initializeTranscript) await transcript.init();
|
|
106
|
+
return {
|
|
107
|
+
repoRoot,
|
|
108
|
+
config: await loadConfig(repoRoot),
|
|
109
|
+
transcript,
|
|
110
|
+
ledger: new ObligationsLedger(),
|
|
111
|
+
runId: 'skill',
|
|
112
|
+
interactive: false,
|
|
113
|
+
confirm: async () => true,
|
|
114
|
+
editsUnlocked: false,
|
|
115
|
+
changeId: null,
|
|
116
|
+
proposalValidated: false,
|
|
117
|
+
filesTouched: new Set(),
|
|
118
|
+
decisions: [],
|
|
119
|
+
lastErc: null,
|
|
120
|
+
lastDrc: null,
|
|
121
|
+
lastLegibility: null,
|
|
122
|
+
lastScore: null,
|
|
123
|
+
lastDrift: null,
|
|
124
|
+
repairCycles: 0,
|
|
125
|
+
finishRequest: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { checkDrift } from '../memory/drift.js';
|
|
|
6
6
|
import { loadConstraints, checkForbiddenPins } from '../memory/constraints.js';
|
|
7
7
|
import { pinNets } from '../kicad/sexp.js';
|
|
8
8
|
import { openspecValidate } from '../openspec/cli.js';
|
|
9
|
-
import { runAgentLoop } from '../agent/loop.js';
|
|
9
|
+
import { runAgentLoop, type RunResult } from '../agent/loop.js';
|
|
10
10
|
import type { RunMetaInput } from '../agent/runmeta.js';
|
|
11
11
|
import type { ProgressRenderer } from '../agent/render.js';
|
|
12
12
|
|
|
@@ -172,7 +172,7 @@ export async function syncResolve(
|
|
|
172
172
|
model: string,
|
|
173
173
|
log: (s: string) => void,
|
|
174
174
|
extras?: { renderer?: ProgressRenderer; meta?: RunMetaInput },
|
|
175
|
-
): Promise<{ ok: boolean }> {
|
|
175
|
+
): Promise<{ ok: boolean; run: RunResult }> {
|
|
176
176
|
const reportText = formatSyncReport(report);
|
|
177
177
|
const res = await runAgentLoop({
|
|
178
178
|
repoRoot,
|
|
@@ -183,5 +183,7 @@ export async function syncResolve(
|
|
|
183
183
|
...(extras?.renderer ? { renderer: extras.renderer } : {}),
|
|
184
184
|
...(extras?.meta ? { meta: extras.meta } : {}),
|
|
185
185
|
});
|
|
186
|
-
|
|
186
|
+
// The full RunResult travels with the verdict so a non-CLI caller (the MCP
|
|
187
|
+
// server) can report the transcript path and files touched the way `do` does.
|
|
188
|
+
return { ok: res.outcome === 'success', run: res };
|
|
187
189
|
}
|
package/src/config.ts
CHANGED
|
@@ -35,9 +35,20 @@ export interface CopperheadConfig {
|
|
|
35
35
|
stageMaxTurns?: Record<string, number>;
|
|
36
36
|
maxRepairCycles: number;
|
|
37
37
|
budgets: Record<string, number>;
|
|
38
|
-
/** Per-turn watchdog (ms). A provider turn
|
|
39
|
-
*
|
|
38
|
+
/** Per-turn inactivity watchdog (ms). A provider turn that goes this long
|
|
39
|
+
* without a response or any streamed progress is treated as hung: aborted and
|
|
40
|
+
* retried, so a hung call can't stall the run forever. A streaming provider
|
|
41
|
+
* restarts it on every progress event, so a long turn that keeps producing
|
|
42
|
+
* output is not killed; a provider that reports no progress gets it as a
|
|
43
|
+
* whole-turn deadline. <=0 disables it. */
|
|
40
44
|
turnTimeoutMs: number;
|
|
45
|
+
/** Hard cap (ms) on one provider turn that is producing output, however much
|
|
46
|
+
* progress it reports. A turn that hits it is too large, not hung, so it fails
|
|
47
|
+
* without a retry: resending the identical request would only run into the cap
|
|
48
|
+
* again. It is never shorter than turnTimeoutMs, and a turn that has reported
|
|
49
|
+
* no progress is judged by turnTimeoutMs alone. <=0 disables it; when unset it
|
|
50
|
+
* defaults to 3600000, or to disabled when turnTimeoutMs is disabled. */
|
|
51
|
+
turnMaxMs: number;
|
|
41
52
|
/** How often (ms) to emit a liveness heartbeat while a provider turn is in
|
|
42
53
|
* flight, so a slow large-output turn is distinguishable from a hung one
|
|
43
54
|
* (5.1). Fires only after the first interval, so quick turns stay silent.
|
|
@@ -79,13 +90,17 @@ export const DEFAULTS: Omit<CopperheadConfig, 'schematic' | 'board'> = {
|
|
|
79
90
|
maxTurns: 40,
|
|
80
91
|
maxRepairCycles: 5,
|
|
81
92
|
budgets: {},
|
|
82
|
-
// 10 min. A single large capture turn (a full
|
|
83
|
-
// ~40k output tokens) on the claude-code
|
|
84
|
-
// minutes; the old 5-min
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
93
|
+
// 10 min without a response or progress. A single large capture turn (a full
|
|
94
|
+
// lib_symbols + instances edit, ~40k output tokens) on the claude-code
|
|
95
|
+
// provider legitimately runs several minutes; the old 5-min whole-turn
|
|
96
|
+
// deadline killed those mid-flight. Some turns run past 10 min too, so the
|
|
97
|
+
// deadline restarts on every streamed progress event: it bounds a silent
|
|
98
|
+
// stretch, not the turn's length. A provider that cannot stream still gets it
|
|
99
|
+
// as a whole-turn deadline, so no turn that finished in time before times out now.
|
|
88
100
|
turnTimeoutMs: 600000,
|
|
101
|
+
// 60 min: the backstop for a turn that keeps streaming. Far past the largest
|
|
102
|
+
// observed turns, while a runaway generation still ends in bounded time.
|
|
103
|
+
turnMaxMs: 3600000,
|
|
89
104
|
// 30s: within one interval an operator knows a turn is alive, and a full
|
|
90
105
|
// 10-min turn emits ~20 lines — enough to distinguish slow from hung without
|
|
91
106
|
// flooding the log. Quick turns (< 30s) emit nothing.
|
|
@@ -119,6 +134,15 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
|
|
|
119
134
|
maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
|
|
120
135
|
budgets: raw.budgets ?? {},
|
|
121
136
|
turnTimeoutMs: typeof raw.turnTimeoutMs === 'number' ? raw.turnTimeoutMs : DEFAULTS.turnTimeoutMs,
|
|
137
|
+
// A repo that switched the turn watchdog off gets no default cap either:
|
|
138
|
+
// before the cap existed that meant no deadline at all, and a default must
|
|
139
|
+
// not quietly bring one back. An explicit turnMaxMs still applies.
|
|
140
|
+
turnMaxMs:
|
|
141
|
+
typeof raw.turnMaxMs === 'number'
|
|
142
|
+
? raw.turnMaxMs
|
|
143
|
+
: typeof raw.turnTimeoutMs === 'number' && raw.turnTimeoutMs <= 0
|
|
144
|
+
? 0
|
|
145
|
+
: DEFAULTS.turnMaxMs,
|
|
122
146
|
heartbeatMs: typeof raw.heartbeatMs === 'number' ? raw.heartbeatMs : DEFAULTS.heartbeatMs,
|
|
123
147
|
maxStageRetries:
|
|
124
148
|
Number.isInteger(raw.maxStageRetries) && (raw.maxStageRetries as number) >= 0
|