copperhead 0.8.1 → 0.10.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/NOTICE +1 -1
- package/README.md +13 -5
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +67 -62
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/openai.js +28 -6
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +95 -1
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/response-cache.js +18 -2
- package/dist/agent/response-cache.js.map +1 -1
- package/dist/agent/tools.js +185 -1
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +77 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +282 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +211 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +61 -4
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/cli.js +7 -26
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +132 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +2389 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +490 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +181 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +561 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +261 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +262 -10
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/memory/bom-table.js +108 -34
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +6 -0
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/openspec/cli.js +2 -1
- package/dist/openspec/cli.js.map +1 -1
- package/dist/util/preflight.js +17 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/redact.js +12 -2
- package/dist/util/redact.js.map +1 -1
- package/package.json +9 -7
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +88 -65
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/openai.ts +38 -4
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/response-cache.ts +17 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +73 -2
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +278 -22
- package/src/commands/doctor.ts +219 -12
- package/src/config.ts +107 -2
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +6 -19
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +171 -0
- package/src/kicad/draft/engine.ts +2466 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +535 -0
- package/src/kicad/emit.ts +236 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +646 -0
- package/src/kicad/score.ts +323 -0
- package/src/kicad/sexp.ts +339 -10
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +119 -31
- package/src/memory/scaffold.ts +6 -0
- package/src/openspec/cli.ts +3 -2
- package/src/util/preflight.ts +18 -0
- package/src/util/redact.ts +12 -2
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
package/src/commands/create.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
-
import { loadConfig } from '../config.js';
|
|
6
|
-
import { bootstrapKicadProject } from '../kicad/bootstrap.js';
|
|
5
|
+
import { loadConfig, resolveCompatSettings } from '../config.js';
|
|
6
|
+
import { bootstrapKicadProject, markCreateOrigin } from '../kicad/bootstrap.js';
|
|
7
7
|
import { exportSvg, runErc } from '../kicad/cli.js';
|
|
8
8
|
import { listSymbols } from '../kicad/sexp.js';
|
|
9
|
+
import { checkLegibility } from '../kicad/legibility.js';
|
|
10
|
+
import { draftSchematicToText, defaultIntentPath } from '../kicad/draft/draft.js';
|
|
9
11
|
import { isDirty, commitAll, changedFiles } from '../util/git.js';
|
|
10
|
-
import type { CopperheadConfig } from '../config.js';
|
|
12
|
+
import type { CompatSettings, CopperheadConfig } from '../config.js';
|
|
11
13
|
import { checkDrift } from '../memory/drift.js';
|
|
12
14
|
import { runAgentLoop, makeProvider, type BudgetExhaustedStats } from '../agent/loop.js';
|
|
13
|
-
import { diagnoseStageFailure, transcriptExcerpt, withTimeout, type StageDiagnosis } from '../agent/recovery.js';
|
|
15
|
+
import { diagnoseStageFailure, transcriptExcerpt, withTimeout, symbolAvailabilityFacts, type StageDiagnosis } from '../agent/recovery.js';
|
|
14
16
|
import type { Provider } from '../agent/types.js';
|
|
15
17
|
import type { RunMetaInput } from '../agent/runmeta.js';
|
|
16
18
|
import { fmtDuration, fmtTokens, type ProgressRenderer } from '../agent/render.js';
|
|
17
19
|
import { copper, dim, ok, stageLine, warn } from '../agent/theme.js';
|
|
18
20
|
import { openspecInit } from '../openspec/cli.js';
|
|
19
21
|
import { sweepStaleTempDirs, pruneHistoryDir } from '../util/tmp.js';
|
|
22
|
+
import { bomSymbolDossier } from '../kicad/dossier.js';
|
|
23
|
+
import { symbolSearchDirs } from '../kicad/symlib.js';
|
|
20
24
|
import { assertDiskSpace, DEFAULT_MIN_FREE_BYTES } from '../util/preflight.js';
|
|
21
25
|
import { runCheck } from './check.js';
|
|
22
26
|
import { emitCreateJlcpcbBom } from './export.js';
|
|
@@ -55,24 +59,133 @@ async function docHasHeading(repoRoot: string, rel: string, word: string): Promi
|
|
|
55
59
|
return re.test(await readFile(p, 'utf8'));
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
export async function writeBriefHash(
|
|
63
|
+
repoRoot: string,
|
|
64
|
+
docsDir: string,
|
|
65
|
+
briefMeta: { path: string; sha256: string },
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const file = path.join(repoRoot, docsDir, 'BRIEF.sha256');
|
|
68
|
+
|
|
69
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(
|
|
73
|
+
file,
|
|
74
|
+
`brief: ${briefMeta.path}
|
|
75
|
+
sha256: ${briefMeta.sha256}
|
|
76
|
+
`,
|
|
77
|
+
{
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
flag: 'wx',
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const e = err as NodeJS.ErrnoException;
|
|
84
|
+
if (e.code !== 'EEXIST') throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true when a directory exists and contains at least one file
|
|
90
|
+
* matching the optional glob-style extension list (case-insensitive).
|
|
91
|
+
* No extension list = any file.
|
|
92
|
+
*/
|
|
93
|
+
async function dirHasFiles(dirPath: string, exts?: string[]): Promise<boolean> {
|
|
94
|
+
if (!existsSync(dirPath)) return false;
|
|
95
|
+
async function walk(dir: string): Promise<boolean> {
|
|
96
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
if (await walk(path.join(dir, entry.name))) return true;
|
|
99
|
+
} else if (!exts || exts.some((e) => entry.name.toLowerCase().endsWith(e))) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return walk(dirPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
58
108
|
export const STAGES: Stage[] = [
|
|
59
109
|
{
|
|
60
110
|
name: 'spec-seed',
|
|
61
|
-
isComplete: (root, docs) =>
|
|
111
|
+
isComplete: async (root, docs) => {
|
|
112
|
+
// The init scaffold writes SPEC.md with a "## Budgets" heading and an
|
|
113
|
+
// HTML comment placeholder — that alone must not count as complete.
|
|
114
|
+
// Require the heading AND at least one non-comment, non-blank line
|
|
115
|
+
// of real budget content beneath it (a filled section vs. an empty placeholder).
|
|
116
|
+
const p = path.join(root, docs, 'SPEC.md');
|
|
117
|
+
if (!existsSync(p)) return false;
|
|
118
|
+
const text = await readFile(p, 'utf8');
|
|
119
|
+
const heading = /^(#{1,6})\s.*\bBudgets?\b.*$/im.exec(text);
|
|
120
|
+
if (!heading) return false;
|
|
121
|
+
// The section runs to the next heading of the SAME OR SHALLOWER depth, not to
|
|
122
|
+
// the next heading of any depth (I17): a real spec routinely splits its budgets
|
|
123
|
+
// into subsections — "## 3. Electrical budgets" followed immediately by
|
|
124
|
+
// "### 3.1 Input and rails" — which leaves the parent's own body empty and read
|
|
125
|
+
// as an unfilled placeholder. Subheadings are part of the section, not its end.
|
|
126
|
+
const depth = heading[1]!.length;
|
|
127
|
+
const afterHeadingLine = text.slice(heading.index + heading[0].length);
|
|
128
|
+
const nextSection = afterHeadingLine.search(new RegExp(`^#{1,${depth}}\\s`, 'm'));
|
|
129
|
+
const section = nextSection >= 0 ? afterHeadingLine.slice(0, nextSection) : afterHeadingLine;
|
|
130
|
+
// Strip HTML comments (single or multi-line); headings inside the section are
|
|
131
|
+
// structure, not content, so a section of nothing but subheadings still fails.
|
|
132
|
+
const cleanSection = section.replace(/<!--[\s\S]*?-->/g, '');
|
|
133
|
+
const realLines = cleanSection
|
|
134
|
+
.split('\n')
|
|
135
|
+
.filter((l) => l.trim().length > 0 && !/^#{1,6}\s/.test(l.trim()));
|
|
136
|
+
return realLines.length > 0;
|
|
137
|
+
},
|
|
62
138
|
prompt: (brief) =>
|
|
63
139
|
`Stage 1 of the create pipeline: seed the requirements. From the product brief below, write docs/SPEC.md (what the device is, top-level constraints and budgets). Every budget you state must also be recorded with record_constraint. Anything the brief does not state: propose a sensible default and flag it ASSUMED. If an openspec/ workspace exists, also seed openspec/specs/ with per-capability requirements using Given/When/Then scenarios.\n\nBrief:\n${brief}`,
|
|
64
140
|
},
|
|
65
141
|
{
|
|
66
142
|
name: 'architecture',
|
|
67
|
-
isComplete: (root, docs) =>
|
|
143
|
+
isComplete: async (root, docs) => {
|
|
144
|
+
// init scaffolds SUBSYSTEMS.md with boilerplate description text and auto-generated
|
|
145
|
+
// "## Sheet X" headings containing "- Ref: Value" symbol bullets.
|
|
146
|
+
// Require at least one level-2+ heading (## section) AND at least one real prose
|
|
147
|
+
// line beneath it (excluding boilerplate and auto-generated symbol bullets).
|
|
148
|
+
const p = path.join(root, docs, 'SUBSYSTEMS.md');
|
|
149
|
+
if (!existsSync(p)) return false;
|
|
150
|
+
const text = await readFile(p, 'utf8');
|
|
151
|
+
// Must have at least one level-2+ (##) section heading
|
|
152
|
+
if (!/^#{2,6}\s/m.test(text)) return false;
|
|
153
|
+
// Filter out headings, scaffold description, and auto-generated symbol bullets (- Ref: Value or - Ref?: Value)
|
|
154
|
+
const contentLines = text.split('\n').filter((l) => {
|
|
155
|
+
const trimmed = l.trim();
|
|
156
|
+
if (!trimmed || trimmed.startsWith('#')) return false;
|
|
157
|
+
if (trimmed.includes('Per-sheet values and reasoning')) return false;
|
|
158
|
+
if (/^-\s+(?:[A-Za-z]+\d+[A-Za-z]*|[A-Za-z]*\?):/.test(trimmed)) return false; // auto-generated refdes symbol bullet (e.g. - R1: 10k, - U?: ESP32, - ?: 10k)
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
return contentLines.length > 0;
|
|
162
|
+
},
|
|
68
163
|
prompt: () =>
|
|
69
164
|
'Stage 2: architecture. Write docs/SUBSYSTEMS.md: the block diagram in prose, one section per subsystem (power, MCU, connectivity, UI, ...), with the reasoning and key values for each. Respect every budget in SPEC.md.',
|
|
70
165
|
},
|
|
71
166
|
{
|
|
72
167
|
name: 'part-selection',
|
|
73
|
-
isComplete: (root, docs) =>
|
|
168
|
+
isComplete: async (root, docs) => {
|
|
169
|
+
// init scaffolds BOM.md with a table pre-filled with UNVERIFIED MPNs
|
|
170
|
+
// extracted from the schematic. Require at least one row whose MPN
|
|
171
|
+
// column is NOT the UNVERIFIED placeholder — i.e. a real part was chosen.
|
|
172
|
+
const p = path.join(root, docs, 'BOM.md');
|
|
173
|
+
if (!existsSync(p)) return false;
|
|
174
|
+
const text = await readFile(p, 'utf8');
|
|
175
|
+
// Find table rows (lines starting with |) that are not the header or separator
|
|
176
|
+
const rows = text.split('\n').filter(
|
|
177
|
+
(l) => l.startsWith('|') && !l.includes('---') && !l.toLowerCase().includes('refdes'),
|
|
178
|
+
);
|
|
179
|
+
if (!rows.length) return false;
|
|
180
|
+
// At least one row must have a non-UNVERIFIED MPN (4th column)
|
|
181
|
+
return rows.some((row) => {
|
|
182
|
+
const cols = row.split('|').map((c) => c.trim());
|
|
183
|
+
const mpn = cols[4] ?? ''; // 0=empty, 1=Refdes, 2=Value, 3=Footprint, 4=MPN
|
|
184
|
+
return mpn && !mpn.toUpperCase().startsWith('UNVERIFIED');
|
|
185
|
+
});
|
|
186
|
+
},
|
|
74
187
|
prompt: () =>
|
|
75
|
-
'Stage 3: part selection. Write docs/BOM.md with the fixed table format (| Refdes | Value | Footprint | MPN | Rationale |). Every MPN you introduce is flagged UNVERIFIED with a datasheet-verifiable justification. Check leakage/quiescent current of every part against the power budget. Run check_drift before finishing.',
|
|
188
|
+
'Stage 3: part selection. Write docs/BOM.md with the fixed table format (| Refdes | Value | Footprint | MPN | Rationale |). The Value column holds the COMPONENT VALUE and nothing else — "4.7uF", "1M", "500mAh Li-Po", "STM32F103C8T6" — because stage 4 draws it on the sheet as that part\'s Value field, where a description ("1S Li-Po cell, 500 mAh, bare leads") collides with neighbouring symbols and fails the legibility gate. Put the prose in the Rationale column instead; that is the column for it, and nothing draws it. One row per refdes: a grouped row ("SW3-SW16", "C5-C8") is not a BOM row and the schematic stage cannot match it. Every MPN you introduce is flagged UNVERIFIED with a datasheet-verifiable justification. Check leakage/quiescent current of every part against the power budget. The design must be capturable with the KiCad symbol libraries installed on THIS machine: run search_symbols for every IC, module, connector and other active part before committing it to the BOM, and if a part has no installed symbol, pick one that has — stage 4 draws only from installed symbols, and a BOM row it cannot resolve makes the whole run unwinnable. Existence is not enough: confirm the chosen symbol with symbol_pins so the pin numbers you wire in stage 4 are real. Multi-unit symbols (gate packs, dual opamps) are fine — the engine places each unit separately under the one refdes, and net endpoints use plain package pin numbers. Run check_drift before finishing.',
|
|
76
189
|
},
|
|
77
190
|
{
|
|
78
191
|
name: 'schematic',
|
|
@@ -98,10 +211,35 @@ export const STAGES: Stage[] = [
|
|
|
98
211
|
// schematic, advancing the pipeline against unverified work. Returning
|
|
99
212
|
// false here keeps the stage active so it re-runs, fixes ERC, and commits
|
|
100
213
|
// through the normal finish gate.
|
|
101
|
-
|
|
214
|
+
if (!(await runErc(p)).ok) return false;
|
|
215
|
+
// Legibility is the one stage-4 output no electrical gate sees (AC-16.22):
|
|
216
|
+
// an ERC-clean sheet with text over symbol bodies passes everything above
|
|
217
|
+
// while being unreviewable. Error-severity findings keep the stage active;
|
|
218
|
+
// advisories never block.
|
|
219
|
+
const legibility = await checkLegibility(p, {
|
|
220
|
+
docsDir: path.join(root, config.docs),
|
|
221
|
+
...(config.legibility ? { config: config.legibility } : {}),
|
|
222
|
+
});
|
|
223
|
+
if (legibility.counts.error !== 0) return false;
|
|
224
|
+
// Drafting mode: the schematic must match a re-draft of the current IR
|
|
225
|
+
// (AC-16.20) — an intent edited after the last draft_schematic call means
|
|
226
|
+
// the sheet on disk no longer reflects the design and the stage stays
|
|
227
|
+
// active until a re-draft.
|
|
228
|
+
const intentRel = defaultIntentPath(config.schematic);
|
|
229
|
+
if (existsSync(path.join(root, intentRel))) {
|
|
230
|
+
const dry = await draftSchematicToText({
|
|
231
|
+
repoRoot: root,
|
|
232
|
+
schematic: config.schematic,
|
|
233
|
+
intentPath: intentRel,
|
|
234
|
+
docsDir: config.docs,
|
|
235
|
+
});
|
|
236
|
+
if (!dry.ok) return false;
|
|
237
|
+
if (dry.text !== (await readFile(p, 'utf8'))) return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
102
240
|
},
|
|
103
241
|
prompt: () =>
|
|
104
|
-
'Stage 4: schematic. An empty KiCad project has already been scaffolded and wired into .copperhead/config.json
|
|
242
|
+
'Stage 4: schematic. An empty KiCad project has already been scaffolded and wired into .copperhead/config.json. You author INTENT, never geometry: write the netlist-intent IR and call draft_schematic — the deterministic engine computes every coordinate, wire, label, power symbol, and group box, and the sheet it draws satisfies the drafting standard by construction (captioned group boxes per SUBSYSTEMS.md subsystem, left-to-right flow, rails up and grounds down, net labels between groups, filled title block). The IR (schematic.intent.json) is JSON: {"version": 1, "parts": [{"ref", "libId", "value", "footprint", "group"}], "nets": [{"name", "pins": ["REF.PIN", …], "kind"?}], "noConnect": ["REF.PIN", …], "hints"?: {"groupOrder"?, "paper"?, "date"?}}. Build it from BOM.md (same refdes and values — validation cross-checks and refuses mismatches) and SUBSYSTEMS.md (every non-power part names one subsystem heading as its group). Use exact canonical KiCad lib_ids (e.g. Device:R) and REAL pin numbers from the library: the pin dossier below (when present) already lists every BOM part\'s installed symbol and its real pins — work from it and from symbol_pins rather than reading .kicad_sym files, and validation lists a part\'s actual pins when you name one that does not exist. Declare every deliberately unused pin in noConnect; power rails are recognized from pin types automatically (override with "kind" only when the inference is wrong — the draft report lists every net\'s resolved class). Pass the full IR as intent_json to draft_schematic; the report embeds the legibility findings and the score for the fresh sheet. To repair ANY finding (ERC, legibility, validation), fix the IR and call draft_schematic again — edit_file is refused on the drafted sheet. Text-collision findings scale with TEXT LENGTH: a net label, part value, or SUBSYSTEMS heading that is shorter draws a smaller box, so renaming a colliding net (and updating PINOUT.md) or tightening a long heading is a real repair lever; paper size and declaration order are not (placement is grid-derived). When the draft is clean run run_erc and check_drift, update PINOUT.md to match the IR\'s pin assignments, and finish.',
|
|
105
243
|
},
|
|
106
244
|
{
|
|
107
245
|
name: 'layout-draft',
|
|
@@ -122,19 +260,37 @@ export const STAGES: Stage[] = [
|
|
|
122
260
|
},
|
|
123
261
|
{
|
|
124
262
|
name: 'outputs',
|
|
125
|
-
isComplete: (root) =>
|
|
263
|
+
isComplete: async (root) => {
|
|
264
|
+
// An empty outputs/ dir (e.g. from a failed export run) must not count
|
|
265
|
+
// as complete. Require at least one Gerber file (any .gbr variant).
|
|
266
|
+
return dirHasFiles(path.join(root, 'outputs'), ['.gbr', '.gtl', '.gbl', '.gbs', '.gbo', '.gbp', '.gbd', '.gto', '.gts', '.gml']);
|
|
267
|
+
},
|
|
126
268
|
prompt: () =>
|
|
127
269
|
'Stage 6: outputs package. Export into outputs/: gerbers+drill (JLC profile), DXF and STEP outline, SVG renders (export_svg), and an ordering BOM.csv generated from BOM.md (refdes, MPN, qty). Every export must succeed.',
|
|
128
270
|
},
|
|
129
271
|
{
|
|
130
272
|
name: 'firmware',
|
|
131
|
-
isComplete: (root) =>
|
|
273
|
+
isComplete: async (root) => {
|
|
274
|
+
// An empty firmware/ dir must not count. Require at least one source file.
|
|
275
|
+
return dirHasFiles(path.join(root, 'firmware'), ['.c', '.h', '.cpp', '.hpp', '.py', '.rs', '.ino', '.s']);
|
|
276
|
+
},
|
|
132
277
|
prompt: () =>
|
|
133
278
|
'Stage 7: firmware scaffold. Generate firmware/ for the chosen MCU HAL: pins.h generated from PINOUT.md (single source of truth), driver stubs, and one working happy path. If the vendor toolchain is available, the build must pass; if not, note "not compiled here" explicitly in DEVPLAN.md.',
|
|
134
279
|
},
|
|
135
280
|
{
|
|
136
281
|
name: 'devplan',
|
|
137
|
-
isComplete: (root, docs) =>
|
|
282
|
+
isComplete: async (root, docs) => {
|
|
283
|
+
// init does NOT scaffold DEVPLAN.md, but a blank file must not count.
|
|
284
|
+
// Require at least one ## section heading AND at least one content line.
|
|
285
|
+
const p = path.join(root, docs, 'DEVPLAN.md');
|
|
286
|
+
if (!existsSync(p)) return false;
|
|
287
|
+
const text = await readFile(p, 'utf8');
|
|
288
|
+
if (!/^#{1,6}\s/m.test(text)) return false;
|
|
289
|
+
const contentLines = text.split('\n').filter(
|
|
290
|
+
(l) => l.trim() && !l.trim().startsWith('#'),
|
|
291
|
+
);
|
|
292
|
+
return contentLines.length > 0;
|
|
293
|
+
},
|
|
138
294
|
prompt: () =>
|
|
139
295
|
'Stage 8: DEVPLAN.md. Write docs/DEVPLAN.md: bring-up steps in order, test points and what to meter first, risk list, and the prototype order plan.',
|
|
140
296
|
},
|
|
@@ -165,6 +321,36 @@ async function emitJlcpcbAfterOutputs(stageName: string, opts: CreateOptions): P
|
|
|
165
321
|
if (out) opts.log(stageLine('outputs', `emitted ${out} (JLCPCB assembly BOM)`, 'ok'));
|
|
166
322
|
}
|
|
167
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The generic "contract not met" line names no defect. For the schematic stage
|
|
326
|
+
* the most common gap after the electrical gates go green is legibility, so
|
|
327
|
+
* name the finding counts by kind — the resume then starts on the actual work
|
|
328
|
+
* instead of rediscovering it.
|
|
329
|
+
*/
|
|
330
|
+
async function contractGapDetail(stageName: string, root: string, config: CopperheadConfig): Promise<string> {
|
|
331
|
+
const generic = 'the run finished but the stage completion contract is not met — no usable artifact was produced';
|
|
332
|
+
if (stageName !== 'schematic' || !config.schematic) return generic;
|
|
333
|
+
const p = path.join(root, config.schematic);
|
|
334
|
+
if (!existsSync(p)) return generic;
|
|
335
|
+
try {
|
|
336
|
+
const report = await checkLegibility(p, {
|
|
337
|
+
docsDir: path.join(root, config.docs),
|
|
338
|
+
...(config.legibility ? { config: config.legibility } : {}),
|
|
339
|
+
});
|
|
340
|
+
if (report.counts.error > 0) {
|
|
341
|
+
const byKind = new Map<string, number>();
|
|
342
|
+
for (const f of report.findings.filter((f) => f.severity === 'error')) {
|
|
343
|
+
byKind.set(f.kind, (byKind.get(f.kind) ?? 0) + 1);
|
|
344
|
+
}
|
|
345
|
+
const counts = [...byKind].map(([k, n]) => `${k}: ${n}`).join(', ');
|
|
346
|
+
return `the schematic stage contract is not met: ${report.counts.error} error-severity legibility finding(s) remain (${counts}); resume to reconcile them`;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// fall through: an unreadable schematic already fails earlier contract steps
|
|
350
|
+
}
|
|
351
|
+
return generic;
|
|
352
|
+
}
|
|
353
|
+
|
|
168
354
|
/** Stages whose output is a KiCad file worth rendering to an image (5.4). */
|
|
169
355
|
const KICAD_STAGES = new Set(['schematic', 'layout-draft', 'outputs']);
|
|
170
356
|
|
|
@@ -184,7 +370,10 @@ function isManagedPath(f: string, config: CopperheadConfig): boolean {
|
|
|
184
370
|
f.startsWith('openspec/') ||
|
|
185
371
|
f.startsWith('outputs/') ||
|
|
186
372
|
f.startsWith('firmware/') ||
|
|
373
|
+
f.startsWith('sym-lib-cache/') ||
|
|
187
374
|
f === '.gitignore' ||
|
|
375
|
+
path.basename(f) === 'sym-lib-table' ||
|
|
376
|
+
path.basename(f) === 'schematic.intent.json' ||
|
|
188
377
|
/\.(kicad_sch|kicad_pcb|kicad_pro|kicad_prl)$/.test(f)
|
|
189
378
|
);
|
|
190
379
|
}
|
|
@@ -284,22 +473,31 @@ async function diagnose(input: {
|
|
|
284
473
|
transcriptDir: string;
|
|
285
474
|
attempt: number;
|
|
286
475
|
maxAttempts: number;
|
|
476
|
+
/** Compatible-endpoint settings, so a `compat` run can diagnose itself. */
|
|
477
|
+
compat?: CompatSettings | undefined;
|
|
287
478
|
}): Promise<StageDiagnosis> {
|
|
288
479
|
let provider: Provider | undefined;
|
|
289
480
|
try {
|
|
290
|
-
provider = await makeProvider(input.model);
|
|
481
|
+
provider = await makeProvider(input.model, false, input.compat);
|
|
291
482
|
const p = provider;
|
|
292
483
|
const excerpt = await transcriptExcerpt(input.transcriptDir);
|
|
293
484
|
return await withTimeout(
|
|
294
|
-
() =>
|
|
295
|
-
|
|
485
|
+
async () => {
|
|
486
|
+
// Fact-check symbol-availability claims before the model judges them: a
|
|
487
|
+
// refusal narrating "library not installed" is adjudicated from the
|
|
488
|
+
// machine's actual libraries, not from the narration (I15/#197). Inside
|
|
489
|
+
// the watchdog, so a wedged filesystem scan cannot outlive timeoutMs.
|
|
490
|
+
const symbolFacts = await symbolAvailabilityFacts(`${input.failure}\n${excerpt}`).catch(() => '');
|
|
491
|
+
return diagnoseStageFailure(p, {
|
|
296
492
|
stageName: input.stageName,
|
|
297
493
|
stageGoal: input.stageGoal,
|
|
298
494
|
failure: input.failure,
|
|
299
495
|
excerpt,
|
|
300
496
|
attempt: input.attempt,
|
|
301
497
|
maxAttempts: input.maxAttempts,
|
|
302
|
-
|
|
498
|
+
...(symbolFacts ? { symbolFacts } : {}),
|
|
499
|
+
});
|
|
500
|
+
},
|
|
303
501
|
input.timeoutMs,
|
|
304
502
|
() => p.close?.(),
|
|
305
503
|
);
|
|
@@ -547,7 +745,16 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
547
745
|
const brief = await readFile(path.resolve(opts.briefPath), 'utf8');
|
|
548
746
|
// Hashed from the content already in hand: a brief edited mid-pipeline shows
|
|
549
747
|
// up as a different sha256 in the next stage's metadata (AC-8.1).
|
|
550
|
-
const
|
|
748
|
+
const resolvedBrief = path.resolve(opts.briefPath);
|
|
749
|
+
const relativeBrief = path.relative(opts.repoRoot, resolvedBrief);
|
|
750
|
+
const briefPath =
|
|
751
|
+
relativeBrief.startsWith('..') || path.isAbsolute(relativeBrief)
|
|
752
|
+
? `external:${path.basename(resolvedBrief)}`
|
|
753
|
+
: relativeBrief;
|
|
754
|
+
const briefMeta = {
|
|
755
|
+
path: briefPath,
|
|
756
|
+
sha256: createHash('sha256').update(brief).digest('hex'),
|
|
757
|
+
};
|
|
551
758
|
const config = await loadConfig(opts.repoRoot);
|
|
552
759
|
// Fail fast on a nearly-full disk (4.1): a create run writes fab outputs and
|
|
553
760
|
// KiCad local history and can otherwise fill the disk mid-stage, failing with
|
|
@@ -567,6 +774,11 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
567
774
|
const pruned = await pruneHistoryDir(opts.repoRoot);
|
|
568
775
|
if (pruned) opts.log(dim(`startup: pruned ${pruned} old .history/ entrie(s) to cap local-history growth`));
|
|
569
776
|
await openspecInit(opts.repoRoot);
|
|
777
|
+
// Stamp the repo create-produced before any stage runs: the marker scopes the
|
|
778
|
+
// legibility finish gate and the fab release gate, and it must hold on
|
|
779
|
+
// resumed runs whose project predates the marker (bootstrapKicadProject
|
|
780
|
+
// no-ops on those, so it cannot be the only writer).
|
|
781
|
+
await markCreateOrigin(opts.repoRoot);
|
|
570
782
|
const completed: string[] = [];
|
|
571
783
|
const stageCosts: StageCost[] = [];
|
|
572
784
|
|
|
@@ -586,6 +798,15 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
586
798
|
opts.log(stageLine(stage.name, 'already complete (resuming past it)', 'ok'));
|
|
587
799
|
await commitResumedStage(opts, config, stage.name);
|
|
588
800
|
completed.push(stage.name);
|
|
801
|
+
if (stage.name === 'spec-seed') {
|
|
802
|
+
try {
|
|
803
|
+
await writeBriefHash(opts.repoRoot, config.docs, briefMeta);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
opts.log(
|
|
806
|
+
`warning: could not record brief provenance (${(err as Error).message})`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
589
810
|
stageCosts.push({ name: stage.name, resumed: true, wallMs: 0, turns: 0, tokensIn: 0, tokensOut: 0, cacheHits: 0 });
|
|
590
811
|
await emitJlcpcbAfterOutputs(stage.name, opts);
|
|
591
812
|
continue;
|
|
@@ -625,13 +846,38 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
625
846
|
`running${attempt > 1 ? ` (attempt ${attempt}/${config.maxStageRetries + 1})` : ''}`,
|
|
626
847
|
),
|
|
627
848
|
);
|
|
849
|
+
// The BOM freezes before this stage, so every part's real pins are
|
|
850
|
+
// computable before the first turn — recomputed per attempt, since a
|
|
851
|
+
// rolled-back retry can run against a different BOM than its
|
|
852
|
+
// predecessor. Advisory only: any failure degrades to no block.
|
|
853
|
+
let dossierBlock = '';
|
|
854
|
+
if (stage.name === 'schematic') {
|
|
855
|
+
try {
|
|
856
|
+
const bomPath = path.join(opts.repoRoot, config.docs, 'BOM.md');
|
|
857
|
+
if (existsSync(bomPath)) {
|
|
858
|
+
// Bounded: a slow or wedged library scan must delay the stage by a
|
|
859
|
+
// fixed cost at most — on timeout the stage simply runs dossier-less.
|
|
860
|
+
const dossier = await withTimeout(
|
|
861
|
+
async () => bomSymbolDossier(await readFile(bomPath, 'utf8'), await symbolSearchDirs()),
|
|
862
|
+
60_000,
|
|
863
|
+
);
|
|
864
|
+
if (dossier) {
|
|
865
|
+
dossierBlock =
|
|
866
|
+
'\n\n## Installed-symbol pin dossier (machine-verified)\nEach BOM part resolved against the KiCad libraries installed on THIS machine: the top name-match lib_id and its REAL pins (number=name/electrical-type). Confirm the match fits the BOM part; alternatives are listed. Passives (R/C/L) draw from their canonical Device symbols and are omitted. Use these pins for REF.PIN endpoints instead of reading .kicad_sym files; for any part not listed, call symbol_pins.\n' +
|
|
867
|
+
dossier;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
// the dossier is context, never a gate — the stage runs without it
|
|
872
|
+
}
|
|
873
|
+
}
|
|
628
874
|
const res = await runAgentLoop({
|
|
629
875
|
repoRoot: opts.repoRoot,
|
|
630
876
|
model: opts.model,
|
|
631
877
|
request: `create pipeline stage: ${stage.name}`,
|
|
632
878
|
stagePrompt: guidance
|
|
633
|
-
? `${basePrompt}\n\n## Recovery guidance (a previous attempt did not complete this stage — do this differently)\n${guidance}`
|
|
634
|
-
: basePrompt
|
|
879
|
+
? `${basePrompt}${dossierBlock}\n\n## Recovery guidance (a previous attempt did not complete this stage — do this differently)\n${guidance}`
|
|
880
|
+
: `${basePrompt}${dossierBlock}`,
|
|
635
881
|
interactive: opts.interactive ?? false,
|
|
636
882
|
allowDirty: true, // stages build on each other's uncommitted state within the pipeline
|
|
637
883
|
...(stageTurns !== undefined ? { maxTurns: stageTurns } : {}),
|
|
@@ -663,7 +909,7 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
663
909
|
res.outcome !== 'success'
|
|
664
910
|
? `the run ended as "${res.outcome}" (${res.exitPath})`
|
|
665
911
|
: !(await stage.isComplete(opts.repoRoot, config.docs))
|
|
666
|
-
?
|
|
912
|
+
? await contractGapDetail(stage.name, opts.repoRoot, config)
|
|
667
913
|
: null;
|
|
668
914
|
if (!failure) {
|
|
669
915
|
stageDone = true;
|
|
@@ -685,6 +931,7 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
685
931
|
const diagnosis = await diagnose({
|
|
686
932
|
model: opts.model,
|
|
687
933
|
timeoutMs: config.turnTimeoutMs,
|
|
934
|
+
compat: resolveCompatSettings(config),
|
|
688
935
|
stageName: stage.name,
|
|
689
936
|
stageGoal: basePrompt,
|
|
690
937
|
failure,
|
|
@@ -721,6 +968,15 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
|
|
|
721
968
|
return { ok: false, completed };
|
|
722
969
|
}
|
|
723
970
|
completed.push(stage.name);
|
|
971
|
+
if (stage.name === 'spec-seed') {
|
|
972
|
+
try {
|
|
973
|
+
await writeBriefHash(opts.repoRoot, config.docs, briefMeta);
|
|
974
|
+
} catch (err) {
|
|
975
|
+
opts.log(
|
|
976
|
+
`warning: could not record brief provenance (${(err as Error).message})`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
724
980
|
await renderStageArtifacts(opts, stage.name, stageTranscriptDir);
|
|
725
981
|
await emitJlcpcbAfterOutputs(stage.name, opts);
|
|
726
982
|
logCumulative(opts, stageCosts);
|