#!/usr/bin/env python3 """Maintain the authoritative task-state ledger and its current-task projection.""" from __future__ import annotations import hashlib import argparse import json import re from datetime import date from pathlib import Path from web_context import predecessor_ids, write_context_manifest, write_context_summary ROOT = Path(__file__).resolve().parents[1] DOCS = ROOT / "docs" TASKS = DOCS / "tasks" CURRENT = DOCS / "current" PROGRESS = CURRENT / "FREECAD_WEB_PROGRESS.md" HANDOFF = CURRENT / "FREECAD_WEB_HANDOFF.md" MIN_CONTEXT = CURRENT / "FREECAD_WEB_CONTEXT_MIN.md" NEXT_TASK = ROOT / "nextTask.md" MATRIX = DOCS / "web-parity-matrix.json" STATE = CURRENT / "task-state.json" CARD_ID = re.compile(r"^#\s+(FW-[^:: ]+)", re.MULTILINE) CARD_STATUS = re.compile(r"^-\s*状态:`([^`]+)`", re.MULTILINE) ACTIVE = re.compile(r"^\|\s*当前任务\s*\|\s*`([^`]+)`", re.MULTILINE) def update_current_projection(active_task: str) -> None: """Keep the two human-readable current pointers aligned after --active.""" progress = PROGRESS.read_text(encoding="utf-8") progress = re.sub( r"(^\|\s*当前任务\s*\|\s*)`[^`]+`(\s*\|$)", rf"\g<1>`{active_task}`\g<2>", progress, flags=re.MULTILINE, ) PROGRESS.write_text(progress, encoding="utf-8") handoff = HANDOFF.read_text(encoding="utf-8") handoff = re.sub( r"(^\|\s*`status`\s*\|\s*)`[^`]+`(\s*\|$)", r"\g<1>`in_progress`\g<2>", handoff, flags=re.MULTILINE, ) handoff = re.sub( r"(^\|\s*`active_task`\s*\|\s*)`[^`]+`(\s*\|$)", rf"\g<1>`{active_task}`\g<2>", handoff, flags=re.MULTILINE, ) card_path = None card_text = None for candidate in TASKS.rglob("*.md"): text = candidate.read_text(encoding="utf-8", errors="replace") if re.search(r"^#\s+" + re.escape(active_task) + r"(?::|:|\s)", text, re.MULTILINE): card_path = "../tasks/" + str(candidate.relative_to(TASKS)) card_text = text break card_link = f"[`{active_task}`]({card_path or '../tasks/'})" handoff = re.sub( r"(^\|\s*`task_card`\s*\|\s*).*$", rf"\g<1>{card_link}", handoff, flags=re.MULTILINE, ) context_link = f"[`context/{active_task}.json`](context/{active_task}.json)" handoff = re.sub( r"(^\|\s*`context_manifest`\s*\|\s*).*$", rf"\g<1>{context_link}", handoff, flags=re.MULTILINE, ) if card_text: heading = re.search(r"^#\s+[^::]+[::]\s*(.+)$", card_text, re.MULTILINE) contract = re.search(r"^-\s*合同:(.+)$", card_text, re.MULTILINE) predecessors = predecessor_ids(card_text) replacements = { "scope": heading.group(1).strip() if heading else active_task, "direct_prerequisites": ", ".join(f"`{item}`" for item in predecessors) or "无", "next_action": contract.group(1).strip() if contract else "执行当前任务卡中的合同", } for field, value in replacements.items(): handoff = re.sub( rf"^\|\s*`{field}`\s*\|.*$", f"| `{field}` | {value} |", handoff, flags=re.MULTILINE, ) HANDOFF.write_text(handoff, encoding="utf-8") if card_text: progress = PROGRESS.read_text(encoding="utf-8") heading = re.search(r"^#\s+[^::]+[::]\s*(.+)$", card_text, re.MULTILINE) if heading: progress = re.sub( r"(^\|\s*目标\s*\|\s*).*$", rf"\g<1>{heading.group(1).strip()} |", progress, flags=re.MULTILINE, ) PROGRESS.write_text(progress, encoding="utf-8") # nextTask.md is a continuation entrypoint, not an independent state # source. Keep its checkpoint synchronized so a fresh session never starts # from a stale historical task. next_text = NEXT_TASK.read_text(encoding="utf-8") module = next((part for part in (card_path or "").split("/") if part not in {"..", "tasks", "p6"}), "unknown") module_display = {row["module"].lower(): row["module"] for row in json.loads(MATRIX.read_text(encoding="utf-8")).get("rows", [])}.get(module.lower(), module) task_scope = heading.group(1).strip() if card_text and (heading := re.search(r"^#\s+[^::]+[::]\s*(.+)$", card_text, re.MULTILINE)) else active_task current_module_status = json.loads(STATE.read_text(encoding="utf-8")).get("module_status", {}) if STATE.exists() else {} completed_modules = [name for name, status in current_module_status.items() if status == "completed"] next_text = re.sub(r"(^- 活动任务:)`[^`]+`", rf"\g<1>`{active_task}`", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 任务:).*$", rf"\g<1>{task_scope}", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 状态:)`[^`]+`", "- 状态:`in_progress`", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 当前模块:).*$", rf"\g<1>`{module_display}`", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 已完成模块:).*$", rf"\g<1>{', '.join(completed_modules) or '无'}", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 待完成模块:).*$", rf"\g<1>{', '.join(name for name, status in current_module_status.items() if status != 'completed') or '无'}", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"然后执行 `[^`]+`。", rf"然后执行当前账本的 `{active_task}`。", next_text, count=1) NEXT_TASK.write_text(next_text, encoding="utf-8") def update_terminal_projection(module_status: dict[str, str]) -> None: """Project the legitimate no-active-task state after the final module gate.""" progress = PROGRESS.read_text(encoding="utf-8") progress = re.sub(r"(^\|\s*当前任务\s*\|\s*)`[^`]+`(\s*\|$)", r"\g<1>`none`\g<2>", progress, flags=re.MULTILINE) progress = re.sub(r"(^\|\s*状态\s*\|\s*)`[^`]+`(\s*\|$)", r"\g<1>`completed`\g<2>", progress, flags=re.MULTILINE) progress = re.sub(r"(^\|\s*目标\s*\|\s*).*$", r"\g<1>全部 Web 对标模块已完成 |", progress, flags=re.MULTILINE) PROGRESS.write_text(progress, encoding="utf-8") handoff = HANDOFF.read_text(encoding="utf-8") terminal_fields = { "active_task": "`none`", "status": "`completed`", "scope": "全部 Web 对标模块已完成", "direct_prerequisites": "无", "task_card": "无", "context_manifest": "无", "next_action": "无待执行叶子任务;新工作必须先创建任务卡并显式领取。", } for field, value in terminal_fields.items(): handoff = re.sub(rf"^\|\s*`{field}`\s*\|.*$", f"| `{field}` | {value} |", handoff, flags=re.MULTILINE) HANDOFF.write_text(handoff, encoding="utf-8") completed_modules = [name for name, status in module_status.items() if status == "completed"] next_text = NEXT_TASK.read_text(encoding="utf-8") next_text = re.sub(r"(^- 活动任务:)`[^`]+`", r"\g<1>`无`", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 任务:).*$", r"\g<1>全部 Web 对标模块已完成", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 状态:)`[^`]+`", r"\g<1>`completed`", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 当前模块:).*$", r"\g<1>`无`", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 已完成模块:).*$", rf"\g<1>{', '.join(completed_modules)}", next_text, count=1, flags=re.MULTILINE) next_text = re.sub(r"(^- 待完成模块:).*$", r"\g<1>无", next_text, count=1, flags=re.MULTILINE) NEXT_TASK.write_text(next_text, encoding="utf-8") MIN_CONTEXT.write_text( "# FreeCAD Web 最小续接上下文\n\n" "- 当前任务:`none`(`completed`)\n" "- 全部 Web 对标模块已通过各自 Y gate;当前没有活动叶子任务。\n" "- 新工作必须先创建任务卡、写入账本并生成独立 context manifest。\n", encoding="utf-8", ) def scan_cards() -> dict[str, str]: result: dict[str, str] = {} for path in sorted(TASKS.rglob("*.md")): if path.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}: continue text = path.read_text(encoding="utf-8", errors="replace") card = CARD_ID.search(text) status = CARD_STATUS.search(text) if not card or not status: continue result[card.group(1)] = status.group(1) return result def update_card_status_projections(statuses: dict[str, str]) -> None: """Keep human-readable card status lines aligned with the ledger. This is deliberately limited to the single status field; result sections and generated card structure remain untouched. """ for path in sorted(TASKS.rglob("*.md")): if path.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}: continue text = path.read_text(encoding="utf-8", errors="replace") card = CARD_ID.search(text) if not card or card.group(1) not in statuses: continue text = re.sub( r"(^-\s*状态:)`[^`]+`", rf"\g<1>`{statuses[card.group(1)]}`", text, count=1, flags=re.MULTILINE, ) path.write_text(text, encoding="utf-8") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--set", dest="updates", action="append", default=[], metavar="TASK=STATUS", help="change one task status; repeatable", ) parser.add_argument("--active", help="set the one active task") parser.add_argument("--clear-active", action="store_true", help="clear the active task after the final executable leaf completes") args = parser.parse_args() if args.active and args.clear_active: parser.error("--active and --clear-active are mutually exclusive") cards = scan_cards() if STATE.exists(): state = json.loads(STATE.read_text(encoding="utf-8")) statuses = { task_id: status for task_id, status in state.get("tasks", {}).items() if task_id in cards } # New generated cards enter the ledger as planned; existing statuses # are never inferred from a card projection. for task_id in cards: statuses.setdefault(task_id, "planned") active_task = None if args.clear_active else (args.active or state.get("active_task")) module_status = dict(state.get("module_status", {})) else: statuses = cards progress_text = PROGRESS.read_text(encoding="utf-8") active_match = ACTIVE.search(progress_text) active_task = None if args.clear_active else (args.active or (active_match.group(1) if active_match else None)) module_status = {} explicit_tasks = set() for update in args.updates: if "=" not in update: parser.error(f"invalid --set value: {update!r}; expected TASK=STATUS") task_id, status = update.split("=", 1) if status not in {"planned", "in_progress", "blocked", "completed"}: parser.error(f"invalid task status: {status!r}") statuses[task_id] = status explicit_tasks.add(task_id) if active_task: statuses[active_task] = "in_progress" # A continuation queue has exactly one runnable card. Automatically clear # stale parent/previous projections when a new active card is claimed. if args.active: active_card_path = None for candidate in TASKS.rglob("*.md"): if candidate.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}: continue if re.search(r"^#\s+" + re.escape(active_task) + r"(?::|:|\s)", candidate.read_text(encoding="utf-8", errors="replace"), re.MULTILINE): active_card_path = candidate break if active_card_path is None: parser.error(f"unknown active task: {active_task}") if active_card_path.parent == TASKS / "p6": parser.error(f"module package is not executable; choose a leaf task below it: {active_task}") if statuses.get(active_task) == "blocked" and active_task not in explicit_tasks: parser.error(f"blocked task requires explicit recovery: --set {active_task}=planned --active {active_task}") for task_id, status in list(statuses.items()): if task_id != active_task and status == "in_progress" and task_id not in explicit_tasks: statuses[task_id] = "planned" if args.clear_active: for task_id, status in list(statuses.items()): if status == "in_progress" and task_id not in explicit_tasks: statuses[task_id] = "planned" if args.active: update_current_projection(active_task) matrix = json.loads(MATRIX.read_text(encoding="utf-8")) if not module_status: module_status = { row["module"]: row["web_status"] for row in matrix.get("rows", []) } state = { "schema": "freecad-web-task-state/v1", "generated_at": date.today().isoformat(), "active_task": active_task, "default_status": "planned", "source_matrix": str(MATRIX.relative_to(ROOT)), "source_matrix_sha256": hashlib.sha256(MATRIX.read_bytes()).hexdigest(), "module_status": module_status, "tasks": dict(sorted(statuses.items())), "counts": { status: sum(value == status for value in statuses.values()) for status in ("planned", "in_progress", "blocked", "completed") }, } update_card_status_projections(statuses) STATE.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if active_task: write_context_manifest(active_task) write_context_summary(active_task) elif args.clear_active: update_terminal_projection(module_status) print(f"wrote {STATE.relative_to(ROOT)}: {len(statuses)} tasks, active={active_task}") if __name__ == "__main__": main()