Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion plugins/codex-fable5/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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

Expand All @@ -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:
Expand Down
32 changes: 30 additions & 2 deletions plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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]:
Expand Down Expand Up @@ -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", [])
Expand All @@ -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))
Expand Down
118 changes: 118 additions & 0 deletions tests/test_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down