From 4670bc49e8c63723b2c738d1027d5d9af7ee9948 Mon Sep 17 00:00:00 2001 From: Yuan <20144414+baskduf@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:04:42 +0900 Subject: [PATCH] Release 0.4.1 findings fixes --- CHANGELOG.md | 7 ++ README.md | 2 +- .../codex-fable5/.codex-plugin/plugin.json | 2 +- .../codex-fable5/scripts/codex_findings.py | 14 ++- .../codex-fable5/scripts/codex_goals.py | 32 ++++- tests/test_scripts.py | 118 ++++++++++++++++++ 6 files changed, 169 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b58c75..7497708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ This project uses a lightweight changelog format: ## Unreleased +## 0.4.1 - 2026-06-15 + +### Fixed + +- Fixed `goals create --force` so stale findings from a replaced plan are archived before they can block the new final checkpoint. +- Fixed malformed `.codex-fable5` ledger JSON handling so CLI commands report controlled `codex-fable5` errors instead of Python tracebacks. + ## 0.4.0 - 2026-06-15 ### Added diff --git a/README.md b/README.md index bcb7f5b..fe2b3bc 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Choose one marketplace source. Stable release: ```bash -codex plugin marketplace add baskduf/FableCodex --ref v0.4.0 +codex plugin marketplace add baskduf/FableCodex --ref v0.4.1 codex plugin add codex-fable5@fablecodex ``` diff --git a/plugins/codex-fable5/.codex-plugin/plugin.json b/plugins/codex-fable5/.codex-plugin/plugin.json index 73b5ef2..9bec973 100644 --- a/plugins/codex-fable5/.codex-plugin/plugin.json +++ b/plugins/codex-fable5/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-fable5", - "version": "0.4.0", + "version": "0.4.1", "description": "Fable-style Codex workflow with source-section coverage, goal and findings gates, verification grounding, VFF routing, and optional provider bridge guidance.", "author": { "name": "Codex Fable5 Maintainers" diff --git a/plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py b/plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py index 51f3fe6..01caa7a 100755 --- a/plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py +++ b/plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py @@ -26,6 +26,16 @@ def now() -> str: return datetime.now(timezone.utc).isoformat() +def read_json(path: Path, label: str) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + sys.exit( + f"codex-fable5: {label} is not valid JSON " + f"({path}:{exc.lineno}:{exc.colno}: {exc.msg})." + ) + + def write_json(path: Path, data: dict[str, Any]) -> None: STATE_DIR.mkdir(exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -41,7 +51,7 @@ def append_event(event: str, **fields: Any) -> None: def load_findings() -> dict[str, Any]: if not FINDINGS_FILE.exists(): return {"created": now(), "findings": []} - data = json.loads(FINDINGS_FILE.read_text(encoding="utf-8")) + data = read_json(FINDINGS_FILE, "findings ledger") data.setdefault("findings", []) return data @@ -54,7 +64,7 @@ def save_findings(data: dict[str, Any]) -> None: def load_goals() -> dict[str, Any] | None: if not GOALS_FILE.exists(): return None - return json.loads(GOALS_FILE.read_text(encoding="utf-8")) + return read_json(GOALS_FILE, "goal plan") def active_goal_id() -> str: diff --git a/plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py b/plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py index dffe45a..ec38b86 100755 --- a/plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py +++ b/plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py @@ -23,6 +23,20 @@ def now() -> str: return datetime.now(timezone.utc).isoformat() +def safe_stamp() -> str: + return now().replace(":", "").replace("+", "Z") + + +def read_json(path: Path, label: str) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + sys.exit( + f"codex-fable5: {label} is not valid JSON " + f"({path}:{exc.lineno}:{exc.colno}: {exc.msg})." + ) + + def write_json(path: Path, data: dict[str, Any]) -> None: STATE_DIR.mkdir(exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -38,7 +52,7 @@ def append_event(event: str, **fields: Any) -> None: def load_plan() -> dict[str, Any]: if not GOALS_FILE.exists(): sys.exit("codex-fable5: no goal plan. Run `create` from the repo root first.") - return json.loads(GOALS_FILE.read_text(encoding="utf-8")) + return read_json(GOALS_FILE, "goal plan") def parse_goal(raw: str, index: int) -> dict[str, Any]: @@ -72,10 +86,22 @@ def terminal_incomplete_goals(goals: list[dict[str, Any]]) -> list[dict[str, Any return [goal for goal in goals if goal["status"] in INCOMPLETE_TERMINAL_STATUSES] +def archive_findings_for_force() -> None: + if not FINDINGS_FILE.exists(): + return + archive_path = STATE_DIR / f"findings.{safe_stamp()}.archive.json" + FINDINGS_FILE.replace(archive_path) + append_event( + "findings_archived", + reason="goals_create_force", + path=str(archive_path), + ) + + def blocking_findings() -> list[dict[str, Any]]: if not FINDINGS_FILE.exists(): return [] - data = json.loads(FINDINGS_FILE.read_text(encoding="utf-8")) + data = read_json(FINDINGS_FILE, "findings ledger") return [ finding for finding in data.get("findings", []) @@ -89,6 +115,8 @@ def cmd_create(args: argparse.Namespace) -> None: goals = [parse_goal(raw, index) for index, raw in enumerate(args.goal, 1)] if not goals: sys.exit("codex-fable5: at least one --goal is required.") + if args.force: + archive_findings_for_force() plan = {"brief": args.brief, "created": now(), "goals": goals} write_json(GOALS_FILE, plan) append_event("plan_created", brief=args.brief, count=len(goals)) diff --git a/tests/test_scripts.py b/tests/test_scripts.py index f45c24f..b78b1de 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -690,6 +690,124 @@ def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: ) self.assertEqual(complete.returncode, 0, complete.stderr) + def test_force_create_archives_stale_findings_before_new_plan(self) -> None: + goals_script = SCRIPTS / "codex_goals.py" + findings_script = SCRIPTS / "codex_findings.py" + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + + def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(script), *args], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual( + run( + goals_script, + "create", + "--brief", + "Old", + "--goal", + "verify::Old final", + ).returncode, + 0, + ) + self.assertEqual(run(goals_script, "next").returncode, 0) + self.assertEqual( + run( + findings_script, + "add", + "--title", + "Old finding", + "--evidence", + "This belongs to the old forced-away plan.", + ).returncode, + 0, + ) + + bad_replace = run( + goals_script, + "create", + "--force", + "--brief", + "Bad", + "--goal", + "missing delimiter", + ) + self.assertNotEqual(bad_replace.returncode, 0) + self.assertTrue((cwd / ".codex-fable5" / "findings.json").exists()) + self.assertFalse(list((cwd / ".codex-fable5").glob("findings.*.archive.json"))) + + replaced = run( + goals_script, + "create", + "--force", + "--brief", + "New", + "--goal", + "verify::New final", + ) + self.assertEqual(replaced.returncode, 0, replaced.stderr) + self.assertTrue(list((cwd / ".codex-fable5").glob("findings.*.archive.json"))) + + gate = run(findings_script, "gate") + self.assertEqual(gate.returncode, 0, gate.stderr) + self.assertIn("findings gate passed", gate.stdout) + + self.assertEqual(run(goals_script, "next").returncode, 0) + complete = run( + goals_script, + "checkpoint", + "--id", + "G001", + "--status", + "complete", + "--evidence", + "new final evidence", + "--verify-cmd", + "smoke", + "--verify-evidence", + "accepted", + ) + self.assertEqual(complete.returncode, 0, complete.stderr) + + def test_malformed_ledger_json_reports_controlled_error(self) -> None: + goals_script = SCRIPTS / "codex_goals.py" + findings_script = SCRIPTS / "codex_findings.py" + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + state_dir = cwd / ".codex-fable5" + state_dir.mkdir() + + (state_dir / "findings.json").write_text("{bad json", encoding="utf-8") + findings_status = subprocess.run( + [sys.executable, str(findings_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(findings_status.returncode, 0) + self.assertIn("findings ledger is not valid JSON", findings_status.stderr) + self.assertNotIn("Traceback", findings_status.stderr) + + (state_dir / "findings.json").unlink() + (state_dir / "goals.json").write_text("{bad json", encoding="utf-8") + goals_status = subprocess.run( + [sys.executable, str(goals_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(goals_status.returncode, 0) + self.assertIn("goal plan is not valid JSON", goals_status.stderr) + self.assertNotIn("Traceback", goals_status.stderr) + def test_litellm_config_generation(self) -> None: plain = self.make_litellm_config.build_config("claude-test", "test-alias") prefixed = self.make_litellm_config.build_config("anthropic/claude-test", "test-alias")