Files
claude-code/scripts/cc2_board.py
2026-05-14 17:00:14 +09:00

353 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate and validate the Claw Code 2.0 roadmap board.
The board is intentionally derived from the frozen ROADMAP.md headings so the
validation can prove zero unmapped roadmap headings. Optional .omx research and
plan files are summarized as source metadata without mutating Ultragoal state.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
VALID_STATUSES = {
"context",
"active",
"open",
"done_verify",
"stale_done",
"superseded",
"deferred_with_rationale",
"rejected_not_claw",
}
REQUIRED_ITEM_FIELDS = {
"id",
"title",
"source_anchor",
"source_type",
"release_bucket",
"lifecycle_status",
"dependencies",
"verification_required",
"deferral_rationale",
}
OPTIONAL_SOURCES = [
".omx/research/claw-open-latest.json",
".omx/research/claw-issues.json",
".omx/research/codex-repo.json",
".omx/research/codex-issues.json",
".omx/research/opencode-repo.json",
".omx/research/opencode-issues.json",
".omx/plans/claw-code-2-0-adaptive-plan.md",
]
@dataclass(frozen=True)
class Heading:
line: int
level: int
title: str
slug: str
parent_phase: str | None
def slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug or "heading"
def sha256(path: Path) -> str | None:
if not path.exists():
return None
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def read_headings(roadmap: Path) -> list[Heading]:
headings: list[Heading] = []
current_phase: str | None = None
seen: dict[str, int] = {}
for line_no, line in enumerate(roadmap.read_text(encoding="utf-8").splitlines(), 1):
m = re.match(r"^(#{1,6})\s+(.*\S)\s*$", line)
if not m:
continue
level = len(m.group(1))
title = m.group(2).strip()
base = slugify(title)
seen[base] = seen.get(base, 0) + 1
slug = base if seen[base] == 1 else f"{base}-{seen[base]}"
parent_phase = current_phase
if level == 2:
if title.startswith("Phase "):
current_phase = title
parent_phase = current_phase
else:
# Top-level buckets after the phase list (Immediate Backlog,
# Deployment gaps, Pinpoints, etc.) are standalone buckets, not
# children of the preceding phase.
current_phase = None
parent_phase = None
headings.append(Heading(line_no, level, title, slug, parent_phase))
return headings
def classify(heading: Heading) -> dict[str, Any]:
t = heading.title
lower = t.lower()
if heading.level == 1 or t in {"Goal", 'Definition of "clawable"', "Current Pain Points", "Product Principles", "Roadmap"}:
status = "context"
elif "rejected" in lower or "not claw" in lower:
status = "rejected_not_claw"
elif "superseded" in lower or "deprecated" in lower:
# Deprecated items are still tracked because they can require migration work.
status = "superseded" if "implemented" in lower or "fixed" in lower else "open"
elif "deferred" in lower:
status = "deferred_with_rationale"
elif "implemented" in lower:
status = "done_verify"
elif "fixed" in lower:
status = "stale_done"
elif heading.level == 2 and t.startswith("Phase "):
status = "active"
else:
status = "open"
if heading.level == 1:
source_type = "roadmap_title"
elif t.startswith("Phase "):
source_type = "roadmap_phase"
elif t.startswith("Pinpoint #"):
source_type = "roadmap_pinpoint"
elif heading.level <= 2:
source_type = "roadmap_context_heading" if status == "context" else "roadmap_backlog_bucket"
else:
source_type = "roadmap_item"
bucket = "context"
if heading.parent_phase:
bucket = slugify(heading.parent_phase)
elif t.startswith("Phase "):
bucket = slugify(t)
elif t.startswith("Pinpoint #"):
bucket = "pinpoints"
elif "Immediate Backlog" in t:
bucket = "immediate-backlog"
elif heading.level == 2 and status != "context":
bucket = slugify(t)
deps: list[str] = []
if heading.parent_phase and heading.level > 2:
deps.append(slugify(heading.parent_phase))
if "plugin" in lower or "mcp" in lower:
deps.append("phase-5-plugin-and-mcp-lifecycle-maturity")
if "event" in lower or "report" in lower or "schema" in lower:
deps.append("phase-2-event-native-clawhip-integration")
if "branch" in lower or "test" in lower or "recovery" in lower:
deps.append("phase-3-branch-test-awareness-and-auto-recovery")
if "worker" in lower or "boot" in lower or "startup" in lower:
deps.append("phase-1-reliable-worker-boot")
deps = sorted(set(d for d in deps if d != slugify(t)))
deferral = None
if status == "deferred_with_rationale":
deferral = "Roadmap title explicitly marks this item deferred; retain as tracked context until a downstream plan reactivates it."
elif status == "rejected_not_claw":
deferral = "Rejected because the roadmap title marks it as not part of the Claw Code product surface."
return {
"source_type": source_type,
"release_bucket": bucket,
"lifecycle_status": status,
"dependencies": deps,
"verification_required": status not in {"context", "rejected_not_claw"},
"deferral_rationale": deferral,
}
def source_manifest(repo_root: Path, context_root: Path) -> list[dict[str, Any]]:
manifest: list[dict[str, Any]] = []
for rel in ["ROADMAP.md", *OPTIONAL_SOURCES]:
base = repo_root if rel == "ROADMAP.md" else context_root
path = base / rel
entry: dict[str, Any] = {
"path": rel,
"exists": path.exists(),
"sha256": sha256(path),
}
if path.exists() and path.suffix == ".json":
try:
data = json.loads(path.read_text(encoding="utf-8"))
entry["record_count"] = len(data) if isinstance(data, list) else len(data) if isinstance(data, dict) else None
except Exception as exc: # validation will surface malformed source separately if needed.
entry["json_error"] = str(exc)
manifest.append(entry)
return manifest
def generate(repo_root: Path, context_root: Path) -> dict[str, Any]:
roadmap = repo_root / "ROADMAP.md"
headings = read_headings(roadmap)
items = []
for index, h in enumerate(headings, 1):
c = classify(h)
items.append({
"id": f"roadmap-{index:03d}-{h.slug}",
"title": h.title,
"source_anchor": f"ROADMAP.md:L{h.line}#{h.slug}",
"source_type": c["source_type"],
"release_bucket": c["release_bucket"],
"lifecycle_status": c["lifecycle_status"],
"dependencies": c["dependencies"],
"verification_required": c["verification_required"],
"deferral_rationale": c["deferral_rationale"],
"roadmap_level": h.level,
"roadmap_line": h.line,
})
status_counts: dict[str, int] = {}
for item in items:
status_counts[item["lifecycle_status"]] = status_counts.get(item["lifecycle_status"], 0) + 1
return {
"schema_version": "cc2.board.v1",
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"goal_id": "G001-stream0-board",
"source_policy": "ROADMAP.md headings are canonical; optional research/plan files are recorded in source_manifest and used as context for downstream prioritization, without mutating .omx/ultragoal.",
"source_manifest": source_manifest(repo_root, context_root),
"summary": {
"roadmap_heading_count": len(headings),
"board_item_count": len(items),
"lifecycle_status_counts": dict(sorted(status_counts.items())),
},
"items": items,
}
def write_markdown(board: dict[str, Any], path: Path) -> None:
lines = [
"# Claw Code 2.0 Canonical Board",
"",
f"- Goal: `{board['goal_id']}`",
f"- Schema: `{board['schema_version']}`",
f"- Generated: `{board['generated_at']}`",
f"- ROADMAP headings mapped: `{board['summary']['roadmap_heading_count']}`",
"",
"## Source Manifest",
"",
"| Source | Exists | SHA-256 | Records |",
"| --- | --- | --- | ---: |",
]
for src in board["source_manifest"]:
lines.append(f"| `{src['path']}` | {src['exists']} | `{src['sha256'] or ''}` | {src.get('record_count', '')} |")
lines.extend([
"",
"## Lifecycle Summary",
"",
"| Status | Count |",
"| --- | ---: |",
])
for status, count in board["summary"]["lifecycle_status_counts"].items():
lines.append(f"| `{status}` | {count} |")
lines.extend([
"",
"## Board Items",
"",
"| ID | Source | Type | Bucket | Status | Verify | Dependencies | Deferral |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
])
for item in board["items"]:
deps = ", ".join(f"`{d}`" for d in item["dependencies"])
deferral = item["deferral_rationale"] or ""
lines.append(
f"| `{item['id']}` | `{item['source_anchor']}` | `{item['source_type']}` | "
f"`{item['release_bucket']}` | `{item['lifecycle_status']}` | {item['verification_required']} | {deps} | {deferral} |"
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def validate(repo_root: Path, board_path: Path) -> list[str]:
errors: list[str] = []
roadmap = repo_root / "ROADMAP.md"
headings = read_headings(roadmap)
try:
board = json.loads(board_path.read_text(encoding="utf-8"))
except Exception as exc:
return [f"failed to read board JSON: {exc}"]
items = board.get("items")
if not isinstance(items, list):
return ["board.items must be a list"]
expected = {f"ROADMAP.md:L{h.line}#{h.slug}": h.title for h in headings}
actual = {item.get("source_anchor"): item for item in items if isinstance(item, dict)}
missing = sorted(set(expected) - set(actual))
extra = sorted(set(actual) - set(expected))
if missing:
errors.append(f"missing ROADMAP heading mappings: {missing[:10]}{' ...' if len(missing) > 10 else ''}")
if extra:
errors.append(f"board has non-ROADMAP anchors not in frozen heading set: {extra[:10]}{' ...' if len(extra) > 10 else ''}")
for anchor, item in actual.items():
missing_fields = REQUIRED_ITEM_FIELDS - set(item)
if missing_fields:
errors.append(f"{anchor}: missing fields {sorted(missing_fields)}")
status = item.get("lifecycle_status")
if status not in VALID_STATUSES:
errors.append(f"{anchor}: invalid lifecycle_status {status!r}")
if not isinstance(item.get("dependencies"), list):
errors.append(f"{anchor}: dependencies must be a list")
if not isinstance(item.get("verification_required"), bool):
errors.append(f"{anchor}: verification_required must be boolean")
if status == "deferred_with_rationale" and not item.get("deferral_rationale"):
errors.append(f"{anchor}: deferred item requires deferral_rationale")
if item.get("title") != expected.get(anchor):
errors.append(f"{anchor}: title mismatch board={item.get('title')!r} roadmap={expected.get(anchor)!r}")
summary = board.get("summary", {})
if summary.get("roadmap_heading_count") != len(headings):
errors.append(f"summary roadmap_heading_count mismatch: {summary.get('roadmap_heading_count')} != {len(headings)}")
if summary.get("board_item_count") != len(items):
errors.append(f"summary board_item_count mismatch: {summary.get('board_item_count')} != {len(items)}")
return errors
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["generate", "validate"])
parser.add_argument("--repo-root", default=".", help="repository root containing ROADMAP.md")
parser.add_argument("--context-root", default=".", help="root containing optional .omx research/plan files")
parser.add_argument("--board-json", default=".omx/cc2/board.json")
parser.add_argument("--board-md", default=".omx/cc2/board.md")
args = parser.parse_args(argv)
repo_root = Path(args.repo_root).resolve()
context_root = Path(args.context_root).resolve()
board_json = repo_root / args.board_json
board_md = repo_root / args.board_md
if args.command == "generate":
board_json.parent.mkdir(parents=True, exist_ok=True)
board = generate(repo_root, context_root)
board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8")
write_markdown(board, board_md)
print(f"generated {board_json} and {board_md} with {board['summary']['board_item_count']} items")
return 0
errors = validate(repo_root, board_json)
if errors:
print("CC2 board validation FAILED", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print(f"CC2 board validation PASS: every ROADMAP heading is mapped in {board_json}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))