#!/usr/bin/env python3 """Generate small, module-local P6 task packets from the source-backed matrix.""" from __future__ import annotations import json import hashlib import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MATRIX = ROOT / "docs" / "web-parity-matrix.json" OUT = ROOT / "docs" / "tasks" / "p6" STATE = ROOT / "docs" / "current" / "task-state.json" MODULE_IDS = { "Assembly": "ASM", "BIM": "BIM", "CAM": "CAM", "Cloud": "CLD", "Draft": "DFT", "Fem": "FEM", "Help": "HLP", "Import": "IMP", "Inspection": "INS", "JtReader": "JTR", "Material": "MAT", "Measure": "MEA", "Mesh": "MSH", "MeshPart": "MSP", "OpenSCAD": "OSC", "Part": "PRT", "PartDesign": "PDN", "Plot": "PLT", "Points": "PTS", "ReverseEngineering": "REV", "Robot": "ROB", "Sandbox": "SDB", "Show": "SHW", "Sketcher": "SKT", "Spreadsheet": "SPR", "Start": "STR", "Surface": "SUR", "TechDraw": "TDR", "TemplatePyMod": "TPM", "Test": "TST", "Tux": "TUX", "Web": "WEB", } PRIMARY_ACTION = { "Assembly": "create/recompute", "BIM": "import/export", "CAM": "create/recompute", "Cloud": "create/recompute", "Draft": "create/recompute", "Fem": "create/recompute", "Help": "inspect/source-inventory", "Import": "import/export", "Inspection": "create/recompute", "JtReader": "create/recompute", "Material": "create/recompute", "Measure": "create/recompute", "Mesh": "create/recompute", "MeshPart": "create/recompute", "OpenSCAD": "inspect/source-inventory", "Part": "create/recompute", "PartDesign": "create/recompute", "Plot": "inspect/source-inventory", "Points": "create/recompute", "ReverseEngineering": "create/recompute", "Robot": "create/recompute", "Sandbox": "create/recompute", "Show": "inspect/source-inventory", "Sketcher": "create/recompute", "Spreadsheet": "create/recompute", "Start": "create/recompute", "Surface": "create/recompute", "TechDraw": "create/recompute", "TemplatePyMod": "inspect/source-inventory", "Test": "inspect/source-inventory", "Tux": "inspect/source-inventory", "Web": "create/recompute", } def load_state() -> dict: if not STATE.exists(): return {"default_status": "planned", "tasks": {}, "module_status": {}} return json.loads(STATE.read_text(encoding="utf-8")) STATE_DATA = load_state() def task_status(task_id: str) -> str: return STATE_DATA.get("tasks", {}).get(task_id, STATE_DATA.get("default_status", "planned")) def module_status(module: str, fallback: str) -> str: return STATE_DATA.get("module_status", {}).get(module, fallback) def write_generated(path: Path, content: str) -> None: """Write the generated prefix while preserving a manually recorded result section.""" if path.exists(): old = path.read_text(encoding="utf-8") marker = "\n## 结果\n" if marker in old and marker not in content: content = content.rstrip() + old[old.index(marker):] path.write_text(content.rstrip() + "\n", encoding="utf-8") def slug(module: str) -> str: return re.sub(r"[^a-z0-9]+", "-", module.lower()).strip("-") def action_slug(action: str) -> str: return re.sub(r"[^a-z0-9]+", "-", action.lower()).strip("-") def module_index(module: str, row: dict) -> str: code = MODULE_IDS[module] action = PRIMARY_ACTION[module] objects = ", ".join(row.get("object_types", [])[:8]) or "无已枚举对象;以源码入口和模块 API 为主" if len(row.get("object_types", [])) > 8: objects += f"(另有 {len(row['object_types']) - 8} 个,完整清单见 object-inventory.json)" formats = ", ".join(row.get("format_extensions", [])) or "无格式入口" api = row.get("python_api_count", 0) gui = "需要 GUI 依赖" if row.get("gui_dependency") else "不需要 GUI 依赖" actions = ", ".join(row.get("user_actions", [])) followups = [action for action in row.get("user_actions", []) if action != action_primary(module)] followup_text = ", ".join(f"`{action}`" for action in followups) or "无" action_rows = [] for action_number, action_name in enumerate(row.get("user_actions", []), start=1): if action_name == action: target = f"{slug(module)}/FW-P6-{code}-C.md" role = "首切片 C-G" else: target = f"{slug(module)}/FW-P6-{code}-ACT-{action_number:02d}.md" role = "后续拆分合同" action_rows.append(f"| `{action_name}` | {role} | [{target.rsplit('/', 1)[-1]}]({target}) |") action_table = "\n".join(action_rows) action_predecessors = [ f"`FW-P6-{code}-ACT-{number:02d}-G`" for number, action_name in enumerate(row.get("user_actions", []), start=1) if action_name != action ] final_predecessor = f"`FW-P6-{code}-X`" + (" + " + " + ".join(action_predecessors) if action_predecessors else "") links = "\n".join( f"| [FW-P6-{code}-{letter}]({slug(module)}/FW-P6-{code}-{letter}.md) | {title} | {prev} |" for letter, title, prev in [ ("A", "源码边界探针", "FW-P6-000"), ("B", "运行时注册与依赖闭包", f"FW-P6-{code}-A + FW-P6-001"), ("C", "一个主要内核动作", f"FW-P6-{code}-B"), ("D", "重算、非法输入与恢复", f"FW-P6-{code}-C"), ("E", "Worker/Facade 消息合同", f"FW-P6-{code}-D + FW-P6-002"), ("F", "Chrome 最小浏览器切片", f"FW-P6-{code}-E + FW-P6-003"), ("G", "持久化或模块边界证据", f"FW-P6-{code}-F"), ("X", "首切片出口", f"FW-P6-{code}-G"), ("Y", "完整动作出口", final_predecessor), ] ) package_status = module_status(module, row.get("web_status", "pending")) # Module parity uses `pending`; executable task cards use `planned`. package_task_status = "planned" if package_status == "pending" else package_status web_status_note = ( "Web 状态已由 Y gate 晋级为 `completed`;边界见对应 module-gate 产物。" if package_status == "completed" else "Web 状态保持 `pending`,直到本包的 Y 卡通过。" ) return f"""# FW-P6-{code}:{module} 功能扩展任务包 本文件只做模块范围和原子卡索引;执行时读取当前原子卡,不读取本包其他卡。 ## 范围快照 - 状态:`{package_task_status}` - 来源:`docs/web-parity-matrix.json`,源码 revision 由 `FW-P6-000` 锁定 - 主要动作:`{action}` - 对象规模:`{row.get('object_type_count', 0)}`;代表入口:{objects} - 格式入口:{formats} - Python API 计数:`{api}`;GUI:{gui} - 对标动作全集:{actions} - {web_status_note} 首个 C-G 切片只覆盖 `{action}`。后续动作:{followup_text};每个后续动作必须在 Y 前完成自己的拆分卡和 C-G 子链,不能因为首个切片通过就把模块标为 `completed`。 ## 动作级入口 | 动作 | 角色 | 入口 | |---|---|---| {action_table} ## 原子卡 | 卡 | 合同 | 直接前置 | |---|---|---| {links} 顺序:`A → B → C → D → E → F → G → X`;随后完成动作级卡,最后执行 `Y`。失败只新增同目录 `R` 恢复卡,不复制原卡全文。 """ def atomic_card(module: str, row: dict, letter: str) -> str: code = MODULE_IDS[module] name = slug(module) prefix = f"FW-P6-{code}" titles = { "A": "源码边界探针", "B": "运行时注册与依赖闭包", "C": "一个主要内核动作", "D": "重算、非法输入与恢复", "E": "Worker/Facade 消息合同", "F": "Chrome 最小浏览器切片", "G": "持久化或模块边界证据", "X": "首切片出口", "Y": "完整动作出口", } prev = { "A": "`FW-P6-000`", "B": f"`{prefix}-A`、`FW-P6-001`", "C": f"`{prefix}-B`", "D": f"`{prefix}-C`", "E": f"`{prefix}-D`、`FW-P6-002`", "F": f"`{prefix}-E`、`FW-P6-003`", "G": f"`{prefix}-F`", "X": f"`{prefix}-G`", "Y": f"`{prefix}-X`", }[letter] if letter == "Y": action_predecessors = [ f"`{prefix}-ACT-{number:02d}-G`" for number, action_name in enumerate(row.get("user_actions", []), start=1) if action_name != PRIMARY_ACTION[module] ] if action_predecessors: prev += " + " + " + ".join(action_predecessors) task_type = {"A": "probe", "F": "slice", "X": "gate", "Y": "gate"}.get(letter, "contract") bodies = { "A": f"""- 合同:确认 `{module}` 的 CMake target、App/Gui 分界、直接依赖、类型注册入口和主要动作源码入口。 - 输入:`workbench-inventory.json`、`object-inventory.json`、`src/Mod/{module}/`。 - 命令/方法:在 `freecad-source` 上运行 `rg -n "BUILD_|Init|addObject|registerType|PyInit" src/Mod/{module}`,再对命中的 CMake 与源码行做最小闭包记录。 - 产物:`docs/p6/{name}-source-boundary.json` 与日志。 - 验收:每个入口有文件和行号;条件编译、缺失依赖和 GUI 符号单独列出;不把源码存在写成运行时通过。 - 不包含:代码修改、浏览器测试、完整 API 迁移。""", "B": f"""- 合同:在独立 WASM probe 中完成 `{module}` 初始化、类型可见性和重复初始化检查。 - 输入:A 的边界 JSON、模块 App CMake、共享模块加载合同。 - 命令/方法:复制现有最小 Web probe 的 configure/build/run 流程;只替换模块 target 和一个代表类型;连续执行初始化两次并保存新的 build/smoke 日志,不覆盖成功证据。 - 产物:`docs/p6/{name}-runtime.json`、构建日志和 WASM SHA-256。 - 验收:初始化计数为 1;重复调用不重复注册;缺失依赖返回结构化错误;无未声明 GUI/动态库符号。 - 不包含:主要动作、Facade、Chrome UI。""", "C": f"""- 合同:只验证一个最小 `{PRIMARY_ACTION[module]}` 合同,固定输入、输出和对象句柄。 - 输入:B 的 WASM、固定 fixture、对应源码 API;对象模块用代表类型,格式模块用一个最小样例。 - 命令/方法:从 A 的主要动作入口选择一个 deterministic fixture;在 C ABI/Worker probe 中执行一次成功路径和一次资源释放,记录输入 hash、结果摘要和返回码。 - 产物:`docs/p6/{name}-kernel.json` 与 smoke 日志。 - 验收:成功结果可重复;对象/几何/格式摘要符合源码语义;异常转为稳定错误码和消息;资源可释放。 - 不包含:第二个动作、全量对象覆盖、GUI 交互。""", "D": f"""- 合同:覆盖一次重算/校验边界和一个非法输入,失败后保留上一次有效状态。 - 输入:C 的成功 fixture;只读模块改为缺少资源/不支持输入边界。 - 命令/方法:复用 C 的同一进程,先记录成功快照,再注入一个字段级非法值或缺失资源;校验错误 code、旧状态 hash、重复回放结果。 - 产物:`docs/p6/{name}-recovery.json` 与日志。 - 验收:错误定位到字段或步骤;有效对象/文件 hash 不污染;重复执行一致;缓存失败不升级为主流程失败。 - 不包含:跨文档事务、并发修改、性能预算。""", "E": f"""- 合同:把 C/D 的一个动作接入 request id、typed result、错误和取消语义。 - 输入:共享 Facade envelope、D 的 fixture 和错误样例。 - 命令/方法:为一个 action 增加 typed request/result schema;顺序发送 success、expected error、duplicate id、cancel 四个 request,保存 Worker trace。 - 产物:`docs/p6/{name}-facade.json`、schema 测试和 Worker 日志。 - 验收:请求严格串行且可关联;JS 不持有原生指针;成功/失败/重复 request 均有固定结果;取消不破坏文档状态。 - 不包含:完整 UI、插件系统、跨工作台组合。""", "F": f"""- 合同:在 Google Chrome 固定版本和 viewport 下完成一个真实 `{PRIMARY_ACTION[module]}` 用户动作回放。 - 输入:Facade bundle、固定 fixture、Chrome harness;只执行 Chrome,不切换 Firefox/WebKit/Safari。 - 命令/方法:调用 `FW-P6-003` harness 启动入口,拦截所有外部请求,执行 ready→action→expected-error→reset,并保存 console/network/截图证据。 - 产物:`docs/p6/{name}-chrome.json`、控制台/网络日志和截图(如适用)。 - 验收:ready、动作结果、错误路径和 Worker 消息通过;外部请求为 0;运行时异常为 0;证据含 Chrome 版本、模式和 viewport。 - 不包含:非 Chrome 兼容矩阵、移动适配、完整 UI。""", "G": f"""- 合同:有保存/重开动作时验证一个最小 FCStd round-trip;否则验证 manifest/资源/只读重放边界。 - 输入:F 的成功状态、P2.3 FCStd 合同、模块 fixture。 - 命令/方法:有 `save/reopen` 时按 P2.3 的临时文件、hash、关闭、重开、对象/属性校验顺序执行;无该动作时运行资源 manifest 和只读重放检查。 - 产物:`docs/p6/{name}-persistence.json` 与日志。 - 验收:保存/重开对象和属性可校验;不适用时明确原因,不伪造通过;错误后旧状态仍有效。 - 不包含:跨版本迁移、全量格式覆盖、升级回滚。""", "X": f"""- 合同:汇总本包 A-G 的独立证据,关闭 `{module}` 的首切片 gate。 - 输入:A-G JSON/日志、源码 revision、共享 gate 报告。 - 命令/方法:逐项读取本包 A-G 产物,核对 revision、输入/输出 hash、Chrome 证据和未覆盖动作;生成机器可读结果后再写人读 gate。 - 产物:`docs/p6/{name}-gate.md` 与 JSON。 - 验收:首个动作的每个原子合同有命令、输入、产物、hash 和结论;本卡只关闭首切片,不修改完整模块状态;其他动作保持 `pending`。 - 不包含:其他模块、全量发布 gate、法律或性能结论。""", "Y": f"""- 合同:汇总首切片和全部动作级子链,决定 `{module}` 是否可以从 `pending` 晋级。 - 输入:X 的首切片 gate、所有动作级卡及其 C-G 子链产物、源码 revision。 - 命令/方法:逐动作核对源码入口、成功/错误/Chrome/持久化证据;任一声明动作缺证据即保持 `pending`,不得用动作计数推断完成。 - 产物:`docs/p6/{name}-module-gate.md` 与 JSON。 - 验收:本模块声明的 {len(row.get('user_actions', []))} 个动作全部有独立结论;未实现或不适用项显式列出;只有本卡可以晋级该模块 Web 状态。 - 不包含:其他模块、全量发布 gate、法律或性能结论。""", }[letter] return f"""# {prefix}-{letter}:{module} {titles[letter]} > 最小执行上下文:只读取本卡、直接前置 {prev} 和本卡列出的产物。浏览器步骤只允许 Google Chrome。 - 状态:`{task_status(f"{prefix}-{letter}")}` - 类型:`{task_type}` - 前置:{prev} {bodies} - 失败恢复:保留首个错误、命令、输入 hash 和部分产物;恢复卡命名 `{prefix}-{letter}-R1`,不得覆盖成功证据。 """ def action_card(module: str, action_name: str, action_number: int) -> str: code = MODULE_IDS[module] name = slug(module) prefix = f"FW-P6-{code}-ACT-{action_number:02d}" action_id = action_slug(action_name) extra_prerequisite = { "python-api": ";Python 运行时前置 `FW-P4-006-G`", "save/reopen": ";持久化证据前置 `freecad-p2.3-persistence-transaction-gate.md`", }.get(action_name, "") return f"""# {prefix}:{module} `{action_name}` 动作拆分合同 > 这是动作级拆分卡,不等于功能已实现。完成本卡后,必须按卡内的 C→D→E→F→G 子链继续执行;只读取本卡、`FW-P6-{code}-B` 和本卡产物。 - 状态:`{task_status(prefix)}` - 类型:`contract` - 前置:`FW-P6-{code}-B`{extra_prerequisite};若首切片已完成,复用其 WASM、loader 和 fixture 约束。 - 合同:为 `{action_name}` 冻结一个可观察用户动作的输入、源码入口、结果摘要、错误边界、Worker request 和 Chrome 回放验收。 - 输入:`{name}-source-boundary.json`、对象/格式/API inventory 中与该动作对应的行、共享 Facade envelope 和 Chrome harness。 - 命令/方法:先在源码中定位动作入口,再写固定 fixture;把实现拆成 `{prefix}-C`(内核)、`{prefix}-D`(错误/恢复)、`{prefix}-E`(Facade)、`{prefix}-F`(Chrome)、`{prefix}-G`(持久化或适用边界)。 - 产物:`docs/p6/{name}-action-{action_number:02d}-{action_id}.json`,以及上述子卡各自的日志和 hash。 - 验收:动作有唯一 request id、成功和预期错误结果、无外部网络请求;未完成子链时本动作保持 `pending`。 - 不包含:其他动作、工作台 gate、全量对象覆盖、跨版本迁移。 - 失败恢复:只记录首个源码/fixture/schema 错误,新增 `{prefix}-R1`;不复制或覆盖首切片证据。 """ def action_child_card(module: str, action_name: str, action_number: int, letter: str) -> str: code = MODULE_IDS[module] name = slug(module) prefix = f"FW-P6-{code}-ACT-{action_number:02d}" titles = { "C": "内核动作", "D": "错误与恢复", "E": "Facade 接入", "F": "Chrome 回放", "G": "持久化或适用边界", } prev = { "C": f"`{prefix}`、`FW-P6-{code}-B`", "D": f"`{prefix}-C`", "E": f"`{prefix}-D`、`FW-P6-002`", "F": f"`{prefix}-E`、`FW-P6-003`", "G": f"`{prefix}-F`", }[letter] bodies = { "C": f"""- 合同:执行 `{action_name}` 的一个 deterministic 内核输入,返回对象/格式/API 摘要。 - 输入:动作拆分卡、B 的 WASM、动作 fixture 和源码入口。 - 命令/方法:单进程执行成功路径、释放路径和重复回放;记录输入 hash、结果 hash、错误 code。 - 产物:`docs/p6/{name}-action-{action_number:02d}-kernel.json` 与 smoke 日志。 - 验收:结果可重复,异常结构化,资源释放;不实现其他动作。""", "D": f"""- 合同:为 `{action_name}` 注入一个字段级非法输入或缺少资源并保持旧状态。 - 输入:`{prefix}-C` 成功快照和错误 fixture。 - 命令/方法:成功→非法→恢复三段回放,比较旧状态 hash 和诊断字段。 - 产物:`docs/p6/{name}-action-{action_number:02d}-recovery.json` 与日志。 - 验收:首个错误可定位,旧状态未污染,重复回放一致。""", "E": f"""- 合同:把 `{action_name}` 接入通用 request id、typed result、error 和 cancel。 - 输入:`{prefix}-D`、共享 envelope、成功和错误样例。 - 命令/方法:顺序发送 success、expected error、duplicate id、cancel;保存 Worker trace。 - 产物:`docs/p6/{name}-action-{action_number:02d}-facade.json` 与 schema 日志。 - 验收:请求可关联且严格串行;JS 不持有原生指针;取消不污染文档。""", "F": f"""- 合同:在 Chrome 固定版本和 viewport 下回放真实 `{action_name}`。 - 输入:`{prefix}-E` bundle、fixture、`FW-P6-003` harness。 - 命令/方法:ready→action→expected-error→reset,拦截外部请求并保存 console/network/截图。 - 产物:`docs/p6/{name}-action-{action_number:02d}-chrome.json` 与日志。 - 验收:Chrome 版本、模式、viewport 齐全;外部请求和非预期异常为 0。""", "G": f"""- 合同:为 `{action_name}` 完成 FCStd round-trip,或记录该动作不适用的边界证据。 - 输入:`{prefix}-F` 成功状态、P2.3 合同和 fixture。 - 命令/方法:有保存动作时执行临时文件、hash、关闭、重开、对象/属性校验;否则执行 manifest/只读重放。 - 产物:`docs/p6/{name}-action-{action_number:02d}-persistence.json` 与日志。 - 验收:对象/属性或“不适用”原因可校验;不伪造未覆盖能力。""", }[letter] task_type = "slice" if letter == "F" else "contract" return f"""# {prefix}-{letter}:{module} `{action_name}` {titles[letter]} > 最小执行上下文:只读取本卡、直接前置 {prev} 和本卡产物。浏览器只允许 Google Chrome。 - 状态:`{task_status(f"{prefix}-{letter}")}` - 类型:`{task_type}` - 前置:{prev} {bodies} - 不包含:其他用户动作、完整工作台 gate、跨版本迁移。 - 失败恢复:保留首个错误、输入 hash 和 trace;新增 `{prefix}-{letter}-R1`,不覆盖成功证据。 """ def action_primary(module: str) -> str: return PRIMARY_ACTION[module] def main() -> None: data = json.loads(MATRIX.read_text(encoding="utf-8")) rows = {row["module"]: row for row in data["rows"]} missing = set(MODULE_IDS) - set(rows) if missing: raise SystemExit(f"matrix missing modules: {sorted(missing)}") OUT.mkdir(parents=True, exist_ok=True) index = [ "# P6 功能扩展任务索引", "", "> P3 gate 已完成的是源码盘点,不是 32 个工作台的 Web 实现。P6 按工作台建立独立任务包;每个包内部再拆为 A-G、X 首切片和 Y 完整出口。", "> 当前 32 个模块声明了 115 个用户动作;每个包先做一个首切片,其他动作必须继续拆独立合同,不能用首切片冒充完整对标。", "> 状态由 `docs/current/task-state.json` 统一维护;本索引和卡片是生成投影。", "> 机器可读文件清单和数量校验见 [`manifest.json`](manifest.json)。", "> 矩阵更新后依次运行生成器、`sync_web_task_state.py` 和 `validate_web_context.py`;不要手改生成结构。", "", "## 共享前置", "", "| 编号 | 合同 | 产物 |", "|---|---|---|", "| [FW-P6-000](../FW-P6-000.md) | 锁定源码、矩阵、构建和 Chrome 环境 | `docs/p6/baseline.json` |", "| [FW-P6-001](../FW-P6-001.md) | 模块初始化、类型注册和依赖错误 | `docs/p6/module-loader.json` |", "| [FW-P6-002](../FW-P6-002.md) | Worker/Facade 通用消息 envelope | `docs/p6/facade-envelope.json` |", "| [FW-P6-003](../FW-P6-003.md) | Chrome 回放 harness 和证据格式 | `docs/p6/chrome-harness.json` |", "", "## 工作台任务包", "", "| 包 | 模块 | 模块状态 | 首个动作 | 全部动作 | 对象 | 格式 | Python API | 直接前置 |", "|---|---|---|---|---|---:|---|---:|---|", ] for module in MODULE_IDS: row = rows[module] code = MODULE_IDS[module] index.append( f"| [FW-P6-{code}](FW-P6-{slug(module)}.md) | `{module}` | " f"`{module_status(module, row.get('web_status', 'pending'))}` | `{PRIMARY_ACTION[module]}` | " f"{', '.join(row['user_actions'])} | {row['object_type_count']} | " f"{', '.join('`'+x+'`' for x in row['format_extensions']) or '-'} | " f"{row['python_api_count']} | `FW-P6-000`, `001`, `002`, `003` |" ) index += [ "", "## 原子步骤语义", "", "| 步骤 | 单一合同 | 不读取的内容 |", "|---|---|---|", "| A | 源码边界探针 | 其他模块实现、浏览器日志 |", "| B | WASM 初始化和类型注册 | 主要动作、UI |", "| C | 一个主要内核动作 | 第二个动作、全量对象 |", "| D | 重算/非法输入/恢复 | 并发和性能 |", "| E | Worker/Facade 消息 | 完整 UI 和插件 |", "| F | Chrome 用户动作 | 非 Chrome 浏览器 |", "| G | FCStd round-trip 或适用边界 | 跨版本迁移 |", "| X | 首切片 gate | 其他模块和发布 gate |", "| Y | 完整动作 gate | 其他模块和发布 gate |", "", "失败恢复卡使用同一包内的 `R` 后缀,例如 `FW-P6-PRT-C-R1`;恢复卡只记录首个错误、最小修复和重放命令,不复制原卡全文。", ] write_generated(OUT / "INDEX.md", "\n".join(index)) for module, row in rows.items(): module_slug = slug(module) write_generated(OUT / f"FW-P6-{module_slug}.md", module_index(module, row)) module_dir = OUT / module_slug module_dir.mkdir(parents=True, exist_ok=True) for letter in "ABCDEFGXY": write_generated( module_dir / f"FW-P6-{MODULE_IDS[module]}-{letter}.md", atomic_card(module, row, letter), ) for action_number, action_name in enumerate(row.get("user_actions", []), start=1): if action_name == PRIMARY_ACTION[module]: continue write_generated( module_dir / f"FW-P6-{MODULE_IDS[module]}-ACT-{action_number:02d}.md", action_card(module, action_name, action_number), ) for letter in "CDEFG": write_generated( module_dir / f"FW-P6-{MODULE_IDS[module]}-ACT-{action_number:02d}-{letter}.md", action_child_card(module, action_name, action_number, letter), ) files = sorted(str(path.relative_to(ROOT)) for path in OUT.rglob("*.md")) manifest = { "schema": "freecad-web-p6-task-packet-manifest/v1", "source_matrix": str(MATRIX.relative_to(ROOT)), "source_matrix_sha256": hashlib.sha256(MATRIX.read_bytes()).hexdigest(), "module_count": len(rows), "declared_action_count": sum(len(row.get("user_actions", [])) for row in rows.values()), "module_index_count": len(rows), "atomic_card_count": len(rows) * 9, "action_split_card_count": sum( len([action for action in row.get("user_actions", []) if action != PRIMARY_ACTION[module]]) for module, row in rows.items() ), "action_child_card_count": sum( 5 * len([action for action in row.get("user_actions", []) if action != PRIMARY_ACTION[module]]) for module, row in rows.items() ), "markdown_file_count": len(files), "files": files, "shared_prerequisites": [ "docs/tasks/FW-P6-000.md", "docs/tasks/FW-P6-001.md", "docs/tasks/FW-P6-002.md", "docs/tasks/FW-P6-003.md", ], } (OUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if __name__ == "__main__": main()