copperhead 0.3.0 → 0.5.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 (67) hide show
  1. package/NOTICE +5 -0
  2. package/README.md +72 -9
  3. package/dist/agent/ledger.js +7 -0
  4. package/dist/agent/ledger.js.map +1 -1
  5. package/dist/agent/loop.js +303 -34
  6. package/dist/agent/loop.js.map +1 -1
  7. package/dist/agent/prompts.js +3 -1
  8. package/dist/agent/prompts.js.map +1 -1
  9. package/dist/agent/providers/anthropic.js +28 -13
  10. package/dist/agent/providers/anthropic.js.map +1 -1
  11. package/dist/agent/providers/codex.js +292 -0
  12. package/dist/agent/providers/codex.js.map +1 -0
  13. package/dist/agent/render.js +170 -0
  14. package/dist/agent/render.js.map +1 -0
  15. package/dist/agent/runmeta.js +124 -0
  16. package/dist/agent/runmeta.js.map +1 -0
  17. package/dist/agent/tools.js +117 -16
  18. package/dist/agent/tools.js.map +1 -1
  19. package/dist/agent/transcript.js +23 -0
  20. package/dist/agent/transcript.js.map +1 -1
  21. package/dist/cli.js +47 -11
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/check.js +9 -2
  24. package/dist/commands/check.js.map +1 -1
  25. package/dist/commands/create.js +57 -3
  26. package/dist/commands/create.js.map +1 -1
  27. package/dist/commands/sync.js +3 -1
  28. package/dist/commands/sync.js.map +1 -1
  29. package/dist/config.js +16 -8
  30. package/dist/config.js.map +1 -1
  31. package/dist/kicad/cli.js +58 -8
  32. package/dist/kicad/cli.js.map +1 -1
  33. package/dist/memory/constraints.js +63 -3
  34. package/dist/memory/constraints.js.map +1 -1
  35. package/dist/memory/drift.js +31 -0
  36. package/dist/memory/drift.js.map +1 -1
  37. package/dist/memory/scaffold.js +2 -1
  38. package/dist/memory/scaffold.js.map +1 -1
  39. package/dist/memory/synap.js +152 -0
  40. package/dist/memory/synap.js.map +1 -0
  41. package/dist/util/git.js +125 -4
  42. package/dist/util/git.js.map +1 -1
  43. package/dist/util/preflight.js +24 -0
  44. package/dist/util/preflight.js.map +1 -0
  45. package/package.json +21 -6
  46. package/src/agent/ledger.ts +9 -1
  47. package/src/agent/loop.ts +333 -35
  48. package/src/agent/prompts.ts +3 -1
  49. package/src/agent/providers/anthropic.ts +40 -16
  50. package/src/agent/providers/codex.ts +339 -0
  51. package/src/agent/render.ts +194 -0
  52. package/src/agent/runmeta.ts +198 -0
  53. package/src/agent/tools.ts +119 -15
  54. package/src/agent/transcript.ts +49 -0
  55. package/src/agent/types.ts +1 -0
  56. package/src/cli.ts +51 -12
  57. package/src/commands/check.ts +9 -3
  58. package/src/commands/create.ts +61 -4
  59. package/src/commands/sync.ts +5 -0
  60. package/src/config.ts +29 -9
  61. package/src/kicad/cli.ts +60 -9
  62. package/src/memory/constraints.ts +90 -3
  63. package/src/memory/drift.ts +32 -0
  64. package/src/memory/scaffold.ts +2 -1
  65. package/src/memory/synap.ts +217 -0
  66. package/src/util/git.ts +134 -4
  67. package/src/util/preflight.ts +22 -0
