From 8d93329fa8435c6f14956c719f4dae79fa14860e Mon Sep 17 00:00:00 2001 From: Poisson Solver Developer Date: Thu, 30 Jul 2026 14:14:50 +0800 Subject: [PATCH 1/2] feat(skills): add workflow intelligence methodology pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distil PCBWorld, pcbGPT, JLCPCB layout practice, and industrial review ops into reusable skills (architecture, layout/DFM/SI, power/clock, constraint SoT, design review, verification, product docs, agent ops) plus kidoc documentation generation — without duplicating analyzer engines. Additive Related Skills links from core kicad skill. Co-authored-by: Cursor --- .agents/skills/clock-tree | 1 + .agents/skills/constraint-management | 1 + .agents/skills/design-review | 1 + .agents/skills/dfm-review | 1 + .agents/skills/eda-prompt-engineering | 1 + .agents/skills/hw-architecture-review | 1 + .agents/skills/knowledge-management | 1 + .agents/skills/multi-agent-coordination | 1 + .agents/skills/pcb-layout-review | 1 + .agents/skills/power-tree | 1 + .agents/skills/product-docs | 1 + .agents/skills/schematic-presentation | 1 + .agents/skills/si-review | 1 + .agents/skills/verification-planning | 1 + CHANGELOG.md | 11 + README.md | 22 + llms.txt | 16 +- skills/clock-tree/SKILL.md | 101 ++ skills/constraint-management/SKILL.md | 79 + .../references/constraint-card-template.md | 11 + skills/design-review/SKILL.md | 75 + skills/dfm-review/SKILL.md | 99 ++ skills/eda-prompt-engineering/SKILL.md | 57 + skills/hw-architecture-review/SKILL.md | 127 ++ .../references/checklist-extended.md | 27 + skills/kicad/SKILL.md | 10 + skills/kidoc/SKILL.md | 253 +++ skills/kidoc/references/document-structure.md | 75 + skills/kidoc/references/rendering-notes.md | 118 ++ skills/kidoc/references/report-ce.md | 97 ++ .../kidoc/references/report-design-review.md | 83 + skills/kidoc/references/report-hdd.md | 150 ++ skills/kidoc/references/report-icd.md | 69 + .../kidoc/references/report-manufacturing.md | 74 + skills/kidoc/scripts/README.md | 88 + .../default_specs/ce_technical_file.json | 113 ++ .../scripts/default_specs/design_review.json | 103 ++ .../scripts/default_specs/emc_report.json | 63 + skills/kidoc/scripts/default_specs/hdd.json | 154 ++ skills/kidoc/scripts/default_specs/icd.json | 74 + .../scripts/default_specs/manufacturing.json | 83 + .../scripts/default_specs/power_analysis.json | 83 + .../default_specs/schematic_review.json | 93 + skills/kidoc/scripts/figures/__init__.py | 25 + .../scripts/figures/generators/__init__.py | 27 + .../scripts/figures/generators/_mpl_common.py | 61 + .../generators/architecture/__init__.py | 320 ++++ .../generators/bus_topology/__init__.py | 223 +++ .../generators/emc_severity/__init__.py | 90 + .../generators/monte_carlo/__init__.py | 102 ++ .../figures/generators/pcb_views/__init__.py | 85 + .../figures/generators/pinout/__init__.py | 575 ++++++ .../figures/generators/power_tree/__init__.py | 657 +++++++ .../generators/schematic_crop/__init__.py | 102 ++ .../generators/schematic_overview/__init__.py | 91 + .../generators/spice_validation/__init__.py | 109 ++ .../generators/thermal_margin/__init__.py | 108 ++ skills/kidoc/scripts/figures/lib/__init__.py | 35 + .../scripts/figures/lib/analysis_helpers.py | 27 + .../kidoc/scripts/figures/lib/color_theme.py | 119 ++ .../scripts/figures/lib/layer_presets.py | 181 ++ .../figures/lib/schematic_constants.py | 43 + skills/kidoc/scripts/figures/lib/styles.py | 434 +++++ .../kidoc/scripts/figures/lib/svg_builder.py | 486 ++++++ skills/kidoc/scripts/figures/lib/svg_embed.py | 514 ++++++ .../kidoc/scripts/figures/lib/svg_to_png.py | 291 ++++ skills/kidoc/scripts/figures/lib/theme.py | 232 +++ skills/kidoc/scripts/figures/registry.py | 82 + .../scripts/figures/renderers/__init__.py | 21 + .../scripts/figures/renderers/_path_setup.py | 38 + skills/kidoc/scripts/figures/renderers/pcb.py | 676 +++++++ .../scripts/figures/renderers/pcb_graphics.py | 754 ++++++++ .../scripts/figures/renderers/sch_graphics.py | 362 ++++ .../scripts/figures/renderers/schematic.py | 1547 +++++++++++++++++ skills/kidoc/scripts/figures/runner.py | 159 ++ skills/kidoc/scripts/kicad_cli.py | 324 ++++ skills/kidoc/scripts/kidoc_datasheet.py | 1058 +++++++++++ skills/kidoc/scripts/kidoc_diagrams.py | 77 + skills/kidoc/scripts/kidoc_docx.py | 294 ++++ skills/kidoc/scripts/kidoc_generate.py | 249 +++ skills/kidoc/scripts/kidoc_html.py | 333 ++++ skills/kidoc/scripts/kidoc_md_parser.py | 284 +++ skills/kidoc/scripts/kidoc_narrative.py | 429 +++++ .../kidoc/scripts/kidoc_narrative_augment.py | 142 ++ .../kidoc/scripts/kidoc_narrative_config.py | 228 +++ .../scripts/kidoc_narrative_extractors.py | 583 +++++++ skills/kidoc/scripts/kidoc_odt.py | 377 ++++ skills/kidoc/scripts/kidoc_orchestrator.py | 208 +++ skills/kidoc/scripts/kidoc_pdf.py | 878 ++++++++++ skills/kidoc/scripts/kidoc_raster.py | 40 + skills/kidoc/scripts/kidoc_scaffold.py | 629 +++++++ skills/kidoc/scripts/kidoc_sections.py | 1195 +++++++++++++ skills/kidoc/scripts/kidoc_spec.py | 314 ++++ skills/kidoc/scripts/kidoc_tables.py | 124 ++ skills/kidoc/scripts/kidoc_templates.py | 120 ++ skills/kidoc/scripts/kidoc_venv.py | 94 + skills/kidoc/scripts/requirements.txt | 6 + skills/knowledge-management/SKILL.md | 63 + skills/multi-agent-coordination/SKILL.md | 57 + skills/pcb-layout-review/SKILL.md | 107 ++ .../references/eco-classes.md | 8 + skills/power-tree/SKILL.md | 85 + skills/product-docs/SKILL.md | 71 + skills/schematic-presentation/SKILL.md | 61 + skills/si-review/SKILL.md | 98 ++ skills/verification-planning/SKILL.md | 63 + 106 files changed, 19596 insertions(+), 1 deletion(-) create mode 120000 .agents/skills/clock-tree create mode 120000 .agents/skills/constraint-management create mode 120000 .agents/skills/design-review create mode 120000 .agents/skills/dfm-review create mode 120000 .agents/skills/eda-prompt-engineering create mode 120000 .agents/skills/hw-architecture-review create mode 120000 .agents/skills/knowledge-management create mode 120000 .agents/skills/multi-agent-coordination create mode 120000 .agents/skills/pcb-layout-review create mode 120000 .agents/skills/power-tree create mode 120000 .agents/skills/product-docs create mode 120000 .agents/skills/schematic-presentation create mode 120000 .agents/skills/si-review create mode 120000 .agents/skills/verification-planning create mode 100644 skills/clock-tree/SKILL.md create mode 100644 skills/constraint-management/SKILL.md create mode 100644 skills/constraint-management/references/constraint-card-template.md create mode 100644 skills/design-review/SKILL.md create mode 100644 skills/dfm-review/SKILL.md create mode 100644 skills/eda-prompt-engineering/SKILL.md create mode 100644 skills/hw-architecture-review/SKILL.md create mode 100644 skills/hw-architecture-review/references/checklist-extended.md create mode 100644 skills/kidoc/SKILL.md create mode 100644 skills/kidoc/references/document-structure.md create mode 100644 skills/kidoc/references/rendering-notes.md create mode 100644 skills/kidoc/references/report-ce.md create mode 100644 skills/kidoc/references/report-design-review.md create mode 100644 skills/kidoc/references/report-hdd.md create mode 100644 skills/kidoc/references/report-icd.md create mode 100644 skills/kidoc/references/report-manufacturing.md create mode 100644 skills/kidoc/scripts/README.md create mode 100644 skills/kidoc/scripts/default_specs/ce_technical_file.json create mode 100644 skills/kidoc/scripts/default_specs/design_review.json create mode 100644 skills/kidoc/scripts/default_specs/emc_report.json create mode 100644 skills/kidoc/scripts/default_specs/hdd.json create mode 100644 skills/kidoc/scripts/default_specs/icd.json create mode 100644 skills/kidoc/scripts/default_specs/manufacturing.json create mode 100644 skills/kidoc/scripts/default_specs/power_analysis.json create mode 100644 skills/kidoc/scripts/default_specs/schematic_review.json create mode 100644 skills/kidoc/scripts/figures/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/_mpl_common.py create mode 100644 skills/kidoc/scripts/figures/generators/architecture/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/bus_topology/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/emc_severity/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/monte_carlo/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/pcb_views/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/pinout/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/power_tree/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/schematic_crop/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/schematic_overview/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/spice_validation/__init__.py create mode 100644 skills/kidoc/scripts/figures/generators/thermal_margin/__init__.py create mode 100644 skills/kidoc/scripts/figures/lib/__init__.py create mode 100644 skills/kidoc/scripts/figures/lib/analysis_helpers.py create mode 100644 skills/kidoc/scripts/figures/lib/color_theme.py create mode 100644 skills/kidoc/scripts/figures/lib/layer_presets.py create mode 100644 skills/kidoc/scripts/figures/lib/schematic_constants.py create mode 100644 skills/kidoc/scripts/figures/lib/styles.py create mode 100644 skills/kidoc/scripts/figures/lib/svg_builder.py create mode 100644 skills/kidoc/scripts/figures/lib/svg_embed.py create mode 100644 skills/kidoc/scripts/figures/lib/svg_to_png.py create mode 100644 skills/kidoc/scripts/figures/lib/theme.py create mode 100644 skills/kidoc/scripts/figures/registry.py create mode 100644 skills/kidoc/scripts/figures/renderers/__init__.py create mode 100644 skills/kidoc/scripts/figures/renderers/_path_setup.py create mode 100644 skills/kidoc/scripts/figures/renderers/pcb.py create mode 100644 skills/kidoc/scripts/figures/renderers/pcb_graphics.py create mode 100644 skills/kidoc/scripts/figures/renderers/sch_graphics.py create mode 100644 skills/kidoc/scripts/figures/renderers/schematic.py create mode 100644 skills/kidoc/scripts/figures/runner.py create mode 100644 skills/kidoc/scripts/kicad_cli.py create mode 100644 skills/kidoc/scripts/kidoc_datasheet.py create mode 100644 skills/kidoc/scripts/kidoc_diagrams.py create mode 100644 skills/kidoc/scripts/kidoc_docx.py create mode 100644 skills/kidoc/scripts/kidoc_generate.py create mode 100644 skills/kidoc/scripts/kidoc_html.py create mode 100644 skills/kidoc/scripts/kidoc_md_parser.py create mode 100644 skills/kidoc/scripts/kidoc_narrative.py create mode 100644 skills/kidoc/scripts/kidoc_narrative_augment.py create mode 100644 skills/kidoc/scripts/kidoc_narrative_config.py create mode 100644 skills/kidoc/scripts/kidoc_narrative_extractors.py create mode 100644 skills/kidoc/scripts/kidoc_odt.py create mode 100644 skills/kidoc/scripts/kidoc_orchestrator.py create mode 100644 skills/kidoc/scripts/kidoc_pdf.py create mode 100644 skills/kidoc/scripts/kidoc_raster.py create mode 100644 skills/kidoc/scripts/kidoc_scaffold.py create mode 100644 skills/kidoc/scripts/kidoc_sections.py create mode 100644 skills/kidoc/scripts/kidoc_spec.py create mode 100644 skills/kidoc/scripts/kidoc_tables.py create mode 100644 skills/kidoc/scripts/kidoc_templates.py create mode 100644 skills/kidoc/scripts/kidoc_venv.py create mode 100644 skills/kidoc/scripts/requirements.txt create mode 100644 skills/knowledge-management/SKILL.md create mode 100644 skills/multi-agent-coordination/SKILL.md create mode 100644 skills/pcb-layout-review/SKILL.md create mode 100644 skills/pcb-layout-review/references/eco-classes.md create mode 100644 skills/power-tree/SKILL.md create mode 100644 skills/product-docs/SKILL.md create mode 100644 skills/schematic-presentation/SKILL.md create mode 100644 skills/si-review/SKILL.md create mode 100644 skills/verification-planning/SKILL.md diff --git a/.agents/skills/clock-tree b/.agents/skills/clock-tree new file mode 120000 index 0000000..6cae635 --- /dev/null +++ b/.agents/skills/clock-tree @@ -0,0 +1 @@ +../../skills/clock-tree \ No newline at end of file diff --git a/.agents/skills/constraint-management b/.agents/skills/constraint-management new file mode 120000 index 0000000..47cc80a --- /dev/null +++ b/.agents/skills/constraint-management @@ -0,0 +1 @@ +../../skills/constraint-management \ No newline at end of file diff --git a/.agents/skills/design-review b/.agents/skills/design-review new file mode 120000 index 0000000..ff8982e --- /dev/null +++ b/.agents/skills/design-review @@ -0,0 +1 @@ +../../skills/design-review \ No newline at end of file diff --git a/.agents/skills/dfm-review b/.agents/skills/dfm-review new file mode 120000 index 0000000..2fdc192 --- /dev/null +++ b/.agents/skills/dfm-review @@ -0,0 +1 @@ +../../skills/dfm-review \ No newline at end of file diff --git a/.agents/skills/eda-prompt-engineering b/.agents/skills/eda-prompt-engineering new file mode 120000 index 0000000..2d21a47 --- /dev/null +++ b/.agents/skills/eda-prompt-engineering @@ -0,0 +1 @@ +../../skills/eda-prompt-engineering \ No newline at end of file diff --git a/.agents/skills/hw-architecture-review b/.agents/skills/hw-architecture-review new file mode 120000 index 0000000..d4fad65 --- /dev/null +++ b/.agents/skills/hw-architecture-review @@ -0,0 +1 @@ +../../skills/hw-architecture-review \ No newline at end of file diff --git a/.agents/skills/knowledge-management b/.agents/skills/knowledge-management new file mode 120000 index 0000000..22f50ab --- /dev/null +++ b/.agents/skills/knowledge-management @@ -0,0 +1 @@ +../../skills/knowledge-management \ No newline at end of file diff --git a/.agents/skills/multi-agent-coordination b/.agents/skills/multi-agent-coordination new file mode 120000 index 0000000..cc68ad1 --- /dev/null +++ b/.agents/skills/multi-agent-coordination @@ -0,0 +1 @@ +../../skills/multi-agent-coordination \ No newline at end of file diff --git a/.agents/skills/pcb-layout-review b/.agents/skills/pcb-layout-review new file mode 120000 index 0000000..721afd3 --- /dev/null +++ b/.agents/skills/pcb-layout-review @@ -0,0 +1 @@ +../../skills/pcb-layout-review \ No newline at end of file diff --git a/.agents/skills/power-tree b/.agents/skills/power-tree new file mode 120000 index 0000000..c8b7d13 --- /dev/null +++ b/.agents/skills/power-tree @@ -0,0 +1 @@ +../../skills/power-tree \ No newline at end of file diff --git a/.agents/skills/product-docs b/.agents/skills/product-docs new file mode 120000 index 0000000..99889a4 --- /dev/null +++ b/.agents/skills/product-docs @@ -0,0 +1 @@ +../../skills/product-docs \ No newline at end of file diff --git a/.agents/skills/schematic-presentation b/.agents/skills/schematic-presentation new file mode 120000 index 0000000..d03fe52 --- /dev/null +++ b/.agents/skills/schematic-presentation @@ -0,0 +1 @@ +../../skills/schematic-presentation \ No newline at end of file diff --git a/.agents/skills/si-review b/.agents/skills/si-review new file mode 120000 index 0000000..63f080d --- /dev/null +++ b/.agents/skills/si-review @@ -0,0 +1 @@ +../../skills/si-review \ No newline at end of file diff --git a/.agents/skills/verification-planning b/.agents/skills/verification-planning new file mode 120000 index 0000000..c5a71fe --- /dev/null +++ b/.agents/skills/verification-planning @@ -0,0 +1 @@ +../../skills/verification-planning \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cef0ef0..ead31c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased — workflow intelligence skills + +### Added +- Methodology skills for architecture, layout review, power/clock trees, constraint/SoT management, DFM/SI signoff, design-review charters, verification planning, schematic presentation, product docs, and agent ops (prompting, multi-agent, knowledge management) +- `kidoc` engineering documentation skill +- Cross-links from core `kicad` Related Skills table + +### Changed +- `llms.txt` / README discoverability for methodology skills +- Default remotes on TeinkBR fork: `origin` → TeinkBR, `upstream` → aklofas + # Changelog All notable changes to kicad-happy are documented here. diff --git a/README.md b/README.md index a2dc123..e065ff0 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,28 @@ Or set up the [GitHub Action](github-action.md) and get automated analysis on ev **Optional Python packages**: `requests` (better HTTP), `playwright` (JS-heavy datasheet sites), `pdftotext` (PDF text extraction). + +## Workflow Intelligence (methodology) + +These skills teach **review and ops methodology**. They hand off to `kicad` / `emc` / `jlcpcb` analyzers rather than duplicating parsers. + +| Skill | Purpose | +|-------|---------| +| [hw-architecture-review](skills/hw-architecture-review/SKILL.md) | System topology, interface ownership, dual-SoT | +| [pcb-layout-review](skills/pcb-layout-review/SKILL.md) | Layout critique & ECO classes | +| [power-tree](skills/power-tree/SKILL.md) / [clock-tree](skills/clock-tree/SKILL.md) | Power & clock domain reviews | +| [constraint-management](skills/constraint-management/SKILL.md) | Freeze, SoT, write gates | +| [dfm-review](skills/dfm-review/SKILL.md) / [si-review](skills/si-review/SKILL.md) | Fab GO & tiered SI | +| [design-review](skills/design-review/SKILL.md) | Chartered reviews, honest score ceilings | +| [schematic-presentation](skills/schematic-presentation/SKILL.md) | Industrial drawing polish | +| [product-docs](skills/product-docs/SKILL.md) | Commercial / CEM documentation | +| [verification-planning](skills/verification-planning/SKILL.md) | Lab & bring-up plans | +| [eda-prompt-engineering](skills/eda-prompt-engineering/SKILL.md) | Grounded agent prompts | +| [multi-agent-coordination](skills/multi-agent-coordination/SKILL.md) | Overnight / parallel HW agents | +| [knowledge-management](skills/knowledge-management/SKILL.md) | Journals, scorecards, indexes | +| [kidoc](skills/kidoc/SKILL.md) | Generate HDD/ICD/CE/manufacturing docs from CAD | + + ## ✅ KiCad version support | Version | Schematic | PCB | Gerber | diff --git a/llms.txt b/llms.txt index 97c8c6e..c407db2 100644 --- a/llms.txt +++ b/llms.txt @@ -2,7 +2,7 @@ > AI-powered design review skills for KiCad electronics projects -A suite of 11 skills that turn an AI coding agent into an electronics design assistant. Structured Python analysis scripts (zero dependencies, Python 3.10+ stdlib only) for KiCad 5-10 projects. Works with Claude Code, OpenAI Codex, Cursor, and Gemini CLI. +A suite of 25+ skills that turn an AI coding agent into an electronics design assistant. Structured Python analysis scripts (zero dependencies, Python 3.10+ stdlib only) for KiCad 5-10 projects. Works with Claude Code, OpenAI Codex, Cursor, and Gemini CLI. Capabilities: schematic analysis (40+ subcircuit detectors), PCB layout review, EMC pre-compliance (44 rules), SPICE simulation, thermal analysis, component sourcing (DigiKey/Mouser/LCSC/element14), BOM management, manufacturing prep (JLCPCB/PCBWay), and professional documentation generation. @@ -33,3 +33,17 @@ Install: `/plugin marketplace add aklofas/kicad-happy` (Claude Code), `gemini sk - [JLCPCB](https://github.com/aklofas/kicad-happy/blob/main/skills/jlcpcb/SKILL.md): BOM/CPL format, assembly constraints, basic vs extended parts - [PCBWay](https://github.com/aklofas/kicad-happy/blob/main/skills/pcbway/SKILL.md): Turnkey assembly rules, MPN-based sourcing + + +## Workflow Intelligence Skills (methodology) + +- [Hardware Architecture Review](skills/hw-architecture-review/SKILL.md): Topology, ownership, dual-SoT, commercial reference match +- [PCB Layout Review](skills/pcb-layout-review/SKILL.md): Placement/stackup/ECO classes beyond analyzer JSON +- [Power Tree](skills/power-tree/SKILL.md) / [Clock Tree](skills/clock-tree/SKILL.md): Rail and REFCLK methodology +- [Constraint Management](skills/constraint-management/SKILL.md): Copper freeze, SoT, agent write gates +- [DFM Review](skills/dfm-review/SKILL.md) / [SI Review](skills/si-review/SKILL.md): Fab GO gate and tiered SI signoff +- [Design Review](skills/design-review/SKILL.md): Chartered reviews and honest EQ ceilings +- [Schematic Presentation](skills/schematic-presentation/SKILL.md) / [Product Docs](skills/product-docs/SKILL.md): Drawing polish and commercial manuals +- [Verification Planning](skills/verification-planning/SKILL.md): Lab and bring-up gates +- [EDA Prompt Engineering](skills/eda-prompt-engineering/SKILL.md) / [Multi-Agent Coordination](skills/multi-agent-coordination/SKILL.md) / [Knowledge Management](skills/knowledge-management/SKILL.md): Agent ops +- [kidoc](skills/kidoc/SKILL.md): Engineering documentation generation from CAD diff --git a/skills/clock-tree/SKILL.md b/skills/clock-tree/SKILL.md new file mode 100644 index 0000000..044904e --- /dev/null +++ b/skills/clock-tree/SKILL.md @@ -0,0 +1,101 @@ +--- +name: clock-tree +description: >- + Review clock and REFCLK trees: oscillator choice, HCSL/LVDS/CMOS distribution, + AC-coupling, series termination, fanout, SSC, and multi-load PCIe clocking. Use + for REFCLK reviews, crystal load-cap checks, clock domain maps, or "who drives + the clock" disputes on PCIe/FPGA/embedded boards. +--- + +# Clock Tree Review + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | Crystal / oscillator detectors, load-cap checks | +| `si-review` | Length, impedance, coupling of clock traces | +| `emc` | Clock harmonic / edge radiation rules | +| `hw-architecture-review` | Clock ownership in system architecture | +| `verification-planning` | Scope REFCLK bring-up | +| `datasheets` | Oscillator / buffer integration rules | + +## Purpose + +Map every clock source → buffer/fanout → loads with electrical standard, +termination, and verification method. Separate **architectural correctness** +from **jitter compliance** (lab-only). + +## When to invoke + +- PCIe REFCLK ownership debates (host vs onboard XO vs clock gen) +- Dual-slot / dual-load clocking +- Crystal load-cap validation +- Before claiming link-ready in EQ scorecards + +## Inputs + +- Schematic SoT + `analyze_schematic.py` JSON +- Architecture clocking decision (common clock / SRNS / SRIS) +- Oscillator and receiver datasheets (or `datasheets` cache) +- PCB lengths for clock nets (optional `analyze_pcb`) + +## Outputs + +1. Clock tree diagram (text) +2. Load table: source, format, termination, sheet refs +3. Risk list → verification IDs +4. Non-claims (jitter, SSC legality, Gen timing) + +## Decision process + +1. Inventory oscillators, crystals, clock gens, FPGA/SoC clock pins. +2. Classify: local XTAL, distributed REFCLK, recovered clock, PLL cascade. +3. For PCIe REFCLK: common clock vs SRNS/SRIS; who sources; series-R / AC-cap policy. +4. Fanout: single load vs dual-slot; buffer needed? +5. Spread-spectrum: intentional or forbidden for the link class. +6. EMC: edge rate and harmonic risk → `emc`. +7. Lab: probe points; do not claim compliance from schematic. + +### Correct vs anti-pattern + +| Correct | Anti-pattern | +|---------|--------------| +| Document Option A onboard XO + series R | Swap host/onboard clocks silently | +| Dual-load flagged for scope | Assume HCSL drives N loads forever | +| MEASURE amplitude/duty | EQ credit for “looks terminated” | + +## Checklist + +- [ ] Source and format (HCSL/LVDS/CMOS/LVPECL) stated +- [ ] Each load listed with termination +- [ ] Coupling caps / series R refs documented +- [ ] Unused clock pins handled (PD/PU/NC per datasheet) +- [ ] Crystal CL / load caps checked (analyzer + datasheet) +- [ ] Dual-load or long stubs flagged +- [ ] MEASURE items for amplitude / duty / jitter + +## Failure modes + +| Mode | Root cause | Impact | Improvement | +|------|------------|--------|-------------| +| Host vs onboard ambiguity | Spec skim | Enum fail | Ownership table | +| Missing AC-coupling | Pattern copy error | Link flaky | Datasheet gate | +| Fake Gen timing claim | Score pressure | Misleading GO | Non-claims | + +## Industrial references + +- PCIe Base Spec clocking chapters (cite revision) +- HCSL buffer vendor app notes +- JLCPCB guide: group clock circuitry on dedicated schematic pages +- pcbGPT semantic failures often omit support passives on clock/power pins + +## Example + +**MatriQ-like:** Onboard Y1 HCSL REFCLK to RC and EP connectors with series resistors R20/R21 policy documented; dual-load → Phase 7-D scope gate; no jitter EQ points until measured. + +## Acceptance criteria + +- Clock tree diagram + load table delivered +- Explicit non-claim on jitter compliance without measurement +- Handoff IDs created in verification-planning when dual-load or long routes exist diff --git a/skills/constraint-management/SKILL.md b/skills/constraint-management/SKILL.md new file mode 100644 index 0000000..4812227 --- /dev/null +++ b/skills/constraint-management/SKILL.md @@ -0,0 +1,79 @@ +--- +name: constraint-management +description: >- + Manage hardware design constraints and sources of truth: copper freeze, dual + schematic SoT, UUID/file affinity, net classes, design rules, and write-gate + policies for AI agents. Use when locking fab packages, resolving SoT conflicts, + session guardian / CAD trust, "do not edit copper", hierarchy Sheetfile policy, + or constraint-guided agent workflows. +--- + +# Constraint & SoT Management + +Implements the industrial lesson: **automation is constrained optimization** +(Quilter; PCBWorld engine feedback; PCBSchemaGen constraint-guided synthesis). + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | Read net classes, rules, board metadata | +| `pcb-layout-review` | Apply fab rules in layout critique | +| `dfm-review` | Map constraints to fab capability | +| `multi-agent-coordination` | Enforce write gates across agents | +| `design-review` | Score honesty when constraints block EQ | + +## Purpose + +Maintain a written constraint set that agents and humans must obey: what file is +SoT, what may be edited, what invalidates fab GO. + +## Constraint categories + +1. **File SoT** — PCB path; customer sch; archival sch +2. **Edit class** — docs / presentation / copper / hierarchy pins +3. **Electrical rules** — clearance, width, diff pair, via +4. **Process** — fab stackup, finish, min drill +5. **Trust** — LIVE GUI must match CLI file (UUID affinity) +6. **Claims** — what EQ/compliance language is forbidden without evidence + +## Decision process + +1. Publish a constraint card at campaign start (markdown table). +2. On every edit request: classify impact → allowed? +3. Copper freeze after fab GO until evidence demands ECO. +4. Dual-SoT: name roles; ban false ERC proofs. +5. Prefer engine/API edits over regex sexp rewrites. +6. Log constraint waivers with owner + expiry. + +## Checklist + +- [ ] PCB SoT path absolute or repo-relative, single +- [ ] Schematic customer SoT vs archival named +- [ ] Copper freeze flag + CAM zip hash/name +- [ ] Net classes exist for HS / power / default +- [ ] Fab rules imported or cited +- [ ] Agent write mode + profile documented +- [ ] LIVE==CLI procedure defined +- [ ] Waiver log location defined + +## Failure modes + +| Mode | MatriQ lesson | +|------|---------------| +| Hollow hier ERC=0 as proof | Dual-SoT policy required | +| Sibling Sheetfile surprise ERC | Experiment with revert path | +| Score chasing copper ECO | Freeze + lab-first EQ>95 | +| Regex CAD edits | Ban; use MCP/API | + +## Industrial references + +- PCBWorld engine-grounded DRC loop (arXiv:2607.05915) +- PCBSchemaGen constraint-guided synthesis + KG verifier (arXiv:2602.00510) +- Quilter constrained-optimization framing +- Agentic EDA handoff contracts (arXiv:2606.19795) + +## Acceptance criteria + +- Constraint card exists and is cited by agents before edits +- Fab-invalidating actions require explicit human unlock language diff --git a/skills/constraint-management/references/constraint-card-template.md b/skills/constraint-management/references/constraint-card-template.md new file mode 100644 index 0000000..ca21ac1 --- /dev/null +++ b/skills/constraint-management/references/constraint-card-template.md @@ -0,0 +1,11 @@ +# Constraint card template + +| ID | Constraint | Value | Owner | Unlocks | +|----|------------|-------|-------|---------| +| C-PCB | PCB SoT | `board_4layer.kicad_pcb` | HW lead | — | +| C-SCH-CUST | Customer sch | `sheets/00_….kicad_sch` | HW lead | — | +| C-SCH-ARCH | Archival sch | `flat.kicad_sch` | HW lead | — | +| C-CU | Copper freeze | ON after fab GO | HW lead | Evidence ECO | +| C-FAB | CAM package | `…_uncond_go.zip` | MFG | Re-export | +| C-WRITE | Agent write | MCP write / API only | Tools | — | +| C-CLAIM | EQ>95 | Needs lab SI + silicon | Lead | MEASURE | diff --git a/skills/design-review/SKILL.md b/skills/design-review/SKILL.md new file mode 100644 index 0000000..16e3c83 --- /dev/null +++ b/skills/design-review/SKILL.md @@ -0,0 +1,75 @@ +--- +name: design-review +description: >- + Design review methodology and charters: evidence-backed findings, EQ + scorecards, severity classes, verification basis, false-positive triage, and + honest ceilings. Use for formal design reviews, principal reviews, scorecard + updates, or "ready for fab" verdicts that must not invent lab scores. +--- + +# Design Review Methodology + +Orchestrates other skills into a **chartered** review. Extends the `kicad` Design +Review Contract with industrial scoring honesty (MatriQ EQ lessons). + +## Related Skills + +| Skill | When | +|-------|------| +| `kicad` + `emc` + `spice` | Analyzer evidence | +| `hw-architecture-review` | Topology | +| `pcb-layout-review` / `dfm-review` / `si-review` | Domain chapters | +| `verification-planning` | Residual gates | +| `multi-agent-coordination` | Parallel reviewers | +| `kidoc` | Design review package export | + +## Purpose + +Ship a review that a principal engineer trusts: verdict, blockers, skipped +analyses, verification basis, and score **ceilings**. + +## Review charter (minimum) + +1. Scope + SoT files +2. Required analyzers list (run or explicit skip) +3. Severity model (blocker / major / minor / note) +4. Verification basis labels (analyzer / datasheet / lab / conjecture) +5. False-positive triage step +6. EQ or readiness score with formula + forbidden claims + +## Decision process + +1. Read charter; refuse uncoped “looks good”. +2. Gather analyzer evidence; triage FPs. +3. Domain chapters via specialized skills. +4. Score only dimensions with evidence; cap SI/PI/compliance without lab. +5. Separate docs/process Δ from physical Δ. +6. Verdict: GO / CONDITIONAL / NO-GO + next human actions (≤3). + +## Checklist + +- [ ] SoT + LIVE==CLI +- [ ] Analyzer matrix completed +- [ ] Blockers table +- [ ] Skipped analyses disclosed +- [ ] FP triage notes +- [ ] Score ceiling rationale +- [ ] Fab GO impact explicit + +## Failure modes + +- Stopping after schematic+PCB JSON dump +- Claiming verified without datasheet +- EQ>95 from overnight cosmetics (MatriQ blocked by SI+silicon) +- Agent timeout residue scored as progress + +## Industrial references + +- kicad-happy Design Review Contract +- pcbGPT: expert review still required (pass@1 hard tasks 0.72) +- Agentic EDA handoff validity (arXiv:2606.19795) + +## Acceptance criteria + +- Principal can sign from the package +- Physical vs docs deltas separated diff --git a/skills/dfm-review/SKILL.md b/skills/dfm-review/SKILL.md new file mode 100644 index 0000000..809a582 --- /dev/null +++ b/skills/dfm-review/SKILL.md @@ -0,0 +1,99 @@ +--- +name: dfm-review +description: >- + Design-for-manufacturing gate review: fab capability alignment, annular rings, + drill, solder mask, assembly constraints, CAM package integrity, and traveler + notes. Use before ordering boards, when validating JLCPCB/PCBWay readiness, + interpreting DFM scores, or protecting a frozen fab GO package. +--- + +# DFM Review Gate + +Complements `jlcpcb` / `pcbway` (order formats) and `kicad` DFM scoring. This +skill is the **manufacturing release gate**. + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | `analyze_pcb.py` DFM, gerbers, tombstoning | +| `jlcpcb` / `pcbway` | Capability + BOM/CPL | +| `pcb-layout-review` | Layout ECO classification | +| `bom` | MPN lock / basic vs extended parts | +| `product-docs` | Traveler / assembly SOP | +| `constraint-management` | Freeze CAM after GO | + +## Purpose + +Answer: **May we order this CAM package?** with evidence, residuals, and +non-blocking debt listed honestly. + +## When to invoke + +- Pre-order fab review +- After copper freeze when confirming GO still holds +- CEM handoff / traveler authoring +- DFM score interpretation disputes + +## Inputs + +- PCB SoT + Gerber/drill set or fab zip +- DRC + `analyze_pcb` DFM JSON +- Fab capability card +- BOM with MPNs / JLCPCB basic vs extended +- Prior GO statement if any + +## Outputs + +- DFM gate report: GO / CONDITIONAL / NO-GO +- Blocker list vs accept-with-notes +- Traveler critical populate list +- CAM pin (path, commit, or hash) + +## Decision process + +1. Identify fab + process (layers, Cu weight, finish, min trace/space). +2. Run DRC + `analyze_pcb` DFM + gerber check if exports exist. +3. Classify findings: order-blocker vs accept-with-notes. +4. Assembly: polarity, paste, thermal pads, fiducials, panel notes. +5. Protect frozen zip: do not regenerate casually after GO. +6. Traveler must list populate-critical parts (option resistors, XO, sense). + +### JLCPCB anchors + +From [JLCPCB Complete PCB Layout Guide](https://jlcpcb.com/blog/complete-pcb-layout-guide): align width/space/via/annular ring to the fab’s current capability table before routing; 4-layer S-G-P-S is the default cost-effective EMI-friendly stackup. + +## Checklist + +- [ ] Fab capability cited (date + URL or saved PDF) +- [ ] Shorts = 0; unconnected policy stated +- [ ] Min drill / annular ring within process +- [ ] Finish note (ENIG etc.) for fingers if needed +- [ ] BOM basic/extended split understood +- [ ] CAM zip name + hash or commit pinned +- [ ] Assembly traveler critical populate list +- [ ] Courtyard density accepted or ECO’d + +## Failure modes + +| Mode | Impact | Fix | +|------|--------|-----| +| Regen Gerbers after “harmless” edit | Silent fab drift | Freeze + re-DRC policy | +| Presentation score ≠ fab readiness | False delay or false GO | Separate scorecards | +| Extended-part surprise | Schedule slip | `bom` lifecycle audit | + +## Industrial references + +- JLCPCB / PCBWay capability pages +- IPC-A-600 / IPC-A-610 (cite class if claimed) +- kicad-happy jlcpcb skill assembly constraints + +## Example + +**Unconditional GO:** CLI shorts=0, DRC residuals accepted, zip `jlcpcb_production_uncond_go` pinned, traveler lists Y1/R20/R21 populate — silk courtyard WARN non-blocking. + +## Acceptance criteria + +- Explicit GO / CONDITIONAL / NO-GO +- Blockers empty for unconditional GO +- Residuals documented without hiding diff --git a/skills/eda-prompt-engineering/SKILL.md b/skills/eda-prompt-engineering/SKILL.md new file mode 100644 index 0000000..81c945d --- /dev/null +++ b/skills/eda-prompt-engineering/SKILL.md @@ -0,0 +1,57 @@ +--- +name: eda-prompt-engineering +description: >- + Prompt patterns for EDA/hardware agents: grounded tool use, generate-execute- + repair loops, evidence tags, non-claims, and anti-hallucination contracts. Use + when writing agent system prompts, overnight master briefs, skill prompts, or + debugging agents that invent DRC/EQ/compliance results. +--- + +# EDA Prompt Engineering + +## Related Skills + +| Skill | Role | +|-------|------| +| `multi-agent-coordination` | Who gets which prompt | +| `constraint-management` | Hard rules injected into prompts | +| `design-review` | Output contract | +| `kicad` | Tool surface agents must call | + +## Purpose + +Encode prompts that force **engine grounding**, iterative repair, and honest +uncertainty — lessons from pcbGPT, PCBSchemaGen, PCBWorld, and MatriQ overnight. + +## Prompt contract (inject) + +``` +1. Tools before memory: call analyzers/MCP; do not invent nets/DRC. +2. Generate → execute → repair; never one-shot board files. +3. Tag every claim: [ANALYZER] [DATASHEET] [LAB] [CONJECTURE]. +4. Forbidden: regex rewrite of .kicad_sch/.kicad_pcb; fake EQ lab scores. +5. Copper freeze / SoT paths: . +6. On failure: revert, log, do not score partial residue as EQ gain. +7. End with ≤3 human actions and explicit non-claims. +``` + +## Patterns + +| Pattern | Use | +|---------|-----| +| Constrained checklist | Reviews | +| Evidence table | Findings | +| Dual-SoT honesty | Hierarchy campaigns | +| Tiered SI language | H0–H3 | +| Morning brief | Overnight handoff | + +## Anti-patterns + +- “Make EQ >95 overnight” without lab unlocks +- Open-loop “route all nets” LLM dumps (PCBWorld: interactive wins) +- Semantic validation without deterministic ERC/execution (pcbGPT stack) + +## Acceptance criteria + +- Prompt includes SoT + freeze + claim tags +- Success metrics measurable without hallucinated lab diff --git a/skills/hw-architecture-review/SKILL.md b/skills/hw-architecture-review/SKILL.md new file mode 100644 index 0000000..8b8a48c --- /dev/null +++ b/skills/hw-architecture-review/SKILL.md @@ -0,0 +1,127 @@ +--- +name: hw-architecture-review +description: >- + Review board-level hardware architecture: functional blocks, hierarchy, + interface ownership, dual schematic SoT, commercial reference pattern match, + and freeze decisions. Use when the user asks for architecture review, system + block diagram critique, hierarchy decisions, PCIe/backplane ownership, + "is this the right topology", commercial riser comparison, dual-SoT policy, + or Phase 1–2 knowledge/architecture gates before layout polish. +--- + +# Hardware Architecture Review + +Methodology skill for **system-level** hardware architecture. Complements `kicad` +(which parses CAD) — this skill decides whether the topology and ownership model +are correct before spending cycles on cosmetics or copper. + +## Related Skills + +| Skill | Handoff | +|-------|---------| +| `kicad` | Extract nets, symbols, hierarchy sheets, power/clock detectors | +| `power-tree` | Deep rail ownership / sequencing | +| `clock-tree` | REFCLK / oscillator / distribution | +| `constraint-management` | Freeze, SoT, UUID affinity after architecture lock | +| `design-review` | Score architecture into EQ / charter | +| `product-docs` | Capture accepted architecture in customer-facing docs | +| `kidoc` | Generate HDD / ICD scaffolds from CAD | + +**Do not** re-implement schematic parsing here — call `kicad` analyzers first. + +## Purpose + +Produce an evidence-backed architecture verdict: Accept / Accept-with-notes / +Revise / Freeze-copper-and-defer. Map every critical interface to an owner +(RC, EP, adapter, external PSU) and a verification gate. + +## When to invoke + +- New product intake or Phase 1–2 of a multi-phase PCB campaign +- Hierarchy vs flat schematic decisions +- Passive duct vs switch/retimer vs active redriver debates +- Commercial reference mapping (e.g. powered riser patterns) +- Before any copper ECO that changes topology + +## Inputs + +- Design intent / requirements (rails, lane count, Gen, form factor) +- Schematic SoT paths (hierarchical root + flat archival if dual-SoT) +- PCB SoT path and fab GO status +- Prior decision log / risk register +- Optional: commercial reference notes or competitor tear-downs +- Analyzer JSON from `analyze_schematic.py` (block inventory) + +## Outputs + +1. **Architecture review memo** (markdown) with: + - Block diagram (text/mermaid) of functional partitions + - Interface ownership table (signal, source, sink, direction, domain) + - Hierarchy / SoT recommendation + - Commercial pattern match score (if reference given) + - Open risks → verification plan IDs +2. **Decision records** for any Accept/Revise calls +3. Explicit **non-claims** (e.g. Gen3 not Gen4; no PCI-SIG compliance) + +## Decision process + +1. **Inventory blocks** from schematic + docs (MCU/FPGA, connectors, power, clocks, sidebands). +2. **Classify each interface:** power / high-speed / sideband / debug / future. +3. **Assign ownership** — who drives, who terminates, who injects power. +4. **Check topology class:** + - Passive copper duct (no switch) vs active retime/switch + - External power injection vs host-only power + - Common clock vs HCSL from host +5. **Hierarchy policy:** customer PDF SoT vs netlist/fab SoT — allow dual-SoT only with written policy (see MatriQ `DUAL_SOT_POLICY` pattern). +6. **Reference match** (optional): score 8–10 industrial patterns; gaps → measure plan, not impulsive ECO. +7. **Gate:** architecture Accept before layout beauty or copper ECO. + +### Correct vs anti-pattern + +| Practice | Correct | Anti-pattern | +|----------|---------|--------------| +| Dual-SoT | Named roles + honesty banners | Claiming hier ERC=0 as silicon proof when sheets are hollow | +| Commercial refs | Pattern score + gap→lab | Copying marketing claims into compliance language | +| Future sheets | Explicit FUTURE / not-in-fab | Shipping stub sheets without demotion | +| Copper | Freeze when fab GO holds | Topology ECO for documentation score | + +## Checklist + +- [ ] Functional blocks listed with sheet/file anchors +- [ ] Every critical net class has an owner and domain +- [ ] Power injection policy stated (host / external / both) +- [ ] Clocking model stated (source, loads, AC-couple/series-R if any) +- [ ] Sidebands (PERST, WAKE, PRESENT, SMBUS) ownership clear +- [ ] Hierarchy/SoT policy written if dual files exist +- [ ] Future/optional partitions demoted in title or banner +- [ ] Non-claims listed (compliance, Gen, cold-plug, etc.) +- [ ] Gaps mapped to `verification-planning` IDs — not silent + +## Failure modes + +| Mode | Root cause | Mitigation | +|------|------------|------------| +| Hollow hierarchy ERC=0 treated as electrical proof | Sheetfile path quirk | Dual-SoT policy + sibling-load experiment documented | +| Architecture churn after fab GO | Score chasing | Copper freeze under constraint-management | +| Missing sideband ownership | Spec skim | Interface table mandatory | +| Over-claiming compliance | LLM fluency | Non-claims section required | + +## Industrial references + +- IPC-2221 design planning principles +- PCI Express Card Electromechanical / Base Spec (lane/sideband ownership — cite revision used) +- JLCPCB layout guide: schematic as absolute truth before layout ([JLCPCB Complete PCB Layout Guide](https://jlcpcb.com/blog/complete-pcb-layout-guide)) +- pcbGPT: grounded generate–execute–repair; drafts need expert review (King et al., arXiv:2606.01188) +- Agentic EDA handoff contracts (arXiv:2606.19795) + +## Example (compressed) + +**Input:** Passive PCIe ×8 RC↔EP interposer, external 12 V brick, common REFCLK oscillator. +**Verdict:** Accept Dolphin-class pattern (passive dual-slot, external power). Gaps: 3V3VAUX unmeasured, Z unmeasured → lab plans, not copper ECO. Dual-SoT: hierarchical customer PDF + flat archival netlist. + +## Acceptance criteria + +- Senior HW engineer can approve/reject from the memo alone +- No CAD mutation required to complete the review +- Every open gap has a verification owner or explicit DEFER +- Does not duplicate `kicad` detector output as architecture truth without ownership interpretation diff --git a/skills/hw-architecture-review/references/checklist-extended.md b/skills/hw-architecture-review/references/checklist-extended.md new file mode 100644 index 0000000..214acb3 --- /dev/null +++ b/skills/hw-architecture-review/references/checklist-extended.md @@ -0,0 +1,27 @@ +# Extended architecture checklist + +## Interface ownership columns + +| Signal / bus | Electrical class | Driver | Receiver | Power domain | Sheet | Verify gate | +|--------------|------------------|--------|----------|--------------|-------|-------------| +| | HS / SB / PWR / DBG | | | | | | + +## Hierarchy decision tree + +1. Single flat schematic sufficient for customer PDF? → Prefer flat + clear titles. +2. Need industrial multi-sheet presentation? → Hierarchical **index** OK if children exported **directly**. +3. Need hierarchical netlist ERC as electrical proof? → Sibling Sheetfile + coherent pins/labels; budget a dedicated milestone. +4. Never score hollow-index ERC=0 as silicon readiness. + +## Commercial pattern axes (example) + +1. Passive vs switch/retimer +2. External power injection +3. Dual-slot rail distribution +4. 3V3VAUX presence +5. REFCLK sourcing model +6. PERST direction +7. Tx/Rx seating / lane swap class +8. SI practice / length +9. Bring-up observability (TP) +10. Documented max power diff --git a/skills/kicad/SKILL.md b/skills/kicad/SKILL.md index 44cb00f..d69bab9 100644 --- a/skills/kicad/SKILL.md +++ b/skills/kicad/SKILL.md @@ -31,6 +31,16 @@ description: >- | `pcbway` | Alternative PCB fabrication & assembly | | `spice` | SPICE simulation verification of detected subcircuits | | `emc` | EMC pre-compliance risk analysis — consumes schematic + PCB analyzer output | +| `hw-architecture-review` | System topology, ownership, dual-SoT, reference match | +| `pcb-layout-review` | Layout methodology / ECO classes beyond analyzer JSON | +| `power-tree` / `clock-tree` | Rail and clock ownership methodology | +| `constraint-management` | Copper freeze, SoT, agent write gates | +| `dfm-review` / `si-review` | Manufacturing gate and tiered SI signoff | +| `design-review` | Chartered review + honest EQ ceilings | +| `schematic-presentation` / `product-docs` | Drawing polish + commercial docs | +| `verification-planning` | Lab/bring-up gates | +| `eda-prompt-engineering` / `multi-agent-coordination` / `knowledge-management` | Agent ops | +| `kidoc` | Engineering documentation generation from CAD | **Handoff guidance:** Use this skill to parse schematics/PCBs and extract structured data. Hand off to `bom` for BOM enrichment, pricing, and ordering. Hand off to `digikey`/`mouser`/`lcsc`/`element14` for part searches and datasheet fetching. Hand off to `jlcpcb`/`pcbway` for fabrication ordering and DFM rule validation. **Always run `spice`** for simulation verification during design reviews when any SPICE simulator is installed (check with `which ngspice ltspice xyce`). **Always run `emc`** for EMC pre-compliance risk analysis during design reviews when both schematic and PCB analysis are available. These are not optional — skipping them leaves value-computation errors and EMC risks undetected. diff --git a/skills/kidoc/SKILL.md b/skills/kidoc/SKILL.md new file mode 100644 index 0000000..a3d890d --- /dev/null +++ b/skills/kidoc/SKILL.md @@ -0,0 +1,253 @@ +--- +name: kidoc +description: >- + Generate professional engineering documentation from KiCad projects — Hardware + Design Descriptions (HDD), CE Technical Files, Interface Control Documents + (ICD), Design Review Packages, and Manufacturing Transfer Packages. Auto-runs + schematic, PCB, EMC, and thermal analyses; renders schematic and PCB SVGs + with subsystem cropping, focus dimming, net highlighting, and pin-net + annotation; generates power tree, bus topology, and architecture block + diagrams. Produces styled PDF with cover pages, TOC, and vector SVG + embedding. Markdown source of truth — human-editable, version-controllable. + Use for "generate documentation", "create report", "HDD", "CE technical + file", "design review package", "ICD", "render schematic", "render layout", + "generate block diagram", "manufacturing package", "generate PDF", or + "custom report". +--- + +# kidoc — Engineering Documentation Skill + +Generate professional engineering documentation from KiCad project files. + +## Quick Start + +One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: + +```bash +python3 skills/kidoc/scripts/kidoc_scaffold.py \ + --project-dir /path/to/kicad/project \ + --type hdd \ + --output reports/HDD.md +``` + +This auto-detects `.kicad_sch` and `.kicad_pcb` files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. + +To produce a PDF: + +```bash +python3 skills/kidoc/scripts/kidoc_generate.py \ + --project-dir /path/to/kicad/project \ + --doc reports/HDD.md \ + --format pdf +``` + +Creates `reports/.venv/` automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). + +## Workflow + +1. **Generate scaffold** — `kidoc_scaffold.py` auto-runs all available analyses, renders schematics, generates diagrams, and writes the markdown scaffold. +2. **Fill narratives** — The agent reads the scaffold and writes engineering prose for each `` placeholder. The engineer reviews and edits. +3. **Regenerate** — On re-run, data sections between `` markers update from fresh analysis; user-written narrative content is preserved. +4. **Render output** — `kidoc_generate.py` produces PDF, HTML, DOCX, or ODT. + +## Document Types + +| Type | Name | Key Sections | +|------|------|-------------| +| `hdd` | Hardware Design Description | System overview, power, signals, analog, thermal, EMC, PCB, mechanical, BOM, test, compliance | +| `ce_technical_file` | CE Technical File | Product ID, essential requirements, harmonized standards, risk assessment, Declaration of Conformity | +| `design_review` | Design Review Package | Review summary (cross-analyzer scores), findings, action items | +| `icd` | Interface Control Document | Interface list, per-connector pinout details, electrical characteristics | +| `manufacturing` | Manufacturing Transfer Package | Assembly overview, PCB fab notes, assembly instructions, test procedures | +| `schematic_review` | Schematic Review Report | System overview, power, signals, analog, BOM, schematic appendix | +| `power_analysis` | Power Analysis Report | Power design, thermal, EMC, BOM | +| `emc_report` | EMC Pre-Compliance Report | EMC analysis, compliance, schematic appendix | + +## Custom Reports + +Use `--spec` to generate reports with arbitrary section ordering: + +```bash +python3 skills/kidoc/scripts/kidoc_scaffold.py \ + --project-dir . --spec my-report.json --output reports/custom.md +``` + +Spec format (JSON): + +```json +{ + "type": "custom", + "title": "USB Interface Analysis", + "sections": [ + {"id": "front_matter", "type": "front_matter"}, + {"id": "signal_interfaces", "type": "signal_interfaces"}, + {"id": "bom", "type": "bom_summary"} + ] +} +``` + +Each section's `type` must match a known section type (same names used in the document types table above). The `id` field is a unique key for that section instance. + +To see the full default spec for any built-in type: + +```bash +python3 skills/kidoc/scripts/kidoc_spec.py --expand hdd +python3 skills/kidoc/scripts/kidoc_spec.py --list +``` + +The `--spec` flag also works with `kidoc_generate.py` (uses the spec title as fallback project name). + +## Schematic and PCB Rendering + +Rendering is integrated into the figure generation engine. The orchestrator and scaffold automatically render schematics and PCB views as part of document generation: + +```bash +# Generate all figures (diagrams + schematic/PCB renders) from analysis JSON +python3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --output reports/figures/ + +# Full orchestration with spec, analysis, and project files +python3 skills/kidoc/scripts/kidoc_orchestrator.py --analysis schematic.json \ + --project-dir . --output reports/figures/ +``` + +The figure generators support: full-sheet rendering (root + all sub-sheets), subsystem cropping (`focus_refs` in spec sections), net highlighting, pin-level net annotation, and all PCB layer presets. These options are configured in the document spec or passed through the analysis dict. + +Rendering features available through the generator framework: +- **Crop**: Focus on a subsystem bounding box around specific component refs +- **Focus/dim**: Show focused components at full opacity, dim the rest to 15% +- **Highlight nets**: Color-trace specific nets via BFS +- **Pin nets**: Annotate pin-level net names at pin tips + +For direct programmatic access, use `figures.renderers`: +```python +from figures.renderers import render_schematic, render_pcb +render_schematic('design.kicad_sch', 'output/', crop_refs=['R1', 'R2'], highlight_nets=['VCC']) +render_pcb('board.kicad_pcb', 'output/', preset_name='assembly-front') +``` + +Layer presets: + +| Preset | Shows | +|--------|-------| +| `assembly-front` | Front silk, fab, pads, outline | +| `assembly-back` | Back silk, fab, pads, outline (mirrored) | +| `routing-front` | Front copper, pads, vias, outline | +| `routing-back` | Back copper, pads, vias, outline | +| `routing-all` | All copper layers, pads, vias, zones | +| `power` | Power planes, vias, zone outlines | + +Additional options: `--highlight-nets`, `--crop-refs`, `--crop x,y,w,h`, `--mirror`, `--overlay annotations.json` (callout boxes with leader lines). + +## Block Diagrams + +```bash +python3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --all --output diagrams/ +python3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --power-tree --output diagrams/ +python3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --bus-topology --output diagrams/ +python3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --architecture --output diagrams/ +``` + +Generated from schematic analysis JSON. Power trees show regulator topology with inductor values, capacitor summaries, and output voltages. + +## Output Formats + +| Format | SVG Handling | Dependencies | +|--------|-------------|------| +| **Markdown** | Image references | Zero-dep | +| **HTML** | Inlined as vector | Zero-dep | +| **PDF** | Vector via svglib, custom converter fallback, raster fallback | Venv (`reports/.venv/`) | +| **DOCX** | Rasterized to 300 DPI PNG | Venv | +| **ODT** | Rasterized to 300 DPI PNG | Venv | + +PDF output includes a styled cover page, table of contents, formatted tables with alternating rows, and vector SVG diagrams. + +## Configuration + +Report settings live in `.kicad-happy.json` under the `"reports"` key. Config files cascade: `~/.kicad-happy.json` (user-level defaults, e.g. company branding) merges with project-level config. + +```jsonc +{ + "project": { + "name": "Widget Board", + "number": "HW-2024-042", + "revision": "1.2", + "company": "Acme Electronics", + "author": "Jane Smith", + "market": "eu" + }, + "reports": { + "classification": "Company Confidential", + "documents": [ + {"type": "hdd", "output": "HDD-{project}-{rev}", "formats": ["pdf", "docx"]} + ], + "branding": { + "logo": "templates/logo.png", + "header_left": "{company}", + "header_right": "{number} Rev {rev}" + } + } +} +``` + +## Writing Narratives + +After generating a scaffold, fill the narrative placeholder sections with engineering prose. + +### Workflow + +1. Run the context builder to get focused data for each section: + ```bash + python3 skills/kidoc/scripts/kidoc_narrative.py \ + --analysis analysis/schematic.json \ + --section power_design + ``` + Or build contexts for all narrative sections at once: + ```bash + python3 skills/kidoc/scripts/kidoc_narrative.py \ + --analysis analysis/schematic.json \ + --report reports/HDD.md + ``` + +2. For each section, read the context and write prose that: + - Explains **why**, not just **what** — engineering rationale, tradeoffs + - References specific component values and part numbers + - Uses quantitative language ("2.3ms hold-up time" not "adequate capacitance") + - Flags deviations from datasheet recommendations + - References SPICE validation results when available + +3. Replace the italic placeholder `*[...]*` in the markdown with real prose. + +4. On regeneration, data tables update automatically. Review narratives for consistency with any changed data. + +### Style Guide + +Write as a senior EE explaining to a peer: +- Lead with the key finding or decision +- Support with specific numbers from the analysis +- Note any risks or deviations +- Keep paragraphs to 3-5 sentences +- Don't repeat data that's already in tables + +## Requirements + +- **Python 3.9+** with `python3-venv` (for PDF/DOCX/ODT generation) +- **KiCad schematic file** (`.kicad_sch`, KiCad 6+) — for SVG rendering +- **Optional:** Analysis JSONs are auto-generated from `.kicad_sch`/`.kicad_pcb`; pre-generated JSONs in `analysis/` (or the path configured in `.kicad-happy.json`) are used if present. Generated figures (diagrams, schematic SVGs) are placed in `reports/figures/` for git tracking + +## Limitations + +- Schematic and PCB renderers support KiCad 6+ formats only (`.kicad_sch`, `.kicad_pcb`) +- Narrative sections require the agent or manual authoring — the scaffold provides structure and data, not prose +- SPICE simulation results require manual simulation setup (not auto-run by scaffold) +- PDF vector SVG embedding uses svglib when available; falls back to raster if svglib cannot parse a particular SVG + +## Related Skills + +| Skill | Relationship | +|-------|-------------| +| `kicad` | Produces schematic/PCB/thermal analysis JSON consumed by scaffolds | +| `emc` | Produces EMC analysis JSON for EMC sections | +| `spice` | SPICE simulation results appear in analog design sections | +| `bom` | BOM data appears in BOM summary sections | + +Run the `kicad` skill's analyzers first, then `emc` and `spice` if available. The scaffold auto-runs `kicad` and `emc` analyses when source files are present, so manual pre-analysis is only needed for SPICE. diff --git a/skills/kidoc/references/document-structure.md b/skills/kidoc/references/document-structure.md new file mode 100644 index 0000000..81b8a1f --- /dev/null +++ b/skills/kidoc/references/document-structure.md @@ -0,0 +1,75 @@ +# Document Structure Reference + +How kidoc generates documents from analysis data. + +## Architecture + +1. **Auto-run analyses** -- kidoc_scaffold.py runs schematic, PCB, EMC, and thermal analyzers automatically when source files exist. No manual pre-step needed. +2. **Skip-not-stub** -- sections that need missing data (e.g., thermal section without thermal JSON) return `None` and are omitted entirely. No empty stubs clutter the output. +3. **Markdown source of truth** -- the generated `.md` file is the canonical document. Edit it directly; re-scaffold and reconcile via git diff. +4. **Professional output** -- markdown renders to PDF (via ReportLab/svglib), DOCX, or ODT. CE Technical Files default to PDF/A. + +## Section Registry + +The scaffold maps section IDs to generator functions. Each document type declares its section list in `kidoc_templates.py`. Config can override the list per document. + +## Hardware Design Description (HDD) + +1. Front Matter -- title, revision history, TOC, acronyms +2. Executive Summary -- auto-generated from analysis stats (components, MCU, rails, PCB, EMC score) +3. System Overview -- architecture diagram, component stats table +4. Power System Design -- power tree diagram, regulator table, decoupling table +5. Signal Interfaces -- I2C/SPI/UART/CAN buses, level shifters +6. Analog Design -- voltage dividers, filters, crystals, opamps +7. Thermal Analysis -- thermal score, per-component Tj table (skipped without thermal data) +8. EMC Considerations -- risk score, findings by category, critical/high detail (skipped without EMC data) +9. PCB Design Details -- layer stats, board dimensions (skipped without PCB data) +10. Mechanical / Environmental -- board outline, mounting (skipped without PCB data) +11. BOM Summary -- full BOM table +12. Test and Debug -- debug interfaces, test strategy +13. Compliance and Standards -- target market, EMC test plan (skipped without EMC data + no market) +14. Appendix: Schematics -- rendered SVGs per sheet + +## CE Technical File + +1. Front Matter +2. Product Identification -- product info table from config +3. Essential Requirements -- directive-to-standard mapping (LVD, EMC, RoHS; RED if RF detected) +4. Harmonized Standards -- configurable list (defaults: EN 55032, 55035, 62368-1, IEC 63000) +5. Risk Assessment -- hazard table auto-populated from thermal/EMC/ESD data +6. EMC Analysis -- shared section +7. Thermal Analysis -- shared section +8. Declaration of Conformity -- pre-filled DoC template +9. BOM Summary +10. Appendix: Schematics + +## Interface Control Document (ICD) + +1. Front Matter +2. System Overview -- architecture diagram, stats +3. Interface List -- connector summary table from ESD audit +4. Connector Details -- per-connector pinout/signal table (filterable via config `connectors` field) +5. Signal Interfaces -- bus analysis, level shifters +6. Electrical Characteristics -- voltage domain table + +## Design Review Package + +1. Front Matter +2. Executive Summary +3. Review Summary -- cross-analyzer scorecard (fab gate, EMC, thermal, BOM completeness) +4. System Overview +5. Power System Design +6. EMC Analysis -- shared section +7. Thermal Analysis -- shared section +8. BOM Summary +9. Action Items -- editable tracking table + +## Manufacturing Transfer Package + +1. Front Matter +2. Assembly Overview -- component count breakdown (SMD/THT/DNP) +3. BOM Summary +4. PCB Fabrication Notes -- fab parameters with sensible defaults (skipped without PCB data) +5. Assembly Instructions -- narrative placeholder +6. Production Test Procedures -- 5-step checklist template +7. Appendix: Schematics diff --git a/skills/kidoc/references/rendering-notes.md b/skills/kidoc/references/rendering-notes.md new file mode 100644 index 0000000..0f5e7cd --- /dev/null +++ b/skills/kidoc/references/rendering-notes.md @@ -0,0 +1,118 @@ +# Schematic SVG Rendering Notes + +## Coordinate System + +KiCad uses two coordinate conventions that must be reconciled: + +- **Schematic placement** (`.kicad_sch` top level): millimetres, Y-down (screen convention) +- **Symbol library internals** (`lib_symbols`): millimetres, Y-up (math convention) + +The transform pipeline for each symbol graphic primitive: + +1. **Mirror** (before rotation): if `mirror_x`, negate Y; if `mirror_y`, negate X +2. **Rotate**: standard 2D rotation by component angle (CCW positive, degrees) +3. **Translate + Y-flip**: `abs_x = cx + rpx`, `abs_y = cy - rpy` + +This matches `compute_pin_positions()` in `analyze_schematic.py`. + +## SVG Constraints (svglib compatibility) + +The SVGs must be parseable by svglib for downstream PDF embedding via ReportLab. +svglib has limited SVG support, so we constrain output to: + +- Inline style attributes only (no ` + + +{chr(10).join(body_parts)} + +""" + + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(page) + + return output_path + + +def main(): + parser = argparse.ArgumentParser(description='Generate HTML from markdown') + parser.add_argument('--input', '-i', required=True, + help='Input markdown file') + parser.add_argument('--output', '-o', required=True, + help='Output HTML file') + parser.add_argument('--config', '-c', default='{}', + help='JSON config string or path to config file') + args = parser.parse_args() + + if os.path.isfile(args.config): + with open(args.config) as f: + config = json.load(f) + else: + config = json.loads(args.config) + + output = generate_html(args.input, args.output, config) + print(output, file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_md_parser.py b/skills/kidoc/scripts/kidoc_md_parser.py new file mode 100644 index 0000000..1f9784e --- /dev/null +++ b/skills/kidoc/scripts/kidoc_md_parser.py @@ -0,0 +1,284 @@ +"""Markdown parser for kidoc document generation. + +Parses the subset of markdown used by kidoc scaffolds into a list of +typed document elements. Used by both kidoc_pdf.py and kidoc_docx.py. + +Supports: headings, paragraphs (with bold/italic/code), tables, images, +code blocks, horizontal rules, bullet lists, numbered lists, blockquotes. +HTML comments are skipped silently. + +Zero external dependencies — Python stdlib only. +""" + +from __future__ import annotations + +import re +from typing import Any + + +# ====================================================================== +# Element types +# ====================================================================== + +def heading(level: int, text: str) -> dict: + return {'type': 'heading', 'level': level, 'text': text} + +def paragraph(runs: list[dict]) -> dict: + return {'type': 'paragraph', 'runs': runs} + +def table(headers: list[str], rows: list[list[str]], + alignments: list[str]) -> dict: + return {'type': 'table', 'headers': headers, 'rows': rows, + 'alignments': alignments} + +def image(alt: str, path: str) -> dict: + return {'type': 'image', 'alt': alt, 'path': path} + +def code_block(code: str, language: str = '') -> dict: + return {'type': 'code_block', 'code': code, 'language': language} + +def horizontal_rule() -> dict: + return {'type': 'hr'} + +def bullet_list(items: list[list[dict]]) -> dict: + return {'type': 'bullet_list', 'items': items} + +def numbered_list(items: list[list[dict]]) -> dict: + return {'type': 'numbered_list', 'items': items} + +def blockquote(runs: list[dict]) -> dict: + return {'type': 'blockquote', 'runs': runs} + + +# ====================================================================== +# Inline parsing (bold, italic, code, links) +# ====================================================================== + +_INLINE_PATTERNS = [ + # Order matters: longer/more specific patterns first + (re.compile(r'\*\*\*(.+?)\*\*\*'), 'bold_italic'), + (re.compile(r'\*\*(.+?)\*\*'), 'bold'), + (re.compile(r'\*(.+?)\*'), 'italic'), + (re.compile(r'`(.+?)`'), 'code'), + (re.compile(r'\[(.+?)\]\((.+?)\)'), 'link'), +] + + +def parse_inline(text: str) -> list[dict]: + """Parse inline formatting into a list of runs. + + Each run: {'text': str, 'bold': bool, 'italic': bool, 'code': bool, 'link': str|None} + """ + runs: list[dict] = [] + pos = 0 + + while pos < len(text): + best_match = None + best_start = len(text) + best_pattern = None + + for pattern, ptype in _INLINE_PATTERNS: + m = pattern.search(text, pos) + if m and m.start() < best_start: + best_match = m + best_start = m.start() + best_pattern = ptype + + if best_match is None: + # No more inline formatting — rest is plain text + remaining = text[pos:] + if remaining: + runs.append(_run(remaining)) + break + + # Add plain text before the match + if best_start > pos: + runs.append(_run(text[pos:best_start])) + + # Add the formatted run + if best_pattern == 'bold_italic': + runs.append(_run(best_match.group(1), bold=True, italic=True)) + elif best_pattern == 'bold': + runs.append(_run(best_match.group(1), bold=True)) + elif best_pattern == 'italic': + runs.append(_run(best_match.group(1), italic=True)) + elif best_pattern == 'code': + runs.append(_run(best_match.group(1), code=True)) + elif best_pattern == 'link': + runs.append(_run(best_match.group(1), link=best_match.group(2))) + + pos = best_match.end() + + return runs if runs else [_run(text)] + + +def _run(text: str, bold: bool = False, italic: bool = False, + code: bool = False, link: str | None = None) -> dict: + return {'text': text, 'bold': bold, 'italic': italic, + 'code': code, 'link': link} + + +# ====================================================================== +# Block-level parsing +# ====================================================================== + +_HEADING_RE = re.compile(r'^(#{1,6})\s+(.+)$') +_IMAGE_RE = re.compile(r'^!\[([^\]]*)\]\(([^)]+)\)\s*$') +_TABLE_SEP_RE = re.compile(r'^\|[\s:|-]+\|\s*$') +_TABLE_ROW_RE = re.compile(r'^\|(.+)\|\s*$') +_CODE_FENCE_RE = re.compile(r'^```(\w*)\s*$') +_HR_RE = re.compile(r'^---+\s*$') +_BULLET_RE = re.compile(r'^[-*+]\s+(.+)$') +_NUMBERED_RE = re.compile(r'^\d+\.\s+(.+)$') +_BLOCKQUOTE_RE = re.compile(r'^>\s*(.*)$') +_COMMENT_RE = re.compile(r'^$') + + +def parse_markdown(text: str) -> list[dict]: + """Parse markdown text into a list of document elements.""" + lines = text.split('\n') + elements: list[dict] = [] + i = 0 + n = len(lines) + + while i < n: + line = lines[i] + stripped = line.strip() + + # Skip empty lines + if not stripped: + i += 1 + continue + + # Skip HTML comments (AUTO markers, NARRATIVE markers) + if _COMMENT_RE.match(stripped): + i += 1 + continue + + # Code fence + m = _CODE_FENCE_RE.match(stripped) + if m: + lang = m.group(1) + code_lines = [] + i += 1 + while i < n and not _CODE_FENCE_RE.match(lines[i].strip()): + code_lines.append(lines[i]) + i += 1 + i += 1 # skip closing fence + elements.append(code_block('\n'.join(code_lines), lang)) + continue + + # Heading + m = _HEADING_RE.match(stripped) + if m: + level = len(m.group(1)) + elements.append(heading(level, m.group(2))) + i += 1 + continue + + # Horizontal rule + if _HR_RE.match(stripped): + elements.append(horizontal_rule()) + i += 1 + continue + + # Image (standalone on a line) + m = _IMAGE_RE.match(stripped) + if m: + elements.append(image(m.group(1), m.group(2))) + i += 1 + continue + + # Table (starts with | and next line is separator) + if _TABLE_ROW_RE.match(stripped) and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1].strip()): + headers = _parse_table_row(stripped) + alignments = _parse_table_alignments(lines[i + 1].strip()) + rows = [] + i += 2 # skip header and separator + while i < n and _TABLE_ROW_RE.match(lines[i].strip()): + rows.append(_parse_table_row(lines[i].strip())) + i += 1 + elements.append(table(headers, rows, alignments)) + continue + + # Bullet list + m = _BULLET_RE.match(stripped) + if m: + items = [] + while i < n and _BULLET_RE.match(lines[i].strip()): + item_m = _BULLET_RE.match(lines[i].strip()) + items.append(parse_inline(item_m.group(1))) + i += 1 + elements.append(bullet_list(items)) + continue + + # Numbered list + m = _NUMBERED_RE.match(stripped) + if m: + items = [] + while i < n and _NUMBERED_RE.match(lines[i].strip()): + item_m = _NUMBERED_RE.match(lines[i].strip()) + items.append(parse_inline(item_m.group(1))) + i += 1 + elements.append(numbered_list(items)) + continue + + # Blockquote + m = _BLOCKQUOTE_RE.match(stripped) + if m: + quote_lines = [] + while i < n and _BLOCKQUOTE_RE.match(lines[i].strip()): + qm = _BLOCKQUOTE_RE.match(lines[i].strip()) + quote_lines.append(qm.group(1)) + i += 1 + elements.append(blockquote(parse_inline(' '.join(quote_lines)))) + continue + + # Default: paragraph (collect consecutive non-empty, non-special lines) + para_lines = [] + while i < n: + l = lines[i].strip() + if not l: + break + if (_HEADING_RE.match(l) or _HR_RE.match(l) or _IMAGE_RE.match(l) + or _CODE_FENCE_RE.match(l) or _COMMENT_RE.match(l) + or (_TABLE_ROW_RE.match(l) and i + 1 < n + and _TABLE_SEP_RE.match(lines[i + 1].strip())) + or _BULLET_RE.match(l) or _NUMBERED_RE.match(l)): + break + para_lines.append(l) + i += 1 + if para_lines: + elements.append(paragraph(parse_inline(' '.join(para_lines)))) + + return elements + + +# ====================================================================== +# Table helpers +# ====================================================================== + +def _parse_table_row(line: str) -> list[str]: + """Parse a markdown table row into cells.""" + # Strip leading/trailing pipes and split + line = line.strip() + if line.startswith('|'): + line = line[1:] + if line.endswith('|'): + line = line[:-1] + return [cell.strip() for cell in line.split('|')] + + +def _parse_table_alignments(sep_line: str) -> list[str]: + """Parse table separator to determine column alignments.""" + cells = _parse_table_row(sep_line) + alignments = [] + for cell in cells: + cell = cell.strip() + if cell.startswith(':') and cell.endswith(':'): + alignments.append('center') + elif cell.endswith(':'): + alignments.append('right') + else: + alignments.append('left') + return alignments diff --git a/skills/kidoc/scripts/kidoc_narrative.py b/skills/kidoc/scripts/kidoc_narrative.py new file mode 100644 index 0000000..c732816 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_narrative.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Narrative context builder for kidoc engineering documentation. + +Assembles focused context packages for each narrative section in a report. +The LLM reads this context and writes +engineering prose. This module does NOT generate prose — it prepares the +data the LLM needs. + +Usage: + # Context for one section + python3 kidoc_narrative.py --analysis schematic.json --section power_design + + # Contexts for all NARRATIVE sections in a report + python3 kidoc_narrative.py --analysis schematic.json --report reports/HDD.md + + # With additional data sources + python3 kidoc_narrative.py --analysis schematic.json --section power_design \ + --spec spec.json --emc emc.json --thermal thermal.json --pcb pcb.json + +Zero external dependencies — Python 3.8+ stdlib only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +from kidoc_narrative_config import ( + SECTION_TITLES, + WRITING_GUIDANCE, + SECTION_QUESTIONS, +) +from kidoc_narrative_extractors import SECTION_DATA_EXTRACTORS +from kidoc_narrative_augment import ( + build_datasheet_notes, + build_spice_notes, + build_cross_references, +) + + +# ====================================================================== +# Main context builder +# ====================================================================== + +def build_narrative_context(section_id: str, section_type: str, + analysis: dict, + spec: dict | None = None, + extractions: dict | None = None, + spice_data: dict | None = None, + existing_narrative: str | None = None, + emc_data: dict | None = None, + thermal_data: dict | None = None, + pcb_data: dict | None = None) -> dict: + """Build focused context for LLM narrative generation. + + Returns a dict with all the data the LLM needs to write prose for + one section. The LLM should NOT see the full analysis JSON — only + this focused slice. + """ + # Audience/tone from spec + audience = '' + tone = 'technical' + questions = [] + if spec: + audience = spec.get('audience', '') + tone = spec.get('tone', 'technical') + # Per-section questions from spec override defaults + for s in spec.get('sections', []): + if s.get('id') == section_id or s.get('type') == section_type: + questions = s.get('questions', []) + break + + if not questions: + questions = list(SECTION_QUESTIONS.get(section_type, [])) + + # Extract focused data + extractor = SECTION_DATA_EXTRACTORS.get(section_type) + if extractor: + data_summary = extractor( + analysis, + emc_data=emc_data, + thermal_data=thermal_data, + pcb_data=pcb_data, + ) + else: + data_summary = 'No data extractor available for this section type.' + + # Datasheet notes + datasheet_notes = build_datasheet_notes(section_type, analysis, extractions) + + # SPICE notes + spice_notes = build_spice_notes(section_type, analysis, spice_data) + + # Cross-references + cross_refs = build_cross_references( + section_type, analysis, + emc_data=emc_data, + thermal_data=thermal_data, + pcb_data=pcb_data, + ) + + # Writing guidance + guidance = WRITING_GUIDANCE.get(section_type, '') + + return { + 'section_id': section_id, + 'section_type': section_type, + 'section_title': SECTION_TITLES.get(section_type, section_type), + 'audience': audience, + 'tone': tone, + 'questions': questions, + 'data_summary': data_summary, + 'datasheet_notes': datasheet_notes, + 'spice_notes': spice_notes, + 'existing_text': existing_narrative or '', + 'cross_references': cross_refs, + 'writing_guidance': guidance, + } + + +# ====================================================================== +# Batch context builder +# ====================================================================== + +# Pattern matching narrative placeholders in scaffold output. +# The scaffold emits italic placeholder text: *[hint text]* +# Nearby headings identify the section. +_NARRATIVE_PLACEHOLDER_RE = re.compile(r'^\*\[.+?\]\*$') + +# Map heading text to section types +_HEADING_TO_SECTION = { + 'executive summary': 'executive_summary', + 'system overview': 'system_overview', + 'power system design': 'power_design', + 'signal interfaces': 'signal_interfaces', + 'analog design': 'analog_design', + 'thermal analysis': 'thermal_analysis', + 'emc considerations': 'emc_analysis', + 'pcb design details': 'pcb_design', + 'mechanical / environmental': 'mechanical_environmental', + 'bom summary': 'bom_summary', + 'test and debug': 'test_debug', + 'compliance and standards': 'compliance', + # CE + 'product identification': 'ce_product_identification', + 'essential requirements': 'ce_essential_requirements', + 'risk assessment': 'ce_risk_assessment', + # Design Review + 'review summary': 'review_summary', + 'action items': 'review_action_items', + # ICD + 'interface list': 'icd_interface_list', + 'connector details': 'icd_connector_details', + 'electrical characteristics': 'icd_electrical_characteristics', + # Manufacturing + 'assembly overview': 'mfg_assembly_overview', + 'pcb fabrication notes': 'mfg_pcb_fab_notes', + 'assembly instructions': 'mfg_assembly_instructions', + 'production test procedures': 'mfg_test_procedures', +} + + +def _detect_sections_from_markdown(md_text: str) -> list[dict]: + """Detect narrative sections from markdown scaffold. + + Returns list of {'section_type': str, 'existing_text': str|None} + for each section that has a narrative placeholder or where the user + has already written content. + """ + lines = md_text.split('\n') + sections = [] + current_section = None + + for line in lines: + stripped = line.strip() + + # Track headings to determine current section + if stripped.startswith('#'): + heading_text = stripped.lstrip('#').strip() + # Remove numbering like "2. " or "## 3. " + heading_clean = re.sub(r'^\d+\.\s*', '', heading_text).lower() + section_type = _HEADING_TO_SECTION.get(heading_clean) + if section_type: + current_section = section_type + + # Detect narrative placeholder + if current_section and _NARRATIVE_PLACEHOLDER_RE.match(stripped): + sections.append({ + 'section_type': current_section, + 'existing_text': None, + }) + + return sections + + +def build_all_narrative_contexts(report_md_path: str, + analysis: dict, + spec: dict | None = None, + extractions: dict | None = None, + spice_data: dict | None = None, + emc_data: dict | None = None, + thermal_data: dict | None = None, + pcb_data: dict | None = None) -> list[dict]: + """Build contexts for all narrative sections in a report. + + Reads the markdown file, finds all narrative placeholder sections, + and builds context for each. + """ + with open(report_md_path, 'r', encoding='utf-8') as f: + md_text = f.read() + + detected = _detect_sections_from_markdown(md_text) + + contexts = [] + for det in detected: + section_type = det['section_type'] + ctx = build_narrative_context( + section_id=section_type, + section_type=section_type, + analysis=analysis, + spec=spec, + extractions=extractions, + spice_data=spice_data, + existing_narrative=det.get('existing_text'), + emc_data=emc_data, + thermal_data=thermal_data, + pcb_data=pcb_data, + ) + contexts.append(ctx) + + return contexts + + +# ====================================================================== +# Output formatting +# ====================================================================== + +def format_context(ctx: dict) -> str: + """Format a narrative context dict as readable text for the LLM.""" + lines = [] + lines.append(f"=== NARRATIVE CONTEXT: {ctx['section_title']} ===") + lines.append(f"Section: {ctx['section_id']} (type: {ctx['section_type']})") + + if ctx.get('audience'): + lines.append(f"Audience: {ctx['audience']}") + if ctx.get('tone'): + lines.append(f"Tone: {ctx['tone']}") + + lines.append("") + lines.append("--- DATA SUMMARY ---") + lines.append(ctx.get('data_summary', '(none)')) + + if ctx.get('datasheet_notes'): + lines.append("") + lines.append("--- DATASHEET NOTES ---") + lines.append(ctx['datasheet_notes']) + + if ctx.get('spice_notes'): + lines.append("") + lines.append("--- SPICE VALIDATION ---") + lines.append(ctx['spice_notes']) + + if ctx.get('cross_references'): + lines.append("") + lines.append("--- CROSS-REFERENCES ---") + lines.append(ctx['cross_references']) + + if ctx.get('existing_text'): + lines.append("") + lines.append("--- EXISTING NARRATIVE (rewrite if stale) ---") + lines.append(ctx['existing_text']) + + if ctx.get('questions'): + lines.append("") + lines.append("--- QUESTIONS TO ADDRESS ---") + for q in ctx['questions']: + lines.append(f" - {q}") + + if ctx.get('writing_guidance'): + lines.append("") + lines.append("--- WRITING GUIDANCE ---") + lines.append(ctx['writing_guidance']) + + lines.append("") + return '\n'.join(lines) + + +# ====================================================================== +# CLI +# ====================================================================== + +def main(): + parser = argparse.ArgumentParser( + description='Build narrative context for kidoc report sections') + parser.add_argument('--analysis', required=True, + help='Path to schematic analysis JSON') + parser.add_argument('--section', + help='Build context for one section type') + parser.add_argument('--report', + help='Build contexts for all narrative sections in a markdown file') + parser.add_argument('--spec', + help='Document spec JSON (for audience/tone/questions)') + parser.add_argument('--emc', + help='Path to EMC analysis JSON') + parser.add_argument('--thermal', + help='Path to thermal analysis JSON') + parser.add_argument('--pcb', + help='Path to PCB analysis JSON') + parser.add_argument('--extractions', + help='Path to datasheet extractions directory or JSON') + parser.add_argument('--spice', + help='Path to SPICE results JSON') + args = parser.parse_args() + + # Load analysis + with open(args.analysis, 'r', encoding='utf-8') as f: + analysis = json.load(f) + + # Load optional data sources + spec = None + if args.spec: + with open(args.spec, 'r', encoding='utf-8') as f: + spec = json.load(f) + + emc_data = None + if args.emc: + with open(args.emc, 'r', encoding='utf-8') as f: + emc_data = json.load(f) + else: + # Try to find emc.json alongside the analysis + emc_path = os.path.join(os.path.dirname(args.analysis), 'emc.json') + if os.path.isfile(emc_path): + with open(emc_path, 'r', encoding='utf-8') as f: + emc_data = json.load(f) + + thermal_data = None + if args.thermal: + with open(args.thermal, 'r', encoding='utf-8') as f: + thermal_data = json.load(f) + else: + thermal_path = os.path.join(os.path.dirname(args.analysis), 'thermal.json') + if os.path.isfile(thermal_path): + with open(thermal_path, 'r', encoding='utf-8') as f: + thermal_data = json.load(f) + + pcb_data = None + if args.pcb: + with open(args.pcb, 'r', encoding='utf-8') as f: + pcb_data = json.load(f) + else: + pcb_path = os.path.join(os.path.dirname(args.analysis), 'pcb.json') + if os.path.isfile(pcb_path): + with open(pcb_path, 'r', encoding='utf-8') as f: + pcb_data = json.load(f) + + spice_data = None + if args.spice: + with open(args.spice, 'r', encoding='utf-8') as f: + spice_data = json.load(f) + + extractions = None + if args.extractions: + ext_path = args.extractions + if os.path.isfile(ext_path): + with open(ext_path, 'r', encoding='utf-8') as f: + extractions = json.load(f) + elif os.path.isdir(ext_path): + # Load all JSONs from directory keyed by filename stem + extractions = {} + for fname in os.listdir(ext_path): + if fname.endswith('.json'): + fpath = os.path.join(ext_path, fname) + try: + with open(fpath, 'r', encoding='utf-8') as f: + extractions[fname.replace('.json', '')] = json.load(f) + except (json.JSONDecodeError, OSError): + pass + + # Build and output context + if args.section: + ctx = build_narrative_context( + section_id=args.section, + section_type=args.section, + analysis=analysis, + spec=spec, + extractions=extractions, + spice_data=spice_data, + emc_data=emc_data, + thermal_data=thermal_data, + pcb_data=pcb_data, + ) + print(format_context(ctx)) + + elif args.report: + if not os.path.isfile(args.report): + print(f"Error: report file not found: {args.report}", file=sys.stderr) + sys.exit(1) + contexts = build_all_narrative_contexts( + report_md_path=args.report, + analysis=analysis, + spec=spec, + extractions=extractions, + spice_data=spice_data, + emc_data=emc_data, + thermal_data=thermal_data, + pcb_data=pcb_data, + ) + if not contexts: + print("No narrative sections found in report.", file=sys.stderr) + sys.exit(0) + for ctx in contexts: + print(format_context(ctx)) + + else: + # No --section or --report: list available section types + print("Available section types:") + for stype in sorted(SECTION_DATA_EXTRACTORS.keys()): + title = SECTION_TITLES.get(stype, stype) + print(f" {stype:30s} {title}") + print(f"\n{len(SECTION_DATA_EXTRACTORS)} extractors available.") + print("\nUse --section or --report to generate context.") + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_narrative_augment.py b/skills/kidoc/scripts/kidoc_narrative_augment.py new file mode 100644 index 0000000..f26c5f0 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_narrative_augment.py @@ -0,0 +1,142 @@ +"""Narrative augmentation — datasheet, SPICE, and cross-reference builders. + +These functions enrich narrative context with supplemental data sources +beyond the primary schematic analysis JSON. + +Zero external dependencies — Python 3.8+ stdlib only. +""" + +from __future__ import annotations + + +# ====================================================================== +# Datasheet notes +# ====================================================================== + +def build_datasheet_notes(section_type: str, analysis: dict, + extractions: dict | None) -> str: + """Build datasheet notes relevant to a section.""" + if not extractions: + return '' + + parts = [] + + if section_type == 'power_design': + regs = [f for f in analysis.get('findings', []) + if f.get('detector') == 'detect_power_regulators'] + for r in regs: + value = r.get('value', '') + ref = r.get('ref', '') + # Look for extraction by MPN or value + for key in (r.get('mpn', ''), value): + if key and key in extractions: + ext = extractions[key] + parts.append(f"{ref} ({value}): {_summarize_extraction(ext)}") + + elif section_type == 'analog_design': + opamps = [f for f in analysis.get('findings', []) + if f.get('detector') == 'detect_opamp_circuits'] + for o in opamps: + value = o.get('value', '') + ref = o.get('ref', '') + for key in (o.get('mpn', ''), value): + if key and key in extractions: + ext = extractions[key] + parts.append(f"{ref} ({value}): {_summarize_extraction(ext)}") + + return '\n'.join(parts) + + +def _summarize_extraction(ext: dict) -> str: + """One-line summary of a datasheet extraction.""" + parts = [] + if ext.get('voltage_ratings'): + parts.append(f"Vmax={ext['voltage_ratings']}") + if ext.get('operating_temp'): + parts.append(f"Temp={ext['operating_temp']}") + if ext.get('package'): + parts.append(f"Package={ext['package']}") + return '; '.join(parts) if parts else '(extraction available)' + + +# ====================================================================== +# SPICE notes +# ====================================================================== + +def build_spice_notes(section_type: str, analysis: dict, + spice_data: dict | None) -> str: + """Build SPICE simulation notes relevant to a section.""" + if not spice_data: + return '' + + results = spice_data.get('simulation_results', []) + if not results: + return '' + + parts = [] + for r in results: + subcircuit_type = r.get('subcircuit_type', '') + # Match SPICE results to section type + relevant = False + if section_type == 'analog_design' and subcircuit_type in ('filter', 'divider', 'opamp'): + relevant = True + elif section_type == 'power_design' and subcircuit_type in ('regulator', 'lc_filter'): + relevant = True + + if relevant: + parts.append( + f"SPICE {r.get('name', '?')}: " + f"measured={r.get('measured_value', '?')}, " + f"expected={r.get('expected_value', '?')}, " + f"{'PASS' if r.get('pass') else 'FAIL'}" + ) + + return '\n'.join(parts) + + +# ====================================================================== +# Cross-references +# ====================================================================== + +def build_cross_references(section_type: str, analysis: dict, + emc_data: dict | None = None, + thermal_data: dict | None = None, + pcb_data: dict | None = None) -> str: + """Brief references to related sections.""" + parts = [] + + if section_type == 'power_design': + if thermal_data: + s = thermal_data.get('summary', {}) + parts.append(f"See Thermal: score {s.get('thermal_score', '?')}/100, " + f"{s.get('components_above_85c', 0)} above 85C") + if emc_data: + dc_findings = [f for f in emc_data.get('findings', []) + if f.get('category') == 'decoupling' and not f.get('suppressed')] + if dc_findings: + parts.append(f"See EMC: {len(dc_findings)} decoupling finding(s)") + + elif section_type == 'emc_analysis': + regs = [f for f in analysis.get('findings', []) + if f.get('detector') == 'detect_power_regulators'] + if regs: + parts.append(f"See Power: {len(regs)} regulator(s)") + if pcb_data: + parts.append(f"See PCB: {pcb_data.get('statistics', {}).get('copper_layers_used', '?')} layers") + + elif section_type == 'thermal_analysis': + regs = [f for f in analysis.get('findings', []) + if f.get('detector') == 'detect_power_regulators'] + pdiss_regs = [r for r in regs if r.get('power_dissipation')] + if pdiss_regs: + parts.append(f"See Power: {len(pdiss_regs)} regulator(s) with dissipation data") + + elif section_type == 'executive_summary': + if emc_data: + s = emc_data.get('summary', {}) + parts.append(f"EMC: {s.get('emc_risk_score', '?')}/100") + if thermal_data: + s = thermal_data.get('summary', {}) + parts.append(f"Thermal: {s.get('thermal_score', '?')}/100") + + return '\n'.join(parts) diff --git a/skills/kidoc/scripts/kidoc_narrative_config.py b/skills/kidoc/scripts/kidoc_narrative_config.py new file mode 100644 index 0000000..4ad06d5 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_narrative_config.py @@ -0,0 +1,228 @@ +"""Narrative configuration: section titles, writing guidance, and questions. + +Pure data module — no logic, no imports. Used by kidoc_narrative.py +to populate LLM context packages. +""" + +from __future__ import annotations + + +# ====================================================================== +# Section title mapping +# ====================================================================== + +SECTION_TITLES = { + 'system_overview': 'System Overview', + 'power_design': 'Power System Design', + 'signal_interfaces': 'Signal Interfaces', + 'analog_design': 'Analog Design', + 'thermal_analysis': 'Thermal Analysis', + 'emc_analysis': 'EMC Considerations', + 'pcb_design': 'PCB Design Details', + 'bom_summary': 'BOM Summary', + 'test_debug': 'Test and Debug', + 'executive_summary': 'Executive Summary', + 'compliance': 'Compliance and Standards', + 'mechanical_environmental': 'Mechanical / Environmental', + # CE Technical File + 'ce_product_identification': 'Product Identification', + 'ce_essential_requirements': 'Essential Requirements', + 'ce_risk_assessment': 'Risk Assessment', + # Design Review + 'review_summary': 'Review Summary', + 'review_action_items': 'Action Items', + # ICD + 'icd_interface_list': 'Interface List', + 'icd_connector_details': 'Connector Details', + 'icd_electrical_characteristics': 'Electrical Characteristics', + # Manufacturing + 'mfg_assembly_overview': 'Assembly Overview', + 'mfg_pcb_fab_notes': 'PCB Fabrication Notes', + 'mfg_assembly_instructions': 'Assembly Instructions', + 'mfg_test_procedures': 'Production Test Procedures', +} + + +# ====================================================================== +# Per-section writing guidance +# ====================================================================== + +WRITING_GUIDANCE = { + 'system_overview': ( + "Write a concise overview of the system architecture. Explain what the " + "board does, its key functional blocks, and how they connect. Reference " + "specific component counts and key ICs by part number. Keep it to 2-3 " + "paragraphs. Don't repeat the data table — explain what it means." + ), + 'power_design': ( + "Explain the power distribution architecture. For each regulator, state " + "the input source, output voltage, topology, and why that topology was " + "chosen (efficiency for buck, simplicity for LDO). Reference specific " + "component values and datasheet recommendations. Flag any deviations " + "from reference designs. Discuss thermal considerations for high-power " + "regulators." + ), + 'signal_interfaces': ( + "Describe each communication bus: what devices are connected, what " + "protocol is used, and any notable configuration (pull-up values, " + "termination, address assignments). Reference specific component " + "references and net names." + ), + 'analog_design': ( + "For each analog subcircuit (filters, dividers, opamp stages), explain " + "the design intent, calculated performance (cutoff frequency, gain, " + "output voltage), and any SPICE validation results. Use quantitative " + "language — 'the RC filter sets a -3dB point at 1.02 kHz' not " + "'appropriate filtering is provided.'" + ), + 'thermal_analysis': ( + "Summarize thermal analysis results. Identify components with the " + "smallest thermal margins. Discuss the adequacy of thermal management " + "(heat sinking, copper area, airflow). Reference specific junction " + "temperatures and maximum ratings." + ), + 'emc_analysis': ( + "Summarize EMC findings by severity. Highlight critical and high-risk " + "findings with specific mitigation recommendations. Reference rule IDs " + "and affected components. Discuss the overall EMC risk level and " + "readiness for pre-compliance testing." + ), + 'pcb_design': ( + "Describe the PCB stackup, layer usage, and key routing decisions. " + "Reference board dimensions, layer count, and critical design rules. " + "Discuss any DFM concerns." + ), + 'bom_summary': ( + "Summarize the BOM: total unique parts, component types breakdown, " + "any missing MPNs that need resolution. Note any single-source or " + "long-lead-time components if known." + ), + 'test_debug': ( + "Describe the test and debug strategy: available debug interfaces, " + "test point placement, production test sequence, and programming " + "access. Reference specific connector references and protocols." + ), + 'executive_summary': ( + "Write a 1-2 paragraph executive summary. State what the board is, " + "its key specifications, and the overall assessment (design maturity, " + "risk level, readiness for next phase). Reference specific numbers " + "from the analysis. This is the most important section — it's what " + "decision-makers read." + ), + 'compliance': ( + "List applicable standards and certification requirements. Discuss " + "pre-compliance test results and gaps. Reference EMC risk score " + "and specific findings that affect certification." + ), + 'mechanical_environmental': ( + "Describe the physical design: board dimensions, mounting method, " + "enclosure constraints, connector placement. State the operating " + "temperature range and environmental requirements." + ), + # CE Technical File + 'ce_product_identification': ( + "Describe the product's intended use, target environment " + "(indoor/outdoor, industrial/consumer), and user profile." + ), + 'ce_essential_requirements': ( + "For each directive, describe how the design meets the essential " + "requirements. Reference test reports, analysis data, and specific " + "design features that ensure compliance." + ), + 'ce_risk_assessment': ( + "Describe risk mitigation measures for each identified hazard. " + "Reference specific design features, test results, and component " + "ratings that address each risk." + ), + # Design Review + 'review_summary': ( + "Provide an overall assessment of design readiness. Highlight " + "critical risks, summarize analyzer scores, and recommend go/no-go " + "for the next design phase." + ), + 'review_action_items': ( + "List action items from the review. Assign severity, owners, and " + "due dates. Prioritize items that block fabrication." + ), + # ICD + 'icd_connector_details': ( + "For each connector, describe the interface protocol, signal levels, " + "timing requirements, and mating connector specification." + ), + 'icd_electrical_characteristics': ( + "Specify voltage levels, impedance, current limits, and timing " + "requirements for each interface." + ), + # Manufacturing + 'mfg_assembly_overview': ( + "Describe assembly requirements: lead-free/leaded process, reflow " + "profile, hand-solder requirements, special handling instructions." + ), + 'mfg_pcb_fab_notes': ( + "Specify impedance control requirements, stackup details, material " + "(FR-4/Rogers), and any special fabrication instructions." + ), + 'mfg_assembly_instructions': ( + "Describe the assembly sequence: paste application, component " + "placement, reflow, hand-solder steps, cleaning, conformal coating." + ), + 'mfg_test_procedures': ( + "Describe pass/fail criteria for each test step. Include expected " + "voltages, test fixture requirements, and failure modes." + ), +} + + +# ====================================================================== +# Section questions — specific questions to address per section +# ====================================================================== + +SECTION_QUESTIONS = { + 'system_overview': [ + "What is the primary function of this board?", + "What are the key functional blocks and how do they interconnect?", + "What are the main ICs and their roles?", + ], + 'power_design': [ + "What is the input voltage source and range?", + "Why was each regulator topology chosen (LDO vs. buck vs. boost)?", + "Are output capacitor values consistent with datasheet recommendations?", + "What is the worst-case power dissipation in each regulator?", + "Is there adequate input decoupling?", + ], + 'signal_interfaces': [ + "What communication protocols are used and between which devices?", + "Are pull-up/termination resistor values appropriate for the bus speed?", + "Is there adequate ESD protection on external interfaces?", + ], + 'analog_design': [ + "What is the design intent of each analog subcircuit?", + "Do calculated values (cutoff, gain, ratio) match the design targets?", + "Have tolerances been analyzed for critical circuits?", + ], + 'thermal_analysis': [ + "Which components have the smallest thermal margin?", + "Is the total board dissipation manageable without forced airflow?", + "Are thermal vias or heatsinks needed for any component?", + ], + 'emc_analysis': [ + "What is the overall EMC risk level?", + "Which findings are most likely to cause certification failure?", + "What are the top mitigation priorities?", + ], + 'pcb_design': [ + "Is the layer count adequate for the routing complexity?", + "Are there impedance-controlled traces that need stackup specification?", + "Are there any DFM violations or concerns?", + ], + 'bom_summary': [ + "How many unique parts are there and is this reasonable for the design?", + "Are there missing MPNs that need resolution before ordering?", + "Are there any single-source or long-lead-time components?", + ], + 'executive_summary': [ + "What does this board do in one sentence?", + "What is the design maturity level (prototype, pre-production, production)?", + "What are the top risks or open items?", + ], +} diff --git a/skills/kidoc/scripts/kidoc_narrative_extractors.py b/skills/kidoc/scripts/kidoc_narrative_extractors.py new file mode 100644 index 0000000..051750b --- /dev/null +++ b/skills/kidoc/scripts/kidoc_narrative_extractors.py @@ -0,0 +1,583 @@ +"""Narrative data extractors — pull focused data from analysis JSON. + +Each extractor takes the analysis dict (plus optional keyword args for +supplemental data sources) and returns a concise text summary for one +report section type. + +Zero external dependencies — Python 3.8+ stdlib only. +""" + +from __future__ import annotations + +from finding_schema import Det, group_findings + + +# ====================================================================== +# Utility +# ====================================================================== + +def _format_freq(hz) -> str: + """Format frequency value for display.""" + if hz is None: + return '?' + try: + hz = float(hz) + except (TypeError, ValueError): + return str(hz) + if hz >= 1e9: + return f"{hz/1e9:.2f}GHz" + if hz >= 1e6: + return f"{hz/1e6:.2f}MHz" + if hz >= 1e3: + return f"{hz/1e3:.2f}kHz" + return f"{hz:.2f}Hz" + + +# ====================================================================== +# Section data extractors +# ====================================================================== + +def _extract_overview_data(analysis: dict, **kwargs) -> str: + """Extract system overview data as concise text summary.""" + parts = [] + stats = analysis.get('statistics', {}) + if stats: + parts.append( + f"Components: {stats.get('total_components', 0)} total, " + f"{stats.get('unique_parts', 0)} unique" + ) + parts.append(f"Nets: {stats.get('total_nets', 0)}") + parts.append(f"Sheets: {stats.get('sheets', 1)}") + + types = stats.get('component_types', {}) + if types: + type_str = ', '.join(f"{v} {k}" for k, v in + sorted(types.items(), key=lambda x: -x[1])) + parts.append(f"Component types: {type_str}") + + missing = stats.get('missing_mpn', []) + if missing: + parts.append(f"Missing MPNs: {len(missing)} components ({', '.join(missing[:5])}" + + (f" +{len(missing)-5}" if len(missing) > 5 else "") + ")") + + # Key ICs + components = analysis.get('components', []) + ics = [c for c in components if c.get('type') == 'ic'] + if ics: + ic_list = [f"{c.get('reference', '?')} ({c.get('value', '?')})" for c in ics[:8]] + parts.append(f"Key ICs: {', '.join(ic_list)}") + + # Power rails + rails = stats.get('power_rails', []) + if rails: + rail_names = [r.get('name', '?') for r in rails if r.get('name')] + parts.append(f"Power rails: {', '.join(rail_names)}") + + # Title block + tb = analysis.get('title_block', {}) + if tb.get('title'): + parts.insert(0, f"Project: {tb['title']}") + + return '\n'.join(parts) if parts else 'No system overview data available.' + + + + +def _extract_power_data(analysis: dict, **kwargs) -> str: + """Extract power design data as concise text summary.""" + parts = [] + _sa = group_findings(analysis) + regs = _sa.get(Det.POWER_REGULATORS, []) + if regs: + parts.append(f"{len(regs)} voltage regulator(s):") + for r in regs: + line = f" - {r.get('ref', '?')}: {r.get('value', '?')}" + line += f", topology={r.get('topology', '?')}" + if r.get('estimated_vout'): + line += f", Vout={r['estimated_vout']:.3f}V" + line += f", input={r.get('input_rail', '?')}" + line += f", output={r.get('output_rail', '?')}" + + # Feedback divider + fb = r.get('feedback_divider') + if fb: + line += (f", feedback R_top={fb.get('r_top', {}).get('ref', '?')}" + f"({fb.get('r_top', {}).get('value', '?')})" + f" R_bot={fb.get('r_bottom', {}).get('ref', '?')}" + f"({fb.get('r_bottom', {}).get('value', '?')})") + + # Input/output caps + in_caps = r.get('input_capacitors', []) + out_caps = r.get('output_capacitors', []) + if in_caps: + cap_str = ', '.join(f"{c.get('ref','?')}={c.get('value','?')}" for c in in_caps) + line += f", input_caps=[{cap_str}]" + if out_caps: + cap_str = ', '.join(f"{c.get('ref','?')}={c.get('value','?')}" for c in out_caps) + line += f", output_caps=[{cap_str}]" + + # Power dissipation + pdiss = r.get('power_dissipation', {}) + if pdiss: + line += (f", Pdiss={pdiss.get('estimated_pdiss_W', '?')}W" + f" (Vin={pdiss.get('vin_estimated_V', '?')}V" + f" dropout={pdiss.get('dropout_V', '?')}V)") + + parts.append(line) + + decoupling = _sa.get(Det.DECOUPLING, []) + if decoupling: + if isinstance(decoupling, list) and decoupling: + total_caps = sum(len(d.get('capacitors', [])) for d in decoupling + if isinstance(d, dict)) + parts.append(f"Decoupling: {len(decoupling)} group(s), {total_caps} capacitor(s)") + for d in decoupling: + if isinstance(d, dict): + ic = d.get('ic_ref') or d.get('ic') or d.get('rail', '?') + caps = d.get('capacitors', []) + cap_str = ', '.join(f"{c.get('ref','?')}={c.get('value','?')}" + for c in caps if isinstance(c, dict)) + parts.append(f" - {ic}: [{cap_str}]") + elif isinstance(decoupling, dict): + parts.append(f"Decoupling: {decoupling.get('total_caps', 0)} capacitor(s)") + + # Protection devices + protection = _sa.get(Det.PROTECTION_DEVICES, []) + if protection: + parts.append(f"Protection devices: {len(protection)}") + for p in protection[:5]: + parts.append(f" - {p.get('ref', '?')}: {p.get('value', '?')} ({p.get('type', '?')})") + + return '\n'.join(parts) if parts else 'No power design data available.' + + +def _extract_signal_data(analysis: dict, **kwargs) -> str: + """Extract signal interface data as concise text summary.""" + parts = [] + + bus_analysis = analysis.get('design_analysis', {}).get('bus_analysis', {}) + for bus_type in ('i2c', 'spi', 'uart', 'can'): + buses = bus_analysis.get(bus_type, []) + for bus in buses: + signals = bus.get('signals', []) + sig_names = [s.get('name', str(s)) if isinstance(s, dict) else str(s) + for s in signals] + if sig_names and any(s for s in sig_names): + bus_id = bus.get('bus_id', bus_type) + parts.append(f"{bus_type.upper()} {bus_id}: {', '.join(sig_names[:10])}") + + # Level shifters + _sa = group_findings(analysis) + shifters = _sa.get(Det.LEVEL_SHIFTERS, []) + if shifters: + parts.append(f"Level shifters: {len(shifters)}") + for s in shifters: + parts.append(f" - {s.get('ref', '?')}: {s.get('value', '')} " + f"({s.get('low_side_rail', '?')} <-> {s.get('high_side_rail', '?')})") + + # ESD coverage + esd = _sa.get(Det.ESD_AUDIT, []) + if esd: + unprotected = [e for e in esd if isinstance(e, dict) and e.get('coverage') == 'none'] + if unprotected: + refs = [e.get('connector_ref', '?') for e in unprotected] + parts.append(f"ESD gaps: {len(unprotected)} connector(s) with no protection " + f"({', '.join(refs[:5])})") + + # Differential pairs + diff_pairs = analysis.get('design_analysis', {}).get('differential_pairs', []) + if diff_pairs: + parts.append(f"Differential pairs: {len(diff_pairs)}") + for dp in diff_pairs[:5]: + parts.append(f" - {dp.get('name', '?')}: " + f"{dp.get('positive_net', '?')} / {dp.get('negative_net', '?')}") + + if not parts: + parts.append('No formal buses or interfaces detected.') + + return '\n'.join(parts) + + +def _extract_analog_data(analysis: dict, **kwargs) -> str: + """Extract analog design data as concise text summary.""" + parts = [] + sa = group_findings(analysis) + + # Voltage dividers + dividers = sa.get(Det.VOLTAGE_DIVIDERS, []) + if dividers: + parts.append(f"{len(dividers)} voltage divider(s):") + for d in dividers: + r_top = d.get('r_top', {}) + r_bot = d.get('r_bottom', {}) + parts.append( + f" - {r_top.get('ref', '?')}({r_top.get('value', '?')}) / " + f"{r_bot.get('ref', '?')}({r_bot.get('value', '?')}), " + f"ratio={d.get('ratio', '?'):.4f}, " + f"top_net={d.get('top_net', '?')}, " + f"mid_net={d.get('mid_net', '?')}, " + f"bottom_net={d.get('bottom_net', '?')}" + ) + connections = d.get('mid_point_connections', []) + if connections: + conn_str = ', '.join( + f"{c.get('component', '?')}.{c.get('pin_name', '?')}" + for c in connections if isinstance(c, dict) + ) + parts.append(f" connects to: {conn_str}") + + # Filters + for ftype, label in [(Det.RC_FILTERS, 'RC filter'), (Det.LC_FILTERS, 'LC filter')]: + filters = sa.get(ftype, []) + if filters: + parts.append(f"{len(filters)} {label}(s):") + for f in filters: + r = f.get('resistor', {}) + c = f.get('capacitor', {}) + r_ref = r.get('ref', '?') if isinstance(r, dict) else str(r) + c_ref = c.get('ref', '?') if isinstance(c, dict) else str(c) + fc = f.get('cutoff_hz') + fc_str = _format_freq(fc) if fc else '?' + parts.append( + f" - {f.get('type', '?')}: {r_ref} + {c_ref}, " + f"fc={fc_str}, " + f"input={f.get('input_net', '?')}, output={f.get('output_net', '?')}" + ) + + # Opamp circuits + opamps = sa.get(Det.OPAMP_CIRCUITS, []) + if opamps: + parts.append(f"{len(opamps)} opamp circuit(s):") + for o in opamps: + parts.append( + f" - {o.get('ref', '?')} ({o.get('value', '?')}): " + f"topology={o.get('topology', '?')}, gain={o.get('gain', '?')}" + ) + + # Crystal circuits + crystals = sa.get(Det.CRYSTAL_CIRCUITS, []) + if crystals: + parts.append(f"{len(crystals)} crystal circuit(s):") + for c in crystals: + freq = c.get('frequency_hz') + parts.append(f" - {c.get('ref', '?')}: {_format_freq(freq) if freq else '?'}") + + if not parts: + parts.append('No analog subcircuits detected.') + + return '\n'.join(parts) + + +def _extract_thermal_data(analysis: dict, **kwargs) -> str: + """Extract thermal analysis data as concise text summary.""" + thermal_data = kwargs.get('thermal_data') + if not thermal_data: + return 'No thermal analysis data available.' + + parts = [] + summary = thermal_data.get('summary', {}) + parts.append(f"Thermal score: {summary.get('thermal_score', '?')}/100") + parts.append(f"Total board dissipation: {summary.get('total_board_dissipation_w', '?')}W") + parts.append(f"Ambient: {summary.get('ambient_c', 25.0)}C") + + hottest = summary.get('hottest_component', {}) + if isinstance(hottest, dict): + parts.append(f"Hottest: {hottest.get('ref', '?')} at {hottest.get('tj_estimated_c', '?')}C") + elif hottest: + parts.append(f"Hottest: {hottest}") + + above_85 = summary.get('components_above_85c', 0) + parts.append(f"Components above 85C: {above_85}") + + assessments = thermal_data.get('thermal_assessments', []) + if assessments: + parts.append(f"\n{len(assessments)} thermal assessment(s):") + for a in assessments: + parts.append( + f" - {a.get('ref', '?')} ({a.get('value', '?')}): " + f"Pdiss={a.get('pdiss_w', 0):.2f}W, " + f"package={a.get('package', '?')}, " + f"Rth_JA={a.get('rtheta_ja_effective', '?')}C/W, " + f"Tj={a.get('tj_estimated_c', 0):.1f}C, " + f"Tj_max={a.get('tj_max_c', '?')}C, " + f"margin={a.get('margin_c', 0):.1f}C" + ) + + findings = thermal_data.get('findings', []) + if findings: + parts.append(f"\n{len(findings)} thermal finding(s):") + for f in findings[:5]: + parts.append(f" - [{f.get('severity', '?')}] {f.get('title', '?')}") + + return '\n'.join(parts) + + +def _extract_emc_data(analysis: dict, **kwargs) -> str: + """Extract EMC analysis data as concise text summary.""" + emc_data = kwargs.get('emc_data') + if not emc_data: + return 'No EMC analysis data available.' + + parts = [] + summary = emc_data.get('summary', {}) + parts.append(f"EMC risk score: {summary.get('emc_risk_score', '?')}/100") + parts.append( + f"Findings: {summary.get('critical', 0)} critical, " + f"{summary.get('high', 0)} high, " + f"{summary.get('medium', 0)} medium, " + f"{summary.get('low', 0)} low" + ) + parts.append(f"Target standard: {emc_data.get('target_standard', '?')}") + + findings = emc_data.get('findings', []) + active = [f for f in findings if not f.get('suppressed')] + if active: + sev_order = {'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3, 'INFO': 4} + active.sort(key=lambda x: sev_order.get(x.get('severity', 'INFO'), 5)) + + # Group by category + by_cat: dict[str, int] = {} + for f in active: + cat = f.get('category', 'other') + by_cat[cat] = by_cat.get(cat, 0) + 1 + parts.append(f"\nCategories: {', '.join(f'{c}({n})' for c, n in sorted(by_cat.items()))}") + + # Top findings + top = [f for f in active if f.get('severity') in ('CRITICAL', 'HIGH')] + if top: + parts.append(f"\n{len(top)} critical/high finding(s):") + for f in top[:10]: + parts.append( + f" - [{f.get('severity')}] {f.get('rule_id', '?')}: " + f"{f.get('title', '?')}" + ) + if f.get('recommendation'): + parts.append(f" Recommendation: {f['recommendation'][:120]}") + + return '\n'.join(parts) + + +def _extract_pcb_data(analysis: dict, **kwargs) -> str: + """Extract PCB design data as concise text summary.""" + pcb_data = kwargs.get('pcb_data') + if not pcb_data: + return 'No PCB analysis data available.' + + parts = [] + stats = pcb_data.get('statistics', {}) + if stats: + parts.append(f"Copper layers: {stats.get('copper_layers_used', '?')}") + parts.append(f"Footprints: {stats.get('footprint_count', '?')} " + f"(front={stats.get('front_side', '?')}, back={stats.get('back_side', '?')})") + parts.append(f"SMD: {stats.get('smd_count', '?')}, THT: {stats.get('tht_count', '?')}") + parts.append(f"Tracks: {stats.get('track_segments', '?')} segments, " + f"{stats.get('total_track_length_mm', '?')}mm total") + parts.append(f"Vias: {stats.get('via_count', '?')}") + parts.append(f"Zones: {stats.get('zone_count', '?')}") + parts.append(f"Board area: {stats.get('board_width_mm', '?')}mm x " + f"{stats.get('board_height_mm', '?')}mm " + f"({stats.get('board_area_mm2', '?')}mm2)") + if stats.get('routing_complete') is not None: + parts.append(f"Routing: {'complete' if stats['routing_complete'] else 'incomplete'}") + + layers = pcb_data.get('layers', []) + copper_layers = [l for l in layers if l.get('type') == 'signal'] + if copper_layers: + parts.append(f"Layer names: {', '.join(l.get('name', '?') for l in copper_layers)}") + + # DFM — violations are now in findings[], summary in dfm_summary + dfm_summary = pcb_data.get('dfm_summary', {}) + dfm_violations = [f for f in pcb_data.get('findings', []) + if isinstance(f, dict) and f.get('category') == 'dfm'] + if dfm_violations: + parts.append(f"\nDFM violations: {len(dfm_violations)}") + for v in dfm_violations[:5]: + parts.append(f" - {v.get('rule_id', '?')}: {v.get('summary', v.get('message', '?'))}") + elif dfm_summary.get('violation_count', 0) > 0: + parts.append(f"\nDFM violations: {dfm_summary['violation_count']}") + + return '\n'.join(parts) + + +def _extract_bom_data(analysis: dict, **kwargs) -> str: + """Extract BOM data as concise text summary.""" + parts = [] + + stats = analysis.get('statistics', {}) + parts.append(f"Total components: {stats.get('total_components', '?')}") + parts.append(f"Unique parts: {stats.get('unique_parts', '?')}") + + types = stats.get('component_types', {}) + if types: + type_str = ', '.join(f"{k}: {v}" for k, v in + sorted(types.items(), key=lambda x: -x[1])) + parts.append(f"By type: {type_str}") + + missing_mpn = stats.get('missing_mpn', []) + if missing_mpn: + parts.append(f"Missing MPNs: {len(missing_mpn)} ({', '.join(missing_mpn[:8])}" + + (f" +{len(missing_mpn)-8}" if len(missing_mpn) > 8 else "") + ")") + + dnp = stats.get('dnp_parts', 0) + if dnp: + parts.append(f"DNP parts: {dnp}") + + bom = analysis.get('bom', []) + if bom: + # Count parts with/without MPN + with_mpn = sum(1 for b in bom if b.get('mpn')) + parts.append(f"BOM lines: {len(bom)} ({with_mpn} with MPN)") + + return '\n'.join(parts) if parts else 'No BOM data available.' + + +def _extract_test_data(analysis: dict, **kwargs) -> str: + """Extract test and debug data as concise text summary.""" + parts = [] + sa = group_findings(analysis) + + debug = sa.get(Det.DEBUG_INTERFACES, []) + if debug: + parts.append(f"{len(debug)} debug interface(s):") + for d in debug: + parts.append(f" - {d.get('ref', '?')}: {d.get('type', '?')} ({d.get('protocol', '?')})") + + # LED indicators + leds = sa.get(Det.LED_AUDIT, []) + if leds: + parts.append(f"{len(leds)} LED indicator(s)") + + if not parts: + parts.append('No debug interfaces detected.') + + return '\n'.join(parts) + + +def _extract_executive_data(analysis: dict, **kwargs) -> str: + """Extract executive summary data combining all sources.""" + parts = [] + + # Core stats + stats = analysis.get('statistics', {}) + tb = analysis.get('title_block', {}) + if tb.get('title'): + parts.append(f"Project: {tb['title']}") + parts.append( + f"Design: {stats.get('total_components', '?')} components, " + f"{stats.get('unique_parts', '?')} unique, " + f"{stats.get('total_nets', '?')} nets" + ) + + # Key ICs + components = analysis.get('components', []) + ics = [c for c in components if c.get('type') == 'ic'] + if ics: + parts.append(f"Key ICs: {', '.join(c.get('value', '?') for c in ics[:5])}") + + # Power summary + regs = group_findings(analysis).get(Det.POWER_REGULATORS, []) + if regs: + rails = [] + for r in regs: + vout = r.get('estimated_vout') + rail = r.get('output_rail') or '?' + if vout: + rails.append(f"{rail} ({vout:.1f}V)") + else: + rails.append(rail) + parts.append(f"Power rails: {', '.join(rails)}") + + # EMC + emc_data = kwargs.get('emc_data') + if emc_data: + emc_sum = emc_data.get('summary', {}) + parts.append( + f"EMC risk: {emc_sum.get('emc_risk_score', '?')}/100 " + f"({emc_sum.get('critical', 0)}C/{emc_sum.get('high', 0)}H/" + f"{emc_sum.get('medium', 0)}M)" + ) + + # Thermal + thermal_data = kwargs.get('thermal_data') + if thermal_data: + t_sum = thermal_data.get('summary', {}) + parts.append(f"Thermal score: {t_sum.get('thermal_score', '?')}/100") + + # PCB + pcb_data = kwargs.get('pcb_data') + if pcb_data: + pcb_stats = pcb_data.get('statistics', {}) + parts.append( + f"PCB: {pcb_stats.get('copper_layers_used', '?')} layers, " + f"{pcb_stats.get('board_width_mm', '?')}x{pcb_stats.get('board_height_mm', '?')}mm" + ) + + # Missing MPNs + missing = stats.get('missing_mpn', []) + if missing: + parts.append(f"Missing MPNs: {len(missing)}") + + return '\n'.join(parts) + + +def _extract_compliance_data(analysis: dict, **kwargs) -> str: + """Extract compliance-relevant data.""" + parts = [] + + emc_data = kwargs.get('emc_data') + if emc_data: + parts.append(f"Target standard: {emc_data.get('target_standard', '?')}") + summary = emc_data.get('summary', {}) + parts.append(f"EMC risk score: {summary.get('emc_risk_score', '?')}/100") + parts.append( + f"Critical: {summary.get('critical', 0)}, High: {summary.get('high', 0)}" + ) + + esd = group_findings(analysis).get(Det.ESD_AUDIT, []) + if esd: + unprotected = [e for e in esd if isinstance(e, dict) and e.get('coverage') == 'none'] + parts.append(f"ESD: {len(esd)} connectors audited, {len(unprotected)} unprotected") + + if not parts: + parts.append('No compliance data available.') + + return '\n'.join(parts) + + +def _extract_mechanical_data(analysis: dict, **kwargs) -> str: + """Extract mechanical/environmental data.""" + parts = [] + pcb_data = kwargs.get('pcb_data') + if pcb_data: + outline = pcb_data.get('board_outline', {}) + if outline: + parts.append(f"Board: {outline.get('width_mm', '?')}mm x " + f"{outline.get('height_mm', '?')}mm") + stats = pcb_data.get('statistics', {}) + parts.append(f"Footprints: front={stats.get('front_side', '?')}, " + f"back={stats.get('back_side', '?')}") + + if not parts: + parts.append('No mechanical data available.') + + return '\n'.join(parts) + + +# ====================================================================== +# Extractor registry +# ====================================================================== + +SECTION_DATA_EXTRACTORS = { + 'system_overview': _extract_overview_data, + 'power_design': _extract_power_data, + 'signal_interfaces': _extract_signal_data, + 'analog_design': _extract_analog_data, + 'thermal_analysis': _extract_thermal_data, + 'emc_analysis': _extract_emc_data, + 'pcb_design': _extract_pcb_data, + 'bom_summary': _extract_bom_data, + 'test_debug': _extract_test_data, + 'executive_summary': _extract_executive_data, + 'compliance': _extract_compliance_data, + 'mechanical_environmental': _extract_mechanical_data, +} diff --git a/skills/kidoc/scripts/kidoc_odt.py b/skills/kidoc/scripts/kidoc_odt.py new file mode 100644 index 0000000..4c69598 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_odt.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""ODT (OpenDocument Text) generation from kidoc markdown scaffolds. + +Converts markdown to ODT using odfpy. SVGs are rasterized to PNG via +svglib + rl-renderPM before embedding. Runs inside reports/.venv/. + +Usage (called by kidoc_generate.py, not directly): + python3 kidoc_odt.py --input reports/HDD.md --output reports/output/HDD.odt + --config '{"project": {"name": "..."}}' +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +from odf.opendocument import OpenDocumentText +from odf import text as odftext +from odf import draw as odfdraw +from odf import table as odftable +from odf.style import (Style, TextProperties, ParagraphProperties, + TableProperties, TableColumnProperties, + TableCellProperties, GraphicProperties, FontFace) +from odf.text import P, H, List, ListItem, ListLevelStyleBullet, ListStyle +from odf.table import Table, TableColumn, TableRow, TableCell + +# Add kidoc scripts to path for sibling imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from kidoc_md_parser import parse_markdown +from kidoc_raster import svg_to_png as _svg_to_png, has_svg_render, get_dpi + + +# ====================================================================== +# Style definitions +# ====================================================================== + +def _create_styles(doc): + """Create document styles and return a style lookup dict.""" + styles = {} + + # Title + s = Style(name='KidocTitle', family='paragraph') + s.addElement(TextProperties(fontsize='20pt', fontweight='bold', + color='#202020')) + s.addElement(ParagraphProperties(marginbottom='0.3cm')) + doc.styles.addElement(s) + styles['title'] = s + + # Headings + for level, size in [(1, '16pt'), (2, '13pt'), (3, '11pt'), (4, '10pt')]: + s = Style(name=f'KidocH{level}', family='paragraph') + s.addElement(TextProperties(fontsize=size, fontweight='bold', + color='#202040')) + s.addElement(ParagraphProperties(margintop='0.4cm', + marginbottom='0.2cm')) + doc.styles.addElement(s) + styles[f'h{level}'] = s + + # Body + s = Style(name='KidocBody', family='paragraph') + s.addElement(TextProperties(fontsize='9pt', color='#000000')) + s.addElement(ParagraphProperties(marginbottom='0.15cm')) + doc.styles.addElement(s) + styles['body'] = s + + # Code + s = Style(name='KidocCode', family='paragraph') + s.addElement(TextProperties(fontsize='8pt', fontfamily='Courier New', + color='#303030')) + s.addElement(ParagraphProperties(marginleft='0.5cm', + marginbottom='0.2cm', + backgroundcolor='#f5f5f5')) + doc.styles.addElement(s) + styles['code'] = s + + # Blockquote + s = Style(name='KidocBlockquote', family='paragraph') + s.addElement(TextProperties(fontsize='9pt', fontstyle='italic', + color='#606060')) + s.addElement(ParagraphProperties(marginleft='1cm', + marginbottom='0.2cm')) + doc.styles.addElement(s) + styles['blockquote'] = s + + # Caption + s = Style(name='KidocCaption', family='paragraph') + s.addElement(TextProperties(fontsize='8pt', fontstyle='italic', + color='#606060')) + s.addElement(ParagraphProperties(textalign='center', + marginbottom='0.3cm')) + doc.styles.addElement(s) + styles['caption'] = s + + # Bold inline + s = Style(name='KidocBold', family='text') + s.addElement(TextProperties(fontweight='bold')) + doc.styles.addElement(s) + styles['bold'] = s + + # Italic inline + s = Style(name='KidocItalic', family='text') + s.addElement(TextProperties(fontstyle='italic')) + doc.styles.addElement(s) + styles['italic'] = s + + # Code inline + s = Style(name='KidocCodeInline', family='text') + s.addElement(TextProperties(fontfamily='Courier New', fontsize='8pt', + color='#c04000')) + doc.styles.addElement(s) + styles['code_inline'] = s + + # Table cell + s = Style(name='KidocTableCell', family='table-cell') + s.addElement(TableCellProperties(padding='0.1cm', + border='0.5pt solid #c0c0c0')) + doc.automaticstyles.addElement(s) + styles['table_cell'] = s + + # Table header cell + s = Style(name='KidocTableHeaderCell', family='table-cell') + s.addElement(TableCellProperties(padding='0.1cm', + border='0.5pt solid #c0c0c0', + backgroundcolor='#e8e8f0')) + doc.automaticstyles.addElement(s) + styles['table_header_cell'] = s + + # Table + s = Style(name='KidocTable', family='table') + s.addElement(TableProperties(width='17cm', align='margins')) + doc.automaticstyles.addElement(s) + styles['table'] = s + + # Frame + s = Style(name='KidocFrame', family='graphic') + s.addElement(GraphicProperties(horizontalpos='center', + horizontalrel='paragraph')) + doc.automaticstyles.addElement(s) + styles['frame'] = s + + return styles + + +# ====================================================================== +# Inline formatting +# ====================================================================== + +def _add_runs_to_paragraph(p, runs: list[dict], styles: dict) -> None: + """Add formatted inline runs to an ODF paragraph.""" + for r in runs: + text_content = r['text'] + if r.get('bold') and r.get('italic'): + span = odftext.Span(stylename=styles['bold'], text='') + inner = odftext.Span(stylename=styles['italic'], text=text_content) + span.addElement(inner) + p.addElement(span) + elif r.get('bold'): + span = odftext.Span(stylename=styles['bold'], text=text_content) + p.addElement(span) + elif r.get('italic'): + span = odftext.Span(stylename=styles['italic'], text=text_content) + p.addElement(span) + elif r.get('code'): + span = odftext.Span(stylename=styles['code_inline'], text=text_content) + p.addElement(span) + else: + span = odftext.Span(text=text_content) + p.addElement(span) + + +# ====================================================================== +# Element conversion +# ====================================================================== + +def _add_element(doc, elem: dict, base_dir: str, styles: dict, + dpi: int, temp_files: list) -> None: + """Add a parsed markdown element to the ODT document.""" + etype = elem['type'] + + if etype == 'heading': + level = min(elem['level'], 4) + style_name = 'title' if level == 1 else f'h{level}' + h = H(outlinelevel=level, stylename=styles.get(style_name, styles['body']), + text=elem['text']) + doc.text.addElement(h) + + elif etype == 'paragraph': + p = P(stylename=styles['body']) + _add_runs_to_paragraph(p, elem['runs'], styles) + doc.text.addElement(p) + + elif etype == 'image': + _add_image(doc, elem, base_dir, styles, dpi, temp_files) + + elif etype == 'table': + _add_table(doc, elem, styles) + + elif etype == 'code_block': + for line in elem['code'].split('\n'): + p = P(stylename=styles['code'], text=line) + doc.text.addElement(p) + + elif etype == 'hr': + p = P(stylename=styles['body'], text='—' * 40) + doc.text.addElement(p) + + elif etype == 'bullet_list': + for item_runs in elem['items']: + p = P(stylename=styles['body'], text='• ') + _add_runs_to_paragraph(p, item_runs, styles) + doc.text.addElement(p) + + elif etype == 'numbered_list': + for i, item_runs in enumerate(elem['items']): + p = P(stylename=styles['body'], text=f'{i+1}. ') + _add_runs_to_paragraph(p, item_runs, styles) + doc.text.addElement(p) + + elif etype == 'blockquote': + p = P(stylename=styles['blockquote']) + _add_runs_to_paragraph(p, elem['runs'], styles) + doc.text.addElement(p) + + +def _add_image(doc, elem: dict, base_dir: str, styles: dict, + dpi: int, temp_files: list) -> None: + """Add an image to the ODT. SVGs are rasterized first.""" + path = elem['path'] + if not os.path.isabs(path): + path = os.path.join(base_dir, path) + + if not os.path.isfile(path): + p = P(stylename=styles['caption'], text=f'[Image not found: {elem["path"]}]') + doc.text.addElement(p) + return + + img_path = path + if path.lower().endswith('.svg'): + png_path = _svg_to_png(path, dpi=dpi) + if png_path: + img_path = png_path + temp_files.append(png_path) + else: + p = P(stylename=styles['caption'], + text=f'[SVG rendering unavailable: {elem["path"]}]') + doc.text.addElement(p) + return + + try: + # Read image to determine size + from PIL import Image as PILImage + with PILImage.open(img_path) as img: + img_w, img_h = img.size + # Scale to fit ~16cm width + max_w_cm = 16.0 + w_cm = min(max_w_cm, img_w * 2.54 / dpi) + h_cm = w_cm * img_h / img_w + + # Create frame + image + p = P(stylename=styles['body']) + frame = odfdraw.Frame(stylename=styles['frame'], + width=f'{w_cm:.1f}cm', height=f'{h_cm:.1f}cm') + href = doc.addPicture(img_path) + img_elem = odfdraw.Image(href=href) + frame.addElement(img_elem) + p.addElement(frame) + doc.text.addElement(p) + except Exception: + p = P(stylename=styles['caption'], + text=f'[Failed to embed image: {elem["path"]}]') + doc.text.addElement(p) + + # Caption + if elem.get('alt'): + p = P(stylename=styles['caption'], text=elem['alt']) + doc.text.addElement(p) + + +def _add_table(doc, elem: dict, styles: dict) -> None: + """Add a table to the ODT.""" + headers = elem['headers'] + rows = elem['rows'] + n_cols = len(headers) + + t = Table(stylename=styles['table']) + + # Columns + col_style = Style(name='KidocTableCol', family='table-column') + col_style.addElement(TableColumnProperties( + columnwidth=f'{17.0/n_cols:.1f}cm')) + doc.automaticstyles.addElement(col_style) + for _ in range(n_cols): + t.addElement(TableColumn(stylename=col_style)) + + # Header row + tr = TableRow() + for h in headers: + tc = TableCell(stylename=styles['table_header_cell']) + p = P(text=h) + # Bold header text + tc.addElement(p) + tr.addElement(tc) + t.addElement(tr) + + # Data rows + for row in rows: + tr = TableRow() + for i in range(n_cols): + cell_text = row[i] if i < len(row) else '' + tc = TableCell(stylename=styles['table_cell']) + tc.addElement(P(text=cell_text)) + tr.addElement(tc) + t.addElement(tr) + + doc.text.addElement(t) + # Spacer + doc.text.addElement(P(stylename=styles['body'], text='')) + + +# ====================================================================== +# Main generation +# ====================================================================== + +def generate_odt(markdown_path: str, output_path: str, config: dict) -> str: + """Convert markdown to ODT. Returns the output path.""" + with open(markdown_path, 'r', encoding='utf-8') as f: + md_text = f.read() + + elements = parse_markdown(md_text) + base_dir = os.path.dirname(os.path.abspath(markdown_path)) + dpi = config.get('reports', {}).get('rendering', {}).get('schematic_dpi', 300) + + doc = OpenDocumentText() + styles = _create_styles(doc) + + temp_files: list[str] = [] + + try: + for elem in elements: + _add_element(doc, elem, base_dir, styles, dpi, temp_files) + + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True) + doc.save(output_path) + finally: + for tf in temp_files: + try: + os.unlink(tf) + except OSError: + pass + + return output_path + + +def main(): + parser = argparse.ArgumentParser(description='Generate ODT from markdown') + parser.add_argument('--input', '-i', required=True, + help='Input markdown file') + parser.add_argument('--output', '-o', required=True, + help='Output ODT file') + parser.add_argument('--config', '-c', default='{}', + help='JSON config string or path to config file') + args = parser.parse_args() + + if os.path.isfile(args.config): + with open(args.config) as f: + config = json.load(f) + else: + config = json.loads(args.config) + + output = generate_odt(args.input, args.output, config) + print(output, file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_orchestrator.py b/skills/kidoc/scripts/kidoc_orchestrator.py new file mode 100644 index 0000000..c8c1ce4 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_orchestrator.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Render orchestrator for kidoc document generation. + +Coordinates all figure generation for a report based on the document +spec. All figures — schematic overviews, subsystem crops, PCB views, +block diagrams, pinouts, and analysis charts — go through the +registered generator framework with tracked JSON inputs. + +Usage: + python3 kidoc_orchestrator.py --spec spec.json --project-dir . --output reports/figures/ + python3 kidoc_orchestrator.py --analysis schematic.json --project-dir . --output reports/figures/ + +Zero external dependencies -- Python 3.8+ stdlib only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# Ensure this script's directory is on sys.path for sibling imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from kidoc_spec import load_spec, expand_type_to_spec +from figures import run_all, FigureTheme + + +# ====================================================================== +# File auto-detection +# ====================================================================== + +def _find_file(project_dir: str, suffix: str) -> Optional[str]: + """Find the first file with *suffix* under *project_dir*.""" + for f in Path(project_dir).rglob(f'*{suffix}'): + return str(f) + return None + + +# ====================================================================== +# Main orchestrator +# ====================================================================== + +def orchestrate_renders(spec: dict, project_dir: str, + analysis: dict, + figures_dir: str, + sch_path: Optional[str] = None, + pcb_path: Optional[str] = None, + config: Optional[dict] = None + ) -> Dict[str, List[str]]: + """Generate all figures for a report based on the document spec. + + All figure generation goes through the registered generator framework. + The analysis dict is augmented with ``_sch_path``, ``_pcb_path``, and + ``_spec_sections`` so generators can access project files and spec data. + + Args: + spec: document spec dict (from kidoc_spec.py) + project_dir: KiCad project directory + analysis: loaded schematic analysis JSON + figures_dir: base output directory (e.g., reports/figures/) + sch_path: path to .kicad_sch (auto-detected if None) + pcb_path: path to .kicad_pcb (auto-detected if None) + config: project config from .kicad-happy.json + + Returns: + dict mapping category -> list of generated figure paths + """ + config = config or {} + + # Auto-detect project files + if not sch_path: + sch_path = _find_file(project_dir, '.kicad_sch') + if not pcb_path: + pcb_path = _find_file(project_dir, '.kicad_pcb') + + # Augment analysis with paths and spec sections so generators + # can access them via the standard prepare(analysis, config) interface + augmented = dict(analysis) if analysis else {} + if sch_path: + augmented['_sch_path'] = sch_path + if pcb_path: + augmented['_pcb_path'] = pcb_path + augmented['_spec_sections'] = spec.get('sections', []) + + # Run all generators through the framework + print(f" Generating figures into {figures_dir}", file=sys.stderr) + paths = run_all(augmented, config, figures_dir) + + result: Dict[str, List[str]] = {} + if paths: + result['_figures'] = paths + + return result + + +# ====================================================================== +# CLI +# ====================================================================== + +def main() -> None: + parser = argparse.ArgumentParser( + description='Render orchestrator for kidoc document generation') + parser.add_argument('--spec', '-s', default=None, + help='Path to document spec JSON ' + '(default: auto-generate from analysis)') + parser.add_argument('--analysis', '-a', default=None, + help='Path to schematic analysis JSON') + parser.add_argument('--project-dir', '-p', required=True, + help='KiCad project directory') + parser.add_argument('--output', '-o', required=True, + help='Output directory for figures') + parser.add_argument('--sch', default=None, + help='Path to .kicad_sch (auto-detected if omitted)') + parser.add_argument('--pcb', default=None, + help='Path to .kicad_pcb (auto-detected if omitted)') + parser.add_argument('--config', default=None, + help='Path to .kicad-happy.json config ' + '(for branding/theme)') + parser.add_argument('--emc', default=None, + help='Path to EMC analysis JSON') + parser.add_argument('--thermal', default=None, + help='Path to thermal analysis JSON') + parser.add_argument('--spice', default=None, + help='Path to SPICE results JSON') + parser.add_argument('--analyze', action='store_true', + help='Run KiCad analysis scripts if no analysis data ' + 'exists.') + args = parser.parse_args() + + # Load or generate spec + if args.spec: + spec = load_spec(args.spec) + else: + spec = expand_type_to_spec('hdd') + + # Load analysis and merge supplemental data + analysis = {} + if args.analysis: + with open(args.analysis) as f: + analysis = json.load(f) + + # If no explicit analysis path, try manifest + if not args.analysis: + try: + _kicad_scripts = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', '..', 'kicad', 'scripts') + if os.path.isdir(_kicad_scripts): + import sys as _sys + _sys.path.insert(0, os.path.abspath(_kicad_scripts)) + from project_config import load_config + _config = load_config(args.project_dir) + _analysis_dir = os.path.join( + args.project_dir, + _config.get('analysis', {}).get('output_dir', 'analysis')) + _manifest_path = os.path.join(_analysis_dir, 'manifest.json') + if os.path.isfile(_manifest_path): + with open(_manifest_path) as f: + _manifest = json.load(f) + _current_id = _manifest.get('current') + if _current_id: + _sch_path = os.path.join(_analysis_dir, _current_id, + 'schematic.json') + if os.path.isfile(_sch_path): + args.analysis = _sch_path + with open(_sch_path) as f: + analysis = json.load(f) + except (ImportError, json.JSONDecodeError, OSError): + pass + + for path in (args.emc, args.thermal, args.spice): + if path: + with open(path) as f: + analysis.update(json.load(f)) + + # Load config + config = None + if args.config: + with open(args.config) as f: + config = json.load(f) + + figures_dir = os.path.abspath(args.output) + project_dir = os.path.abspath(args.project_dir) + + print(f"Orchestrating renders into {figures_dir}", file=sys.stderr) + result = orchestrate_renders( + spec, project_dir, analysis, figures_dir, + sch_path=args.sch, pcb_path=args.pcb, config=config, + ) + + # Report + total = sum(len(v) for v in result.values()) + print(f"\nGenerated {total} figure(s):", file=sys.stderr) + for category, paths in sorted(result.items()): + print(f" {category}:", file=sys.stderr) + for p in paths: + print(f" {p}", file=sys.stderr) + + # Output JSON manifest to stdout + json.dump(result, sys.stdout, indent=2) + sys.stdout.write('\n') + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_pdf.py b/skills/kidoc/scripts/kidoc_pdf.py new file mode 100644 index 0000000..4d06dd1 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_pdf.py @@ -0,0 +1,878 @@ +#!/usr/bin/env python3 +"""Publication-quality PDF generation from kidoc markdown scaffolds. + +Produces professional engineering documents with dark navy headers, +styled cover pages, table of contents, formatted tables with alternating +rows, and vector SVG diagrams. + +Styling modeled after B&W generate_pdfs.py with engineering-document +terminology and kidoc markdown parser integration. + +Usage (called by kidoc_generate.py, not directly): + python3 kidoc_pdf.py --input reports/HDD.md --output reports/output/HDD.pdf + --config '{"project": {"name": "..."}}' +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from datetime import datetime + +# These imports require the venv to be active +from reportlab.lib.pagesizes import letter, A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.lib.colors import HexColor, white +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY +from reportlab.platypus import ( + Paragraph, Spacer, Table, TableStyle, + PageBreak, Preformatted, HRFlowable, KeepTogether, Flowable, +) +from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate, Frame, NextPageTemplate + +# Add kidoc scripts to path for the markdown parser and SVG embed +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from kidoc_md_parser import parse_markdown, parse_inline +from figures.lib.svg_embed import svg_to_flowable + + +# ====================================================================== +# Brand colors (engineering document palette) +# ====================================================================== + +DARK_NAVY = HexColor("#1a1a2e") +ACCENT_BLUE = HexColor("#0f4c75") +ACCENT_TEAL = HexColor("#1b6ca8") +LIGHT_BLUE = HexColor("#e8f4fc") +DARK_TEXT = HexColor("#2c2c2c") +MEDIUM_TEXT = HexColor("#555555") +LIGHT_TEXT = HexColor("#999999") +TABLE_HEADER_BG = HexColor("#1a3a5c") +TABLE_ALT_ROW = HexColor("#f4f8fb") +RULE_COLOR = HexColor("#d0d8e0") +CALLOUT_BG = HexColor("#edf5fb") +CALLOUT_BORDER = HexColor("#1b6ca8") +WHITE = white + +PAGE_SIZES = { + 'letter': letter, + 'a4': A4, +} + + +def _resolve_branding(config: dict) -> dict: + """Resolve branding settings from config, with defaults.""" + branding = config.get('reports', {}).get('branding', {}) + colors = branding.get('colors', {}) + + return { + 'dark_navy': HexColor(colors.get('primary', '#1a1a2e')), + 'accent_blue': HexColor(colors.get('accent', '#0f4c75')), + 'accent_teal': HexColor(colors.get('highlight', '#1b6ca8')), + 'table_header_bg': HexColor(colors.get('table_header', '#1a3a5c')), + 'table_alt_row': HexColor(colors.get('table_alt_row', '#f4f8fb')), + 'callout_bg': HexColor(colors.get('callout_bg', '#edf5fb')), + 'callout_border': HexColor(colors.get('callout_border', '#1b6ca8')), + 'company_name': branding.get('company_name', + config.get('project', {}).get('company', '')), + 'logo_path': branding.get('logo', ''), + 'header_left': branding.get('header_left', '{company}'), + 'header_right': branding.get('header_right', '{number} Rev {rev}'), + } + + +# ====================================================================== +# Custom flowables +# ====================================================================== + +class CalloutBox(Flowable): + """A colored box with left border accent for callouts/blockquotes.""" + + def __init__(self, text, width, bg_color, border_color, style, + border_width=3): + Flowable.__init__(self) + self.text = text + self.box_width = width + self.bg_color = bg_color + self.border_color = border_color + self.style = style + self.border_width = border_width + self._para = Paragraph(text, style) + self._para.wrap(width - 24, 10000) + self.height = self._para.height + 16 + + def wrap(self, availWidth, availHeight): + self._para.wrap(self.box_width - 24, availHeight) + self.height = self._para.height + 16 + self.width = self.box_width + return (self.width, self.height) + + def draw(self): + c = self.canv + c.saveState() + c.setFillColor(self.bg_color) + c.roundRect(0, 0, self.box_width, self.height, 3, fill=1, stroke=0) + c.setFillColor(self.border_color) + c.rect(0, 0, self.border_width, self.height, fill=1, stroke=0) + self._para.drawOn(c, 12 + self.border_width, 8) + c.restoreState() + + +# ====================================================================== +# Styles +# ====================================================================== + +def create_styles(brand: dict | None = None): + """Build the complete style set for engineering documents.""" + b = brand or {} + dark_navy = b.get('dark_navy', DARK_NAVY) + accent_blue = b.get('accent_blue', ACCENT_BLUE) + accent_teal = b.get('accent_teal', ACCENT_TEAL) + + s = {} + s['h1'] = ParagraphStyle( + 'H1', fontName='Helvetica-Bold', fontSize=16, leading=20, + textColor=dark_navy, spaceBefore=20, spaceAfter=4, + ) + s['h2'] = ParagraphStyle( + 'H2', fontName='Helvetica-Bold', fontSize=13, leading=16, + textColor=accent_blue, spaceBefore=16, spaceAfter=6, + ) + s['h3'] = ParagraphStyle( + 'H3', fontName='Helvetica-Bold', fontSize=11, leading=14, + textColor=accent_teal, spaceBefore=10, spaceAfter=4, + ) + s['body'] = ParagraphStyle( + 'Body', fontName='Helvetica', fontSize=9.5, leading=13.5, + textColor=DARK_TEXT, spaceAfter=7, alignment=TA_JUSTIFY, + ) + s['bullet'] = ParagraphStyle( + 'Bullet', fontName='Helvetica', fontSize=9.5, leading=13.5, + textColor=DARK_TEXT, spaceAfter=3, leftIndent=18, bulletIndent=6, + ) + s['numbered'] = ParagraphStyle( + 'Numbered', fontName='Helvetica', fontSize=9.5, leading=13.5, + textColor=DARK_TEXT, spaceAfter=3, leftIndent=18, bulletIndent=6, + ) + s['callout'] = ParagraphStyle( + 'Callout', fontName='Helvetica', fontSize=9.5, leading=13, + textColor=DARK_TEXT, + ) + s['code'] = ParagraphStyle( + 'Code', fontName='Courier', fontSize=7.5, leading=9.5, + textColor=DARK_TEXT, backColor=HexColor('#f5f5f5'), + borderWidth=0.5, borderColor=HexColor('#e0e0e0'), + borderPadding=6, leftIndent=6, spaceAfter=8, + ) + s['table_header'] = ParagraphStyle( + 'TH', fontName='Helvetica-Bold', fontSize=8, leading=11, + textColor=WHITE, + ) + s['table_cell'] = ParagraphStyle( + 'TC', fontName='Helvetica', fontSize=8, leading=11, + textColor=DARK_TEXT, + ) + s['table_cell_bold'] = ParagraphStyle( + 'TCB', fontName='Helvetica-Bold', fontSize=8, leading=11, + textColor=DARK_TEXT, + ) + s['toc'] = ParagraphStyle( + 'TOC', fontName='Helvetica', fontSize=10, leading=18, + textColor=DARK_TEXT, leftIndent=0, + ) + s['toc_sub'] = ParagraphStyle( + 'TOCSub', fontName='Helvetica', fontSize=9, leading=16, + textColor=MEDIUM_TEXT, leftIndent=16, + ) + s['meta_label'] = ParagraphStyle( + 'MetaLabel', fontName='Helvetica', fontSize=8.5, leading=12, + textColor=LIGHT_TEXT, + ) + s['meta_value'] = ParagraphStyle( + 'MetaValue', fontName='Helvetica-Bold', fontSize=8.5, leading=12, + textColor=DARK_TEXT, + ) + s['figure_caption'] = ParagraphStyle( + 'FigCaption', fontName='Helvetica-Oblique', fontSize=8, leading=11, + textColor=MEDIUM_TEXT, spaceAfter=10, spaceBefore=2, + alignment=TA_CENTER, + ) + return s + + +# ====================================================================== +# Document template with cover + main page templates +# ====================================================================== + +class KidocDocTemplate(BaseDocTemplate): + """Custom document template with cover page and main page layouts.""" + + def __init__(self, filename, doc_title='', doc_subtitle='', + company='', classification='', doc_date='', + brand: dict | None = None, **kwargs): + self.doc_title = doc_title + self.doc_subtitle = doc_subtitle + self.company = company + self.classification = classification + self.doc_date = doc_date or datetime.now().strftime("%B %d, %Y") + self.brand = brand or {} + super().__init__(filename, **kwargs) + + page_w, page_h = kwargs.get('pagesize', letter) + margin = kwargs.get('leftMargin', inch) + content_w = page_w - 2 * margin + + frame = Frame( + margin, 0.7 * inch, content_w, page_h - 1.4 * inch, + id='normal', topPadding=6, bottomPadding=6, + ) + self.addPageTemplates([ + PageTemplate(id='cover', frames=[frame], + onPage=self._draw_cover_page), + PageTemplate(id='main', frames=[frame], + onPage=self._draw_page), + ]) + + def _draw_cover_page(self, canvas, doc): + """Dark navy header band, company name, accent line.""" + canvas.saveState() + page_w, page_h = doc.pagesize + margin = doc.leftMargin + dark_navy = self.brand.get('dark_navy', DARK_NAVY) + accent_teal = self.brand.get('accent_teal', ACCENT_TEAL) + + # Top accent block + canvas.setFillColor(dark_navy) + canvas.rect(0, page_h - 1.2 * inch, page_w, 1.2 * inch, + fill=1, stroke=0) + canvas.setStrokeColor(accent_teal) + canvas.setLineWidth(3) + canvas.line(0, page_h - 1.2 * inch, page_w, page_h - 1.2 * inch) + + # Company name in header block + canvas.setFillColor(WHITE) + canvas.setFont('Helvetica-Bold', 14) + canvas.drawString(margin, page_h - 0.55 * inch, + self.company.upper() if self.company else '') + canvas.setFont('Helvetica', 9) + canvas.setFillColor(HexColor("#8899aa")) + canvas.drawString(margin, page_h - 0.8 * inch, self.doc_subtitle) + + # Bottom accent bar + canvas.setFillColor(accent_teal) + canvas.rect(0, 0.5 * inch, page_w, 3, fill=1, stroke=0) + + # Footer + canvas.setFillColor(LIGHT_TEXT) + canvas.setFont('Helvetica', 7) + if self.classification: + canvas.drawString(margin, 0.3 * inch, + f"{self.classification} | {self.company}") + canvas.drawRightString(page_w - margin, 0.3 * inch, self.doc_date) + + canvas.restoreState() + + def _draw_page(self, canvas, doc): + """Compact navy header bar, footer with classification and page.""" + canvas.saveState() + page_w, page_h = doc.pagesize + margin = doc.leftMargin + dark_navy = self.brand.get('dark_navy', DARK_NAVY) + accent_teal = self.brand.get('accent_teal', ACCENT_TEAL) + + # Header bar + canvas.setFillColor(dark_navy) + canvas.rect(0, page_h - 0.45 * inch, page_w, 0.45 * inch, + fill=1, stroke=0) + canvas.setStrokeColor(accent_teal) + canvas.setLineWidth(2) + canvas.line(0, page_h - 0.45 * inch, page_w, page_h - 0.45 * inch) + + canvas.setFillColor(WHITE) + canvas.setFont('Helvetica-Bold', 7.5) + canvas.drawString(margin, page_h - 0.3 * inch, + self.company.upper() if self.company else '') + canvas.setFont('Helvetica', 7) + canvas.setFillColor(HexColor("#8899aa")) + canvas.drawRightString(page_w - margin, page_h - 0.3 * inch, + self.doc_subtitle) + + # Footer + canvas.setStrokeColor(RULE_COLOR) + canvas.setLineWidth(0.4) + canvas.line(margin, 0.55 * inch, page_w - margin, 0.55 * inch) + + canvas.setFillColor(LIGHT_TEXT) + canvas.setFont('Helvetica', 6.5) + if self.classification: + canvas.drawString(margin, 0.38 * inch, self.classification) + canvas.drawCentredString(page_w / 2, 0.38 * inch, self.doc_date) + canvas.drawRightString(page_w - margin, 0.38 * inch, + f"Page {doc.page}") + + canvas.restoreState() + + +# ====================================================================== +# XML escaping and inline formatting +# ====================================================================== + +def _escape_xml(text: str) -> str: + """Escape special XML characters for ReportLab Paragraph markup.""" + return (text.replace('&', '&').replace('<', '<') + .replace('>', '>').replace('"', '"')) + + +def _runs_to_xml(runs: list[dict]) -> str: + """Convert inline runs from kidoc_md_parser to ReportLab XML markup.""" + parts = [] + for r in runs: + text = _escape_xml(r['text']) + if r.get('code'): + parts.append( + f'' + f'{text}') + elif r.get('bold') and r.get('italic'): + parts.append(f'{text}') + elif r.get('bold'): + parts.append(f'{text}') + elif r.get('italic'): + parts.append(f'{text}') + elif r.get('link'): + href = _escape_xml(r['link']) + parts.append( + f'{text}') + else: + parts.append(text) + return ''.join(parts) + + +def _strip_html_comments(text: str) -> str: + """Remove HTML comments (AUTO markers, NARRATIVE markers, etc.).""" + return re.sub(r'', '', text, flags=re.DOTALL) + + +# ====================================================================== +# Cover page +# ====================================================================== + +def build_cover(title: str, subtitle: str, meta_lines: list[tuple], + styles: dict, classification: str = '', + brand: dict | None = None) -> list: + """Build cover page flowables: title, accent rule, metadata table.""" + b = brand or {} + dark_navy = b.get('dark_navy', DARK_NAVY) + accent_teal = b.get('accent_teal', ACCENT_TEAL) + + elements = [] + elements.append(Spacer(1, 1.6 * inch)) + + # Title + elements.append(Paragraph( + _escape_xml(title).replace('\n', '
'), + ParagraphStyle( + 'CoverTitle', fontName='Helvetica-Bold', fontSize=34, + leading=40, textColor=dark_navy, + ), + )) + elements.append(Spacer(1, 6)) + + # Accent rule (35% width, 3pt teal, left-aligned) + elements.append(HRFlowable( + width="35%", thickness=3, color=accent_teal, + spaceBefore=0, spaceAfter=16, hAlign='LEFT', + )) + + # Subtitle + elements.append(Paragraph( + _escape_xml(subtitle), + ParagraphStyle( + 'CoverSub', fontName='Helvetica', fontSize=14, leading=18, + textColor=MEDIUM_TEXT, + ), + )) + elements.append(Spacer(1, 1.2 * inch)) + + # Metadata table with subtle bottom borders + if meta_lines: + meta_data = [] + for label, value in meta_lines: + meta_data.append([ + Paragraph(_escape_xml(label), styles['meta_label']), + Paragraph(_escape_xml(value), styles['meta_value']), + ]) + meta_table = Table(meta_data, colWidths=[1.5 * inch, 4 * inch]) + meta_table.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LINEBELOW', (0, 0), (-1, -2), 0.3, RULE_COLOR), + ])) + elements.append(meta_table) + + elements.append(Spacer(1, 1 * inch)) + + # Classification tag + if classification: + elements.append(Paragraph( + f'{_escape_xml(classification.upper())}', + ParagraphStyle('ConfTag', fontSize=9, leading=12), + )) + + return elements + + +# ====================================================================== +# Table of Contents +# ====================================================================== + +def build_toc(elements: list[dict], styles: dict, + brand: dict | None = None) -> list: + """Build TOC from parsed markdown elements. + + Extracts headings from the element list and renders as a styled TOC. + Only includes level-1 and level-2 headings (## and ###). + Skips the first heading (document title, shown on cover). + """ + b = brand or {} + dark_navy = b.get('dark_navy', DARK_NAVY) + accent_teal = b.get('accent_teal', ACCENT_TEAL) + + flowables = [] + flowables.append(Paragraph("Table of Contents", ParagraphStyle( + 'TOCTitle', fontName='Helvetica-Bold', fontSize=18, leading=22, + textColor=dark_navy, spaceAfter=12, + ))) + flowables.append(HRFlowable( + width="100%", thickness=1, color=accent_teal, + spaceBefore=0, spaceAfter=12, + )) + + # Collect headings, skip the first H1 (title) + first_h1_seen = False + for elem in elements: + if elem['type'] != 'heading': + continue + level = elem['level'] + if level == 1 and not first_h1_seen: + first_h1_seen = True + continue + if level > 3: + continue + + text = _escape_xml(elem['text']) + if level <= 2: + flowables.append(Paragraph( + f'{text}', styles['toc'], + )) + else: + flowables.append(Paragraph(text, styles['toc_sub'])) + + flowables.append(PageBreak()) + return flowables + + +# ====================================================================== +# Table builder +# ====================================================================== + +def build_table(headers: list[str], rows: list[list[str]], + styles: dict, content_w: float, + brand: dict | None = None) -> list: + """Build a styled table: dark header, alternating rows, rounded corners. + + First column rendered bold. Thin borders with RULE_COLOR. + """ + if not headers: + return [] + + b = brand or {} + table_header_bg = b.get('table_header_bg', TABLE_HEADER_BG) + table_alt_row = b.get('table_alt_row', TABLE_ALT_ROW) + accent_blue = b.get('accent_blue', ACCENT_BLUE) + + num_cols = len(headers) + table_data = [] + + # Header row + header = [Paragraph(_escape_xml(h), styles['table_header']) + for h in headers] + table_data.append(header) + + # Data rows + for row in rows: + styled_row = [] + for j in range(num_cols): + cell_text = row[j] if j < len(row) else '' + if j == 0: + styled_row.append( + Paragraph(_escape_xml(cell_text), styles['table_cell_bold'])) + else: + styled_row.append( + Paragraph(_escape_xml(cell_text), styles['table_cell'])) + table_data.append(styled_row) + + col_widths = [content_w / num_cols] * num_cols + t = Table(table_data, colWidths=col_widths, repeatRows=1) + + cmds = [ + ('BACKGROUND', (0, 0), (-1, 0), table_header_bg), + ('TEXTCOLOR', (0, 0), (-1, 0), WHITE), + ('BOTTOMPADDING', (0, 0), (-1, 0), 7), + ('TOPPADDING', (0, 0), (-1, 0), 7), + ('LEFTPADDING', (0, 0), (-1, -1), 6), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ('TOPPADDING', (0, 1), (-1, -1), 5), + ('BOTTOMPADDING', (0, 1), (-1, -1), 5), + ('GRID', (0, 0), (-1, -1), 0.4, HexColor("#dde4ea")), + ('LINEBELOW', (0, 0), (-1, 0), 1.5, accent_blue), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ROUNDEDCORNERS', [3, 3, 3, 3]), + ] + + # Alternating row colors + for ri in range(1, len(table_data)): + if ri % 2 == 0: + cmds.append(('BACKGROUND', (0, ri), (-1, ri), table_alt_row)) + + t.setStyle(TableStyle(cmds)) + return [t, Spacer(1, 8)] + + +# ====================================================================== +# Element-to-flowable conversion +# ====================================================================== + +def elements_to_flowables(elements: list[dict], styles: dict, + base_dir: str, content_w: float, + brand: dict | None = None) -> list: + """Convert parsed markdown elements to ReportLab flowables. + + Uses kidoc_md_parser element types. The first H1 heading is skipped + (it appears on the cover page). + """ + b = brand or {} + accent_blue = b.get('accent_blue', ACCENT_BLUE) + accent_teal = b.get('accent_teal', ACCENT_TEAL) + callout_bg = b.get('callout_bg', CALLOUT_BG) + callout_border = b.get('callout_border', CALLOUT_BORDER) + + flowables = [] + first_h1_seen = False + figure_counter = 0 + + for elem in elements: + etype = elem['type'] + + # --- heading --- + if etype == 'heading': + level = elem['level'] + text = _escape_xml(elem['text']) + + if level == 1 and not first_h1_seen: + first_h1_seen = True + continue # skip title, it's on the cover + + if level == 1: + flowables.append(Paragraph(text, styles['h1'])) + flowables.append(HRFlowable( + width="100%", thickness=1.5, color=accent_blue, + spaceBefore=0, spaceAfter=6, + )) + elif level == 2: + # KeepTogether: heading + rule stays with first paragraph + heading_group = [ + Spacer(1, 6), + Paragraph(text, styles['h1']), + HRFlowable( + width="100%", thickness=1, color=accent_teal, + spaceBefore=0, spaceAfter=4, + ), + ] + flowables.append(KeepTogether(heading_group)) + elif level == 3: + flowables.append(Spacer(1, 4)) + flowables.append(Paragraph(text, styles['h2'])) + else: + flowables.append(Paragraph(text, styles['h3'])) + + # --- paragraph --- + elif etype == 'paragraph': + xml = _runs_to_xml(elem['runs']) + if xml.strip(): + flowables.append(Paragraph(xml, styles['body'])) + + # --- image --- + elif etype == 'image': + figure_counter += 1 + img_flowables = _build_image( + elem, base_dir, content_w, styles, figure_counter) + flowables.extend(img_flowables) + + # --- table --- + elif etype == 'table': + flowables.extend(build_table( + elem['headers'], elem['rows'], styles, content_w, + brand=b)) + + # --- code_block --- + elif etype == 'code_block': + code = _escape_xml(elem['code']) + flowables.append(Preformatted(code, styles['code'])) + + # --- bullet_list --- + elif etype == 'bullet_list': + for item_runs in elem['items']: + xml = _runs_to_xml(item_runs) + flowables.append(Paragraph( + f' {xml}', + styles['bullet'], + )) + + # --- numbered_list --- + elif etype == 'numbered_list': + for i, item_runs in enumerate(elem['items'], 1): + xml = _runs_to_xml(item_runs) + flowables.append(Paragraph( + f'{i}. {xml}', + styles['numbered'], + )) + + # --- blockquote --- + elif etype == 'blockquote': + xml = _runs_to_xml(elem['runs']) + flowables.append(Spacer(1, 4)) + flowables.append(CalloutBox( + xml, content_w, callout_bg, callout_border, + styles['callout'], + )) + flowables.append(Spacer(1, 4)) + + # --- hr --- + elif etype == 'hr': + flowables.append(Spacer(1, 4)) + flowables.append(HRFlowable( + width="100%", thickness=0.5, color=RULE_COLOR, + spaceBefore=2, spaceAfter=6, + )) + + return flowables + + +def _build_image(elem: dict, base_dir: str, content_w: float, + styles: dict, figure_num: int) -> list: + """Build image flowable with figure caption. + + Uses svg_to_flowable for vector SVG embedding. Falls back to + placeholder text if file not found. + + Caps image height to fit within the page content area and wraps + figure + caption in KeepTogether to prevent splitting across pages. + Large full-sheet schematics (width > 400pt) get a PageBreak before them. + """ + MAX_IMAGE_HEIGHT = 500 # points — leaves room for header, footer, caption + + path = elem['path'] + if not os.path.isabs(path): + path = os.path.join(base_dir, path) + + flowables = [] + + if not os.path.isfile(path): + flowables.append(Paragraph( + f'[Image not found: {_escape_xml(elem["path"])}]', + styles['figure_caption'], + )) + return flowables + + max_width = content_w + img_flowable = None + if path.lower().endswith('.svg'): + img_flowable = svg_to_flowable(path, max_width) + else: + # Raster image + try: + from reportlab.platypus import Image + img_flowable = Image(path) + if img_flowable.drawWidth > max_width: + scale = max_width / img_flowable.drawWidth + img_flowable.drawWidth *= scale + img_flowable.drawHeight *= scale + except Exception: + flowables.append(Paragraph( + f'[Failed to load image: {_escape_xml(elem["path"])}]', + styles['figure_caption'], + )) + return flowables + + if img_flowable is None: + return flowables + + # Cap image height to fit within the page content frame + img_h = getattr(img_flowable, 'height', 0) or getattr(img_flowable, 'drawHeight', 0) + img_w = getattr(img_flowable, 'width', 0) or getattr(img_flowable, 'drawWidth', 0) + if img_h > MAX_IMAGE_HEIGHT: + ratio = MAX_IMAGE_HEIGHT / img_h + if hasattr(img_flowable, 'height'): + img_flowable.width = img_flowable.width * ratio + img_flowable.height = MAX_IMAGE_HEIGHT + if hasattr(img_flowable, 'drawHeight'): + img_flowable.drawWidth = img_flowable.drawWidth * ratio + img_flowable.drawHeight = MAX_IMAGE_HEIGHT + if hasattr(img_flowable, 'renderScale'): + img_flowable.renderScale = getattr(img_flowable, 'renderScale', 1.0) * ratio + img_w = img_w * ratio + + # Full-sheet schematics get a page break before them + if img_w > 400: + flowables.append(PageBreak()) + + # Build figure group: image + optional caption, kept together + figure_elements = [img_flowable] + + # Figure caption (if alt text provided) + alt = elem.get('alt', '').strip() + if alt: + figure_elements.append(Spacer(1, 4)) + figure_elements.append(Paragraph( + f'Figure {figure_num}: {_escape_xml(alt)}', + styles['figure_caption'], + )) + + flowables.append(KeepTogether(figure_elements)) + return flowables + + +# ====================================================================== +# Main generation +# ====================================================================== + +def generate_pdf(markdown_path: str, output_path: str, config: dict) -> str: + """Convert markdown to PDF with publication-quality styling. + + Returns the output path. + """ + with open(markdown_path, 'r', encoding='utf-8') as f: + md_text = f.read() + + # Strip HTML comments before parsing + md_text = _strip_html_comments(md_text) + + elements = parse_markdown(md_text) + brand = _resolve_branding(config) + styles = create_styles(brand=brand) + base_dir = os.path.dirname(os.path.abspath(markdown_path)) + + # Extract config + project = config.get('project', {}) + reports = config.get('reports', {}) + + title = project.get('name', 'Untitled Document') + subtitle = reports.get('subtitle', project.get('number', '')) + company = project.get('company', '') + classification = reports.get('classification', '') + author = project.get('author', '') + revision = project.get('revision', '') + doc_date = project.get('date', datetime.now().strftime("%Y-%m-%d")) + + # If markdown starts with H1, use that as the title + for elem in elements: + if elem['type'] == 'heading' and elem['level'] == 1: + title = elem['text'] + break + + # Page size + page_size_name = reports.get('page_size', 'letter') + page_size = PAGE_SIZES.get(page_size_name.lower(), letter) + page_w = page_size[0] + margin = inch + content_w = page_w - 2 * margin + + # Build document + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', + exist_ok=True) + + doc = KidocDocTemplate( + output_path, + doc_title=title, + doc_subtitle=subtitle or title, + company=company, + classification=classification, + doc_date=doc_date, + brand=brand, + pagesize=page_size, + topMargin=0.7 * inch, + bottomMargin=0.7 * inch, + leftMargin=margin, + rightMargin=margin, + title=title, + author=author or company, + subject=subtitle or title, + ) + + story = [] + + # Cover page + meta_lines = [] + if project.get('number'): + meta_lines.append(("Document", project['number'])) + if revision: + meta_lines.append(("Revision", revision)) + if author: + meta_lines.append(("Author", author)) + if doc_date: + meta_lines.append(("Date", doc_date)) + if classification: + meta_lines.append(("Classification", classification)) + + story.extend(build_cover(title, subtitle or title, meta_lines, styles, + classification, brand=brand)) + story.append(PageBreak()) + + # Switch to main template after cover + story.append(NextPageTemplate('main')) + + # TOC (only if enough sections) + heading_count = sum( + 1 for e in elements + if e['type'] == 'heading' and e['level'] <= 2 + ) + if heading_count >= 4: + story.extend(build_toc(elements, styles, brand=brand)) + + # Content + story.extend(elements_to_flowables(elements, styles, base_dir, content_w, + brand=brand)) + + doc.build(story) + return output_path + + +def main(): + parser = argparse.ArgumentParser( + description='Generate publication-quality PDF from markdown') + parser.add_argument('--input', '-i', required=True, + help='Input markdown file') + parser.add_argument('--output', '-o', required=True, + help='Output PDF file') + parser.add_argument('--config', '-c', default='{}', + help='JSON config string or path to config file') + args = parser.parse_args() + + # Load config + if os.path.isfile(args.config): + with open(args.config) as f: + config = json.load(f) + else: + config = json.loads(args.config) + + output = generate_pdf(args.input, args.output, config) + print(output, file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_raster.py b/skills/kidoc/scripts/kidoc_raster.py new file mode 100644 index 0000000..c7dc193 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_raster.py @@ -0,0 +1,40 @@ +"""Shared SVG-to-PNG rasterization utility. + +Used by kidoc_docx.py and kidoc_odt.py for embedding figures in +document formats that don't support inline SVG. Wraps the Pillow-based +rasterizer from ``figures.lib.svg_to_png``. +""" + +from __future__ import annotations + +import os +import tempfile + +try: + from figures.lib.svg_to_png import svg_to_png as _svg_to_png_impl + _HAS_SVG_RENDER = True +except ImportError: + _HAS_SVG_RENDER = False + + +def has_svg_render() -> bool: + """Check whether SVG rasterization is available.""" + return _HAS_SVG_RENDER + + +def svg_to_png(svg_path: str, dpi: int = 300) -> str | None: + """Convert SVG to a temporary PNG file. Returns PNG path or None.""" + if not _HAS_SVG_RENDER or not os.path.isfile(svg_path): + return None + try: + fd, png_path = tempfile.mkstemp(suffix='.png') + os.close(fd) + _svg_to_png_impl(svg_path, png_path, dpi=dpi) + return png_path + except Exception: + return None + + +def get_dpi(config: dict) -> int: + """Extract schematic rendering DPI from config.""" + return config.get('reports', {}).get('rendering', {}).get('schematic_dpi', 300) diff --git a/skills/kidoc/scripts/kidoc_scaffold.py b/skills/kidoc/scripts/kidoc_scaffold.py new file mode 100644 index 0000000..d5a3901 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_scaffold.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python3 +"""Markdown scaffold generator for engineering documentation. + +Reads analysis JSONs and .kicad-happy.json config to produce a structured +markdown document with `` markers for +regeneratable content and narrative placeholders for Claude/user prose. + +Usage: + python3 kidoc_scaffold.py --project-dir . --type hdd --output reports/HDD.md + python3 kidoc_scaffold.py --project-dir . --type design_review --output reports/DR.md + python3 kidoc_scaffold.py --project-dir . --config .kicad-happy.json --output reports/ + +Explicit analyzer JSON inputs (bypass the analysis cache lookup — useful +for harness batch runs and any caller that already has analyzer outputs +on disk but not organised under an analysis/manifest.json convention): + + python3 kidoc_scaffold.py --schematic-json sch.json --pcb-json pcb.json \\ + --emc-json emc.json --thermal-json thermal.json --type hdd \\ + --output reports/HDD.md + +Zero external dependencies — Python 3.8+ stdlib only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_kicad_scripts = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', '..', 'kicad', 'scripts') +if os.path.isdir(_kicad_scripts): + sys.path.insert(0, os.path.abspath(_kicad_scripts)) + +from kidoc_sections import ( + section_front_matter, section_executive_summary, + section_system_overview, section_power_design, + section_signal_interfaces, section_analog_design, section_thermal, + section_emc, section_pcb_design, section_bom_summary, + section_test_debug, section_compliance, section_appendix_schematics, + section_mechanical_environmental, + # CE Technical File + section_ce_product_identification, section_ce_essential_requirements, + section_ce_harmonized_standards, section_ce_risk_assessment, + section_ce_declaration_of_conformity, + # Design Review + section_review_summary, section_review_action_items, + # ICD + section_icd_interface_list, section_icd_connector_details, + section_icd_electrical_characteristics, + # Manufacturing + section_mfg_assembly_overview, section_mfg_pcb_fab_notes, + section_mfg_assembly_instructions, section_mfg_test_procedures, +) +from kidoc_templates import get_section_list, get_document_title + +# Try to import project_config for cascading config loading +try: + from project_config import load_config, load_config_from_path +except ImportError: + def load_config(search_dir): + return {'version': 1, 'project': {}, 'suppressions': []} + def load_config_from_path(path): + return {'version': 1, 'project': {}, 'suppressions': []} + + +# ====================================================================== +# Analysis cache loading +# ====================================================================== + +def load_analysis_cache(project_dir: str, + cache_dir: str | None = None) -> dict: + """Load all analysis JSONs from the analysis/ manifest. + + Uses the manifest.json in the analysis directory to find the current + run folder and load all output JSONs from it. + + Falls back to direct file search in cache_dir or project_dir if + no manifest exists (for backwards compat during transition). + + Args: + project_dir: Root directory of the KiCad project. + cache_dir: Override analysis directory path. + + Returns dict with keys: schematic, pcb, emc, thermal, spice, gate. + """ + cache = {} + + # Determine analysis directory + analysis_dir = cache_dir + if not analysis_dir: + # Read output_dir from config + try: + from project_config import load_config + config = load_config(project_dir) + analysis_dir = os.path.join( + project_dir, + config.get('analysis', {}).get('output_dir', 'analysis')) + except ImportError: + analysis_dir = os.path.join(project_dir, 'analysis') + + # Try manifest-based loading + manifest_path = os.path.join(analysis_dir, 'manifest.json') + if os.path.isfile(manifest_path): + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + current_id = manifest.get('current') + if current_id and current_id in manifest.get('runs', {}): + run_dir = os.path.join(analysis_dir, current_id) + run_meta = manifest['runs'][current_id] + for analysis_type, filename in run_meta.get('outputs', {}).items(): + filepath = os.path.join(run_dir, filename) + if os.path.isfile(filepath): + try: + with open(filepath, 'r', encoding='utf-8') as f: + cache[analysis_type] = json.load(f) + except json.JSONDecodeError: + pass + return cache + except (json.JSONDecodeError, OSError): + pass # Fall through to empty return + + # No manifest -- return empty cache + return cache + + +def _load_explicit_jsons(paths: dict) -> dict: + """Load analyzer JSONs from explicit CLI-provided paths. + + Bypasses the manifest-based analysis cache lookup entirely. Used + when a caller (e.g., a batch harness runner) already has analyzer + outputs on disk and doesn't want kidoc to run its own discovery. + + Args: + paths: dict mapping analysis type key ('schematic', 'pcb', 'emc', + 'thermal') to file path, or None for unprovided types. + + Returns: + dict mapping analysis type to loaded JSON dict, for paths that + were provided and loaded successfully. Types with a None path + are skipped. Types with an unreadable or malformed file cause + an error and sys.exit(1) — this function assumes explicit paths + are authoritative and should fail loudly rather than silently + skip. + """ + cache = {} + for analysis_type, path in paths.items(): + if path is None: + continue + if not os.path.isfile(path): + print(f"Error: {analysis_type} JSON not found: {path}", + file=sys.stderr) + sys.exit(1) + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + print(f"Error: cannot load {analysis_type} JSON {path}: {e}", + file=sys.stderr) + sys.exit(1) + if ('signal_analysis' in data and 'findings' not in data + and analysis_type == 'schematic'): + print(f"Error: {path} uses the pre-v1.3 signal_analysis " + f"wrapper format.\nRe-run analyze_schematic.py to " + f"produce the current findings[] format.", + file=sys.stderr) + sys.exit(1) + cache[analysis_type] = data + return cache + + +# ====================================================================== +# Template variable resolution +# ====================================================================== + +def resolve_template_vars(text: str, config: dict) -> str: + """Replace {project}, {rev}, etc. placeholders.""" + project = config.get('project', {}) + replacements = { + '{project}': project.get('name', ''), + '{rev}': project.get('revision', ''), + '{company}': project.get('company', ''), + '{number}': project.get('number', ''), + '{classification}': config.get('reports', {}).get('classification', ''), + '{author}': project.get('author', ''), + } + for key, val in replacements.items(): + text = text.replace(key, val) + return text + + +# ====================================================================== +# Scaffold generation +# ====================================================================== + +def scaffold_document(project_dir: str, doc_type: str, output_path: str, + config: dict, + analysis_cache: dict | None = None, + analysis_dir: str | None = None, + spec: dict | None = None) -> str: + """Generate a markdown scaffold for the specified document type. + + Returns the markdown content (also writes to output_path). + """ + if analysis_cache is None: + analysis_cache = load_analysis_cache(project_dir) + + analysis = analysis_cache.get('schematic', {}) + pcb_data = analysis_cache.get('pcb') + emc_data = analysis_cache.get('emc') + thermal_data = analysis_cache.get('thermal') + + # Determine paths for diagrams and schematic SVGs. + # Figures live under reports/figures/ (git-tracked), separate from + # analysis/ (gitignored, managed by analysis_cache.py) which holds JSON data. + output_abs = os.path.abspath(output_path) + reports_root = os.path.dirname(output_abs) + figures_base = os.path.join(reports_root, 'figures') + diagrams_dir = os.path.join(figures_base, 'diagrams') + sch_cache_dir = os.path.join(figures_base, 'schematics') + + # Use relative paths from the output file's directory + output_dir = os.path.dirname(os.path.abspath(output_path)) + try: + diagrams_rel = os.path.relpath(diagrams_dir, output_dir) + sch_cache_rel = os.path.relpath(sch_cache_dir, output_dir) + except ValueError: + diagrams_rel = diagrams_dir + sch_cache_rel = sch_cache_dir + + # Get sections for this document type (spec overrides config overrides) + if spec: + from kidoc_spec import get_section_types + sections = get_section_types(spec) + else: + sections = get_section_list(doc_type, config) + + gate_data = analysis_cache.get('gate') + + # Build markdown + parts = [] + + section_map = { + # Core sections (HDD) + 'front_matter': lambda: section_front_matter(config, doc_type), + 'executive_summary': lambda: section_executive_summary(analysis, emc_data, thermal_data, pcb_data), + 'system_overview': lambda: section_system_overview(analysis, diagrams_rel), + 'power_design': lambda: section_power_design(analysis, diagrams_rel), + 'signal_interfaces': lambda: section_signal_interfaces(analysis), + 'analog_design': lambda: section_analog_design(analysis, diagrams_rel), + 'thermal_analysis': lambda: section_thermal(thermal_data), + 'emc_analysis': lambda: section_emc(emc_data), + 'pcb_design': lambda: section_pcb_design(pcb_data), + 'mechanical_environmental': lambda: section_mechanical_environmental(analysis, pcb_data), + 'bom_summary': lambda: section_bom_summary(analysis), + 'test_debug': lambda: section_test_debug(analysis), + 'compliance': lambda: section_compliance(analysis, emc_data, config), + 'appendix_schematics': lambda: section_appendix_schematics(sch_cache_rel, analysis, sch_cache_dir), + # CE Technical File + 'ce_product_identification': lambda: section_ce_product_identification(analysis, config), + 'ce_essential_requirements': lambda: section_ce_essential_requirements(analysis, config), + 'ce_harmonized_standards': lambda: section_ce_harmonized_standards(config), + 'ce_risk_assessment': lambda: section_ce_risk_assessment(analysis, emc_data, thermal_data), + 'ce_declaration_of_conformity': lambda: section_ce_declaration_of_conformity(config), + # Design Review + 'review_summary': lambda: section_review_summary(analysis, emc_data, thermal_data, gate_data), + 'review_action_items': lambda: section_review_action_items(config), + # ICD + 'icd_interface_list': lambda: section_icd_interface_list(analysis), + 'icd_connector_details': lambda: section_icd_connector_details(analysis, config), + 'icd_electrical_characteristics': lambda: section_icd_electrical_characteristics(analysis), + # Manufacturing + 'mfg_assembly_overview': lambda: section_mfg_assembly_overview(analysis), + 'mfg_pcb_fab_notes': lambda: section_mfg_pcb_fab_notes(pcb_data), + 'mfg_assembly_instructions': lambda: section_mfg_assembly_instructions(analysis), + 'mfg_test_procedures': lambda: section_mfg_test_procedures(analysis), + } + + for section_name in sections: + generator = section_map.get(section_name) + if generator: + content = generator() + if content is not None: + parts.append(content) + + markdown = "\n".join(parts) + + # Resolve template variables + markdown = resolve_template_vars(markdown, config) + + # Write output (overwrites — use git to track/merge user edits) + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(markdown) + + return markdown + + +# ====================================================================== +# Auto-run analyses +# ====================================================================== + +def _auto_run_analyses(project_dir: str, analysis_dir: str, + figures_dir: str | None = None, + sch_path: str | None = None, + pcb_path: str | None = None) -> dict[str, bool]: + """Auto-run available analyses that haven't been generated yet. + + Args: + figures_dir: Base directory for generated figures (diagrams, schematics). + Defaults to ``analysis_dir`` parent's ``figures/`` sibling when None. + + Returns dict of {analysis_name: was_run_successfully} for reporting. + """ + if figures_dir is None: + # Default: reports/figures/ (sibling of analysis output) + figures_dir = os.path.join(os.path.dirname(os.path.normpath(analysis_dir)), + '..', 'figures') + results = {} + scripts_dir = os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', '..', 'kicad', 'scripts')) + + os.makedirs(analysis_dir, exist_ok=True) + + # Auto-detect schematic and PCB files if not specified + if not sch_path: + for f in Path(project_dir).rglob('*.kicad_sch'): + sch_path = str(f) + break + if not pcb_path: + for f in Path(project_dir).rglob('*.kicad_pcb'): + pcb_path = str(f) + break + + def _run_analysis(name: str, cmd: list[str]) -> None: + """Run an analysis subprocess with timeout, recording result.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120) + results[name] = result.returncode == 0 + except subprocess.TimeoutExpired: + results[name] = False + + # Schematic analysis + sch_json = os.path.join(analysis_dir, 'schematic.json') + if sch_path and not os.path.isfile(sch_json): + analyzer = os.path.join(scripts_dir, 'analyze_schematic.py') + if os.path.isfile(analyzer): + _run_analysis('schematic', + [sys.executable, analyzer, sch_path, + '--output', sch_json]) + + # PCB analysis + pcb_json = os.path.join(analysis_dir, 'pcb.json') + if pcb_path and not os.path.isfile(pcb_json): + analyzer = os.path.join(scripts_dir, 'analyze_pcb.py') + if os.path.isfile(analyzer): + _run_analysis('pcb', + [sys.executable, analyzer, pcb_path, + '--output', pcb_json]) + + # EMC analysis (requires both schematic + PCB JSONs) + emc_json = os.path.join(analysis_dir, 'emc.json') + if (os.path.isfile(sch_json) and os.path.isfile(pcb_json) + and not os.path.isfile(emc_json)): + emc_scripts = os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', '..', 'emc', 'scripts')) + analyzer = os.path.join(emc_scripts, 'analyze_emc.py') + if os.path.isfile(analyzer): + _run_analysis('emc', + [sys.executable, analyzer, + '--schematic', sch_json, '--pcb', pcb_json, + '--output', emc_json]) + + # Thermal analysis (requires both schematic + PCB JSONs) + thermal_json = os.path.join(analysis_dir, 'thermal.json') + if (os.path.isfile(sch_json) and os.path.isfile(pcb_json) + and not os.path.isfile(thermal_json)): + analyzer = os.path.join(scripts_dir, 'analyze_thermal.py') + if os.path.isfile(analyzer): + _run_analysis('thermal', + [sys.executable, analyzer, + '--schematic', sch_json, '--pcb', pcb_json, + '--output', thermal_json]) + + # Figures (diagrams + charts from schematic analysis JSON) + # Run via venv so matplotlib generators can render + diagrams_dir = os.path.join(os.path.normpath(figures_dir), 'diagrams') + if os.path.isfile(sch_json): + try: + from kidoc_venv import ensure_venv + venv_py = ensure_venv(project_dir) + except Exception as exc: + print(f" Warning: venv setup failed ({exc}), " + f"matplotlib figures will be skipped", + file=sys.stderr) + venv_py = sys.executable + + diagrams_script = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'kidoc_diagrams.py') + cmd = [venv_py, diagrams_script, + '--analysis', sch_json, + '--output', diagrams_dir] + if os.path.isfile(emc_json): + cmd.extend(['--emc', emc_json]) + if os.path.isfile(thermal_json): + cmd.extend(['--thermal', thermal_json]) + spice_json = os.path.join(analysis_dir, 'spice.json') + if os.path.isfile(spice_json): + cmd.extend(['--spice', spice_json]) + _run_analysis('diagrams', cmd) + + # Schematic SVG renders (requires .kicad_sch) + sch_cache_dir = os.path.join(os.path.normpath(figures_dir), 'schematics') + if sch_path and not os.path.isdir(sch_cache_dir): + try: + from figures.renderers import render_schematic + os.makedirs(sch_cache_dir, exist_ok=True) + paths = render_schematic(sch_path, sch_cache_dir) + results['renders'] = bool(paths) + except (OSError, ValueError) as exc: + print(f" Warning: schematic render failed: {exc}", + file=sys.stderr) + results['renders'] = False + + return results + + +def _print_analysis_summary(results: dict, analysis_dir: str, + figures_dir: str | None = None) -> None: + """Print what analyses are available and what's missing.""" + available = [] + missing = [] + + checks = { + 'schematic': 'schematic.json', + 'pcb': 'pcb.json', + 'emc': 'emc.json', + 'thermal': 'thermal.json', + 'spice': 'spice.json', + } + + for name, filename in checks.items(): + path = os.path.join(analysis_dir, filename) + if os.path.isfile(path): + if name in results: + available.append(f" {name}: auto-generated") + else: + available.append(f" {name}: found") + else: + if name in results: + missing.append(f" {name}: auto-run failed") + elif name == 'spice': + missing.append(f" {name}: requires manual SPICE simulation") + else: + missing.append(f" {name}: not available (no source data)") + + # Check diagrams and renders (under figures/ directory) + fig_base = figures_dir or os.path.join( + os.path.dirname(os.path.normpath(analysis_dir)), '..', 'figures') + diagrams_dir = os.path.join(os.path.normpath(fig_base), 'diagrams') + if os.path.isdir(diagrams_dir): + if 'diagrams' in results: + available.append(" diagrams: auto-generated") + else: + available.append(" diagrams: found") + else: + missing.append(" diagrams: not generated") + + sch_fig_dir = os.path.join(os.path.normpath(fig_base), 'schematics') + if os.path.isdir(sch_fig_dir): + if 'renders' in results: + available.append(" renders: auto-generated") + else: + available.append(" renders: found") + else: + missing.append(" renders: not generated (needs kicad-cli)") + + if available: + print("Analysis data:", file=sys.stderr) + for a in available: + print(a, file=sys.stderr) + if missing: + print("Not included (run separately to add):", file=sys.stderr) + for m in missing: + print(m, file=sys.stderr) + + +# ====================================================================== +# Main +# ====================================================================== + +def main(): + parser = argparse.ArgumentParser( + description='Generate markdown scaffold for engineering documentation') + parser.add_argument('--project-dir', '-p', default='.', + help='Path to KiCad project directory') + parser.add_argument('--type', '-t', default='hdd', + choices=['hdd', 'ce_technical_file', 'design_review', + 'icd', 'manufacturing', + 'schematic_review', 'power_analysis', + 'emc_report'], + help='Document type (default: hdd)') + parser.add_argument('--spec', default=None, + help='Path to document spec JSON (overrides --type)') + parser.add_argument('--output', '-o', required=True, + help='Output markdown file path') + parser.add_argument('--config', default=None, + help='Path to .kicad-happy.json config') + parser.add_argument('--analysis-dir', default=None, + help='Directory containing analysis JSONs') + parser.add_argument('--analyze', action='store_true', + help='Run KiCad analysis scripts if no analysis data ' + 'exists. Without this flag, errors when analysis ' + 'is missing.') + parser.add_argument('--schematic-json', default=None, + help='Explicit path to schematic analyzer JSON. ' + 'When any of --schematic-json/--pcb-json/' + '--emc-json/--thermal-json is provided, the ' + 'manifest-based analysis cache lookup is ' + 'bypassed entirely.') + parser.add_argument('--pcb-json', default=None, + help='Explicit path to PCB analyzer JSON. See ' + '--schematic-json for mode notes.') + parser.add_argument('--emc-json', default=None, + help='Explicit path to EMC analyzer JSON. See ' + '--schematic-json for mode notes.') + parser.add_argument('--thermal-json', default=None, + help='Explicit path to thermal analyzer JSON. See ' + '--schematic-json for mode notes.') + args = parser.parse_args() + + # Load spec (--spec overrides --type) + if args.spec: + from kidoc_spec import load_spec + spec = load_spec(args.spec) + doc_type = spec.get('type', 'custom') + else: + from kidoc_spec import load_builtin_spec + spec = load_builtin_spec(args.type) + doc_type = args.type + + # Load config + if args.config: + config = load_config_from_path(args.config) + else: + config = load_config(args.project_dir) + + # Determine analysis directory from config or CLI + if args.analysis_dir: + analysis_dir = args.analysis_dir + else: + analysis_dir = os.path.join( + args.project_dir, + config.get('analysis', {}).get('output_dir', 'analysis')) + + # Figures (diagrams, schematics) go under reports/figures/ (git-tracked), + # separate from analysis/ which holds analysis JSONs. + output_dir = os.path.dirname(os.path.abspath(args.output)) + figures_dir = os.path.join(output_dir, 'figures') + + # Explicit JSON inputs bypass the manifest-based cache entirely. + # If any of --schematic-json/--pcb-json/--emc-json/--thermal-json + # is provided, _load_explicit_jsons is authoritative and we skip + # both the cache lookup and --analyze auto-run. + explicit_jsons = { + 'schematic': args.schematic_json, + 'pcb': args.pcb_json, + 'emc': args.emc_json, + 'thermal': args.thermal_json, + } + has_explicit = any(p is not None for p in explicit_jsons.values()) + + if has_explicit: + if args.analyze: + print('Error: --analyze cannot be combined with explicit ' + '--schematic-json/--pcb-json/--emc-json/--thermal-json ' + 'flags. Explicit JSONs bypass the analysis pipeline; ' + 'drop --analyze or drop the explicit flags.', + file=sys.stderr) + sys.exit(1) + cache = _load_explicit_jsons(explicit_jsons) + else: + # Load analysis cache from manifest + cache = load_analysis_cache(args.project_dir, analysis_dir) + + if not cache: + if args.analyze: + # Run analyses using existing _auto_run_analyses + auto_results = _auto_run_analyses(args.project_dir, analysis_dir, + figures_dir=figures_dir) + cache = load_analysis_cache(args.project_dir, analysis_dir) + _print_analysis_summary(auto_results, analysis_dir, + figures_dir=figures_dir) + if not cache: + print('Error: Analysis scripts ran but produced no output.', + file=sys.stderr) + sys.exit(1) + else: + print(f'Error: No analysis data found in ' + f'{analysis_dir}.\n' + 'Run with --analyze to generate it, or run the ' + 'KiCad analysis skill first.', + file=sys.stderr) + sys.exit(1) + + # Generate scaffold + scaffold_document( + project_dir=args.project_dir, + doc_type=doc_type, + output_path=args.output, + config=config, + analysis_cache=cache, + analysis_dir=analysis_dir, + spec=spec, + ) + + print(args.output, file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_sections.py b/skills/kidoc/scripts/kidoc_sections.py new file mode 100644 index 0000000..c7778e6 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_sections.py @@ -0,0 +1,1195 @@ +"""Section content generators for the markdown scaffold. + +Each function generates clean markdown directly from analysis data. +No template markers in the output — the generated markdown is the final +document. On regeneration, the scaffold is re-generated and the user +reconciles with their edits via git diff/merge. + +Narrative prompts are italic placeholder text that the user or the agent +replaces with real prose. + +Zero external dependencies — Python stdlib only. +""" + +from __future__ import annotations + +from kidoc_tables import ( + markdown_table, format_voltage, format_frequency, + format_current, format_capacitance, format_resistance, +) +from finding_schema import Det, group_findings + + +def _auto(section_id: str, content: str) -> str: + """Emit auto-generated content directly. No markers needed.""" + return content + + +def _narrative(section_id: str, hint: str = "") -> str: + """Generate a narrative prompt placeholder. + + Italic text that the user or Claude replaces with real prose. + """ + return f"*[{hint or 'Describe the design decisions and rationale for this section.'}]*" + + +# ====================================================================== +# Front matter +# ====================================================================== + +def section_front_matter(config: dict, doc_type: str) -> str: + """Generate title page and revision history.""" + project = config.get('project', {}) + name = project.get('name', 'Untitled Project') + number = project.get('number', '') + revision = project.get('revision', '') + company = project.get('company', '') + author = project.get('author', '') + + doc_titles = { + 'hdd': 'Hardware Design Description', + 'ce_technical_file': 'CE Technical File', + 'design_review': 'Design Review Package', + 'icd': 'Interface Control Document', + 'manufacturing': 'Manufacturing Transfer Package', + } + doc_title = doc_titles.get(doc_type, 'Engineering Document') + + lines = [f"# {doc_title}"] + lines.append("") + lines.append(_auto("front_matter_info", "\n".join(filter(None, [ + f"**Project:** {name}" if name else None, + f"**Document Number:** {number}" if number else None, + f"**Revision:** {revision}" if revision else None, + f"**Company:** {company}" if company else None, + f"**Author:** {author}" if author else None, + ])))) + lines.append("") + + # Revision history + rev_history = config.get('reports', {}).get('revision_history', []) + if rev_history: + rows = [[r.get('rev', ''), r.get('date', ''), r.get('author', ''), + r.get('description', '')] for r in rev_history] + lines.append("## Revision History") + lines.append("") + lines.append(_auto("revision_history", + markdown_table(['Rev', 'Date', 'Author', 'Description'], rows))) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Executive summary +# ====================================================================== + +def section_executive_summary(analysis: dict, emc_data: dict | None, + thermal_data: dict | None, + pcb_data: dict | None) -> str: + """Auto-generated one-paragraph project overview.""" + lines = ["## Executive Summary"] + lines.append("") + + stats = analysis.get('statistics', {}) + total = stats.get('total_components', 0) + unique = stats.get('unique_parts', 0) + nets = stats.get('total_nets', 0) + sheets = stats.get('sheets', 1) + + # Identify key ICs + _sa = group_findings(analysis) + regulators = _sa.get(Det.POWER_REGULATORS, []) + mcus = [c for c in analysis.get('components', []) + if c.get('type') == 'ic' and any(k in c.get('lib_id', '').lower() + for k in ('mcu', 'stm32', 'esp32', 'rp2040', 'atmega', 'nrf', + 'samd', 'wroom', 'wrover', 'microcontroller'))] + + parts = [] + parts.append(f"This design contains **{total} components** ({unique} unique parts) " + f"across **{nets} nets**" + + (f" on {sheets} schematic sheets" if sheets > 1 else "") + + ".") + + if mcus: + mcu_list = ', '.join(c.get('value', c.get('reference', '?')) for c in mcus[:3]) + parts.append(f"The primary processor is **{mcu_list}**.") + + if regulators: + rails = [f"{r.get('output_rail', '?')} ({r.get('estimated_vout', '?')}V)" + for r in regulators if r.get('estimated_vout')] + if rails: + parts.append(f"Power rails: {', '.join(rails)}.") + + # PCB info + if pcb_data: + pcb_stats = pcb_data.get('statistics', {}) + layers = pcb_stats.get('copper_layers', '') + outline = pcb_data.get('board_outline', {}) + dims = '' + if outline: + w = outline.get('width_mm') + h = outline.get('height_mm') + if w and h: + dims = f" ({w}×{h}mm)" + if layers: + parts.append(f"{layers}-layer PCB{dims}, " + f"{pcb_stats.get('routing_completion', '?')}% routed.") + + # EMC summary + if emc_data: + emc_sum = emc_data.get('summary', {}) + score = emc_sum.get('emc_risk_score') + if score is not None: + parts.append(f"EMC risk score: {score}/100.") + + lines.append(' '.join(parts)) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# System overview +# ====================================================================== + +def section_system_overview(analysis: dict, diagrams_dir: str) -> str: + """Generate system overview section.""" + lines = ["## 2. System Overview"] + lines.append("") + + # Architecture diagram + lines.append(f"![System Architecture]({diagrams_dir}/architecture.svg)") + lines.append("") + lines.append(_narrative("system_overview_description", + "Describe the system's purpose, key functions, and " + "high-level architecture. Reference the block diagram above.")) + lines.append("") + + # Statistics table — include PCB data if available from analysis + stats = analysis.get('statistics', {}) + if stats: + rows = [ + ['Total components', str(stats.get('total_components', 0))], + ['Unique parts', str(stats.get('unique_parts', 0))], + ['Nets', str(stats.get('total_nets', 0))], + ['Schematic sheets', str(stats.get('sheets', 1))], + ] + # Add SMD/THT if available + smd = stats.get('smd_count') + tht = stats.get('tht_count') + if smd is not None or tht is not None: + rows.append(['SMD / THT', f"{smd or 0} / {tht or 0}"]) + # Add DNP if any + dnp = stats.get('dnp_count', 0) + if dnp: + rows.append(['Do Not Populate', str(dnp)]) + # Missing MPNs + missing = stats.get('missing_mpns', 0) + if missing: + rows.append(['Missing MPNs', str(missing)]) + lines.append(markdown_table(['Metric', 'Value'], rows)) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Power design +# ====================================================================== + +def section_power_design(analysis: dict, diagrams_dir: str) -> str: + """Generate power system design section.""" + lines = ["## 3. Power System Design"] + lines.append("") + + # Power tree diagram + lines.append(_auto("power_tree_diagram", + f"![Power Tree]({diagrams_dir}/power_tree.svg)")) + lines.append("") + lines.append(_narrative("power_design_rationale", + "Describe the power architecture: input voltage range, " + "why this topology was chosen, efficiency targets, thermal constraints.")) + lines.append("") + + # Power regulators table + _sa = group_findings(analysis) + regulators = _sa.get(Det.POWER_REGULATORS, []) + if regulators: + rows = [] + for reg in regulators: + vout = reg.get('estimated_vout') or reg.get('output_voltage') + rows.append([ + reg.get('ref', '?'), + reg.get('value', ''), + reg.get('topology', ''), + reg.get('input_rail', '?'), + reg.get('output_rail', '?'), + format_voltage(vout), + ]) + lines.append(_auto("power_rail_table", + markdown_table( + ['Ref', 'Part', 'Topology', 'Input Rail', 'Output Rail', 'Vout'], + rows))) + lines.append("") + + # Decoupling analysis + decoupling = _sa.get(Det.DECOUPLING, []) + if decoupling: + rows = [] + for d in decoupling: + refs = d.get('capacitors', []) + cap_refs = ', '.join(c.get('ref', '') for c in refs) if isinstance(refs, list) else '' + # IC ref — use ic_ref, fall back to rail name + ic_ref = d.get('ic_ref') or d.get('ic') or '' + if not ic_ref or ic_ref == '?': + ic_ref = d.get('rail', '?') + ' rail' + total_uf = sum(c.get('farads', 0) for c in refs if isinstance(c, dict)) * 1e6 + total_str = f"{total_uf:.0f}µF" if total_uf >= 1 else '' + rows.append([ + ic_ref, + d.get('rail', '?'), + cap_refs, + total_str, + ]) + lines.append("### Decoupling") + lines.append("") + lines.append(markdown_table(['IC', 'Rail', 'Capacitors', 'Total'], rows)) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Signal interfaces +# ====================================================================== + +def section_signal_interfaces(analysis: dict) -> str: + """Generate signal interfaces section.""" + lines = ["## 4. Signal Interfaces"] + lines.append("") + + bus_analysis = analysis.get('design_analysis', {}).get('bus_analysis', {}) + any_bus = False + + for bus_type in ('i2c', 'spi', 'uart', 'can'): + buses = bus_analysis.get(bus_type, []) + if not buses: + continue + for i, bus in enumerate(buses): + signals = bus.get('signals', []) + sig_names = [s.get('name', str(s)) if isinstance(s, dict) else str(s) + for s in signals] + # Skip buses with no signal names (empty entries look broken) + if not sig_names or all(not s for s in sig_names): + continue + any_bus = True + if not any(l.startswith(f"### {bus_type.upper()}") for l in lines): + lines.append(f"### {bus_type.upper()}") + lines.append("") + bus_id = bus.get('bus_id', f'{bus_type}_{i}') + lines.append(f"**{bus_id}**: {', '.join(sig_names[:10])}") + lines.append("") + + if not any_bus: + lines.append("*No formal buses detected.*") + lines.append("") + + # Level shifters + _sa = group_findings(analysis) + shifters = _sa.get(Det.LEVEL_SHIFTERS, []) + if shifters: + lines.append("### Level Shifting") + lines.append("") + rows = [[s.get('ref', '?'), s.get('value', ''), + s.get('low_side_rail', '?'), s.get('high_side_rail', '?')] + for s in shifters] + lines.append(_auto("level_shifters", + markdown_table(['Ref', 'Part', 'Low Side', 'High Side'], rows))) + lines.append("") + + lines.append(_narrative("signal_interfaces_notes", + "Describe interface design decisions: " + "pull-up values, termination, protection, signal integrity.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Analog design +# ====================================================================== + +def section_analog_design(analysis: dict, diagrams_dir: str) -> str: + """Generate analog design section.""" + lines = ["## 5. Analog Design"] + lines.append("") + + sa = group_findings(analysis) + + # Voltage dividers + dividers = sa.get(Det.VOLTAGE_DIVIDERS, []) + if dividers: + lines.append("### Voltage Dividers") + lines.append("") + rows = [] + for d in dividers: + mid_net = d.get('mid_net', '?') + # Replace internal net names with what the divider connects to + if mid_net.startswith('__unnamed') or mid_net.startswith('Net-'): + # Check if this is a feedback divider for a regulator + connections = d.get('mid_point_connections', []) + if connections: + # Format as "U3 FB, C12" from the connection dicts + parts = [] + for c in connections[:3]: + if isinstance(c, dict): + comp = c.get('component', '') + pin = c.get('pin_name', '') + if comp and pin: + parts.append(f"{comp} {pin}") + elif comp: + parts.append(comp) + else: + parts.append(str(c)) + mid_net = ', '.join(parts) if parts else "(internal)" + else: + mid_net = "(internal)" + rows.append([ + d.get('r_top', {}).get('ref', '?'), + d.get('r_bottom', {}).get('ref', '?'), + f"{d.get('ratio', 0):.3f}", + mid_net, + ]) + lines.append(markdown_table(['R_top', 'R_bottom', 'Ratio', 'Output Net'], rows)) + lines.append("") + + # Filters + for ftype, label in [(Det.RC_FILTERS, 'RC Filters'), (Det.LC_FILTERS, 'LC Filters')]: + filters = sa.get(ftype, []) + if filters: + lines.append(f"### {label}") + lines.append("") + rows = [] + for f in filters: + fc = f.get('cutoff_hz') + rows.append([ + f.get('type', '?'), + f.get('resistor', {}).get('ref', '?') if isinstance(f.get('resistor'), dict) else str(f.get('resistor', '?')), + f.get('capacitor', {}).get('ref', '?') if isinstance(f.get('capacitor'), dict) else str(f.get('capacitor', '?')), + format_frequency(fc), + ]) + lines.append(_auto(f"{ftype}_table", + markdown_table(['Type', 'R', 'C', 'Cutoff'], rows))) + lines.append("") + + # Crystal circuits + crystals = sa.get(Det.CRYSTAL_CIRCUITS, []) + if crystals: + lines.append("### Crystal / Oscillator") + lines.append("") + for c in crystals: + freq = c.get('frequency_hz') + lines.append(_auto(f"crystal_{c.get('ref', 'X')}", + f"**{c.get('ref', '?')}**: {format_frequency(freq)}")) + lines.append("") + + # Op-amp circuits + opamps = sa.get(Det.OPAMP_CIRCUITS, []) + if opamps: + lines.append("### Op-Amp Circuits") + lines.append("") + rows = [] + for o in opamps: + rows.append([ + o.get('ref', '?'), + o.get('value', ''), + o.get('topology', '?'), + str(o.get('gain', '—')), + ]) + lines.append(_auto("opamp_table", + markdown_table(['Ref', 'Part', 'Topology', 'Gain'], rows))) + lines.append("") + + if not any([dividers, sa.get(Det.RC_FILTERS), sa.get(Det.LC_FILTERS), + crystals, opamps]): + lines.append("*No analog subcircuits detected.*") + lines.append("") + + lines.append(_narrative("analog_design_notes", + "Describe analog design decisions: component selection rationale, " + "SPICE verification results, tolerance analysis.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Thermal analysis +# ====================================================================== + +def section_thermal(thermal_data: dict | None) -> str: + """Generate thermal analysis section from analyze_thermal output.""" + lines = ["## 6. Thermal Analysis"] + lines.append("") + + if not thermal_data: + return None + + summary = thermal_data.get('summary', {}) + lines.append(_auto("thermal_summary", + f"**Thermal Score:** {summary.get('thermal_score', '—')}/100 | " + f"**Hottest Component:** {summary.get('hottest_component', '—')} | " + f"**Components >85°C:** {summary.get('components_above_85c', 0)}")) + lines.append("") + + assessments = thermal_data.get('thermal_assessments', []) + if assessments: + rows = [] + for a in assessments: + rows.append([ + a.get('ref', '?'), + a.get('value', ''), + a.get('package', ''), + f"{a.get('pdiss_w', 0):.2f}W", + f"{a.get('tj_estimated_c', 0):.0f}°C", + f"{a.get('margin_c', 0):.0f}°C", + ]) + lines.append(_auto("thermal_table", + markdown_table( + ['Ref', 'Part', 'Package', 'Pdiss', 'Tj Est', 'Margin'], + rows, ['left', 'left', 'left', 'right', 'right', 'right']))) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# EMC considerations +# ====================================================================== + +def section_emc(emc_data: dict | None) -> str: + """Generate EMC section from analyze_emc output.""" + lines = ["## 7. EMC Considerations"] + lines.append("") + + if not emc_data: + return None + + summary = emc_data.get('summary', {}) + lines.append(_auto("emc_summary", + f"**EMC Risk Score:** {summary.get('emc_risk_score', '—')}/100 | " + f"**Critical:** {summary.get('critical', 0)} | " + f"**High:** {summary.get('high', 0)} | " + f"**Medium:** {summary.get('medium', 0)}")) + lines.append("") + + findings = emc_data.get('findings', []) + active = [f for f in findings if not f.get('suppressed')] + if active: + # Group by category, then show summary + details + from collections import OrderedDict + by_category: dict[str, list] = OrderedDict() + sev_order = {'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3, 'INFO': 4} + for f in sorted(active, key=lambda x: sev_order.get(x.get('severity', 'INFO'), 5)): + cat = f.get('category', 'other') + by_category.setdefault(cat, []).append(f) + + # Category summary table + cat_rows = [] + for cat, cat_findings in by_category.items(): + sev_counts = {} + for f in cat_findings: + s = f.get('severity', 'INFO') + sev_counts[s] = sev_counts.get(s, 0) + 1 + sev_str = ', '.join(f"{c}×{s}" for s, c in + sorted(sev_counts.items(), + key=lambda x: sev_order.get(x[0], 5))) + cat_rows.append([ + cat.replace('_', ' ').title(), + str(len(cat_findings)), + sev_str, + ]) + lines.append("### Findings by Category") + lines.append("") + lines.append(markdown_table(['Category', 'Count', 'Severity Breakdown'], cat_rows)) + lines.append("") + + # Top findings detail (limit to most severe) + top = [f for f in active if f.get('severity') in ('CRITICAL', 'HIGH')][:15] + if top: + lines.append("### Critical and High Findings") + lines.append("") + detail_rows = [] + for f in top: + detail_rows.append([ + f.get('severity', '?'), + f.get('rule_id', '?'), + f.get('title', ''), + ]) + lines.append(markdown_table(['Severity', 'Rule', 'Finding'], detail_rows)) + lines.append("") + lines.append("") + lines.append(_narrative("emc_notes", + "Describe EMC design strategy: shielding, filtering, " + "layout decisions for emissions compliance.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# PCB design +# ====================================================================== + +def section_pcb_design(pcb_data: dict | None) -> str | None: + """Generate PCB design section from analyze_pcb output.""" + if not pcb_data: + return None + + lines = ["## 8. PCB Design Details"] + lines.append("") + + stats = pcb_data.get('statistics', {}) + if stats: + rows = [ + ['Copper layers', str(stats.get('copper_layers', '?'))], + ['Footprints (front/back)', f"{stats.get('front_footprints', '?')}/{stats.get('back_footprints', '?')}"], + ['Track segments', str(stats.get('track_segments', '?'))], + ['Vias', str(stats.get('via_count', '?'))], + ['Routing completion', f"{stats.get('routing_completion', '?')}%"], + ] + lines.append(_auto("pcb_stats", + markdown_table(['Metric', 'Value'], rows))) + lines.append("") + + # Board outline + outline = pcb_data.get('board_outline', {}) + if outline: + w = outline.get('width_mm', '?') + h = outline.get('height_mm', '?') + lines.append(_auto("board_dimensions", + f"**Board Dimensions:** {w}mm × {h}mm")) + lines.append("") + + lines.append(_narrative("pcb_design_notes", + "Describe PCB layout decisions: stackup, impedance control, " + "routing strategy, DFM considerations.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# BOM summary +# ====================================================================== + +def section_bom_summary(analysis: dict) -> str: + """Generate BOM summary section.""" + lines = ["## 10. BOM Summary"] + lines.append("") + + bom = analysis.get('bom', []) + if not bom: + lines.append("*No BOM data available.*") + lines.append("") + return "\n".join(lines) + + rows = [] + for item in bom: + refs = item.get('references', []) + ref_str = ', '.join(refs[:5]) + if len(refs) > 5: + ref_str += f" +{len(refs) - 5}" + rows.append([ + ref_str, + item.get('value', ''), + item.get('footprint', '').split(':')[-1] if item.get('footprint') else '', + item.get('mpn', ''), + str(item.get('quantity', len(refs))), + ]) + + lines.append(_auto("bom_table", + markdown_table(['References', 'Value', 'Footprint', 'MPN', 'Qty'], + rows[:50], + ['left', 'left', 'left', 'left', 'right']))) + if len(rows) > 50: + lines.append(f"*... and {len(rows) - 50} more line items.*") + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Test and debug +# ====================================================================== + +def section_test_debug(analysis: dict) -> str: + """Generate test and debug section.""" + lines = ["## 11. Test and Debug"] + lines.append("") + + # Debug interfaces + debug = group_findings(analysis).get(Det.DEBUG_INTERFACES, []) + if debug: + rows = [[d.get('ref', '?'), d.get('type', ''), d.get('protocol', '')] + for d in debug] + lines.append(_auto("debug_interfaces", + markdown_table(['Ref', 'Type', 'Protocol'], rows))) + lines.append("") + + lines.append(_narrative("test_strategy", + "Describe the testing approach: test points, production test " + "procedures, debug access, programming interface.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Compliance +# ====================================================================== + +def section_compliance(analysis: dict, emc_data: dict | None, + config: dict) -> str | None: + """Generate compliance and standards section.""" + market = config.get('project', {}).get('market', '') + if not emc_data and not market: + return None + + lines = ["## 12. Compliance and Standards"] + lines.append("") + if market: + lines.append(_auto("target_market", f"**Target Market:** {market.upper()}")) + lines.append("") + + # EMC test plan + if emc_data: + test_plan = emc_data.get('test_plan', {}) + if test_plan: + lines.append("### EMC Test Plan") + lines.append("") + lines.append(_auto("emc_test_plan", + f"*See EMC analysis output for detailed test plan.*")) + lines.append("") + + lines.append(_narrative("compliance_notes", + "List applicable standards (FCC, CE, UL), " + "certification strategy, and pre-compliance test results.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Appendices +# ====================================================================== + +def section_appendix_schematics(sch_cache_dir: str, + analysis: dict, + sch_cache_abs: str | None = None) -> str: + """Generate appendix with full schematic sheet images. + + Args: + sch_cache_dir: Relative path for markdown image links. + analysis: Schematic analysis data. + sch_cache_abs: Absolute path for filesystem checks (falls back to + sch_cache_dir if not provided). + """ + lines = ["## Appendix A: Schematic Drawings"] + lines.append("") + + # Use absolute path for filesystem checks, relative for markdown links + import os + check_dir = sch_cache_abs or sch_cache_dir + if os.path.isdir(check_dir): + svgs = sorted(f for f in os.listdir(check_dir) if f.endswith('.svg')) + if svgs: + for svg_file in svgs: + name = svg_file.replace('.svg', '').replace('_', ' ') + lines.append(f"### {name}") + lines.append("") + lines.append(f"![{name}]({sch_cache_dir}/{svg_file})") + lines.append("") + else: + lines.append("*No schematic SVGs found. Run kidoc_render.py first.*") + else: + lines.append(f"*Schematic cache directory not found: {sch_cache_dir}*") + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# CE Technical File — specialized sections +# ====================================================================== + +def section_ce_product_identification(analysis: dict, config: dict) -> str: + """CE Technical File: product identification.""" + lines = ["## Product Identification"] + lines.append("") + + project = config.get('project', {}) + rows = [ + ['Product Name', project.get('name', '—')], + ['Model / Part Number', project.get('number', '—')], + ['Revision', project.get('revision', '—')], + ['Manufacturer', project.get('company', '—')], + ['Intended Use', ''], + ] + lines.append(_auto("ce_product_id", markdown_table(['Field', 'Value'], rows))) + lines.append("") + lines.append(_narrative("ce_intended_use", + "Describe the product's intended use, target environment " + "(indoor/outdoor, industrial/consumer), and user profile.")) + lines.append("") + return "\n".join(lines) + + +def section_ce_essential_requirements(analysis: dict, config: dict) -> str: + """CE Technical File: essential requirements mapping.""" + lines = ["## Essential Requirements"] + lines.append("") + lines.append("Mapping of EU directive essential requirements to design evidence.") + lines.append("") + + # Determine applicable directives based on market + rows = [ + ['LVD 2014/35/EU', 'Electrical safety', 'EN 62368-1', ''], + ['EMC 2014/30/EU', 'Electromagnetic compatibility', 'EN 55032, EN 55035', ''], + ['RoHS 2011/65/EU', 'Hazardous substance restriction', 'EN IEC 63000', ''], + ] + + # Add radio if applicable + sa = group_findings(analysis) + if sa.get(Det.RF_CHAINS) or sa.get(Det.RF_MATCHING): + rows.append(['RED 2014/53/EU', 'Radio equipment', 'EN 300 328, EN 301 489', '']) + + lines.append(_auto("ce_essential_reqs", + markdown_table( + ['Directive', 'Requirement', 'Harmonized Standard', 'Evidence'], + rows))) + lines.append("") + lines.append(_narrative("ce_essential_req_notes", + "For each directive, describe how the design meets the " + "essential requirements. Reference test reports and analysis data.")) + lines.append("") + return "\n".join(lines) + + +def section_ce_harmonized_standards(config: dict) -> str: + """CE Technical File: harmonized standards list.""" + lines = ["## Harmonized Standards Applied"] + lines.append("") + + # Get standards from config or use defaults for EU market + reports = config.get('reports', {}) + standards = [] + for doc_def in reports.get('documents', []): + if doc_def.get('type') == 'ce_technical_file': + standards = doc_def.get('standards', []) + break + + if not standards: + standards = ['EN 55032', 'EN 55035', 'EN 62368-1', 'EN IEC 63000'] + + rows = [] + standard_descriptions = { + 'EN 55032': ('EMC emissions', 'Limits for electromagnetic disturbances'), + 'EN 55035': ('EMC immunity', 'Immunity requirements for multimedia equipment'), + 'EN 62368-1': ('Safety', 'Audio/video, IT and communication technology equipment'), + 'EN IEC 63000': ('RoHS', 'Technical documentation for hazardous substance assessment'), + 'EN 300 328': ('Radio', 'Wideband data transmission (2.4 GHz)'), + 'EN 301 489': ('Radio EMC', 'EMC standard for radio equipment'), + 'EN 61000-4-2': ('ESD immunity', 'Electrostatic discharge immunity test'), + 'EN 61000-4-3': ('Radiated immunity', 'Radiated RF electromagnetic field immunity'), + } + for std in standards: + category, desc = standard_descriptions.get(std, ('', std)) + rows.append([std, category, desc]) + + lines.append(_auto("ce_standards", + markdown_table(['Standard', 'Category', 'Description'], rows))) + lines.append("") + return "\n".join(lines) + + +def section_ce_risk_assessment(analysis: dict, emc_data: dict | None, + thermal_data: dict | None) -> str: + """CE Technical File: risk assessment.""" + lines = ["## Risk Assessment"] + lines.append("") + + rows = [ + ['Electrical shock', 'Low voltage (<50V DC)', '', ''], + ['Overheating / fire', '', '', ''], + ['EMI emissions', '', '', ''], + ['ESD susceptibility', '', '', ''], + ['Mechanical hazard', '', '', ''], + ] + + # Populate from analysis data + if thermal_data: + summary = thermal_data.get('summary', {}) + hottest = summary.get('hottest_component', '—') + above_85 = summary.get('components_above_85c', 0) + rows[1][1] = f"Hottest: {hottest}" + rows[1][2] = 'HIGH' if above_85 > 0 else 'LOW' + rows[1][3] = f"{above_85} components above 85°C" if above_85 else 'Within limits' + + if emc_data: + summary = emc_data.get('summary', {}) + score = summary.get('emc_risk_score', '—') + critical = summary.get('critical', 0) + rows[2][1] = f"EMC risk score: {score}/100" + rows[2][2] = 'HIGH' if critical > 0 else ('MEDIUM' if score and score > 50 else 'LOW') + rows[2][3] = f"{critical} critical findings" if critical else 'Pre-compliance assessment' + + # ESD from analysis + esd_audit = group_findings(analysis).get(Det.ESD_AUDIT, []) + unprotected = 0 + for e in esd_audit: + if isinstance(e, dict): + try: + cov = float(e.get('coverage', 1.0)) + if cov < 1.0: + unprotected += 1 + except (TypeError, ValueError): + pass + if unprotected: + rows[3][1] = f"{unprotected} connectors with gaps" + rows[3][2] = 'MEDIUM' + rows[3][3] = 'Partial ESD protection coverage' + else: + rows[3][1] = 'All connectors protected' + rows[3][2] = 'LOW' + + lines.append(_auto("ce_risk_assessment", + markdown_table(['Hazard', 'Details', 'Risk Level', 'Mitigation'], rows))) + lines.append("") + lines.append(_narrative("ce_risk_notes", + "Describe risk mitigation measures for each identified hazard. " + "Reference specific design features and test results.")) + lines.append("") + return "\n".join(lines) + + +def section_ce_declaration_of_conformity(config: dict) -> str: + """CE Technical File: Declaration of Conformity template.""" + lines = ["## EU Declaration of Conformity"] + lines.append("") + + project = config.get('project', {}) + lines.append(_auto("ce_doc_template", "\n".join([ + "**EU DECLARATION OF CONFORMITY**", + "", + f"**Manufacturer:** {project.get('company', '________________')}", + f"**Product:** {project.get('name', '________________')}", + f"**Model:** {project.get('number', '________________')}", + "", + "We declare under our sole responsibility that the product described above " + "is in conformity with the relevant Union harmonisation legislation:", + "", + "- Directive 2014/35/EU (Low Voltage Directive)", + "- Directive 2014/30/EU (EMC Directive)", + "- Directive 2011/65/EU (RoHS Directive)", + "", + "Harmonized standards applied: *(see Harmonized Standards section)*", + "", + "Signed: ________________ Date: ________________", + "", + "Name: ________________ Position: ________________", + ]))) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Design Review — specialized sections +# ====================================================================== + +def section_review_summary(analysis: dict, emc_data: dict | None, + thermal_data: dict | None, + gate_data: dict | None) -> str: + """Design Review: summary of findings across all analyzers.""" + lines = ["## Review Summary"] + lines.append("") + + rows = [] + + # Fab gate + if gate_data: + status = gate_data.get('overall_status', '?') + summary = gate_data.get('summary', {}) + rows.append(['Fab Release Gate', status, + f"{summary.get('pass', 0)} pass, {summary.get('fail', 0)} fail"]) + + # EMC + if emc_data: + summary = emc_data.get('summary', {}) + score = summary.get('emc_risk_score', '—') + rows.append(['EMC Risk Score', f"{score}/100", + f"C:{summary.get('critical', 0)} H:{summary.get('high', 0)} " + f"M:{summary.get('medium', 0)}"]) + + # Thermal + if thermal_data: + summary = thermal_data.get('summary', {}) + score = summary.get('thermal_score', '—') + rows.append(['Thermal Score', f"{score}/100", + f"{summary.get('components_above_85c', 0)} above 85°C"]) + + # BOM completeness + stats = analysis.get('statistics', {}) + missing_mpn = stats.get('missing_mpns', 0) + total = stats.get('total_components', 0) + if total: + rows.append(['BOM Completeness', + f"{total - missing_mpn}/{total} MPNs", + f"{missing_mpn} missing" if missing_mpn else 'Complete']) + + if rows: + lines.append(_auto("review_summary_table", + markdown_table(['Check', 'Status', 'Details'], rows))) + lines.append("") + lines.append(_narrative("review_overall_assessment", + "Provide an overall assessment of design readiness. " + "Highlight critical risks and recommend go/no-go.")) + lines.append("") + return "\n".join(lines) + + +def section_review_action_items(config: dict) -> str: + """Design Review: action items table.""" + lines = ["## Action Items"] + lines.append("") + lines.append(_auto("review_actions", + markdown_table( + ['#', 'Finding', 'Severity', 'Owner', 'Due Date', 'Status'], + [['1', '', '', '', '', 'OPEN']], + ['right', 'left', 'left', 'left', 'left', 'left']))) + lines.append("") + lines.append(_narrative("review_action_notes", + "List action items from the review. Assign owners and due dates. " + "Track resolution status.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# ICD — specialized sections +# ====================================================================== + +def section_icd_interface_list(analysis: dict) -> str: + """ICD: summary table of all external interfaces.""" + lines = ["## Interface List"] + lines.append("") + + rows = [] + # Connectors from ESD audit (they enumerate all external connectors) + esd = group_findings(analysis).get(Det.ESD_AUDIT, []) + for e in esd: + if isinstance(e, dict): + rows.append([ + e.get('connector_ref', '?'), + e.get('connector_value', ''), + e.get('interface_type', ''), + str(len(e.get('signal_nets', []))), + e.get('risk_level', ''), + ]) + + if rows: + lines.append(_auto("icd_interface_list", + markdown_table( + ['Connector', 'Type', 'Interface', 'Signals', 'ESD Risk'], + rows))) + else: + lines.append("*No external interfaces detected. Add connector analysis data.*") + lines.append("") + return "\n".join(lines) + + +def section_icd_connector_details(analysis: dict, config: dict) -> str: + """ICD: per-connector pinout and signal details.""" + lines = ["## Connector Details"] + lines.append("") + + # Get specific connectors from config if specified + target_connectors = [] + for doc_def in config.get('reports', {}).get('documents', []): + if doc_def.get('type') == 'icd': + target_connectors = doc_def.get('connectors', []) + + esd = group_findings(analysis).get(Det.ESD_AUDIT, []) + ic_pins = analysis.get('ic_pin_analysis', {}) + + for e in esd: + if not isinstance(e, dict): + continue + ref = e.get('connector_ref', '') + if target_connectors and ref not in target_connectors: + continue + + lines.append(f"### {ref} — {e.get('connector_value', '')}") + lines.append("") + lines.append(f"**Interface:** {e.get('interface_type', 'General')}") + lines.append("") + + # Signal list + signals = e.get('signal_nets', []) + protected = set(e.get('protected_nets', [])) + if signals: + rows = [] + for sig in signals: + prot = 'Yes' if sig in protected else 'No' + rows.append([sig, '', '', prot]) + lines.append(_auto(f"icd_connector_{ref}", + markdown_table( + ['Signal', 'Direction', 'Voltage Level', 'ESD Protected'], + rows))) + lines.append("") + lines.append(_narrative(f"icd_connector_{ref}_notes", + f"Describe the {ref} interface: protocol, signal levels, " + f"timing requirements, mating connector specification.")) + lines.append("") + + if not esd: + lines.append("*No connector data available. Run schematic analysis with ESD audit.*") + lines.append("") + + return "\n".join(lines) + + +def section_icd_electrical_characteristics(analysis: dict) -> str: + """ICD: electrical characteristics summary.""" + lines = ["## Electrical Characteristics"] + lines.append("") + + # Power domains give voltage levels + domains = analysis.get('design_analysis', {}).get('power_domains', {}) + domain_groups = domains.get('domain_groups', {}) + + if domain_groups: + rows = [] + for domain_name in sorted(domain_groups.keys()) if isinstance(domain_groups, dict) else []: + rows.append([domain_name, '', '', '']) + if rows: + lines.append(_auto("icd_voltage_levels", + markdown_table( + ['Voltage Domain', 'Nominal', 'Min', 'Max'], + rows, ['left', 'right', 'right', 'right']))) + lines.append("") + lines.append(_narrative("icd_electrical_notes", + "Specify voltage levels, impedance, current limits, " + "and timing requirements for each interface.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Manufacturing — specialized sections +# ====================================================================== + +def section_mfg_assembly_overview(analysis: dict) -> str: + """Manufacturing: assembly overview.""" + lines = ["## Assembly Overview"] + lines.append("") + + stats = analysis.get('statistics', {}) + rows = [ + ['Total components', str(stats.get('total_components', '?'))], + ['SMD components', str(stats.get('smd_count', '?'))], + ['THT components', str(stats.get('tht_count', '?'))], + ['DNP components', str(stats.get('dnp_count', 0))], + ['Unique parts', str(stats.get('unique_parts', '?'))], + ] + lines.append(_auto("mfg_overview", + markdown_table(['Metric', 'Count'], rows))) + lines.append("") + lines.append(_narrative("mfg_overview_notes", + "Describe assembly requirements: lead-free/leaded, " + "reflow profile, hand-solder requirements, special handling.")) + lines.append("") + return "\n".join(lines) + + +def section_mfg_pcb_fab_notes(pcb_data: dict | None) -> str | None: + """Manufacturing: PCB fabrication notes.""" + if not pcb_data: + return None + + lines = ["## PCB Fabrication Notes"] + lines.append("") + + stats = pcb_data.get('statistics', {}) + outline = pcb_data.get('board_outline', {}) + + rows = [ + ['Board dimensions', f"{outline.get('width_mm', '?')}mm × {outline.get('height_mm', '?')}mm"], + ['Copper layers', str(stats.get('copper_layers', '?'))], + ['Board thickness', '1.6mm'], + ['Copper weight', '1 oz'], + ['Surface finish', 'HASL / ENIG'], + ['Solder mask', 'Green'], + ['Silkscreen', 'White'], + ['Min trace/space', ''], + ['Min drill', ''], + ['IPC class', 'Class 2'], + ] + lines.append(_auto("mfg_fab_notes", + markdown_table(['Parameter', 'Value'], rows))) + lines.append("") + lines.append(_narrative("mfg_fab_notes_detail", + "Specify impedance control requirements, stackup details, " + "material (FR-4/Rogers), and any special fabrication instructions.")) + lines.append("") + return "\n".join(lines) + + +def section_mfg_assembly_instructions(analysis: dict) -> str: + """Manufacturing: assembly instructions.""" + lines = ["## Assembly Instructions"] + lines.append("") + lines.append(_narrative("mfg_assembly_instructions", + "Describe the assembly sequence: paste application, component " + "placement, reflow profile, hand-solder steps, conformal coating, " + "cleaning requirements, and special handling for sensitive components.")) + lines.append("") + return "\n".join(lines) + + +def section_mfg_test_procedures(analysis: dict) -> str: + """Manufacturing: production test procedures.""" + lines = ["## Production Test Procedures"] + lines.append("") + + lines.append(_auto("mfg_test_checklist", "\n".join([ + "1. Visual inspection (IPC-A-610 Class 2)", + "2. Power-on test: verify all voltage rails", + "3. Functional test: verify communication interfaces", + "4. Programming: flash firmware", + "5. Final inspection and labeling", + ]))) + lines.append("") + lines.append(_narrative("mfg_test_details", + "Describe pass/fail criteria for each test step. " + "Include expected voltages, test fixture requirements, " + "and failure modes to watch for.")) + lines.append("") + return "\n".join(lines) + + +# ====================================================================== +# Mechanical / Environmental +# ====================================================================== + +def section_mechanical_environmental(analysis: dict, pcb_data: dict | None) -> str | None: + """Mechanical and environmental specifications.""" + if not pcb_data: + return None + + lines = ["## 9. Mechanical / Environmental"] + lines.append("") + + if pcb_data: + outline = pcb_data.get('board_outline', {}) + if outline: + lines.append(_auto("mech_dimensions", + f"**Board Dimensions:** " + f"{outline.get('width_mm', '?')}mm × " + f"{outline.get('height_mm', '?')}mm")) + lines.append("") + + lines.append(_narrative("mechanical_notes", + "Describe: mounting method, enclosure constraints, " + "connector accessibility, operating temperature range, " + "humidity, vibration requirements.")) + lines.append("") + return "\n".join(lines) diff --git a/skills/kidoc/scripts/kidoc_spec.py b/skills/kidoc/scripts/kidoc_spec.py new file mode 100644 index 0000000..0a4deaf --- /dev/null +++ b/skills/kidoc/scripts/kidoc_spec.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Document spec parser, validator, and expander for kidoc. + +A spec is a JSON dict describing a complete document: its type, title, +audience, tone, input files, output formats, and an ordered list of +section definitions. Specs can be created from built-in templates +(expanded with defaults) or loaded from user-authored JSON files. + +Usage: + python3 kidoc_spec.py --list # list built-in types + python3 kidoc_spec.py --expand hdd # expand type to full spec JSON + python3 kidoc_spec.py --validate spec.json # validate a spec file + +Zero external dependencies -- Python 3.8+ stdlib only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from kidoc_templates import DOCUMENT_TEMPLATES, get_document_title + + +# ====================================================================== +# Constants +# ====================================================================== + +DEFAULT_SPECS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'default_specs') + +VALID_RENDER_MODES = ('auto', 'kicad-cli', 'crop', 'annotated') +VALID_NARRATIVE_MODES = ('auto', 'none') +VALID_TONES = ('technical', 'executive', 'conversational') + +# Default section spec values +SECTION_DEFAULTS = { + 'render': 'auto', + 'narrative': 'auto', + 'focus_refs': [], + 'highlight_nets': [], + 'include': [], + 'questions': [], +} + + +# ====================================================================== +# Spec loading +# ====================================================================== + +def load_spec(spec_path: str) -> dict: + """Load a JSON spec file, validate, and return the spec dict. + + Raises ValueError on invalid JSON or missing required fields. + """ + path = Path(spec_path) + if not path.exists(): + raise FileNotFoundError(f'Spec file not found: {spec_path}') + + with open(path, 'r', encoding='utf-8') as f: + try: + spec = json.load(f) + except json.JSONDecodeError as exc: + raise ValueError(f'Invalid JSON in {spec_path}: {exc}') from exc + + return validate_spec(spec) + + +def load_builtin_spec(doc_type: str) -> dict: + """Load a built-in spec from default_specs/ or generate from template. + + Checks for a pre-generated JSON file first; falls back to + expand_type_to_spec() if no file exists. + """ + json_path = os.path.join(DEFAULT_SPECS_DIR, f'{doc_type}.json') + if os.path.isfile(json_path): + return load_spec(json_path) + return expand_type_to_spec(doc_type) + + +def expand_type_to_spec(doc_type: str) -> dict: + """Expand a type name into a full spec dict with sections. + + Uses DOCUMENT_TEMPLATES to populate the section list with defaults. + Raises ValueError if the type is unknown. + """ + if doc_type not in DOCUMENT_TEMPLATES: + available = ', '.join(sorted(DOCUMENT_TEMPLATES)) + raise ValueError( + f'Unknown document type: {doc_type!r}. ' + f'Available types: {available}' + ) + + template = DOCUMENT_TEMPLATES[doc_type] + + sections = [] + for section_name in template['sections']: + section = { + 'id': section_name, + 'type': section_name, + } + section.update({k: _deep_copy_default(v) + for k, v in SECTION_DEFAULTS.items()}) + sections.append(section) + + spec = { + 'type': doc_type, + 'title': template['name'], + 'audience': '', + 'tone': 'technical', + 'schematic': '', + 'pcb': '', + 'default_formats': list(template.get('default_formats', ['pdf'])), + 'sections': sections, + } + + return spec + + +# ====================================================================== +# Validation +# ====================================================================== + +def validate_spec(spec: dict) -> dict: + """Fill defaults for missing fields and validate required fields. + + Returns the (possibly modified) spec dict. + Raises ValueError on invalid data. + """ + if not isinstance(spec, dict): + raise ValueError('Spec must be a JSON object (dict)') + + # Top-level defaults + spec.setdefault('type', 'custom') + spec.setdefault('title', get_document_title(spec['type']) + if spec['type'] in DOCUMENT_TEMPLATES + else 'Custom Document') + spec.setdefault('audience', '') + spec.setdefault('tone', 'technical') + spec.setdefault('schematic', '') + spec.setdefault('pcb', '') + spec.setdefault('default_formats', ['pdf']) + + # Validate tone + if spec['tone'] not in VALID_TONES: + raise ValueError( + f'Invalid tone: {spec["tone"]!r}. ' + f'Must be one of: {", ".join(VALID_TONES)}' + ) + + # Validate default_formats + if not isinstance(spec['default_formats'], list): + raise ValueError('default_formats must be a list') + + # Sections + if 'sections' not in spec: + raise ValueError('Spec must contain a "sections" list') + + if not isinstance(spec['sections'], list): + raise ValueError('"sections" must be a list') + + seen_ids = set() + for i, section in enumerate(spec['sections']): + if not isinstance(section, dict): + raise ValueError(f'Section {i} must be a dict') + + # Required: id and type + if 'id' not in section: + raise ValueError(f'Section {i} missing required field "id"') + if 'type' not in section: + section['type'] = section['id'] + + sid = section['id'] + if sid in seen_ids: + raise ValueError(f'Duplicate section id: {sid!r}') + seen_ids.add(sid) + + # Fill section defaults + for key, default_val in SECTION_DEFAULTS.items(): + section.setdefault(key, _deep_copy_default(default_val)) + + # Validate render mode + if section['render'] not in VALID_RENDER_MODES: + raise ValueError( + f'Section {sid!r}: invalid render mode {section["render"]!r}. ' + f'Must be one of: {", ".join(VALID_RENDER_MODES)}' + ) + + # Validate narrative mode + if section['narrative'] not in VALID_NARRATIVE_MODES: + raise ValueError( + f'Section {sid!r}: invalid narrative mode ' + f'{section["narrative"]!r}. ' + f'Must be one of: {", ".join(VALID_NARRATIVE_MODES)}' + ) + + # Validate list fields + for list_field in ('focus_refs', 'highlight_nets', + 'include', 'questions'): + if not isinstance(section.get(list_field, []), list): + raise ValueError( + f'Section {sid!r}: {list_field!r} must be a list' + ) + + return spec + + +# ====================================================================== +# Accessors +# ====================================================================== + +def get_section_types(spec: dict) -> list[str]: + """Extract the ordered list of section type names from a spec.""" + return [s['type'] for s in spec.get('sections', [])] + + +def get_section_spec(spec: dict, section_id: str) -> dict | None: + """Get the spec dict for a single section by id. + + Returns None if no section with the given id exists. + """ + for section in spec.get('sections', []): + if section['id'] == section_id: + return section + return None + + +def list_builtin_types() -> list[str]: + """List available built-in document types (sorted).""" + return sorted(DOCUMENT_TEMPLATES.keys()) + + +# ====================================================================== +# Output +# ====================================================================== + +def save_spec(spec: dict, output_path: str) -> None: + """Write a spec dict to a JSON file.""" + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + json.dump(spec, f, indent=2) + f.write('\n') + + +# ====================================================================== +# Helpers +# ====================================================================== + +def _deep_copy_default(val): + """Return a copy of a default value (handles lists, dicts, scalars).""" + if isinstance(val, list): + return list(val) + if isinstance(val, dict): + return dict(val) + return val + + +# ====================================================================== +# CLI +# ====================================================================== + +def main(): + parser = argparse.ArgumentParser( + description='kidoc spec tool -- parse, validate, and expand ' + 'document specs' + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--list', action='store_true', + help='List available built-in document types') + group.add_argument('--expand', metavar='TYPE', + help='Expand a type name to a full spec JSON') + group.add_argument('--validate', metavar='FILE', + help='Validate a spec JSON file') + + args = parser.parse_args() + + if args.list: + types = list_builtin_types() + for t in types: + title = get_document_title(t) + print(f' {t:24s} {title}') + print(f'\n{len(types)} types available.') + return + + if args.expand: + try: + spec = expand_type_to_spec(args.expand) + except ValueError as exc: + print(f'Error: {exc}', file=sys.stderr) + sys.exit(1) + json.dump(spec, sys.stdout, indent=2) + sys.stdout.write('\n') + return + + if args.validate: + try: + spec = load_spec(args.validate) + n_sections = len(spec.get('sections', [])) + print(f'OK: {args.validate} -- type={spec["type"]!r}, ' + f'{n_sections} sections') + except (FileNotFoundError, ValueError) as exc: + print(f'FAIL: {args.validate} -- {exc}', file=sys.stderr) + sys.exit(1) + return + + +if __name__ == '__main__': + main() diff --git a/skills/kidoc/scripts/kidoc_tables.py b/skills/kidoc/scripts/kidoc_tables.py new file mode 100644 index 0000000..07cfc31 --- /dev/null +++ b/skills/kidoc/scripts/kidoc_tables.py @@ -0,0 +1,124 @@ +"""Markdown table formatting and unit formatting utilities. + +Zero external dependencies — Python stdlib only. +""" + +from __future__ import annotations + + +def markdown_table(headers: list[str], rows: list[list[str]], + alignments: list[str] | None = None) -> str: + """Generate a markdown table with proper column padding. + + *alignments*: list of ``'left'``, ``'center'``, or ``'right'`` per column. + Defaults to left-aligned. + """ + if not headers: + return '' + + n_cols = len(headers) + if alignments is None: + alignments = ['left'] * n_cols + + # Compute column widths + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row[:n_cols]): + widths[i] = max(widths[i], len(str(cell))) + + def _pad(text: str, width: int, align: str) -> str: + text = str(text) + if align == 'right': + return text.rjust(width) + elif align == 'center': + return text.center(width) + return text.ljust(width) + + # Header row + header_line = '| ' + ' | '.join( + _pad(h, widths[i], alignments[i]) for i, h in enumerate(headers) + ) + ' |' + + # Separator row + sep_parts = [] + for i, a in enumerate(alignments): + w = widths[i] + if a == 'center': + sep_parts.append(':' + '-' * max(w - 2, 1) + ':') + elif a == 'right': + sep_parts.append('-' * max(w - 1, 1) + ':') + else: + sep_parts.append('-' * w) + sep_line = '| ' + ' | '.join(sep_parts) + ' |' + + # Data rows + data_lines = [] + for row in rows: + padded = [] + for i in range(n_cols): + cell = str(row[i]) if i < len(row) else '' + padded.append(_pad(cell, widths[i], alignments[i])) + data_lines.append('| ' + ' | '.join(padded) + ' |') + + return '\n'.join([header_line, sep_line] + data_lines) + + +# --------------------------------------------------------------------------- +# Unit formatters +# --------------------------------------------------------------------------- + +def format_voltage(v: float | None) -> str: + """Format a voltage value: ``3.3V``, ``1.8V``, ``—`` for None.""" + if v is None: + return '—' + if abs(v) >= 1: + return f"{v:.1f}V" if v != int(v) else f"{int(v)}V" + return f"{v * 1000:.0f}mV" + + +def format_frequency(hz: float | None) -> str: + """Format frequency: ``1.23kHz``, ``48.0MHz``, ``—`` for None.""" + if hz is None: + return '—' + if hz >= 1e9: + return f"{hz / 1e9:.1f}GHz" + if hz >= 1e6: + return f"{hz / 1e6:.1f}MHz" + if hz >= 1e3: + return f"{hz / 1e3:.1f}kHz" + return f"{hz:.0f}Hz" + + +def format_current(a: float | None) -> str: + """Format current: ``500mA``, ``2.5A``, ``—`` for None.""" + if a is None: + return '—' + if abs(a) >= 1: + return f"{a:.1f}A" if a != int(a) else f"{int(a)}A" + if abs(a) >= 0.001: + return f"{a * 1000:.0f}mA" + return f"{a * 1e6:.0f}µA" + + +def format_capacitance(f: float | None) -> str: + """Format capacitance: ``100nF``, ``10µF``, ``—`` for None.""" + if f is None: + return '—' + if f >= 1e-3: + return f"{f * 1e3:.0f}mF" + if f >= 1e-6: + return f"{f * 1e6:.0f}µF" + if f >= 1e-9: + return f"{f * 1e9:.0f}nF" + return f"{f * 1e12:.0f}pF" + + +def format_resistance(ohms: float | None) -> str: + """Format resistance: ``10kΩ``, ``4.7Ω``, ``—`` for None.""" + if ohms is None: + return '—' + if ohms >= 1e6: + return f"{ohms / 1e6:.1f}MΩ" + if ohms >= 1e3: + return f"{ohms / 1e3:.1f}kΩ" if ohms != int(ohms / 1e3) * 1e3 else f"{int(ohms / 1e3)}kΩ" + return f"{ohms:.1f}Ω" if ohms != int(ohms) else f"{int(ohms)}Ω" diff --git a/skills/kidoc/scripts/kidoc_templates.py b/skills/kidoc/scripts/kidoc_templates.py new file mode 100644 index 0000000..b7870dc --- /dev/null +++ b/skills/kidoc/scripts/kidoc_templates.py @@ -0,0 +1,120 @@ +"""Document type template definitions for kidoc. + +Each document type defines its section list, default output formats, +and type-specific options. Used by kidoc_scaffold.py to determine +which sections to generate. + +Zero external dependencies — constants only. +""" + +from __future__ import annotations + + +DOCUMENT_TEMPLATES = { + "hdd": { + "name": "Hardware Design Description", + "sections": [ + "front_matter", "executive_summary", "system_overview", "power_design", + "signal_interfaces", "analog_design", "thermal_analysis", + "emc_analysis", "pcb_design", "mechanical_environmental", + "bom_summary", "test_debug", "compliance", + "appendix_schematics", + ], + "default_formats": ["pdf", "docx"], + }, + "ce_technical_file": { + "name": "CE Technical File", + "sections": [ + "front_matter", "ce_product_identification", + "ce_essential_requirements", "ce_harmonized_standards", + "ce_risk_assessment", "emc_analysis", "thermal_analysis", + "ce_declaration_of_conformity", + "bom_summary", "appendix_schematics", + ], + "default_formats": ["pdf"], + "pdfa": True, + }, + "design_review": { + "name": "Design Review Package", + "sections": [ + "front_matter", "executive_summary", "review_summary", + "system_overview", "power_design", + "emc_analysis", "thermal_analysis", + "bom_summary", "review_action_items", + ], + "default_formats": ["pdf"], + }, + "icd": { + "name": "Interface Control Document", + "sections": [ + "front_matter", "system_overview", + "icd_interface_list", "icd_connector_details", + "signal_interfaces", "icd_electrical_characteristics", + ], + "default_formats": ["pdf", "docx"], + }, + "manufacturing": { + "name": "Manufacturing Transfer Package", + "sections": [ + "front_matter", "mfg_assembly_overview", + "bom_summary", "mfg_pcb_fab_notes", + "mfg_assembly_instructions", "mfg_test_procedures", + "appendix_schematics", + ], + "default_formats": ["pdf"], + }, + "schematic_review": { + "name": "Schematic Review Report", + "sections": [ + "front_matter", "executive_summary", "system_overview", + "power_design", "signal_interfaces", "analog_design", + "bom_summary", "appendix_schematics", + ], + "default_formats": ["pdf"], + }, + "power_analysis": { + "name": "Power Analysis Report", + "sections": [ + "front_matter", "executive_summary", "power_design", + "thermal_analysis", "emc_analysis", "bom_summary", + "appendix_schematics", + ], + "default_formats": ["pdf"], + }, + "emc_report": { + "name": "EMC Pre-Compliance Report", + "sections": [ + "front_matter", "executive_summary", + "emc_analysis", "compliance", + "appendix_schematics", + ], + "default_formats": ["pdf"], + }, +} + + +def get_template(doc_type: str) -> dict: + """Get the template for a document type, with defaults fallback.""" + return DOCUMENT_TEMPLATES.get(doc_type, DOCUMENT_TEMPLATES['hdd']) + + +def get_section_list(doc_type: str, config: dict | None = None) -> list[str]: + """Get the section list for a document type, allowing config overrides.""" + template = get_template(doc_type) + sections = list(template['sections']) + + # Allow config to override sections + if config: + reports = config.get('reports', {}) + for doc_def in reports.get('documents', []): + if doc_def.get('type') == doc_type and 'sections' in doc_def: + sections = doc_def['sections'] + break + + return sections + + +def get_document_title(doc_type: str) -> str: + """Get the display name for a document type.""" + template = get_template(doc_type) + return template['name'] diff --git a/skills/kidoc/scripts/kidoc_venv.py b/skills/kidoc/scripts/kidoc_venv.py new file mode 100644 index 0000000..2b1e8ba --- /dev/null +++ b/skills/kidoc/scripts/kidoc_venv.py @@ -0,0 +1,94 @@ +"""Virtual environment manager for kidoc report generation. + +Creates and manages a project-local venv at ``reports/.venv/`` with the +dependencies needed for PDF/DOCX generation. Never touches the user's +global or user Python environment. + +Zero external dependencies — Python stdlib only. +""" + +from __future__ import annotations + +import os +import platform +import subprocess +import sys + + +REQUIREMENTS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'requirements.txt') + + +def venv_dir(project_dir: str) -> str: + """Return the path to the venv directory.""" + return os.path.join(project_dir, 'reports', '.venv') + + +def venv_python(project_dir: str) -> str: + """Return the path to the venv Python executable.""" + vd = venv_dir(project_dir) + if platform.system() == 'Windows': + return os.path.join(vd, 'Scripts', 'python.exe') + return os.path.join(vd, 'bin', 'python') + + +def venv_pip(project_dir: str) -> str: + """Return the path to the venv pip executable.""" + vd = venv_dir(project_dir) + if platform.system() == 'Windows': + return os.path.join(vd, 'Scripts', 'pip.exe') + return os.path.join(vd, 'bin', 'pip') + + +def is_venv_ready(project_dir: str) -> bool: + """Check if the venv exists and has required packages.""" + py = venv_python(project_dir) + if not os.path.isfile(py): + return False + # Quick check: try importing the key packages + try: + result = subprocess.run( + [py, '-c', 'import reportlab; import docx; import odf; import PIL'], + capture_output=True, timeout=10) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def ensure_venv(project_dir: str, quiet: bool = False) -> str: + """Ensure the venv exists and has required packages. + + Creates the venv and installs dependencies if needed. + Returns the path to the venv Python executable. + """ + py = venv_python(project_dir) + + if is_venv_ready(project_dir): + return py + + vd = venv_dir(project_dir) + _log = (lambda msg: None) if quiet else (lambda msg: print(msg, file=sys.stderr)) + + # Create venv if it doesn't exist + if not os.path.isfile(py): + _log(f"Creating venv at {vd}") + os.makedirs(os.path.dirname(vd), exist_ok=True) + subprocess.run([sys.executable, '-m', 'venv', vd], check=True) + + # Install/upgrade dependencies + pip = venv_pip(project_dir) + if not os.path.isfile(pip): + # Fallback: use venv python -m pip + pip_cmd = [py, '-m', 'pip'] + else: + pip_cmd = [pip] + + _log("Installing report generation dependencies...") + cmd = pip_cmd + ['install', '-q', '-r', REQUIREMENTS_FILE] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + _log(f"pip install failed: {result.stderr}") + raise RuntimeError(f"Failed to install dependencies: {result.stderr}") + + _log("Venv ready.") + return py diff --git a/skills/kidoc/scripts/requirements.txt b/skills/kidoc/scripts/requirements.txt new file mode 100644 index 0000000..5c752b2 --- /dev/null +++ b/skills/kidoc/scripts/requirements.txt @@ -0,0 +1,6 @@ +reportlab>=4.0,<5 +python-docx>=1.0,<2 +odfpy>=1.4,<2 +Pillow>=10.0,<14 +svglib>=1.5,<2 +matplotlib>=3.5,<4 diff --git a/skills/knowledge-management/SKILL.md b/skills/knowledge-management/SKILL.md new file mode 100644 index 0000000..e884129 --- /dev/null +++ b/skills/knowledge-management/SKILL.md @@ -0,0 +1,63 @@ +--- +name: knowledge-management +description: >- + Hardware knowledge management for AI-assisted PCB programs: journals, + scorecards, dual-SoT indexes, decision logs, evidence packs, and preventing + corpus fragmentation. Use when organizing phase docs, building productization + indexes, or capturing overnight lessons into reusable memory. +--- + +# Hardware Knowledge Management + +## Related Skills + +| Skill | Role | +|-------|------| +| `product-docs` | Customer-facing index | +| `design-review` | Scorecard format | +| `constraint-management` | Policy docs as knowledge | +| `kidoc` | Generated reports filing | + +## Purpose + +Keep engineering truth findable: one index, named SoT, evidence beside claims, +and lessons distilled into skills — not lost in chat. + +## Artifact set + +| Artifact | Role | +|----------|------| +| Decision log | Binding choices | +| Risk register | Open risks → gates | +| EQ scorecard | Weighted honesty | +| Iteration log | Chronology | +| Morning brief | Shift handoff | +| Evidence dir | ERC/DRC/PNG/JSON | +| Productization index | Customer entrypoint | + +## Decision process + +1. Prefer append-only logs; never rewrite history without note. +2. Every score cites evidence paths. +3. Dual-SoT and freeze policies live as first-class docs. +4. Distill repeats into skills (`kicad-happy`) rather than more phase notes. +5. Index deep trees (`phase6/`, `phase7/`) from one README. + +## Checklist + +- [ ] Single STATUS or brief for “where are we” +- [ ] Evidence paths relative and existent +- [ ] SHAs for parent + vendor CAD +- [ ] Forbidden claims listed +- [ ] Skill distillation backlog for repeats + +## Failure modes + +- 100+ markdown files, no index (MatriQ productization finding) +- EQ numbers without formula +- Chat-only decisions + +## Acceptance criteria + +- New engineer finds SoT + GO + next actions in ≤5 minutes +- Lessons have skill or ADR owners diff --git a/skills/multi-agent-coordination/SKILL.md b/skills/multi-agent-coordination/SKILL.md new file mode 100644 index 0000000..971a8f1 --- /dev/null +++ b/skills/multi-agent-coordination/SKILL.md @@ -0,0 +1,57 @@ +--- +name: multi-agent-coordination +description: >- + Coordinate multi-agent hardware campaigns: phase gates, write locks, specialist + roles, timeout handling, morning briefs, and evidence packs. Use for overnight + master agents, parallel architecture/SI/docs reviewers, or recovering from + agent hangs without corrupting CAD SoT. +--- + +# Multi-Agent Hardware Coordination + +## Related Skills + +| Skill | Role | +|-------|------| +| `eda-prompt-engineering` | Per-role prompts | +| `constraint-management` | Global write locks | +| `design-review` | Merge specialist findings | +| `knowledge-management` | Logs / scorecards | +| `verification-planning` | Lab after agents stop | + +## Purpose + +Run Phase 0–7 style organisations without CAD corruption or false progress. + +## Role map (example) + +| Role | Edits CAD? | Output | +|------|------------|--------| +| Master / orchestrator | No (policy) | Briefs, gates | +| Architecture | No | Review memo | +| Schematic presentation | Chrome only | Before/after | +| CAD trust / guardian | Read + reopen SoT | Congruence report | +| Docs productization | No | Product pack | +| Layout ECO | Only if unlocked | ECO pack + DRC | + +## Coordination rules + +1. **Phase 0 gate** before mutations. +2. **Single writer** per file set; others read-only. +3. **Checkpoint commits** before risky hierarchy experiments. +4. **Timeouts ≠ progress** — residue unscored until validated. +5. **Morning brief** mandatory after overnight: SHAs, EQ, do/don’t. +6. **Handoff contracts** (Agentic EDA): receiver must be able to consume artifacts. + +## Failure modes (MatriQ) + +- M4 agents PING timeout scored as EQ work +- Sibling Sheetfile experiment without instant revert path +- Cursor MCP stuck readonly while config says write +- Parallel writers on same sheet + +## Acceptance criteria + +- Lock table published +- Revert path tested once +- Brief includes fab GO protection status diff --git a/skills/pcb-layout-review/SKILL.md b/skills/pcb-layout-review/SKILL.md new file mode 100644 index 0000000..f01f463 --- /dev/null +++ b/skills/pcb-layout-review/SKILL.md @@ -0,0 +1,107 @@ +--- +name: pcb-layout-review +description: >- + Methodology for PCB layout review: placement strategy, stackup, return paths, + plane strategy, connector seating, and fab-aligned design rules — beyond raw + analyzer JSON. Use when reviewing layout quality, stackup choices, placement + density, "is this layout industrial", pre-fab layout critique, or interpreting + analyze_pcb.py / DRC results into engineering decisions. +--- + +# PCB Layout Review (Methodology) + +Complements `kicad` `analyze_pcb.py` and `emc`. This skill is the **senior layout +engineer decision layer**: what the numbers mean, what to fix now vs defer, and +how to align rules with the fabricator. + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | Run `analyze_pcb.py --full`, DRC, cross_analysis | +| `emc` | Plane voids, edge radiation, return-path rules | +| `dfm-review` | Fab capability gate and CAM package | +| `si-review` | Diff-pair / impedance / length methodology | +| `jlcpcb` / `pcbway` | Manufacturer capability tables | +| `constraint-management` | Net classes, copper freeze | + +## Purpose + +Turn analyzer + DRC evidence into a layout verdict with prioritized ECO classes +(A docs / B silk / C production / D copper) without silent copper edits. + +## When to invoke + +- "Review my layout before order" +- Interpreting high DRC counts that are not shorts +- Stackup or plane strategy disputes +- Connector finger / CEM seating reviews +- Post-routing industrial critique + +## Inputs + +- `.kicad_pcb` SoT path + stackup notes +- `analyze_pcb.py --full` JSON +- DRC report (CLI and/or live GUI — state congruence) +- Fab process card (e.g. JLCPCB 4L 1oz) +- Optional: silk/assembly drawings + +## Outputs + +- Layout review report: placement, stackup, planes, HS corridors, DFM residuals +- ECO class table (A–D) with fab-GO impact +- Explicit LIVE vs CLI congruence note + +## Decision process + +1. **Confirm board SoT** and that live GUI matches CLI file (UUID affinity). +2. **Run analyzers** — do not invent geometry from memory. +3. **Separate** shorts/unconnected (blockers) from style/silk/courtyard (non-blockers). +4. **Stackup:** Signal-GND-PWR-Signal (or documented alternative) with return strategy for HS. +5. **Placement:** functional clustering; power stages; connector keepouts; courtyard overlaps. +6. **Planes:** continuity under HS; stitching; splits that HS crosses → flag via `emc`. +7. **Rules:** min width/space/via/annular ring vs fab card (JLCPCB: ~5 mil 2L / 3.5 mil 4–6L typical; verify current fab table). +8. **Verdict:** Fab GO / conditional GO / NO-GO — never upgrade GO by ignoring shorts. + +### JLCPCB layout-guide anchors + +Per [JLCPCB Complete PCB Layout Guide](https://jlcpcb.com/blog/complete-pcb-layout-guide): + +- Schematic quality drives layout quality (netlist is absolute connectivity truth) +- Define rules before routing (width, clearance, via, stackup) +- 4-layer S-G-P-S is the default EMI-friendly stackup for cost-effective boards +- Plan manufacturability against the chosen fab’s capability page + +## Checklist + +- [ ] Board SoT + LIVE==CLI stated +- [ ] Shorts = 0 for GO claims +- [ ] Stackup documented and matches fab order +- [ ] HS corridors have continuous reference intent +- [ ] Power entry / fuse / bulk caps placement reviewed +- [ ] Connector seating / finger finish notes +- [ ] Courtyard density called out (accept or ECO) +- [ ] Silk/docs debt not confused with electrical blockers +- [ ] Analyzer false positives triaged + +## Failure modes + +| Mode | Impact | Fix | +|------|--------|-----| +| Treating all DRC as blockers | False NO-GO | Severity triage | +| Editing copper to raise EQ docs score | Invalidates fab package | Freeze copper | +| Ignoring LIVE≠CLI | False trust | Session guardian / reopen SoT only | +| Open-loop LLM track edits | DRVs | Engine-grounded edits only (PCBWorld lesson) | + +## Industrial references + +- JLCPCB Complete PCB Layout Guide (2025/2026) +- IPC-2221 / IPC-7351 courtyard concepts +- PCBWorld: engine-grounded interactive routing + DRC feedback beats open-loop LLM tracks (Song et al., arXiv:2607.05915) +- Quilter: PCB automation as constrained optimization, not spectacle + +## Acceptance criteria + +- Every recommended copper change cites DRC/analyzer/evidence +- Fab GO impact column filled for each finding +- Does not replace `kicad` scripts — interprets them diff --git a/skills/pcb-layout-review/references/eco-classes.md b/skills/pcb-layout-review/references/eco-classes.md new file mode 100644 index 0000000..4a7fe7e --- /dev/null +++ b/skills/pcb-layout-review/references/eco-classes.md @@ -0,0 +1,8 @@ +# ECO class legend (layout) + +| Class | Meaning | Typical fab impact | +|-------|---------|-------------------| +| A | Documentation only | None | +| B | Silk / courtyard / text | None if no copper | +| C | Production package (traveler, assembly drawing) | None | +| D | Copper / stackup / net | Invalidates frozen CAM unless re-DRC + re-export | diff --git a/skills/power-tree/SKILL.md b/skills/power-tree/SKILL.md new file mode 100644 index 0000000..547efbe --- /dev/null +++ b/skills/power-tree/SKILL.md @@ -0,0 +1,85 @@ +--- +name: power-tree +description: >- + Build and review power trees: rail ownership, regulator chains, sequencing, + current budget, sense/test points, and external vs host power injection. Use + when reviewing power architecture, PDN, bucks/LDOs, "where does 12V go", + inrush, fuse strategy, or interpreting kicad power-path detector output. +--- + +# Power Tree Review + +Methodology for **power domain architecture**. Uses `kicad` analyzer power-path +output as evidence; adds ownership, budget, and bring-up gates. + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | Detector power trees, feedback ratios, decoupling inventory | +| `spice` | Simulate regulators / PDN when models exist | +| `emc` | Switching node / input loop / PDN impedance risks | +| `si-review` | Power integrity adjacent to HS corridors | +| `verification-planning` | Lab Iin / ripple / sequence plans | +| `kidoc` | Power analysis report type | + +## Purpose + +Produce a rail-by-rail tree with source, conversion, loads, protection, TP, and +**what is measured vs assumed**. + +## Inputs + +- Schematic SoT + `analyze_schematic.py` JSON +- Power intent (host rails, brick, PoE, USB-PD, etc.) +- Connector pinouts for power pins +- Optional datasheets for regulators / hot-swap + +## Outputs + +``` +VIN / brick +├── Fuse / ideal diode / switch +├── Buck A → VoutA → loads… +└── LDO B → VoutB → loads… +``` + +Plus: budget table, sequence notes, open `[MEASURE]` items, ECO class. + +## Decision process + +1. List all power symbols / PWR flags / connector power pins. +2. Trace each regulator input→output→loads (analyzer + manual spot-check). +3. Mark **injection points** (external brick vs host golden fingers). +4. Check protection: fuse, TVS, reverse, UVLO as applicable. +5. Check decoupling strategy qualitatively; defer quantitative PDN to `emc`/`spice`. +6. Identify sense/TP coverage for bring-up. +7. Never invent measured Iin — mark MEASURE. + +## Checklist + +- [ ] Tree covers every rail name used on connectors +- [ ] Each regulator has enable/sequence note if multi-rail +- [ ] External injection isolated from host power policy stated +- [ ] Fuse/polyfuse ratings vs budget +- [ ] Bulk + HF caps called out per stage +- [ ] Test points listed or gap logged +- [ ] Assumptions vs MEASURE explicitly separated + +## Failure modes + +- Collapsing AUX and main 3V3 without EP need check +- Budget from marketing max without derating +- Claiming PI signoff from schematic alone + +## Industrial references + +- IPC power distribution planning practice +- Regulator datasheet typical apps (via `datasheets` skill) +- pcbGPT / PCBSchemaGen: support circuitry (bulk, feedback) is a top semantic failure class + +## Acceptance criteria + +- Tree reproducible from schematic evidence +- Every unknown current tagged MEASURE +- Handoff to verification-planning for lab gates diff --git a/skills/product-docs/SKILL.md b/skills/product-docs/SKILL.md new file mode 100644 index 0000000..f642274 --- /dev/null +++ b/skills/product-docs/SKILL.md @@ -0,0 +1,71 @@ +--- +name: product-docs +description: >- + Author commercial / industrial product documentation packages: hardware + reference guides, naming standards, travelers, honest non-claims, and + customer PDF indexes — distinct from CAD-generated engineering reports. Use + for productization, customer manuals, industrial handoff packs, or shipping + docs without changing copper. +--- + +# Commercial Product Documentation + +Complements `kidoc` (CAD-driven engineering reports). This skill builds the +**product narrative and ops package** a senior customer or CEM needs. + +## Related Skills + +| Skill | Role | +|-------|------| +| `kidoc` | HDD, ICD, manufacturing transfer scaffolds from CAD | +| `schematic-presentation` | Drawing standards for customer PDF | +| `hw-architecture-review` | Architecture accepted content | +| `dfm-review` | Traveler / fab notes | +| `knowledge-management` | Index fragmented engineering corpus | + +## Purpose + +Ship Class A documentation: reference guide, naming standard, engineering notes +library, productization README — **without** mandatory copper ECO. + +## Document set (typical) + +1. Hardware Reference Guide (architecture, power, clocks, bring-up) +2. Industrial net naming standard (CAD SoT vs future lexicon) +3. Engineering notes (populate rules, option resistors) +4. Production traveler / CEM remarks +5. Index linking deep phase docs → product folder + +## Decision process + +1. Freeze architecture claims to accepted design. +2. Separate **customer truth** from lab unknowns (`[MEASURE]`). +3. Prefer docs over CAD renames when connectivity risk is high. +4. Dual-SoT: tell customers which PDF to open. +5. Non-claims: Gen, compliance lab, cold-plug, warranty bounds. + +## Checklist + +- [ ] Guide answers: what it is, power, clocks, connectors, bring-up +- [ ] Non-claims section present +- [ ] CAD net names used as SoT in manuals +- [ ] Traveler critical populate list +- [ ] Deep docs indexed, not duplicated wholesale +- [ ] Fab GO status stated and protected + +## Failure modes + +- Fragmented phase folders with no product index (MatriQ A1) +- Compliance language without lab +- Editing nets for prettier names pre-fab + +## Industrial references + +- IEC/ISO technical documentation structure practices +- CEM traveler conventions +- kidoc document types for engineering vs this commercial layer + +## Acceptance criteria + +- Senior engineer GO on docs package independent of silk polish +- Copper unchanged unless Class D explicitly approved diff --git a/skills/schematic-presentation/SKILL.md b/skills/schematic-presentation/SKILL.md new file mode 100644 index 0000000..3289a25 --- /dev/null +++ b/skills/schematic-presentation/SKILL.md @@ -0,0 +1,61 @@ +--- +name: schematic-presentation +description: >- + Industrial schematic presentation standards: title blocks, alignment, spacing, + hierarchy visuals, stacked text cleanup, demotion banners, and customer PDF + export — without changing connectivity. Use for schematic polish, library + designer review, raising presentation scores, or Altium/KiCad drawing standards. +--- + +# Industrial Schematic Presentation + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | ERC after cosmetic edits; export PDF/SVG | +| `product-docs` | What customer PDF must communicate | +| `constraint-management` | Wire-gated edits; no net changes | +| `design-review` | Score readability separately from electrical | +| `kidoc` | Render crops for reports | + +## Purpose + +Raise **drawing quality** (MatriQ 72→80 pattern) while preserving ERC and fab GO. +Connectivity edits are out of scope unless explicitly commissioned. + +## Decision process + +1. Confirm customer SoT sheets (not flat archival). +2. Visual QA: stacked text, title/rev drift, overlapping notes, border crowding. +3. Prefer MCP / sch API with wire-drop guards; never blind sexp regex. +4. Unify title/rev strings; add FUTURE/demotion banners where needed. +5. Export direct child PDFs if hierarchy index hollow. +6. Re-ERC; abort if connectivity deltas appear. + +## Checklist + +- [ ] Scope = presentation only (written) +- [ ] Title blocks consistent +- [ ] No stacked free-text on critical areas +- [ ] Future sheets demoted +- [ ] Flat archival not used as customer PDF +- [ ] ERC unchanged or understood +- [ ] Before/after PNG evidence saved + +## Failure modes + +- Touching wires to “nudge” symbols → ERC spikes +- Polishing flat archival instead of customer hierarchy +- Counting presentation points as SI/PI EQ + +## Industrial references + +- IEEE/IEC schematic drafting conventions (organization, readable flow) +- JLCPCB guide: schematic as communication tool; hierarchical sheets +- Corporate library standards (Altium/KiCad house styles) + +## Acceptance criteria + +- Presentation score rationale + evidence paths +- Zero intentional netlist changes diff --git a/skills/si-review/SKILL.md b/skills/si-review/SKILL.md new file mode 100644 index 0000000..a3cf4fb --- /dev/null +++ b/skills/si-review/SKILL.md @@ -0,0 +1,98 @@ +--- +name: si-review +description: >- + Signal-integrity review methodology separating heuristic CAD checks from + measured signoff: impedance, length matching, return paths, crosstalk, + vias/stubs, and PCIe/USB/SerDes corridors. Use for SI readiness, controlled-Z + planning, "will this pass eye", or when refusing fake compliance scores. +--- + +# Signal Integrity Review + +## Related Skills + +| Skill | Role | +|-------|------| +| `kicad` | Trace length, proximity, layer transitions | +| `emc` | Return path, edge, diff skew rules | +| `pcb-layout-review` | Corridor / stackup context | +| `clock-tree` | REFCLK SI specifics | +| `verification-planning` | TDR/VNA/eye lab plan | +| `constraint-management` | Controlled-Z order constraints | + +## Purpose + +Produce an SI **readiness** assessment with a hard split between heuristic CAD +checks and measured signoff. Protects scorecards from fictional compliance. + +## When to invoke + +- Pre-fab SI readiness +- EQ debates on SI dimension +- Controlled-impedance order planning +- Post-layout diff-pair critique + +## Inputs + +- PCB SoT + stackup +- HS net list (diff pairs, clocks, memory) +- `analyze_pcb.py --full` (+ proximity) and `emc` JSON +- Fab impedance options / coupon policy + +## Outputs + +- SI readiness memo with **tier** per claim +- Gap list → verification IDs +- Explicit non-claims (PCI-SIG, Gen eye) + +## SI tiers + +| Tier | Meaning | Allowed language | +|------|---------|------------------| +| H0 Heuristic | Analyzer/geometry only | “risk”, “readiness” | +| H1 Fab intent | Controlled-Z ordered | “impedance intent” | +| H2 Measured | TDR/VNA/coupon | “measured Z” | +| H3 Link | Eye / BER / enum | “link validated” | + +**Never** score H2/H3 without lab evidence. + +## Decision process + +1. Identify HS nets. +2. Run heuristic checks (`analyze_pcb`, `emc`). +3. Confirm reference plane intent under HS. +4. Note via count / stub risk qualitatively. +5. State impedance target and whether fab coupon ordered. +6. Map gaps to lab IDs — do not invent EQ points. + +## Checklist + +- [ ] HS net list complete +- [ ] Diff pair skew / length notes (heuristic) +- [ ] Return path voids flagged or clear +- [ ] Impedance target + stackup cite +- [ ] Measurement plan or explicit DEFER +- [ ] Compliance non-claims (PCI-SIG, Gen) + +## Failure modes + +| Mode | MatriQ / literature lesson | +|------|----------------------------| +| Docs-only SI EQ bump | Physical Δ = 0 overnight | +| Open-loop LLM tracks | PCBWorld: interactive+DRC wins | +| EMC≠SI signoff | Different oracles | + +## Industrial references + +- PCBWorld (arXiv:2607.05915) +- Vendor SerDes SI guides +- JLCPCB impedance / stackup calculators + +## Example + +Short 4-layer PCIe ×8 corridor, Z unmeasured → H0 readiness ~acceptable for fab; EQ SI capped until H1/H2; unlock = controlled-Z coupon + TDR. + +## Acceptance criteria + +- Tier label on every SI claim +- Lab unlocks listed when score ceiling binds diff --git a/skills/verification-planning/SKILL.md b/skills/verification-planning/SKILL.md new file mode 100644 index 0000000..95725f6 --- /dev/null +++ b/skills/verification-planning/SKILL.md @@ -0,0 +1,63 @@ +--- +name: verification-planning +description: >- + Plan hardware verification and bring-up: power, SI, PCIe/enum, EMC pre-scan, + and lab instrumentation — without claiming results not measured. Use for + validation plans, Phase 7-style readiness, test ICDs, or EQ unlock roadmaps. +--- + +# Verification Planning + +## Related Skills + +| Skill | Role | +|-------|------| +| `power-tree` / `clock-tree` / `si-review` | What to measure | +| `emc` | Pre-compliance test plan hooks | +| `design-review` | Residuals feeding the plan | +| `product-docs` | Bring-up chapter | +| `spice` | Pre-lab simulation where models exist | + +## Purpose + +Convert open risks into **executable** lab/simulation gates with IDs, equipment, +pass criteria, and dependencies — never fake pass marks. + +## Plan structure + +| ID | Domain | Setup | Stimulus | Measure | Pass | Depends | +|----|--------|-------|----------|---------|------|---------| +| PWR-01 | Power | … | … | … | … | … | + +## Decision process + +1. Import residuals from architecture / SI / DFM / review. +2. Order strategically: CAD trust → power → SI coupon → link enum → EMC. +3. Define instrumentation (scope, DMM, TDR, protocol analyzer). +4. Mark simulation-only vs silicon-required. +5. Keep fab GO independent of unrun lab gates when copper frozen. + +## Checklist + +- [ ] Each residual risk mapped to a gate or WAIVE +- [ ] Equipment list +- [ ] Safety (hot-plug, sequencing) +- [ ] Data capture path (who files results) +- [ ] No pass criteria that require unavailable gear without DEFER + +## Failure modes + +- Writing plans that silently imply completion +- Blocking fab on lab that cannot run pre-board +- Skipping power before high-speed bring-up + +## Industrial references + +- PCIe CEM bring-up practice +- IPC testability concepts +- MatriQ Phase 7-C/D/E plan pattern + +## Acceptance criteria + +- Plans are runnable by a lab engineer without the author +- Claims language reserved until results filed From 4664ced81c431d8f186e3093e1951165ef92585a Mon Sep 17 00:00:00 2001 From: Poisson Solver Developer Date: Thu, 30 Jul 2026 14:15:28 +0800 Subject: [PATCH 2/2] fix(skills): complete eda-prompt-engineering failure modes Satisfy Phase 6 validation checklist section parity with peer methodology skills. Co-authored-by: Cursor --- skills/eda-prompt-engineering/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/skills/eda-prompt-engineering/SKILL.md b/skills/eda-prompt-engineering/SKILL.md index 81c945d..316f2a2 100644 --- a/skills/eda-prompt-engineering/SKILL.md +++ b/skills/eda-prompt-engineering/SKILL.md @@ -51,6 +51,15 @@ uncertainty — lessons from pcbGPT, PCBSchemaGen, PCBWorld, and MatriQ overnigh - Open-loop “route all nets” LLM dumps (PCBWorld: interactive wins) - Semantic validation without deterministic ERC/execution (pcbGPT stack) +## Failure modes + +| Mode | Impact | Fix | +|------|--------|-----| +| Unbounded excellence targets | Agent hangs / false EQ | Cap to evidence unlocks | +| Missing claim tags | Fake “verified” language | Require [ANALYZER]/[LAB]/… | +| No SoT in prompt | Wrong file edited | Paste constraint card | +| One-shot board generation | DRVs / unreviewable CAD | Force execute–repair loop | + ## Acceptance criteria - Prompt includes SoT + freeze + claim tags