copperhead 0.9.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
@@ -0,0 +1,560 @@
1
+ /**
2
+ * `copperhead mcp` — a stdio MCP server exposing the gated pipeline to MCP
3
+ * hosts as five opaque, outcome-level tools (design D1).
4
+ *
5
+ * The security boundary is tool granularity, not prompt wording. Hosts get
6
+ * `check`, `do`, `sync` and `init` as whole-pipeline invocations, plus the
7
+ * read-only `doctor` probe, and nothing finer: no file-edit tool, no raw KiCad
8
+ * tool, no way to drive one step of the loop. There is therefore no sequence of MCP calls that skips the spec gate or
9
+ * the verification gate, because every mutating path runs the same loop the CLI
10
+ * runs. Safety rails are inherited rather than restated — this module is a
11
+ * transport adapter over the existing command entry points, and deliberately
12
+ * owns no policy of its own.
13
+ *
14
+ * Two rails are enforced here because they are properties of the transport
15
+ * rather than of the pipeline: stdout carries only the JSON-RPC stream (every
16
+ * human-readable byte goes to stderr, or the protocol corrupts), and mutating
17
+ * calls are serialized per repo *within this server process*. That lock is
18
+ * in-memory, so it does not interlock two separately spawned servers or a
19
+ * concurrent CLI run — those are held apart by the loop's own dirty-tree
20
+ * preflight, not by this.
21
+ */
22
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
23
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
24
+ import { z } from 'zod';
25
+ import { existsSync } from 'node:fs';
26
+ import { createRequire } from 'node:module';
27
+
28
+ import { loadConfig, resolveModel, type ModelSource } from '../config.js';
29
+ import { runCheck } from '../commands/check.js';
30
+ import { syncVerify, syncResolve, formatSyncReport } from '../commands/sync.js';
31
+ import { runInit, InitError } from '../memory/scaffold.js';
32
+ import { SandboxError } from '../util/paths.js';
33
+ import { runDoctor } from '../commands/doctor.js';
34
+ import { runAgentLoop, type RunResult } from '../agent/loop.js';
35
+ import { kicadCliVersion } from '../kicad/cli.js';
36
+ import { seal, type ToolResult, type ToolErrorKind } from '../agent/envelope.js';
37
+ import { plainRenderer, type ProgressRenderer } from '../agent/render.js';
38
+
39
+ /** The copperhead package version, for run self-description (AC-8.1). This is
40
+ * deliberately not MCP_PROTOCOL_VERSION: run metadata records which copperhead
41
+ * produced a commit, and the transport's own version would be a false answer. */
42
+ const { version: COPPERHEAD_VERSION } = createRequire(import.meta.url)('../../package.json') as { version: string };
43
+
44
+ /**
45
+ * Unstable by declaration (design D7). The `0.` major is load-bearing: it is
46
+ * the signal to hosts that tool names, input schemas and result shapes may
47
+ * change in any release, and it is what defers a registry listing until the
48
+ * stabilization criteria in the proposal are met. A test asserts the `0.`, so
49
+ * removing the experimental status is a deliberate edit rather than a drift.
50
+ */
51
+ export const MCP_PROTOCOL_VERSION = '0.1.0';
52
+
53
+ /** The entire surface. Anything not on this list is not reachable over MCP. */
54
+ export const PIPELINE_TOOL_NAMES = [
55
+ 'copperhead_check',
56
+ 'copperhead_do',
57
+ 'copperhead_sync',
58
+ 'copperhead_init',
59
+ 'copperhead_doctor',
60
+ ] as const;
61
+
62
+ export type PipelineToolName = (typeof PIPELINE_TOOL_NAMES)[number];
63
+
64
+ /**
65
+ * Per-tool input schema versions, bumped when a tool's inputs change shape.
66
+ * Separate from MCP_PROTOCOL_VERSION so one tool changing does not imply the
67
+ * whole surface did.
68
+ */
69
+ export const TOOL_SCHEMA_VERSIONS: Record<PipelineToolName, number> = {
70
+ copperhead_check: 1,
71
+ copperhead_do: 1,
72
+ copperhead_sync: 1,
73
+ copperhead_init: 1,
74
+ copperhead_doctor: 1,
75
+ };
76
+
77
+ /** Human-readable output goes to stderr; stdout belongs to JSON-RPC alone. */
78
+ const note = (line: string): void => {
79
+ process.stderr.write(`${line}\n`);
80
+ };
81
+
82
+ /** A sealed failure envelope. `seal` redacts, so a key can never ride out. */
83
+ export function failure(kind: ToolErrorKind, message: string): ToolResult {
84
+ return seal({ ok: false, summary: message, error: { kind, message } });
85
+ }
86
+
87
+ /**
88
+ * Resolve the model for an LLM-backed tool, converting the two ways model
89
+ * resolution can fail into typed errors a host agent can act on: no credential
90
+ * at all, and two credentials with nothing to choose between them. Neither is
91
+ * an exception — a host that cannot run `do` should be told why in a result.
92
+ */
93
+ async function resolveModelOrFail(
94
+ repoRoot: string,
95
+ ): Promise<{ model: string; source: ModelSource } | { error: ToolResult }> {
96
+ try {
97
+ const config = await loadConfig(repoRoot);
98
+ const { model, source } = resolveModel(undefined, config);
99
+ return { model, source };
100
+ } catch (err) {
101
+ // `unavailable` rather than `validation`: the call was well-formed, the
102
+ // environment just cannot serve it.
103
+ return { error: failure('unavailable', (err as Error).message) };
104
+ }
105
+ }
106
+
107
+ /** kicad-cli is a hard precondition for every tool, exactly as in the CLI. */
108
+ async function requireKicad(): Promise<{ version: string } | { error: ToolResult }> {
109
+ try {
110
+ return { version: await kicadCliVersion() };
111
+ } catch (err) {
112
+ return {
113
+ error: failure(
114
+ 'unavailable',
115
+ `kicad-cli is not available: ${(err as Error).message}. Install KiCad 9+ and ensure kicad-cli is on PATH.`,
116
+ ),
117
+ };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Mutating tools are serialized per repo. Rejecting rather than queueing is
123
+ * deliberate: a `do` run can take minutes, and a host blocked on an invisible
124
+ * queue looks hung, while a typed busy error is something an agent can relay
125
+ * and retry. Concurrent `check` calls are unrestricted — they mutate nothing.
126
+ */
127
+ export class RepoLocks {
128
+ private readonly busy = new Set<string>();
129
+
130
+ tryAcquire(repoRoot: string): boolean {
131
+ if (this.busy.has(repoRoot)) return false;
132
+ this.busy.add(repoRoot);
133
+ return true;
134
+ }
135
+
136
+ release(repoRoot: string): void {
137
+ this.busy.delete(repoRoot);
138
+ }
139
+
140
+ isBusy(repoRoot: string): boolean {
141
+ return this.busy.has(repoRoot);
142
+ }
143
+ }
144
+
145
+ /** Progress sink: what the server needs from a host to report a long run. */
146
+ export interface ProgressSink {
147
+ (update: { message: string; progress: number }): void;
148
+ }
149
+
150
+ /**
151
+ * A ProgressRenderer that mirrors loop progress onto MCP progress
152
+ * notifications. `progress` is a monotonically rising count of observed events
153
+ * rather than a percentage: the loop cannot know how many turns a run will take,
154
+ * and inventing a denominator would be a worse lie than an open-ended counter.
155
+ */
156
+ export function mcpRenderer(sink: ProgressSink): ProgressRenderer {
157
+ let ticks = 0;
158
+ const emit = (message: string): void => {
159
+ ticks += 1;
160
+ sink({ message, progress: ticks });
161
+ };
162
+ const base = plainRenderer((line) => {
163
+ note(line);
164
+ });
165
+ return {
166
+ log: (line) => {
167
+ base.log(line);
168
+ },
169
+ turnStart: (turn, maxTurns, tokensIn, tokensOut) => {
170
+ base.turnStart(turn, maxTurns, tokensIn, tokensOut);
171
+ emit(`turn ${turn}/${maxTurns}`);
172
+ },
173
+ toolResult: (name, firstLine, ok, viewHint) => {
174
+ base.toolResult(name, firstLine, ok, viewHint);
175
+ emit(`${name}: ${firstLine}`);
176
+ },
177
+ status: (text) => {
178
+ base.status(text);
179
+ },
180
+ heartbeat: (info) => {
181
+ base.heartbeat(info);
182
+ emit('working');
183
+ },
184
+ finish: (line) => {
185
+ base.finish(line);
186
+ emit(line);
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Map a finished run onto the status vocabulary hosts see. `rolled_back` is the
193
+ * honest default for every non-success exit path that is not a refusal: the
194
+ * loop restores its git snapshot on the way out, so "the run failed" and "the
195
+ * tree is back where it started" are the same fact.
196
+ */
197
+ export function runStatus(res: RunResult, dryRun: boolean): 'committed' | 'rolled_back' | 'refused' | 'dry_run' {
198
+ if (res.outcome === 'refused') return 'refused';
199
+ if (res.outcome === 'success') return dryRun ? 'dry_run' : 'committed';
200
+ return 'rolled_back';
201
+ }
202
+
203
+ export interface ServerOptions {
204
+ repoRoot: string;
205
+ }
206
+
207
+ /**
208
+ * Build the server and register the tools. Exported separately from
209
+ * `startMcpServer` so tests can drive the tool surface without a transport.
210
+ */
211
+ export function createMcpServer(opts: ServerOptions): McpServer {
212
+ const { repoRoot } = opts;
213
+ const locks = new RepoLocks();
214
+
215
+ const server = new McpServer(
216
+ { name: 'copperhead', version: MCP_PROTOCOL_VERSION },
217
+ {
218
+ instructions:
219
+ 'EXPERIMENTAL, UNSTABLE SURFACE: tool names, inputs and result shapes may change in any release. ' +
220
+ 'Use these tools to change or verify a KiCad project instead of editing .kicad_sch / .kicad_pcb files ' +
221
+ 'directly. Every mutation runs a spec-gated, verified, rollback-on-failure pipeline; editing the files ' +
222
+ 'yourself bypasses all of it.',
223
+ },
224
+ );
225
+
226
+ const toMcp = (result: ToolResult): { content: { type: 'text'; text: string }[]; isError?: boolean } => ({
227
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
228
+ ...(result.ok ? {} : { isError: true }),
229
+ });
230
+
231
+ server.registerTool(
232
+ 'copperhead_check',
233
+ {
234
+ title: 'Verify the KiCad project',
235
+ description:
236
+ 'Run ERC, DRC, doc-drift and spec validation on the project. Makes no model call and no network call, ' +
237
+ 'and changes nothing. Safe to call at any time, including concurrently.',
238
+ inputSchema: {},
239
+ _meta: { schemaVersion: TOOL_SCHEMA_VERSIONS.copperhead_check },
240
+ },
241
+ async () => {
242
+ const kicad = await requireKicad();
243
+ if ('error' in kicad) return toMcp(kicad.error);
244
+ try {
245
+ const res = await runCheck(repoRoot, (s) => {
246
+ note(s);
247
+ });
248
+ return toMcp(
249
+ seal({
250
+ ok: res.ok,
251
+ summary: res.ok ? 'check passed' : 'check found violations',
252
+ viewHint: 'diagnostic',
253
+ data: res,
254
+ }),
255
+ );
256
+ } catch (err) {
257
+ return toMcp(failure('exception', (err as Error).message));
258
+ }
259
+ },
260
+ );
261
+
262
+ server.registerTool(
263
+ 'copperhead_doctor',
264
+ {
265
+ title: 'Check that this host can run copperhead',
266
+ description:
267
+ 'Probe the environment copperhead needs: node, kicad-cli, git, openspec, and whether a model credential ' +
268
+ 'resolves. Makes no model call and no network call, and changes nothing. Call this first when another ' +
269
+ 'tool reports that something is unavailable.',
270
+ inputSchema: {},
271
+ _meta: { schemaVersion: TOOL_SCHEMA_VERSIONS.copperhead_doctor },
272
+ },
273
+ async () => {
274
+ // Deliberately no requireKicad() preflight. A missing kicad-cli is the
275
+ // single most likely thing a host needs told about, and gating this tool
276
+ // on it would make the diagnostic fail in exactly the case it exists for;
277
+ // runDoctor probes kicad-cli and reports it as a failed check instead.
278
+ try {
279
+ const report = await runDoctor({ repoRoot });
280
+ const failed = report.checks.filter((c) => c.status === 'fail');
281
+ return toMcp(
282
+ seal({
283
+ ok: report.ok,
284
+ summary: report.ok
285
+ ? 'environment is ready'
286
+ : `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(', ')}`,
287
+ viewHint: 'diagnostic',
288
+ data: report,
289
+ }),
290
+ );
291
+ } catch (err) {
292
+ return toMcp(failure('exception', (err as Error).message));
293
+ }
294
+ },
295
+ );
296
+
297
+ server.registerTool(
298
+ 'copperhead_init',
299
+ {
300
+ title: 'Scaffold design docs from the schematic',
301
+ description:
302
+ 'Generate the docs/ memory scaffold from an existing schematic. Also installs a git pre-commit hook ' +
303
+ 'that runs copperhead check before each commit. Idempotent, and refuses rather than overwriting docs ' +
304
+ 'a human has hand-edited.',
305
+ inputSchema: {
306
+ path: z.string().optional().describe('where to look for KiCad files, relative to the repo root'),
307
+ },
308
+ _meta: { schemaVersion: TOOL_SCHEMA_VERSIONS.copperhead_init },
309
+ },
310
+ async ({ path: searchPath }) => {
311
+ const kicad = await requireKicad();
312
+ if ('error' in kicad) return toMcp(kicad.error);
313
+ if (!locks.tryAcquire(repoRoot)) {
314
+ return toMcp(failure('unavailable', 'another copperhead run is in progress for this repo; retry shortly'));
315
+ }
316
+ try {
317
+ const res = await runInit({
318
+ repoRoot,
319
+ ...(searchPath ? { searchPath } : {}),
320
+ force: false,
321
+ installHooks: true,
322
+ });
323
+ const refused = res.refused.length > 0;
324
+ return toMcp(
325
+ seal({
326
+ ok: !refused,
327
+ summary: refused
328
+ ? `init refused ${res.refused.length} hand-edited doc(s)`
329
+ : `init wrote ${res.created.length} file(s)`,
330
+ viewHint: 'mutation',
331
+ data: res,
332
+ }),
333
+ );
334
+ } catch (err) {
335
+ // A missing schematic is the user's situation, not a crash: report it
336
+ // as a validation failure so the host can ask for a path.
337
+ // A missing schematic and a path that escapes the repo are both the
338
+ // caller's situation, not a crash: report them as validation failures
339
+ // so the host can correct the input rather than retrying blindly.
340
+ const kind: ToolErrorKind =
341
+ err instanceof InitError || err instanceof SandboxError ? 'validation' : 'exception';
342
+ return toMcp(failure(kind, (err as Error).message));
343
+ } finally {
344
+ locks.release(repoRoot);
345
+ }
346
+ },
347
+ );
348
+
349
+ server.registerTool(
350
+ 'copperhead_do',
351
+ {
352
+ title: 'Make a verified change to the project',
353
+ description:
354
+ 'Run the full gated pipeline for a change request: propose, spec-gate, edit, verify with ERC/DRC, repair, ' +
355
+ 'and commit — or roll back to the pre-run state if verification cannot be satisfied. This is the only way ' +
356
+ 'to change the project. Long-running; progress is streamed. Requires an API key in the environment.',
357
+ inputSchema: {
358
+ request: z.string().min(1).describe('the change request, in natural language'),
359
+ dry_run: z.boolean().optional().describe('propose the change and write nothing'),
360
+ },
361
+ _meta: { schemaVersion: TOOL_SCHEMA_VERSIONS.copperhead_do },
362
+ },
363
+ async ({ request, dry_run: dryRun }, extra) => {
364
+ const kicad = await requireKicad();
365
+ if ('error' in kicad) return toMcp(kicad.error);
366
+ const resolved = await resolveModelOrFail(repoRoot);
367
+ if ('error' in resolved) return toMcp(resolved.error);
368
+ if (!locks.tryAcquire(repoRoot)) {
369
+ return toMcp(failure('unavailable', 'another copperhead run is in progress for this repo; retry shortly'));
370
+ }
371
+ const progressToken = extra?._meta?.progressToken;
372
+ const sink: ProgressSink = (update): void => {
373
+ if (progressToken === undefined) return;
374
+ void extra
375
+ ?.sendNotification({
376
+ method: 'notifications/progress',
377
+ params: { progressToken, progress: update.progress, message: update.message },
378
+ })
379
+ .catch(() => {
380
+ // A host that stopped listening must not fail the run.
381
+ });
382
+ };
383
+ try {
384
+ const res = await runAgentLoop({
385
+ repoRoot,
386
+ request,
387
+ model: resolved.model,
388
+ // allowDirty is deliberately not exposed: it is a safety rail, and a
389
+ // tool input that switches a rail off is a rail a host can bypass.
390
+ allowDirty: false,
391
+ dryRun: dryRun ?? false,
392
+ // No confirm callback and no budget-extension prompt: there is no
393
+ // human on this transport, so the loop must fail rather than block.
394
+ interactive: false,
395
+ renderer: mcpRenderer(sink),
396
+ meta: {
397
+ command: 'do',
398
+ modelSource: resolved.source,
399
+ version: COPPERHEAD_VERSION,
400
+ kicadCliVersion: kicad.version,
401
+ },
402
+ });
403
+ const status = runStatus(res, dryRun ?? false);
404
+ return toMcp(
405
+ seal({
406
+ // A rollback is a successful tool call whose result says the run
407
+ // did not land (design D6). Only `refused` is reported as not-ok,
408
+ // because that is the pipeline declining rather than failing.
409
+ ok: status !== 'refused',
410
+ summary: `${status}: ${res.summary}`,
411
+ viewHint: 'mutation',
412
+ data: {
413
+ status,
414
+ commit: res.commit,
415
+ filesTouched: res.filesTouched,
416
+ transcriptDir: res.transcriptDir,
417
+ exitPath: res.exitPath,
418
+ stats: res.stats,
419
+ },
420
+ }),
421
+ );
422
+ } catch (err) {
423
+ return toMcp(failure('exception', (err as Error).message));
424
+ } finally {
425
+ locks.release(repoRoot);
426
+ }
427
+ },
428
+ );
429
+
430
+ server.registerTool(
431
+ 'copperhead_sync',
432
+ {
433
+ title: 'Verify design-state consistency, and optionally resolve drift',
434
+ description:
435
+ 'Run the deterministic consistency check across docs, constraints and the KiCad files. With resolve=true, ' +
436
+ 'additionally run the gated loop to fix the drift it found. Requirement violations are always reported and ' +
437
+ 'never auto-resolved — those are for a human. resolve=true requires an API key in the environment.',
438
+ inputSchema: {
439
+ resolve: z.boolean().optional().describe('run the LLM resolve phase for resolvable drift'),
440
+ },
441
+ _meta: { schemaVersion: TOOL_SCHEMA_VERSIONS.copperhead_sync },
442
+ },
443
+ async ({ resolve }, extra) => {
444
+ const kicad = await requireKicad();
445
+ if ('error' in kicad) return toMcp(kicad.error);
446
+ let report;
447
+ try {
448
+ report = await syncVerify(repoRoot);
449
+ } catch (err) {
450
+ return toMcp(failure('exception', (err as Error).message));
451
+ }
452
+ const verdict = (summary: string, ok = true): ToolResult =>
453
+ seal({ ok, summary, viewHint: resolve ? 'mutation' : 'diagnostic', data: report });
454
+
455
+ if (!resolve) return toMcp(verdict(formatSyncReport(report)));
456
+
457
+ // Truth precedence (design D14): a requirement violation is never
458
+ // silently resolved, so the resolve phase does not start when one exists.
459
+ if (report.violations.length) {
460
+ return toMcp(
461
+ verdict(
462
+ `${report.violations.length} requirement violation(s) found; these are never auto-resolved. ` +
463
+ `Resolve them by hand or change the requirement.\n\n${formatSyncReport(report)}`,
464
+ false,
465
+ ),
466
+ );
467
+ }
468
+ if (!report.resolvable.length) return toMcp(verdict('design state is consistent; nothing to resolve'));
469
+
470
+ const resolved = await resolveModelOrFail(repoRoot);
471
+ if ('error' in resolved) return toMcp(resolved.error);
472
+ if (!locks.tryAcquire(repoRoot)) {
473
+ return toMcp(failure('unavailable', 'another copperhead run is in progress for this repo; retry shortly'));
474
+ }
475
+ // The report above was computed before the lock was held, so another run
476
+ // may have rewritten the tree in between. Re-verify now that nothing else
477
+ // can move, rather than asking the loop to fix drift that is already gone.
478
+ const fresh = await syncVerify(repoRoot);
479
+ if (fresh.violations.length || !fresh.resolvable.length) {
480
+ locks.release(repoRoot);
481
+ return toMcp(
482
+ seal({
483
+ ok: !fresh.violations.length,
484
+ summary: fresh.violations.length
485
+ ? `${fresh.violations.length} requirement violation(s) found; these are never auto-resolved.`
486
+ : 'design state is consistent; nothing to resolve',
487
+ viewHint: 'diagnostic',
488
+ data: fresh,
489
+ }),
490
+ );
491
+ }
492
+ const progressToken = extra?._meta?.progressToken;
493
+ const sink: ProgressSink = (update): void => {
494
+ if (progressToken === undefined) return;
495
+ void extra
496
+ ?.sendNotification({
497
+ method: 'notifications/progress',
498
+ params: { progressToken, progress: update.progress, message: update.message },
499
+ })
500
+ .catch(() => {
501
+ // A host that stopped listening must not fail the run.
502
+ });
503
+ };
504
+ try {
505
+ const res = await syncResolve(
506
+ repoRoot,
507
+ fresh,
508
+ resolved.model,
509
+ (s) => {
510
+ note(s);
511
+ },
512
+ {
513
+ renderer: mcpRenderer(sink),
514
+ meta: {
515
+ command: 'sync',
516
+ modelSource: resolved.source,
517
+ version: COPPERHEAD_VERSION,
518
+ kicadCliVersion: kicad.version,
519
+ },
520
+ },
521
+ );
522
+ return toMcp(
523
+ seal({
524
+ ok: res.ok,
525
+ summary: res.ok ? 'drift resolved and verified' : `resolve did not land: ${res.run.summary}`,
526
+ viewHint: 'mutation',
527
+ data: {
528
+ resolved: res.ok,
529
+ report: fresh,
530
+ commit: res.run.commit,
531
+ filesTouched: res.run.filesTouched,
532
+ transcriptDir: res.run.transcriptDir,
533
+ exitPath: res.run.exitPath,
534
+ },
535
+ }),
536
+ );
537
+ } catch (err) {
538
+ return toMcp(failure('exception', (err as Error).message));
539
+ } finally {
540
+ locks.release(repoRoot);
541
+ }
542
+ },
543
+ );
544
+
545
+ return server;
546
+ }
547
+
548
+ /** Start the server on stdio and serve until the host closes the transport. */
549
+ export async function startMcpServer(opts: ServerOptions): Promise<void> {
550
+ if (!existsSync(opts.repoRoot)) {
551
+ throw new Error(`repo not found: ${opts.repoRoot}`);
552
+ }
553
+ const server = createMcpServer(opts);
554
+ note(
555
+ `copperhead mcp ${MCP_PROTOCOL_VERSION} (EXPERIMENTAL — unstable surface: tool names, inputs and results may ` +
556
+ `change in any release)`,
557
+ );
558
+ note(`repo: ${opts.repoRoot}`);
559
+ await server.connect(new StdioServerTransport());
560
+ }