#!/usr/bin/env python3 """Validate bounded task context and cross-document state consistency.""" from __future__ import annotations import hashlib import json import re import sys from pathlib import Path from web_context import context_manifest, load_cards, predecessor_ids ROOT = Path(__file__).resolve().parents[1] DOCS = ROOT / "docs" CURRENT = DOCS / "current" TASKS = DOCS / "tasks" BUDGET = CURRENT / "FREECAD_WEB_CONTEXT_BUDGET.json" STATE = CURRENT / "task-state.json" PROGRESS = CURRENT / "FREECAD_WEB_PROGRESS.md" PLAN = CURRENT / "FREECAD_WEB_EXECUTION_PLAN.md" MIN_CONTEXT = CURRENT / "FREECAD_WEB_CONTEXT_MIN.md" P6_INDEX = TASKS / "p6" / "INDEX.md" MATRIX = DOCS / "web-parity-matrix.json" MATRIX_MD = DOCS / "web-parity-matrix.md" 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) STATUS = {"planned", "in_progress", "blocked", "completed"} def main() -> int: errors: list[str] = [] budget = json.loads(BUDGET.read_text(encoding="utf-8")) limits = budget["limits"] state = json.loads(STATE.read_text(encoding="utf-8")) matrix = json.loads(MATRIX.read_text(encoding="utf-8")) for path, limit_key in ( (CURRENT / "INDEX.md", "handoff_bytes"), (CURRENT / "FREECAD_WEB_HANDOFF.md", "handoff_bytes"), (MIN_CONTEXT, "handoff_bytes"), (PLAN, "execution_index_bytes"), (P6_INDEX, "task_index_bytes"), ): if path.stat().st_size > limits[limit_key]: errors.append(f"{path.relative_to(ROOT)} exceeds {limit_key}") parsed_cards = load_cards() cards: dict[str, tuple[Path, str]] = {} runnable_statuses: dict[str, str] = {} card_predecessors: dict[str, list[str]] = {} max_card_bytes = 0 max_context_bytes = 0 for card_id, (path, card_status, text) in parsed_cards.items(): cards[card_id] = (path, card_status) if card_status not in STATUS: errors.append(f"{card_id}: invalid status {card_status!r}") if path.stat().st_size > limits["task_card_bytes"]: errors.append(f"{path.relative_to(ROOT)} exceeds task_card_bytes") max_card_bytes = max(max_card_bytes, path.stat().st_size) # P6 module package indexes live directly under p6 and are containers; # only cards below a module directory count toward the active-task limit. if "/p6/" in str(path) and path.parent != TASKS / "p6": runnable_statuses[card_id] = card_status ids = predecessor_ids(text) card_predecessors[card_id] = ids count = len(ids) if count > limits["direct_prerequisite_count"]: errors.append(f"{card_id}: {count} direct prerequisites exceeds limit") for card_id, (path, _) in cards.items(): context_bytes = path.stat().st_size for predecessor_id in card_predecessors.get(card_id, []): predecessor = cards.get(predecessor_id) if predecessor: context_bytes += predecessor[0].stat().st_size # Raw dependency-card size is diagnostic only. context_manifest() # enforces the real execution budget and explicitly excludes completed # prerequisite bodies that do not fit, while retaining dependency IDs. max_context_bytes = max(max_context_bytes, context_bytes) # The executable context is the materialized closure, not just the sum of # Markdown card sizes. This includes the L0 handoff and explicitly named # evidence while excluding logs, source trees, and broad inventories. packet_max_bytes = 0 packet_max_task = None for card_id in cards: try: packet = context_manifest(card_id, cards=parsed_cards) totals = packet["totals"] packet_limits = packet["budget"] if totals["bytes"] > packet_limits["total_bytes_limit"]: errors.append(f"{card_id}: materialized context exceeds task_context_bytes") if totals["evidence_bytes"] > packet_limits["evidence_bytes_limit"]: errors.append(f"{card_id}: direct evidence exceeds direct_evidence_bytes") if totals["evidence_files"] > packet_limits["evidence_file_limit"]: errors.append(f"{card_id}: direct evidence exceeds file-count limit") for item in packet["excluded"]: if item["reason"] == "missing prerequisite card": errors.append(f"{card_id}: missing prerequisite card {item['path']}") if totals["bytes"] > packet_max_bytes: packet_max_bytes, packet_max_task = totals["bytes"], card_id except (KeyError, OSError, json.JSONDecodeError) as exc: errors.append(f"{card_id}: context packet generation failed: {exc}") active_for_packet = state.get("active_task") active_packet = None if active_for_packet in cards: try: active_packet = context_manifest(active_for_packet, cards=parsed_cards) totals = active_packet["totals"] packet_limits = active_packet["budget"] if totals["bytes"] > packet_limits["total_bytes_limit"]: errors.append(f"{active_for_packet}: materialized context exceeds task_context_bytes") if totals["evidence_bytes"] > packet_limits["evidence_bytes_limit"]: errors.append(f"{active_for_packet}: direct evidence exceeds direct_evidence_bytes") if totals["evidence_files"] > packet_limits["evidence_file_limit"]: errors.append(f"{active_for_packet}: direct evidence exceeds file-count limit") for item in active_packet["excluded"]: if item["reason"] == "missing prerequisite card": errors.append(f"{active_for_packet}: missing prerequisite card {item['path']}") except (KeyError, OSError, json.JSONDecodeError) as exc: errors.append(f"{active_for_packet}: context packet generation failed: {exc}") active = state.get("active_task") terminal = active is None and state.get("module_status") and all( status == "completed" for status in state["module_status"].values() ) if active is None and not terminal: errors.append("active task may be empty only after all module gates are completed") progress_match = ACTIVE.search(PROGRESS.read_text(encoding="utf-8")) progress_active = progress_match.group(1) if progress_match else None if progress_active == "none": progress_active = None if active != progress_active: errors.append(f"active task mismatch: state={active!r}, progress={progress_active!r}") handoff_text = (CURRENT / "FREECAD_WEB_HANDOFF.md").read_text(encoding="utf-8", errors="replace") handoff_active_match = re.search(r"^\|\s*`active_task`\s*\|\s*`([^`]+)`", handoff_text, re.MULTILINE) handoff_active = handoff_active_match.group(1) if handoff_active_match else None if handoff_active == "none": handoff_active = None if handoff_active != active: errors.append(f"active task mismatch: state={active!r}, handoff={handoff_active!r}") handoff_status_match = re.search(r"^\|\s*`status`\s*\|\s*`([^`]+)`", handoff_text, re.MULTILINE) expected_handoff_status = "completed" if terminal else "in_progress" if not handoff_status_match or handoff_status_match.group(1) != expected_handoff_status: errors.append(f"handoff status must be `{expected_handoff_status}`") handoff_card_match = re.search(r"^\|\s*`task_card`\s*\|\s*.*\(([^)]+)\)", handoff_text, re.MULTILINE) expected_card_suffix = f"../tasks/{cards[active][0].relative_to(TASKS).as_posix()}" if active in cards else None if not terminal and (not handoff_card_match or expected_card_suffix not in handoff_card_match.group(1)): errors.append(f"handoff task_card link does not point to {active}") handoff_context_match = re.search(r"^\|\s*`context_manifest`\s*\|\s*.*\(([^)]+)\)", handoff_text, re.MULTILINE) expected_context_suffix = f"context/{active}.json" if not terminal and (not handoff_context_match or expected_context_suffix not in handoff_context_match.group(1)): errors.append(f"handoff context_manifest link does not point to {active}") if active in cards: expected_predecessors = predecessor_ids(parsed_cards[active][2]) handoff_predecessor_match = re.search(r"^\|\s*`direct_prerequisites`\s*\|\s*(.*?)\s*\|$", handoff_text, re.MULTILINE) actual_predecessors = re.findall(r"FW-[A-Z0-9-]+", handoff_predecessor_match.group(1)) if handoff_predecessor_match else [] if actual_predecessors != expected_predecessors: errors.append(f"handoff direct_prerequisites mismatch for {active}") manifest_path = CURRENT / "context" / f"{active}.json" if active_packet and not manifest_path.exists(): errors.append(f"missing materialized context manifest: {manifest_path.relative_to(ROOT)}") elif active_packet and manifest_path.exists(): try: saved_packet = json.loads(manifest_path.read_text(encoding="utf-8")) if saved_packet.get("task_id") != active or saved_packet.get("totals") != active_packet.get("totals"): errors.append(f"stale context manifest for {active}; rerun prepare_web_task_context.py") except (OSError, json.JSONDecodeError) as exc: errors.append(f"invalid context manifest for {active}: {exc}") if not MIN_CONTEXT.exists(): errors.append("missing minimal continuation summary; rerun prepare_web_task_context.py") elif terminal: summary = MIN_CONTEXT.read_text(encoding="utf-8", errors="replace") if "- 当前任务:`none`(`completed`)" not in summary: errors.append("terminal minimal continuation summary is stale") else: summary = MIN_CONTEXT.read_text(encoding="utf-8", errors="replace") summary_match = re.search(r"^- 当前任务:`([^`]+)`", summary, re.MULTILINE) if not summary_match or summary_match.group(1) != active: errors.append(f"minimal continuation summary points to {summary_match.group(1) if summary_match else None!r}") manifest_hash_match = re.search(r"^- 清单 hash:`([^`]+)`", summary, re.MULTILINE) if not manifest_hash_match: errors.append("minimal continuation summary is missing manifest hash") elif manifest_path.exists(): actual_hash = hashlib.sha256(manifest_path.read_bytes()).hexdigest() if manifest_hash_match.group(1) != actual_hash: errors.append(f"stale minimal continuation summary for {active}; rerun prepare_web_task_context.py") active_card = cards.get(active) if not terminal and not active_card: errors.append(f"active task {active!r} has no task card") elif not terminal and active_card[1] != "in_progress": errors.append(f"active task {active} card status is {active_card[1]!r}") active_count = sum(value == "in_progress" for value in runnable_statuses.values()) if terminal and active_count != 0: errors.append(f"terminal state has {active_count} in_progress runnable cards") elif active_count > limits["max_active_tasks"]: errors.append(f"{active_count} runnable cards are in_progress") state_tasks = state.get("tasks", {}) for card_id, (_, card_status) in cards.items(): if state_tasks.get(card_id) != card_status: errors.append(f"state/card mismatch for {card_id}: {state_tasks.get(card_id)!r} != {card_status}") missing_state = set(cards) - set(state_tasks) if missing_state: errors.append(f"cards missing from task-state.json: {sorted(missing_state)[:5]}") stale_state = set(state_tasks) - set(cards) if stale_state: errors.append(f"task-state.json contains stale cards: {sorted(stale_state)[:5]}") matrix_by_module = {row["module"]: row["web_status"] for row in matrix.get("rows", [])} if state.get("module_status") != matrix_by_module: errors.append("task-state.json module_status does not match web-parity-matrix.json") matrix_hash = hashlib.sha256(MATRIX.read_bytes()).hexdigest() if state.get("source_matrix_sha256") != matrix_hash: errors.append("task-state.json source_matrix_sha256 is stale") markdown = MATRIX_MD.read_text(encoding="utf-8") markdown_by_module = {} for line in markdown.splitlines(): match = re.match(r"^\| `([^`]+)` \|.*\| `(pending|in_progress|blocked|completed)` \|", line) if match: markdown_by_module[match.group(1)] = match.group(2) if markdown_by_module != matrix_by_module: errors.append("web-parity-matrix.md statuses do not match web-parity-matrix.json") if errors: print("CONTEXT VALIDATION FAILED") for error in errors: print(f"- {error}") return 1 packet_totals = active_packet["totals"] if active_packet else {"bytes": 0, "estimated_tokens": 0} print( f"CONTEXT VALIDATION PASSED: {len(cards)} cards, active={active}, " f"max_card={max_card_bytes}B, max_direct_context={max_context_bytes}B, " f"max_packet={packet_max_bytes}B ({packet_max_task}), " f"active_packet={packet_totals['bytes']}B/~{packet_totals['estimated_tokens']} tokens, " f"budget={limits['task_context_tokens']} estimated tokens" ) return 0 if __name__ == "__main__": sys.exit(main())