Self-contained skill for tracking technical changes with structured JSON records, an enforced state machine, and a session handoff format that lets a new AI session resume work cleanly when a previous one expires. Includes: - 5 stdlib-only Python scripts (init, create, update, status, validator) all supporting --help and --json - 3 reference docs (lifecycle state machine, JSON schema, handoff format) - /tc dispatcher in commands/tc.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
278 lines
9.7 KiB
Python
278 lines
9.7 KiB
Python
#!/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())
|