diff --git a/.github/workflows/repo_state.yml b/.github/workflows/repo_state.yml new file mode 100644 index 0000000..6caa25f --- /dev/null +++ b/.github/workflows/repo_state.yml @@ -0,0 +1,30 @@ +name: Repo State + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + repo-state: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Generate repo state + run: | + python tools/repo_state/collect.py --repo-name Kill_LIFE + + - name: Upload repo-state artifact + uses: actions/upload-artifact@v4 + with: + name: repo-state + path: | + docs/REPO_STATE.md + docs/repo_state.json diff --git a/.github/workflows/repo_state_header_gate.yml b/.github/workflows/repo_state_header_gate.yml new file mode 100644 index 0000000..df4919c --- /dev/null +++ b/.github/workflows/repo_state_header_gate.yml @@ -0,0 +1,41 @@ +name: Repo State Header Gate + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + header-gate: + runs-on: ubuntu-latest + steps: + - name: Checkout Kill_LIFE + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Clone sibling repos + run: | + cd .. + git clone --depth=1 https://github.com/electron-rare/RTC_BL_PHONE.git RTC_BL_PHONE + git clone --depth=1 https://github.com/electron-rare/le-mystere-professeur-zacus.git le-mystere-professeur-zacus + + - name: Build global header + run: | + tools/repo_state/repo_refresh.sh --header-only + + - name: Lint header contract + run: | + python tools/repo_state/lint_header_contract.py + + - name: Upload global repo-state artifacts + uses: actions/upload-artifact@v4 + with: + name: global-repo-state + path: | + artifacts/repo_state/global_summary.md + artifacts/repo_state/global_index.json + artifacts/repo_state/header.latest.md diff --git a/docs/REPO_STATE.md b/docs/REPO_STATE.md new file mode 100644 index 0000000..5c14a3e --- /dev/null +++ b/docs/REPO_STATE.md @@ -0,0 +1,11 @@ + +Repo: Kill_LIFE +Branch: codex/repo-state-global-index +HEAD: 432c76a57e5cd9c1603d68f56cc6d012168be1b3 +HeadDate: 2026-02-21T03:03:06+01:00 +HeadSubject: Merge pull request #9 from electron-rare/codex/autonomy-plan-todo-hw-local +RepoURL: https://github.com/electron-rare/Kill_LIFE.git +ProjectKind: agentic_orchestrator +PivotChanges: [{"path": "(none)", "tags": ["general_change"]}] +ImpactGates: general_change +GeneratedAtUTC: 2026-02-21T02:22:47Z diff --git a/docs/REPO_STATE_HEADER_CONTRACT.md b/docs/REPO_STATE_HEADER_CONTRACT.md new file mode 100644 index 0000000..25c91a0 --- /dev/null +++ b/docs/REPO_STATE_HEADER_CONTRACT.md @@ -0,0 +1,64 @@ +# REPO_STATE Header Contract + +Version: `v1` + +## Source of truth + +The global response header MUST be derived only from each repository file: + +- `docs/REPO_STATE.md` + +No free-form manual edits are allowed in generated outputs. + +## Required local files (Kill_LIFE) + +- `tools/repo_state/collect.py` +- `tools/repo_state/repo_refresh.sh` +- `tools/repo_state/lint_header_contract.py` +- `docs/REPO_STATE.md` +- `docs/repo_state.json` + +## Header format (mandatory) + +```md +[REPO-STATE UTC: ] +Kill_LIFE | HEAD | pivots: <...> | gates: <...> +RTC_BL_PHONE | HEAD | pivots: <...> | gates: <...> +le-mystere-professeur-zacus | HEAD | pivots: <...> | gates: <...> +[/REPO-STATE] +``` + +## Contract checks + +`repo_state_header_gate` MUST fail if any of the following is true: + +- missing `` marker in `docs/REPO_STATE.md` +- missing required keys in `docs/REPO_STATE.md` or `docs/repo_state.json` +- malformed header markers in `artifacts/repo_state/header.latest.md` +- missing one of the 3 repository lines in the generated header + +## Commands + +Generate local state: + +```bash +python tools/repo_state/collect.py --repo-name Kill_LIFE +``` + +Generate global artifacts from sibling repos: + +```bash +tools/repo_state/repo_refresh.sh +``` + +Header only: + +```bash +tools/repo_state/repo_refresh.sh --header-only +``` + +JSON only: + +```bash +tools/repo_state/repo_refresh.sh --json-only +``` diff --git a/docs/repo_state.json b/docs/repo_state.json new file mode 100644 index 0000000..666c266 --- /dev/null +++ b/docs/repo_state.json @@ -0,0 +1,22 @@ +{ + "schema_version": "repo_state.v1", + "generated_at_utc": "2026-02-21T02:22:47Z", + "repo": "Kill_LIFE", + "repo_url": "https://github.com/electron-rare/Kill_LIFE.git", + "branch": "codex/repo-state-global-index", + "head": "432c76a57e5cd9c1603d68f56cc6d012168be1b3", + "head_date": "2026-02-21T03:03:06+01:00", + "head_subject": "Merge pull request #9 from electron-rare/codex/autonomy-plan-todo-hw-local", + "project_kind": "agentic_orchestrator", + "pivot_changes": [ + { + "path": "(none)", + "tags": [ + "general_change" + ] + } + ], + "impact_gates": [ + "general_change" + ] +} diff --git a/tools/repo_state/collect.py b/tools/repo_state/collect.py new file mode 100755 index 0000000..38e9c3f --- /dev/null +++ b/tools/repo_state/collect.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Generate repo state files (Markdown + JSON) for one repository.""" +from __future__ import annotations + +import argparse +import json +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Set + + +REQUIRED_SCHEMA_VERSION = "repo_state.v1" + + +def run_git(repo_root: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def detect_project_kind(repo_root: Path) -> str: + if (repo_root / "tools" / "ai").exists() and (repo_root / "zeroclaw").exists(): + return "agentic_orchestrator" + if (repo_root / "platformio.ini").exists(): + return "firmware_embedded" + if (repo_root / "hardware" / "firmware" / "platformio.ini").exists(): + return "hardware_firmware_hybrid" + if (repo_root / "kit-maitre-du-jeu").exists(): + return "narrative_hardware_hybrid" + return "general" + + +def gates_for_path(path: str) -> Set[str]: + p = path.strip().lstrip("./") + gates: Set[str] = set() + + if p.startswith("tools/ai/") or p.startswith("tools/repo_state/") or p.startswith("zeroclaw/"): + gates.add("agentic_orchestration") + if p.startswith(".github/workflows/"): + gates.add("ci_integrity") + if ( + p == "platformio.ini" + or p.startswith("firmware/") + or p.startswith("src/") + or p.startswith("hardware/firmware/") + ): + gates.add("firmware_build_test") + if p.startswith("hardware/"): + gates.add("hardware_validation") + if p == "README.md" or p.startswith("docs/") or p.startswith("specs/"): + gates.add("docs_specs_sync") + if p.startswith("compliance/") or p.startswith("security/") or p.startswith("standards/"): + gates.add("compliance_security") + + if not gates: + gates.add("general_change") + + return gates + + +def get_changed_paths(repo_root: Path, limit: int = 5) -> List[str]: + raw = run_git(repo_root, "show", "--name-only", "--pretty=format:", "HEAD") + paths = [line.strip() for line in raw.splitlines() if line.strip()] + return paths[:limit] + + +def build_state(repo_root: Path, repo_name_override: str | None) -> Dict[str, object]: + repo_name = repo_name_override or repo_root.name + + head = run_git(repo_root, "rev-parse", "HEAD") + branch = run_git(repo_root, "rev-parse", "--abbrev-ref", "HEAD") + head_date = run_git(repo_root, "show", "-s", "--format=%cI", "HEAD") + head_subject = run_git(repo_root, "show", "-s", "--format=%s", "HEAD") + + try: + repo_url = run_git(repo_root, "config", "--get", "remote.origin.url") + except subprocess.CalledProcessError: + repo_url = "" + + changed_paths = get_changed_paths(repo_root) + pivot_changes: List[Dict[str, object]] = [] + all_gates: Set[str] = set() + + for path in changed_paths: + gates = sorted(gates_for_path(path)) + pivot_changes.append({"path": path, "tags": gates}) + all_gates.update(gates) + + if not pivot_changes: + pivot_changes = [{"path": "(none)", "tags": ["general_change"]}] + all_gates.add("general_change") + + generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + return { + "schema_version": REQUIRED_SCHEMA_VERSION, + "generated_at_utc": generated_at, + "repo": repo_name, + "repo_url": repo_url, + "branch": branch, + "head": head, + "head_date": head_date, + "head_subject": head_subject, + "project_kind": detect_project_kind(repo_root), + "pivot_changes": pivot_changes, + "impact_gates": sorted(all_gates), + } + + +def write_outputs(state: Dict[str, object], out_md: Path, out_json: Path) -> None: + out_md.parent.mkdir(parents=True, exist_ok=True) + out_json.parent.mkdir(parents=True, exist_ok=True) + + pivot_changes_inline = json.dumps(state["pivot_changes"], ensure_ascii=False) + impact_gates_inline = ", ".join(state["impact_gates"]) + + md_content = "\n".join( + [ + "", + f"Repo: {state['repo']}", + f"Branch: {state['branch']}", + f"HEAD: {state['head']}", + f"HeadDate: {state['head_date']}", + f"HeadSubject: {state['head_subject']}", + f"RepoURL: {state['repo_url']}", + f"ProjectKind: {state['project_kind']}", + f"PivotChanges: {pivot_changes_inline}", + f"ImpactGates: {impact_gates_inline}", + f"GeneratedAtUTC: {state['generated_at_utc']}", + "", + ] + ) + out_md.write_text(md_content, encoding="utf-8") + out_json.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate docs/REPO_STATE.md and docs/repo_state.json") + parser.add_argument("--repo-name", default=None, help="Override repo name") + parser.add_argument("--repo-root", default=".", help="Path to the git repository root (default: .)") + parser.add_argument("--out-md", default="docs/REPO_STATE.md", help="Output markdown path") + parser.add_argument("--out-json", default="docs/repo_state.json", help="Output json path") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + if not (repo_root / ".git").exists(): + raise SystemExit(f"[fail] not a git repository: {repo_root}") + + state = build_state(repo_root, args.repo_name) + out_md = (repo_root / args.out_md).resolve() + out_json = (repo_root / args.out_json).resolve() + write_outputs(state, out_md, out_json) + + print(f"[ok] generated {out_md}") + print(f"[ok] generated {out_json}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/repo_state/lint_header_contract.py b/tools/repo_state/lint_header_contract.py new file mode 100755 index 0000000..8a3a051 --- /dev/null +++ b/tools/repo_state/lint_header_contract.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Lint repo-state contract for local/global header generation.""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +REQUIRED_MD_KEYS = [ + "Repo", + "Branch", + "HEAD", + "HeadDate", + "ProjectKind", + "PivotChanges", + "ImpactGates", +] +REQUIRED_JSON_KEYS = [ + "schema_version", + "generated_at_utc", + "repo", + "repo_url", + "branch", + "head", + "head_date", + "head_subject", + "project_kind", + "pivot_changes", + "impact_gates", +] +REQUIRED_REPOS = ["Kill_LIFE", "RTC_BL_PHONE", "le-mystere-professeur-zacus"] + + +def fail(msg: str) -> None: + print(f"[fail] {msg}", file=sys.stderr) + raise SystemExit(1) + + +def parse_kv_lines(path: Path) -> dict: + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "": + fail(f"missing REPO_STATE marker in {path}") + + data: dict[str, str] = {} + for line in lines: + if ":" not in line: + continue + key, value = line.split(":", 1) + data[key.strip()] = value.strip() + return data + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", default=".") + parser.add_argument("--header-file", default="artifacts/repo_state/header.latest.md") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + md_file = repo_root / "docs/REPO_STATE.md" + json_file = repo_root / "docs/repo_state.json" + refresh_script = repo_root / "tools/repo_state/repo_refresh.sh" + collect_script = repo_root / "tools/repo_state/collect.py" + header_file = (repo_root / args.header_file).resolve() + + for path in [md_file, json_file, refresh_script, collect_script, header_file]: + if not path.exists(): + fail(f"missing required file: {path}") + + kv = parse_kv_lines(md_file) + missing_md = [k for k in REQUIRED_MD_KEYS if k not in kv] + if missing_md: + fail(f"missing keys in {md_file}: {', '.join(missing_md)}") + + try: + json.loads(kv["PivotChanges"]) + except json.JSONDecodeError as exc: + fail(f"invalid PivotChanges JSON in {md_file}: {exc}") + + state = json.loads(json_file.read_text(encoding="utf-8")) + missing_json = [k for k in REQUIRED_JSON_KEYS if k not in state] + if missing_json: + fail(f"missing keys in {json_file}: {', '.join(missing_json)}") + + header_text = header_file.read_text(encoding="utf-8") + if "[REPO-STATE UTC:" not in header_text or "[/REPO-STATE]" not in header_text: + fail(f"invalid header markers in {header_file}") + + for repo in REQUIRED_REPOS: + pattern = re.compile(rf"^{re.escape(repo)}\s+\| HEAD [0-9a-f]{{7,40}} \| pivots: .+ \| gates: .+$", re.MULTILINE) + if not pattern.search(header_text): + fail(f"missing repo line in header for {repo}") + + print("[ok] repo-state header contract is valid") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/repo_state/repo_refresh.sh b/tools/repo_state/repo_refresh.sh new file mode 100755 index 0000000..141b271 --- /dev/null +++ b/tools/repo_state/repo_refresh.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <&2 + usage + exit 1 + ;; + esac +done + +if [[ "$HEADER_ONLY" == "1" && "$JSON_ONLY" == "1" ]]; then + echo "[fail] --header-only and --json-only are mutually exclusive" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KILL_LIFE_REPO="$(cd "$SCRIPT_DIR/../.." && pwd)" +PARENT_DIR="$(cd "$KILL_LIFE_REPO/.." && pwd)" + +REPO_ROOT_KILL_LIFE="${REPO_ROOT_KILL_LIFE:-$PARENT_DIR/Kill_LIFE}" +REPO_ROOT_RTC_BL_PHONE="${REPO_ROOT_RTC_BL_PHONE:-$PARENT_DIR/RTC_BL_PHONE}" +REPO_ROOT_ZACUS="${REPO_ROOT_ZACUS:-$PARENT_DIR/le-mystere-professeur-zacus}" + +ART_DIR="${REPO_STATE_ART_DIR:-$KILL_LIFE_REPO/artifacts/repo_state}" +SUMMARY_FILE="$ART_DIR/global_summary.md" +INDEX_JSON_FILE="$ART_DIR/global_index.json" +HEADER_FILE="$ART_DIR/header.latest.md" + +mkdir -p "$ART_DIR" + +refresh_one_repo() { + local repo_name="$1" + local repo_root="$2" + + if [[ ! -d "$repo_root/.git" ]]; then + echo "[fail] missing repo for $repo_name at $repo_root" >&2 + return 1 + fi + + local collector="$repo_root/tools/repo_state/collect.py" + if [[ -f "$collector" ]]; then + python3 "$collector" --repo-name "$repo_name" --repo-root "$repo_root" + else + python3 "$KILL_LIFE_REPO/tools/repo_state/collect.py" --repo-root "$repo_root" --repo-name "$repo_name" + fi +} + +if [[ "$NO_REFRESH" != "1" ]]; then + refresh_one_repo "Kill_LIFE" "$REPO_ROOT_KILL_LIFE" + refresh_one_repo "RTC_BL_PHONE" "$REPO_ROOT_RTC_BL_PHONE" + refresh_one_repo "le-mystere-professeur-zacus" "$REPO_ROOT_ZACUS" +fi + +KILL_MD="$REPO_ROOT_KILL_LIFE/docs/REPO_STATE.md" +RTC_MD="$REPO_ROOT_RTC_BL_PHONE/docs/REPO_STATE.md" +ZACUS_MD="$REPO_ROOT_ZACUS/docs/REPO_STATE.md" + +python3 - "$SUMMARY_FILE" "$INDEX_JSON_FILE" "$HEADER_FILE" "$KILL_MD" "$RTC_MD" "$ZACUS_MD" <<'PY' +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +summary_file = Path(sys.argv[1]) +index_json_file = Path(sys.argv[2]) +header_file = Path(sys.argv[3]) +md_files = [Path(p) for p in sys.argv[4:]] + +required_keys = [ + "Repo", + "Branch", + "HEAD", + "HeadDate", + "ProjectKind", + "PivotChanges", + "ImpactGates", +] + + +def parse_md(path: Path) -> dict: + if not path.exists(): + raise SystemExit(f"[fail] missing file: {path}") + + lines = path.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "": + raise SystemExit(f"[fail] invalid header marker in {path}") + + data = {} + for line in lines: + if ":" not in line: + continue + k, v = line.split(":", 1) + data[k.strip()] = v.strip() + + missing = [k for k in required_keys if k not in data] + if missing: + raise SystemExit(f"[fail] missing keys in {path}: {', '.join(missing)}") + + try: + pivots = json.loads(data["PivotChanges"]) + except json.JSONDecodeError as exc: + raise SystemExit(f"[fail] invalid PivotChanges JSON in {path}: {exc}") + + gates = [g.strip() for g in data["ImpactGates"].split(",") if g.strip()] + + return { + "repo": data["Repo"], + "branch": data["Branch"], + "head": data["HEAD"], + "head_date": data["HeadDate"], + "project_kind": data["ProjectKind"], + "pivot_changes": pivots, + "impact_gates": gates, + "source": str(path), + } + + +entries = [parse_md(p) for p in md_files] +now_utc = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + +def pivot_text(entry: dict) -> str: + pivots = entry["pivot_changes"] + if not pivots: + return "(none)" + paths = [] + for item in pivots[:2]: + p = str(item.get("path", "")).strip() or "(none)" + paths.append(p) + if len(pivots) > 2: + paths.append("...") + return "; ".join(paths) + +header_lines = [f"[REPO-STATE UTC: {now_utc}]"] +for entry in entries: + head_short = entry["head"][:8] + pivots = pivot_text(entry) + gates = ", ".join(entry["impact_gates"]) if entry["impact_gates"] else "general_change" + header_lines.append(f"{entry['repo']:<28} | HEAD {head_short} | pivots: {pivots} | gates: {gates}") +header_lines.append("[/REPO-STATE]") +header_text = "\n".join(header_lines) + "\n" +header_file.write_text(header_text, encoding="utf-8") + +summary_lines = ["# Global Repo State", "", f"GeneratedAtUTC: `{now_utc}`", ""] +for entry in entries: + summary_lines.append(f"## {entry['repo']}") + summary_lines.append(f"- Branch: `{entry['branch']}`") + summary_lines.append(f"- HEAD: `{entry['head']}`") + summary_lines.append(f"- HeadDate: `{entry['head_date']}`") + summary_lines.append(f"- ProjectKind: `{entry['project_kind']}`") + summary_lines.append(f"- ImpactGates: `{', '.join(entry['impact_gates'])}`") + summary_lines.append(f"- Pivots: `{pivot_text(entry)}`") + summary_lines.append("") +summary_file.write_text("\n".join(summary_lines), encoding="utf-8") + +index_data = { + "schema_version": "global_repo_index.v1", + "generated_at_utc": now_utc, + "repos": entries, +} +index_json_file.write_text(json.dumps(index_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +PY + +if [[ "$HEADER_ONLY" == "1" ]]; then + cat "$HEADER_FILE" + exit 0 +fi + +if [[ "$JSON_ONLY" == "1" ]]; then + cat "$INDEX_JSON_FILE" + exit 0 +fi + +echo "[ok] wrote $SUMMARY_FILE" +echo "[ok] wrote $INDEX_JSON_FILE" +echo "[ok] wrote $HEADER_FILE" +cat "$HEADER_FILE"