#!/usr/bin/env python3 """TC Create — Create a new Technical Change record. Generates the next sequential TC ID, scaffolds the record directory, writes a fully populated tc_record.json (status=planned, R1 creation revision), and appends a registry entry with recomputed statistics. Usage: python3 tc_create.py --root . --name user-auth \\ --title "Add JWT authentication" --scope feature --priority high \\ --summary "Adds JWT login + middleware" \\ --motivation "Required for protected endpoints" Exit codes: 0 = created 1 = warnings (e.g. validation soft warnings) 2 = critical error (registry missing, bad args, schema invalid) """ from __future__ import annotations import argparse import json import os import re import sys from datetime import datetime, timezone from pathlib import Path VALID_STATUSES = ("planned", "in_progress", "blocked", "implemented", "tested", "deployed") VALID_SCOPES = ("feature", "bugfix", "refactor", "infrastructure", "documentation", "hotfix", "enhancement") VALID_PRIORITIES = ("critical", "high", "medium", "low") def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def slugify(text: str) -> str: text = text.lower().strip() text = re.sub(r"[^a-z0-9\s-]", "", text) text = re.sub(r"[\s_]+", "-", text) text = re.sub(r"-+", "-", text) return text.strip("-") def date_slug(dt: datetime) -> str: return dt.strftime("%m-%d-%y") def write_json_atomic(path: Path, data: dict) -> None: tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") tmp.replace(path) def compute_stats(records: list) -> dict: stats = { "total": len(records), "by_status": {s: 0 for s in VALID_STATUSES}, "by_scope": {s: 0 for s in VALID_SCOPES}, "by_priority": {p: 0 for p in VALID_PRIORITIES}, } for rec in records: for key, bucket in (("status", "by_status"), ("scope", "by_scope"), ("priority", "by_priority")): v = rec.get(key, "") if v in stats[bucket]: stats[bucket][v] += 1 return stats def build_record(tc_id: str, title: str, scope: str, priority: str, summary: str, motivation: str, project_name: str, author: str, session_id: str, platform: str, model: str) -> dict: ts = now_iso() return { "tc_id": tc_id, "parent_tc": None, "title": title, "status": "planned", "priority": priority, "created": ts, "updated": ts, "created_by": author, "project": project_name, "description": { "summary": summary, "motivation": motivation, "scope": scope, "detailed_design": None, "breaking_changes": [], "dependencies": [], }, "files_affected": [], "revision_history": [ { "revision_id": "R1", "timestamp": ts, "author": author, "summary": "TC record created", "field_changes": [ {"field": "status", "action": "set", "new_value": "planned", "reason": "initial creation"}, ], } ], "sub_tcs": [], "test_cases": [], "approval": { "approved": False, "approved_by": None, "approved_date": None, "approval_notes": "", "test_coverage_status": "none", }, "session_context": { "current_session": { "session_id": session_id, "platform": platform, "model": model, "started": ts, "last_active": ts, }, "handoff": { "progress_summary": "", "next_steps": [], "blockers": [], "key_context": [], "files_in_progress": [], "decisions_made": [], }, "session_history": [], }, "tags": [], "related_tcs": [], "notes": "", "metadata": { "project": project_name, "created_by": author, "last_modified_by": author, "last_modified": ts, "estimated_effort": None, }, } def main() -> int: parser = argparse.ArgumentParser(description="Create a new TC record.") parser.add_argument("--root", default=".", help="Project root (default: current directory)") parser.add_argument("--name", required=True, help="Functionality slug (kebab-case, e.g. user-auth)") parser.add_argument("--title", required=True, help="Human-readable title (5-120 chars)") parser.add_argument("--scope", required=True, choices=VALID_SCOPES, help="Change category") parser.add_argument("--priority", default="medium", choices=VALID_PRIORITIES, help="Priority level") parser.add_argument("--summary", required=True, help="Concise summary (10+ chars)") parser.add_argument("--motivation", required=True, help="Why this change is needed") parser.add_argument("--author", default=None, help="Author identifier (defaults to config default_author)") parser.add_argument("--session-id", default=None, help="Session identifier (default: auto)") parser.add_argument("--platform", default="claude_code", choices=("claude_code", "claude_web", "api", "other")) parser.add_argument("--model", default="unknown", help="AI model identifier") parser.add_argument("--json", action="store_true", help="Output as JSON") args = parser.parse_args() root = Path(args.root).resolve() tc_dir = root / "docs" / "TC" config_path = tc_dir / "tc_config.json" registry_path = tc_dir / "tc_registry.json" if not config_path.exists() or not registry_path.exists(): msg = f"TC tracking not initialized at {tc_dir}. Run tc_init.py first." print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 try: config = json.loads(config_path.read_text(encoding="utf-8")) registry = json.loads(registry_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: msg = f"Failed to read config/registry: {e}" print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 project_name = config.get("project_name", "Unknown Project") author = args.author or config.get("default_author", "Claude") session_id = args.session_id or f"session-{int(datetime.now().timestamp())}-{os.getpid()}" if len(args.title) < 5 or len(args.title) > 120: msg = "Title must be 5-120 characters." print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 if len(args.summary) < 10: msg = "Summary must be at least 10 characters." print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 name_slug = slugify(args.name) if not name_slug: msg = "Invalid name slug." print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 next_num = registry.get("next_tc_number", 1) today = datetime.now() tc_id = f"TC-{next_num:03d}-{date_slug(today)}-{name_slug}" record_dir = tc_dir / "records" / tc_id if record_dir.exists(): msg = f"Record directory already exists: {record_dir}" print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 record = build_record( tc_id=tc_id, title=args.title, scope=args.scope, priority=args.priority, summary=args.summary, motivation=args.motivation, project_name=project_name, author=author, session_id=session_id, platform=args.platform, model=args.model, ) try: record_dir.mkdir(parents=True, exist_ok=False) (tc_dir / "evidence" / tc_id).mkdir(parents=True, exist_ok=True) write_json_atomic(record_dir / "tc_record.json", record) except OSError as e: msg = f"Failed to write record: {e}" print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 registry_entry = { "tc_id": tc_id, "title": args.title, "status": "planned", "scope": args.scope, "priority": args.priority, "created": record["created"], "updated": record["updated"], "path": f"records/{tc_id}/tc_record.json", } registry["records"].append(registry_entry) registry["next_tc_number"] = next_num + 1 registry["updated"] = now_iso() registry["statistics"] = compute_stats(registry["records"]) try: write_json_atomic(registry_path, registry) except OSError as e: msg = f"Failed to update registry: {e}" print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}") return 2 result = { "status": "created", "tc_id": tc_id, "title": args.title, "scope": args.scope, "priority": args.priority, "record_path": str(record_dir / "tc_record.json"), } if args.json: print(json.dumps(result, indent=2)) else: print(f"Created {tc_id}") print(f" Title: {args.title}") print(f" Scope: {args.scope}") print(f" Priority: {args.priority}") print(f" Record: {record_dir / 'tc_record.json'}") print() print(f"Next: tc_update.py --root {args.root} --tc-id {tc_id} --set-status in_progress") return 0 if __name__ == "__main__": sys.exit(main())