308 lines
14 KiB
Python
308 lines
14 KiB
Python
#!/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()
|