From e66aceb372ba6acfdef8306a1e268cf6612fe01a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 26 Apr 2026 04:53:29 +0100 Subject: [PATCH] Add persistence scaffolding --- AGENTS.md | 114 ++++++++ notes/fresh-workspace-handoff.md | 111 ++++++++ state/branches.json | 17 ++ state/evidence.jsonl | 3 + state/map.yaml | 90 +++++++ state/scratch.md | 7 + tools/repixelizer_prepare_compaction.py | 328 ++++++++++++++++++++++++ tools/repixelizer_state.py | 179 +++++++++++++ 8 files changed, 849 insertions(+) create mode 100644 AGENTS.md create mode 100644 notes/fresh-workspace-handoff.md create mode 100644 state/branches.json create mode 100644 state/evidence.jsonl create mode 100644 state/map.yaml create mode 100644 state/scratch.md create mode 100644 tools/repixelizer_prepare_compaction.py create mode 100644 tools/repixelizer_state.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a72780c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# Repixelizer Instructions + +## Project Purpose + +Repixelizer is a standalone Python CLI for forcing fake pixel art onto a real +pixel lattice without turning the solver into a landfill of compensating +heuristics. + +The live machine is intentionally lean: infer a lattice, analyze structure, +optimize one displacement field, sample real source pixels, then write +diagnostics good enough to catch when we are lying to ourselves. + +## Canonical State + +- Treat `state/map.yaml` as the canonical current project map. +- Treat `state/scratch.md` as disposable working memory for one bounded subgoal. +- Treat `state/evidence.jsonl` as the durable distilled ledger of decisions, + regressions, verified findings, and rejected paths. +- Treat `state/branches.json` as hypothesis tracking, not phase/status prose. +- Treat `notes/fresh-workspace-handoff.md` as the compact re-entry packet. +- Treat `docs/implementation-plan.md` as the forward plan. +- Treat `docs/lean-optimizer-algorithm-map.md` as the source-grounded live + algorithm map. +- Update `state/map.yaml` when project understanding changes. +- Add evidence after meaningful research, implementation, verification, or + rejected paths, but keep it distilled. Routine "I just did this" proof belongs + in git history, diagnostics, benchmark artifacts, or commit messages unless it + changes what the next agent should believe. + +## Important Paths + +- Project root: `E:\Projects\repixelizer` +- Implementation plan: `E:\Projects\repixelizer\docs\implementation-plan.md` +- Algorithm map: `E:\Projects\repixelizer\docs\lean-optimizer-algorithm-map.md` +- Handoff summary: `E:\Projects\repixelizer\notes\fresh-workspace-handoff.md` +- State CLI: `E:\Projects\repixelizer\tools\repixelizer_state.py` +- Pre-compaction helper: `E:\Projects\repixelizer\tools\repixelizer_prepare_compaction.py` +- Live pipeline: `E:\Projects\repixelizer\src\repixelizer\pipeline.py` +- Live solver: `E:\Projects\repixelizer\src\repixelizer\phase_field.py` +- Metrics: `E:\Projects\repixelizer\src\repixelizer\metrics.py` + +## Useful Commands + +Use the repo virtualenv when available: + +```powershell +.\.venv\Scripts\python .\tools\repixelizer_state.py status +.\.venv\Scripts\python .\tools\repixelizer_state.py add-evidence --type research --status ok --note "..." +.\.venv\Scripts\python .\tools\repixelizer_prepare_compaction.py +.\.venv\Scripts\python -m pytest -q +``` + +Fallback if the virtualenv is unavailable: + +```powershell +python .\tools\repixelizer_state.py status +python .\tools\repixelizer_prepare_compaction.py +``` + +## Session Bootstrap And Re-entry Protocol + +On fresh session load, do this before editing: + +1. read: + - `state/map.yaml` + - `notes/fresh-workspace-handoff.md` + - `docs/lean-optimizer-algorithm-map.md` + - `docs/implementation-plan.md` +2. run: + - `.\.venv\Scripts\python .\tools\repixelizer_state.py status` + - `git status --short --branch` + - `git log --oneline -5` +3. restate the current next action from persisted state before touching code + +After compaction, resume, or suspicious continuity loss: + +1. rerun `repixelizer_state.py status` +2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md` +3. treat the persisted next action as authoritative unless fresh repo evidence + contradicts it + +When context pressure is rising: + +1. stop broad exploration +2. narrow the active move to one bounded landing zone +3. persist map/handoff updates plus distilled evidence before continuity gets + atomized + +Do not wait for the blackout and then act surprised. + +When the user says to prepare for imminent compaction: + +1. run `tools/repixelizer_prepare_compaction.py` before editing persistence surfaces +2. use its warnings as the checklist for map, handoff, scratch, evidence, and git hygiene +3. update only the state that actually changed +4. run `tools/repixelizer_prepare_compaction.py` again after edits +5. fix errors, address warnings, and commit the completed persistence pass unless the work is deliberately mid-surgery + +## Operating Discipline + +- Before substantial edits, restate the current mechanism and intended change. +- Prefer one clear hypothesis per pass. +- Validate against the real objective: pinned badge runs, focused crops, visual + diffs, and `source_structure`, not just a convenient proxy metric. +- Revert failed regression or benchmark fixes immediately before trying the next + idea. +- If the diff grows while understanding shrinks, stop implementation and switch + to diagnosis, mapping, or simplification. +- Keep prose and maps grounded in concrete source paths. +- Do not reintroduce trays, portrait layers, candidate sets, or multi-stage + solver religions unless the design has actually changed and the map says so. +- Before handoff or compaction, sync `state/map.yaml`, refresh + `notes/fresh-workspace-handoff.md`, add distilled evidence when future belief + changed, and make the next action explicit. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md new file mode 100644 index 0000000..053ab9b --- /dev/null +++ b/notes/fresh-workspace-handoff.md @@ -0,0 +1,111 @@ +# Fresh Workspace Handoff + +This is the re-entry packet for `E:\Projects\repixelizer`. + +It is intentionally short. Historical proof belongs in git history, +diagnostics, benchmark artifacts, and the distilled `state/evidence.jsonl` +ledger; exact control flow belongs in `docs/lean-optimizer-algorithm-map.md`; +forward planning belongs in `docs/implementation-plan.md`. + +## Rehydrate + +From the repo root: + +```powershell +.\.venv\Scripts\python .\tools\repixelizer_state.py status +Get-Content '.\state\map.yaml' +Get-Content '.\notes\fresh-workspace-handoff.md' +Get-Content '.\docs\lean-optimizer-algorithm-map.md' +Get-Content '.\docs\implementation-plan.md' +git status --short --branch +git log --oneline -5 +Get-Content '.\state\evidence.jsonl' -Tail 8 +``` + +Do not trust this file for the exact live HEAD. Always check git. + +## Current Orientation + +- `phase-field` is the only live reconstruction engine. +- The live pipeline is `source -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`. +- The low-confidence phase rerank path is a short preview solve, not a second full optimizer. +- Cleanup is secondary and usually a no-op; the core result is supposed to come from the solver, not cleanup cosplay. +- The current tracked weakness is the widened dark contour near the badge sword tip on tapered shapes. +- `source_structure` exists because lattice-only `source_fidelity` could call visibly better outputs worse. +- The old tray optimizer is dead and should stay dead unless the entire machine map changes for a real reason. + +## Critical Doctrine + +- Persistent state is the agent's mind. +- Cut persistent memory as ruthlessly as code; stale context is bad thought, not harmless clutter. +- Remember Jenga: growing diffs, growing notes, and growing confidence are not proof that the system still makes sense. +- If compaction hits while source gathering or slice planning is still unpersisted, that work is gone. Re-gather it instead of pretending continuity happened. +- Keep the machine explainable in plain language and anchored to code. If a change cannot be explained against the algorithm map, it is not ready to land. + +## Landed Machine + +The current spine: + +- lattice inference with fixed-size and searched-size paths +- low-confidence phase rerank through short preview reconstruction +- edge analysis feeding one projected displacement-field optimizer +- nearest-source final sampling from `uv0_px + disp_t` +- cleanup, optional palette fit, diagnostics, compare mode, tuning, and GUI observer events on the live path +- `source_structure` plus `source_fidelity` in run summaries and comparisons +- focused sword-tip fixture in `tests/fixtures/real/ai-badge-tip-focus.json` + +The exact current control flow is documented in +`docs/lean-optimizer-algorithm-map.md`. + +## Boundaries + +- Do not reintroduce tray optimizers, candidate sets, portrait layers, or multi-stage solver religions casually. +- Do not trust a metric win that makes the image look worse. +- Do not let cleanup become the real solver. +- Do not let `state/evidence.jsonl` turn into an activity feed. +- Do not restart broad exploratory surgery when the current weak spot is still one bounded tapered-contour blemish. + +## Verification Guardrails + +- For scaffolding or note changes, run the repo-local state helper and compaction helper. +- For Python changes, run: + +```powershell +.\.venv\Scripts\python -m pytest -q +``` + +- For solver behavior changes, reproduce the pinned badge case from + `docs/implementation-plan.md`, inspect the sword-tip focus fixture, and + compare both the image and `source_structure`. + +## Persistent State Hygiene + +Rules now in force: + +- `state/map.yaml` is canonical current truth. +- `state/scratch.md` is disposable scratch. +- `state/evidence.jsonl` is a distilled durable belief ledger. +- `tools/repixelizer_prepare_compaction.py` is the pre-compaction persistence check; run it before and after imminent-compaction persistence passes. +- this handoff is a compact re-entry packet. +- `docs/implementation-plan.md` is the forward plan. +- `docs/lean-optimizer-algorithm-map.md` is the source-grounded control-flow map. + +Do not let any one note become all of those things. That is how the tower grows +sideways and starts calling itself architecture. + +## Next Real Move + +Do not continue implementation automatically from a rehydrate-only request. + +If the user asks to continue, the current next move is to take one bounded +hypothesis for tapered-contour behavior in `src/repixelizer/phase_field.py`, +check it against the pinned badge case and +`tests/fixtures/real/ai-badge-tip-focus.json`, and revert it if the visual +result or `source_structure` does not clearly improve. + +## Immediate Re-entry Instruction + +After compaction, first rehydrate and reorient from the listed files and git +state. Do not continue implementation merely because the state names a next +move. Wait for the user's next instruction unless they explicitly say to +continue. diff --git a/state/branches.json b/state/branches.json new file mode 100644 index 0000000..cc55187 --- /dev/null +++ b/state/branches.json @@ -0,0 +1,17 @@ +{ + "branches": [ + { + "id": "main", + "hypothesis": "A lean phase-field solver plus structure-aware evaluation can repixelize fake pixel art better than resize baselines without reviving optimizer sprawl.", + "status": "active", + "artifacts": [ + "state/map.yaml", + "state/scratch.md", + "state/evidence.jsonl", + "docs/implementation-plan.md", + "docs/lean-optimizer-algorithm-map.md" + ], + "notes": "Active mainline. Current solver work should stay anchored to the pinned badge case and the sword-tip focus fixture." + } + ] +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl new file mode 100644 index 0000000..96f5541 --- /dev/null +++ b/state/evidence.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-04-26T00:00:00Z","type":"design","status":"accepted","note":"Repixelizer now has one live reconstruction engine: phase-field in src/repixelizer/phase_field.py. The old tray-based optimizer is gone."} +{"ts":"2026-04-26T00:00:00Z","type":"metric","status":"accepted","note":"source_structure is tracked alongside source_fidelity because lattice-only scoring could rank visibly better outputs as worse."} +{"ts":"2026-04-26T00:00:00Z","type":"regression","status":"rejected","note":"The d9fa411 phase-field tuning pass was a real regression on the pinned badge case; reverting it restores the known-good badge result exactly."} diff --git a/state/map.yaml b/state/map.yaml new file mode 100644 index 0000000..05bcd21 --- /dev/null +++ b/state/map.yaml @@ -0,0 +1,90 @@ +project: + name: Repixelizer + status: active + phase: phase_field_stabilization + premise: > + Recover fake pixel art by inferring a real lattice and moving one + displacement field under one coherent objective instead of piling up + decorative solver machinery. + +objective: + primary: > + Produce true grid-aligned output that preserves silhouette and internal + structure better than resize baselines on icons, emblems, logos, and simple + sprites. + current: > + Keep the phase-field pipeline lean and coherent while improving the tracked + sword-tip contour failure near tapered shapes without reviving optimizer + sprawl. + +constraints: + - Prefer one field, one compact objective, and nearest-source final sampling. + - Use visible structure and focused fixtures, not just lattice proxy metrics. + - Keep compare mode, diagnostics, tuning, and GUI observer surfaces working. + - Avoid reviving trays, portrait layers, or multi-stage solver religions. + +invariants: + - The map is canonical and slow-changing. + - Scratch is temporary and disposable. + - Evidence is durable and distilled; routine activity logs belong in git history, diagnostics, benchmark artifacts, or commit messages. + - The live pipeline is `source -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`. + - `phase-field` is the only live reconstruction engine. + - The final image is emitted by nearest-source sampling from `uv0_px + disp_t`, not by bilinear output sampling. + - If a change cannot be explained cleanly against `docs/lean-optimizer-algorithm-map.md`, it should not land. + - Failed benchmark or regression fixes should be reverted before the next hypothesis. + - Persistent state cruft is the same failure class as code cruft. + +data_flow: + observe: + - task request + - repository state + - focused fixtures and diagnostics + - algorithm map and implementation plan + process: + - rehydrate from canonical state + - choose one bounded hypothesis + - modify only the surface that deserves to exist + - verify visually and numerically + - distill belief-changing evidence + - commit or backtrack + cut: + - revert failed code hypotheses immediately + - reject proxy-only wins when the image got worse + - trim duplicate or stale persistent notes instead of preserving them as fake continuity + +active_subgoals: + - Fix the tracked sword-tip contour widening without sacrificing the current internal linework win. + - Improve along-stroke versus across-stroke behavior near tapered contours in a way that still fits the one-field optimizer model. + - Keep inference, compare mode, diagnostics, tuning, and GUI observer events working after solver changes. + - Preserve `source_structure` as a real objective signal instead of over-trusting lattice-only fidelity. + +accepted_design: + - `phase-field` in `src/repixelizer/phase_field.py` is the only live reconstruction engine. + - The working pipeline is `source image -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`. + - `src/repixelizer/pipeline.py` owns orchestration and phase rerank selection. + - `src/repixelizer/analysis.py` provides the normalized edge scout used for loss and cleanup guidance. + - `src/repixelizer/phase_field.py` owns the one honest optimization variable: `disp_t`. + - The optimizer starts from fixed lattice centers, optimizes displacement with projected Adam, then rounds to real source pixels for final output. + - Cleanup is local and secondary; it does not define the core reconstruction model. + - `source_fidelity` is useful but incomplete; `source_structure` exists because visible structure can improve while lattice-only scoring complains. + - The tracked badge fixture and sword-tip focus are the current reality check for solver changes. + - The algorithm map and forward plan must stay separate from scratch and evidence so the notes do not congeal into one giant lying blob. + +landed_baseline: + - Lattice inference with fixed or searched target dimensions and optional low-confidence phase rerank. + - Edge analysis plus one displacement-field optimizer with projected constraints. + - Compare mode, diagnostics writing, tuning, and GUI observer plumbing all working on the phase-field path. + - `source_structure` reporting alongside `source_fidelity`. + - Focus fixture `tests/fixtures/real/ai-badge-tip-focus.json` for the tracked tapered-contour blemish. + +rejected_paths: + - name: tray_optimizer_sprawl + reason: the old tray-based optimizer added machinery without preserving the current lean model + - name: metric_worship + reason: lattice-only fidelity can call visibly better outputs worse + - name: multi_stage_solver_religion + reason: stacked stages and compensators hide confusion instead of fixing the one-field model + +current_status: + summary: The repo has one live reconstruction engine, `phase-field`, wired through the main pipeline with lattice inference, edge analysis, projected displacement optimization, cleanup, optional palette fitting, diagnostics, compare mode, tuning, and GUI observer events. `source_structure` now rides alongside `source_fidelity` because lattice-only scoring was lying about visible quality. The tracked weakness is still the widened dark contour near the badge sword tip on tapered shapes. + next_action: After rehydrate, if the user wants continued solver work, take one bounded hypothesis aimed at along-stroke versus across-stroke behavior near tapered contours in `src/repixelizer/phase_field.py`, validate it against the pinned badge case and `tests/fixtures/real/ai-badge-tip-focus.json`, and revert immediately if the visual result or `source_structure` does not clearly improve. diff --git a/state/scratch.md b/state/scratch.md new file mode 100644 index 0000000..3f84d3b --- /dev/null +++ b/state/scratch.md @@ -0,0 +1,7 @@ +# Scratch + +This file is disposable working memory for the current bounded subgoal. + +## Current Subgoal + +No active scratch subgoal. diff --git a/tools/repixelizer_prepare_compaction.py b/tools/repixelizer_prepare_compaction.py new file mode 100644 index 0000000..9b92429 --- /dev/null +++ b/tools/repixelizer_prepare_compaction.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +STATE_DIR = ROOT / "state" +NOTES_DIR = ROOT / "notes" +DOCS_DIR = ROOT / "docs" + +MAP_PATH = STATE_DIR / "map.yaml" +SCRATCH_PATH = STATE_DIR / "scratch.md" +BRANCHES_PATH = STATE_DIR / "branches.json" +EVIDENCE_PATH = STATE_DIR / "evidence.jsonl" +HANDOFF_PATH = NOTES_DIR / "fresh-workspace-handoff.md" +PLAN_PATH = DOCS_DIR / "implementation-plan.md" +ALGO_MAP_PATH = DOCS_DIR / "lean-optimizer-algorithm-map.md" +AGENTS_PATH = ROOT / "AGENTS.md" + + +@dataclass +class Finding: + level: str + message: str + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def run_git(args: list[str]) -> str: + completed = subprocess.run( + ["git", *args], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def extract_map_field(name: str) -> str | None: + prefix = f" {name}:" + for line in read_text(MAP_PATH).splitlines(): + if line.startswith(prefix): + return line.split(":", 1)[1].strip() + return None + + +def extract_active_subgoals() -> list[str]: + lines = read_text(MAP_PATH).splitlines() + results: list[str] = [] + in_section = False + for line in lines: + if not in_section: + if line.strip() == "active_subgoals:": + in_section = True + continue + if line.startswith(" - "): + results.append(line[4:].strip()) + continue + if line and not line.startswith(" "): + break + return results + + +def current_scratch_subgoal() -> str | None: + lines = read_text(SCRATCH_PATH).splitlines() + for index, line in enumerate(lines): + if line.strip() != "## Current Subgoal": + continue + for candidate in lines[index + 1 :]: + stripped = candidate.strip() + if stripped: + return stripped + return None + + +def load_evidence() -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for line_number, line in enumerate(read_text(EVIDENCE_PATH).splitlines(), start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"evidence line {line_number} is invalid JSON: {exc}") from exc + if not isinstance(record, dict): + raise ValueError(f"evidence line {line_number} is not a JSON object") + records.append(record) + return records + + +def load_branches() -> dict[str, Any]: + data = json.loads(read_text(BRANCHES_PATH)) + if not isinstance(data, dict): + raise ValueError("branches.json must be a JSON object") + branches = data.get("branches") + if not isinstance(branches, list): + raise ValueError("branches.json must contain a branches list") + return data + + +def add_required_file_checks(findings: list[Finding]) -> None: + for path in [ + MAP_PATH, + SCRATCH_PATH, + BRANCHES_PATH, + EVIDENCE_PATH, + HANDOFF_PATH, + PLAN_PATH, + ALGO_MAP_PATH, + AGENTS_PATH, + ]: + if path.exists(): + findings.append(Finding("ok", f"found {path.relative_to(ROOT)}")) + else: + findings.append(Finding("error", f"missing {path.relative_to(ROOT)}")) + + +def add_content_checks(findings: list[Finding]) -> tuple[list[dict[str, Any]], dict[str, Any]]: + evidence: list[dict[str, Any]] = [] + branches: dict[str, Any] = {} + + summary = extract_map_field("summary") + next_action = extract_map_field("next_action") + if summary: + findings.append(Finding("ok", "state/map.yaml has current_status.summary")) + else: + findings.append(Finding("error", "state/map.yaml is missing current_status.summary")) + if next_action: + findings.append(Finding("ok", "state/map.yaml has current_status.next_action")) + else: + findings.append(Finding("error", "state/map.yaml is missing current_status.next_action")) + + subgoals = extract_active_subgoals() + if subgoals: + findings.append(Finding("ok", f"state/map.yaml has {len(subgoals)} active subgoal(s)")) + else: + findings.append(Finding("warn", "state/map.yaml has no active_subgoals entries")) + + scratch_subgoal = current_scratch_subgoal() + if scratch_subgoal == "No active scratch subgoal.": + findings.append(Finding("ok", "state/scratch.md has no stale active scratch subgoal")) + elif scratch_subgoal: + findings.append(Finding("warn", f"state/scratch.md has active scratch subgoal: {scratch_subgoal}")) + else: + findings.append(Finding("warn", "state/scratch.md has no Current Subgoal value")) + + try: + evidence = load_evidence() + findings.append(Finding("ok", f"state/evidence.jsonl parses ({len(evidence)} record(s))")) + except ValueError as exc: + findings.append(Finding("error", str(exc))) + + if EVIDENCE_PATH.exists() and EVIDENCE_PATH.stat().st_size > 25_000: + findings.append(Finding("warn", "state/evidence.jsonl is larger than 25 KB; consider distillation")) + + try: + branches = load_branches() + active = [ + branch + for branch in branches.get("branches", []) + if isinstance(branch, dict) and branch.get("status") == "active" + ] + findings.append(Finding("ok", f"state/branches.json parses ({len(active)} active branch(es))")) + except (json.JSONDecodeError, ValueError) as exc: + findings.append(Finding("error", f"branches.json parse failed: {exc}")) + + return evidence, branches + + +def add_handoff_checks(findings: list[Finding]) -> None: + text = read_text(HANDOFF_PATH) + stale_branch = re.search(r"Current branch before .*ahead \d+", text) + stale_head = re.search(r"Current HEAD before .*\b[0-9a-f]{7,40}\b", text) + if stale_branch or stale_head: + findings.append(Finding("error", "handoff embeds an exact branch or HEAD snapshot; use git commands instead")) + else: + findings.append(Finding("ok", "handoff avoids exact branch/HEAD snapshots")) + + required_phrases = [ + "Do not continue implementation automatically from a rehydrate-only request.", + "Do not trust this file for the exact live HEAD.", + "Immediate Re-entry Instruction", + ] + for phrase in required_phrases: + if phrase in text: + findings.append(Finding("ok", f"handoff contains: {phrase}")) + else: + findings.append(Finding("warn", f"handoff missing: {phrase}")) + + +def add_agents_checks(findings: list[Finding]) -> None: + text = read_text(AGENTS_PATH) + if "repixelizer_prepare_compaction.py" in text: + findings.append(Finding("ok", "AGENTS.md tells agents to use the compaction helper")) + else: + findings.append(Finding("error", "AGENTS.md does not mention tools/repixelizer_prepare_compaction.py")) + if "prepare for imminent compaction" in text.lower(): + findings.append(Finding("ok", "AGENTS.md names the imminent-compaction trigger")) + else: + findings.append(Finding("warn", "AGENTS.md does not name the imminent-compaction trigger phrase")) + + +def add_git_checks(findings: list[Finding]) -> tuple[str, str]: + status = run_git(["status", "--short", "--branch"]) + log = run_git(["log", "--oneline", "-5"]) + dirty_lines = [line for line in status.splitlines()[1:] if line.strip()] + if dirty_lines: + findings.append(Finding("warn", "git worktree has uncommitted changes; commit or explain before compaction")) + else: + findings.append(Finding("ok", "git worktree is clean")) + return status, log + + +def render_report(findings: list[Finding], status: str, log: str, evidence: list[dict[str, Any]]) -> str: + summary = extract_map_field("summary") or "(missing)" + next_action = extract_map_field("next_action") or "(missing)" + subgoals = extract_active_subgoals() + + counts = { + "ok": sum(1 for finding in findings if finding.level == "ok"), + "warn": sum(1 for finding in findings if finding.level == "warn"), + "error": sum(1 for finding in findings if finding.level == "error"), + } + + lines = [ + "Repixelizer pre-compaction persistence check", + f"Workspace: {ROOT}", + f"Findings: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error", + "", + "Git status:", + status, + "", + "Recent commits:", + log, + "", + f"Summary: {summary}", + f"Next action: {next_action}", + ] + if subgoals: + lines.append("Active subgoals:") + lines.extend(f"- {item}" for item in subgoals) + if evidence: + latest = evidence[-1] + lines.extend( + [ + "", + "Latest distilled evidence:", + f"- {latest.get('ts', '(missing ts)')} {latest.get('type', '(missing type)')}/{latest.get('status', '(missing status)')}: {latest.get('note', '(missing note)')}", + ] + ) + + lines.append("") + lines.append("Findings:") + for finding in findings: + lines.append(f"[{finding.level.upper()}] {finding.message}") + + lines.extend( + [ + "", + "Pre-compaction checklist:", + "- Update state/map.yaml only if current understanding changed.", + "- Refresh notes/fresh-workspace-handoff.md if re-entry instructions changed.", + "- Add distilled evidence only for a belief-changing lesson, verification, rejected path, or scar.", + "- Keep exact branch/HEAD out of handoff prose; git commands own volatile truth.", + "- Commit completed persistence changes, or state why the worktree must stay dirty.", + "- Re-run this helper after edits before yielding to compaction.", + ] + ) + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Audit Repixelizer persistent state before imminent compaction." + ) + parser.add_argument( + "--strict", + action="store_true", + help="Exit nonzero on warnings as well as errors.", + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + findings: list[Finding] = [] + evidence: list[dict[str, Any]] = [] + + add_required_file_checks(findings) + try: + evidence, _branches = add_content_checks(findings) + except OSError as exc: + findings.append(Finding("error", f"state content check failed: {exc}")) + try: + add_handoff_checks(findings) + except OSError as exc: + findings.append(Finding("error", f"handoff check failed: {exc}")) + try: + add_agents_checks(findings) + except OSError as exc: + findings.append(Finding("error", f"AGENTS check failed: {exc}")) + try: + status, log = add_git_checks(findings) + except (OSError, subprocess.CalledProcessError) as exc: + findings.append(Finding("error", f"git check failed: {exc}")) + status = "(git status unavailable)" + log = "(git log unavailable)" + + print(render_report(findings, status, log, evidence)) + + has_error = any(finding.level == "error" for finding in findings) + has_warning = any(finding.level == "warn" for finding in findings) + if has_error or (args.strict and has_warning): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/repixelizer_state.py b/tools/repixelizer_state.py new file mode 100644 index 0000000..cc3a7c5 --- /dev/null +++ b/tools/repixelizer_state.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +STATE_DIR = ROOT / "state" +MAP_PATH = STATE_DIR / "map.yaml" +BRANCHES_PATH = STATE_DIR / "branches.json" +EVIDENCE_PATH = STATE_DIR / "evidence.jsonl" + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def write_text(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + + +def load_branches() -> dict[str, Any]: + return json.loads(read_text(BRANCHES_PATH)) + + +def save_branches(data: dict[str, Any]) -> None: + write_text(BRANCHES_PATH, json.dumps(data, indent=2) + "\n") + + +def append_evidence(record: dict[str, Any]) -> None: + with EVIDENCE_PATH.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=True) + "\n") + + +def utc_stamp() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def extract_map_field(name: str) -> str | None: + prefix = f" {name}:" + for line in read_text(MAP_PATH).splitlines(): + if line.startswith(prefix): + return line.split(":", 1)[1].strip() + return None + + +def extract_active_subgoals() -> list[str]: + lines = read_text(MAP_PATH).splitlines() + results: list[str] = [] + in_section = False + for line in lines: + if not in_section: + if line.strip() == "active_subgoals:": + in_section = True + continue + if line.startswith(" - "): + results.append(line[4:].strip()) + continue + if line and not line.startswith(" "): + break + return results + + +def cmd_status(_: argparse.Namespace) -> int: + branches = load_branches().get("branches", []) + active = [branch for branch in branches if branch.get("status") == "active"] + summary = extract_map_field("summary") or "(missing)" + next_action = extract_map_field("next_action") or "(missing)" + subgoals = extract_active_subgoals() + + print(f"Workspace: {ROOT}") + print(f"Summary: {summary}") + print(f"Next action: {next_action}") + print(f"Active branches: {len(active)} / {len(branches)}") + if subgoals: + print("Active subgoals:") + for item in subgoals: + print(f"- {item}") + return 0 + + +def cmd_add_evidence(args: argparse.Namespace) -> int: + record: dict[str, Any] = { + "ts": utc_stamp(), + "type": args.type, + "status": args.status, + "note": args.note, + } + if args.branch: + record["branch"] = args.branch + append_evidence(record) + print("Appended evidence record.") + return 0 + + +def cmd_add_branch(args: argparse.Namespace) -> int: + data = load_branches() + branches = data.setdefault("branches", []) + if any(branch.get("id") == args.id for branch in branches): + raise SystemExit(f"Branch '{args.id}' already exists.") + branches.append( + { + "id": args.id, + "hypothesis": args.hypothesis, + "status": "active", + "artifacts": args.artifact or [], + "notes": args.note or "", + } + ) + save_branches(data) + print(f"Added branch '{args.id}'.") + return 0 + + +def cmd_close_branch(args: argparse.Namespace) -> int: + data = load_branches() + for branch in data.get("branches", []): + if branch.get("id") != args.id: + continue + branch["status"] = args.status + if args.note: + branch["notes"] = args.note + save_branches(data) + print(f"Updated branch '{args.id}' to status '{args.status}'.") + return 0 + raise SystemExit(f"Branch '{args.id}' was not found.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Inspect and update Repixelizer persistent state files." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + status_parser = subparsers.add_parser("status", help="Show a compact state summary.") + status_parser.set_defaults(func=cmd_status) + + evidence_parser = subparsers.add_parser( + "add-evidence", help="Append one distilled JSONL evidence record." + ) + evidence_parser.add_argument("--type", required=True) + evidence_parser.add_argument("--status", required=True) + evidence_parser.add_argument("--note", required=True) + evidence_parser.add_argument("--branch") + evidence_parser.set_defaults(func=cmd_add_evidence) + + add_branch_parser = subparsers.add_parser( + "add-branch", help="Create a new active branch entry." + ) + add_branch_parser.add_argument("--id", required=True) + add_branch_parser.add_argument("--hypothesis", required=True) + add_branch_parser.add_argument("--artifact", action="append") + add_branch_parser.add_argument("--note") + add_branch_parser.set_defaults(func=cmd_add_branch) + + close_branch_parser = subparsers.add_parser( + "close-branch", help="Close an existing branch." + ) + close_branch_parser.add_argument("--id", required=True) + close_branch_parser.add_argument( + "--status", required=True, choices=["accepted", "rejected", "archived"] + ) + close_branch_parser.add_argument("--note") + close_branch_parser.set_defaults(func=cmd_close_branch) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main())