From 6787cfcb75fa7d8b330087b2056e0fdef5a2fe29 Mon Sep 17 00:00:00 2001 From: Yuan <20144414+baskduf@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:53:34 +0900 Subject: [PATCH] Harden ledger state handling --- CHANGELOG.md | 6 + CONTRIBUTING.md | 11 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- README.zh-TW.md | 2 +- docs/RELEASING.md | 5 +- examples/codex-config.litellm.toml | 4 +- examples/litellm-fable5.yaml | 6 +- .../references/provider-bridge.md | 6 +- .../codex-fable5/scripts/codex_findings.py | 270 +++++++++----- .../codex-fable5/scripts/codex_goals.py | 301 ++++++++++----- .../codex-fable5/scripts/fable_coverage.py | 26 +- .../scripts/make_litellm_config.py | 10 +- tests/test_scripts.py | 343 ++++++++++++++++++ 16 files changed, 783 insertions(+), 215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3d41c..5834ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ This project uses a lightweight changelog format: - Added explicit conversion priorities and "do not convert" boundaries for turning Claude/Fable prompt sections into Codex-native behavior. - Added currentness notes for Fable/Mythos provider availability so routing examples are treated as templates unless official docs and account access prove availability. +### Fixed + +- Hardened goal and findings ledgers against malformed state, concurrent findings writes, and failed forced plan replacement. +- Made coverage checking tolerate source markdown that starts below an H1 heading. +- Aligned release, contributor, README, and provider-bridge examples with the current package layout and verification commands. + ## 0.4.1 - 2026-06-15 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb88b36..5cf952f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,18 +15,22 @@ Thanks for helping improve FableCodex. This project is a Codex-native workflow p This repository intentionally uses the Python standard library for tests. ```bash -python -m unittest discover -s tests -v -python plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py +python3 -m unittest discover -s tests -v +python3 plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py ``` Before opening a pull request, also run: ```bash -python -m py_compile \ +python3 -m py_compile \ + plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py \ plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \ plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \ plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \ tests/test_scripts.py +sh -n plugins/codex-fable5/bin/codex-fable5 +sh -n plugins/codex-fable5/bin/codex-findings +sh -n plugins/codex-fable5/bin/codex-goals ``` ## Contribution Rules @@ -45,4 +49,3 @@ python -m py_compile \ - Any current product, model, API, or provider claim is verified against an official source. - Documentation is updated when user-facing behavior changes. - Licensing and attribution notes remain accurate. - diff --git a/README.ja.md b/README.ja.md index 21054e2..537cbdd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -169,7 +169,7 @@ codex-fable5 findings add \ --title "最終検証の不足" \ --severity high \ --source review \ - --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py:180" \ + --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py" \ --evidence "テスト実行の証拠がなくても final checkpoint が完了できる。" ``` diff --git a/README.ko.md b/README.ko.md index f058c8e..783754e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -169,7 +169,7 @@ codex-fable5 findings add \ --title "최종 검증 누락" \ --severity high \ --source review \ - --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py:180" \ + --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py" \ --evidence "테스트 실행 증거가 없어도 final checkpoint가 완료될 수 있다." ``` diff --git a/README.md b/README.md index 0a0e8ce..4f64f8b 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ codex-fable5 findings add \ --title "Missing final verification" \ --severity high \ --source review \ - --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py:180" \ + --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py" \ --evidence "Final checkpoint can complete without proof that tests ran." ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index ddc2c79..82992fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -169,7 +169,7 @@ codex-fable5 findings add \ --title "缺少最终验证" \ --severity high \ --source review \ - --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py:180" \ + --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py" \ --evidence "即使没有测试运行证明,final checkpoint 也可以完成。" ``` diff --git a/README.zh-TW.md b/README.zh-TW.md index feed5da..32c451d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -169,7 +169,7 @@ codex-fable5 findings add \ --title "缺少最終驗證" \ --severity high \ --source review \ - --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py:180" \ + --location "plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py" \ --evidence "即使沒有測試執行證明,final checkpoint 也可以完成。" ``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 57dcf8b..e1cfa74 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -29,8 +29,9 @@ python3 plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py 8. Tag the release with the plugin version, for example: ```bash -git tag v0.3.1 -git push origin main --tags +VERSION=$(python3 -c 'import json; print(json.load(open("plugins/codex-fable5/.codex-plugin/plugin.json"))["version"])') +git tag "v${VERSION}" +git push origin main "v${VERSION}" ``` 9. Create a GitHub release that summarizes user-visible changes and links to the changelog. diff --git a/examples/codex-config.litellm.toml b/examples/codex-config.litellm.toml index 8281f53..d2b2f78 100644 --- a/examples/codex-config.litellm.toml +++ b/examples/codex-config.litellm.toml @@ -1,7 +1,9 @@ # Example only. Put provider routing in user-level ~/.codex/config.toml. +# Replace placeholders with a model that official Anthropic docs and your account +# currently show as available, and with the matching LiteLLM alias. model_provider = "litellm-fable5" -model = "claude-fable-5" +model = "your-codex-model-alias" [model_providers.litellm-fable5] name = "LiteLLM Fable 5" diff --git a/examples/litellm-fable5.yaml b/examples/litellm-fable5.yaml index 7479ac5..5788b7e 100644 --- a/examples/litellm-fable5.yaml +++ b/examples/litellm-fable5.yaml @@ -1,7 +1,9 @@ +# Example only. Replace placeholders with a model that official Anthropic docs +# and your account currently show as available. model_list: - - model_name: "claude-fable-5" + - model_name: "your-codex-model-alias" litellm_params: - model: "anthropic/claude-fable-5" + model: "anthropic/replace-with-current-anthropic-model" api_key: os.environ/ANTHROPIC_API_KEY litellm_settings: diff --git a/plugins/codex-fable5/skills/codex-fable5/references/provider-bridge.md b/plugins/codex-fable5/skills/codex-fable5/references/provider-bridge.md index 271d071..7de7d63 100644 --- a/plugins/codex-fable5/skills/codex-fable5/references/provider-bridge.md +++ b/plugins/codex-fable5/skills/codex-fable5/references/provider-bridge.md @@ -25,8 +25,8 @@ Generate a starter LiteLLM config: ```bash python3 plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \ - --model claude-fable-5 \ - --alias claude-fable-5 \ + --model replace-with-current-anthropic-model \ + --alias your-codex-model-alias \ --output litellm-fable5.yaml ``` @@ -36,7 +36,7 @@ Put provider config in your user-level `~/.codex/config.toml`, not in a project- ```toml model_provider = "litellm-fable5" -model = "claude-fable-5" +model = "your-codex-model-alias" [model_providers.litellm-fable5] name = "LiteLLM Fable 5" 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 01caa7a..86c5362 100755 --- a/plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py +++ b/plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py @@ -4,29 +4,70 @@ from __future__ import annotations import argparse +from contextlib import contextmanager import json +import os import re import sys +import tempfile +import time from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Iterator + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback only. + fcntl = None STATE_DIR = Path(".codex-fable5") GOALS_FILE = STATE_DIR / "goals.json" FINDINGS_FILE = STATE_DIR / "findings.json" LEDGER_FILE = STATE_DIR / "ledger.jsonl" +LOCK_FILE = STATE_DIR / "state.lock" OPEN_STATUSES = {"open"} BLOCKING_STATUSES = {"open", "blocked"} TERMINAL_STATUSES = {"resolved", "rejected"} SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3} +FINDING_STATUSES = {"open", "blocked", "resolved", "rejected"} +FINDING_REQUIRED_FIELDS = {"id", "goal", "title", "severity", "source", "status", "evidence"} +LOCK_TIMEOUT_SECONDS = 30.0 def now() -> str: return datetime.now(timezone.utc).isoformat() -def read_json(path: Path, label: str) -> dict[str, Any]: +@contextmanager +def locked_state() -> Iterator[None]: + STATE_DIR.mkdir(exist_ok=True) + if fcntl is None: + fallback = STATE_DIR / "state.lockdir" + deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS + while True: + try: + fallback.mkdir() + break + except FileExistsError: + if time.monotonic() >= deadline: + sys.exit(f"codex-fable5: timed out waiting for state lock ({fallback}).") + time.sleep(0.05) + try: + yield + finally: + fallback.rmdir() + return + + with LOCK_FILE.open("a", encoding="utf-8") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + +def read_json(path: Path, label: str) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -38,7 +79,19 @@ def read_json(path: Path, label: str) -> dict[str, Any]: 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") + tmp_name = "" + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=STATE_DIR, + prefix=f".{path.name}.", + delete=False, + ) as handle: + tmp_name = handle.name + handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + handle.flush() + os.fsync(handle.fileno()) + Path(tmp_name).replace(path) def append_event(event: str, **fields: Any) -> None: @@ -48,12 +101,45 @@ def append_event(event: str, **fields: Any) -> None: handle.write(json.dumps(record, ensure_ascii=False) + "\n") +def require_object(value: Any, path: Path, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + sys.exit(f"codex-fable5: {label} must be a JSON object ({path}).") + return value + + +def validate_findings(data: dict[str, Any], path: Path, label: str) -> dict[str, Any]: + data.setdefault("findings", []) + findings = data["findings"] + if not isinstance(findings, list): + sys.exit(f"codex-fable5: {label} field 'findings' must be a list ({path}).") + for index, finding in enumerate(findings, 1): + if not isinstance(finding, dict): + sys.exit(f"codex-fable5: {label} finding {index} must be an object ({path}).") + missing = sorted(FINDING_REQUIRED_FIELDS - finding.keys()) + if missing: + fields = ", ".join(missing) + sys.exit(f"codex-fable5: {label} finding {index} is missing {fields} ({path}).") + status = finding.get("status") + if not isinstance(status, str) or status not in FINDING_STATUSES: + sys.exit(f"codex-fable5: {label} finding {index} has invalid status {status!r} ({path}).") + return data + + +def validate_goals(data: dict[str, Any], path: Path, label: str) -> dict[str, Any]: + goals = data.get("goals") + if not isinstance(goals, list): + sys.exit(f"codex-fable5: {label} field 'goals' must be a list ({path}).") + for index, goal in enumerate(goals, 1): + if not isinstance(goal, dict): + sys.exit(f"codex-fable5: {label} goal {index} must be an object ({path}).") + return data + + def load_findings() -> dict[str, Any]: if not FINDINGS_FILE.exists(): return {"created": now(), "findings": []} - data = read_json(FINDINGS_FILE, "findings ledger") - data.setdefault("findings", []) - return data + data = require_object(read_json(FINDINGS_FILE, "findings ledger"), FINDINGS_FILE, "findings ledger") + return validate_findings(data, FINDINGS_FILE, "findings ledger") def save_findings(data: dict[str, Any]) -> None: @@ -64,7 +150,8 @@ def save_findings(data: dict[str, Any]) -> None: def load_goals() -> dict[str, Any] | None: if not GOALS_FILE.exists(): return None - return read_json(GOALS_FILE, "goal plan") + data = require_object(read_json(GOALS_FILE, "goal plan"), GOALS_FILE, "goal plan") + return validate_goals(data, GOALS_FILE, "goal plan") def active_goal_id() -> str: @@ -120,36 +207,37 @@ def format_finding(finding: dict[str, Any]) -> str: def cmd_add(args: argparse.Namespace) -> None: - data = load_findings() - finding_id = next_finding_id(data["findings"]) - goal = args.goal.strip() or active_goal_id() - title = require_text(args.title, "--title") - evidence = require_text(args.evidence, "--evidence") - finding = { - "id": finding_id, - "goal": goal, - "title": title, - "severity": args.severity, - "source": args.source, - "status": "open", - "location": args.location.strip(), - "evidence": evidence, - "resolution": "", - "verify_cmd": "", - "verify_evidence": "", - "created": now(), - "updated": "", - } - data["findings"].append(finding) - save_findings(data) - append_event( - "finding_added", - id=finding_id, - goal=goal, - severity=args.severity, - source=args.source, - title=finding["title"], - ) + with locked_state(): + data = load_findings() + finding_id = next_finding_id(data["findings"]) + goal = args.goal.strip() or active_goal_id() + title = require_text(args.title, "--title") + evidence = require_text(args.evidence, "--evidence") + finding = { + "id": finding_id, + "goal": goal, + "title": title, + "severity": args.severity, + "source": args.source, + "status": "open", + "location": args.location.strip(), + "evidence": evidence, + "resolution": "", + "verify_cmd": "", + "verify_evidence": "", + "created": now(), + "updated": "", + } + data["findings"].append(finding) + save_findings(data) + append_event( + "finding_added", + id=finding_id, + goal=goal, + severity=args.severity, + source=args.source, + title=finding["title"], + ) print(f"codex-fable5: added {finding_id}") print(format_finding(finding)) @@ -202,70 +290,74 @@ def cmd_next(args: argparse.Namespace) -> None: def cmd_resolve(args: argparse.Namespace) -> None: - data = load_findings() - finding = get_finding(data, args.id) - if finding["status"] not in {"open", "blocked"}: - sys.exit(f"codex-fable5: {args.id} is {finding['status']}; reopen it first.") - - evidence = require_text(args.evidence, "--evidence") - verify_evidence = require_text(args.verify_evidence, "--verify-evidence") - finding["status"] = "resolved" - finding["resolution"] = evidence - finding["verify_cmd"] = args.verify_cmd.strip() - finding["verify_evidence"] = verify_evidence - finding["updated"] = now() - save_findings(data) - append_event( - "finding_resolved", - id=args.id, - goal=finding.get("goal", ""), - verify_cmd=finding["verify_cmd"], - verify_evidence=finding["verify_evidence"], - ) + with locked_state(): + data = load_findings() + finding = get_finding(data, args.id) + if finding["status"] not in {"open", "blocked"}: + sys.exit(f"codex-fable5: {args.id} is {finding['status']}; reopen it first.") + + evidence = require_text(args.evidence, "--evidence") + verify_evidence = require_text(args.verify_evidence, "--verify-evidence") + finding["status"] = "resolved" + finding["resolution"] = evidence + finding["verify_cmd"] = args.verify_cmd.strip() + finding["verify_evidence"] = verify_evidence + finding["updated"] = now() + save_findings(data) + append_event( + "finding_resolved", + id=args.id, + goal=finding.get("goal", ""), + verify_cmd=finding["verify_cmd"], + verify_evidence=finding["verify_evidence"], + ) print(f"codex-fable5: {args.id} -> resolved") def cmd_reject(args: argparse.Namespace) -> None: - data = load_findings() - finding = get_finding(data, args.id) - if finding["status"] in TERMINAL_STATUSES: - sys.exit(f"codex-fable5: {args.id} is already {finding['status']}.") - - reason = require_text(args.reason, "--reason") - finding["status"] = "rejected" - finding["resolution"] = reason - finding["updated"] = now() - save_findings(data) - append_event("finding_rejected", id=args.id, goal=finding.get("goal", ""), reason=reason) + with locked_state(): + data = load_findings() + finding = get_finding(data, args.id) + if finding["status"] in TERMINAL_STATUSES: + sys.exit(f"codex-fable5: {args.id} is already {finding['status']}.") + + reason = require_text(args.reason, "--reason") + finding["status"] = "rejected" + finding["resolution"] = reason + finding["updated"] = now() + save_findings(data) + append_event("finding_rejected", id=args.id, goal=finding.get("goal", ""), reason=reason) print(f"codex-fable5: {args.id} -> rejected") def cmd_block(args: argparse.Namespace) -> None: - data = load_findings() - finding = get_finding(data, args.id) - if finding["status"] in TERMINAL_STATUSES: - sys.exit(f"codex-fable5: {args.id} is already {finding['status']}.") - - reason = require_text(args.reason, "--reason") - finding["status"] = "blocked" - finding["resolution"] = reason - finding["updated"] = now() - save_findings(data) - append_event("finding_blocked", id=args.id, goal=finding.get("goal", ""), reason=reason) + with locked_state(): + data = load_findings() + finding = get_finding(data, args.id) + if finding["status"] in TERMINAL_STATUSES: + sys.exit(f"codex-fable5: {args.id} is already {finding['status']}.") + + reason = require_text(args.reason, "--reason") + finding["status"] = "blocked" + finding["resolution"] = reason + finding["updated"] = now() + save_findings(data) + append_event("finding_blocked", id=args.id, goal=finding.get("goal", ""), reason=reason) print(f"codex-fable5: {args.id} -> blocked") def cmd_reopen(args: argparse.Namespace) -> None: - data = load_findings() - finding = get_finding(data, args.id) - previous_status = finding["status"] - finding["status"] = "open" - finding["resolution"] = "" - finding["verify_cmd"] = "" - finding["verify_evidence"] = "" - finding["updated"] = now() - save_findings(data) - append_event("finding_reopened", id=args.id, previous_status=previous_status) + with locked_state(): + data = load_findings() + finding = get_finding(data, args.id) + previous_status = finding["status"] + finding["status"] = "open" + finding["resolution"] = "" + finding["verify_cmd"] = "" + finding["verify_evidence"] = "" + finding["updated"] = now() + save_findings(data) + append_event("finding_reopened", id=args.id, previous_status=previous_status) print(f"codex-fable5: {args.id} reopened from {previous_status}") 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 ec38b86..8d4699c 100755 --- a/plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py +++ b/plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py @@ -4,19 +4,34 @@ from __future__ import annotations import argparse +from contextlib import contextmanager import json +import os import sys +import tempfile +import time from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Iterator + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback only. + fcntl = None STATE_DIR = Path(".codex-fable5") GOALS_FILE = STATE_DIR / "goals.json" FINDINGS_FILE = STATE_DIR / "findings.json" LEDGER_FILE = STATE_DIR / "ledger.jsonl" +LOCK_FILE = STATE_DIR / "state.lock" OPEN_STATUSES = {"pending", "in_progress"} INCOMPLETE_TERMINAL_STATUSES = {"failed", "blocked"} BLOCKING_FINDING_STATUSES = {"open", "blocked"} +GOAL_STATUSES = {"pending", "in_progress", "complete", "failed", "blocked"} +GOAL_REQUIRED_FIELDS = {"id", "title", "objective", "status", "evidence", "verify_cmd", "verify_evidence"} +FINDING_STATUSES = {"open", "blocked", "resolved", "rejected"} +FINDING_REQUIRED_FIELDS = {"id", "goal", "title", "severity", "source", "status", "evidence"} +LOCK_TIMEOUT_SECONDS = 30.0 def now() -> str: @@ -27,7 +42,35 @@ def safe_stamp() -> str: return now().replace(":", "").replace("+", "Z") -def read_json(path: Path, label: str) -> dict[str, Any]: +@contextmanager +def locked_state() -> Iterator[None]: + STATE_DIR.mkdir(exist_ok=True) + if fcntl is None: + fallback = STATE_DIR / "state.lockdir" + deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS + while True: + try: + fallback.mkdir() + break + except FileExistsError: + if time.monotonic() >= deadline: + sys.exit(f"codex-fable5: timed out waiting for state lock ({fallback}).") + time.sleep(0.05) + try: + yield + finally: + fallback.rmdir() + return + + with LOCK_FILE.open("a", encoding="utf-8") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + +def read_json(path: Path, label: str) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -39,7 +82,19 @@ def read_json(path: Path, label: str) -> dict[str, Any]: 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") + tmp_name = "" + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=STATE_DIR, + prefix=f".{path.name}.", + delete=False, + ) as handle: + tmp_name = handle.name + handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + handle.flush() + os.fsync(handle.fileno()) + Path(tmp_name).replace(path) def append_event(event: str, **fields: Any) -> None: @@ -49,10 +104,56 @@ def append_event(event: str, **fields: Any) -> None: handle.write(json.dumps(record, ensure_ascii=False) + "\n") +def require_object(value: Any, path: Path, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + sys.exit(f"codex-fable5: {label} must be a JSON object ({path}).") + return value + + +def validate_plan(data: dict[str, Any], path: Path, label: str) -> dict[str, Any]: + if not isinstance(data.get("brief"), str): + sys.exit(f"codex-fable5: {label} field 'brief' must be a string ({path}).") + goals = data.get("goals") + if not isinstance(goals, list): + sys.exit(f"codex-fable5: {label} field 'goals' must be a list ({path}).") + if not goals: + sys.exit(f"codex-fable5: {label} field 'goals' must contain at least one goal ({path}).") + for index, goal in enumerate(goals, 1): + if not isinstance(goal, dict): + sys.exit(f"codex-fable5: {label} goal {index} must be an object ({path}).") + missing = sorted(GOAL_REQUIRED_FIELDS - goal.keys()) + if missing: + fields = ", ".join(missing) + sys.exit(f"codex-fable5: {label} goal {index} is missing {fields} ({path}).") + status = goal.get("status") + if not isinstance(status, str) or status not in GOAL_STATUSES: + sys.exit(f"codex-fable5: {label} goal {index} has invalid status {status!r} ({path}).") + return data + + +def validate_findings(data: dict[str, Any], path: Path, label: str) -> dict[str, Any]: + data.setdefault("findings", []) + findings = data.get("findings") + if not isinstance(findings, list): + sys.exit(f"codex-fable5: {label} field 'findings' must be a list ({path}).") + for index, finding in enumerate(findings, 1): + if not isinstance(finding, dict): + sys.exit(f"codex-fable5: {label} finding {index} must be an object ({path}).") + missing = sorted(FINDING_REQUIRED_FIELDS - finding.keys()) + if missing: + fields = ", ".join(missing) + sys.exit(f"codex-fable5: {label} finding {index} is missing {fields} ({path}).") + status = finding.get("status") + if not isinstance(status, str) or status not in FINDING_STATUSES: + sys.exit(f"codex-fable5: {label} finding {index} has invalid status {status!r} ({path}).") + return data + + 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 read_json(GOALS_FILE, "goal plan") + data = require_object(read_json(GOALS_FILE, "goal plan"), GOALS_FILE, "goal plan") + return validate_plan(data, GOALS_FILE, "goal plan") def parse_goal(raw: str, index: int) -> dict[str, Any]: @@ -101,7 +202,8 @@ def archive_findings_for_force() -> None: def blocking_findings() -> list[dict[str, Any]]: if not FINDINGS_FILE.exists(): return [] - data = read_json(FINDINGS_FILE, "findings ledger") + data = require_object(read_json(FINDINGS_FILE, "findings ledger"), FINDINGS_FILE, "findings ledger") + data = validate_findings(data, FINDINGS_FILE, "findings ledger") return [ finding for finding in data.get("findings", []) @@ -110,113 +212,116 @@ def blocking_findings() -> list[dict[str, Any]]: def cmd_create(args: argparse.Namespace) -> None: - if GOALS_FILE.exists() and not args.force: - sys.exit("codex-fable5: plan already exists. Use `status` or replace it with --force.") 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)) + with locked_state(): + if GOALS_FILE.exists() and not args.force: + sys.exit("codex-fable5: plan already exists. Use `status` or replace it with --force.") + write_json(GOALS_FILE, plan) + if args.force: + archive_findings_for_force() + append_event("plan_created", brief=args.brief, count=len(goals)) print(f"codex-fable5: plan created with {len(goals)} stories") for goal in goals: print(f" {goal['id']} {goal['title']}: {goal['objective']}") def cmd_next(_: argparse.Namespace) -> None: - plan = load_plan() - active = [goal for goal in plan["goals"] if goal["status"] == "in_progress"] - if active: - goal = active[0] - else: - incomplete = terminal_incomplete_goals(plan["goals"]) - if incomplete: - goal = incomplete[0] - previous_status = goal["status"] - goal["status"] = "in_progress" - write_json(GOALS_FILE, plan) - append_event( - "story_reopened", - id=goal["id"], - title=goal["title"], - previous_status=previous_status, - ) - print(f"Reopened {goal['id']} from {previous_status}.") + with locked_state(): + plan = load_plan() + active = [goal for goal in plan["goals"] if goal["status"] == "in_progress"] + if active: + goal = active[0] else: - pending = [goal for goal in plan["goals"] if goal["status"] == "pending"] - if not pending: - print("codex-fable5: all stories complete") - return - goal = pending[0] - goal["status"] = "in_progress" - write_json(GOALS_FILE, plan) - append_event("story_started", id=goal["id"], title=goal["title"]) - - is_final = goal["id"] == plan["goals"][-1]["id"] - print(f"=== codex-fable5 handoff: {goal['id']} {goal['title']}") - print(f"Objective: {goal['objective']}") - print("Rule: work this story only and produce concrete evidence.") - command = ( - f"codex-fable5 goals checkpoint --id {goal['id']} --status complete " - '--evidence ""' - ) - if is_final: - print("Final story: completion requires --verify-cmd and --verify-evidence.") - command += ' --verify-cmd "" --verify-evidence ""' - print(f"On completion: {command}") + incomplete = terminal_incomplete_goals(plan["goals"]) + if incomplete: + goal = incomplete[0] + previous_status = goal["status"] + goal["status"] = "in_progress" + write_json(GOALS_FILE, plan) + append_event( + "story_reopened", + id=goal["id"], + title=goal["title"], + previous_status=previous_status, + ) + print(f"Reopened {goal['id']} from {previous_status}.") + else: + pending = [goal for goal in plan["goals"] if goal["status"] == "pending"] + if not pending: + print("codex-fable5: all stories complete") + return + goal = pending[0] + goal["status"] = "in_progress" + write_json(GOALS_FILE, plan) + append_event("story_started", id=goal["id"], title=goal["title"]) + + is_final = goal["id"] == plan["goals"][-1]["id"] + print(f"=== codex-fable5 handoff: {goal['id']} {goal['title']}") + print(f"Objective: {goal['objective']}") + print("Rule: work this story only and produce concrete evidence.") + command = ( + f"codex-fable5 goals checkpoint --id {goal['id']} --status complete " + '--evidence ""' + ) + if is_final: + print("Final story: completion requires --verify-cmd and --verify-evidence.") + command += ' --verify-cmd "" --verify-evidence ""' + print(f"On completion: {command}") def cmd_checkpoint(args: argparse.Namespace) -> None: - plan = load_plan() - goal = next((item for item in plan["goals"] if item["id"] == args.id), None) - if goal is None: - sys.exit(f"codex-fable5: unknown goal id {args.id}.") - if goal["status"] != "in_progress": - sys.exit(f"codex-fable5: {args.id} is {goal['status']}; activate it with `next` first.") - - evidence = args.evidence.strip() - verify_cmd = args.verify_cmd.strip() - verify_evidence = args.verify_evidence.strip() - if args.status == "complete": - if not evidence: - sys.exit("codex-fable5: complete checkpoints require non-empty --evidence.") - if goal["id"] == plan["goals"][-1]["id"] and not (verify_cmd and verify_evidence): - sys.exit("codex-fable5: final story requires --verify-cmd and --verify-evidence.") - if goal["id"] == plan["goals"][-1]["id"]: - findings = blocking_findings() - if findings: - ids = ", ".join(str(finding.get("id", "?")) for finding in findings) - sys.exit( - "codex-fable5: final story requires findings gate; " - f"{len(findings)} blocking findings remain ({ids})." - ) - - goal["status"] = args.status - goal["evidence"] = evidence - goal["verify_cmd"] = verify_cmd - goal["verify_evidence"] = verify_evidence - write_json(GOALS_FILE, plan) - append_event( - "checkpoint", - id=goal["id"], - status=args.status, - evidence=evidence, - verify_cmd=verify_cmd, - verify_evidence=verify_evidence, - ) - remaining = [item for item in plan["goals"] if item["status"] in OPEN_STATUSES] - print(f"codex-fable5: {goal['id']} -> {args.status}") - if terminal_incomplete_goals(plan["goals"]): - summary = incomplete_terminal_summary(plan["goals"]) - print(f"codex-fable5: plan is not complete; {summary}.") - if remaining: - print(f"codex-fable5: {len(remaining)} open stories remain blocked.") - elif remaining: - print(f"codex-fable5: {len(remaining)} stories left") - else: - print("codex-fable5: all stories complete") + with locked_state(): + plan = load_plan() + goal = next((item for item in plan["goals"] if item["id"] == args.id), None) + if goal is None: + sys.exit(f"codex-fable5: unknown goal id {args.id}.") + if goal["status"] != "in_progress": + sys.exit(f"codex-fable5: {args.id} is {goal['status']}; activate it with `next` first.") + + evidence = args.evidence.strip() + verify_cmd = args.verify_cmd.strip() + verify_evidence = args.verify_evidence.strip() + if args.status == "complete": + if not evidence: + sys.exit("codex-fable5: complete checkpoints require non-empty --evidence.") + if goal["id"] == plan["goals"][-1]["id"] and not (verify_cmd and verify_evidence): + sys.exit("codex-fable5: final story requires --verify-cmd and --verify-evidence.") + if goal["id"] == plan["goals"][-1]["id"]: + findings = blocking_findings() + if findings: + ids = ", ".join(str(finding.get("id", "?")) for finding in findings) + sys.exit( + "codex-fable5: final story requires findings gate; " + f"{len(findings)} blocking findings remain ({ids})." + ) + + goal["status"] = args.status + goal["evidence"] = evidence + goal["verify_cmd"] = verify_cmd + goal["verify_evidence"] = verify_evidence + write_json(GOALS_FILE, plan) + append_event( + "checkpoint", + id=goal["id"], + status=args.status, + evidence=evidence, + verify_cmd=verify_cmd, + verify_evidence=verify_evidence, + ) + remaining = [item for item in plan["goals"] if item["status"] in OPEN_STATUSES] + print(f"codex-fable5: {goal['id']} -> {args.status}") + if terminal_incomplete_goals(plan["goals"]): + summary = incomplete_terminal_summary(plan["goals"]) + print(f"codex-fable5: plan is not complete; {summary}.") + if remaining: + print(f"codex-fable5: {len(remaining)} open stories remain blocked.") + elif remaining: + print(f"codex-fable5: {len(remaining)} stories left") + else: + print("codex-fable5: all stories complete") def cmd_status(_: argparse.Namespace) -> None: diff --git a/plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py b/plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py index 20006de..ee7a5b4 100755 --- a/plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py +++ b/plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py @@ -17,18 +17,24 @@ def normalize_status(raw: str) -> str: def extract_source_sections(path: Path) -> list[str]: - stack: list[str] = [] - sections: list[str] = [] + headings: list[tuple[int, str]] = [] for line in path.read_text(encoding="utf-8").splitlines(): match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line) - if not match: - continue - level = len(match.group(1)) - title = match.group(2).strip() - stack = stack[: level - 1] - stack.append(title) - if level > 1: - sections.append(" > ".join(stack[1:])) + if match: + headings.append((len(match.group(1)), match.group(2).strip())) + if not headings: + return [] + + base_level = 1 if any(level == 1 for level, _ in headings) else min(level for level, _ in headings) - 1 + stack: dict[int, str] = {} + sections: list[str] = [] + for level, title in headings: + for stale_level in [item for item in stack if item >= level]: + del stack[stale_level] + stack[level] = title + if level > base_level: + parts = [stack[item] for item in sorted(stack) if base_level < item <= level] + sections.append(" > ".join(parts)) return sections diff --git a/plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py b/plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py index f2e812f..26570b2 100755 --- a/plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py +++ b/plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py @@ -6,6 +6,8 @@ import argparse from pathlib import Path +DEFAULT_MODEL = "replace-with-current-anthropic-model" + def q(value: str) -> str: return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' @@ -15,6 +17,8 @@ def build_config(model: str, alias: str) -> str: provider_model = model if model.startswith("anthropic/") else f"anthropic/{model}" return "\n".join( [ + "# Example only. Replace the model with one that official Anthropic docs", + "# and your account currently show as available.", "model_list:", " - model_name: " + q(alias), " litellm_params:", @@ -31,7 +35,11 @@ def build_config(model: str, alias: str) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--model", default="claude-fable-5", help="Anthropic model name.") + parser.add_argument( + "--model", + default=DEFAULT_MODEL, + help="Anthropic model name; verify official availability and account access first.", + ) parser.add_argument("--alias", default=None, help="Model name Codex should send to LiteLLM.") parser.add_argument("--output", "-o", default="-", help="Output YAML path, or '-' for stdout.") args = parser.parse_args() diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 1afd1b7..32c2b3f 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -9,6 +9,7 @@ import textwrap import unittest from pathlib import Path +from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[1] @@ -31,6 +32,7 @@ class ScriptTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.fable_coverage = load_script("fable_coverage") + cls.codex_goals = load_script("codex_goals") cls.codex_findings = load_script("codex_findings") cls.make_litellm_config = load_script("make_litellm_config") @@ -311,6 +313,24 @@ def test_coverage_helpers_parse_headings_and_matrix_rows(self) -> None: {"alpha": "adapted", "alpha > beta": "implemented", "gamma": "not_applicable"}, ) + def test_coverage_helpers_parse_sources_without_h1(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.md" + source.write_text( + textwrap.dedent( + """\ + ## alpha + ### beta + ## gamma + """ + ), + encoding="utf-8", + ) + + sections = self.fable_coverage.extract_source_sections(source) + + self.assertEqual(sections, ["alpha", "alpha > beta", "gamma"]) + def test_goal_ledger_flow_and_final_verification_gate(self) -> None: script = SCRIPTS / "codex_goals.py" with tempfile.TemporaryDirectory() as tmp: @@ -632,6 +652,65 @@ def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: allow_blocked_gate = run(findings_script, "gate", "--allow-blocked") self.assertEqual(allow_blocked_gate.returncode, 0, allow_blocked_gate.stderr) + def test_findings_parallel_adds_do_not_lose_entries(self) -> None: + script = SCRIPTS / "codex_findings.py" + with tempfile.TemporaryDirectory() as tmp: + processes = [ + subprocess.Popen( + [ + sys.executable, + str(script), + "add", + "--title", + f"Finding {index}", + "--evidence", + "Parallel add should persist exactly once.", + ], + cwd=tmp, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + for index in range(20) + ] + results = [process.communicate() + (process.returncode,) for process in processes] + + for stdout, stderr, returncode in results: + self.assertEqual(returncode, 0, stderr or stdout) + + data = json.loads((Path(tmp) / ".codex-fable5" / "findings.json").read_text()) + ids = [finding["id"] for finding in data["findings"]] + + self.assertEqual(len(ids), 20) + self.assertEqual(len(set(ids)), 20) + self.assertEqual(ids, [f"F{index:03d}" for index in range(1, 21)]) + + def test_lock_fallback_times_out_on_stale_lockdir(self) -> None: + for module in [self.codex_findings, self.codex_goals]: + with self.subTest(module=module.__name__): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + state_dir = cwd / ".codex-fable5" + state_dir.mkdir() + (state_dir / "state.lockdir").mkdir() + + old_cwd = Path.cwd() + original_fcntl = module.fcntl + original_timeout = module.LOCK_TIMEOUT_SECONDS + try: + os.chdir(cwd) + module.fcntl = None + module.LOCK_TIMEOUT_SECONDS = 0.01 + with self.assertRaises(SystemExit) as raised: + with module.locked_state(): + pass + finally: + module.fcntl = original_fcntl + module.LOCK_TIMEOUT_SECONDS = original_timeout + os.chdir(old_cwd) + + self.assertIn("timed out waiting for state lock", str(raised.exception)) + def test_goal_final_checkpoint_requires_findings_gate(self) -> None: goals_script = SCRIPTS / "codex_goals.py" findings_script = SCRIPTS / "codex_findings.py" @@ -714,6 +793,69 @@ def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: ) self.assertEqual(complete.returncode, 0, complete.stderr) + def test_goal_final_checkpoint_rejects_malformed_findings_ledger(self) -> None: + goals_script = SCRIPTS / "codex_goals.py" + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + + def run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(goals_script), *args], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual( + run("create", "--brief", "Smoke", "--goal", "verify::Confirm final state").returncode, + 0, + ) + self.assertEqual(run("next").returncode, 0) + findings_path = cwd / ".codex-fable5" / "findings.json" + findings_path.write_text( + json.dumps( + { + "findings": [ + { + "id": "F001", + "goal": "G001", + "title": "Malformed status", + "severity": "high", + "source": "review", + "status": "OPEN", + "location": "", + "evidence": "Uppercase status should not be accepted by final gate.", + "resolution": "", + "verify_cmd": "", + "verify_evidence": "", + "created": "test", + "updated": "", + } + ] + } + ), + encoding="utf-8", + ) + + complete = run( + "checkpoint", + "--id", + "G001", + "--status", + "complete", + "--evidence", + "final evidence", + "--verify-cmd", + "smoke", + "--verify-evidence", + "accepted", + ) + + self.assertNotEqual(complete.returncode, 0) + self.assertIn("invalid status", complete.stderr) + self.assertNotIn("Traceback", 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" @@ -799,6 +941,80 @@ def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: ) self.assertEqual(complete.returncode, 0, complete.stderr) + def test_force_create_keeps_findings_when_goal_write_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + state_dir = cwd / ".codex-fable5" + state_dir.mkdir() + goals_path = state_dir / "goals.json" + findings_path = state_dir / "findings.json" + goals_path.write_text( + json.dumps( + { + "brief": "Old", + "created": "test", + "goals": [ + { + "id": "G001", + "title": "old", + "objective": "Old objective", + "status": "in_progress", + "evidence": "", + "verify_cmd": "", + "verify_evidence": "", + } + ], + } + ), + encoding="utf-8", + ) + findings_path.write_text( + json.dumps( + { + "created": "test", + "findings": [ + { + "id": "F001", + "goal": "G001", + "title": "Old finding", + "severity": "high", + "source": "review", + "status": "open", + "location": "", + "evidence": "Must remain active if replacement fails.", + "resolution": "", + "verify_cmd": "", + "verify_evidence": "", + "created": "test", + "updated": "", + } + ], + } + ), + encoding="utf-8", + ) + + original_write_json = self.codex_goals.write_json + + def fail_write(*_: object) -> None: + raise OSError("simulated write failure") + + old_cwd = Path.cwd() + try: + os.chdir(cwd) + self.codex_goals.write_json = fail_write + with self.assertRaises(OSError): + self.codex_goals.cmd_create( + SimpleNamespace(brief="New", goal=["verify::New final"], force=True) + ) + finally: + self.codex_goals.write_json = original_write_json + os.chdir(old_cwd) + + self.assertTrue(findings_path.exists()) + self.assertFalse(list(state_dir.glob("findings.*.archive.json"))) + self.assertIn("Old objective", goals_path.read_text(encoding="utf-8")) + def test_malformed_ledger_json_reports_controlled_error(self) -> None: goals_script = SCRIPTS / "codex_goals.py" findings_script = SCRIPTS / "codex_findings.py" @@ -832,6 +1048,133 @@ def test_malformed_ledger_json_reports_controlled_error(self) -> None: self.assertIn("goal plan is not valid JSON", goals_status.stderr) self.assertNotIn("Traceback", goals_status.stderr) + def test_malformed_ledger_schema_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("[]", 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 must be a JSON object", findings_status.stderr) + self.assertNotIn("Traceback", findings_status.stderr) + + (state_dir / "findings.json").write_text('{"findings": "bad"}', encoding="utf-8") + findings_bad_list = subprocess.run( + [sys.executable, str(findings_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(findings_bad_list.returncode, 0) + self.assertIn("field 'findings' must be a list", findings_bad_list.stderr) + self.assertNotIn("Traceback", findings_bad_list.stderr) + + (state_dir / "findings.json").write_text( + json.dumps( + { + "findings": [ + { + "id": "F001", + "goal": "", + "title": "Bad status", + "severity": "medium", + "source": "review", + "status": [], + "evidence": "Status must not crash validation.", + } + ] + } + ), + encoding="utf-8", + ) + findings_bad_status = subprocess.run( + [sys.executable, str(findings_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(findings_bad_status.returncode, 0) + self.assertIn("invalid status", findings_bad_status.stderr) + self.assertNotIn("Traceback", findings_bad_status.stderr) + + (state_dir / "goals.json").write_text("[]", 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 must be a JSON object", goals_status.stderr) + self.assertNotIn("Traceback", goals_status.stderr) + + (state_dir / "goals.json").write_text('{"brief": "bad", "goals": "bad"}', encoding="utf-8") + goals_bad_list = subprocess.run( + [sys.executable, str(goals_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(goals_bad_list.returncode, 0) + self.assertIn("field 'goals' must be a list", goals_bad_list.stderr) + self.assertNotIn("Traceback", goals_bad_list.stderr) + + (state_dir / "goals.json").write_text( + json.dumps( + { + "brief": "bad", + "goals": [ + { + "id": "G001", + "title": "bad", + "objective": "Bad status should not crash validation.", + "status": [], + "evidence": "", + "verify_cmd": "", + "verify_evidence": "", + } + ], + } + ), + encoding="utf-8", + ) + goals_bad_status = subprocess.run( + [sys.executable, str(goals_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(goals_bad_status.returncode, 0) + self.assertIn("invalid status", goals_bad_status.stderr) + self.assertNotIn("Traceback", goals_bad_status.stderr) + + (state_dir / "goals.json").write_text('{"brief": "empty", "goals": []}', encoding="utf-8") + goals_empty = subprocess.run( + [sys.executable, str(goals_script), "status"], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(goals_empty.returncode, 0) + self.assertIn("field 'goals' must contain at least one goal", goals_empty.stderr) + self.assertNotIn("Traceback", goals_empty.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")