#!/usr/bin/env python """curate.py -- generation-start curator: demote memories, cookbook, rules; trim HOW_WE_WORK. Subcommands: memory, cookbook, rules, how-we-work, discussions, ops. memory --route FILE=STORE: write each memory's fact to its store (developer and how-we-work: a bullet in that HOW_WE_WORK.md section, within card.max_chars; rule: rules_add.py; cookbook: cookbook_add.sh; ops: docs/ops/.md + its index row; archive: nothing), then demote it to gen.md. Every route is validated first; any refusal writes nothing and exits 1. `memory --route --gen legacy` with no specs: nothing left to route (all kept); only sets the pa.json `memory_routed` marker. ops --sunset --gen G: retire unreferenced ops topics; ops --reindex: refresh the docs/ops/INDEX.md line counts and append rows for unindexed docs/ops/*.md. Every subcommand takes --dry-run and prints a table of what it would do. Stdlib only; no imports from `pa/`. """ import argparse import hashlib import json import os import re import shutil import subprocess import sys try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass def die(msg): sys.stdout.write("refused: %s\n" % msg) raise SystemExit(1) def find_root(start=None): env = os.environ.get("PA_PROJECT_ROOT") if env: return os.path.abspath(env) cur = os.path.abspath(start or os.getcwd()) while True: if os.path.isfile(os.path.join(cur, ".claude", "pa.json")): return cur parent = os.path.dirname(cur) if parent == cur: break cur = parent try: out = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=start or os.getcwd(), capture_output=True, text=True) if out.returncode == 0 and out.stdout.strip(): return os.path.abspath(out.stdout.strip()) except Exception: pass return os.path.abspath(start or os.getcwd()) def read_text(path): with open(path, encoding="utf-8", newline="") as fh: return fh.read() def write_text(path, text): d = os.path.dirname(path) if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True) with open(path, "w", encoding="utf-8", newline="") as fh: fh.write(text) def _cnote(path, **kw): try: _d = os.path.dirname(os.path.abspath(__file__)) if _d not in sys.path: sys.path.insert(0, _d) import _credit _credit.note(path, **kw) except Exception: pass def rel(root, path): try: return os.path.relpath(path, root).replace("\\", "/") except ValueError: return path.replace("\\", "/") def stable_hash(line): """Short stable hash for a line (first 8 hex chars of sha256).""" return hashlib.sha256(line.strip().encode("utf-8")).hexdigest()[:8] # --------------------------------------------------------------------------- # # memory --demote ... --gen [--dir DIR] # --------------------------------------------------------------------------- # def _memory_index_path(memdir): return os.path.join(memdir, "MEMORY.md") def _gen_archive_path(memdir, gen): return os.path.join(memdir, "gen%s.md" % gen) def _parse_index_line(line): """-> (filename, rest) or None.""" m = re.match(r"^-\s+\[([^\]]+)\]\(([^)]+)\)", line) if m: return m.group(2), m.group(1) return None def _count_gen_entries(text): """Count ## headings in a gen archive file.""" return sum(1 for line in text.split("\n") if line.startswith("## ")) _ROUTE_STORES = ("developer", "how-we-work", "rule", "cookbook", "ops", "archive") _CARD_SECTIONS = {"developer": r"^## Developer\b", "how-we-work": r"^## Conventions & house style\b"} def _card_cap(root): """card.max_chars from .claude/pa.json, default 7000 (mirrors card.py:_max_chars).""" try: with open(os.path.join(root, ".claude", "pa.json"), encoding="utf-8") as fh: return json.load(fh).get("card", {}).get("max_chars", 7000) except (OSError, ValueError, AttributeError): return 7000 def _split_frontmatter(text): """-> (frontmatter dict, body) for an optional leading `---` YAML block.""" t = text.replace("\r\n", "\n") fm = {} if t.startswith("---\n"): end = t.find("\n---", 3) if end != -1: for ln in t[4:end].split("\n"): key, sep, val = ln.partition(":") if sep and key.strip(): fm[key.strip()] = val.strip().strip("\"'") nl = t.find("\n", end + 4) t = t[nl + 1:] if nl != -1 else "" return fm, t.strip("\n") def _one_line(s): return " ".join(s.split()) def _card_add_bullet(text, pattern, bullet): """Insert `bullet` as the last bullet of the section whose heading matches; None if absent.""" lines = text.split("\n") cr = "\r" if "\r\n" in text else "" start = next((i for i, ln in enumerate(lines) if re.match(pattern, ln.rstrip("\r"))), None) if start is None: return None end = next((j for j in range(start + 1, len(lines)) if lines[j].startswith("## ")), len(lines)) at, in_bullet, last_text = None, False, start + 1 for j in range(start + 1, end): s = lines[j].rstrip("\r") if s.startswith("- "): at, in_bullet = j + 1, True elif in_bullet and s.strip() and s[:1] in " \t": at = j + 1 else: in_bullet = False if s.strip(): last_text = j + 1 lines.insert(at if at is not None else last_text, bullet + cr) return "\n".join(lines) def _plan_routes(specs, root, memdir, index_lines, gen): """Validate every --route spec; -> (routes, refusals, card). Nothing is written.""" routes, refusals, seen = [], [], set() card_path = os.path.join(root, "HOW_WE_WORK.md") card_text = read_text(card_path) if os.path.isfile(card_path) else None card_start = card_text cap = _card_cap(root) rules_dir = os.path.join(root, "rules") used = set() if os.path.isfile(os.path.join(rules_dir, "INDEX.md")): used |= {int(m) for m in re.findall(r"(?m)^L(\d+)\b", read_text(os.path.join(rules_dir, "INDEX.md")))} if os.path.isdir(rules_dir): used |= {int(m.group(1)) for m in (re.match(r"^L(\d+)\.md$", n) for n in os.listdir(rules_dir)) if m} next_rule = max(used) + 1 if used else 1 cb_dir = os.path.join(root, "cookbook") cb_taken = set(os.listdir(cb_dir)) if os.path.isdir(cb_dir) else set() next_cb = 1 bash = shutil.which("bash") for spec in specs: fname, sep, store = spec.rpartition("=") if not sep or not fname: refusals.append((spec, "expected FILE=STORE")) continue if store not in _ROUTE_STORES: refusals.append((fname, "unknown store %r (one of %s)" % (store, ", ".join(_ROUTE_STORES)))) continue if fname in seen: refusals.append((fname, "routed twice")) continue seen.add(fname) fpath = os.path.join(memdir, fname) if not os.path.isfile(fpath): refusals.append((fname, "no memory file at %s" % fpath)) continue entry = None for line in index_lines: m = re.match(r"^-\s+\[([^\]]+)\]\(([^)]+)\)\s*(?:[—–-]\s*)?(.*)$", line) if m and m.group(2) == fname: entry = m break if entry is None: refusals.append((fname, "no index line in MEMORY.md")) continue fm, body = _split_frontmatter(read_text(fpath)) title = _one_line(fm.get("name") or entry.group(1)) summary = _one_line(fm.get("description") or entry.group(3)) r = {"file": fname, "store": store, "title": title, "summary": summary, "body": body} stem = os.path.splitext(fname)[0] if store in _CARD_SECTIONS: if card_text is None: refusals.append((fname, "no HOW_WE_WORK.md")) continue new = _card_add_bullet(card_text, _CARD_SECTIONS[store], "- %s: %s" % (title, summary)) if new is None: refusals.append((fname, "HOW_WE_WORK.md has no section matching %s" % _CARD_SECTIONS[store])) continue if len(new) > cap: refusals.append((fname, "HOW_WE_WORK.md would be %d/%d chars" % (len(new), cap))) continue card_text = new r["target"] = "HOW_WE_WORK.md" elif store == "rule": r["id"] = "L%d" % next_rule next_rule += 1 r["target"] = "rules/%s.md" % r["id"] elif store == "cookbook": if bash is None: refusals.append((fname, "no bash on PATH for cookbook_add.sh")) continue if not os.path.isfile(os.path.join(root, ".claude", "pa.json")): refusals.append((fname, "no .claude/pa.json at the root " "(cookbook_add.sh would resolve another root)")) continue while "C%04d.md" % next_cb in cb_taken: next_cb += 1 r["target"] = "cookbook/C%04d.md" % next_cb next_cb += 1 elif store == "ops": if not os.path.isfile(os.path.join(root, "docs", "ops", "INDEX.md")): refusals.append((fname, "no docs/ops/INDEX.md")) continue r["target"] = "docs/ops/%s.md" % stem if os.path.exists(os.path.join(root, r["target"])): refusals.append((fname, "%s exists" % r["target"])) continue else: r["target"] = rel(root, _gen_archive_path(memdir, gen)) routes.append(r) card = None if card_text is not None and card_text != card_start: card = (card_path, card_text, len(card_text) - len(card_start)) return routes, refusals, card def _apply_routes(routes, card, root): """Write each route's fact to its store (the demote follows in cmd_memory).""" if card: write_text(card[0], card[1]) _cnote(card[0], script="curate.py", sub="memory", added=card[2], root=root) here = os.path.dirname(os.path.abspath(__file__)) env = dict(os.environ, PA_PROJECT_ROOT=root, PA_PYTHON=sys.executable) scratch = os.path.join(root, ".run") ops = False for r in routes: origin = "legacy memory %s" % r["file"] if r["store"] in ("rule", "cookbook"): body = os.path.join(scratch, "curate-route-%s" % r["file"]) write_text(body, r["body"] + "\n") if r["store"] == "rule": cmd = [sys.executable, os.path.join(here, "rules_add.py"), "add", "--id", r["id"], "--title", r["title"], "--file", body, "--origin", origin, "--tags", "legacy,memory"] else: cmd = [shutil.which("bash"), os.path.join(here, "cookbook_add.sh").replace("\\", "/"), "--title", r["title"], "--tags", "legacy,memory", "--origin", origin, "--file", body.replace("\\", "/")] res = subprocess.run(cmd, cwd=root, env=env, capture_output=True, text=True) os.remove(body) if res.returncode != 0: die("%s: %s: %s" % (r["file"], os.path.basename(cmd[1]), (res.stdout + res.stderr).strip())) elif r["store"] == "ops": path = os.path.join(root, r["target"]) write_text(path, "# %s\n\n%s\n" % (r["title"], r["body"])) _cnote(path, script="curate.py", sub="memory", whole=True, root=root) ops = True if ops: _ops_reindex(argparse.Namespace(dry_run=False), root) def cmd_memory(args, root): if not args.demote and args.route is None: die("--demote or --route is required") if args.route == [] and not args.demote: if str(args.gen) != "legacy": die("--route with no FILE=STORE needs --gen legacy") # every memory kept: nothing left to route; only the marker is written sys.stdout.write("memory --route --gen legacy\n route: nothing left to route\n") if args.dry_run: sys.stdout.write("dry-run: no changes\n") return 0 _mark_memory_routed(root) return 0 memdir = args.dir or os.path.join(root, ".claude-state", "memory") gen = args.gen index_path = _memory_index_path(memdir) if not os.path.isfile(index_path): die("no MEMORY.md at %s" % index_path) actions = [] index_lines = read_text(index_path).rstrip("\n").split("\n") demote = list(args.demote or []) routes, refusals, card = _plan_routes(args.route or [], root, memdir, index_lines, gen) demote += [r["file"] for r in routes if r["file"] not in demote] for fname in demote: fpath = os.path.join(memdir, fname) if not os.path.isfile(fpath): die("no memory file at %s" % fpath) # find the index line idx = None for i, line in enumerate(index_lines): parsed = _parse_index_line(line) if parsed and parsed[0] == fname: idx = i break if idx is None: die("no index line for %s in MEMORY.md" % fname) actions.append({"file": fname, "path": fpath, "index_line": idx}) # print the table archive_path = _gen_archive_path(memdir, gen) existing_count = 0 if os.path.isfile(archive_path): existing_count = _count_gen_entries(read_text(archive_path)) new_count = existing_count + len(actions) if args.route: sys.stdout.write("memory --route --gen %s\n" % gen) else: sys.stdout.write("memory --demote --gen %s\n" % gen) for r in routes: sys.stdout.write(" route: %s -> %s (%s)\n" % (r["file"], r["store"], r["target"])) for a in actions: sys.stdout.write(" demote: %s -> gen%s.md\n" % (a["file"], gen)) sys.stdout.write(" archive: %s (%d entries after)\n" % (rel(root, archive_path), new_count)) link_line = "- [Generation %s memories](gen%s.md) — %d entries" % (gen, gen, new_count) sys.stdout.write(" link: %s\n" % link_line) for fname, why in refusals: sys.stdout.write("refused: %s: %s\n" % (fname, why)) if refusals: return 1 if args.dry_run: sys.stdout.write("dry-run: no changes\n") return 0 _apply_routes(routes, card, root) # append each memory to the archive archive_parts = [] if os.path.isfile(archive_path): archive_parts.append(read_text(archive_path).rstrip("\n")) for a in actions: content = read_text(a["path"]).rstrip("\n") # use the filename stem as the heading name name = os.path.splitext(a["file"])[0] archive_parts.append("\n## %s\n\n%s" % (name, content)) write_text(archive_path, "\n".join(archive_parts).lstrip("\n") + "\n") _cnote(archive_path, script="curate.py", sub="memory", whole=True, root=root) # remove demoted files and their index lines (reverse order to keep indices valid) _removed_chars = sum(len(index_lines[a["index_line"]]) for a in actions) for a in sorted(actions, key=lambda x: x["index_line"], reverse=True): index_lines.pop(a["index_line"]) os.remove(a["path"]) # update or add the link line for this generation gen_link_idx = None for i, line in enumerate(index_lines): if re.match(r"^-\s+\[Generation %s memories\]" % re.escape(str(gen)), line): gen_link_idx = i break if gen_link_idx is not None: _removed_chars += len(index_lines[gen_link_idx]) index_lines[gen_link_idx] = link_line else: index_lines.append(link_line) write_text(index_path, "\n".join(index_lines) + "\n") _cnote(index_path, script="curate.py", sub="memory", removed=_removed_chars, added=len(link_line), root=root) if args.route and str(gen) == "legacy": _mark_memory_routed(root) return 0 def _pa_version(conf): """The package version as installed tools get it (pa.__version__ from