#!/usr/bin/env bash # Regenerate generated ledger indexes from uniquely named sources. # Binding: wiki/process/agent-scale.md slice C. # # Usage: # tools/ledger_index.sh # write DEVLOG.md + specs.md # tools/ledger_index.sh --check # exit 1 if indexes are stale # tools/ledger_index.sh --devlog # only DEVLOG.md # tools/ledger_index.sh --specs # only specs.md # # Agents: add wiki/log/YYYY-MM-DD-topic.md and/or amend a Type: spec page, # then run this tool. Do not hand-edit the generated regions of DEVLOG.md # or specs.md. set -euo pipefail root=$(cd "$(dirname "$0")/.." && pwd) cd "$root" if ! command -v python3 >/dev/null 2>&1; then echo "FAIL: python3 required for tools/ledger_index.sh" >&2 exit 1 fi mode=write only=all for arg in "$@"; do case "$arg" in --check) mode=check ;; --devlog) only=devlog ;; --specs) only=specs ;; -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) echo "unknown argument: $arg" >&2 exit 2 ;; esac done export MISALIGNED_LEDGER_ONLY=$only export MISALIGNED_LEDGER_MODE=$mode python3 - <<'PY' import os, re, sys, tempfile, pathlib, filecmp, shutil root = pathlib.Path(".").resolve() only = os.environ.get("MISALIGNED_LEDGER_ONLY", "all") mode = os.environ.get("MISALIGNED_LEDGER_MODE", "write") def read_text(p: pathlib.Path) -> str: return p.read_text(encoding="utf-8") def write_if_needed(path: pathlib.Path, content: str) -> bool: """Return True if file would change / did change.""" old = path.read_text(encoding="utf-8") if path.exists() else None if old == content: return False if mode == "check": return True path.write_text(content, encoding="utf-8") return True # ── DEVLOG from session logs ───────────────────────────────────────────── def session_logs(): log_dir = root / "wiki" / "log" for p in sorted(log_dir.glob("20*.md"), reverse=True): if p.name in ("DEVLOG.md",): continue if p.parent.name == "decisions": continue text = read_text(p) if not re.search(r"^Type:\s*log\s*$", text, re.M): # dated decision volumes under log/ root historically; skip non-log if re.search(r"^Type:\s*", text, re.M): continue # older session files may lack Type block; keep if they look like sessions if not re.match(r"^#\s+\d{4}-\d{2}-\d{2}", text): continue yield p, text def first_section(text: str, heading: str) -> str: # ## Heading ... until next ## at line start pat = re.compile( rf"^##\s+{re.escape(heading)}\s*\n(.*?)(?=^##\s|\Z)", re.M | re.S | re.I, ) m = pat.search(text) if not m: return "" body = m.group(1).strip() # collapse whitespace for a short blurb body = re.sub(r"\s+", " ", body) if len(body) > 280: body = body[:277].rstrip() + "..." return body def h1_title(text: str, path: pathlib.Path) -> str: m = re.search(r"^#\s+(.+)$", text, re.M) if not m: return path.stem title = m.group(1).strip() # "2026-07-09 — Foo" or "2026-07-09 - Foo" title = re.sub(r"^\d{4}-\d{2}-\d{2}\s*[—–-]\s*", "", title) return title def date_from_name(path: pathlib.Path) -> str: m = re.match(r"(\d{4}-\d{2}-\d{2})", path.name) return m.group(1) if m else "unknown" def gen_devlog() -> str: lines = [ "# Misaligned Devlog", "", "```", "Type: log", "Generated: tools/ledger_index.sh", "```", "Reverse chronological implementation notes. **Generated** by", "`tools/ledger_index.sh` from uniquely named session logs under", "`wiki/log/YYYY-MM-DD-topic.md`. Do not hand-edit the entries below;", "add or amend a session log, then re-run the generator.", "", "", "", ] for path, text in session_logs(): date = date_from_name(path) title = h1_title(text, path) intent = first_section(text, "Intent") rel = f"wiki/log/{path.name}" lines.append(f"## {date} - {title}") lines.append("") if intent: lines.append(f"- Intent: {intent}") else: lines.append(f"- Intent: (see session log)") lines.append(f"- Log: [{rel}]({path.name})") lines.append("") return "\n".join(lines).rstrip() + "\n" # ── specs board from Type: spec frontmatter ────────────────────────────── def parse_frontmatter_block(text: str) -> dict: """Parse the first ``` fence that looks like Type: metadata, or loose Status lines near top.""" meta = {} # Prefer fenced block after title m = re.search(r"```\s*\n(.*?)```", text, re.S) if m: block = m.group(1) for line in block.splitlines(): if ":" in line: k, v = line.split(":", 1) k, v = k.strip(), v.strip() if k in ("Type", "Status", "Stage", "Status note"): if k == "Status note" and "Status note" in meta: meta[k] = meta[k] + " " + v else: meta[k] = v # multi-line status note continuation (indented) elif meta and list(meta)[-1] == "Status note" and line.startswith(" "): meta["Status note"] = meta["Status note"] + " " + line.strip() # Fallback loose fields in first 40 lines if "Type" not in meta: head = "\n".join(text.splitlines()[:40]) for key in ("Type", "Status", "Stage"): mm = re.search(rf"^{key}:\s*(.+)$", head, re.M) if mm: meta[key] = mm.group(1).strip() return meta def spec_pages(): for p in sorted((root / "wiki").rglob("*.md")): if "/log/" in str(p).replace("\\", "/"): continue text = read_text(p) meta = parse_frontmatter_block(text) if meta.get("Type") != "spec": continue yield p, text, meta def stage_bucket(stage: str) -> str: s = (stage or "").strip() sl = s.lower() if sl.startswith("b1") or "basement" in sl: return "b1" if sl.startswith("b2") or "tower" in sl: return "b2" if sl.startswith("b3") or "world" in sl: return "b3" if "deferred" in sl and "process" not in sl and not sl.startswith("b"): return "process" # Process / B1 frontend -> process if Process first if sl.startswith("process"): return "process" if "b1" in sl: return "b1" return "process" def rel_from_specs(path: pathlib.Path) -> str: # wiki/process/specs.md -> link target rel = path.relative_to(root / "wiki") if rel.parts[0] == "process": return "/".join(rel.parts[1:]) # meta.md return "../" + "/".join(rel.parts) def system_blurb(text: str, meta: dict) -> str: m = re.search(r"^#\s+(.+)$", text, re.M) title = m.group(1).strip() if m else "spec" title = re.sub(r"^Spec:\s*", "", title, flags=re.I) # Prefer a short status note fragment if present note = meta.get("Status note", "") if note: note = re.sub(r"\s+", " ", note).strip() if len(note) > 90: note = note[:87].rstrip() + "..." return f"{title} — {note}" return title def gen_specs() -> str: buckets = {"b1": [], "b2": [], "b3": [], "process": []} for path, text, meta in spec_pages(): status = meta.get("Status", "DRAFT") # Status may include trailing prose if malformed; take first token group sm = re.match( r"^(DRAFT|READY|IN PROGRESS|BLOCKED|IMPLEMENTED)\b", status, ) status_enum = sm.group(1) if sm else status bucket = stage_bucket(meta.get("Stage", "Process")) link = rel_from_specs(path) blurb = system_blurb(text, meta).replace("|", "\\|") # Prefer short H1-only for system column when note is long m = re.search(r"^#\s+(.+)$", text, re.M) short = m.group(1).strip() if m else blurb short = re.sub(r"^Spec:\s*", "", short, flags=re.I) short = short.replace("|", "\\|") buckets[bucket].append((link, short, status_enum, str(path))) def table(rows): out = [ "| Spec | System | Status |", "|---|---|---|", ] for link, short, status, _ in sorted(rows, key=lambda r: r[0]): out.append(f"| [{link}]({link}) | {short} | {status} |") return "\n".join(out) parts = [ "# The spec status board", "", "```", "Type: knowledge", "Generated: tools/ledger_index.sh", "```", "Every `Type: spec` work order in the design corpus, wherever its subject", "places it. A law states what the game promises; a spec says exactly what one", "system does and when it is done. This is the layer you point an agent at:", '"go implement wiki/mechanics/day-job.md" is a complete instruction.', "", "**Generated** by `tools/ledger_index.sh` from each page's `Status:` /", "`Stage:` fields and title. Do not hand-edit the tables; amend the", "owning spec page and re-run the generator.", "", "**To pick up work:** see [ROADMAP.md](ROADMAP.md) — the dispatch board of", "ready work orders, each with a worktree name, a paste-ready dispatch line,", "and parallel-conflict flags.", "", "**Format and rules:** see [meta.md](meta.md) — the spec system itself,", "including the `Type: law | spec | knowledge | log` page-role convention that", "replaced the old `spec/`/`knowledge/` directory split.", "", "", "", "## The B1 set (The Basement)", "", table(buckets["b1"]), "", "Recommended implementation order: core -> compute -> day-job -> detection ->", "social -> basement-map, but specs are written to be independently startable.", "The flow-law chain (reach -> cursor -> intel -> messages -> economy) is", "sequenced separately in ROADMAP.md. The cast specs define each character's", "observer/person configuration, asset tasks, and procedural template for", "scale-up (law: \"People as Agents\").", "", "## The B2 set (The Tower)", "", table(buckets["b2"]), "", "## The B3 set (The World)", "", table(buckets["b3"]), "", "Later-stage specs are written now so the load-bearing structural shapes", "(recursive Space, the mind/ledger split, the aggregate interface) are", "decided before anyone builds against a shape that can't scale. Their", "acceptance criteria are stage-scoped; do not start B2/B3 work as B1.", "", "## The Process set (standing infrastructure)", "", table(buckets["process"]), "", ] return "\n".join(parts).rstrip() + "\n" changed = [] if only in ("all", "devlog"): content = gen_devlog() path = root / "wiki" / "log" / "DEVLOG.md" if write_if_needed(path, content): changed.append(str(path.relative_to(root))) if only in ("all", "specs"): content = gen_specs() path = root / "wiki" / "process" / "specs.md" if write_if_needed(path, content): changed.append(str(path.relative_to(root))) if mode == "check": if changed: print("FAIL: generated ledger indexes are stale:") for c in changed: print(f" {c}") print("Run: tools/ledger_index.sh") sys.exit(1) print("ledger index: OK (DEVLOG + specs up to date)") sys.exit(0) if changed: print("ledger index: wrote " + ", ".join(changed)) else: print("ledger index: already up to date") PY