#!/usr/bin/env python3 """session_scaffold.py -- scaffold 1 logic-session folder + _context skeleton. SE PORT of AI_INFRA scripts/session_scaffold.py (adopted S148, 2026-07-24, owner "doi ung dung chinh xac nhu hub"). Near-verbatim: the logic is repo-agnostic; only the template CONTENT differs (SE STOCK-map rows). Kept stdlib-only + same CLI so a future hub change can be re-pulled by diff instead of re-derived. Tach LOGIC-session khoi VAT-LY window: tao `.claude/sessions/session-/` + `_context-s-.md` tu template `.claude/templates/session-context-template.md`. Design pins: - path HARD-PIN: `.claude/sessions/session-/` (KHONG configurable -- uniform cross-repo). - ts-moc = `git log -1 --format=%cI` (committer ISO-8601 cua HEAD), KHONG datetime.now tuy-tien; nguon-ts ghi thang vao file (audit). Fallback datetime.now CHI khi git down, tag ro "FALLBACK". - Idempotent: folder ton tai -> bao + KHONG de `_context` (chi tao neu thieu). - Cap so KHONG phai viec cua script nay -- caller truyen vao. Doc-quyen cap so = /session-start (session-start.md BUOC 0.8); /pause va /tiep chi tao folder cho dang mo. stdlib-only . Python 3.11+ . UTF-8 no-BOM . LF. """ from __future__ import annotations import argparse import subprocess import sys from datetime import datetime, timezone from pathlib import Path SESSIONS_SUBPATH: tuple[str, ...] = (".claude", "sessions") TEMPLATE_SUBPATH: tuple[str, ...] = (".claude", "templates", "session-context-template.md") def repo_root() -> Path: """Repo root via `git rev-parse --show-toplevel`; fallback = parent-of-scripts/.""" try: out = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=True, ) root = out.stdout.strip() if root: return Path(root) except (subprocess.CalledProcessError, FileNotFoundError, OSError): pass return Path(__file__).resolve().parent.parent def head_ts() -> tuple[str, str]: """Return (ts, source). Prefer HEAD committer ISO-8601, tagged with short-sha for audit.""" try: out = subprocess.run( ["git", "log", "-1", "--format=%cI%x09%h"], capture_output=True, text=True, check=True, ).stdout.strip() if out: ts, _, sha = out.partition("\t") ts, sha = ts.strip(), sha.strip() if ts: return ts, f"git log -1 --format=%cI @ HEAD {sha}" except (subprocess.CalledProcessError, FileNotFoundError, OSError): pass return ( datetime.now(timezone.utc).isoformat(timespec="seconds"), "datetime.now(UTC) FALLBACK (git unavailable -- non-deterministic)", ) def render(template_text: str, n: int, ts: str, ts_source: str) -> str: """Fill placeholders with str.replace (NOT str.format) so literal `{`/`}` in the template's JSON schema example survive untouched.""" return ( template_text .replace("{{N}}", str(n)) .replace("{{TS}}", ts) .replace("{{TS_SOURCE}}", ts_source) ) def _rel(p: Path, root: Path) -> str: try: return p.relative_to(root).as_posix() except ValueError: return str(p) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( prog="session_scaffold.py", description="Scaffold 1 logic-session folder + _context skeleton " "(.claude/sessions/session-/). Idempotent; safe to re-run.", ) ap.add_argument("--session", type=int, required=True, metavar="N", help="logic-session ID N (folder = session-, regex ^session-\\d+$)") ap.add_argument("--dry-run", action="store_true", help="print planned actions, write NOTHING") args = ap.parse_args(argv) n: int = args.session if n < 0: print(f"ERROR: --session must be a non-negative integer (got {n})", file=sys.stderr) return 2 root = repo_root() session_dir = root.joinpath(*SESSIONS_SUBPATH) / f"session-{n}" context_path = session_dir / f"_context-s-{n}.md" template_path = root.joinpath(*TEMPLATE_SUBPATH) if not template_path.is_file(): print(f"ERROR: template not found: {template_path}", file=sys.stderr) return 2 ts, ts_source = head_ts() folder_exists = session_dir.exists() context_exists = context_path.exists() if args.dry_run: print(f"[dry-run] session N = {n}") print(f"[dry-run] ts (moc) = {ts}") print(f"[dry-run] ts-source = {ts_source}") print(f"[dry-run] template = {_rel(template_path, root)}") print(f"[dry-run] session folder = {_rel(session_dir, root)} " f"({'EXISTS' if folder_exists else 'would mkdir'})") print(f"[dry-run] context file = {_rel(context_path, root)} " f"({'EXISTS -> would SKIP (idempotent)' if context_exists else 'would write'})") print("[dry-run] NOTHING written.") return 0 created_folder = False if not folder_exists: session_dir.mkdir(parents=True, exist_ok=True) created_folder = True if context_exists: print(f"[idempotent] {_rel(context_path, root)} EXISTS -- NOT overwriting.") if created_folder: print(f"[note] folder {_rel(session_dir, root)} was missing -> created " f"(unusual: context present without folder).") return 0 template_text = template_path.read_text(encoding="utf-8") rendered = render(template_text, n, ts, ts_source) with open(context_path, "w", encoding="utf-8", newline="\n") as fh: fh.write(rendered) print(f"[created] folder = {_rel(session_dir, root)} ({'new' if created_folder else 'existed'})") print(f"[created] context = {_rel(context_path, root)}") print(f"[created] ts(moc) = {ts} (source: {ts_source})") print("[note] NOT committed (commit theo nghi-thuc /pause | /snapshot | /session-end).") return 0 if __name__ == "__main__": raise SystemExit(main())