copperhead 0.3.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 (81) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +87 -0
  3. package/dist/agent/filetools.js +118 -0
  4. package/dist/agent/filetools.js.map +1 -0
  5. package/dist/agent/ledger.js +43 -0
  6. package/dist/agent/ledger.js.map +1 -0
  7. package/dist/agent/loop.js +279 -0
  8. package/dist/agent/loop.js.map +1 -0
  9. package/dist/agent/prompts.js +47 -0
  10. package/dist/agent/prompts.js.map +1 -0
  11. package/dist/agent/providers/anthropic.js +78 -0
  12. package/dist/agent/providers/anthropic.js.map +1 -0
  13. package/dist/agent/providers/openai.js +74 -0
  14. package/dist/agent/providers/openai.js.map +1 -0
  15. package/dist/agent/tools.js +439 -0
  16. package/dist/agent/tools.js.map +1 -0
  17. package/dist/agent/transcript.js +60 -0
  18. package/dist/agent/transcript.js.map +1 -0
  19. package/dist/agent/types.js +2 -0
  20. package/dist/agent/types.js.map +1 -0
  21. package/dist/cli.js +185 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/commands/check.js +64 -0
  24. package/dist/commands/check.js.map +1 -0
  25. package/dist/commands/create.js +91 -0
  26. package/dist/commands/create.js.map +1 -0
  27. package/dist/commands/sync.js +144 -0
  28. package/dist/commands/sync.js.map +1 -0
  29. package/dist/config.js +64 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/kicad/cli.js +91 -0
  32. package/dist/kicad/cli.js.map +1 -0
  33. package/dist/kicad/report.js +48 -0
  34. package/dist/kicad/report.js.map +1 -0
  35. package/dist/kicad/sexp.js +291 -0
  36. package/dist/kicad/sexp.js.map +1 -0
  37. package/dist/memory/constraints.js +43 -0
  38. package/dist/memory/constraints.js.map +1 -0
  39. package/dist/memory/drift.js +81 -0
  40. package/dist/memory/drift.js.map +1 -0
  41. package/dist/memory/scaffold.js +215 -0
  42. package/dist/memory/scaffold.js.map +1 -0
  43. package/dist/openspec/cli.js +31 -0
  44. package/dist/openspec/cli.js.map +1 -0
  45. package/dist/util/env.js +68 -0
  46. package/dist/util/env.js.map +1 -0
  47. package/dist/util/git.js +53 -0
  48. package/dist/util/git.js.map +1 -0
  49. package/dist/util/paths.js +25 -0
  50. package/dist/util/paths.js.map +1 -0
  51. package/dist/util/redact.js +21 -0
  52. package/dist/util/redact.js.map +1 -0
  53. package/dist/util/retry.js +27 -0
  54. package/dist/util/retry.js.map +1 -0
  55. package/package.json +73 -0
  56. package/src/agent/filetools.ts +136 -0
  57. package/src/agent/ledger.ts +71 -0
  58. package/src/agent/loop.ts +320 -0
  59. package/src/agent/prompts.ts +58 -0
  60. package/src/agent/providers/anthropic.ts +81 -0
  61. package/src/agent/providers/openai.ts +80 -0
  62. package/src/agent/tools.ts +481 -0
  63. package/src/agent/transcript.ts +78 -0
  64. package/src/agent/types.ts +32 -0
  65. package/src/cli.ts +188 -0
  66. package/src/commands/check.ts +85 -0
  67. package/src/commands/create.ts +125 -0
  68. package/src/commands/sync.ts +182 -0
  69. package/src/config.ts +77 -0
  70. package/src/kicad/cli.ts +106 -0
  71. package/src/kicad/report.ts +81 -0
  72. package/src/kicad/sexp.ts +327 -0
  73. package/src/memory/constraints.ts +73 -0
  74. package/src/memory/drift.ts +97 -0
  75. package/src/memory/scaffold.ts +241 -0
  76. package/src/openspec/cli.ts +43 -0
  77. package/src/util/env.ts +65 -0
  78. package/src/util/git.ts +63 -0
  79. package/src/util/paths.ts +25 -0
  80. package/src/util/redact.ts +20 -0
  81. package/src/util/retry.ts +33 -0
