copperhead 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/agent/context.js +2 -0
- package/dist/agent/context.js.map +1 -0
- package/dist/agent/dock-renderer.js +2 -2
- package/dist/agent/dock-renderer.js.map +1 -1
- package/dist/agent/envelope.js +105 -0
- package/dist/agent/envelope.js.map +1 -0
- package/dist/agent/loop.js +34 -14
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/providers/claude-code.js +17 -1
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/codex.js +84 -39
- package/dist/agent/providers/codex.js.map +1 -1
- package/dist/agent/recovery.js +91 -14
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/registry.js +49 -0
- package/dist/agent/registry.js.map +1 -0
- package/dist/agent/render.js +2 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/theme.js +10 -5
- package/dist/agent/theme.js.map +1 -1
- package/dist/agent/tools.js +99 -769
- package/dist/agent/tools.js.map +1 -1
- package/dist/capabilities/define.js +35 -0
- package/dist/capabilities/define.js.map +1 -0
- package/dist/capabilities/handlers.js +744 -0
- package/dist/capabilities/handlers.js.map +1 -0
- package/dist/capabilities/helpers.js +39 -0
- package/dist/capabilities/helpers.js.map +1 -0
- package/dist/capabilities/index.js +50 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/skills/generate-report.js +23 -0
- package/dist/capabilities/skills/generate-report.js.map +1 -0
- package/dist/cli.js +84 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +5 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +33 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skill.js +109 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +18 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/draft/draft.js +3 -0
- package/dist/kicad/draft/draft.js.map +1 -1
- package/dist/kicad/draft/engine.js +3139 -218
- package/dist/kicad/draft/engine.js.map +1 -1
- package/dist/kicad/draft/symsource.js +24 -10
- package/dist/kicad/draft/symsource.js.map +1 -1
- package/dist/kicad/emit.js +45 -6
- package/dist/kicad/emit.js.map +1 -1
- package/dist/kicad/legibility.js +51 -4
- package/dist/kicad/legibility.js.map +1 -1
- package/dist/kicad/score.js +173 -3
- package/dist/kicad/score.js.map +1 -1
- package/dist/kicad/sexp.js +32 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/scaffold.js +8 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/package.json +5 -2
- package/src/agent/context.ts +35 -0
- package/src/agent/dock-renderer.ts +3 -2
- package/src/agent/envelope.ts +124 -0
- package/src/agent/loop.ts +45 -17
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/recovery.ts +89 -12
- package/src/agent/registry.ts +58 -0
- package/src/agent/render.ts +4 -3
- package/src/agent/theme.ts +15 -5
- package/src/agent/tools.ts +124 -816
- package/src/agent/types.ts +10 -5
- package/src/capabilities/define.ts +88 -0
- package/src/capabilities/handlers.ts +769 -0
- package/src/capabilities/helpers.ts +37 -0
- package/src/capabilities/index.ts +53 -0
- package/src/capabilities/skills/generate-report.ts +25 -0
- package/src/cli.ts +84 -1
- package/src/commands/create.ts +5 -2
- package/src/commands/doctor.ts +34 -3
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +32 -8
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/draft/draft.ts +2 -0
- package/src/kicad/draft/engine.ts +3034 -226
- package/src/kicad/draft/symsource.ts +24 -10
- package/src/kicad/emit.ts +71 -7
- package/src/kicad/legibility.ts +55 -6
- package/src/kicad/score.ts +187 -8
- package/src/kicad/sexp.ts +37 -6
- package/src/mcp/server.ts +560 -0
- package/src/memory/scaffold.ts +8 -1
package/dist/agent/tools.js
CHANGED
|
@@ -1,781 +1,111 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { scoreSchematic, formatScore } from '../kicad/score.js';
|
|
10
|
-
import { draftSchematic, defaultIntentPath, formatSchematicDraftReport } from '../kicad/draft/draft.js';
|
|
11
|
-
import { verifySchematicSymbols, searchInstalledSymbols, symbolSearchDirs, resolveLibrarySymbol, comparePinNumbers } from '../kicad/symlib.js';
|
|
12
|
-
import { checkDrift } from '../memory/drift.js';
|
|
13
|
-
import { saveConstraint, classifyAffectsTarget, affectsTargetExists } from '../memory/constraints.js';
|
|
14
|
-
import { openspecValidate } from '../openspec/cli.js';
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
16
|
-
import { isEngineAuthoredSchematic } from '../kicad/fab.js';
|
|
17
|
-
const str = (args, key) => {
|
|
18
|
-
const v = args[key];
|
|
19
|
-
if (typeof v !== 'string' || v === '')
|
|
20
|
-
throw new Error(`missing required string arg "${key}"`);
|
|
21
|
-
return v;
|
|
22
|
-
};
|
|
23
|
-
// U+FFFD (the Unicode replacement character) is what a byte sequence becomes
|
|
24
|
-
// when UTF-8 decoding fails — most often a multibyte glyph (Ω, µ, ±, °) split
|
|
25
|
-
// across a streaming chunk boundary and decoded per-chunk upstream in the
|
|
26
|
-
// provider SDK (I2). It never appears in a legitimately authored PCB doc, so
|
|
27
|
-
// its presence in a content-bearing tool arg means the value arrived corrupted.
|
|
28
|
-
// Reject the call before it lands on disk so the model re-emits; the corruption
|
|
29
|
-
// is nondeterministic (it depends on where a chunk boundary fell), so the retry
|
|
30
|
-
// almost always comes through clean — far cheaper than shipping a mangled value
|
|
31
|
-
// like "5.1kΩ" → "5.1k�" into DECISIONS.md and only noticing on review.
|
|
32
|
-
const REPLACEMENT_CHAR = '�';
|
|
33
|
-
export function corruptionError(fields) {
|
|
34
|
-
const bad = Object.entries(fields)
|
|
35
|
-
.filter(([, v]) => typeof v === 'string' && v.includes(REPLACEMENT_CHAR))
|
|
36
|
-
.map(([k]) => k);
|
|
37
|
-
if (!bad.length)
|
|
38
|
-
return null;
|
|
39
|
-
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").`;
|
|
1
|
+
import { corruptionError } from '../capabilities/helpers.js';
|
|
2
|
+
import { flatten, failResult, seal, unavailable } from './envelope.js';
|
|
3
|
+
import { registry } from './registry.js';
|
|
4
|
+
import { withRetry, isRateLimit } from '../util/retry.js';
|
|
5
|
+
import { MAX_TURN_TIMEOUTS, TurnTimeoutError, withWatchdog } from './recovery.js';
|
|
6
|
+
export { corruptionError, registry };
|
|
7
|
+
export function availableTools(ctx) {
|
|
8
|
+
return registry.list(ctx);
|
|
40
9
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
10
|
+
export async function dispatchToolResult(ctx, name, args, opts = {}) {
|
|
11
|
+
if (opts.only && !opts.only.has(name))
|
|
12
|
+
return unavailable(name, ctx.editsUnlocked, 'not part of this skill');
|
|
13
|
+
const entry = availableTools(ctx).find((e) => e.name === name);
|
|
14
|
+
if (!entry)
|
|
15
|
+
return unavailable(name, ctx.editsUnlocked);
|
|
16
|
+
try {
|
|
17
|
+
if (entry.kind === 'skill') {
|
|
18
|
+
if (!opts.provider)
|
|
19
|
+
return failResult('validation', `skill "${name}" requires a provider`, 'diagnostic');
|
|
20
|
+
return await runSkillSubRun({ ctx, skill: entry, args, provider: opts.provider });
|
|
21
|
+
}
|
|
22
|
+
// Unified retry policy (design D10): a thrown 429 backs off through the
|
|
23
|
+
// same withRetry/isRateLimit pair the provider path uses. Typed envelope
|
|
24
|
+
// failures (validation/refusal/unavailable) return, so they never retry;
|
|
25
|
+
// local tool errors are not 429s, so they surface on the first throw.
|
|
26
|
+
return await withRetry(() => entry.handler(ctx, args), { isRetryable: isRateLimit, baseMs: 250 });
|
|
49
27
|
}
|
|
50
|
-
|
|
51
|
-
|
|
28
|
+
catch (err) {
|
|
29
|
+
return failResult('exception', `error: ${err.message}`);
|
|
52
30
|
}
|
|
53
31
|
}
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
name: 'search',
|
|
75
|
-
description: 'Regex search over the repo (ripgrep-style). Optional glob filter, e.g. "**/*.kicad_sch".',
|
|
76
|
-
parameters: {
|
|
77
|
-
type: 'object',
|
|
78
|
-
properties: { pattern: { type: 'string' }, glob: { type: 'string' } },
|
|
79
|
-
required: ['pattern'],
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
requiresUnlock: false,
|
|
83
|
-
handler: async (ctx, args) => {
|
|
84
|
-
const pattern = args.pattern;
|
|
85
|
-
if (typeof pattern !== 'string' || pattern.trim() === '') {
|
|
86
|
-
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';
|
|
87
|
-
}
|
|
88
|
-
const matches = await toolSearch(ctx.repoRoot, pattern, args.glob);
|
|
89
|
-
if (!matches.length)
|
|
90
|
-
return 'no matches';
|
|
91
|
-
return matches.map((m) => `${m.file}:${m.line}: ${m.text}`).join('\n');
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
schema: {
|
|
96
|
-
name: 'list_symbols',
|
|
97
|
-
description: 'List schematic symbols: ref, value, footprint, sheet.',
|
|
98
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
99
|
-
},
|
|
100
|
-
requiresUnlock: false,
|
|
101
|
-
handler: async (ctx) => {
|
|
102
|
-
if (!ctx.config.schematic)
|
|
103
|
-
return 'no schematic configured';
|
|
104
|
-
const syms = await listSymbols(path.join(ctx.repoRoot, ctx.config.schematic));
|
|
105
|
-
return JSON.stringify(syms.map(({ ref, value, footprint, sheet }) => ({ ref, value, footprint, sheet })), null, 2);
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
schema: {
|
|
110
|
-
name: 'list_nets',
|
|
111
|
-
description: 'List net names found in the schematic.',
|
|
112
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
113
|
-
},
|
|
114
|
-
requiresUnlock: false,
|
|
115
|
-
handler: async (ctx) => {
|
|
116
|
-
if (!ctx.config.schematic)
|
|
117
|
-
return 'no schematic configured';
|
|
118
|
-
return JSON.stringify(await listNets(path.join(ctx.repoRoot, ctx.config.schematic)));
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
schema: {
|
|
123
|
-
name: 'propose_change',
|
|
124
|
-
description: 'Write the OpenSpec change proposal for this run (the plan step). Must be called and validated before edit tools unlock.',
|
|
125
|
-
parameters: {
|
|
126
|
-
type: 'object',
|
|
127
|
-
properties: {
|
|
128
|
-
id: { type: 'string', description: 'kebab-case change id' },
|
|
129
|
-
why: { type: 'string' },
|
|
130
|
-
what_changes: { type: 'string', description: 'markdown bullet list of changes' },
|
|
131
|
-
tasks: { type: 'string', description: 'markdown checklist of implementation steps' },
|
|
132
|
-
},
|
|
133
|
-
required: ['id', 'why', 'what_changes', 'tasks'],
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
requiresUnlock: false,
|
|
137
|
-
handler: async (ctx, args) => {
|
|
138
|
-
const id = str(args, 'id');
|
|
139
|
-
const dir = resolveInRepo(ctx.repoRoot, path.join('openspec', 'changes', id));
|
|
140
|
-
await mkdir(dir, { recursive: true });
|
|
141
|
-
const auto = ctx.interactive ? '' : '\n> Marker: AUTO (autonomous mode; auto-approved, reviewable after the fact)\n';
|
|
142
|
-
await writeFile(path.join(dir, 'proposal.md'), `# Proposal: ${id}\n${auto}\n## Why\n\n${str(args, 'why')}\n\n## What Changes\n\n${str(args, 'what_changes')}\n`, 'utf8');
|
|
143
|
-
await writeFile(path.join(dir, 'tasks.md'), `# Tasks\n\n${str(args, 'tasks')}\n`, 'utf8');
|
|
144
|
-
ctx.changeId = id;
|
|
145
|
-
return `proposal written to openspec/changes/${id}/ — now call validate_change`;
|
|
146
|
-
},
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
schema: {
|
|
150
|
-
name: 'validate_change',
|
|
151
|
-
description: 'Validate the current change proposal; on success the edit tools unlock.',
|
|
152
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
153
|
-
},
|
|
154
|
-
requiresUnlock: false,
|
|
155
|
-
handler: async (ctx) => {
|
|
156
|
-
if (!ctx.changeId)
|
|
157
|
-
return 'no proposal yet: call propose_change first';
|
|
158
|
-
let ok;
|
|
159
|
-
let detail;
|
|
160
|
-
if (existsSync(path.join(ctx.repoRoot, 'openspec', 'config.yaml'))) {
|
|
161
|
-
const res = await openspecValidate(ctx.repoRoot, ctx.changeId);
|
|
162
|
-
ok = res.ok;
|
|
163
|
-
detail = res.output;
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
// No OpenSpec workspace in the target repo: structural validation of the
|
|
167
|
-
// proposal files themselves (the invariant is the gate, not the CLI).
|
|
168
|
-
const dir = path.join(ctx.repoRoot, 'openspec', 'changes', ctx.changeId);
|
|
169
|
-
ok = existsSync(path.join(dir, 'proposal.md')) && existsSync(path.join(dir, 'tasks.md'));
|
|
170
|
-
detail = ok ? 'structural validation passed (no openspec workspace)' : 'proposal files missing';
|
|
171
|
-
}
|
|
172
|
-
if (!ok)
|
|
173
|
-
return `validation FAILED:\n${detail}`;
|
|
174
|
-
ctx.proposalValidated = true;
|
|
175
|
-
if (ctx.interactive) {
|
|
176
|
-
const approved = await ctx.confirm(`Proposal ${ctx.changeId} validated. Unlock edit tools and proceed?`);
|
|
177
|
-
if (!approved)
|
|
178
|
-
return 'proposal validated but human declined; edits remain locked';
|
|
179
|
-
}
|
|
180
|
-
ctx.editsUnlocked = true;
|
|
181
|
-
await ctx.transcript.event('edit-tools-unlocked', { changeId: ctx.changeId });
|
|
182
|
-
return `validation passed; edit tools are now unlocked`;
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
schema: {
|
|
187
|
-
name: 'edit_file',
|
|
188
|
-
description: '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.',
|
|
189
|
-
parameters: {
|
|
190
|
-
type: 'object',
|
|
191
|
-
properties: {
|
|
192
|
-
path: { type: 'string' },
|
|
193
|
-
old_string: { type: 'string' },
|
|
194
|
-
new_string: { type: 'string' },
|
|
195
|
-
replace_all: { type: 'boolean' },
|
|
196
|
-
},
|
|
197
|
-
required: ['path', 'old_string', 'new_string'],
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
requiresUnlock: true,
|
|
201
|
-
handler: async (ctx, args) => {
|
|
202
|
-
const corrupt = corruptionError({ new_string: args.new_string });
|
|
203
|
-
if (corrupt)
|
|
204
|
-
return corrupt;
|
|
205
|
-
const rel = str(args, 'path');
|
|
206
|
-
const abs = resolveInRepo(ctx.repoRoot, rel);
|
|
207
|
-
// Engine-drafted sheets are regenerated wholesale from the IR: a hand
|
|
208
|
-
// edit would be destroyed by the next re-draft and would break the
|
|
209
|
-
// byte-identical staleness check. Geometry repairs go through the IR
|
|
210
|
-
// (design D5). Hand-drawn schematics never carry the draft generator
|
|
211
|
-
// marker, so `do` on existing repos is untouched by this guard.
|
|
212
|
-
if (rel.endsWith('.kicad_sch') && existsSync(abs)) {
|
|
213
|
-
const head = (await readFile(abs, 'utf8')).slice(0, 400);
|
|
214
|
-
if (isEngineAuthoredSchematic(head)) {
|
|
215
|
-
return `refused: ${rel} is engine-drafted from ${defaultIntentPath(rel)}. Revise the intent (edit_file on the intent JSON) and call draft_schematic to regenerate the sheet; direct geometry edits would be lost on the next re-draft.`;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// Text edits can corrupt an s-expression file in ways the editor cannot
|
|
219
|
-
// see; a corrupted file then fails every later ERC/DRC with an opaque
|
|
220
|
-
// error. Validate loadability with KiCad itself and roll the edit back
|
|
221
|
-
// rather than letting the file drift unusable. Only schematics and
|
|
222
|
-
// boards are probeable; .kicad_pro/.kicad_sym/.kicad_mod edits must not
|
|
223
|
-
// be probed (a sch/pcb probe rejects them wholesale).
|
|
224
|
-
const before = isProbeableKicadFile(rel) ? await readFile(abs, 'utf8') : null;
|
|
225
|
-
const res = await toolEditFile(ctx.repoRoot, rel, str(args, 'old_string'), args.new_string, args.replace_all === true);
|
|
226
|
-
if (before !== null) {
|
|
227
|
-
const loadErr = await kicadLoadError(abs);
|
|
228
|
-
if (loadErr) {
|
|
229
|
-
const after = await readFile(abs, 'utf8');
|
|
230
|
-
await writeFile(abs, before, 'utf8');
|
|
231
|
-
if (await kicadLoadError(abs)) {
|
|
232
|
-
// The file was already unloadable before this edit. Reverting
|
|
233
|
-
// would deadlock incremental repair (every partial fix undone
|
|
234
|
-
// unless one edit fixes the whole file), so keep the edit and
|
|
235
|
-
// keep the pressure on with the probe output.
|
|
236
|
-
await writeFile(abs, after, 'utf8');
|
|
237
|
-
markTouched(ctx, rel);
|
|
238
|
-
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}`;
|
|
239
|
-
}
|
|
240
|
-
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.`;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
markTouched(ctx, rel);
|
|
244
|
-
return res;
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
schema: {
|
|
249
|
-
name: 'write_file',
|
|
250
|
-
description: '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.',
|
|
251
|
-
parameters: {
|
|
252
|
-
type: 'object',
|
|
253
|
-
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
254
|
-
required: ['path', 'content'],
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
requiresUnlock: true,
|
|
258
|
-
handler: async (ctx, args) => {
|
|
259
|
-
const corrupt = corruptionError({ content: args.content });
|
|
260
|
-
if (corrupt)
|
|
261
|
-
return corrupt;
|
|
262
|
-
const rel = str(args, 'path');
|
|
263
|
-
const res = await toolWriteFile(ctx.repoRoot, rel, args.content);
|
|
264
|
-
markTouched(ctx, rel);
|
|
265
|
-
return res;
|
|
266
|
-
},
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
schema: {
|
|
270
|
-
name: 'run_erc',
|
|
271
|
-
description: 'Run kicad-cli ERC on the schematic. Clears the ERC obligation when clean.',
|
|
272
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
273
|
-
},
|
|
274
|
-
requiresUnlock: false,
|
|
275
|
-
handler: async (ctx) => {
|
|
276
|
-
if (!ctx.config.schematic)
|
|
277
|
-
return 'no schematic configured; ERC does not apply yet — skip it until a schematic exists and is set in .copperhead/config.json';
|
|
278
|
-
const schPath = path.join(ctx.repoRoot, ctx.config.schematic);
|
|
279
|
-
const report = await runErc(schPath);
|
|
280
|
-
ctx.lastErc = report;
|
|
281
|
-
if (report.ok)
|
|
282
|
-
ctx.ledger.clear('erc');
|
|
283
|
-
else
|
|
284
|
-
ctx.repairCycles++;
|
|
285
|
-
const out = formatViolations(report);
|
|
286
|
-
// A zero-symbol schematic passes ERC with 0 violations — a false green
|
|
287
|
-
// (3.2) that lets a premature finish look verified (an empty sheet also
|
|
288
|
-
// passes drift). The stage contract already requires symbols>0, but a bare
|
|
289
|
-
// "ERC clean" on the empty starting sheet still misleads the model, so warn
|
|
290
|
-
// here too: no gate should read as satisfied by the empty starting state.
|
|
291
|
-
if (report.ok && !(await listSymbols(schPath)).length) {
|
|
292
|
-
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.`;
|
|
293
|
-
}
|
|
294
|
-
return out;
|
|
295
|
-
},
|
|
296
|
-
},
|
|
297
|
-
{
|
|
298
|
-
schema: {
|
|
299
|
-
name: 'search_symbols',
|
|
300
|
-
description: 'Search EVERY installed KiCad symbol library for a part or symbol name; returns matching lib_ids as Lib:Name, exact matches first. Library nicknames rarely follow from the part number (TPS61165DBV is in Driver_LED, AudioJack3 in Connector_Audio, INA226 in Sensor_Energy), so a failed single-library probe proves nothing about availability — use this before concluding a part has no symbol, and before committing any active part to the BOM: a part is only drawable if its symbol appears here.',
|
|
301
|
-
parameters: {
|
|
302
|
-
type: 'object',
|
|
303
|
-
properties: {
|
|
304
|
-
query: { type: 'string', description: 'part or symbol name, e.g. "TLV320AIC3204" or "AudioJack3"' },
|
|
305
|
-
},
|
|
306
|
-
required: ['query'],
|
|
307
|
-
},
|
|
308
|
-
},
|
|
309
|
-
requiresUnlock: false,
|
|
310
|
-
handler: async (_ctx, args) => {
|
|
311
|
-
const query = str(args, 'query');
|
|
312
|
-
const dirs = await symbolSearchDirs();
|
|
313
|
-
if (!dirs.length)
|
|
314
|
-
return 'no installed KiCad symbol library directories found on this machine';
|
|
315
|
-
const hits = await searchInstalledSymbols(query, dirs);
|
|
316
|
-
if (!hits.length) {
|
|
317
|
-
return `no installed symbol matches "${query}" (searched every library in: ${dirs.join(', ')}). The part is not capturable on this machine as named — choose a part whose symbol exists, or a same-family variant that does.`;
|
|
318
|
-
}
|
|
319
|
-
return `installed symbols matching "${query}":\n${hits.map((h) => ` - ${h}`).join('\n')}`;
|
|
320
|
-
},
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
schema: {
|
|
324
|
-
name: 'symbol_pins',
|
|
325
|
-
description: 'Return the REAL pins (number, name, electrical type) of an installed KiCad symbol by lib_id, following extends links, plus its unit count — the authoritative source for REF.PIN endpoints, instead of guessing pins or reading .kicad_sym files. Warns when the symbol is multi-unit, which the drafting engine refuses. On a miss it lists the closest names in that library and where the symbol actually lives, so one call answers both "what are the pins" and "which lib_id is right".',
|
|
326
|
-
parameters: {
|
|
327
|
-
type: 'object',
|
|
328
|
-
properties: {
|
|
329
|
-
lib_id: { type: 'string', description: 'full library identifier, e.g. "Device:R" or "Audio:TLV320AIC3100"' },
|
|
330
|
-
},
|
|
331
|
-
required: ['lib_id'],
|
|
332
|
-
},
|
|
333
|
-
},
|
|
334
|
-
requiresUnlock: false,
|
|
335
|
-
handler: async (_ctx, args) => {
|
|
336
|
-
const libId = str(args, 'lib_id');
|
|
337
|
-
const name = libId.includes(':') ? libId.slice(libId.indexOf(':') + 1) : libId;
|
|
338
|
-
const dirs = await symbolSearchDirs();
|
|
339
|
-
if (!dirs.length) {
|
|
340
|
-
return `cannot verify "${libId}": no installed KiCad symbol library directories were found on this machine, so nothing can be resolved or ruled out. Install the KiCad symbol libraries (or set KICAD_SYMBOL_DIR), or choose a part you can verify another way.`;
|
|
341
|
-
}
|
|
342
|
-
const r = await resolveLibrarySymbol(libId, dirs);
|
|
343
|
-
if (r.status === 'ok') {
|
|
344
|
-
const pins = [...r.pins]
|
|
345
|
-
.sort((a, b) => comparePinNumbers(a.number, b.number))
|
|
346
|
-
.map((p) => ` ${p.number}: ${p.name === '~' || !p.name ? '(unnamed)' : p.name} · ${p.type}`);
|
|
347
|
-
const multi = r.units >= 2
|
|
348
|
-
? `\nNOTE: this symbol defines ${r.units} units; the drafting engine places each unit separately under one refdes (U1A/U1B), and net endpoints keep plain package pin numbers.`
|
|
349
|
-
: '';
|
|
350
|
-
return `${libId} — ${r.pins.length} pin(s), ${r.units} unit(s):\n${pins.join('\n')}${multi}`;
|
|
351
|
-
}
|
|
352
|
-
if (r.status === 'found-elsewhere') {
|
|
353
|
-
return `"${libId}" does not resolve, but the symbol is installed as: ${r.libIds.join(', ')} — use one of these lib_ids (and call symbol_pins on it for the pin table).`;
|
|
354
|
-
}
|
|
355
|
-
const elsewhere = await searchInstalledSymbols(name, dirs, 6);
|
|
356
|
-
const where = elsewhere.length
|
|
357
|
-
? `\ninstalled as: ${elsewhere.join(', ')}`
|
|
358
|
-
: `\nno installed symbol matches "${name}" in any library — the part is not capturable as named.`;
|
|
359
|
-
if (r.status === 'no-symbol') {
|
|
360
|
-
const close = r.candidates.length ? `\nclosest in that library: ${r.candidates.join(', ')}` : '';
|
|
361
|
-
return `"${libId}" does not exist in that library.${close}${where}`;
|
|
362
|
-
}
|
|
363
|
-
const lib = libId.includes(':') ? libId.slice(0, libId.indexOf(':')) : libId;
|
|
364
|
-
return `no library named "${lib}" is installed.${where}`;
|
|
365
|
-
},
|
|
366
|
-
},
|
|
367
|
-
{
|
|
368
|
-
schema: {
|
|
369
|
-
name: 'verify_symbols',
|
|
370
|
-
description: "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.",
|
|
371
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
372
|
-
},
|
|
373
|
-
requiresUnlock: false,
|
|
374
|
-
handler: async (ctx) => {
|
|
375
|
-
if (!ctx.config.schematic)
|
|
376
|
-
return 'no schematic configured; verify_symbols does not apply yet';
|
|
377
|
-
const { findings, checked, skipped } = await verifySchematicSymbols(path.join(ctx.repoRoot, ctx.config.schematic));
|
|
378
|
-
if (!findings.length) {
|
|
379
|
-
return `verify_symbols: ${checked} symbol(s) match the installed KiCad library. No divergences.`;
|
|
380
|
-
}
|
|
381
|
-
const lines = findings.map((f) => ` - [${f.kind}] ${f.detail}`);
|
|
382
|
-
const mismatches = findings.filter((f) => f.kind !== 'no-library').length;
|
|
383
|
-
return `verify_symbols: ${checked} verified, ${skipped} unverifiable (library not installed), ${mismatches} issue(s) to reconcile:\n${lines.join('\n')}`;
|
|
384
|
-
},
|
|
385
|
-
},
|
|
386
|
-
{
|
|
387
|
-
schema: {
|
|
388
|
-
name: 'draft_schematic',
|
|
389
|
-
description: 'Regenerate the schematic deterministically from the netlist-intent IR (schematic.intent.json beside the schematic). Pass intent_json to write a new IR first, or omit it to re-draft the existing file. The engine computes ALL geometry (placement, wires, labels, power symbols, group boxes); never author coordinates. The report embeds the legibility findings and score for the fresh sheet. A failed validation leaves the previous schematic untouched.',
|
|
390
|
-
parameters: {
|
|
391
|
-
type: 'object',
|
|
392
|
-
properties: {
|
|
393
|
-
intent_json: { type: 'string', description: 'full IR document as JSON text (optional: omit to re-draft the current IR)' },
|
|
394
|
-
},
|
|
395
|
-
required: [],
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
|
-
requiresUnlock: true,
|
|
399
|
-
handler: async (ctx, args) => {
|
|
400
|
-
if (!ctx.config.schematic)
|
|
401
|
-
return 'no schematic configured; set one in .copperhead/config.json first';
|
|
402
|
-
const intentRel = defaultIntentPath(ctx.config.schematic);
|
|
403
|
-
if (typeof args.intent_json === 'string' && args.intent_json.trim()) {
|
|
404
|
-
const corrupt = corruptionError({ intent_json: args.intent_json });
|
|
405
|
-
if (corrupt)
|
|
406
|
-
return corrupt;
|
|
32
|
+
export async function dispatchTool(ctx, name, args, opts = {}) {
|
|
33
|
+
return flatten(await dispatchToolResult(ctx, name, args, opts));
|
|
34
|
+
}
|
|
35
|
+
export async function runSkillSubRun(opts) {
|
|
36
|
+
const { ctx, skill, args, provider } = opts;
|
|
37
|
+
const savedFinish = ctx.finishRequest;
|
|
38
|
+
const only = new Set(skill.tools.filter((n) => n !== 'finish'));
|
|
39
|
+
const results = [];
|
|
40
|
+
const messages = [
|
|
41
|
+
{ role: 'system', content: skill.prompt(ctx, args) },
|
|
42
|
+
{ role: 'user', content: 'Execute this skill. Use the available tools, then stop when the report is complete.' },
|
|
43
|
+
];
|
|
44
|
+
try {
|
|
45
|
+
for (let turn = 0; turn < (skill.maxTurns ?? 8); turn++) {
|
|
46
|
+
if (await skill.isComplete(ctx, args))
|
|
47
|
+
break;
|
|
48
|
+
const tools = registry.list(ctx).filter((e) => e.kind === 'tool' && only.has(e.name)).map((e) => e.schema);
|
|
49
|
+
let res;
|
|
50
|
+
let timeoutRetries = 0;
|
|
51
|
+
while (true) {
|
|
407
52
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const res = await draftSchematic({
|
|
417
|
-
repoRoot: ctx.repoRoot,
|
|
418
|
-
schematic: ctx.config.schematic,
|
|
419
|
-
intentPath: intentRel,
|
|
420
|
-
docsDir: ctx.config.docs,
|
|
421
|
-
});
|
|
422
|
-
if (!res.ok)
|
|
423
|
-
return res.message;
|
|
424
|
-
markTouched(ctx, ctx.config.schematic);
|
|
425
|
-
// embed the checker and score in the draft report (design D5): a
|
|
426
|
-
// draft-check-score iteration costs one tool call, and the embedded
|
|
427
|
-
// checker result drives the ledger obligation exactly like check_legibility
|
|
428
|
-
const docsAbs = path.join(ctx.repoRoot, ctx.config.docs);
|
|
429
|
-
const leg = await checkLegibility(res.schematicPath, {
|
|
430
|
-
docsDir: docsAbs,
|
|
431
|
-
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
432
|
-
});
|
|
433
|
-
ctx.lastLegibility = leg.counts;
|
|
434
|
-
ctx.ledger.onLegibilityResult(leg.counts.error);
|
|
435
|
-
const score = await scoreSchematic(res.schematicPath, {
|
|
436
|
-
docsDir: docsAbs,
|
|
437
|
-
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
438
|
-
});
|
|
439
|
-
ctx.lastScore = score.composite;
|
|
440
|
-
return [formatSchematicDraftReport(res.report), formatLegibility(leg), formatScore(score)].join('\n');
|
|
441
|
-
},
|
|
442
|
-
},
|
|
443
|
-
{
|
|
444
|
-
schema: {
|
|
445
|
-
name: 'score_schematic',
|
|
446
|
-
description: 'Deterministic quantitative legibility score for the schematic: composite 0-100 with the per-metric breakdown (crossings, bends, alignment, spacing, symmetry, balance, …). Error-severity legibility findings cap the composite. Advisory: informs, never gates by itself.',
|
|
447
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
448
|
-
},
|
|
449
|
-
requiresUnlock: false,
|
|
450
|
-
handler: async (ctx) => {
|
|
451
|
-
if (!ctx.config.schematic)
|
|
452
|
-
return 'no schematic configured; score_schematic does not apply yet';
|
|
453
|
-
const report = await scoreSchematic(path.join(ctx.repoRoot, ctx.config.schematic), {
|
|
454
|
-
docsDir: path.join(ctx.repoRoot, ctx.config.docs),
|
|
455
|
-
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
456
|
-
});
|
|
457
|
-
ctx.lastScore = report.composite;
|
|
458
|
-
return formatScore(report);
|
|
459
|
-
},
|
|
460
|
-
},
|
|
461
|
-
{
|
|
462
|
-
schema: {
|
|
463
|
-
name: 'check_legibility',
|
|
464
|
-
description: 'Run the deterministic legibility checker against the schematic: group boxes and captions, symbol/text collisions, grid alignment, frame and title-block use. Returns numbered findings with coordinates and the concrete fix, or "no findings". Error-severity findings must be reconciled before finish; clears the legibility obligation when clean.',
|
|
465
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
466
|
-
},
|
|
467
|
-
requiresUnlock: false,
|
|
468
|
-
handler: async (ctx) => {
|
|
469
|
-
// Mirrors check_drift's vacuous path: with no schematic configured there is
|
|
470
|
-
// nothing to be illegible, and leaving the obligation open would deadlock
|
|
471
|
-
// any stage that edited a stray .kicad_sch without config wiring.
|
|
472
|
-
if (!ctx.config.schematic) {
|
|
473
|
-
ctx.ledger.clear('legibility');
|
|
474
|
-
return 'no schematic configured; legibility does not apply yet';
|
|
475
|
-
}
|
|
476
|
-
const report = await checkLegibility(path.join(ctx.repoRoot, ctx.config.schematic), {
|
|
477
|
-
docsDir: path.join(ctx.repoRoot, ctx.config.docs),
|
|
478
|
-
...(ctx.config.legibility ? { config: ctx.config.legibility } : {}),
|
|
479
|
-
});
|
|
480
|
-
ctx.lastLegibility = report.counts;
|
|
481
|
-
ctx.ledger.onLegibilityResult(report.counts.error);
|
|
482
|
-
return formatLegibility(report);
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
{
|
|
486
|
-
schema: {
|
|
487
|
-
name: 'run_drc',
|
|
488
|
-
description: 'Run kicad-cli DRC on the board. Clears the DRC obligation when clean.',
|
|
489
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
490
|
-
},
|
|
491
|
-
requiresUnlock: false,
|
|
492
|
-
handler: async (ctx) => {
|
|
493
|
-
if (!ctx.config.board)
|
|
494
|
-
return 'no board configured; DRC does not apply yet — skip it until a board exists and is set in .copperhead/config.json';
|
|
495
|
-
const report = await runDrc(path.join(ctx.repoRoot, ctx.config.board));
|
|
496
|
-
ctx.lastDrc = report;
|
|
497
|
-
if (report.ok)
|
|
498
|
-
ctx.ledger.clear('drc');
|
|
499
|
-
else
|
|
500
|
-
ctx.repairCycles++;
|
|
501
|
-
return formatViolations(report);
|
|
502
|
-
},
|
|
503
|
-
},
|
|
504
|
-
{
|
|
505
|
-
schema: {
|
|
506
|
-
name: 'export_svg',
|
|
507
|
-
description: 'Export an SVG render of the schematic or board into .copperhead/renders/.',
|
|
508
|
-
parameters: {
|
|
509
|
-
type: 'object',
|
|
510
|
-
properties: { kind: { type: 'string', enum: ['sch', 'pcb'] } },
|
|
511
|
-
required: ['kind'],
|
|
512
|
-
},
|
|
513
|
-
},
|
|
514
|
-
requiresUnlock: false,
|
|
515
|
-
handler: async (ctx, args) => {
|
|
516
|
-
const kind = str(args, 'kind');
|
|
517
|
-
const file = kind === 'sch' ? ctx.config.schematic : ctx.config.board;
|
|
518
|
-
if (!file)
|
|
519
|
-
return `no ${kind} configured`;
|
|
520
|
-
const outDir = path.join(ctx.repoRoot, '.copperhead', 'renders');
|
|
521
|
-
await mkdir(outDir, { recursive: true });
|
|
522
|
-
return exportSvg(kind, path.join(ctx.repoRoot, file), outDir);
|
|
523
|
-
},
|
|
524
|
-
},
|
|
525
|
-
{
|
|
526
|
-
schema: {
|
|
527
|
-
name: 'export_outputs',
|
|
528
|
-
description: 'Export the fabrication package into outputs/: gerbers+drill, DXF outline, STEP, SVG renders. Reports per-artifact success/failure.',
|
|
529
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
530
|
-
},
|
|
531
|
-
requiresUnlock: true,
|
|
532
|
-
handler: async (ctx) => {
|
|
533
|
-
if (!ctx.config.board)
|
|
534
|
-
return 'no board configured';
|
|
535
|
-
const outDir = path.join(ctx.repoRoot, 'outputs');
|
|
536
|
-
await mkdir(outDir, { recursive: true });
|
|
537
|
-
const res = await exportFab(path.join(ctx.repoRoot, ctx.config.board), ctx.config.schematic ? path.join(ctx.repoRoot, ctx.config.schematic) : null, outDir);
|
|
538
|
-
ctx.filesTouched.add('outputs/');
|
|
539
|
-
const lines = [`produced: ${res.produced.join(', ') || '(none)'}`];
|
|
540
|
-
for (const f of res.failed)
|
|
541
|
-
lines.push(`FAILED ${f.artifact}: ${f.reason}`);
|
|
542
|
-
return lines.join('\n');
|
|
543
|
-
},
|
|
544
|
-
},
|
|
545
|
-
{
|
|
546
|
-
schema: {
|
|
547
|
-
name: 'check_drift',
|
|
548
|
-
description: 'Compare BOM.md/PINOUT.md tables against the parsed schematic. Clears the drift obligation when clean.',
|
|
549
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
550
|
-
},
|
|
551
|
-
requiresUnlock: false,
|
|
552
|
-
handler: async (ctx) => {
|
|
553
|
-
// No schematic yet means there is nothing for the docs to drift against,
|
|
554
|
-
// so the obligation is vacuously satisfied and must be cleared. Returning
|
|
555
|
-
// without clearing deadlocks every docs-only stage of the create pipeline
|
|
556
|
-
// (spec-seed, architecture, part-selection all run before the schematic
|
|
557
|
-
// exists): a doc edit opens the drift obligation, this is the only tool
|
|
558
|
-
// that clears it, and finish refuses while any obligation is open.
|
|
559
|
-
if (!ctx.config.schematic) {
|
|
560
|
-
ctx.ledger.clear('drift');
|
|
561
|
-
return 'no schematic configured; drift vacuously clean';
|
|
562
|
-
}
|
|
563
|
-
const mismatches = await checkDrift(ctx.repoRoot, ctx.config.docs, ctx.config.schematic);
|
|
564
|
-
if (!mismatches.length) {
|
|
565
|
-
ctx.ledger.clear('drift');
|
|
566
|
-
return 'drift: clean';
|
|
567
|
-
}
|
|
568
|
-
return mismatches.map((m) => `${m.doc}: claims "${m.claim}" but actual is "${m.actual}"`).join('\n');
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
{
|
|
572
|
-
schema: {
|
|
573
|
-
name: 'record_constraint',
|
|
574
|
-
description: '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.',
|
|
575
|
-
parameters: {
|
|
576
|
-
type: 'object',
|
|
577
|
-
properties: {
|
|
578
|
-
key: { type: 'string', description: 'e.g. power.sleep_current_uA' },
|
|
579
|
-
min: { type: 'number' },
|
|
580
|
-
max: { type: 'number' },
|
|
581
|
-
forbidden: { type: 'array', items: { type: 'string' } },
|
|
582
|
-
value: { type: 'string' },
|
|
583
|
-
source: { type: 'string', description: 'doc/spec location that states this' },
|
|
584
|
-
affects: { type: 'array', items: { type: 'string' } },
|
|
585
|
-
},
|
|
586
|
-
required: ['key', 'source', 'affects'],
|
|
587
|
-
},
|
|
588
|
-
},
|
|
589
|
-
requiresUnlock: true,
|
|
590
|
-
handler: async (ctx, args) => {
|
|
591
|
-
const key = str(args, 'key');
|
|
592
|
-
const affects = args.affects ?? [];
|
|
593
|
-
// An affects item whose target artifact is not built yet (no schematic or
|
|
594
|
-
// board configured, no BOM.md) has nothing to revisit; opening an
|
|
595
|
-
// obligation now only forces a ceremonial "not yet created" resolution.
|
|
596
|
-
// Defer it in the registry instead — reopenDeferredAffects re-opens it at
|
|
597
|
-
// the start of the first run where the artifact exists, which is when the
|
|
598
|
-
// revisit actually means something.
|
|
599
|
-
const deferred = [];
|
|
600
|
-
const openNow = [];
|
|
601
|
-
for (const item of affects) {
|
|
602
|
-
const target = classifyAffectsTarget(item);
|
|
603
|
-
if (target && !affectsTargetExists(target, ctx.repoRoot, ctx.config))
|
|
604
|
-
deferred.push(item);
|
|
605
|
-
else
|
|
606
|
-
openNow.push(item);
|
|
607
|
-
}
|
|
608
|
-
await saveConstraint(ctx.repoRoot, key, {
|
|
609
|
-
...(args.min !== undefined ? { min: args.min } : {}),
|
|
610
|
-
...(args.max !== undefined ? { max: args.max } : {}),
|
|
611
|
-
...(args.forbidden !== undefined ? { forbidden: args.forbidden } : {}),
|
|
612
|
-
...(args.value !== undefined ? { value: args.value } : {}),
|
|
613
|
-
source: str(args, 'source'),
|
|
614
|
-
affects,
|
|
615
|
-
...(deferred.length ? { deferred } : {}),
|
|
616
|
-
});
|
|
617
|
-
ctx.ledger.onConstraintChange(key, openNow);
|
|
618
|
-
ctx.ledger.clear('constraint-dual-write', key);
|
|
619
|
-
const parts = [`constraint ${key} recorded`];
|
|
620
|
-
parts.push(`revisit obligations opened for: ${openNow.join(', ') || '(none)'}`);
|
|
621
|
-
if (deferred.length) {
|
|
622
|
-
parts.push(`deferred until the target artifact exists (no resolve_affected needed now): ${deferred.join(', ')}`);
|
|
623
|
-
}
|
|
624
|
-
return parts.join('; ');
|
|
625
|
-
},
|
|
626
|
-
},
|
|
627
|
-
{
|
|
628
|
-
schema: {
|
|
629
|
-
name: 'resolve_affected',
|
|
630
|
-
description: '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.',
|
|
631
|
-
parameters: {
|
|
632
|
-
type: 'object',
|
|
633
|
-
properties: {
|
|
634
|
-
constraint_key: { type: 'string' },
|
|
635
|
-
item: { type: 'string' },
|
|
636
|
-
resolution: { type: 'string', description: '"changed: ..." or "no change needed: <reason>"' },
|
|
637
|
-
resolutions: {
|
|
638
|
-
type: 'array',
|
|
639
|
-
description: 'batch form: resolve many obligations in one call',
|
|
640
|
-
items: {
|
|
641
|
-
type: 'object',
|
|
642
|
-
properties: {
|
|
643
|
-
constraint_key: { type: 'string' },
|
|
644
|
-
item: { type: 'string' },
|
|
645
|
-
resolution: { type: 'string' },
|
|
646
|
-
},
|
|
647
|
-
required: ['constraint_key', 'item', 'resolution'],
|
|
648
|
-
},
|
|
649
|
-
},
|
|
650
|
-
},
|
|
651
|
-
required: [],
|
|
652
|
-
},
|
|
653
|
-
},
|
|
654
|
-
requiresUnlock: true,
|
|
655
|
-
handler: async (ctx, args) => {
|
|
656
|
-
// An item that matches nothing must not read as success: the model would
|
|
657
|
-
// move on believing the obligation closed, and only find out at finish.
|
|
658
|
-
const resolveOne = (constraintKey, item, resolution) => {
|
|
659
|
-
const detail = `${constraintKey} affects ${item}`;
|
|
660
|
-
if (!ctx.ledger.clear('affects-revisit', detail)) {
|
|
661
|
-
const open = ctx.ledger.openOfKind('affects-revisit');
|
|
662
|
-
if (!open.length)
|
|
663
|
-
return `error: no open affects-revisit obligation matches "${detail}"`;
|
|
664
|
-
return [
|
|
665
|
-
`error: no open affects-revisit obligation matches "${detail}".`,
|
|
666
|
-
'Match these exactly:',
|
|
667
|
-
...open.map((o) => ` - ${o.detail}`),
|
|
668
|
-
].join('\n');
|
|
53
|
+
// The main loop's watchdog: streamed progress restarts the idle
|
|
54
|
+
// deadline, and turnMaxMs caps a turn that keeps streaming.
|
|
55
|
+
res = await withRetry(() => withWatchdog((activity) => provider.chat(messages, tools, { onStream: () => activity() }), {
|
|
56
|
+
idleMs: ctx.config.turnTimeoutMs,
|
|
57
|
+
maxMs: ctx.config.turnMaxMs,
|
|
58
|
+
onTimeout: () => provider.close?.(),
|
|
59
|
+
}), { isRetryable: isRateLimit, baseMs: 250 });
|
|
60
|
+
break;
|
|
669
61
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
62
|
+
catch (err) {
|
|
63
|
+
// Only a hung turn retries: one stopped at the hard cap is too large,
|
|
64
|
+
// and resending it would only run into the cap again.
|
|
65
|
+
if (err instanceof TurnTimeoutError && err.kind === 'idle' && timeoutRetries++ < MAX_TURN_TIMEOUTS) {
|
|
66
|
+
await ctx.transcript.event('skill-turn-timeout', {
|
|
67
|
+
skill: skill.name,
|
|
68
|
+
kind: err.kind,
|
|
69
|
+
ms: err.ms,
|
|
70
|
+
attempt: timeoutRetries,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
681
73
|
}
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
.join('\n');
|
|
685
|
-
}
|
|
686
|
-
if (typeof args.constraint_key === 'string' && typeof args.item === 'string' && typeof args.resolution === 'string') {
|
|
687
|
-
return resolveOne(args.constraint_key, args.item, args.resolution);
|
|
688
|
-
}
|
|
689
|
-
return 'error: pass either resolutions: [{constraint_key, item, resolution}, ...] or the single form constraint_key + item + resolution';
|
|
690
|
-
},
|
|
691
|
-
},
|
|
692
|
-
{
|
|
693
|
-
schema: {
|
|
694
|
-
name: 'record_decision',
|
|
695
|
-
description: 'Append a non-trivial decision to docs/DECISIONS.md: what was decided, the one-line why, and what it affects.',
|
|
696
|
-
parameters: {
|
|
697
|
-
type: 'object',
|
|
698
|
-
properties: {
|
|
699
|
-
decision: { type: 'string' },
|
|
700
|
-
rationale: { type: 'string' },
|
|
701
|
-
affects: { type: 'string', description: 'refdes/nets/docs affected' },
|
|
702
|
-
},
|
|
703
|
-
required: ['decision', 'rationale'],
|
|
704
|
-
},
|
|
705
|
-
},
|
|
706
|
-
requiresUnlock: true,
|
|
707
|
-
handler: async (ctx, args) => {
|
|
708
|
-
const corrupt = corruptionError({ decision: args.decision, rationale: args.rationale, affects: args.affects });
|
|
709
|
-
if (corrupt)
|
|
710
|
-
return corrupt;
|
|
711
|
-
const decision = str(args, 'decision');
|
|
712
|
-
const rationale = str(args, 'rationale');
|
|
713
|
-
const affects = args.affects ?? '';
|
|
714
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
715
|
-
const entry = `- ${date} [run ${ctx.runId}] ${decision} | why: ${rationale}${affects ? ` | affects: ${affects}` : ''}`;
|
|
716
|
-
const p = path.join(ctx.repoRoot, ctx.config.docs, 'DECISIONS.md');
|
|
717
|
-
await appendFile(p, entry + '\n', 'utf8');
|
|
718
|
-
ctx.decisions.push(`${decision} | why: ${rationale}`);
|
|
719
|
-
ctx.filesTouched.add(path.join(ctx.config.docs, 'DECISIONS.md'));
|
|
720
|
-
return 'decision recorded';
|
|
721
|
-
},
|
|
722
|
-
},
|
|
723
|
-
{
|
|
724
|
-
schema: {
|
|
725
|
-
name: 'finish',
|
|
726
|
-
description: '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.',
|
|
727
|
-
parameters: {
|
|
728
|
-
type: 'object',
|
|
729
|
-
properties: {
|
|
730
|
-
outcome: { type: 'string', enum: ['done', 'refuse'] },
|
|
731
|
-
summary: { type: 'string' },
|
|
732
|
-
},
|
|
733
|
-
required: ['outcome', 'summary'],
|
|
734
|
-
},
|
|
735
|
-
},
|
|
736
|
-
requiresUnlock: false,
|
|
737
|
-
handler: async (ctx, args) => {
|
|
738
|
-
const outcome = str(args, 'outcome');
|
|
739
|
-
const summary = str(args, 'summary');
|
|
740
|
-
if (outcome === 'refuse') {
|
|
741
|
-
ctx.finishRequest = { outcome, summary };
|
|
742
|
-
return 'refusal recorded; run will end';
|
|
743
|
-
}
|
|
744
|
-
const problems = [];
|
|
745
|
-
const touchedKicad = [...ctx.filesTouched].some((f) => isKicadFile(f));
|
|
746
|
-
if (touchedKicad) {
|
|
747
|
-
if (!ctx.lastErc?.ok)
|
|
748
|
-
problems.push('ERC has not passed since the last schematic edit (run run_erc)');
|
|
749
|
-
const touchedPcb = [...ctx.filesTouched].some((f) => f.endsWith('.kicad_pcb'));
|
|
750
|
-
if (touchedPcb && !ctx.lastDrc?.ok)
|
|
751
|
-
problems.push('DRC has not passed since the last board edit (run run_drc)');
|
|
752
|
-
}
|
|
753
|
-
// the changelog obligation is cleared by the commit path itself
|
|
754
|
-
const blocking = ctx.ledger.openObligations.filter((o) => o.kind !== 'changelog');
|
|
755
|
-
if (blocking.length) {
|
|
756
|
-
problems.push('open sync obligations:\n' + blocking.map((o) => ` - [${o.kind}] ${o.detail}`).join('\n'));
|
|
757
|
-
}
|
|
758
|
-
if (problems.length) {
|
|
759
|
-
return `cannot finish yet:\n${problems.map((p) => `- ${p}`).join('\n')}`;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
760
76
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
77
|
+
messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
|
|
78
|
+
if (!res.toolCalls.length) {
|
|
79
|
+
if (await skill.isComplete(ctx, args))
|
|
80
|
+
break;
|
|
81
|
+
messages.push({ role: 'user', content: 'Continue using tools until the report is complete.' });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (const call of res.toolCalls) {
|
|
85
|
+
const envelope = await dispatchToolResult(ctx, call.name, call.args, { only });
|
|
86
|
+
results.push({ name: call.name, result: envelope });
|
|
87
|
+
const flat = flatten(envelope);
|
|
88
|
+
await ctx.transcript.event('skill-tool', {
|
|
89
|
+
skill: skill.name,
|
|
90
|
+
name: call.name,
|
|
91
|
+
args: call.args,
|
|
92
|
+
result: flat,
|
|
93
|
+
envelope,
|
|
94
|
+
});
|
|
95
|
+
messages.push({ role: 'tool', toolCallId: call.id, content: flat });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
776
98
|
}
|
|
777
|
-
|
|
778
|
-
|
|
99
|
+
finally {
|
|
100
|
+
ctx.finishRequest = savedFinish;
|
|
779
101
|
}
|
|
102
|
+
const detail = results.map(({ name, result }) => `${name}:\n${flatten(result)}`).join('\n\n') || 'no tool results';
|
|
103
|
+
const complete = await skill.isComplete(ctx, args);
|
|
104
|
+
return seal({
|
|
105
|
+
ok: complete,
|
|
106
|
+
summary: complete ? 'design report' : 'design report incomplete',
|
|
107
|
+
detail,
|
|
108
|
+
viewHint: 'diagnostic',
|
|
109
|
+
});
|
|
780
110
|
}
|
|
781
111
|
//# sourceMappingURL=tools.js.map
|