@@ -0,0 +1,198 @@
1
+ import path from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readFile, readdir } from 'node:fs/promises';
5
+ import { loadConstraints } from '../memory/constraints.js';
6
+ import { kicadCliVersion } from '../kicad/cli.js';
7
+ import { branchName, headCommit, uncommittedCount } from '../util/git.js';
8
+ import type { CopperheadConfig, ModelSource } from '../config.js';
9
+
10
+ /** Caller-supplied run identity: facts the loop cannot probe for itself. */
11
+ export interface RunMetaInput {
12
+ command?: 'do' | 'create' | 'sync';
13
+ modelSource?: ModelSource;
14
+ version?: string;
15
+ kicadCliVersion?: string;
16
+ stage?: { name: string; index: number; total: number };
17
+ brief?: { path: string; sha256: string };
18
+ }
19
+
20
+ /**
21
+ * Everything a run needs to be self-describing (AC-8.1). Collected once,
22
+ * rendered onto three surfaces: run-start event, summary.md ## Environment,
23
+ * and the live CLI header. Probe failures are nulls, never errors (AC-8.3).
24
+ */
25
+ export interface RunMeta {
26
+ request: string;
27
+ model: string;
28
+ provider: string;
29
+ modelSource: ModelSource | null;
30
+ runId: string;
31
+ startedAt: string;
32
+ command: 'do' | 'create' | 'sync' | null;
33
+ interactive: boolean;
34
+ stage: { name: string; index: number; total: number } | null;
35
+ brief: { path: string; sha256: string } | null;
36
+ versions: {
37
+ copperhead: string | null;
38
+ installPath: string | null;
39
+ kicadCli: string | null;
40
+ node: string;
41
+ platform: string;
42
+ };
43
+ config: {
44
+ schematic: string | null;
45
+ board: string | null;
46
+ docs: string;
47
+ maxTurns: number;
48
+ maxRepairCycles: number;
49
+ budgets: Record<string, number>;
50
+ };
51
+ git: {
52
+ commit: string | null;
53
+ branch: string | null;
54
+ dirty: boolean | null;
55
+ uncommittedFiles: number | null;
56
+ preCommitHookInstalled: boolean | null;
57
+ };
58
+ openConstraints: number | null;
59
+ priorRuns: number | null;
60
+ }
61
+
62
+ /** A metadata probe must never fail the run it describes (design D4). */
63
+ async function probe<T>(fn: () => Promise<T> | T): Promise<T | null> {
64
+ try {
65
+ return await fn();
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function packageRoot(): string {
72
+ // src/agent/ and dist/agent/ both sit two levels below the package root.
73
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
74
+ }
75
+
76
+ function ownVersion(): string {
77
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
78
+ return version;
79
+ }
80
+
81
+ export interface CollectRunMetaOptions {
82
+ repoRoot: string;
83
+ config: CopperheadConfig;
84
+ /** Effective turn budget for this run (flag override already applied). */
85
+ maxTurns: number;
86
+ runId: string;
87
+ request: string;
88
+ model: string;
89
+ provider: string;
90
+ interactive: boolean;
91
+ input?: RunMetaInput | undefined;
92
+ }
93
+
94
+ export async function collectRunMeta(opts: CollectRunMetaOptions): Promise<RunMeta> {
95
+ const { repoRoot, config, input } = opts;
96
+ const [copperhead, kicadCli, commit, branch, uncommitted, hook, openConstraints, priorRuns] = await Promise.all([
97
+ probe(() => input?.version ?? ownVersion()),
98
+ probe(() => input?.kicadCliVersion ?? kicadCliVersion()),
99
+ probe(() => headCommit(repoRoot)),
100
+ probe(() => branchName(repoRoot)),
101
+ probe(() => uncommittedCount(repoRoot)),
102
+ probe(async () => {
103
+ const hookText = await readFile(path.join(repoRoot, '.git', 'hooks', 'pre-commit'), 'utf8');
104
+ return hookText.includes('copperhead');
105
+ }).then((v) => v ?? false),
106
+ probe(async () => Object.keys(await loadConstraints(repoRoot)).length),
107
+ probe(async () => {
108
+ const entries = await readdir(path.join(repoRoot, '.copperhead', 'runs'));
109
+ return entries.filter((e) => e !== opts.runId).length;
110
+ }).then((v) => v ?? 0),
111
+ ]);
112
+
113
+ return {
114
+ request: opts.request,
115
+ model: opts.model,
116
+ provider: opts.provider,
117
+ modelSource: input?.modelSource ?? null,
118
+ runId: opts.runId,
119
+ startedAt: new Date().toISOString(),
120
+ command: input?.command ?? null,
121
+ interactive: opts.interactive,
122
+ stage: input?.stage ?? null,
123
+ brief: input?.brief ?? null,
124
+ versions: {
125
+ copperhead,
126
+ installPath: await probe(packageRoot),
127
+ kicadCli,
128
+ node: process.version,
129
+ platform: `${process.platform}-${process.arch}`,
130
+ },
131
+ config: {
132
+ schematic: config.schematic,
133
+ board: config.board,
134
+ docs: config.docs,
135
+ maxTurns: opts.maxTurns,
136
+ maxRepairCycles: config.maxRepairCycles,
137
+ budgets: config.budgets,
138
+ },
139
+ git: {
140
+ commit,
141
+ branch,
142
+ dirty: uncommitted === null ? null : uncommitted > 0,
143
+ uncommittedFiles: uncommitted,
144
+ preCommitHookInstalled: hook,
145
+ },
146
+ openConstraints,
147
+ priorRuns,
148
+ };
149
+ }
150
+
151
+ const unk = (v: string | null | undefined): string => v ?? 'unknown';
152
+
153
+ /** ≤ 2 lines, printed before the first turn (AC-8.4). */
154
+ export function renderCliHeader(meta: RunMeta): string[] {
155
+ const v = meta.versions;
156
+ const line1 = [
157
+ `copperhead v${unk(v.copperhead)}${v.installPath ? ` (${v.installPath})` : ''}`,
158
+ `kicad-cli ${unk(v.kicadCli)}`,
159
+ `node ${v.node}`,
160
+ v.platform,
161
+ ].join(' · ');
162
+
163
+ const repoState =
164
+ meta.git.dirty === null
165
+ ? 'unknown'
166
+ : meta.git.dirty
167
+ ? `dirty(${meta.git.uncommittedFiles})`
168
+ : 'clean';
169
+ const line2 = [
170
+ `run ${meta.runId}`,
171
+ unk(meta.command),
172
+ ...(meta.stage ? [`stage ${meta.stage.name} (${meta.stage.index}/${meta.stage.total})`] : []),
173
+ `model ${meta.model} (${meta.provider}, via ${unk(meta.modelSource)})`,
174
+ `turns ≤${meta.config.maxTurns}`,
175
+ `repo ${unk(meta.git.branch)}@${meta.git.commit?.slice(0, 7) ?? 'unknown'} ${repoState}`,
176
+ ].join(' · ');
177
+ return [line1, line2];
178
+ }
179
+
180
+ /** The `## Environment` section of summary.md; values mirror the run-start event (AC-8.4). */
181
+ export function renderEnvironmentSection(meta: RunMeta): string[] {
182
+ const v = meta.versions;
183
+ const c = meta.config;
184
+ const g = meta.git;
185
+ return [
186
+ `## Environment`,
187
+ ``,
188
+ `- **Run:** ${meta.runId} · ${unk(meta.command)} · started ${meta.startedAt} · ${meta.interactive ? 'interactive' : 'autonomous'}`,
189
+ ...(meta.stage ? [`- **Stage:** ${meta.stage.name} (${meta.stage.index}/${meta.stage.total})`] : []),
190
+ ...(meta.brief ? [`- **Brief:** ${meta.brief.path} (sha256 ${meta.brief.sha256.slice(0, 12)}…)`] : []),
191
+ `- **Model:** ${meta.model} (${meta.provider}, via ${unk(meta.modelSource)})`,
192
+ `- **copperhead:** v${unk(v.copperhead)}${v.installPath ? ` at ${v.installPath}` : ''}`,
193
+ `- **Tooling:** kicad-cli ${unk(v.kicadCli)} · node ${v.node} · ${v.platform}`,
194
+ `- **Config:** schematic ${c.schematic ?? 'null'} · board ${c.board ?? 'null'} · docs ${c.docs} · maxTurns ${c.maxTurns} · maxRepairCycles ${c.maxRepairCycles} · budgets ${JSON.stringify(c.budgets)}`,
195
+ `- **Repo:** ${unk(g.branch)}@${g.commit ?? 'unknown'} · ${g.dirty === null ? 'unknown' : g.dirty ? `dirty (${g.uncommittedFiles} uncommitted)` : 'clean'} · pre-commit hook ${g.preCommitHookInstalled === null ? 'unknown' : g.preCommitHookInstalled ? 'installed' : 'absent'}`,
196
+ `- **Memory:** ${meta.openConstraints ?? 'unknown'} open constraint(s) · ${meta.priorRuns ?? 'unknown'} prior run(s)`,
197
+ ];
198
+ }
@@ -1,13 +1,13 @@
1
1
  import path from 'node:path';
2
- import { writeFile, mkdir, appendFile } from 'node:fs/promises';
2
+ import { writeFile, mkdir, appendFile, readFile } from 'node:fs/promises';
3
3
  import type { ToolSchema } from './types.js';
4
4
  import { toolReadFile, toolWriteFile, toolEditFile, toolSearch } from './filetools.js';
5
5
  import { resolveInRepo, isKicadFile } from '../util/paths.js';
6
- import { runErc, runDrc, exportSvg, exportFab } from '../kicad/cli.js';
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
9
  import { checkDrift } from '../memory/drift.js';
10
- import { saveConstraint } from '../memory/constraints.js';
10
+ import { saveConstraint, classifyAffectsTarget, affectsTargetExists } from '../memory/constraints.js';
11
11
  import { openspecValidate } from '../openspec/cli.js';
12
12
  import { existsSync } from 'node:fs';
13
13
  import type { CopperheadConfig } from '../config.js';
@@ -99,7 +99,11 @@ export const TOOLS: ToolDef[] = [
99
99
  },
100
100
  requiresUnlock: false,
101
101
  handler: async (ctx, args) => {
102
- const matches = await toolSearch(ctx.repoRoot, str(args, 'pattern'), args.glob as string | undefined);
102
+ const pattern = args.pattern;
103
+ if (typeof pattern !== 'string' || pattern.trim() === '') {
104
+ return 'error: search requires a non-empty regex in "pattern" (narrow by file with "glob", e.g. {"pattern": "GPIO", "glob": "**/*.md"}); to list files, use a broad pattern like "." with a glob';
105
+ }
106
+ const matches = await toolSearch(ctx.repoRoot, pattern, args.glob as string | undefined);
103
107
  if (!matches.length) return 'no matches';
104
108
  return matches.map((m) => `${m.file}:${m.line}: ${m.text}`).join('\n');
105
109
  },
@@ -213,6 +217,14 @@ export const TOOLS: ToolDef[] = [
213
217
  requiresUnlock: true,
214
218
  handler: async (ctx, args) => {
215
219
  const rel = str(args, 'path');
220
+ const abs = resolveInRepo(ctx.repoRoot, rel);
221
+ // Text edits can corrupt an s-expression file in ways the editor cannot
222
+ // see; a corrupted file then fails every later ERC/DRC with an opaque
223
+ // error. Validate loadability with KiCad itself and roll the edit back
224
+ // rather than letting the file drift unusable. Only schematics and
225
+ // boards are probeable; .kicad_pro/.kicad_sym/.kicad_mod edits must not
226
+ // be probed (a sch/pcb probe rejects them wholesale).
227
+ const before = isProbeableKicadFile(rel) ? await readFile(abs, 'utf8') : null;
216
228
  const res = await toolEditFile(
217
229
  ctx.repoRoot,
218
230
  rel,
@@ -220,6 +232,23 @@ export const TOOLS: ToolDef[] = [
220
232
  args.new_string as string,
221
233
  args.replace_all === true,
222
234
  );
235
+ if (before !== null) {
236
+ const loadErr = await kicadLoadError(abs);
237
+ if (loadErr) {
238
+ const after = await readFile(abs, 'utf8');
239
+ await writeFile(abs, before, 'utf8');
240
+ if (await kicadLoadError(abs)) {
241
+ // The file was already unloadable before this edit. Reverting
242
+ // would deadlock incremental repair (every partial fix undone
243
+ // unless one edit fixes the whole file), so keep the edit and
244
+ // keep the pressure on with the probe output.
245
+ await writeFile(abs, after, 'utf8');
246
+ markTouched(ctx, rel);
247
+ return `${res}\nnote: ${rel} was already unloadable before this edit, so the edit is KEPT. Keep repairing until it loads. kicad-cli says:\n${loadErr}`;
248
+ }
249
+ return `edit REVERTED: it would make ${rel} unloadable in KiCad. kicad-cli says:\n${loadErr}\nRe-read the surrounding file text and make a smaller, syntactically complete edit.`;
250
+ }
251
+ }
223
252
  markTouched(ctx, rel);
224
253
  return res;
225
254
  },
@@ -250,7 +279,8 @@ export const TOOLS: ToolDef[] = [
250
279
  },
251
280
  requiresUnlock: false,
252
281
  handler: async (ctx) => {
253
- if (!ctx.config.schematic) return 'no schematic configured';
282
+ if (!ctx.config.schematic)
283
+ return 'no schematic configured; ERC does not apply yet — skip it until a schematic exists and is set in .copperhead/config.json';
254
284
  const report = await runErc(path.join(ctx.repoRoot, ctx.config.schematic));
255
285
  ctx.lastErc = report;
256
286
  if (report.ok) ctx.ledger.clear('erc');
@@ -266,7 +296,8 @@ export const TOOLS: ToolDef[] = [
266
296
  },
267
297
  requiresUnlock: false,
268
298
  handler: async (ctx) => {
269
- if (!ctx.config.board) return 'no board configured';
299
+ if (!ctx.config.board)
300
+ return 'no board configured; DRC does not apply yet — skip it until a board exists and is set in .copperhead/config.json';
270
301
  const report = await runDrc(path.join(ctx.repoRoot, ctx.config.board));
271
302
  ctx.lastDrc = report;
272
303
  if (report.ok) ctx.ledger.clear('drc');
@@ -325,7 +356,16 @@ export const TOOLS: ToolDef[] = [
325
356
  },
326
357
  requiresUnlock: false,
327
358
  handler: async (ctx) => {
328
- if (!ctx.config.schematic) return 'no schematic configured';
359
+ // No schematic yet means there is nothing for the docs to drift against,
360
+ // so the obligation is vacuously satisfied and must be cleared. Returning
361
+ // without clearing deadlocks every docs-only stage of the create pipeline
362
+ // (spec-seed, architecture, part-selection all run before the schematic
363
+ // exists): a doc edit opens the drift obligation, this is the only tool
364
+ // that clears it, and finish refuses while any obligation is open.
365
+ if (!ctx.config.schematic) {
366
+ ctx.ledger.clear('drift');
367
+ return 'no schematic configured; drift vacuously clean';
368
+ }
329
369
  const mismatches = await checkDrift(ctx.repoRoot, ctx.config.docs, ctx.config.schematic);
330
370
  if (!mismatches.length) {
331
371
  ctx.ledger.clear('drift');
@@ -357,6 +397,19 @@ export const TOOLS: ToolDef[] = [
357
397
  handler: async (ctx, args) => {
358
398
  const key = str(args, 'key');
359
399
  const affects = (args.affects as string[]) ?? [];
400
+ // An affects item whose target artifact is not built yet (no schematic or
401
+ // board configured, no BOM.md) has nothing to revisit; opening an
402
+ // obligation now only forces a ceremonial "not yet created" resolution.
403
+ // Defer it in the registry instead — reopenDeferredAffects re-opens it at
404
+ // the start of the first run where the artifact exists, which is when the
405
+ // revisit actually means something.
406
+ const deferred: string[] = [];
407
+ const openNow: string[] = [];
408
+ for (const item of affects) {
409
+ const target = classifyAffectsTarget(item);
410
+ if (target && !affectsTargetExists(target, ctx.repoRoot, ctx.config)) deferred.push(item);
411
+ else openNow.push(item);
412
+ }
360
413
  await saveConstraint(ctx.repoRoot, key, {
361
414
  ...(args.min !== undefined ? { min: args.min as number } : {}),
362
415
  ...(args.max !== undefined ? { max: args.max as number } : {}),
@@ -364,33 +417,84 @@ export const TOOLS: ToolDef[] = [
364
417
  ...(args.value !== undefined ? { value: args.value as string } : {}),
365
418
  source: str(args, 'source'),
366
419
  affects,
420
+ ...(deferred.length ? { deferred } : {}),
367
421
  });
368
- ctx.ledger.onConstraintChange(key, affects);
422
+ ctx.ledger.onConstraintChange(key, openNow);
369
423
  ctx.ledger.clear('constraint-dual-write', key);
370
- return `constraint ${key} recorded; revisit obligations opened for: ${affects.join(', ') || '(none)'}`;
424
+ const parts = [`constraint ${key} recorded`];
425
+ parts.push(`revisit obligations opened for: ${openNow.join(', ') || '(none)'}`);
426
+ if (deferred.length) {
427
+ parts.push(
428
+ `deferred until the target artifact exists (no resolve_affected needed now): ${deferred.join(', ')}`,
429
+ );
430
+ }
431
+ return parts.join('; ');
371
432
  },
372
433
  },
373
434
  {
374
435
  schema: {
375
436
  name: 'resolve_affected',
376
437
  description:
377
- 'Explicitly resolve an affects-revisit obligation: state whether the affected item changed or why no change is needed.',
438
+ 'Explicitly resolve affects-revisit obligations: state whether each affected item changed or why no change is needed. Pass resolutions[] to clear many in one call, or the single constraint_key/item/resolution form.',
378
439
  parameters: {
379
440
  type: 'object',
380
441
  properties: {
381
442
  constraint_key: { type: 'string' },
382
443
  item: { type: 'string' },
383
444
  resolution: { type: 'string', description: '"changed: ..." or "no change needed: <reason>"' },
445
+ resolutions: {
446
+ type: 'array',
447
+ description: 'batch form: resolve many obligations in one call',
448
+ items: {
449
+ type: 'object',
450
+ properties: {
451
+ constraint_key: { type: 'string' },
452
+ item: { type: 'string' },
453
+ resolution: { type: 'string' },
454
+ },
455
+ required: ['constraint_key', 'item', 'resolution'],
456
+ },
457
+ },
384
458
  },
385
- required: ['constraint_key', 'item', 'resolution'],
459
+ required: [],
386
460
  },
387
461
  },
388
462
  requiresUnlock: true,
389
463
  handler: async (ctx, args) => {
390
- const detail = `${str(args, 'constraint_key')} affects ${str(args, 'item')}`;
391
- ctx.ledger.clear('affects-revisit', detail);
392
- ctx.decisions.push(`[affects] ${detail}: ${str(args, 'resolution')}`);
393
- return `resolved: ${detail}`;
464
+ // An item that matches nothing must not read as success: the model would
465
+ // move on believing the obligation closed, and only find out at finish.
466
+ const resolveOne = (constraintKey: string, item: string, resolution: string): string => {
467
+ const detail = `${constraintKey} affects ${item}`;
468
+ if (!ctx.ledger.clear('affects-revisit', detail)) {
469
+ const open = ctx.ledger.openOfKind('affects-revisit');
470
+ if (!open.length) return `error: no open affects-revisit obligation matches "${detail}"`;
471
+ return [
472
+ `error: no open affects-revisit obligation matches "${detail}".`,
473
+ 'Match these exactly:',
474
+ ...open.map((o) => ` - ${o.detail}`),
475
+ ].join('\n');
476
+ }
477
+ ctx.decisions.push(`[affects] ${detail}: ${resolution}`);
478
+ return `resolved: ${detail}`;
479
+ };
480
+
481
+ const batch = args.resolutions;
482
+ if (Array.isArray(batch) && batch.length) {
483
+ // Entries resolve independently: one bad key must not waste the call.
484
+ return batch
485
+ .map((entry, i) => {
486
+ const e = entry as Record<string, unknown>;
487
+ if (typeof e?.constraint_key !== 'string' || typeof e?.item !== 'string' || typeof e?.resolution !== 'string') {
488
+ return `error: resolutions[${i}] needs string constraint_key, item, and resolution`;
489
+ }
490
+ return resolveOne(e.constraint_key, e.item, e.resolution);
491
+ })
492
+ .join('\n');
493
+ }
494
+ if (typeof args.constraint_key === 'string' && typeof args.item === 'string' && typeof args.resolution === 'string') {
495
+ return resolveOne(args.constraint_key, args.item, args.resolution);
496
+ }
497
+ return 'error: pass either resolutions: [{constraint_key, item, resolution}, ...] or the single form constraint_key + item + resolution';
394
498
  },
395
499
  },
396
500
  {
@@ -1,6 +1,31 @@
1
1
  import { appendFile, mkdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { redactSecrets } from '../util/redact.js';
4
+ import { renderEnvironmentSection, type RunMeta } from './runmeta.js';
5
+ import { fmtDuration, fmtTokens } from './render.js';
6
+
7
+ /** How a run terminated — the single most-queried triage fact (AC-8.5). */
8
+ export type ExitPath =
9
+ | 'done'
10
+ | 'refused'
11
+ | 'turn-budget-exhausted'
12
+ | 'repair-cycles-exhausted'
13
+ | 'commit-failed'
14
+ | 'provider-error'
15
+ | 'stalled';
16
+
17
+ /** Post-run addenda recorded at every terminal branch (AC-8.5). */
18
+ export interface RunStats {
19
+ exitPath: ExitPath;
20
+ turnsUsed: number;
21
+ maxTurns: number;
22
+ repairCyclesUsed: number;
23
+ maxRepairCycles: number;
24
+ tokensIn: number;
25
+ tokensOut: number;
26
+ perTurn: { turn: number; in: number; out: number }[];
27
+ durationMs: number;
28
+ }
4
29
 
5
30
  export interface RunSummaryData {
6
31
  request: string;
@@ -15,6 +40,23 @@ export interface RunSummaryData {
15
40
  outcome: 'success' | 'failure' | 'aborted';
16
41
  openObligations: string | null;
17
42
  detail?: string;
43
+ env?: RunMeta;
44
+ stats?: RunStats;
45
+ }
46
+
47
+ function renderRunStats(s: RunStats): string[] {
48
+ return [
49
+ `## Run stats`,
50
+ ``,
51
+ `- **Exit path:** ${s.exitPath}`,
52
+ `- **Turns:** ${s.turnsUsed} / ${s.maxTurns}`,
53
+ `- **Repair cycles:** ${s.repairCyclesUsed} / ${s.maxRepairCycles}`,
54
+ `- **Tokens:** ${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
55
+ `- **Duration:** ${fmtDuration(s.durationMs)}`,
56
+ ...(s.perTurn.length
57
+ ? [`- **Per turn:** ${s.perTurn.map((t) => `${t.turn}: ${t.in}/${t.out}`).join(' · ')}`]
58
+ : []),
59
+ ];
18
60
  }
19
61
 
20
62
  /**
@@ -38,6 +80,10 @@ export class Transcript {
38
80
 
39
81
  async event(type: string, data: unknown): Promise<void> {
40
82
  const line = redactSecrets(JSON.stringify({ ts: new Date().toISOString(), type, data }));
83
+ // The audit trail must survive anything that happens to the working tree
84
+ // mid-run (a rollback path once deleted this directory); losing an event
85
+ // is acceptable, crashing the run to report one is not.
86
+ await mkdir(this.dir, { recursive: true });
41
87
  await appendFile(this.jsonlPath, line + '\n', 'utf8');
42
88
  }
43
89
 
@@ -50,6 +96,8 @@ export class Transcript {
50
96
  `- **OpenSpec change:** ${s.changeId ?? 'n/a'}`,
51
97
  `- **Tokens:** ${s.tokensIn} in / ${s.tokensOut} out`,
52
98
  ``,
99
+ ...(s.env ? [...renderEnvironmentSection(s.env), ``] : []),
100
+ ...(s.stats ? [...renderRunStats(s.stats), ``] : []),
53
101
  `## Plan`,
54
102
  ``,
55
103
  s.plan ?? '(no plan recorded)',
@@ -72,6 +120,7 @@ export class Transcript {
72
120
  }
73
121
  if (s.detail) lines.push('', '## Detail', '', s.detail);
74
122
  const out = path.join(this.dir, 'summary.md');
123
+ await mkdir(this.dir, { recursive: true });
75
124
  await writeFile(out, redactSecrets(lines.join('\n') + '\n'), 'utf8');
76
125
  return out;
77
126
  }
@@ -29,4 +29,5 @@ export interface ChatOpts {
29
29
  export interface Provider {
30
30
  readonly name: string;
31
31
  chat(messages: Msg[], tools: ToolSchema[], opts?: ChatOpts): Promise<Turn>;
32
+ close?(): Promise<void>;
32
33
  }
package/src/cli.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import path from 'node:path';
4
+ import { createRequire } from 'node:module';
4
5
  import { createInterface } from 'node:readline/promises';
5
6
  import { loadConfig, resolveModel } from './config.js';
6
7
  import { runInit, InitError } from './memory/scaffold.js';
7
8
  import { runCheck } from './commands/check.js';
8
9
  import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
9
10
  import { runCreate } from './commands/create.js';
10
- import { runAgentLoop } from './agent/loop.js';
11
+ import { runAgentLoop, type BudgetExhaustedStats } from './agent/loop.js';
12
+ import { makeRenderer } from './agent/render.js';
11
13
  import { kicadCliVersion } from './kicad/cli.js';
12
14
  import { loadEnvFile } from './util/env.js';
13
15
 
@@ -17,6 +19,12 @@ import { loadEnvFile } from './util/env.js';
17
19
  // A real environment variable always beats the file.
18
20
  loadEnvFile(process.cwd());
19
21
 
22
+ // Single source of truth for the version. Both src/cli.ts (via tsx) and
23
+ // dist/cli.js sit one level below the package root, so the path holds either
24
+ // way, and a release can never ship a version string that disagrees with the
25
+ // package it was published as.
26
+ const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
27
+
20
28
  const program = new Command();
21
29
 
22
30
  const repoOf = (opts: { repo?: string }): string => path.resolve(opts.repo ?? process.cwd());
@@ -28,12 +36,32 @@ async function confirmTty(question: string): Promise<boolean> {
28
36
  return /^y(es)?$/i.test(answer.trim());
29
37
  }
30
38
 
39
+ /**
40
+ * Attended runs get a decision point instead of a rollback when the turn
41
+ * budget runs out (issue #15). Non-TTY (CI, pipes) keeps fail-and-restore.
42
+ */
43
+ function budgetContinuePrompt(): ((stats: BudgetExhaustedStats) => Promise<number>) | undefined {
44
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
45
+ return async (stats) => {
46
+ // ceil of the ORIGINAL budget (design D1), so repeat extensions offer the
47
+ // same increment instead of escalating with the extended turn count.
48
+ const extra = Math.ceil(stats.maxTurns / 2);
49
+ const k = (n: number) => `${(n / 1000).toFixed(1)}k`;
50
+ 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?`;
51
+ return (await confirmTty(q)) ? extra : 0;
52
+ };
53
+ }
54
+
31
55
  program
32
56
  .name('copperhead')
33
57
  .description('Cursor for circuit boards: an AI agent for real KiCad repositories')
34
- .version('0.1.0')
58
+ .version(version)
35
59
  .option('--repo <path>', 'target repository (default: cwd)')
36
- .option('--json', 'machine-readable output');
60
+ .option('--json', 'machine-readable output')
61
+ .option('--plain', 'plain log-style output (no interactive status line)');
62
+
63
+ const rendererOf = () =>
64
+ makeRenderer({ json: Boolean(program.opts().json), plain: Boolean(program.opts().plain) });
37
65
 
38
66
  program
39
67
  .command('init')
@@ -89,7 +117,7 @@ program
89
117
  .command('do')
90
118
  .description('the core loop: propose, edit, verify, propagate, commit')
91
119
  .argument('<request>', 'the change request in natural language')
92
- .option('--model <model>', 'gpt-5 | claude (or a full model id)')
120
+ .option('--model <model>', 'codex | gpt-5 | claude (or a provider-specific model id)')
93
121
  .option('--max-turns <n>', 'turn budget for this run')
94
122
  .option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
95
123
  .option('--dry-run', 'propose the diff, write nothing')
@@ -101,9 +129,10 @@ program
101
129
  ) => {
102
130
  const repo = repoOf(program.opts());
103
131
  try {
104
- await kicadCliVersion();
132
+ const kicadVer = await kicadCliVersion();
105
133
  const config = await loadConfig(repo);
106
- const model = resolveModel(opts.model, config);
134
+ const { model, source } = resolveModel(opts.model, config);
135
+ const continuePrompt = budgetContinuePrompt();
107
136
  const res = await runAgentLoop({
108
137
  repoRoot: repo,
109
138
  request,
@@ -113,6 +142,9 @@ program
113
142
  dryRun: opts.dryRun ?? false,
114
143
  interactive: opts.interactive ?? false,
115
144
  confirm: confirmTty,
145
+ ...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
146
+ renderer: rendererOf(),
147
+ meta: { command: 'do', modelSource: source, version, kicadCliVersion: kicadVer },
116
148
  });
117
149
  if (program.opts().json) console.log(JSON.stringify(res, null, 2));
118
150
  process.exit(res.outcome === 'failure' ? 1 : 0);
@@ -131,7 +163,7 @@ program
131
163
  .action(async (opts: { model?: string; dryRun?: boolean }) => {
132
164
  const repo = repoOf(program.opts());
133
165
  try {
134
- await kicadCliVersion();
166
+ const kicadVer = await kicadCliVersion();
135
167
  const report = await syncVerify(repo);
136
168
  const json = Boolean(program.opts().json);
137
169
  if (json) console.log(JSON.stringify(report, null, 2));
@@ -147,8 +179,11 @@ program
147
179
  process.exit(0);
148
180
  }
149
181
  const config = await loadConfig(repo);
150
- const model = resolveModel(opts.model, config);
151
- const res = await syncResolve(repo, report, model, json ? () => {} : (s) => console.log(s));
182
+ const { model, source } = resolveModel(opts.model, config);
183
+ const res = await syncResolve(repo, report, model, json ? () => {} : (s) => console.log(s), {
184
+ renderer: rendererOf(),
185
+ meta: { command: 'sync', modelSource: source, version, kicadCliVersion: kicadVer },
186
+ });
152
187
  process.exit(res.ok ? 0 : 1);
153
188
  } catch (err) {
154
189
  console.error((err as Error).message);
@@ -160,20 +195,24 @@ program
160
195
  .command('create')
161
196
  .description('Mode A: full pipeline from a product brief to the output package')
162
197
  .requiredOption('--brief <file>', 'product brief (markdown)')
163
- .option('--model <model>', 'gpt-5 | claude')
198
+ .option('--model <model>', 'codex | gpt-5 | claude')
164
199
  .option('--interactive', 're-enable the human gates (spec approval, pre-export)')
165
200
  .action(async (opts: { brief: string; model?: string; interactive?: boolean }) => {
166
201
  const repo = repoOf(program.opts());
167
202
  try {
168
- await kicadCliVersion();
203
+ const kicadVer = await kicadCliVersion();
169
204
  const config = await loadConfig(repo);
170
- const model = resolveModel(opts.model, config);
205
+ const { model, source } = resolveModel(opts.model, config);
206
+ const continuePrompt = budgetContinuePrompt();
171
207
  const res = await runCreate({
172
208
  repoRoot: repo,
173
209
  briefPath: opts.brief,
174
210
  model,
175
211
  interactive: opts.interactive ?? false,
212
+ ...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
176
213
  log: (s) => console.log(s),
214
+ renderer: rendererOf(),
215
+ meta: { command: 'create', modelSource: source, version, kicadCliVersion: kicadVer },
177
216
  });
178
217
  process.exit(res.ok ? 0 : 1);
179
218
  } catch (err) {