@@ -0,0 +1,80 @@
1
+ import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
2
+
3
+ interface OpenAIToolCall {
4
+ id: string;
5
+ function: { name: string; arguments: string };
6
+ }
7
+
8
+ export class OpenAIProvider implements Provider {
9
+ readonly name = 'openai';
10
+
11
+ constructor(
12
+ private readonly model = 'gpt-5',
13
+ private readonly apiKey = process.env.OPENAI_API_KEY,
14
+ ) {
15
+ if (!this.apiKey) throw new Error('OPENAI_API_KEY is not set');
16
+ }
17
+
18
+ async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
19
+ const { default: OpenAI } = await import('openai');
20
+ const client = new OpenAI({ apiKey: this.apiKey });
21
+ const res = await client.chat.completions.create({
22
+ model: this.model,
23
+ max_completion_tokens: opts.maxTokens ?? 8192,
24
+ messages: messages.map((m) => {
25
+ switch (m.role) {
26
+ case 'system':
27
+ return { role: 'system' as const, content: m.content };
28
+ case 'user':
29
+ return { role: 'user' as const, content: m.content };
30
+ case 'assistant':
31
+ return {
32
+ role: 'assistant' as const,
33
+ content: m.content,
34
+ ...(m.toolCalls?.length
35
+ ? {
36
+ tool_calls: m.toolCalls.map((t) => ({
37
+ id: t.id,
38
+ type: 'function' as const,
39
+ function: { name: t.name, arguments: JSON.stringify(t.args) },
40
+ })),
41
+ }
42
+ : {}),
43
+ };
44
+ case 'tool':
45
+ return { role: 'tool' as const, tool_call_id: m.toolCallId, content: m.content };
46
+ }
47
+ }),
48
+ ...(tools.length
49
+ ? {
50
+ tools: tools.map((t) => ({
51
+ type: 'function' as const,
52
+ function: { name: t.name, description: t.description, parameters: t.parameters },
53
+ })),
54
+ }
55
+ : {}),
56
+ });
57
+ const choice = res.choices[0];
58
+ const toolCalls = ((choice?.message.tool_calls ?? []) as OpenAIToolCall[]).map((t) => ({
59
+ id: t.id,
60
+ name: t.function.name,
61
+ args: safeParse(t.function.arguments),
62
+ }));
63
+ return {
64
+ text: choice?.message.content ?? null,
65
+ toolCalls,
66
+ usage: {
67
+ inputTokens: res.usage?.prompt_tokens ?? 0,
68
+ outputTokens: res.usage?.completion_tokens ?? 0,
69
+ },
70
+ };
71
+ }
72
+ }
73
+
74
+ function safeParse(s: string): Record<string, unknown> {
75
+ try {
76
+ return JSON.parse(s) as Record<string, unknown>;
77
+ } catch {
78
+ return { _raw: s };
79
+ }
80
+ }
@@ -0,0 +1,481 @@
1
+ import path from 'node:path';
2
+ import { writeFile, mkdir, appendFile } from 'node:fs/promises';
3
+ import type { ToolSchema } from './types.js';
4
+ import { toolReadFile, toolWriteFile, toolEditFile, toolSearch } from './filetools.js';
5
+ import { resolveInRepo, isKicadFile } from '../util/paths.js';
6
+ import { runErc, runDrc, exportSvg, exportFab } from '../kicad/cli.js';
7
+ import { formatViolations, type CheckReport } from '../kicad/report.js';
8
+ import { listSymbols, listNets } from '../kicad/sexp.js';
9
+ import { checkDrift } from '../memory/drift.js';
10
+ import { saveConstraint } from '../memory/constraints.js';
11
+ import { openspecValidate } from '../openspec/cli.js';
12
+ import { existsSync } from 'node:fs';
13
+ import type { CopperheadConfig } from '../config.js';
14
+ import { ObligationsLedger } from './ledger.js';
15
+ import type { Transcript } from './transcript.js';
16
+
17
+ export interface FinishRequest {
18
+ outcome: 'done' | 'refuse';
19
+ summary: string;
20
+ }
21
+
22
+ /** Mutable state one run threads through every tool call. */
23
+ export interface RunContext {
24
+ repoRoot: string;
25
+ config: CopperheadConfig;
26
+ transcript: Transcript;
27
+ ledger: ObligationsLedger;
28
+ runId: string;
29
+ interactive: boolean;
30
+ confirm: (question: string) => Promise<boolean>;
31
+ editsUnlocked: boolean;
32
+ changeId: string | null;
33
+ proposalValidated: boolean;
34
+ filesTouched: Set<string>;
35
+ decisions: string[];
36
+ lastErc: CheckReport | null;
37
+ lastDrc: CheckReport | null;
38
+ repairCycles: number;
39
+ finishRequest: FinishRequest | null;
40
+ }
41
+
42
+ export interface ToolDef {
43
+ schema: ToolSchema;
44
+ /** Edit-tier tools are absent from the tool list until the proposal validates. */
45
+ requiresUnlock: boolean;
46
+ handler: (ctx: RunContext, args: Record<string, unknown>) => Promise<string>;
47
+ }
48
+
49
+ const str = (args: Record<string, unknown>, key: string): string => {
50
+ const v = args[key];
51
+ if (typeof v !== 'string' || v === '') throw new Error(`missing required string arg "${key}"`);
52
+ return v;
53
+ };
54
+
55
+ function markTouched(ctx: RunContext, rel: string): void {
56
+ ctx.filesTouched.add(rel);
57
+ if (isKicadFile(rel)) {
58
+ ctx.ledger.onKicadEdit(rel);
59
+ if (rel.endsWith('.kicad_sch')) ctx.lastErc = null;
60
+ if (rel.endsWith('.kicad_pcb')) ctx.lastDrc = null;
61
+ } else if (rel.endsWith('.md')) {
62
+ ctx.ledger.onDocEdit(rel);
63
+ }
64
+ }
65
+
66
+ export const TOOLS: ToolDef[] = [
67
+ {
68
+ schema: {
69
+ name: 'read_file',
70
+ description: 'Read a repo-relative file, optionally a line range. Returns text (line-numbered when ranged).',
71
+ parameters: {
72
+ type: 'object',
73
+ properties: {
74
+ path: { type: 'string' },
75
+ start_line: { type: 'number' },
76
+ end_line: { type: 'number' },
77
+ },
78
+ required: ['path'],
79
+ },
80
+ },
81
+ requiresUnlock: false,
82
+ handler: (ctx, args) =>
83
+ toolReadFile(
84
+ ctx.repoRoot,
85
+ str(args, 'path'),
86
+ args.start_line as number | undefined,
87
+ args.end_line as number | undefined,
88
+ ),
89
+ },
90
+ {
91
+ schema: {
92
+ name: 'search',
93
+ description: 'Regex search over the repo (ripgrep-style). Optional glob filter, e.g. "**/*.kicad_sch".',
94
+ parameters: {
95
+ type: 'object',
96
+ properties: { pattern: { type: 'string' }, glob: { type: 'string' } },
97
+ required: ['pattern'],
98
+ },
99
+ },
100
+ requiresUnlock: false,
101
+ handler: async (ctx, args) => {
102
+ const matches = await toolSearch(ctx.repoRoot, str(args, 'pattern'), args.glob as string | undefined);
103
+ if (!matches.length) return 'no matches';
104
+ return matches.map((m) => `${m.file}:${m.line}: ${m.text}`).join('\n');
105
+ },
106
+ },
107
+ {
108
+ schema: {
109
+ name: 'list_symbols',
110
+ description: 'List schematic symbols: ref, value, footprint, sheet.',
111
+ parameters: { type: 'object', properties: {}, required: [] },
112
+ },
113
+ requiresUnlock: false,
114
+ handler: async (ctx) => {
115
+ if (!ctx.config.schematic) return 'no schematic configured';
116
+ const syms = await listSymbols(path.join(ctx.repoRoot, ctx.config.schematic));
117
+ return JSON.stringify(syms.map(({ ref, value, footprint, sheet }) => ({ ref, value, footprint, sheet })), null, 2);
118
+ },
119
+ },
120
+ {
121
+ schema: {
122
+ name: 'list_nets',
123
+ description: 'List net names found in the schematic.',
124
+ parameters: { type: 'object', properties: {}, required: [] },
125
+ },
126
+ requiresUnlock: false,
127
+ handler: async (ctx) => {
128
+ if (!ctx.config.schematic) return 'no schematic configured';
129
+ return JSON.stringify(await listNets(path.join(ctx.repoRoot, ctx.config.schematic)));
130
+ },
131
+ },
132
+ {
133
+ schema: {
134
+ name: 'propose_change',
135
+ description:
136
+ 'Write the OpenSpec change proposal for this run (the plan step). Must be called and validated before edit tools unlock.',
137
+ parameters: {
138
+ type: 'object',
139
+ properties: {
140
+ id: { type: 'string', description: 'kebab-case change id' },
141
+ why: { type: 'string' },
142
+ what_changes: { type: 'string', description: 'markdown bullet list of changes' },
143
+ tasks: { type: 'string', description: 'markdown checklist of implementation steps' },
144
+ },
145
+ required: ['id', 'why', 'what_changes', 'tasks'],
146
+ },
147
+ },
148
+ requiresUnlock: false,
149
+ handler: async (ctx, args) => {
150
+ const id = str(args, 'id');
151
+ const dir = resolveInRepo(ctx.repoRoot, path.join('openspec', 'changes', id));
152
+ await mkdir(dir, { recursive: true });
153
+ const auto = ctx.interactive ? '' : '\n> Marker: AUTO (autonomous mode; auto-approved, reviewable after the fact)\n';
154
+ await writeFile(
155
+ path.join(dir, 'proposal.md'),
156
+ `# Proposal: ${id}\n${auto}\n## Why\n\n${str(args, 'why')}\n\n## What Changes\n\n${str(args, 'what_changes')}\n`,
157
+ 'utf8',
158
+ );
159
+ await writeFile(path.join(dir, 'tasks.md'), `# Tasks\n\n${str(args, 'tasks')}\n`, 'utf8');
160
+ ctx.changeId = id;
161
+ return `proposal written to openspec/changes/${id}/ — now call validate_change`;
162
+ },
163
+ },
164
+ {
165
+ schema: {
166
+ name: 'validate_change',
167
+ description: 'Validate the current change proposal; on success the edit tools unlock.',
168
+ parameters: { type: 'object', properties: {}, required: [] },
169
+ },
170
+ requiresUnlock: false,
171
+ handler: async (ctx) => {
172
+ if (!ctx.changeId) return 'no proposal yet: call propose_change first';
173
+ let ok: boolean;
174
+ let detail: string;
175
+ if (existsSync(path.join(ctx.repoRoot, 'openspec', 'config.yaml'))) {
176
+ const res = await openspecValidate(ctx.repoRoot, ctx.changeId);
177
+ ok = res.ok;
178
+ detail = res.output;
179
+ } else {
180
+ // No OpenSpec workspace in the target repo: structural validation of the
181
+ // proposal files themselves (the invariant is the gate, not the CLI).
182
+ const dir = path.join(ctx.repoRoot, 'openspec', 'changes', ctx.changeId);
183
+ ok = existsSync(path.join(dir, 'proposal.md')) && existsSync(path.join(dir, 'tasks.md'));
184
+ detail = ok ? 'structural validation passed (no openspec workspace)' : 'proposal files missing';
185
+ }
186
+ if (!ok) return `validation FAILED:\n${detail}`;
187
+ ctx.proposalValidated = true;
188
+ if (ctx.interactive) {
189
+ const approved = await ctx.confirm(`Proposal ${ctx.changeId} validated. Unlock edit tools and proceed?`);
190
+ if (!approved) return 'proposal validated but human declined; edits remain locked';
191
+ }
192
+ ctx.editsUnlocked = true;
193
+ await ctx.transcript.event('edit-tools-unlocked', { changeId: ctx.changeId });
194
+ return `validation passed; edit tools are now unlocked`;
195
+ },
196
+ },
197
+ {
198
+ schema: {
199
+ name: 'edit_file',
200
+ description:
201
+ '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.',
202
+ parameters: {
203
+ type: 'object',
204
+ properties: {
205
+ path: { type: 'string' },
206
+ old_string: { type: 'string' },
207
+ new_string: { type: 'string' },
208
+ replace_all: { type: 'boolean' },
209
+ },
210
+ required: ['path', 'old_string', 'new_string'],
211
+ },
212
+ },
213
+ requiresUnlock: true,
214
+ handler: async (ctx, args) => {
215
+ const rel = str(args, 'path');
216
+ const res = await toolEditFile(
217
+ ctx.repoRoot,
218
+ rel,
219
+ str(args, 'old_string'),
220
+ args.new_string as string,
221
+ args.replace_all === true,
222
+ );
223
+ markTouched(ctx, rel);
224
+ return res;
225
+ },
226
+ },
227
+ {
228
+ schema: {
229
+ name: 'write_file',
230
+ description: 'Create a new file (docs, outputs). Refuses to overwrite anything or to create KiCad files.',
231
+ parameters: {
232
+ type: 'object',
233
+ properties: { path: { type: 'string' }, content: { type: 'string' } },
234
+ required: ['path', 'content'],
235
+ },
236
+ },
237
+ requiresUnlock: true,
238
+ handler: async (ctx, args) => {
239
+ const rel = str(args, 'path');
240
+ const res = await toolWriteFile(ctx.repoRoot, rel, args.content as string);
241
+ markTouched(ctx, rel);
242
+ return res;
243
+ },
244
+ },
245
+ {
246
+ schema: {
247
+ name: 'run_erc',
248
+ description: 'Run kicad-cli ERC on the schematic. Clears the ERC obligation when clean.',
249
+ parameters: { type: 'object', properties: {}, required: [] },
250
+ },
251
+ requiresUnlock: false,
252
+ handler: async (ctx) => {
253
+ if (!ctx.config.schematic) return 'no schematic configured';
254
+ const report = await runErc(path.join(ctx.repoRoot, ctx.config.schematic));
255
+ ctx.lastErc = report;
256
+ if (report.ok) ctx.ledger.clear('erc');
257
+ else ctx.repairCycles++;
258
+ return formatViolations(report);
259
+ },
260
+ },
261
+ {
262
+ schema: {
263
+ name: 'run_drc',
264
+ description: 'Run kicad-cli DRC on the board. Clears the DRC obligation when clean.',
265
+ parameters: { type: 'object', properties: {}, required: [] },
266
+ },
267
+ requiresUnlock: false,
268
+ handler: async (ctx) => {
269
+ if (!ctx.config.board) return 'no board configured';
270
+ const report = await runDrc(path.join(ctx.repoRoot, ctx.config.board));
271
+ ctx.lastDrc = report;
272
+ if (report.ok) ctx.ledger.clear('drc');
273
+ else ctx.repairCycles++;
274
+ return formatViolations(report);
275
+ },
276
+ },
277
+ {
278
+ schema: {
279
+ name: 'export_svg',
280
+ description: 'Export an SVG render of the schematic or board into .copperhead/renders/.',
281
+ parameters: {
282
+ type: 'object',
283
+ properties: { kind: { type: 'string', enum: ['sch', 'pcb'] } },
284
+ required: ['kind'],
285
+ },
286
+ },
287
+ requiresUnlock: false,
288
+ handler: async (ctx, args) => {
289
+ const kind = str(args, 'kind') as 'sch' | 'pcb';
290
+ const file = kind === 'sch' ? ctx.config.schematic : ctx.config.board;
291
+ if (!file) return `no ${kind} configured`;
292
+ const outDir = path.join(ctx.repoRoot, '.copperhead', 'renders');
293
+ await mkdir(outDir, { recursive: true });
294
+ return exportSvg(kind, path.join(ctx.repoRoot, file), outDir);
295
+ },
296
+ },
297
+ {
298
+ schema: {
299
+ name: 'export_outputs',
300
+ description:
301
+ 'Export the fabrication package into outputs/: gerbers+drill, DXF outline, STEP, SVG renders. Reports per-artifact success/failure.',
302
+ parameters: { type: 'object', properties: {}, required: [] },
303
+ },
304
+ requiresUnlock: true,
305
+ handler: async (ctx) => {
306
+ if (!ctx.config.board) return 'no board configured';
307
+ const outDir = path.join(ctx.repoRoot, 'outputs');
308
+ await mkdir(outDir, { recursive: true });
309
+ const res = await exportFab(
310
+ path.join(ctx.repoRoot, ctx.config.board),
311
+ ctx.config.schematic ? path.join(ctx.repoRoot, ctx.config.schematic) : null,
312
+ outDir,
313
+ );
314
+ ctx.filesTouched.add('outputs/');
315
+ const lines = [`produced: ${res.produced.join(', ') || '(none)'}`];
316
+ for (const f of res.failed) lines.push(`FAILED ${f.artifact}: ${f.reason}`);
317
+ return lines.join('\n');
318
+ },
319
+ },
320
+ {
321
+ schema: {
322
+ name: 'check_drift',
323
+ description: 'Compare BOM.md/PINOUT.md tables against the parsed schematic. Clears the drift obligation when clean.',
324
+ parameters: { type: 'object', properties: {}, required: [] },
325
+ },
326
+ requiresUnlock: false,
327
+ handler: async (ctx) => {
328
+ if (!ctx.config.schematic) return 'no schematic configured';
329
+ const mismatches = await checkDrift(ctx.repoRoot, ctx.config.docs, ctx.config.schematic);
330
+ if (!mismatches.length) {
331
+ ctx.ledger.clear('drift');
332
+ return 'drift: clean';
333
+ }
334
+ return mismatches.map((m) => `${m.doc}: claims "${m.claim}" but actual is "${m.actual}"`).join('\n');
335
+ },
336
+ },
337
+ {
338
+ schema: {
339
+ name: 'record_constraint',
340
+ description:
341
+ 'Record a constraint in .copperhead/constraints.json (dual write: put the same fact in the relevant doc in this same turn). Opens revisit obligations for every item in affects.',
342
+ parameters: {
343
+ type: 'object',
344
+ properties: {
345
+ key: { type: 'string', description: 'e.g. power.sleep_current_uA' },
346
+ min: { type: 'number' },
347
+ max: { type: 'number' },
348
+ forbidden: { type: 'array', items: { type: 'string' } },
349
+ value: { type: 'string' },
350
+ source: { type: 'string', description: 'doc/spec location that states this' },
351
+ affects: { type: 'array', items: { type: 'string' } },
352
+ },
353
+ required: ['key', 'source', 'affects'],
354
+ },
355
+ },
356
+ requiresUnlock: true,
357
+ handler: async (ctx, args) => {
358
+ const key = str(args, 'key');
359
+ const affects = (args.affects as string[]) ?? [];
360
+ await saveConstraint(ctx.repoRoot, key, {
361
+ ...(args.min !== undefined ? { min: args.min as number } : {}),
362
+ ...(args.max !== undefined ? { max: args.max as number } : {}),
363
+ ...(args.forbidden !== undefined ? { forbidden: args.forbidden as string[] } : {}),
364
+ ...(args.value !== undefined ? { value: args.value as string } : {}),
365
+ source: str(args, 'source'),
366
+ affects,
367
+ });
368
+ ctx.ledger.onConstraintChange(key, affects);
369
+ ctx.ledger.clear('constraint-dual-write', key);
370
+ return `constraint ${key} recorded; revisit obligations opened for: ${affects.join(', ') || '(none)'}`;
371
+ },
372
+ },
373
+ {
374
+ schema: {
375
+ name: 'resolve_affected',
376
+ description:
377
+ 'Explicitly resolve an affects-revisit obligation: state whether the affected item changed or why no change is needed.',
378
+ parameters: {
379
+ type: 'object',
380
+ properties: {
381
+ constraint_key: { type: 'string' },
382
+ item: { type: 'string' },
383
+ resolution: { type: 'string', description: '"changed: ..." or "no change needed: <reason>"' },
384
+ },
385
+ required: ['constraint_key', 'item', 'resolution'],
386
+ },
387
+ },
388
+ requiresUnlock: true,
389
+ 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}`;
394
+ },
395
+ },
396
+ {
397
+ schema: {
398
+ name: 'record_decision',
399
+ description:
400
+ 'Append a non-trivial decision to docs/DECISIONS.md: what was decided, the one-line why, and what it affects.',
401
+ parameters: {
402
+ type: 'object',
403
+ properties: {
404
+ decision: { type: 'string' },
405
+ rationale: { type: 'string' },
406
+ affects: { type: 'string', description: 'refdes/nets/docs affected' },
407
+ },
408
+ required: ['decision', 'rationale'],
409
+ },
410
+ },
411
+ requiresUnlock: true,
412
+ handler: async (ctx, args) => {
413
+ const decision = str(args, 'decision');
414
+ const rationale = str(args, 'rationale');
415
+ const affects = (args.affects as string | undefined) ?? '';
416
+ const date = new Date().toISOString().slice(0, 10);
417
+ const entry = `- ${date} [run ${ctx.runId}] ${decision} | why: ${rationale}${affects ? ` | affects: ${affects}` : ''}`;
418
+ const p = path.join(ctx.repoRoot, ctx.config.docs, 'DECISIONS.md');
419
+ await appendFile(p, entry + '\n', 'utf8');
420
+ ctx.decisions.push(`${decision} | why: ${rationale}`);
421
+ ctx.filesTouched.add(path.join(ctx.config.docs, 'DECISIONS.md'));
422
+ return 'decision recorded';
423
+ },
424
+ },
425
+ {
426
+ schema: {
427
+ name: 'finish',
428
+ description:
429
+ 'End the run. outcome "done" requires all verification gates and sync obligations to be satisfied; outcome "refuse" ends without edits, citing the violated budget/constraint in summary.',
430
+ parameters: {
431
+ type: 'object',
432
+ properties: {
433
+ outcome: { type: 'string', enum: ['done', 'refuse'] },
434
+ summary: { type: 'string' },
435
+ },
436
+ required: ['outcome', 'summary'],
437
+ },
438
+ },
439
+ requiresUnlock: false,
440
+ handler: async (ctx, args) => {
441
+ const outcome = str(args, 'outcome') as 'done' | 'refuse';
442
+ const summary = str(args, 'summary');
443
+ if (outcome === 'refuse') {
444
+ ctx.finishRequest = { outcome, summary };
445
+ return 'refusal recorded; run will end';
446
+ }
447
+ const problems: string[] = [];
448
+ const touchedKicad = [...ctx.filesTouched].some((f) => isKicadFile(f));
449
+ if (touchedKicad) {
450
+ if (!ctx.lastErc?.ok) problems.push('ERC has not passed since the last schematic edit (run run_erc)');
451
+ const touchedPcb = [...ctx.filesTouched].some((f) => f.endsWith('.kicad_pcb'));
452
+ if (touchedPcb && !ctx.lastDrc?.ok) problems.push('DRC has not passed since the last board edit (run run_drc)');
453
+ }
454
+ // the changelog obligation is cleared by the commit path itself
455
+ const blocking = ctx.ledger.openObligations.filter((o) => o.kind !== 'changelog');
456
+ if (blocking.length) {
457
+ problems.push('open sync obligations:\n' + blocking.map((o) => ` - [${o.kind}] ${o.detail}`).join('\n'));
458
+ }
459
+ if (problems.length) {
460
+ return `cannot finish yet:\n${problems.map((p) => `- ${p}`).join('\n')}`;
461
+ }
462
+ ctx.finishRequest = { outcome, summary };
463
+ return 'all gates satisfied; run will commit';
464
+ },
465
+ },
466
+ ];
467
+
468
+ /** Compose the tool list for the current state (design D2: the lock is structural). */
469
+ export function availableTools(ctx: RunContext): ToolDef[] {
470
+ return TOOLS.filter((t) => !t.requiresUnlock || ctx.editsUnlocked);
471
+ }
472
+
473
+ export async function dispatchTool(ctx: RunContext, name: string, args: Record<string, unknown>): Promise<string> {
474
+ const tool = availableTools(ctx).find((t) => t.schema.name === name);
475
+ if (!tool) return `tool "${name}" is not available${ctx.editsUnlocked ? '' : ' (edit tools unlock after the proposal validates)'}`;
476
+ try {
477
+ return await tool.handler(ctx, args);
478
+ } catch (err) {
479
+ return `error: ${(err as Error).message}`;
480
+ }
481
+ }
@@ -0,0 +1,78 @@
1
+ import { appendFile, mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { redactSecrets } from '../util/redact.js';
4
+
5
+ export interface RunSummaryData {
6
+ request: string;
7
+ changeId: string | null;
8
+ plan: string | null;
9
+ filesTouched: string[];
10
+ ercResult: string | null;
11
+ drcResult: string | null;
12
+ decisions: string[];
13
+ tokensIn: number;
14
+ tokensOut: number;
15
+ outcome: 'success' | 'failure' | 'aborted';
16
+ openObligations: string | null;
17
+ detail?: string;
18
+ }
19
+
20
+ /**
21
+ * Audit trail for a run: JSONL transcript plus a human-readable summary.md,
22
+ * both redacted at write time (AC-4.1, design D12).
23
+ */
24
+ export class Transcript {
25
+ readonly dir: string;
26
+ readonly jsonlPath: string;
27
+
28
+ constructor(repoRoot: string, stamp = new Date()) {
29
+ const ts = stamp.toISOString().replace(/[:.]/g, '-');
30
+ this.dir = path.join(repoRoot, '.copperhead', 'runs', ts);
31
+ this.jsonlPath = path.join(this.dir, 'transcript.jsonl');
32
+ }
33
+
34
+ async init(): Promise<void> {
35
+ await mkdir(this.dir, { recursive: true });
36
+ await writeFile(this.jsonlPath, '', 'utf8');
37
+ }
38
+
39
+ async event(type: string, data: unknown): Promise<void> {
40
+ const line = redactSecrets(JSON.stringify({ ts: new Date().toISOString(), type, data }));
41
+ await appendFile(this.jsonlPath, line + '\n', 'utf8');
42
+ }
43
+
44
+ async writeSummary(s: RunSummaryData): Promise<string> {
45
+ const lines = [
46
+ `# Run summary`,
47
+ ``,
48
+ `- **Request:** ${s.request}`,
49
+ `- **Outcome:** ${s.outcome}`,
50
+ `- **OpenSpec change:** ${s.changeId ?? 'n/a'}`,
51
+ `- **Tokens:** ${s.tokensIn} in / ${s.tokensOut} out`,
52
+ ``,
53
+ `## Plan`,
54
+ ``,
55
+ s.plan ?? '(no plan recorded)',
56
+ ``,
57
+ `## Files touched`,
58
+ ``,
59
+ ...(s.filesTouched.length ? s.filesTouched.map((f) => `- ${f}`) : ['(none)']),
60
+ ``,
61
+ `## Verification`,
62
+ ``,
63
+ `- ERC: ${s.ercResult ?? 'not run'}`,
64
+ `- DRC: ${s.drcResult ?? 'not run'}`,
65
+ ``,
66
+ `## Decisions`,
67
+ ``,
68
+ ...(s.decisions.length ? s.decisions.map((d) => `- ${d}`) : ['(none)']),
69
+ ];
70
+ if (s.openObligations) {
71
+ lines.push('', '## Open sync obligations (unmet at run end)', '', s.openObligations);
72
+ }
73
+ if (s.detail) lines.push('', '## Detail', '', s.detail);
74
+ const out = path.join(this.dir, 'summary.md');
75
+ await writeFile(out, redactSecrets(lines.join('\n') + '\n'), 'utf8');
76
+ return out;
77
+ }
78
+ }
@@ -0,0 +1,32 @@
1
+ export interface ToolSchema {
2
+ name: string;
3
+ description: string;
4
+ parameters: Record<string, unknown>; // JSON Schema
5
+ }
6
+
7
+ export interface ToolCall {
8
+ id: string;
9
+ name: string;
10
+ args: Record<string, unknown>;
11
+ }
12
+
13
+ export type Msg =
14
+ | { role: 'system'; content: string }
15
+ | { role: 'user'; content: string }
16
+ | { role: 'assistant'; content: string | null; toolCalls?: ToolCall[] }
17
+ | { role: 'tool'; toolCallId: string; content: string };
18
+
19
+ export interface Turn {
20
+ text: string | null;
21
+ toolCalls: ToolCall[];
22
+ usage: { inputTokens: number; outputTokens: number };
23
+ }
24
+
25
+ export interface ChatOpts {
26
+ maxTokens?: number;
27
+ }
28
+
29
+ export interface Provider {
30
+ readonly name: string;
31
+ chat(messages: Msg[], tools: ToolSchema[], opts?: ChatOpts): Promise<Turn>;
32
+ }