dsh-eda-mcp 1.0.0 → 1.0.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1 — 2026-10-20
4
+
5
+ 1.0.0 的真机回归修掉 4 个 bug(全部由"跑一遍"发现,不是靠读代码):
6
+
7
+ 1. **原理图几何审计误报 30 对重叠**(`eda_schematic_audit`)
8
+ `sch_PrimitiveComponent.getAll` 的列表里混着图框/标题栏图元,其包围盒是整张页面(实测 `0..1170 × 0..825`),于是与每个符号"重叠"。现在**丢弃面积超过页面 40% 的图元**并在 `droppedSheetPrimitives` 里报数量;复测:30 对误报 → **0 fail**(31 器件 / 30 个有效包围盒 / 丢弃 1 个)。
9
+
10
+ 2. **参数持久化"报成功但没写"**(`eda_design_intake`)
11
+ ① `dmt_Project.modifyProjectDescription` 在本机构建上**返回 false、不生效**(与 create/delete/copyProject 同类);② 我的调用还把文本当成 uuid 传。现在:先按官方签名 `(projectUuid, description)` 写并**回读校验**,失败则落到工程目录侧的**侧车文件** `.dsh-eda-spec/<uuid>.json`,`persisted` 只在真的读回成功后为 true,并新增 `persistVia`(`description` / `file` / `none`)与 `persistPath`。真机复测:`persisted=true via=file`。
12
+
13
+ 3. **PCB 机械检查是死代码**(`eda_design_audit`)
14
+ `pcb_PrimitiveComponent.getAll([undefined, undefined])` 在该构建上返回**空列表**(同样的 API 在 `pcb_get_components` 里能取到 29 个器件),导致器件重叠 / 出板框两项检查从未执行。改用与 `pcb_get_components` 相同的调用形态,并把 `counts.componentsRead` 暴露出来;复测:`componentsRead: 29`。
15
+
16
+ 4. **交付清单挂死超时**(`eda_delivery_checklist`)
17
+ 自动切文档后紧接着调网表导出会被页面加载阻塞 → MCP 超时。现在切文档后等待 1.5 s;同时把**跳过**与**失败**分开:新增 `verdict`(`pass` / `incomplete` / `fail`)、`skippedSteps`、`failedSteps`、`next`,默认仍自动切文档。真机复测:**一次调用 7.5 s,四步全过 `verdict=pass`**;`switchDocuments:false` 时如实报 `incomplete` 并指出缺哪个文档。
18
+
19
+ ### 回归结果(扩展 0.4.6 / 桥接 1.0.1)
20
+
21
+ | 项 | 结果 |
22
+ |---|---|
23
+ | `eda_schematic_audit` | ✅ 0 fail / 1 warn(12 个符号偏离 10 单位栅格)/ 1 skip(无标签锚点) |
24
+ | `eda_netlist_erc` | ✅ 15 项全执行,14 pass / 0 fail / 1 warn(USB-C SBU1/SBU2 未接) |
25
+ | `eda_design_intake` 持久化 | ✅ `persisted=true via=file` |
26
+ | `eda_design_audit`(PCB) | ✅ `componentsRead:29`、0 违规 / 4 警告(5V 线宽偏细、无覆铜、无丝印、无定位孔) |
27
+ | `eda_delivery_checklist` | ✅ `verdict=pass`、7.5 s、四步(ERC / BOM / PCB 审计 / Gerber 86.8 KB) |
28
+ | `eda_pcb_canvas_origin` | ✅ 偏移 `0,0`,画布↔数据坐标互转一致 |
29
+ | `eda_pcb_create_pour` | ❌ **仍失败**:单多边形与复杂多边形都被接受,但 `pcb_PrimitivePour.create` 对任何网络/层/填充方式一律拒绝 → 判定为**应用侧限制**(1.0.0 里我写"是我用错 API",据此修正) |
30
+
31
+ ### 已知遗留(需你决定)
32
+
33
+ - 侧车目录 `D:\文档\LCEDA-Pro\projects\.dsh-eda-spec\` 是回归测试写入的(1 个 json),清理需要你确认(它在你的工程目录内)。
34
+ - 覆铜只能手工:工具已把失败原因写准,`eda_design_audit` 持续报 `copper-pour` warn。
35
+
3
36
  ## 1.0.0 — 2026-10-20
4
37
 
5
38
  第一个稳定版。0.7.4 之后这一轮把"能画"推进到"画得对、画得像参考设计、交付有证据":工具 106 → **109**,单测 6 → **8 套**,线协议 0.4.2 → **0.4.6**。
@@ -239,6 +239,13 @@
239
239
  | `eda_delivery_checklist` | 一次串完 ERC + PCB 审计/DRC + Gerber + BOM,逐步 PASS/FAIL;`outPath` 落 Markdown 报告 | 交付前,代替"我说做完了" |
240
240
 
241
241
  **交付口径**:`eda_delivery_checklist` 返回 `ok:true` 才算可以下单;任何一步 FAIL 都要先修,不许口头绕过。报告里带时间戳与计数,可直接给人复核。
242
+ ### 交付清单怎么读(1.0.1 起)
243
+
244
+ - 一次调用会**自己切到原理图再切到 PCB**(`switchDocuments:false` 改按当前文档跑一半,另一半标注跳过原因);
245
+ - 返回 `verdict`:`pass`(四步全过)/ `incomplete`(有步骤因文档不对被跳过)/ `fail`(有步骤真失败);`ok` 等价于 `verdict==='pass'`;
246
+ - `skippedSteps` 与 `failedSteps` 分开列 —— **别把"没跑"当成"通过"**;四步全过实测约 **7.5 s**;
247
+ - 投板前最后一条命令就用它,`outPath` 落 Markdown 存档。
248
+
242
249
 
243
250
  **参数持久化**:`eda_design_intake({answers})` 会把答复写进**工程描述**(`[dsh-eda-spec] {...}`),下次会话调用 `eda_design_intake` 会自动读回,不用重新猜;`reset:true` 清空。
244
251
 
package/docs/tools.md CHANGED
@@ -53,7 +53,7 @@ Design limits enforced by these tools (track widths, via sizes, net naming) are
53
53
  | `eda_open_document` | Open/activate a document tab by uuid (dmt_EditorControl.openDocument). Works for a schematic page or a PCB, so tab-dependent APIs can run. | `documentUuid: string, splitScreenId?: string` |
54
54
  | `eda_get_net_labels` | List every label-like primitive on the active schematic page: attribute primitives (netlabel/netport/netflag) PLUS plain texts left by the createNetLabel fallback, so they can be audited and deleted. Reports kind/primitiveId/net per entry. | `parentPrimitiveId?: string` |
55
55
  | `eda_schematic_audit` | Geometry audit of the active schematic page (the netlist ERC only judges connectivity): symbols outside the frame, symbols overlapping the title block, body-on-body symbol overlap, off-grid placement, and labels anchored inside a symbol body. Read-only; pair it with eda_netlist_erc before routing. | `grid?: number, includeOk?: boolean` |
56
- | `eda_delivery_checklist` | Delivery checklist: run connectivity (eda_netlist_erc), PCB audit + DRC, Gerber export and BOM export in ONE read-only call and report per-step pass/fail with counts. Pass outPath to also write a Markdown report you can hand to a reviewer. Use it instead of claiming a board is finished. | `outPath?: string, pcbDrc?: boolean` |
56
+ | `eda_delivery_checklist` | Delivery checklist: run connectivity (eda_netlist_erc), PCB audit + DRC, Gerber export and BOM export in ONE read-only call and report per-step pass/fail with counts. It switches to the schematic and the PCB itself (switchDocuments:false keeps the current document and reports the other steps as skipped). verdict is pass / incomplete / fail; pass outPath to also write a Markdown report you can hand to a reviewer. | `outPath?: string, pcbDrc?: boolean, switchDocuments?: boolean` |
57
57
  | `eda_design_intake` | Requirement intake for a new board: call it BEFORE placing anything when the request is vague (for example: draw me an X board). Without answers it returns the questions in 6 sections (power, core, IO, mechanical, process, delivery), each marked critical or fallback-able with the reason it changes the drawing; pass answers:{questionId:value} to record them and see what is still missing. Ask the user with ask_user_question for the critical ones instead of guessing. | `answers?: object, reset?: boolean` |
58
58
  | `eda_netlist_erc` | Read-only electrical smoke test over the schematic NETLIST (no application DRC needed): duplicate designators, unconnected pins, single-endpoint nets, shorted parts, power rails and decoupling, USB D+/D- pair, crystal (two nets + load caps + reaches the MCU), reset RC, BOOT pull resistor, Type-C CC pull-downs. DRC can be clean while the circuit is still wrong; these failures all show up in the netlist. Pass includeOk:true to see passing checks too. | `includeOk?: boolean` |
59
59
  | `eda_pcb_canvas_origin` | Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API. | `align?: boolean, offsetX?: number, offsetY?: number, point?: object` |
Binary file
package/lib/bridge.mjs CHANGED
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { statSync } from "node:fs";
5
5
  import { readFile } from "node:fs/promises";
6
- import { unlink, writeFile } from "node:fs/promises";
6
+ import { mkdir, unlink, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { basename, join } from "node:path";
9
9
  import { createServer } from "node:http";
@@ -1094,7 +1094,7 @@ var TOOLS = [
1094
1094
  { name: "pcb_draw_text", description: "Put text on a PCB layer - silkscreen by default (3 top silk / 4 bottom silk). Use it for pin functions, board markings, version and fabrication notes; silkscreen text must not cover pads.", inputSchema: objectSchema({ x: { type: "number" }, y: { type: "number" }, text: { type: "string" }, layer: { type: "string" }, fontSize: { type: "number" }, lineWidth: { type: "number" }, rotation: { type: "number" }, mirror: { type: "boolean" }, alignMode: { type: "integer" }, locked: { type: "boolean" } }, ["x", "y", "text"]) },
1095
1095
  { name: "pcb_draw_mounting_hole", description: 'Place a NON-plated mounting hole or a slot (pcb_PrimitivePad with metallization:false). shape "round" is a drill-sized hole, shape "slot" an oval cut-out. Use this for mounting holes and connector slots instead of hacking the board outline.', inputSchema: objectSchema({ x: { type: "number" }, y: { type: "number" }, diameter: { type: "number" }, shape: { type: "string", enum: ["round", "slot"] }, slotLength: { type: "number" }, rotation: { type: "number" }, plated: { type: "boolean" }, annulus: { type: "number" }, padNumber: { type: "string" }, layer: { type: "string" } }, ["x", "y", "diameter"]) },
1096
1096
  { name: "eda_schematic_audit", description: "Geometry audit of the active schematic page (the netlist ERC only judges connectivity): symbols outside the frame, symbols overlapping the title block, body-on-body symbol overlap, off-grid placement, and labels anchored inside a symbol body. Read-only; pair it with eda_netlist_erc before routing.", inputSchema: objectSchema({ grid: { type: "number" }, includeOk: { type: "boolean" } }) },
1097
- { name: "eda_delivery_checklist", description: "Delivery checklist: run connectivity (eda_netlist_erc), PCB audit + DRC, Gerber export and BOM export in ONE read-only call and report per-step pass/fail with counts. Pass outPath to also write a Markdown report you can hand to a reviewer. Use it instead of claiming a board is finished.", inputSchema: objectSchema({ outPath: { type: "string" }, pcbDrc: { type: "boolean" } }) },
1097
+ { name: "eda_delivery_checklist", description: "Delivery checklist: run connectivity (eda_netlist_erc), PCB audit + DRC, Gerber export and BOM export in ONE read-only call and report per-step pass/fail with counts. It switches to the schematic and the PCB itself (switchDocuments:false keeps the current document and reports the other steps as skipped). verdict is pass / incomplete / fail; pass outPath to also write a Markdown report you can hand to a reviewer.", inputSchema: objectSchema({ outPath: { type: "string" }, pcbDrc: { type: "boolean" }, switchDocuments: { type: "boolean" } }) },
1098
1098
  { name: "eda_design_intake", description: "Requirement intake for a new board: call it BEFORE placing anything when the request is vague (for example: draw me an X board). Without answers it returns the questions in 6 sections (power, core, IO, mechanical, process, delivery), each marked critical or fallback-able with the reason it changes the drawing; pass answers:{questionId:value} to record them and see what is still missing. Ask the user with ask_user_question for the critical ones instead of guessing.", inputSchema: objectSchema({ answers: { type: "object", additionalProperties: true }, reset: { type: "boolean" } }) },
1099
1099
  { name: "eda_netlist_erc", description: "Read-only electrical smoke test over the schematic NETLIST (no application DRC needed): duplicate designators, unconnected pins, single-endpoint nets, shorted parts, power rails and decoupling, USB D+/D- pair, crystal (two nets + load caps + reaches the MCU), reset RC, BOOT pull resistor, Type-C CC pull-downs. DRC can be clean while the circuit is still wrong; these failures all show up in the netlist. Pass includeOk:true to see passing checks too.", inputSchema: objectSchema({ includeOk: { type: "boolean" } }) },
1100
1100
  { name: "eda_pcb_canvas_origin", description: "Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API.", inputSchema: objectSchema({ align: { type: "boolean" }, offsetX: { type: "number" }, offsetY: { type: "number" }, point: { type: "object", additionalProperties: true } }) },
@@ -1509,6 +1509,13 @@ var BRIEF_TAG = "[dsh-eda-spec]";
1509
1509
  function briefBlock(brief) {
1510
1510
  return BRIEF_TAG + " " + JSON.stringify(brief);
1511
1511
  }
1512
+ function specFilePath(project) {
1513
+ const dir = String(project?.teamUuid ?? "");
1514
+ const uuid2 = String(project?.uuid ?? "");
1515
+ if (dir === "" || uuid2 === "") return "";
1516
+ if (!/^[A-Za-z]:[\\/]/.test(dir) && !dir.startsWith("/")) return "";
1517
+ return join(dir, ".dsh-eda-spec", uuid2 + ".json");
1518
+ }
1512
1519
  function parseBriefBlock(description) {
1513
1520
  const text = typeof description === "string" ? description : "";
1514
1521
  const index = text.indexOf(BRIEF_TAG);
@@ -1589,7 +1596,7 @@ async function auditPcb(args, timeoutMs) {
1589
1596
  const vias = await pcbPrimitiveList("pcb_PrimitiveVia.getAll", [netFilter, void 0], timeoutMs);
1590
1597
  const pours = await pcbPrimitiveList("pcb_PrimitivePour.getAll", [netFilter, void 0, void 0], timeoutMs);
1591
1598
  const outline = await pcbPrimitiveList("pcb_PrimitivePolyline.getAll", [void 0, PCB_LAYERS.boardOutline, void 0], timeoutMs);
1592
- const pcbComponents = await pcbPrimitiveList("pcb_PrimitiveComponent.getAll", [void 0, void 0], timeoutMs);
1599
+ const pcbComponents = await callExtension("pcb_PrimitiveComponent.getAll", [resolvePcbLayer(void 0), void 0], timeoutMs).then((raw) => Array.isArray(raw) ? raw : []).catch(() => []);
1593
1600
  const violations = [];
1594
1601
  const warnings = [];
1595
1602
  const add = (bucket, rule, message, samples) => {
@@ -1726,7 +1733,7 @@ async function auditPcb(args, timeoutMs) {
1726
1733
  ];
1727
1734
  return {
1728
1735
  ok: violations.length === 0,
1729
- counts: { tracks: tracks.length, vias: vias.length, pours: pours.length, outlinePolylines: outline.length, nets: netNames.length },
1736
+ counts: { tracks: tracks.length, vias: vias.length, pours: pours.length, outlinePolylines: outline.length, nets: netNames.length, componentsRead: pcbComponents.length },
1730
1737
  minWidthByNet: Object.fromEntries([...widthByNet.entries()].map(([net, width]) => [net, { widthMil: width, role: classifyNet(net), suggestedMil: suggestTrackWidthMil(net) }])),
1731
1738
  violations,
1732
1739
  warnings,
@@ -3078,23 +3085,33 @@ async function executeTool(name, rawArgs, timeoutMs) {
3078
3085
  }
3079
3086
  case "eda_schematic_audit": {
3080
3087
  const paper = paperOf(args.paper);
3088
+ const frame = paper.frame;
3089
+ const frameArea = (frame.right - frame.left) * (frame.top - frame.bottom);
3081
3090
  const rawList = await callExtension("sch_PrimitiveComponent.getAll", [], timeoutMs);
3082
3091
  const list = Array.isArray(rawList) ? rawList.slice(0, 200) : [];
3083
3092
  const components = [];
3093
+ let droppedSheetPrimitives = 0;
3084
3094
  for (let index = 0; index < list.length; index += 1) {
3085
3095
  const entry = list[index] ?? {};
3086
- const designator = String(entry.designator ?? entry.Designator ?? entry.name ?? "#" + index);
3096
+ const designator = String(entry.designator ?? entry.Designator ?? entry.Name ?? entry.name ?? "#" + index);
3087
3097
  const id = String(entry.primitiveId ?? entry.PrimitiveId ?? "");
3088
3098
  let bounds;
3089
3099
  if (id !== "") {
3090
3100
  const box = await bboxOf([id], timeoutMs);
3091
3101
  if (box !== void 0) bounds = { left: box.minX, right: box.maxX, bottom: box.minY, top: box.maxY };
3092
3102
  }
3103
+ if (bounds !== void 0) {
3104
+ const boundsArea = (bounds.right - bounds.left) * (bounds.top - bounds.bottom);
3105
+ if (boundsArea > frameArea * 0.4) {
3106
+ bounds = void 0;
3107
+ droppedSheetPrimitives += 1;
3108
+ }
3109
+ }
3093
3110
  components.push({ designator, x: Number(entry.x ?? entry.X ?? 0), y: Number(entry.y ?? entry.Y ?? 0), bounds });
3094
3111
  }
3095
3112
  const report = auditSchematicGeometry({
3096
3113
  components,
3097
- frame: paper.frame,
3114
+ frame,
3098
3115
  titleBlock: paper.titleBlock,
3099
3116
  grid: args.grid === void 0 ? 10 : Number(args.grid)
3100
3117
  });
@@ -3104,57 +3121,111 @@ async function executeTool(name, rawArgs, timeoutMs) {
3104
3121
  counts: report.counts,
3105
3122
  componentCount: report.componentCount,
3106
3123
  boxesRead: components.filter((component) => component.bounds !== void 0).length,
3107
- checks
3124
+ droppedSheetPrimitives,
3125
+ checks,
3126
+ notes: droppedSheetPrimitives > 0 ? ["Skipped " + droppedSheetPrimitives + " primitive(s) whose box covers the whole sheet (frame/title block); counting them made every symbol look overlapped."] : []
3108
3127
  };
3109
3128
  }
3110
3129
  case "eda_delivery_checklist": {
3111
3130
  const steps = [];
3112
3131
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3132
+ const project = await callExtension("dmt_Project.getCurrentProjectInfo", [], timeoutMs).catch(() => null);
3133
+ const boards = Array.isArray(project?.data) ? project.data : [];
3134
+ const schematicPage = boards[0]?.schematic?.page?.[0]?.uuid ?? "";
3135
+ const pcbUuid = boards.find((board) => board?.pcb?.uuid)?.pcb?.uuid ?? "";
3136
+ const switchDocuments = args.switchDocuments !== false;
3137
+ const documentType = async () => {
3138
+ try {
3139
+ const info = await callExtension("dmt_SelectControl.getCurrentDocumentInfo", [], timeoutMs);
3140
+ return Number(info?.documentType);
3141
+ } catch {
3142
+ return NaN;
3143
+ }
3144
+ };
3145
+ const activate = async (uuid2) => {
3146
+ if (typeof uuid2 !== "string" || uuid2 === "") return false;
3147
+ try {
3148
+ await callExtension("dmt_EditorControl.openDocument", [uuid2], timeoutMs);
3149
+ await new Promise((resolve) => setTimeout(resolve, 1500));
3150
+ return true;
3151
+ } catch {
3152
+ return false;
3153
+ }
3154
+ };
3113
3155
  let netlistErc = null;
3114
- try {
3115
- const parsedNetlist = await fetchParsedNetlist(timeoutMs);
3116
- netlistErc = runNetlistErc(parsedNetlist);
3117
- steps.push({ step: "netlist-erc", ok: netlistErc.ok, detail: netlistErc.counts, failing: netlistErc.checks.filter((check) => check.status === "fail").map((check) => check.id) });
3118
- } catch (error) {
3119
- steps.push({ step: "netlist-erc", ok: false, error: error instanceof Error ? error.message : String(error) });
3156
+ const onSchematic = switchDocuments ? await activate(schematicPage) : await documentType() === 1;
3157
+ if (!onSchematic) {
3158
+ steps.push({ step: "netlist-erc", document: "schematic", ok: false, skipped: "schematic page is not the active document" });
3159
+ steps.push({ step: "bom", document: "schematic", ok: false, skipped: "schematic page is not the active document" });
3160
+ } else {
3161
+ try {
3162
+ const parsedNetlist = await fetchParsedNetlist(timeoutMs);
3163
+ netlistErc = runNetlistErc(parsedNetlist);
3164
+ steps.push({
3165
+ step: "netlist-erc",
3166
+ document: "schematic",
3167
+ ok: netlistErc.ok,
3168
+ detail: netlistErc.counts,
3169
+ failing: netlistErc.checks.filter((check) => check.status === "fail").map((check) => check.id)
3170
+ });
3171
+ } catch (error) {
3172
+ steps.push({ step: "netlist-erc", document: "schematic", ok: false, error: error instanceof Error ? error.message : String(error) });
3173
+ }
3174
+ }
3175
+ let bom = null;
3176
+ if (onSchematic) {
3177
+ try {
3178
+ const payload = await callExtension("sch_ManufactureData.getBomFile", [void 0, "csv"], timeoutMs);
3179
+ const text = typeof payload === "string" ? payload : typeof payload?.text === "string" ? payload.text : "";
3180
+ const lines = text === "" ? 0 : text.split(/\r?\n/).filter((line) => line.trim() !== "").length;
3181
+ bom = { rows: Math.max(0, lines - 1), ok: lines > 1 };
3182
+ steps.push({ step: "bom", document: "schematic", ok: bom.ok, detail: { rows: bom.rows }, ...bom.ok ? {} : { error: "no BOM rows exported" } });
3183
+ } catch (error) {
3184
+ steps.push({ step: "bom", document: "schematic", ok: false, error: error instanceof Error ? error.message : String(error) });
3185
+ }
3120
3186
  }
3187
+ const onPcb = switchDocuments ? await activate(pcbUuid) : await documentType() === 3;
3121
3188
  let pcbAudit = null;
3122
- try {
3123
- pcbAudit = await auditPcb({ drc: args.pcbDrc !== false }, timeoutMs);
3124
- steps.push({
3125
- step: "pcb-audit",
3126
- ok: Array.isArray(pcbAudit.violations) && pcbAudit.violations.length === 0,
3127
- detail: { violations: pcbAudit.violations?.length ?? null, warnings: pcbAudit.warnings?.length ?? null, drc: pcbAudit.drc },
3128
- failing: (pcbAudit.violations ?? []).map((entry) => entry.rule)
3129
- });
3130
- } catch (error) {
3131
- steps.push({ step: "pcb-audit", ok: false, error: error instanceof Error ? error.message : String(error) });
3189
+ if (!onPcb) {
3190
+ steps.push({ step: "pcb-audit", document: "pcb", ok: false, skipped: "PCB is not the active document" });
3191
+ steps.push({ step: "gerber", document: "pcb", ok: false, skipped: "PCB is not the active document" });
3192
+ } else {
3193
+ try {
3194
+ pcbAudit = await auditPcb({ drc: args.pcbDrc !== false }, timeoutMs);
3195
+ steps.push({
3196
+ step: "pcb-audit",
3197
+ document: "pcb",
3198
+ ok: (pcbAudit.violations ?? []).length === 0,
3199
+ detail: { violations: pcbAudit.violations?.length ?? null, warnings: pcbAudit.warnings?.length ?? null, drc: pcbAudit.drc },
3200
+ failing: (pcbAudit.violations ?? []).map((entry) => entry.rule)
3201
+ });
3202
+ } catch (error) {
3203
+ steps.push({ step: "pcb-audit", document: "pcb", ok: false, error: error instanceof Error ? error.message : String(error) });
3204
+ }
3132
3205
  }
3133
3206
  let gerber = null;
3134
- try {
3135
- const payload = await callExtension("pcb_ManufactureData.getGerberFile", [void 0, void 0, void 0, void 0, void 0, void 0], timeoutMs);
3136
- const bytes = payload && typeof payload === "object" ? Number(payload.size ?? payload.bytes ?? 0) : typeof payload === "string" ? payload.length : 0;
3137
- gerber = { bytes, ok: bytes > 1e3 };
3138
- steps.push({ step: "gerber", ok: gerber.ok, detail: { bytes } });
3139
- } catch (error) {
3140
- steps.push({ step: "gerber", ok: false, error: error instanceof Error ? error.message : String(error) });
3141
- }
3142
- let bom = null;
3143
- try {
3144
- const payload = await callExtension("sch_ManufactureData.getBomFile", [void 0, "csv"], timeoutMs);
3145
- const text = typeof payload === "string" ? payload : typeof payload?.text === "string" ? payload.text : "";
3146
- const lines = text === "" ? 0 : text.split(/\r?\n/).filter((line) => line.trim() !== "").length;
3147
- bom = { rows: Math.max(0, lines - 1), ok: lines > 1 };
3148
- steps.push({ step: "bom", ok: bom.ok, detail: { rows: bom.rows } });
3149
- } catch (error) {
3150
- steps.push({ step: "bom", ok: false, error: error instanceof Error ? error.message : String(error) });
3207
+ if (onPcb) {
3208
+ try {
3209
+ const payload = await callExtension("pcb_ManufactureData.getGerberFile", [void 0, void 0, void 0, void 0, void 0, void 0], timeoutMs);
3210
+ const bytes = payload && typeof payload === "object" ? Number(payload.size ?? payload.bytes ?? 0) : typeof payload === "string" ? payload.length : 0;
3211
+ gerber = { bytes, ok: bytes > 1e3 };
3212
+ steps.push({ step: "gerber", document: "pcb", ok: gerber.ok, detail: { bytes } });
3213
+ } catch (error) {
3214
+ steps.push({ step: "gerber", document: "pcb", ok: false, error: error instanceof Error ? error.message : String(error) });
3215
+ }
3151
3216
  }
3152
- const failed = steps.filter((step) => step.ok !== true);
3217
+ const failedSteps = steps.filter((step) => step.ok !== true && step.skipped === void 0);
3218
+ const skippedSteps = steps.filter((step) => step.ok !== true && step.skipped !== void 0);
3219
+ const verdict = failedSteps.length > 0 ? "fail" : skippedSteps.length > 0 ? "incomplete" : "pass";
3153
3220
  const report = {
3154
- ok: failed.length === 0,
3221
+ ok: verdict === "pass",
3222
+ verdict,
3223
+ failedSteps: failedSteps.map((step) => step.step),
3224
+ skippedSteps: skippedSteps.map((step) => step.step),
3225
+ next: verdict === "pass" ? "Every step passed: the board can be ordered." : verdict === "incomplete" ? "Run the checklist again with the other document active (" + skippedSteps.map((step) => step.document).filter((value, index, all) => all.indexOf(value) === index).join(", ") + " needed)." : "Fix the failing steps first; skipped steps are listed separately.",
3155
3226
  startedAt,
3156
3227
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3157
- steps,
3228
+ documents: { schematicPage, pcb: pcbUuid, switched: switchDocuments },
3158
3229
  netlistErc: netlistErc === null ? null : { ok: netlistErc.ok, counts: netlistErc.counts, componentCount: netlistErc.componentCount, netCount: netlistErc.netCount },
3159
3230
  pcbAudit: pcbAudit === null ? null : { violations: pcbAudit.violations, warnings: pcbAudit.warnings },
3160
3231
  gerber,
@@ -3168,10 +3239,12 @@ async function executeTool(name, rawArgs, timeoutMs) {
3168
3239
  "- \u5F00\u59CB\uFF1A" + report.startedAt,
3169
3240
  "- \u7ED3\u675F\uFF1A" + report.finishedAt,
3170
3241
  "- \u7ED3\u8BBA\uFF1A" + (report.ok ? "PASS\uFF08\u53EF\u4EE5\u4E0B\u5355\uFF09" : "FAIL\uFF08\u6709\u672A\u901A\u8FC7\u9879\uFF09"),
3242
+ "- \u539F\u7406\u56FE\u9875\uFF1A" + (schematicPage || "\u672A\u53D6\u5230"),
3243
+ "- PCB\uFF1A" + (pcbUuid || "\u672A\u53D6\u5230"),
3171
3244
  "",
3172
- "| \u6B65\u9AA4 | \u7ED3\u679C | \u8BF4\u660E |",
3173
- "|---|---|---|",
3174
- ...steps.map((step) => "| " + step.step + " | " + (step.ok === true ? "PASS" : "FAIL") + " | " + JSON.stringify(step.detail ?? step.error ?? {}) + " |"),
3245
+ "| \u6B65\u9AA4 | \u6587\u6863 | \u7ED3\u679C | \u8BF4\u660E |",
3246
+ "|---|---|---|---|",
3247
+ ...steps.map((step) => "| " + step.step + " | " + step.document + " | " + (step.ok === true ? "PASS" : "FAIL") + " | " + JSON.stringify(step.detail ?? step.error ?? {}) + " |"),
3175
3248
  "",
3176
3249
  "## \u7535\u6C14\u68C0\u67E5\uFF08eda_netlist_erc\uFF09",
3177
3250
  "",
@@ -3212,17 +3285,43 @@ async function executeTool(name, rawArgs, timeoutMs) {
3212
3285
  const merged = { ...designBrief ?? {}, ...answersReport.brief };
3213
3286
  const mergedReport = buildIntake(merged);
3214
3287
  let persisted = false;
3288
+ let persistVia = "none";
3289
+ let persistPath = "";
3215
3290
  if (Object.keys(merged).length > 0) {
3216
3291
  designBrief = merged;
3217
3292
  if (answers !== null) {
3218
3293
  try {
3219
3294
  const project = await callExtension("dmt_Project.getCurrentProjectInfo", [], timeoutMs);
3295
+ const projectUuid = String(project?.uuid ?? "");
3220
3296
  const existing = typeof project?.description === "string" ? project.description : "";
3221
3297
  const index = existing.indexOf(BRIEF_TAG);
3222
3298
  const base = index < 0 ? existing : existing.slice(0, index).trimEnd();
3223
3299
  const next = (base === "" ? "" : base + "\n") + briefBlock(merged);
3224
- await callExtension("dmt_Project.modifyProjectDescription", [next], timeoutMs);
3225
- persisted = true;
3300
+ let stored = false;
3301
+ if (projectUuid !== "") {
3302
+ try {
3303
+ await callExtension("dmt_Project.modifyProjectDescription", [projectUuid, next], timeoutMs);
3304
+ const readBack = await callExtension("dmt_Project.getCurrentProjectInfo", [], timeoutMs);
3305
+ stored = String(readBack?.description ?? "").includes(BRIEF_TAG);
3306
+ if (stored) persistVia = "description";
3307
+ } catch {
3308
+ stored = false;
3309
+ }
3310
+ }
3311
+ if (!stored) {
3312
+ const sidecar = specFilePath(project);
3313
+ if (sidecar !== "") {
3314
+ await mkdir(join(sidecar, ".."), { recursive: true });
3315
+ await writeFile(sidecar, JSON.stringify({ projectUuid, brief: merged }, null, 2) + "\n");
3316
+ const check = JSON.parse(await readFile(sidecar, "utf8"));
3317
+ stored = check !== null && typeof check === "object" && Object.keys(merged).every((key) => check.brief?.[key] === merged[key]);
3318
+ if (stored) {
3319
+ persistVia = "file";
3320
+ persistPath = sidecar;
3321
+ }
3322
+ }
3323
+ }
3324
+ persisted = stored === true;
3226
3325
  } catch {
3227
3326
  persisted = false;
3228
3327
  }
@@ -3238,6 +3337,8 @@ async function executeTool(name, rawArgs, timeoutMs) {
3238
3337
  brief: mergedReport.brief,
3239
3338
  hydratedFromProject: answers === null && designBrief !== null,
3240
3339
  persisted,
3340
+ persistVia,
3341
+ persistPath,
3241
3342
  sections: mergedReport.sections.filter((section) => section.questions.some((question) => mergedReport.brief[question.id] === void 0)),
3242
3343
  next: mergedReport.ready ? "All critical parameters are known: lay out the schematic, then eda_netlist_erc before routing." : "Ask the user for the missing critical parameters with ask_user_question, grouped by section, before placing parts. Do not invent values."
3243
3344
  };
@@ -3682,7 +3783,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
3682
3783
  raw = await attempt("solid", false);
3683
3784
  } catch (secondFailure) {
3684
3785
  const detail = secondFailure instanceof Error ? secondFailure.message : String(secondFailure);
3685
- throw new Error("eda_pcb_create_pour failed twice (first: " + (firstFailure instanceof Error ? firstFailure.message : String(firstFailure)) + "; retry: " + detail + "). polygonStage=" + (polygonOk ? "ok" : "failed") + ": " + (polygonOk ? "the app accepted the outline but refused the pour itself, so this is an application limit, not a coordinate problem. Pour by hand in the PCB editor (\u8986\u94DC \u2192 select GND \u2192 top + bottom); eda_design_audit reports it as a warn until then." : "the outline polygon was rejected too: check that every point is inside the board outline and flat [x1,y1,...].") + " retried=" + retried);
3786
+ throw new Error("eda_pcb_create_pour failed twice (first: " + (firstFailure instanceof Error ? firstFailure.message : String(firstFailure)) + "; retry: " + detail + "). polygonStage=" + (polygonOk ? "ok" : "failed") + ": " + (polygonOk ? "the app accepted the outline but refused the pour itself, and so does the complex polygon the docs require (createComplexPolygon); single and complex polygons, every net/layer/fill combination, all refused, so pour creation is an application limit on this build, not a coordinate problem. Pour by hand in the PCB editor (\u8986\u94DC \u2192 select GND \u2192 top + bottom); eda_design_audit reports it as a warn until then." : "the outline polygon was rejected too: check that every point is inside the board outline and flat [x1,y1,...].") + " retried=" + retried);
3686
3787
  }
3687
3788
  }
3688
3789
  return {
package/lib/index.js CHANGED
@@ -272,6 +272,13 @@ var SKILL_PLAYBOOK_LINES = [
272
272
  '| `eda_delivery_checklist` | \u4E00\u6B21\u4E32\u5B8C ERC + PCB \u5BA1\u8BA1/DRC + Gerber + BOM\uFF0C\u9010\u6B65 PASS/FAIL\uFF1B`outPath` \u843D Markdown \u62A5\u544A | \u4EA4\u4ED8\u524D\uFF0C\u4EE3\u66FF"\u6211\u8BF4\u505A\u5B8C\u4E86" |',
273
273
  "",
274
274
  "**\u4EA4\u4ED8\u53E3\u5F84**\uFF1A`eda_delivery_checklist` \u8FD4\u56DE `ok:true` \u624D\u7B97\u53EF\u4EE5\u4E0B\u5355\uFF1B\u4EFB\u4F55\u4E00\u6B65 FAIL \u90FD\u8981\u5148\u4FEE\uFF0C\u4E0D\u8BB8\u53E3\u5934\u7ED5\u8FC7\u3002\u62A5\u544A\u91CC\u5E26\u65F6\u95F4\u6233\u4E0E\u8BA1\u6570\uFF0C\u53EF\u76F4\u63A5\u7ED9\u4EBA\u590D\u6838\u3002",
275
+ "### \u4EA4\u4ED8\u6E05\u5355\u600E\u4E48\u8BFB\uFF081.0.1 \u8D77\uFF09",
276
+ "",
277
+ "- \u4E00\u6B21\u8C03\u7528\u4F1A**\u81EA\u5DF1\u5207\u5230\u539F\u7406\u56FE\u518D\u5207\u5230 PCB**\uFF08`switchDocuments:false` \u6539\u6309\u5F53\u524D\u6587\u6863\u8DD1\u4E00\u534A\uFF0C\u53E6\u4E00\u534A\u6807\u6CE8\u8DF3\u8FC7\u539F\u56E0\uFF09\uFF1B",
278
+ "- \u8FD4\u56DE `verdict`\uFF1A`pass`\uFF08\u56DB\u6B65\u5168\u8FC7\uFF09/ `incomplete`\uFF08\u6709\u6B65\u9AA4\u56E0\u6587\u6863\u4E0D\u5BF9\u88AB\u8DF3\u8FC7\uFF09/ `fail`\uFF08\u6709\u6B65\u9AA4\u771F\u5931\u8D25\uFF09\uFF1B`ok` \u7B49\u4EF7\u4E8E `verdict==='pass'`\uFF1B",
279
+ '- `skippedSteps` \u4E0E `failedSteps` \u5206\u5F00\u5217 \u2014\u2014 **\u522B\u628A"\u6CA1\u8DD1"\u5F53\u6210"\u901A\u8FC7"**\uFF1B\u56DB\u6B65\u5168\u8FC7\u5B9E\u6D4B\u7EA6 **7.5 s**\uFF1B',
280
+ "- \u6295\u677F\u524D\u6700\u540E\u4E00\u6761\u547D\u4EE4\u5C31\u7528\u5B83\uFF0C`outPath` \u843D Markdown \u5B58\u6863\u3002",
281
+ "",
275
282
  "",
276
283
  "**\u53C2\u6570\u6301\u4E45\u5316**\uFF1A`eda_design_intake({answers})` \u4F1A\u628A\u7B54\u590D\u5199\u8FDB**\u5DE5\u7A0B\u63CF\u8FF0**\uFF08`[dsh-eda-spec] {...}`\uFF09\uFF0C\u4E0B\u6B21\u4F1A\u8BDD\u8C03\u7528 `eda_design_intake` \u4F1A\u81EA\u52A8\u8BFB\u56DE\uFF0C\u4E0D\u7528\u91CD\u65B0\u731C\uFF1B`reset:true` \u6E05\u7A7A\u3002",
277
284
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-eda-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "DeepSeek Harness (DSH) plugin — connects DSH to JLCEDA Pro (EasyEDA Pro) via a local MCP bridge and draws schematics through the JLCEDA extension API.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { execFileSync, spawn } from 'node:child_process'
4
4
  import { existsSync, statSync } from 'node:fs'
5
5
  import { readFile } from 'node:fs/promises'
6
- import { unlink, writeFile } from 'node:fs/promises'
6
+ import { mkdir, unlink, writeFile } from 'node:fs/promises'
7
7
  import { tmpdir } from 'node:os'
8
8
  import { basename, join } from 'node:path'
9
9
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
@@ -318,7 +318,7 @@ const TOOLS = [
318
318
  { name: 'pcb_draw_text', description: 'Put text on a PCB layer - silkscreen by default (3 top silk / 4 bottom silk). Use it for pin functions, board markings, version and fabrication notes; silkscreen text must not cover pads.', inputSchema: objectSchema({ x: { type: 'number' }, y: { type: 'number' }, text: { type: 'string' }, layer: { type: 'string' }, fontSize: { type: 'number' }, lineWidth: { type: 'number' }, rotation: { type: 'number' }, mirror: { type: 'boolean' }, alignMode: { type: 'integer' }, locked: { type: 'boolean' } }, ['x', 'y', 'text']) },
319
319
  { name: 'pcb_draw_mounting_hole', description: 'Place a NON-plated mounting hole or a slot (pcb_PrimitivePad with metallization:false). shape "round" is a drill-sized hole, shape "slot" an oval cut-out. Use this for mounting holes and connector slots instead of hacking the board outline.', inputSchema: objectSchema({ x: { type: 'number' }, y: { type: 'number' }, diameter: { type: 'number' }, shape: { type: 'string', enum: ['round', 'slot'] }, slotLength: { type: 'number' }, rotation: { type: 'number' }, plated: { type: 'boolean' }, annulus: { type: 'number' }, padNumber: { type: 'string' }, layer: { type: 'string' } }, ['x', 'y', 'diameter']) },
320
320
  { name: 'eda_schematic_audit', description: "Geometry audit of the active schematic page (the netlist ERC only judges connectivity): symbols outside the frame, symbols overlapping the title block, body-on-body symbol overlap, off-grid placement, and labels anchored inside a symbol body. Read-only; pair it with eda_netlist_erc before routing.", inputSchema: objectSchema({ grid: { type: 'number' }, includeOk: { type: 'boolean' } }) },
321
- { name: 'eda_delivery_checklist', description: "Delivery checklist: run connectivity (eda_netlist_erc), PCB audit + DRC, Gerber export and BOM export in ONE read-only call and report per-step pass/fail with counts. Pass outPath to also write a Markdown report you can hand to a reviewer. Use it instead of claiming a board is finished.", inputSchema: objectSchema({ outPath: { type: 'string' }, pcbDrc: { type: 'boolean' } }) },
321
+ { name: 'eda_delivery_checklist', description: "Delivery checklist: run connectivity (eda_netlist_erc), PCB audit + DRC, Gerber export and BOM export in ONE read-only call and report per-step pass/fail with counts. It switches to the schematic and the PCB itself (switchDocuments:false keeps the current document and reports the other steps as skipped). verdict is pass / incomplete / fail; pass outPath to also write a Markdown report you can hand to a reviewer.", inputSchema: objectSchema({ outPath: { type: 'string' }, pcbDrc: { type: 'boolean' }, switchDocuments: { type: 'boolean' } }) },
322
322
  { name: 'eda_design_intake', description: "Requirement intake for a new board: call it BEFORE placing anything when the request is vague (for example: draw me an X board). Without answers it returns the questions in 6 sections (power, core, IO, mechanical, process, delivery), each marked critical or fallback-able with the reason it changes the drawing; pass answers:{questionId:value} to record them and see what is still missing. Ask the user with ask_user_question for the critical ones instead of guessing.", inputSchema: objectSchema({ answers: { type: 'object', additionalProperties: true }, reset: { type: 'boolean' } }) },
323
323
  { name: 'eda_netlist_erc', description: 'Read-only electrical smoke test over the schematic NETLIST (no application DRC needed): duplicate designators, unconnected pins, single-endpoint nets, shorted parts, power rails and decoupling, USB D+/D- pair, crystal (two nets + load caps + reaches the MCU), reset RC, BOOT pull resistor, Type-C CC pull-downs. DRC can be clean while the circuit is still wrong; these failures all show up in the netlist. Pass includeOk:true to see passing checks too.', inputSchema: objectSchema({ includeOk: { type: 'boolean' } }) },
324
324
  { name: 'eda_pcb_canvas_origin', description: 'Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API.', inputSchema: objectSchema({ align: { type: 'boolean' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, point: { type: 'object', additionalProperties: true } }) },
@@ -754,6 +754,15 @@ const BRIEF_TAG = '[dsh-eda-spec]'
754
754
  function briefBlock(brief) {
755
755
  return BRIEF_TAG + ' ' + JSON.stringify(brief)
756
756
  }
757
+ /** Sidecar file for the design brief, next to a local project directory. */
758
+ function specFilePath(project) {
759
+ const dir = String(project?.teamUuid ?? '')
760
+ const uuid = String(project?.uuid ?? '')
761
+ if (dir === '' || uuid === '') return ''
762
+ if (!/^[A-Za-z]:[\\/]/.test(dir) && !dir.startsWith('/')) return ''
763
+ return join(dir, '.dsh-eda-spec', uuid + '.json')
764
+ }
765
+
757
766
  function parseBriefBlock(description) {
758
767
  const text = typeof description === 'string' ? description : ''
759
768
  const index = text.indexOf(BRIEF_TAG)
@@ -845,7 +854,11 @@ async function auditPcb(args, timeoutMs) {
845
854
  const vias = await pcbPrimitiveList('pcb_PrimitiveVia.getAll', [netFilter, undefined], timeoutMs)
846
855
  const pours = await pcbPrimitiveList('pcb_PrimitivePour.getAll', [netFilter, undefined, undefined], timeoutMs)
847
856
  const outline = await pcbPrimitiveList('pcb_PrimitivePolyline.getAll', [undefined, PCB_LAYERS.boardOutline, undefined], timeoutMs)
848
- const pcbComponents = await pcbPrimitiveList('pcb_PrimitiveComponent.getAll', [undefined, undefined], timeoutMs)
857
+ // Same call shape as pcb_get_components: with a bare [undefined, undefined] the app
858
+ // returns an empty list here, which silently disabled the mechanical checks below.
859
+ const pcbComponents = await callExtension('pcb_PrimitiveComponent.getAll', [resolvePcbLayer(undefined), undefined], timeoutMs)
860
+ .then((raw) => (Array.isArray(raw) ? raw : []))
861
+ .catch(() => [])
849
862
 
850
863
  const violations = []
851
864
  const warnings = []
@@ -996,7 +1009,7 @@ async function auditPcb(args, timeoutMs) {
996
1009
 
997
1010
  return {
998
1011
  ok: violations.length === 0,
999
- counts: { tracks: tracks.length, vias: vias.length, pours: pours.length, outlinePolylines: outline.length, nets: netNames.length },
1012
+ counts: { tracks: tracks.length, vias: vias.length, pours: pours.length, outlinePolylines: outline.length, nets: netNames.length, componentsRead: pcbComponents.length },
1000
1013
  minWidthByNet: Object.fromEntries([...widthByNet.entries()].map(([net, width]) => [net, { widthMil: width, role: classifyNet(net), suggestedMil: suggestTrackWidthMil(net) }])),
1001
1014
  violations, warnings, checklist, drc,
1002
1015
  rules: {
@@ -2432,25 +2445,33 @@ async function executeTool(name, rawArgs, timeoutMs) {
2432
2445
 
2433
2446
  case 'eda_schematic_audit': {
2434
2447
  const paper = paperOf(args.paper)
2448
+ const frame = paper.frame
2449
+ const frameArea = (frame.right - frame.left) * (frame.top - frame.bottom)
2435
2450
  const rawList = await callExtension('sch_PrimitiveComponent.getAll', [], timeoutMs)
2436
2451
  const list = Array.isArray(rawList) ? rawList.slice(0, 200) : []
2437
2452
  const components = []
2453
+ let droppedSheetPrimitives = 0
2438
2454
  for (let index = 0; index < list.length; index += 1) {
2439
2455
  const entry = list[index] ?? {}
2440
- const designator = String(entry.designator ?? entry.Designator ?? entry.name ?? ('#' + index))
2456
+ const designator = String(entry.designator ?? entry.Designator ?? entry.Name ?? entry.name ?? ('#' + index))
2441
2457
  const id = String(entry.primitiveId ?? entry.PrimitiveId ?? '')
2442
2458
  let bounds
2443
2459
  if (id !== '') {
2444
- // Per-symbol box: the batch call returns one box for the whole selection,
2445
- // which cannot detect symbol-on-symbol overlap.
2446
2460
  const box = await bboxOf([id], timeoutMs)
2447
2461
  if (box !== undefined) bounds = { left: box.minX, right: box.maxX, bottom: box.minY, top: box.maxY }
2448
2462
  }
2463
+ // Frame / title-block primitives live in the same list and their box is the whole
2464
+ // sheet (measured: one entry reported 0..1170 x 0..825 and then 'overlapped'
2465
+ // every symbol and the title block).
2466
+ if (bounds !== undefined) {
2467
+ const boundsArea = (bounds.right - bounds.left) * (bounds.top - bounds.bottom)
2468
+ if (boundsArea > frameArea * 0.4) { bounds = undefined; droppedSheetPrimitives += 1 }
2469
+ }
2449
2470
  components.push({ designator, x: Number(entry.x ?? entry.X ?? 0), y: Number(entry.y ?? entry.Y ?? 0), bounds })
2450
2471
  }
2451
2472
  const report = auditSchematicGeometry({
2452
2473
  components,
2453
- frame: paper.frame,
2474
+ frame,
2454
2475
  titleBlock: paper.titleBlock,
2455
2476
  grid: args.grid === undefined ? 10 : Number(args.grid),
2456
2477
  })
@@ -2460,68 +2481,128 @@ async function executeTool(name, rawArgs, timeoutMs) {
2460
2481
  counts: report.counts,
2461
2482
  componentCount: report.componentCount,
2462
2483
  boxesRead: components.filter(component => component.bounds !== undefined).length,
2484
+ droppedSheetPrimitives,
2463
2485
  checks,
2486
+ notes: droppedSheetPrimitives > 0
2487
+ ? ['Skipped ' + droppedSheetPrimitives + ' primitive(s) whose box covers the whole sheet (frame/title block); counting them made every symbol look overlapped.']
2488
+ : [],
2464
2489
  }
2465
2490
  }
2466
2491
 
2467
2492
  case 'eda_delivery_checklist': {
2468
- // One call that proves the board is ready: connectivity, geometry, fabrication
2469
- // exports. Every step records what it actually returned, so the report can be
2470
- // handed to a reviewer instead of trusted as a claim.
2493
+ // One call that proves the board is ready. Two of the four steps only work with the
2494
+ // schematic active and two only with the PCB active, so the checklist switches
2495
+ // documents itself (switchDocuments:false keeps whatever is open).
2471
2496
  const steps = []
2472
2497
  const startedAt = new Date().toISOString()
2498
+ const project = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs).catch(() => null)
2499
+ const boards = Array.isArray(project?.data) ? project.data : []
2500
+ const schematicPage = boards[0]?.schematic?.page?.[0]?.uuid ?? ''
2501
+ const pcbUuid = boards.find((board) => board?.pcb?.uuid)?.pcb?.uuid ?? ''
2502
+ // Document switching used to be the default and hung the call (openDocument + a
2503
+ // page load blocked the following netlist export). Default is now 'run with the
2504
+ // document this step needs active'; switchDocuments:true still tries to switch.
2505
+ const switchDocuments = args.switchDocuments !== false
2506
+ const documentType = async () => {
2507
+ try {
2508
+ const info = await callExtension('dmt_SelectControl.getCurrentDocumentInfo', [], timeoutMs)
2509
+ return Number(info?.documentType)
2510
+ } catch { return NaN }
2511
+ }
2512
+ const activate = async (uuid) => {
2513
+ if (typeof uuid !== 'string' || uuid === '') return false
2514
+ try {
2515
+ await callExtension('dmt_EditorControl.openDocument', [uuid], timeoutMs)
2516
+ // Give the editor a beat before the next API needs the page.
2517
+ await new Promise(resolve => setTimeout(resolve, 1500))
2518
+ return true
2519
+ } catch { return false }
2520
+ }
2473
2521
 
2474
2522
  let netlistErc = null
2475
- try {
2476
- const parsedNetlist = await fetchParsedNetlist(timeoutMs)
2477
- netlistErc = runNetlistErc(parsedNetlist)
2478
- steps.push({ step: 'netlist-erc', ok: netlistErc.ok, detail: netlistErc.counts, failing: netlistErc.checks.filter(check => check.status === 'fail').map(check => check.id) })
2479
- } catch (error) {
2480
- steps.push({ step: 'netlist-erc', ok: false, error: error instanceof Error ? error.message : String(error) })
2523
+ // Step documents: schematic steps need documentType 1, PCB steps need 3.
2524
+ const onSchematic = switchDocuments ? await activate(schematicPage) : (await documentType()) === 1
2525
+ if (!onSchematic) {
2526
+ steps.push({ step: 'netlist-erc', document: 'schematic', ok: false, skipped: 'schematic page is not the active document' })
2527
+ steps.push({ step: 'bom', document: 'schematic', ok: false, skipped: 'schematic page is not the active document' })
2528
+ } else {
2529
+ try {
2530
+ const parsedNetlist = await fetchParsedNetlist(timeoutMs)
2531
+ netlistErc = runNetlistErc(parsedNetlist)
2532
+ steps.push({
2533
+ step: 'netlist-erc',
2534
+ document: 'schematic',
2535
+ ok: netlistErc.ok,
2536
+ detail: netlistErc.counts,
2537
+ failing: netlistErc.checks.filter(check => check.status === 'fail').map(check => check.id),
2538
+ })
2539
+ } catch (error) {
2540
+ steps.push({ step: 'netlist-erc', document: 'schematic', ok: false, error: error instanceof Error ? error.message : String(error) })
2541
+ }
2481
2542
  }
2482
2543
 
2544
+ let bom = null
2545
+ if (onSchematic) {
2546
+ try {
2547
+ const payload = await callExtension('sch_ManufactureData.getBomFile', [undefined, 'csv'], timeoutMs)
2548
+ const text = typeof payload === 'string' ? payload : (typeof payload?.text === 'string' ? payload.text : '')
2549
+ const lines = text === '' ? 0 : text.split(/\r?\n/).filter(line => line.trim() !== '').length
2550
+ bom = { rows: Math.max(0, lines - 1), ok: lines > 1 }
2551
+ steps.push({ step: 'bom', document: 'schematic', ok: bom.ok, detail: { rows: bom.rows }, ...(bom.ok ? {} : { error: 'no BOM rows exported' }) })
2552
+ } catch (error) {
2553
+ steps.push({ step: 'bom', document: 'schematic', ok: false, error: error instanceof Error ? error.message : String(error) })
2554
+ }
2555
+ }
2556
+ const onPcb = switchDocuments ? await activate(pcbUuid) : (await documentType()) === 3
2483
2557
  let pcbAudit = null
2484
- try {
2485
- pcbAudit = await auditPcb({ drc: args.pcbDrc !== false }, timeoutMs)
2486
- steps.push({
2487
- step: 'pcb-audit',
2488
- ok: Array.isArray(pcbAudit.violations) && pcbAudit.violations.length === 0,
2489
- detail: { violations: pcbAudit.violations?.length ?? null, warnings: pcbAudit.warnings?.length ?? null, drc: pcbAudit.drc },
2490
- failing: (pcbAudit.violations ?? []).map(entry => entry.rule),
2491
- })
2492
- } catch (error) {
2493
- steps.push({ step: 'pcb-audit', ok: false, error: error instanceof Error ? error.message : String(error) })
2558
+ if (!onPcb) {
2559
+ steps.push({ step: 'pcb-audit', document: 'pcb', ok: false, skipped: 'PCB is not the active document' })
2560
+ steps.push({ step: 'gerber', document: 'pcb', ok: false, skipped: 'PCB is not the active document' })
2561
+ } else {
2562
+ try {
2563
+ pcbAudit = await auditPcb({ drc: args.pcbDrc !== false }, timeoutMs)
2564
+ steps.push({
2565
+ step: 'pcb-audit',
2566
+ document: 'pcb',
2567
+ ok: (pcbAudit.violations ?? []).length === 0,
2568
+ detail: { violations: pcbAudit.violations?.length ?? null, warnings: pcbAudit.warnings?.length ?? null, drc: pcbAudit.drc },
2569
+ failing: (pcbAudit.violations ?? []).map(entry => entry.rule),
2570
+ })
2571
+ } catch (error) {
2572
+ steps.push({ step: 'pcb-audit', document: 'pcb', ok: false, error: error instanceof Error ? error.message : String(error) })
2573
+ }
2494
2574
  }
2495
2575
 
2496
2576
  let gerber = null
2497
- try {
2498
- const payload = await callExtension('pcb_ManufactureData.getGerberFile', [undefined, undefined, undefined, undefined, undefined, undefined], timeoutMs)
2499
- const bytes = payload && typeof payload === 'object'
2500
- ? Number(payload.size ?? payload.bytes ?? 0)
2501
- : (typeof payload === 'string' ? payload.length : 0)
2502
- gerber = { bytes, ok: bytes > 1000 }
2503
- steps.push({ step: 'gerber', ok: gerber.ok, detail: { bytes } })
2504
- } catch (error) {
2505
- steps.push({ step: 'gerber', ok: false, error: error instanceof Error ? error.message : String(error) })
2506
- }
2507
-
2508
- let bom = null
2509
- try {
2510
- const payload = await callExtension('sch_ManufactureData.getBomFile', [undefined, 'csv'], timeoutMs)
2511
- const text = typeof payload === 'string' ? payload : (typeof payload?.text === 'string' ? payload.text : '')
2512
- const lines = text === '' ? 0 : text.split(/\r?\n/).filter(line => line.trim() !== '').length
2513
- bom = { rows: Math.max(0, lines - 1), ok: lines > 1 }
2514
- steps.push({ step: 'bom', ok: bom.ok, detail: { rows: bom.rows } })
2515
- } catch (error) {
2516
- steps.push({ step: 'bom', ok: false, error: error instanceof Error ? error.message : String(error) })
2577
+ if (onPcb) {
2578
+ try {
2579
+ const payload = await callExtension('pcb_ManufactureData.getGerberFile', [undefined, undefined, undefined, undefined, undefined, undefined], timeoutMs)
2580
+ const bytes = payload && typeof payload === 'object'
2581
+ ? Number(payload.size ?? payload.bytes ?? 0)
2582
+ : (typeof payload === 'string' ? payload.length : 0)
2583
+ gerber = { bytes, ok: bytes > 1000 }
2584
+ steps.push({ step: 'gerber', document: 'pcb', ok: gerber.ok, detail: { bytes } })
2585
+ } catch (error) {
2586
+ steps.push({ step: 'gerber', document: 'pcb', ok: false, error: error instanceof Error ? error.message : String(error) })
2587
+ }
2517
2588
  }
2518
2589
 
2519
- const failed = steps.filter(step => step.ok !== true)
2590
+ const failedSteps = steps.filter(step => step.ok !== true && step.skipped === undefined)
2591
+ const skippedSteps = steps.filter(step => step.ok !== true && step.skipped !== undefined)
2592
+ const verdict = failedSteps.length > 0 ? 'fail' : (skippedSteps.length > 0 ? 'incomplete' : 'pass')
2520
2593
  const report = {
2521
- ok: failed.length === 0,
2594
+ ok: verdict === 'pass',
2595
+ verdict,
2596
+ failedSteps: failedSteps.map(step => step.step),
2597
+ skippedSteps: skippedSteps.map(step => step.step),
2598
+ next: verdict === 'pass'
2599
+ ? 'Every step passed: the board can be ordered.'
2600
+ : (verdict === 'incomplete'
2601
+ ? 'Run the checklist again with the other document active (' + skippedSteps.map(step => step.document).filter((value, index, all) => all.indexOf(value) === index).join(', ') + ' needed).'
2602
+ : 'Fix the failing steps first; skipped steps are listed separately.'),
2522
2603
  startedAt,
2523
2604
  finishedAt: new Date().toISOString(),
2524
- steps,
2605
+ documents: { schematicPage, pcb: pcbUuid, switched: switchDocuments },
2525
2606
  netlistErc: netlistErc === null ? null : { ok: netlistErc.ok, counts: netlistErc.counts, componentCount: netlistErc.componentCount, netCount: netlistErc.netCount },
2526
2607
  pcbAudit: pcbAudit === null ? null : { violations: pcbAudit.violations, warnings: pcbAudit.warnings },
2527
2608
  gerber,
@@ -2536,10 +2617,12 @@ async function executeTool(name, rawArgs, timeoutMs) {
2536
2617
  '- 开始:' + report.startedAt,
2537
2618
  '- 结束:' + report.finishedAt,
2538
2619
  '- 结论:' + (report.ok ? 'PASS(可以下单)' : 'FAIL(有未通过项)'),
2620
+ '- 原理图页:' + (schematicPage || '未取到'),
2621
+ '- PCB:' + (pcbUuid || '未取到'),
2539
2622
  '',
2540
- '| 步骤 | 结果 | 说明 |',
2541
- '|---|---|---|',
2542
- ...steps.map(step => '| ' + step.step + ' | ' + (step.ok === true ? 'PASS' : 'FAIL') + ' | ' + JSON.stringify(step.detail ?? step.error ?? {}) + ' |'),
2623
+ '| 步骤 | 文档 | 结果 | 说明 |',
2624
+ '|---|---|---|---|',
2625
+ ...steps.map(step => '| ' + step.step + ' | ' + step.document + ' | ' + (step.ok === true ? 'PASS' : 'FAIL') + ' | ' + JSON.stringify(step.detail ?? step.error ?? {}) + ' |'),
2543
2626
  '',
2544
2627
  '## 电气检查(eda_netlist_erc)',
2545
2628
  '',
@@ -2566,6 +2649,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2566
2649
  return report
2567
2650
  }
2568
2651
 
2652
+
2569
2653
  case 'eda_design_intake': {
2570
2654
  if (args.reset === true) designBrief = null
2571
2655
  if (designBrief === null) {
@@ -2580,17 +2664,41 @@ async function executeTool(name, rawArgs, timeoutMs) {
2580
2664
  const merged = { ...(designBrief ?? {}), ...answersReport.brief }
2581
2665
  const mergedReport = buildIntake(merged)
2582
2666
  let persisted = false
2667
+ let persistVia = 'none'
2668
+ let persistPath = ''
2583
2669
  if (Object.keys(merged).length > 0) {
2584
2670
  designBrief = merged
2585
2671
  if (answers !== null) {
2586
2672
  try {
2587
2673
  const project = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs)
2674
+ const projectUuid = String(project?.uuid ?? '')
2588
2675
  const existing = typeof project?.description === 'string' ? project.description : ''
2589
2676
  const index = existing.indexOf(BRIEF_TAG)
2590
2677
  const base = index < 0 ? existing : existing.slice(0, index).trimEnd()
2591
2678
  const next = (base === '' ? '' : base + '\n') + briefBlock(merged)
2592
- await callExtension('dmt_Project.modifyProjectDescription', [next], timeoutMs)
2593
- persisted = true
2679
+ // The description write is inert on this build (measured: returns false and
2680
+ // the description stays empty), so fall back to a sidecar file next to the
2681
+ // local project directory and verify whichever path was used.
2682
+ let stored = false
2683
+ if (projectUuid !== '') {
2684
+ try {
2685
+ await callExtension('dmt_Project.modifyProjectDescription', [projectUuid, next], timeoutMs)
2686
+ const readBack = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs)
2687
+ stored = String(readBack?.description ?? '').includes(BRIEF_TAG)
2688
+ if (stored) persistVia = 'description'
2689
+ } catch { stored = false }
2690
+ }
2691
+ if (!stored) {
2692
+ const sidecar = specFilePath(project)
2693
+ if (sidecar !== '') {
2694
+ await mkdir(join(sidecar, '..'), { recursive: true })
2695
+ await writeFile(sidecar, JSON.stringify({ projectUuid, brief: merged }, null, 2) + '\n')
2696
+ const check = JSON.parse(await readFile(sidecar, 'utf8'))
2697
+ stored = check !== null && typeof check === 'object' && Object.keys(merged).every(key => check.brief?.[key] === merged[key])
2698
+ if (stored) { persistVia = 'file'; persistPath = sidecar }
2699
+ }
2700
+ }
2701
+ persisted = stored === true
2594
2702
  } catch { persisted = false }
2595
2703
  }
2596
2704
  }
@@ -2604,6 +2712,8 @@ async function executeTool(name, rawArgs, timeoutMs) {
2604
2712
  brief: mergedReport.brief,
2605
2713
  hydratedFromProject: answers === null && designBrief !== null,
2606
2714
  persisted,
2715
+ persistVia,
2716
+ persistPath,
2607
2717
  sections: mergedReport.sections.filter(section => section.questions.some(question => mergedReport.brief[question.id] === undefined)),
2608
2718
  next: mergedReport.ready
2609
2719
  ? 'All critical parameters are known: lay out the schematic, then eda_netlist_erc before routing.'
@@ -3065,7 +3175,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
3065
3175
  (firstFailure instanceof Error ? firstFailure.message : String(firstFailure)) + '; retry: ' + detail + '). ' +
3066
3176
  'polygonStage=' + (polygonOk ? 'ok' : 'failed') + ': ' +
3067
3177
  (polygonOk
3068
- ? 'the app accepted the outline but refused the pour itself, so this is an application limit, not a coordinate problem. ' +
3178
+ ? 'the app accepted the outline but refused the pour itself, and so does the complex polygon the docs require (createComplexPolygon); single and complex polygons, every net/layer/fill combination, all refused, so pour creation is an application limit on this build, not a coordinate problem. ' +
3069
3179
  'Pour by hand in the PCB editor (覆铜 → select GND → top + bottom); eda_design_audit reports it as a warn until then.'
3070
3180
  : 'the outline polygon was rejected too: check that every point is inside the board outline and flat [x1,y1,...].') +
3071
3181
  ' retried=' + retried)
@@ -246,6 +246,13 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
246
246
  "| `eda_delivery_checklist` | 一次串完 ERC + PCB 审计/DRC + Gerber + BOM,逐步 PASS/FAIL;`outPath` 落 Markdown 报告 | 交付前,代替\"我说做完了\" |",
247
247
  "",
248
248
  "**交付口径**:`eda_delivery_checklist` 返回 `ok:true` 才算可以下单;任何一步 FAIL 都要先修,不许口头绕过。报告里带时间戳与计数,可直接给人复核。",
249
+ "### 交付清单怎么读(1.0.1 起)",
250
+ "",
251
+ "- 一次调用会**自己切到原理图再切到 PCB**(`switchDocuments:false` 改按当前文档跑一半,另一半标注跳过原因);",
252
+ "- 返回 `verdict`:`pass`(四步全过)/ `incomplete`(有步骤因文档不对被跳过)/ `fail`(有步骤真失败);`ok` 等价于 `verdict==='pass'`;",
253
+ "- `skippedSteps` 与 `failedSteps` 分开列 —— **别把\"没跑\"当成\"通过\"**;四步全过实测约 **7.5 s**;",
254
+ "- 投板前最后一条命令就用它,`outPath` 落 Markdown 存档。",
255
+ "",
249
256
  "",
250
257
  "**参数持久化**:`eda_design_intake({answers})` 会把答复写进**工程描述**(`[dsh-eda-spec] {...}`),下次会话调用 `eda_design_intake` 会自动读回,不用重新猜;`reset:true` 清空。",
251
258
  "",