copperhead 0.6.0 → 0.7.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 (58) hide show
  1. package/dist/agent/loop.js +118 -16
  2. package/dist/agent/loop.js.map +1 -1
  3. package/dist/agent/prompts.js +2 -1
  4. package/dist/agent/prompts.js.map +1 -1
  5. package/dist/agent/providers/claude-code.js +207 -27
  6. package/dist/agent/providers/claude-code.js.map +1 -1
  7. package/dist/agent/recovery.js +148 -0
  8. package/dist/agent/recovery.js.map +1 -0
  9. package/dist/agent/render.js +17 -2
  10. package/dist/agent/render.js.map +1 -1
  11. package/dist/agent/response-cache.js +81 -0
  12. package/dist/agent/response-cache.js.map +1 -0
  13. package/dist/agent/tools.js +61 -4
  14. package/dist/agent/tools.js.map +1 -1
  15. package/dist/agent/transcript.js.map +1 -1
  16. package/dist/commands/create.js +470 -35
  17. package/dist/commands/create.js.map +1 -1
  18. package/dist/config.js +19 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/kicad/bootstrap.js +166 -0
  21. package/dist/kicad/bootstrap.js.map +1 -0
  22. package/dist/kicad/spice.js +306 -0
  23. package/dist/kicad/spice.js.map +1 -0
  24. package/dist/kicad/symlib.js +228 -0
  25. package/dist/kicad/symlib.js.map +1 -0
  26. package/dist/memory/bom-table.js +193 -22
  27. package/dist/memory/bom-table.js.map +1 -1
  28. package/dist/memory/drift.js +33 -11
  29. package/dist/memory/drift.js.map +1 -1
  30. package/dist/util/git.js +37 -1
  31. package/dist/util/git.js.map +1 -1
  32. package/dist/util/preflight.js +37 -0
  33. package/dist/util/preflight.js.map +1 -1
  34. package/dist/util/retry.js +23 -0
  35. package/dist/util/retry.js.map +1 -1
  36. package/dist/util/tmp.js +119 -0
  37. package/dist/util/tmp.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/agent/loop.ts +136 -16
  40. package/src/agent/prompts.ts +2 -1
  41. package/src/agent/providers/claude-code.ts +207 -24
  42. package/src/agent/recovery.ts +162 -0
  43. package/src/agent/render.ts +28 -1
  44. package/src/agent/response-cache.ts +80 -0
  45. package/src/agent/tools.ts +62 -4
  46. package/src/agent/transcript.ts +1 -0
  47. package/src/agent/types.ts +17 -0
  48. package/src/commands/create.ts +528 -38
  49. package/src/config.ts +34 -0
  50. package/src/kicad/bootstrap.ts +181 -0
  51. package/src/kicad/spice.ts +399 -0
  52. package/src/kicad/symlib.ts +248 -0
  53. package/src/memory/bom-table.ts +191 -20
  54. package/src/memory/drift.ts +42 -11
  55. package/src/util/git.ts +37 -1
  56. package/src/util/preflight.ts +44 -0
  57. package/src/util/retry.ts +29 -0
  58. package/src/util/tmp.ts +113 -0
@@ -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: 'Create a new file (docs, outputs). Refuses to overwrite anything or to create KiCad files.',
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 report = await runErc(path.join(ctx.repoRoot, ctx.config.schematic));
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
- return formatViolations(report);
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) ?? '';
@@ -12,6 +12,7 @@ export type ExitPath =
12
12
  | 'repair-cycles-exhausted'
13
13
  | 'commit-failed'
14
14
  | 'provider-error'
15
+ | 'session-limit'
15
16
  | 'stalled';
16
17
 
17
18
  /** Post-run addenda recorded at every terminal branch (AC-8.5). */
@@ -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 {