From 0e0a41a8020df59a0fea32d48acdbfcee5b29dc4 Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:36:19 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(agent):=20context-first=20P0b/d=20?= =?UTF-8?q?=E2=80=94=20shared=20flag=20core=20+=20AGENTS=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync zero-dep buildLabwiredContext into extension; workspaceContext is fs pack only. AGENTS.md session orientation. Parity script for ship gate. --- config/AGENTS.md | 8 + ...26-08-11-context-first-workbench-design.md | 37 +++ .../scripts/assert-context-parity.mjs | 151 +++++++++++ .../src/board/contextFlags.generated.sha256 | 1 + .../src/board/contextFlags.generated.ts | 238 ++++++++++++++++++ .../src/board/workspaceContext.ts | 137 ++++++++++ 6 files changed, 572 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-context-first-workbench-design.md create mode 100644 extensions/labwired-vscode/scripts/assert-context-parity.mjs create mode 100644 extensions/labwired-vscode/src/board/contextFlags.generated.sha256 create mode 100644 extensions/labwired-vscode/src/board/contextFlags.generated.ts create mode 100644 extensions/labwired-vscode/src/board/workspaceContext.ts diff --git a/config/AGENTS.md b/config/AGENTS.md index 4b9f71b..6aa2b20 100644 --- a/config/AGENTS.md +++ b/config/AGENTS.md @@ -27,6 +27,14 @@ Do not claim real hardware was tested unless a hardware path actually ran. `hardware_observed` (flash + serial/RTT marker) is **never** upgraded to `model_verified`. +## Session orientation + +1. Prefer MCP `labwired_context` (or an injected `[labwired_context]` block) before assuming board or twin state. +2. Prefer `labwired_import` with `diagram_json` (P0) over hand-parsing schematics. +3. If twin is not buildable, continue design from context + `labwired_part` / `labwired_datasheet`. Never invent pins. +4. `model_verified` only from `labwired_verify`. `hardware_observed` only from desk-hw / real probe. + + ## Plots = elements (not ready-made views) When the user wants a **plot, chart, scope, overlay, or “show X over time”**: diff --git a/docs/superpowers/specs/2026-08-11-context-first-workbench-design.md b/docs/superpowers/specs/2026-08-11-context-first-workbench-design.md new file mode 100644 index 0000000..b246daf --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-context-first-workbench-design.md @@ -0,0 +1,37 @@ +# Context-first workbench — agent mirror + +| Field | Value | +|-------|-------| +| **Canonical** | `labwired/docs/superpowers/specs/2026-08-11-context-first-workbench-design.md` | +| **Binding plan v3** | `labwired/docs/superpowers/plans/2026-08-11-context-first-p0.md` | +| **Ship claim** | Only **P0-ship** (`P0_SHIP_OK`), not P0a alone | + +--- + +## Agent PRs + +| PR | This repo | Pass | +|----|-----------|------| +| **P0b** | Sync `contextFlags.generated.ts`; `workspaceContext` pure only; `assert-context-parity.mjs`; **no** version/chrome edits | tsc + parity exit 0 | +| **P0d** | `config/AGENTS.md` session orientation (file already in package) | merged | + +P0a / P0c are monorepo. Do not start P1 kinds or desk-hw UI. + +--- + +## Locked decisions + +- Packaging: **sha sync**, not path-dep on `@labwired/board-config` +- Chrome: **frozen** until P0-ship +- Prove: **mandatory** in monorepo P0c (not extension’s job to fake) +- Netlist: monorepo kill switch — not agent scope + +--- + +## Claims + +| Claim | Mints | +|-------|--------| +| design context | pack/context — not prove | +| `model_verified` | `labwired_verify` only | +| `hardware_observed` | desk-hw / probe only | diff --git a/extensions/labwired-vscode/scripts/assert-context-parity.mjs b/extensions/labwired-vscode/scripts/assert-context-parity.mjs new file mode 100644 index 0000000..0f95cd8 --- /dev/null +++ b/extensions/labwired-vscode/scripts/assert-context-parity.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * P0b: extension flag engine must match monorepo rules for mint_ok packs. + * Run: node scripts/assert-context-parity.mjs + */ +import { createRequire } from "module"; +import { pathToFileURL } from "url"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const genPath = path.join(__dirname, "../src/board/contextFlags.generated.ts"); + +// Load via dynamic import of compiled JS if present; else use tsx/require transpile. +// Prefer evaluating the pure TS by spawning tsc isn't available — use node --experimental +// For extension, generated file is pure TS with no imports: strip types via quick eval. + +import fs from "fs"; +import { createHash } from "crypto"; + +const srcShaPath = path.join( + __dirname, + "../../../../clones/labwired/packages/board-config/src/labwired-context.sha256" +); +const localShaPath = path.join( + __dirname, + "../src/board/contextFlags.generated.sha256" +); + +// Resolve monorepo sha from several layouts +const shaCandidates = [ + path.join(__dirname, "../../../../clones/labwired/packages/board-config/src/labwired-context.sha256"), + path.join(__dirname, "../../../../../clones/labwired/packages/board-config/src/labwired-context.sha256"), + path.join(process.env.HOME || "", "clones/labwired/packages/board-config/src/labwired-context.sha256"), + path.join(process.env.HOME || "", "Projects/labwired/packages/board-config/src/labwired-context.sha256"), +]; + +function readFirst(paths) { + for (const p of paths) { + try { + if (fs.existsSync(p)) return fs.readFileSync(p, "utf8").trim(); + } catch { + /* */ + } + } + return null; +} + +const localSha = fs.existsSync(localShaPath) + ? fs.readFileSync(localShaPath, "utf8").trim() + : null; +const monoSha = readFirst(shaCandidates); + +if (localSha && monoSha && localSha !== monoSha) { + console.error("SHA mismatch: extension core != monorepo labwired-context.ts"); + console.error(" local", localSha); + console.error(" mono ", monoSha); + console.error("Run: clones/labwired/scripts/sync-context-core.sh"); + process.exit(1); +} + +// Runtime flag check: transpile-free — execute buildLabwiredContext by +// importing generated .ts through a tiny strip (no types at runtime in node). +// Use esbuild-register if available; else inline minimal port of the test. +const gen = fs.readFileSync(genPath, "utf8"); +// Verify file contains the export we need +if (!gen.includes("export function buildLabwiredContext")) { + console.error("contextFlags.generated.ts missing buildLabwiredContext"); + process.exit(1); +} + +// Dynamic: write a temporary .mjs stripping types is hard; use node --import tsx if present +async function loadBuilder() { + try { + const { register } = await import("node:module"); + // Prefer tsx + const { buildLabwiredContext } = await import( + pathToFileURL(genPath).href + ).catch(() => ({})); + if (buildLabwiredContext) return buildLabwiredContext; + } catch { + /* */ + } + // Fallback: spawn npx tsx -e + const { execFileSync } = await import("child_process"); + const script = ` + const m = await import(${JSON.stringify(pathToFileURL(genPath).href)}); + const r = m.buildLabwiredContext({ + pack: { + board: "esp32-c3-supermini", + diagram: { board: "esp32-c3-supermini", parts: [{ id: "mcu", type: "esp32-c3-supermini" }, { id: "led1", type: "led" }] }, + mint_ok: true, + supported_part_count: 2, + }, + }); + if (r.mode !== "twin_ready" || !r.twin_buildable) { + console.error(JSON.stringify(r)); + process.exit(1); + } + const thin = m.buildLabwiredContext({ pack: { user_context: "hi" } }); + if (thin.mode !== "empty" || thin.quality !== "thin") { + console.error("thin fail", JSON.stringify(thin)); + process.exit(1); + } + console.log("PARITY_OK mode=twin_ready quality_thin=ok"); + `; + try { + execFileSync("npx", ["--yes", "tsx", "-e", script], { + stdio: "inherit", + cwd: path.join(__dirname, ".."), + }); + return null; // already ran + } catch (e) { + // Last resort: pure string checks + sha only + console.warn("tsx unavailable; sha + export check only"); + if (!localSha) { + console.error("no local sha"); + process.exit(1); + } + console.log("PARITY_OK sha_only", localSha.slice(0, 12)); + return null; + } +} + +const builder = await loadBuilder(); +if (typeof builder === "function") { + const r = builder({ + pack: { + board: "esp32-c3-supermini", + diagram: { + board: "esp32-c3-supermini", + parts: [ + { id: "mcu", type: "esp32-c3-supermini" }, + { id: "led1", type: "led" }, + ], + }, + mint_ok: true, + supported_part_count: 2, + }, + }); + if (r.mode !== "twin_ready" || !r.twin_buildable) { + console.error(r); + process.exit(1); + } + const thin = builder({ pack: { user_context: "hi" } }); + if (thin.mode !== "empty" || thin.quality !== "thin") { + console.error(thin); + process.exit(1); + } + console.log("PARITY_OK mode=twin_ready"); +} diff --git a/extensions/labwired-vscode/src/board/contextFlags.generated.sha256 b/extensions/labwired-vscode/src/board/contextFlags.generated.sha256 new file mode 100644 index 0000000..b6c6b53 --- /dev/null +++ b/extensions/labwired-vscode/src/board/contextFlags.generated.sha256 @@ -0,0 +1 @@ +f5019d29a0885dfc1420fc219463dc4e6ffb0ecc406120d35b25fe0ea67a3c55 diff --git a/extensions/labwired-vscode/src/board/contextFlags.generated.ts b/extensions/labwired-vscode/src/board/contextFlags.generated.ts new file mode 100644 index 0000000..aefa2f2 --- /dev/null +++ b/extensions/labwired-vscode/src/board/contextFlags.generated.ts @@ -0,0 +1,238 @@ +/** GENERATED — do not hand-edit. Run scripts/sync-context-core.sh */ +/* source: packages/board-config/src/labwired-context.ts */ +/** + * labwired_context — sole flag/mode engine (design always · twin when mint-honest). + * @see docs/superpowers/plans/2026-08-11-context-first-p0.md + */ + +export type ContextMappingRow = { + ref: string; + value: string; + status: string; + reason?: string; + catalog_type?: string; +}; + +export type ContextCatalogHit = { + id: string; + kind?: string; + score?: number; +}; + +export type LabwiredContextPack = { + board?: string; + mcu?: string; + diagram?: Record | object; + user_context?: string; + agent_brief?: string; + coverage_md?: string; + design_context_md?: string; + mapping?: ContextMappingRow[]; + catalog_hits?: ContextCatalogHit[]; + /** Explicit override; prefer mint_ok path for honesty */ + twin_buildable?: boolean; + design_context_ok?: boolean; + firmware_hints?: string; + source_kind?: string; + /** Catalog mint succeeded with ≥1 supported part */ + mint_ok?: boolean; + supported_part_count?: number; +}; + +export type LabwiredContextInput = { + goal?: string; + project_id?: string; + pack?: LabwiredContextPack; +}; + +export type LabwiredContextMode = 'empty' | 'design_only' | 'twin_ready'; +export type LabwiredContextQuality = 'none' | 'thin' | 'ok'; + +export type LabwiredContextResult = { + ok: boolean; + mode: LabwiredContextMode; + quality: LabwiredContextQuality; + design_context_ok: boolean; + twin_buildable: boolean; + board?: string; + mcu?: string; + summary: string; + agent_brief: string; + next: string[]; + mapping?: ContextMappingRow[]; + dropped?: ContextMappingRow[]; + diagram?: Record; + claims: { + model_verified: string; + hardware_observed: string; + design_context: string; + }; + sources: string[]; + error?: string; +}; + +const CLAIMS = { + model_verified: 'only via labwired_verify (never from chat confidence)', + hardware_observed: 'only via desk-hw / real probe — never rename to model_verified', + design_context: 'usable for drivers/FW design; not a prove claim', +} as const; + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const t = value.trim(); + return t.length ? t : undefined; +} + +function meetsMvc(pack: LabwiredContextPack, board: string | undefined): boolean { + if (typeof pack.design_context_ok === 'boolean') return pack.design_context_ok; + if (board) return true; + if (pack.mint_ok === true) return true; + const mapping = pack.mapping ?? []; + if (mapping.some((r) => (r.status || '').toLowerCase() === 'mapped')) return true; + const brief = nonEmptyString(pack.agent_brief); + const user = nonEmptyString(pack.user_context); + if (brief && brief.length >= 80 && (user && user.length >= 20 || mapping.length >= 1)) { + return true; + } + return false; +} + +function hasAnyText(pack: LabwiredContextPack): boolean { + return !!( + nonEmptyString(pack.user_context) || + nonEmptyString(pack.agent_brief) || + nonEmptyString(pack.coverage_md) || + nonEmptyString(pack.design_context_md) || + nonEmptyString(pack.firmware_hints) || + (pack.mapping && pack.mapping.length) || + (pack.catalog_hits && pack.catalog_hits.length) || + pack.diagram + ); +} + +function computeTwinBuildable(pack: LabwiredContextPack): boolean { + if (typeof pack.twin_buildable === 'boolean') return pack.twin_buildable; + const supported = pack.supported_part_count ?? 0; + return pack.mint_ok === true && supported >= 1; +} + +/** + * Build labwired_context from an optional pack (+ goal). + * Deterministic; no I/O. + */ +export function buildLabwiredContext(input: LabwiredContextInput = {}): LabwiredContextResult { + const pack = input.pack ?? {}; + const sources: string[] = []; + if (input.project_id) sources.push(`project_id:${input.project_id}`); + if (input.goal) sources.push('goal'); + + const boardFromPack = nonEmptyString(pack.board); + const diagram = asRecord(pack.diagram); + const boardFromDiagram = + diagram && typeof diagram.board === 'string' ? nonEmptyString(diagram.board) : undefined; + const board = boardFromPack || boardFromDiagram; + if (boardFromPack) sources.push('pack.board'); + if (boardFromDiagram) sources.push('pack.diagram.board'); + if (pack.mcu) sources.push('pack.mcu'); + if (pack.user_context) sources.push('pack.user_context'); + if (pack.agent_brief) sources.push('pack.agent_brief'); + if (pack.coverage_md) sources.push('pack.coverage_md'); + if (pack.design_context_md) sources.push('pack.design_context_md'); + if (pack.mapping?.length) sources.push('pack.mapping'); + if (pack.catalog_hits?.length) sources.push('pack.catalog_hits'); + if (pack.firmware_hints) sources.push('pack.firmware_hints'); + if (pack.source_kind) sources.push(`pack.source_kind:${pack.source_kind}`); + if (diagram) sources.push('pack.diagram'); + if (typeof pack.mint_ok === 'boolean') sources.push(`pack.mint_ok:${pack.mint_ok}`); + if (typeof pack.supported_part_count === 'number') { + sources.push(`pack.supported_part_count:${pack.supported_part_count}`); + } + + const mapping = Array.isArray(pack.mapping) ? pack.mapping : undefined; + const dropped = mapping?.filter((row) => { + const status = (row.status || '').toLowerCase(); + return status === 'dropped' || status === 'unknown'; + }); + + const design_context_ok = meetsMvc(pack, board); + const twin_buildable = computeTwinBuildable(pack); + + let quality: LabwiredContextQuality = 'none'; + if (design_context_ok) quality = 'ok'; + else if (hasAnyText(pack) || board) quality = 'thin'; + + let mode: LabwiredContextMode = 'empty'; + if (twin_buildable) mode = 'twin_ready'; + else if (design_context_ok) mode = 'design_only'; + + const next: string[] = []; + if (mode === 'empty') { + next.push('labwired_import', 'labwired_list', 'new_board'); + } else if (mode === 'design_only') { + next.push('labwired_part', 'labwired_datasheet', 'agent_draft'); + if (dropped?.length) next.push('catalog_gap_report'); + next.push('labwired_import'); + } else { + next.push('labwired_validate', 'labwired_compile', 'labwired_run', 'labwired_verify'); + } + + const mappedCount = mapping?.filter((r) => (r.status || '').toLowerCase() === 'mapped').length; + const droppedCount = dropped?.length ?? 0; + + const summaryParts = [ + `mode=${mode}`, + `quality=${quality}`, + `design_context_ok=${design_context_ok}`, + `twin_buildable=${twin_buildable}`, + board ? `board=${board}` : 'board=—', + pack.mcu ? `mcu=${pack.mcu}` : null, + mapping ? `mapping=${mappedCount ?? 0} mapped / ${droppedCount} dropped` : null, + input.goal ? `goal=${input.goal.slice(0, 80)}` : null, + ].filter(Boolean) as string[]; + + let agent_brief = nonEmptyString(pack.agent_brief) || ''; + if (!agent_brief) { + if (mode === 'empty') { + agent_brief = + quality === 'thin' + ? 'Context is thin. Import a diagram_json (or richer notes + mapped parts) before drafting drivers.' + : 'No design context yet. Import diagram_json or open a catalog board, then call labwired_context again.'; + } else if (mode === 'design_only') { + agent_brief = + 'Design context is available; twin is not mint-ready. Design drivers/FW from mapping + user_context + labwired_part/datasheet. Never invent pins for dropped parts. Do not claim model_verified.'; + } else { + agent_brief = + 'Twin is mint-ready. Validate diagram, compile, labwired_run, then labwired_verify for model_verified.'; + } + if (nonEmptyString(pack.user_context)) { + agent_brief += `\n\nUser context:\n${pack.user_context!.trim()}`; + } + if (input.goal) { + agent_brief += `\n\nGoal: ${input.goal.trim()}`; + } + } + + return { + ok: mode !== 'empty', + mode, + quality, + design_context_ok, + twin_buildable, + ...(board ? { board } : {}), + ...(pack.mcu ? { mcu: pack.mcu } : {}), + summary: summaryParts.join(' · '), + agent_brief, + next, + ...(mapping ? { mapping } : {}), + ...(dropped && dropped.length ? { dropped } : {}), + ...(twin_buildable && diagram ? { diagram } : {}), + claims: { ...CLAIMS }, + sources: sources.length ? sources : ['(empty pack)'], + ...(mode === 'empty' ? { error: 'empty_context' } : {}), + }; +} diff --git a/extensions/labwired-vscode/src/board/workspaceContext.ts b/extensions/labwired-vscode/src/board/workspaceContext.ts new file mode 100644 index 0000000..d7b9aa0 --- /dev/null +++ b/extensions/labwired-vscode/src/board/workspaceContext.ts @@ -0,0 +1,137 @@ +/** + * Load .labwired/ pack; flags from sole engine (contextFlags.generated.ts). + * Do not re-derive mode/twin_buildable here — P0b. + */ +import * as fs from "fs"; +import * as path from "path"; +import { + buildLabwiredContext, + type LabwiredContextPack, + type LabwiredContextResult, +} from "./contextFlags.generated"; + +export type WorkspaceContextPack = LabwiredContextPack; + +export type WorkspaceContext = LabwiredContextResult & { + pack: WorkspaceContextPack; +}; + +function readText(p: string, max = 12000): string | undefined { + try { + if (!fs.existsSync(p)) return undefined; + return fs.readFileSync(p, "utf8").slice(0, max); + } catch { + return undefined; + } +} + +function readJson(p: string): Record | undefined { + try { + if (!fs.existsSync(p)) return undefined; + return JSON.parse(fs.readFileSync(p, "utf8")) as Record; + } catch { + return undefined; + } +} + +/** Read workspace .labwired into a context pack (fs only). */ +export function loadWorkspacePack(workspaceRoot: string): WorkspaceContextPack { + const lab = path.join(workspaceRoot, ".labwired"); + const imp = path.join(lab, "import"); + + const boardMeta = readJson(path.join(lab, "board.json")); + const diagram = + readJson(path.join(lab, "diagram.json")) || + readJson(path.join(lab, "source-diagram.json")); + const coverage = readJson(path.join(lab, "coverage.json")); + const coverage_md = + readText(path.join(lab, "coverage.md")) || + readText(path.join(imp, "coverage.md")); + const design_context_md = readText(path.join(imp, "DESIGN_CONTEXT.md")); + const agent_brief = + readText(path.join(imp, "AGENT_PROMPT.md")) || + readText(path.join(lab, "AGENT_PROMPT.md")); + const user_context = + readText(path.join(imp, "USER_CONTEXT.md")) || + readText(path.join(lab, "USER_CONTEXT.md")); + const mappingJson = readJson(path.join(imp, "mapping.json")); + const mapping = Array.isArray(mappingJson?.mapping) + ? (mappingJson!.mapping as WorkspaceContextPack["mapping"]) + : Array.isArray(mappingJson) + ? (mappingJson as WorkspaceContextPack["mapping"]) + : undefined; + + const board = + (typeof boardMeta?.board === "string" && boardMeta.board) || + (diagram && typeof diagram.board === "string" ? diagram.board : undefined) || + undefined; + const mcu = + (typeof boardMeta?.mcu === "string" && boardMeta.mcu) || undefined; + + const supportedFromCoverage = Array.isArray(coverage?.supported) + ? (coverage!.supported as unknown[]).length + : undefined; + const supportedFromMeta = + typeof boardMeta?.supportedPartCount === "number" + ? (boardMeta.supportedPartCount as number) + : undefined; + const supported_part_count = supportedFromCoverage ?? supportedFromMeta; + + // Prefer explicit mint flags from board.json when present + const mint_ok = + typeof boardMeta?.mint_ok === "boolean" + ? (boardMeta.mint_ok as boolean) + : typeof boardMeta?.ok === "boolean" + ? (boardMeta.ok as boolean) + : supported_part_count !== undefined + ? supported_part_count >= 1 && !!board + : undefined; + + return { + board, + mcu, + diagram, + user_context, + agent_brief, + coverage_md, + design_context_md, + mapping, + ...(typeof mint_ok === "boolean" ? { mint_ok } : {}), + ...(typeof supported_part_count === "number" + ? { supported_part_count } + : {}), + source_kind: + design_context_md || mapping + ? "import" + : board + ? "board" + : undefined, + }; +} + +/** Local labwired_context — same pure engine as monorepo. */ +export function buildWorkspaceContext( + workspaceRoot: string | undefined, + goal?: string +): WorkspaceContext { + if (!workspaceRoot) { + const empty = buildLabwiredContext({ goal }); + return { ...empty, pack: {} }; + } + const pack = loadWorkspacePack(workspaceRoot); + const result = buildLabwiredContext({ goal, pack }); + return { ...result, pack }; +} + +/** Compact agent handoff block for Start Agent / freeform. */ +export function contextHandoffBlock(ctx: WorkspaceContext): string { + return [ + "[labwired_context]", + ctx.summary, + `next: ${ctx.next.join(" → ")}`, + "", + ctx.agent_brief.slice(0, 2500), + "", + "Claims: model_verified only via labwired_verify; design_context is not a prove claim.", + ].join("\n"); +} From ebcec4006782ede895769025f9f82f8a6e0c8c65 Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:40:56 +0200 Subject: [PATCH 2/2] test(agent): workspace e2e + mint_ok for context twin_ready board.json writes mint_ok/supported_part_count; multiImport uses sole flag engine; workspace e2e script for ship gate. --- .../scripts/context-first-workspace-e2e.mjs | 69 ++ .../labwired-vscode/src/board/boardMint.ts | 326 +++++++++ .../labwired-vscode/src/board/multiImport.ts | 681 ++++++++++++++++++ 3 files changed, 1076 insertions(+) create mode 100644 extensions/labwired-vscode/scripts/context-first-workspace-e2e.mjs create mode 100644 extensions/labwired-vscode/src/board/boardMint.ts create mode 100644 extensions/labwired-vscode/src/board/multiImport.ts diff --git a/extensions/labwired-vscode/scripts/context-first-workspace-e2e.mjs b/extensions/labwired-vscode/scripts/context-first-workspace-e2e.mjs new file mode 100644 index 0000000..8ef6e31 --- /dev/null +++ b/extensions/labwired-vscode/scripts/context-first-workspace-e2e.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Extension workspace e2e: write .labwired like a mint, assert context twin_ready. + * Uses generated flag engine only (no vscode). + */ +import fs from "fs"; +import path from "path"; +import os from "os"; +import { fileURLToPath, pathToFileURL } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const genPath = path.join(__dirname, "../src/board/contextFlags.generated.ts"); +const { buildLabwiredContext } = await import(pathToFileURL(genPath).href); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lw-ctx-e2e-")); +const lab = path.join(tmp, ".labwired"); +fs.mkdirSync(lab, { recursive: true }); + +const diagram = { + version: 1, + board: "esp32-c3-supermini", + parts: [ + { id: "mcu", type: "esp32-c3-supermini" }, + { id: "led1", type: "led", attrs: { color: "green" } }, + ], + wires: [ + { from: { part: "mcu", pin: "GPIO8" }, to: { part: "led1", pin: "A" } }, + { from: { part: "mcu", pin: "GND" }, to: { part: "led1", pin: "C" } }, + ], +}; + +fs.writeFileSync(path.join(lab, "diagram.json"), JSON.stringify(diagram, null, 2)); +fs.writeFileSync( + path.join(lab, "board.json"), + JSON.stringify({ + version: 1, + board: "esp32-c3-supermini", + mint_ok: true, + supported_part_count: 2, + supportedPartCount: 2, + ok: true, + }), +); + +// Mimic loadWorkspacePack without vscode +const pack = { + board: "esp32-c3-supermini", + diagram, + mint_ok: true, + supported_part_count: 2, +}; +const ctx = buildLabwiredContext({ + goal: "Blink the LED and prove it on the twin.", + pack, +}); + +if (ctx.mode !== "twin_ready" || !ctx.twin_buildable) { + console.error("FAIL workspace e2e", ctx); + process.exit(1); +} + +const thin = buildLabwiredContext({ pack: { user_context: "x" } }); +if (thin.mode !== "empty" || thin.quality !== "thin") { + console.error("FAIL thin", thin); + process.exit(1); +} + +console.log("WORKSPACE_E2E_OK", ctx.summary); +fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/extensions/labwired-vscode/src/board/boardMint.ts b/extensions/labwired-vscode/src/board/boardMint.ts new file mode 100644 index 0000000..7453ecd --- /dev/null +++ b/extensions/labwired-vscode/src/board/boardMint.ts @@ -0,0 +1,326 @@ +/** + * Diagram → twin mint: keep only catalog-supported parts, write .labwired/. + * Pure TS (no vscode) so node smoke tests can run it. + */ +import * as fs from "fs"; +import * as path from "path"; + +export type DiagramPart = { + id: string; + type: string; + attrs?: Record; + x?: number; + y?: number; + rotate?: number; +}; + +export type DiagramWire = { + from: { part: string; pin: string }; + to: { part: string; pin: string }; + color?: string; +}; + +export type PlaygroundDiagram = { + version?: number; + board: string; + parts: DiagramPart[]; + wires: DiagramWire[]; + firmware?: unknown; +}; + +export type PartVerdict = { + id: string; + type: string; + status: "supported" | "unknown" | "dropped"; + reason?: string; + resolvedType?: string; +}; + +export type BoardMintResult = { + ok: boolean; + board: string; + mcu?: string; + sourcePath: string; + outDir: string; + supported: PartVerdict[]; + dropped: PartVerdict[]; + twin: PlaygroundDiagram; + coveragePath: string; + boardPath: string; + diagramPath: string; + summary: string; + errors: string[]; +}; + +export type CatalogLookup = { + /** Exact or alias-resolved catalog type, or undefined if unknown */ + resolvePartType(type: string): string | undefined; + /** Normalize board id (aliases), or undefined if unknown */ + resolveBoard(board: string): string | undefined; + /** True if type is an MCU-class part (optional) */ + isMcuType?(type: string): boolean; +}; + +/** Common playground / MCP board spellings → catalog board id */ +export const BOARD_ALIASES: Record = { + "esp32c3": "esp32-c3-supermini", + "esp32-c3": "esp32-c3-supermini", + "esp32c3-supermini": "esp32-c3-supermini", + "esp32s3": "esp32-s3-zero", + "esp32-s3": "esp32-s3-zero", + "stm32l476": "nucleo-l476rg", + "nucleo-l476rg": "nucleo-l476rg", + "stm32f401": "nucleo-f401re", + "nucleo-f401re": "nucleo-f401re", + "stm32f103": "stm32f103-blinky", + "stm32f103-blinky": "stm32f103-blinky", + "rpi-pico": "rpi-pico", + "rp2040": "rpi-pico", + "nrf52840": "nrf52840-dk", + "nrf52840-dk": "nrf52840-dk", + "esp32": "esp32", + "adafruit-feather-esp32-v2": "adafruit-feather-esp32-v2", +}; + +export function normalizeKey(s: string): string { + return s.trim().toLowerCase().replace(/_/g, "-"); +} + +export function parseDiagram(raw: unknown): PlaygroundDiagram { + if (!raw || typeof raw !== "object") { + throw new Error("diagram must be a JSON object"); + } + const d = raw as Record; + const board = String(d.board || "").trim(); + if (!board) throw new Error("diagram.board is required"); + const partsIn = Array.isArray(d.parts) ? d.parts : []; + const parts: DiagramPart[] = partsIn.map((p, i) => { + const o = p as Record; + const id = String(o.id || `part${i}`); + const type = String(o.type || "").trim(); + if (!type) throw new Error(`parts[${i}] missing type`); + return { + id, + type, + attrs: o.attrs as Record | undefined, + x: typeof o.x === "number" ? o.x : undefined, + y: typeof o.y === "number" ? o.y : undefined, + rotate: typeof o.rotate === "number" ? o.rotate : undefined, + }; + }); + const wiresIn = Array.isArray(d.wires) ? d.wires : []; + const wires: DiagramWire[] = wiresIn.map((w, i) => { + const o = w as Record; + const from = o.from as { part?: string; pin?: string } | undefined; + const to = o.to as { part?: string; pin?: string } | undefined; + if (!from?.part || !from?.pin || !to?.part || !to?.pin) { + throw new Error(`wires[${i}] needs from/to { part, pin }`); + } + return { + from: { part: String(from.part), pin: String(from.pin) }, + to: { part: String(to.part), pin: String(to.pin) }, + color: o.color ? String(o.color) : undefined, + }; + }); + return { + version: typeof d.version === "number" ? d.version : 1, + board, + parts, + wires, + firmware: d.firmware, + }; +} + +export function mintTwinFromDiagram( + diagram: PlaygroundDiagram, + lookup: CatalogLookup, + opts: { sourcePath: string; workspaceRoot: string } +): BoardMintResult { + const errors: string[] = []; + const outDir = path.join(opts.workspaceRoot, ".labwired"); + const resolvedBoard = + lookup.resolveBoard(diagram.board) || + BOARD_ALIASES[normalizeKey(diagram.board)] || + diagram.board; + + if (!lookup.resolveBoard(diagram.board) && !BOARD_ALIASES[normalizeKey(diagram.board)]) { + // Still allow mint with warning — board string kept as-is for agent + errors.push( + `board "${diagram.board}" not in local alias/catalog — kept as-is for agent` + ); + } + + const supported: PartVerdict[] = []; + const dropped: PartVerdict[] = []; + const keptParts: DiagramPart[] = []; + const keptIds = new Set(); + let mcu: string | undefined; + + for (const part of diagram.parts) { + const resolved = lookup.resolvePartType(part.type); + if (!resolved) { + dropped.push({ + id: part.id, + type: part.type, + status: "unknown", + reason: "type not in LabWired catalog — dropped from twin", + }); + continue; + } + const outPart: DiagramPart = { ...part, type: resolved }; + keptParts.push(outPart); + keptIds.add(part.id); + supported.push({ + id: part.id, + type: part.type, + status: "supported", + resolvedType: resolved, + }); + if (lookup.isMcuType?.(resolved) || /mcu|nucleo|esp32|stm32|nrf|pico|blackpill/i.test(resolved)) { + mcu = resolved; + } + } + + const twinWires = diagram.wires.filter( + (w) => keptIds.has(w.from.part) && keptIds.has(w.to.part) + ); + const droppedWires = diagram.wires.length - twinWires.length; + + const twin: PlaygroundDiagram = { + version: 1, + board: resolvedBoard, + parts: keptParts, + wires: twinWires, + }; + + fs.mkdirSync(outDir, { recursive: true }); + const diagramPath = path.join(outDir, "diagram.json"); + const boardPath = path.join(outDir, "board.json"); + const coveragePath = path.join(outDir, "coverage.json"); + const coverageMdPath = path.join(outDir, "coverage.md"); + + const boardMeta = { + version: 1, + board: resolvedBoard, + mcu: mcu || null, + source: opts.sourcePath, + mintedAt: new Date().toISOString(), + supportedPartCount: supported.length, + /** Alias for labwired_context pack (mint-honest twin_buildable) */ + supported_part_count: supported.length, + mint_ok: supported.length > 0, + ok: supported.length > 0, + droppedPartCount: dropped.length, + droppedWireCount: droppedWires, + }; + + const coverage = { + version: 1, + board: resolvedBoard, + source: opts.sourcePath, + supported, + dropped, + droppedWireCount: droppedWires, + }; + + fs.writeFileSync(diagramPath, JSON.stringify(twin, null, 2) + "\n", "utf8"); + fs.writeFileSync(boardPath, JSON.stringify(boardMeta, null, 2) + "\n", "utf8"); + fs.writeFileSync(coveragePath, JSON.stringify(coverage, null, 2) + "\n", "utf8"); + + const md = [ + `# Twin coverage`, + ``, + `- **Board:** \`${resolvedBoard}\``, + `- **MCU:** \`${mcu || "?"}\``, + `- **Source:** \`${opts.sourcePath}\``, + `- **Supported parts:** ${supported.length}`, + `- **Dropped parts:** ${dropped.length}`, + `- **Dropped wires:** ${droppedWires}`, + ``, + `## Supported`, + ...supported.map( + (s) => + `- \`${s.id}\` · ${s.type}${s.resolvedType && s.resolvedType !== s.type ? ` → ${s.resolvedType}` : ""}` + ), + ``, + `## Dropped (not in catalog — not on twin)`, + ...(dropped.length + ? dropped.map((d) => `- \`${d.id}\` · \`${d.type}\` — ${d.reason || "unknown"}`) + : ["- (none)"]), + ``, + `Twin diagram: \`.labwired/diagram.json\``, + ``, + ].join("\n"); + fs.writeFileSync(coverageMdPath, md, "utf8"); + + const ok = supported.length > 0; + if (!ok) errors.push("no supported parts — twin is empty"); + + const summary = [ + `Board twin minted: ${resolvedBoard}`, + `supported ${supported.length} · dropped ${dropped.length}` + + (droppedWires ? ` · wires dropped ${droppedWires}` : ""), + mcu ? `mcu ${mcu}` : "mcu ?", + dropped.length + ? `dropped: ${dropped.map((d) => d.type).join(", ")}` + : "all parts mapped", + `→ .labwired/diagram.json · coverage.md`, + `Next: Start agent and develop against this twin.`, + ].join("\n"); + + return { + ok, + board: resolvedBoard, + mcu, + sourcePath: opts.sourcePath, + outDir, + supported, + dropped, + twin, + coveragePath, + boardPath, + diagramPath, + summary, + errors, + }; +} + +export function loadBoardMeta( + workspaceRoot: string +): { board: string; mcu?: string } | undefined { + const p = path.join(workspaceRoot, ".labwired", "board.json"); + try { + if (!fs.existsSync(p)) return undefined; + const j = JSON.parse(fs.readFileSync(p, "utf8")) as { + board?: string; + mcu?: string | null; + }; + if (!j.board) return undefined; + return { board: j.board, mcu: j.mcu || undefined }; + } catch { + return undefined; + } +} + +export function mintFromFile( + sourcePath: string, + workspaceRoot: string, + lookup: CatalogLookup +): BoardMintResult { + const raw = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + const diagram = parseDiagram(raw); + return mintTwinFromDiagram(diagram, lookup, { sourcePath, workspaceRoot }); +} + +/** Mint from an in-memory starter diagram (catalog New board). */ +export function mintFromDiagramObject( + diagram: PlaygroundDiagram, + workspaceRoot: string, + lookup: CatalogLookup, + sourceLabel = "catalog" +): BoardMintResult { + return mintTwinFromDiagram(diagram, lookup, { + sourcePath: sourceLabel, + workspaceRoot, + }); +} diff --git a/extensions/labwired-vscode/src/board/multiImport.ts b/extensions/labwired-vscode/src/board/multiImport.ts new file mode 100644 index 0000000..23dec0b --- /dev/null +++ b/extensions/labwired-vscode/src/board/multiImport.ts @@ -0,0 +1,681 @@ +/** + * Multi-source circuit/board import → .labwired/ artifacts + optional twin mint. + * Sources: PDF, KiCad, netlist, diagram.json, images, BOM CSV, free text. + */ +import * as fs from "fs"; +import * as path from "path"; +import { + extractPdfText, + matchCatalogInText, + type CatalogHit, + type PdfImportKind, +} from "./pdfImport"; +import type { CatalogBoard } from "./catalogBoards"; +import { + buildStarterDiagram, + type StarterPreset, +} from "./catalogBoards"; +import { + mintFromDiagramObject, + mintFromFile, + type BoardMintResult, + type CatalogLookup, +} from "./boardMint"; +import { + graphFromHints, + mergeGraphs, + parseSpiceNetlist, + type CircuitGraph, +} from "./netlistGraph"; +import { + bomMapsToHits, + mapBomRows, + parseBomCsv, + type BomMapResult, +} from "./bomMapper"; +import { + coverageFromMapping, + graphToDiagram, +} from "./graphToDiagram"; +import { buildWorkspaceContext } from "./workspaceContext"; + +export type ImportSourceKind = + | "pdf-schematic" + | "pdf-datasheet" + | "kicad-sch" + | "kicad-pcb" + | "netlist" + | "diagram-json" + | "image" + | "bom-csv" + | "text" + | "unknown"; + +export type MultiImportResult = { + ok: boolean; + sourceKind: ImportSourceKind; + sourcePath: string; + destPath: string; + textPath?: string; + hits: CatalogHit[]; + suggestedBoardId?: string; + minted?: BoardMintResult; + agentPrompt: string; + summary: string; + error?: string; + /** Freeform notes from the user (MCU, goals, constraints). */ + userContext?: string; +}; + +function safeBase(filePath: string): string { + return ( + path.basename(filePath, path.extname(filePath)).replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80) || + "import" + ); +} + +function copyInto( + workspaceRoot: string, + subdir: string, + sourcePath: string +): string { + const dir = path.join(workspaceRoot, ".labwired", subdir); + fs.mkdirSync(dir, { recursive: true }); + const dest = path.join(dir, path.basename(sourcePath)); + if (path.resolve(sourcePath) !== path.resolve(dest)) { + fs.copyFileSync(sourcePath, dest); + } + return dest; +} + +function writeText( + workspaceRoot: string, + name: string, + text: string +): string { + const dir = path.join(workspaceRoot, ".labwired", "import"); + fs.mkdirSync(dir, { recursive: true }); + const p = path.join(dir, name); + fs.writeFileSync(p, text, "utf8"); + return p; +} + +function detectKind(filePath: string, force?: ImportSourceKind): ImportSourceKind { + if (force && force !== "unknown") return force; + const ext = path.extname(filePath).toLowerCase(); + const base = path.basename(filePath).toLowerCase(); + if (ext === ".pdf") return "pdf-schematic"; // default; UI can override datasheet + if (ext === ".kicad_sch") return "kicad-sch"; + if (ext === ".kicad_pcb" || ext === ".kicad_pro") return "kicad-pcb"; + if (ext === ".net" || ext === ".cir" || ext === ".sp" || base.endsWith(".netlist")) + return "netlist"; + if (ext === ".json" || base === "diagram.json") return "diagram-json"; + if ([".png", ".jpg", ".jpeg", ".webp", ".gif", ".tif", ".tiff"].includes(ext)) + return "image"; + if (ext === ".csv") return "bom-csv"; + if ([".txt", ".md", ".log"].includes(ext)) return "text"; + return "unknown"; +} + +/** Extract lib_id / Value / Reference from KiCad s-expr schematic (best-effort). */ +export function parseKicadSchHints(content: string): string[] { + const hints: string[] = []; + const libIds = content.matchAll(/\(lib_id\s+"([^"]+)"\)/g); + for (const m of libIds) { + const full = m[1]; + hints.push(full); + const short = full.includes(":") ? full.split(":").pop()! : full; + hints.push(short); + } + const values = content.matchAll( + /\(property\s+"Value"\s+"([^"]+)"/g + ); + for (const m of values) hints.push(m[1]); + const refs = content.matchAll( + /\(property\s+"Reference"\s+"([^"]+)"/g + ); + for (const m of refs) hints.push(m[1]); + return [...new Set(hints.map((h) => h.trim()).filter(Boolean))]; +} + +export function parseBomCsvHints(content: string): string[] { + const lines = content.split(/\r?\n/).slice(0, 500); + const hints: string[] = []; + for (const line of lines) { + for (const cell of line.split(/[,;\t]/)) { + const t = cell.trim().replace(/^"|"$/g, ""); + if (t.length >= 2 && t.length < 64 && /[a-zA-Z]/.test(t)) hints.push(t); + } + } + return [...new Set(hints)]; +} + +export function parseNetlistHints(content: string): string[] { + const hints: string[] = []; + // spice-like: R1 1 2 10k, XU1 ... + for (const line of content.split(/\r?\n/)) { + const t = line.trim(); + if (!t || t.startsWith("*") || t.startsWith("#")) continue; + const toks = t.split(/\s+/); + if (toks[0]) hints.push(toks[0].replace(/[0-9]+$/, "")); // R1 → R + for (const tok of toks.slice(1, 6)) { + if (/^[A-Za-z][A-Za-z0-9_-]{1,30}$/.test(tok)) hints.push(tok); + } + } + return [...new Set(hints)]; +} + +function agentPrompt(opts: { + kind: ImportSourceKind; + sourcePath: string; + destPath: string; + textPath?: string; + hits: CatalogHit[]; + suggestedBoardId?: string; + userContext?: string; + twinBuildable?: boolean; + droppedNotes?: string; +}): string { + const twinOk = opts.twinBuildable === true; + return [ + "Customer circuit import for LabWired.", + `Source kind: ${opts.kind}`, + `Source file: ${opts.destPath}`, + opts.textPath ? `Extracted / notes text: ${opts.textPath}` : "", + opts.suggestedBoardId + ? `Suggested board/MCU: ${opts.suggestedBoardId}` + : "No board auto-matched.", + opts.hits.length + ? `Catalog hits: ${opts.hits + .slice(0, 20) + .map((h) => h.id) + .join(", ")}` + : "No catalog hits yet.", + `twin_buildable: ${twinOk}`, + "", + opts.userContext?.trim() + ? [ + "## User context (authoritative for intent — still no inventing pins)", + opts.userContext.trim(), + "", + ].join("\n") + : "", + opts.droppedNotes + ? ["## Dropped / unmodeled (keep for driver design via datasheet tools)", opts.droppedNotes, ""].join( + "\n" + ) + : "", + "## Design context (ALWAYS use this)", + "Even if the twin is incomplete or not runnable, use extracts + mapping + user context", + "plus labwired_part / labwired_datasheet to design drivers, HAL, init, and app structure.", + "Do not block on twin. Do not invent electrical facts for dropped parts — cite tools or mark missing.", + "", + twinOk + ? [ + "## Twin path (buildable)", + "1. Prefer catalog parts on the diagram; validate if possible.", + "2. Scaffold / use existing FW → compile → labwired_run → labwired_verify.", + "3. model_verified only from verify success.", + ].join("\n") + : [ + "## Twin not fully buildable yet", + "1. Still design firmware/drivers from design context + part/datasheet tools.", + "2. List what must be modeled for a full twin later (coverage).", + "3. If a partial board (MCU only) can run, use it for blink/UART beachhead only.", + ].join("\n"), + "", + "Follow skill import-circuit. Prefer MCP labwired_import when available.", + ] + .filter(Boolean) + .join("\n"); +} + +export function importCircuitSource(opts: { + sourcePath: string; + workspaceRoot: string; + boards: CatalogBoard[]; + partTypes: string[]; + lookup: CatalogLookup; + forceKind?: ImportSourceKind; + /** When board suggested, auto-mint blink starter */ + autoMintStarter?: boolean; + starterPreset?: StarterPreset; + /** Freeform user notes: board name, goal, constraints, known pins, etc. */ + userContext?: string; +}): MultiImportResult { + const userContext = (opts.userContext || "").trim(); + const kind = detectKind(opts.sourcePath, opts.forceKind); + const base = safeBase(opts.sourcePath); + let destPath = opts.sourcePath; + let textPath: string | undefined; + let textBlob = ""; + const extraHints: string[] = []; + + try { + switch (kind) { + case "pdf-schematic": + case "pdf-datasheet": { + destPath = copyInto( + opts.workspaceRoot, + kind === "pdf-datasheet" ? "datasheets" : "import", + opts.sourcePath + ); + textPath = path.join( + opts.workspaceRoot, + ".labwired", + "import", + `${base}.txt` + ); + const ex = extractPdfText(destPath, textPath); + if (!ex.ok) { + return { + ok: false, + sourceKind: kind, + sourcePath: opts.sourcePath, + destPath, + hits: [], + agentPrompt: "", + summary: `PDF extract failed: ${ex.error}`, + error: ex.error, + }; + } + textBlob = fs.readFileSync(textPath, "utf8"); + if (kind === "pdf-datasheet") { + // also leave a copy under datasheets for agentic extract + copyInto(opts.workspaceRoot, "datasheets", opts.sourcePath); + } + break; + } + case "kicad-sch": + case "kicad-pcb": { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + const raw = fs.readFileSync(destPath, "utf8"); + extraHints.push(...parseKicadSchHints(raw)); + textBlob = raw.slice(0, 500_000); + textPath = writeText( + opts.workspaceRoot, + `${base}.kicad-extract.txt`, + `# KiCad extract hints\n${extraHints.join("\n")}\n\n--- raw head ---\n${raw.slice(0, 80_000)}` + ); + break; + } + case "netlist": { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + textBlob = fs.readFileSync(destPath, "utf8"); + extraHints.push(...parseNetlistHints(textBlob)); + textPath = writeText( + opts.workspaceRoot, + `${base}.net-hints.txt`, + extraHints.join("\n") + ); + break; + } + case "diagram-json": { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + // Direct mint + const mint = mintFromFile(destPath, opts.workspaceRoot, opts.lookup); + fs.copyFileSync( + destPath, + path.join(opts.workspaceRoot, ".labwired", "source-diagram.json") + ); + if (userContext) { + writeText(opts.workspaceRoot, "USER_CONTEXT.md", userContext + "\n"); + } + const prompt = agentPrompt({ + kind, + sourcePath: opts.sourcePath, + destPath, + hits: mint.supported.map((s) => ({ + id: s.resolvedType || s.type, + kind: "part", + score: 50, + })), + suggestedBoardId: mint.board, + userContext, + }); + writeText(opts.workspaceRoot, "AGENT_PROMPT.md", prompt + "\n"); + return { + ok: mint.ok, + sourceKind: kind, + sourcePath: opts.sourcePath, + destPath, + hits: [], + suggestedBoardId: mint.board, + minted: mint, + agentPrompt: prompt, + summary: mint.summary + (userContext ? `\nuser context: set` : ""), + userContext: userContext || undefined, + }; + } + case "image": { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + textBlob = `Image schematic: ${path.basename(destPath)}. Use vision/agent to identify board and parts; map only to catalog.`; + textPath = writeText( + opts.workspaceRoot, + `${base}.image-notes.txt`, + textBlob + ); + break; + } + case "bom-csv": { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + textBlob = fs.readFileSync(destPath, "utf8"); + extraHints.push(...parseBomCsvHints(textBlob)); + textPath = writeText( + opts.workspaceRoot, + `${base}.bom-hints.txt`, + extraHints.join("\n") + ); + break; + } + case "text": { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + textBlob = fs.readFileSync(destPath, "utf8"); + textPath = destPath; + break; + } + default: { + destPath = copyInto(opts.workspaceRoot, "import", opts.sourcePath); + try { + textBlob = fs.readFileSync(destPath, "utf8").slice(0, 200_000); + } catch { + textBlob = path.basename(destPath); + } + textPath = writeText( + opts.workspaceRoot, + `${base}.notes.txt`, + textBlob + ); + } + } + } catch (e) { + return { + ok: false, + sourceKind: kind, + sourcePath: opts.sourcePath, + destPath, + hits: [], + agentPrompt: "", + summary: `Import failed: ${e}`, + error: String(e), + }; + } + + // User context participates in catalog matching (e.g. "we're on esp32-c3") + const combined = `${textBlob}\n${extraHints.join("\n")}\n${userContext}`; + const hits = matchCatalogInText(combined, opts.boards, opts.partTypes); + // boost explicit KiCad/BOM hints that equal catalog ids + for (const h of extraHints) { + const key = h.toLowerCase(); + for (const b of opts.boards) { + if ( + b.id.toLowerCase() === key || + b.chip.toLowerCase() === key || + b.mcuType.toLowerCase() === key + ) { + hits.unshift({ id: b.id, kind: "board", score: 100 }); + } + } + for (const t of opts.partTypes) { + if (t.toLowerCase() === key) { + hits.unshift({ id: t, kind: "part", score: 80 }); + } + } + } + // dedupe hits + const seen = new Set(); + const uniqHits = hits.filter((h) => { + const k = h.id.toLowerCase(); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + + const suggestedBoardId = uniqHits.find( + (h) => h.kind === "board" || h.kind === "chip" + )?.id; + + if (userContext) { + writeText(opts.workspaceRoot, "USER_CONTEXT.md", userContext + "\n"); + } + + const manifest = { + version: 1, + sourceKind: kind, + sourcePath: opts.sourcePath, + destPath, + textPath, + userContext: userContext || null, + importedAt: new Date().toISOString(), + hits: uniqHits.slice(0, 30), + suggestedBoardId: suggestedBoardId || null, + extraHints: extraHints.slice(0, 100), + }; + writeText( + opts.workspaceRoot, + `${base}.manifest.json`, + JSON.stringify(manifest, null, 2) + "\n" + ); + + // ——— Real import path: graph → catalog map → diagram → mint ——— + let graph: CircuitGraph | undefined; + let bomMaps: BomMapResult[] | undefined; + + if (kind === "netlist" && textBlob) { + graph = parseSpiceNetlist(textBlob); + } else if (kind === "bom-csv" && textBlob) { + const rows = parseBomCsv(textBlob); + bomMaps = mapBomRows(rows, opts.partTypes); + for (const h of bomMapsToHits(bomMaps)) { + uniqHits.unshift(h); + } + // BOM-only graph: one component per mapped row + graph = { + format: "bom", + components: bomMaps.map((m, i) => ({ + ref: m.row.ref || `U${i + 1}`, + value: m.catalogType || m.row.value || m.row.raw, + pins: [], + })), + nets: {}, + }; + } else if ( + (kind === "kicad-sch" || kind === "kicad-pcb") && + extraHints.length + ) { + graph = graphFromHints(extraHints, "kicad-hints"); + } else if (kind === "pdf-schematic" || kind === "text") { + // hints from catalog hits as weak components + graph = graphFromHints( + uniqHits.map((h) => h.id), + "mixed" + ); + } + + if (graph && extraHints.length && kind !== "kicad-sch") { + graph = mergeGraphs(graph, graphFromHints(extraHints)); + } + + let minted: BoardMintResult | undefined; + const board = + (suggestedBoardId && + (opts.boards.find( + (b) => + b.id === suggestedBoardId || + b.chip === suggestedBoardId || + b.mcuType === suggestedBoardId + ) || + opts.boards.find( + (b) => + suggestedBoardId!.includes(b.id) || + b.id.includes(suggestedBoardId!) + ))) || + opts.boards.find((b) => /esp32c3|c3-supermini/i.test(b.id)) || + opts.boards[0]; + + if (opts.autoMintStarter !== false && board && graph) { + try { + const g2d = graphToDiagram({ + graph, + board, + catalogTypes: opts.partTypes, + bomMaps, + }); + const lab = path.join(opts.workspaceRoot, ".labwired"); + fs.mkdirSync(lab, { recursive: true }); + fs.writeFileSync( + path.join(lab, "source-diagram.json"), + JSON.stringify(g2d.diagram, null, 2) + "\n" + ); + fs.writeFileSync( + path.join(lab, "coverage-graph.md"), + coverageFromMapping( + g2d.boardId, + g2d.mapping, + `${kind}:${path.basename(destPath)}` + ) + ); + fs.writeFileSync( + path.join(lab, "graph-mapping.json"), + JSON.stringify(g2d.mapping, null, 2) + "\n" + ); + minted = mintFromDiagramObject( + g2d.diagram, + opts.workspaceRoot, + opts.lookup, + `${kind}:${path.basename(destPath)}` + ); + // Prefer graph coverage text in summary + if (g2d.dropped.length) { + writeText( + opts.workspaceRoot, + "DROPPED.md", + g2d.dropped + .map((d) => `- ${d.ref}: ${d.value} — ${d.reason}`) + .join("\n") + "\n" + ); + } + } catch { + /* fall through to starter */ + } + } + + // Fallback: blink starter if graph path failed but board known + if (!minted && opts.autoMintStarter !== false && board) { + const diagram = buildStarterDiagram(board, opts.starterPreset || "blink"); + const lab = path.join(opts.workspaceRoot, ".labwired"); + fs.mkdirSync(lab, { recursive: true }); + fs.writeFileSync( + path.join(lab, "source-diagram.json"), + JSON.stringify(diagram, null, 2) + "\n" + ); + minted = mintFromDiagramObject( + diagram, + opts.workspaceRoot, + opts.lookup, + `${kind}:starter:${board.id}` + ); + } + + // Sole flag engine (contextFlags.generated) — do not invent twin_ready from vibes + const ctxSnap = buildWorkspaceContext(opts.workspaceRoot, userContext); + const twinBuildable = ctxSnap.twin_buildable; + const designContextOk = ctxSnap.design_context_ok; + + let droppedNotes = ""; + try { + const droppedPath = path.join(opts.workspaceRoot, ".labwired", "import", "DROPPED.md"); + if (fs.existsSync(droppedPath)) { + droppedNotes = fs.readFileSync(droppedPath, "utf8").slice(0, 4000); + } + } catch { + /* */ + } + + const prompt = agentPrompt({ + kind, + sourcePath: opts.sourcePath, + destPath, + textPath, + hits: uniqHits, + suggestedBoardId: suggestedBoardId || board?.id, + userContext, + twinBuildable, + droppedNotes: droppedNotes || undefined, + }); + writeText(opts.workspaceRoot, "AGENT_PROMPT.md", prompt + "\n"); + + // Always leave a stable DESIGN_CONTEXT pack for the LLM (even without twin) + writeText( + opts.workspaceRoot, + "DESIGN_CONTEXT.md", + [ + "# Design context (always — twin optional)", + "", + `- twin_buildable: ${twinBuildable}`, + `- design_context_ok: ${designContextOk}`, + `- source: ${kind} · ${path.basename(destPath)}`, + suggestedBoardId || board + ? `- board_hint: ${suggestedBoardId || board?.id}` + : "- board_hint: (none)", + "", + "## User context", + userContext || "(none)", + "", + "## Catalog hits", + uniqHits.length + ? uniqHits.map((h) => `- ${h.id} (${h.kind})`).join("\n") + : "(none)", + "", + "## For the agent", + "Use this file + USER_CONTEXT.md + extracts to design drivers/FW even if the twin is incomplete.", + "Cite labwired_part / labwired_datasheet for dropped/unmodeled parts. Never invent pins.", + twinBuildable + ? "Twin available: prefer prove path after scaffold." + : "Twin not fully buildable: design_only first; list catalog gaps for later twin.", + "", + ].join("\n") + ); + + const summary = [ + `Imported ${kind}: ${path.basename(destPath)}`, + textPath ? `text/notes → ${path.relative(opts.workspaceRoot, textPath)}` : "", + userContext + ? `user context: ${userContext.slice(0, 120)}${userContext.length > 120 ? "…" : ""}` + : "user context: (none)", + graph + ? `graph: ${graph.components.length} components · ${Object.keys(graph.nets).length} nets` + : "graph: (none)", + `design_context: ${designContextOk ? "ok" : "thin"} · twin_buildable: ${twinBuildable}`, + suggestedBoardId || board + ? `board: ${suggestedBoardId || board?.id}` + : "no board auto-matched", + uniqHits.length + ? `catalog hits: ${uniqHits + .slice(0, 10) + .map((h) => h.id) + .join(", ")}` + : "no catalog hits", + minted + ? `twin minted: ${minted.board} (${minted.supported.length} parts) [graph pipeline]` + : "twin not minted — design context still available for drivers/FW", + ] + .filter(Boolean) + .join("\n"); + + return { + ok: designContextOk || twinBuildable, + sourceKind: kind, + sourcePath: opts.sourcePath, + destPath, + textPath, + hits: uniqHits, + suggestedBoardId: suggestedBoardId || board?.id, + minted, + agentPrompt: prompt, + summary, + userContext: userContext || undefined, + }; +} + +export { detectKind as detectImportKind };