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.
Files changed (99) hide show
  1. package/README.md +41 -2
  2. package/dist/agent/context.js +2 -0
  3. package/dist/agent/context.js.map +1 -0
  4. package/dist/agent/dock-renderer.js +2 -2
  5. package/dist/agent/dock-renderer.js.map +1 -1
  6. package/dist/agent/envelope.js +105 -0
  7. package/dist/agent/envelope.js.map +1 -0
  8. package/dist/agent/loop.js +34 -14
  9. package/dist/agent/loop.js.map +1 -1
  10. package/dist/agent/providers/claude-code.js +17 -1
  11. package/dist/agent/providers/claude-code.js.map +1 -1
  12. package/dist/agent/providers/codex.js +84 -39
  13. package/dist/agent/providers/codex.js.map +1 -1
  14. package/dist/agent/recovery.js +91 -14
  15. package/dist/agent/recovery.js.map +1 -1
  16. package/dist/agent/registry.js +49 -0
  17. package/dist/agent/registry.js.map +1 -0
  18. package/dist/agent/render.js +2 -2
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/theme.js +10 -5
  21. package/dist/agent/theme.js.map +1 -1
  22. package/dist/agent/tools.js +99 -769
  23. package/dist/agent/tools.js.map +1 -1
  24. package/dist/capabilities/define.js +35 -0
  25. package/dist/capabilities/define.js.map +1 -0
  26. package/dist/capabilities/handlers.js +744 -0
  27. package/dist/capabilities/handlers.js.map +1 -0
  28. package/dist/capabilities/helpers.js +39 -0
  29. package/dist/capabilities/helpers.js.map +1 -0
  30. package/dist/capabilities/index.js +50 -0
  31. package/dist/capabilities/index.js.map +1 -0
  32. package/dist/capabilities/skills/generate-report.js +23 -0
  33. package/dist/capabilities/skills/generate-report.js.map +1 -0
  34. package/dist/cli.js +84 -1
  35. package/dist/cli.js.map +1 -1
  36. package/dist/commands/create.js +5 -2
  37. package/dist/commands/create.js.map +1 -1
  38. package/dist/commands/doctor.js +33 -3
  39. package/dist/commands/doctor.js.map +1 -1
  40. package/dist/commands/skill.js +109 -0
  41. package/dist/commands/skill.js.map +1 -0
  42. package/dist/commands/sync.js +3 -1
  43. package/dist/commands/sync.js.map +1 -1
  44. package/dist/config.js +18 -6
  45. package/dist/config.js.map +1 -1
  46. package/dist/kicad/cli.js +106 -18
  47. package/dist/kicad/cli.js.map +1 -1
  48. package/dist/kicad/draft/draft.js +3 -0
  49. package/dist/kicad/draft/draft.js.map +1 -1
  50. package/dist/kicad/draft/engine.js +3139 -218
  51. package/dist/kicad/draft/engine.js.map +1 -1
  52. package/dist/kicad/draft/symsource.js +24 -10
  53. package/dist/kicad/draft/symsource.js.map +1 -1
  54. package/dist/kicad/emit.js +45 -6
  55. package/dist/kicad/emit.js.map +1 -1
  56. package/dist/kicad/legibility.js +51 -4
  57. package/dist/kicad/legibility.js.map +1 -1
  58. package/dist/kicad/score.js +173 -3
  59. package/dist/kicad/score.js.map +1 -1
  60. package/dist/kicad/sexp.js +32 -6
  61. package/dist/kicad/sexp.js.map +1 -1
  62. package/dist/mcp/server.js +485 -0
  63. package/dist/mcp/server.js.map +1 -0
  64. package/dist/memory/scaffold.js +8 -1
  65. package/dist/memory/scaffold.js.map +1 -1
  66. package/package.json +5 -2
  67. package/src/agent/context.ts +35 -0
  68. package/src/agent/dock-renderer.ts +3 -2
  69. package/src/agent/envelope.ts +124 -0
  70. package/src/agent/loop.ts +45 -17
  71. package/src/agent/providers/claude-code.ts +22 -1
  72. package/src/agent/providers/codex.ts +91 -42
  73. package/src/agent/recovery.ts +89 -12
  74. package/src/agent/registry.ts +58 -0
  75. package/src/agent/render.ts +4 -3
  76. package/src/agent/theme.ts +15 -5
  77. package/src/agent/tools.ts +124 -816
  78. package/src/agent/types.ts +10 -5
  79. package/src/capabilities/define.ts +88 -0
  80. package/src/capabilities/handlers.ts +769 -0
  81. package/src/capabilities/helpers.ts +37 -0
  82. package/src/capabilities/index.ts +53 -0
  83. package/src/capabilities/skills/generate-report.ts +25 -0
  84. package/src/cli.ts +84 -1
  85. package/src/commands/create.ts +5 -2
  86. package/src/commands/doctor.ts +34 -3
  87. package/src/commands/skill.ts +127 -0
  88. package/src/commands/sync.ts +5 -3
  89. package/src/config.ts +32 -8
  90. package/src/kicad/cli.ts +129 -18
  91. package/src/kicad/draft/draft.ts +2 -0
  92. package/src/kicad/draft/engine.ts +3034 -226
  93. package/src/kicad/draft/symsource.ts +24 -10
  94. package/src/kicad/emit.ts +71 -7
  95. package/src/kicad/legibility.ts +55 -6
  96. package/src/kicad/score.ts +187 -8
  97. package/src/kicad/sexp.ts +37 -6
  98. package/src/mcp/server.ts +560 -0
  99. 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
- .action(async () => {
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')
@@ -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;
@@ -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
- return { name: 'kicad-cli', status: 'ok', detail: await probe() };
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 >= 9 (bundles kicad-cli); ERC/DRC gates need it.',
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
+ }
@@ -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
- return { ok: res.outcome === 'success' };
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 exceeding this is aborted and
39
- * retried, so a hung call can't stall the run forever. <=0 disables it. */
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 lib_symbols + instances edit,
83
- // ~40k output tokens) on the claude-code provider legitimately runs several
84
- // minutes; the old 5-min deadline killed those mid-flight and, because the
85
- // watchdog budget is spent per stage, could fail a stage that was only slow,
86
- // not hung. 10 min clears the largest observed turns while still catching a
87
- // genuinely stuck subprocess.
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