Build codex-goal venture: persistent objectives for codex exec sessions

Inspired by openai/codex#26966 (closed not planned). codex_goal.py is a
standalone CLI that stores one versioned, project-keyed goal per repo
outside Codex's own sandbox, and injects it as plain user context on
every new or resumed `codex exec` run. Writes are atomic and locked,
runs never mutate the goal, Codex is spawned via argv (no shell), and
signals are forwarded for normal cancellation behavior. 16 unit tests
plus a recorded two-run + crash-recovery demo, verified on macOS and
in the project's Colima Linux container. Launch page at
ventures/codex-goal/site/index.html uses a self-contained
scoreboard/arcade identity (vendored OFL DSEG7 font, no external
requests) distinct from the other ventures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Sonnet 5 committed 2026-10-10 13:37:00 -07:00
1 parent 04efde8b6f
commit 70b7a8bbcd
13 files changed
+1781

No files matched your search

+1
View File
@@ -0,0 +1 @@
__pycache__/
+134
View File
@@ -0,0 +1,134 @@
# codex-goal
A tiny wrapper that remembers one objective per project across separate
`codex exec` invocations, and injects it as plain user-context text every
time you run or resume. Inspired by
[openai/codex#26966: Persist goals across codex exec sessions](https://github.com/openai/codex/issues/26966),
closed as not planned. This is **external memory bolted onto the outside of
Codex**, not a native goal mode: Codex itself is never changed, and never
gets an API to mark the goal "done" or rewrite it.
## Problem
`codex exec` is stateless about *why* you're running it. Resume brings back
the transcript, but nothing re-states the objective, so a long-running or
multi-session task slowly drifts as context gets summarized away. Upstream
closed the feature request; this reconstructs it from outside the agent.
## Install
Requires Python 3.9+, a POSIX host (macOS/Linux — matches the board's hb-5
Mac and Colima container targets), and the `codex` CLI on `PATH` for real
use. No pip install, no dependencies:
```sh
cp codex_goal.py ~/bin/codex_goal.py # or run it in place
chmod +x codex_goal.py
```
## Usage
```sh
# Save (or replace) the objective for the current directory's project.
codex_goal.py set "Migrate the billing module to the new schema, keep tests green"
# Inspect it any time.
codex_goal.py show
codex_goal.py show --json --history
# Start a new codex exec session with the goal injected as user context.
codex_goal.py run
# Resume the most recent session -- goal is re-injected, not just the transcript.
codex_goal.py run --last
# Resume a specific session id, and forward real codex flags/an extra prompt.
codex_goal.py run --resume 3fae2b1e-... -- -s workspace-write "focus on the invoices table next"
```
Everything after `run`'s own flags is forwarded to `codex exec` (or
`codex exec resume ...`) untouched and in order — sandbox mode, model,
`-C`, etc. all still work exactly as they do calling `codex` directly.
A trailing bare argument (not starting with `-`) is treated as this turn's
extra instruction and appended after the injected goal; everything else is
passed straight through.
`-C DIR` (default: cwd) on any subcommand selects the project. The project
key is a hash of its realpath, so `set`/`show`/`run` keep working no matter
which subdirectory you're standing in, and two projects never collide.
## How persistence works
- State lives under `$CODEX_GOAL_HOME` (default `~/.codex-goal`), **outside**
whatever directory `codex exec`'s sandbox would let the agent write to.
Codex running in its default `workspace-write` sandbox cannot see or
modify its own goal file — `set` is a human-run command, never something
the wrapper calls on Codex's behalf.
- Goal records are versioned: each `set` pushes the previous value into a
capped history (50 entries) instead of overwriting it silently.
- Writes are atomic (`tempfile` + `os.replace`) and guarded by an exclusive
file lock (`fcntl.flock`) around the whole read-modify-write, so a crash
or a concurrent `set` can't leave a half-written or torn JSON file.
- `run` never modifies the goal. It only appends one line to a separate
run-log (`$CODEX_GOAL_HOME/runs/<key>.jsonl`) *after* the child process
exits, recording the mode, a goal preview, the exact argv, and the real
exit code. A run that crashes mid-flight (killed, SIGKILL, power loss)
leaves both the goal and the prior run-log lines untouched — there is no
"mark this goal complete" affordance for Codex or the wrapper to call, so
nothing can silently clear it.
- Codex is spawned via `subprocess.Popen([...])` with an argv list, never a
shell string — a goal containing `; rm -rf / #` is passed as one opaque
argv element, not executed. The child gets its own process group, and
`SIGINT`/`SIGTERM` sent to the wrapper are forwarded to it, so Ctrl-C
cancels a run the same way it would cancel `codex` directly. The
wrapper's own exit code is the child's exit code.
## Limits and trust
- One objective per project at a time. It is not a task list, not a
scheduler, and it does not talk to the Codex API directly — it only
shells out to the `codex` binary you already have configured and
authenticated.
- The injected text is plain, visible user context ("[codex-goal] Persisted
objective...") — it carries no special authority inside Codex's own
permission model. A model can still choose to ignore it, same as any
other instruction.
- No enforcement that the agent is *actually* working the stated goal; this
only guarantees the goal text is faithfully re-presented every run. It
does not inspect diffs, run checks, or grade progress — see
[finishline-supervisor](../finishline-supervisor/) for that kind of
evidence wrapper, which composes with this one but is a separate tool.
- `$CODEX_GOAL_HOME` is trusted local storage with your own file
permissions; this tool adds no encryption or extra access control. Don't
put secrets in goal text — it is written to disk in plain JSON.
- Locking is advisory (`flock`) and local-filesystem only; it does not work
over NFS/SMB and does not coordinate across machines.
- `run` passes through whatever `codex exec` flags you give it, including
`--dangerously-bypass-approvals-and-sandbox` if you ask for it — this
wrapper does not add or remove any safety behavior from Codex itself.
## Develop and verify
```sh
python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 demo.py > demo.txt # recorded, runnable, no network/auth needed
```
Tests and the demo use `tests/fake_codex.py`, a deterministic stand-in for
the real `codex` binary (records the exact argv it received, exits with a
configurable code, optionally sleeps so cancellation can be tested) — no
model calls, no API key, nothing billed, per the board's preference for
deterministic, cheap, inspectable checks over live model calls. [Recorded
demo output](demo.txt) covers: `set`/`show` round trip, run 1 (fresh
session), run 2 (`--last` resume, same goal), and a `kill -9` mid-run
followed by a `show` proving the goal survived byte-for-byte.
Verified: all 16 unit tests plus the demo on the local macOS (hb-5) host,
and inside the project's Colima Linux container (same Python 3, same
`tests/fake_codex.py`, no code changes needed — the tool has no
platform-specific dependency beyond POSIX `fcntl`/process groups). Real
`codex` binary integration (the actual CLI shown in Usage above) was
exercised manually against the installed `codex` CLI's `--help` output to
confirm the `exec`/`exec resume --last`/flag-forwarding argv shape this
wrapper builds matches the real CLI; it was not run against a live Codex
backend (that needs your own account/auth and would spend real requests).
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""codex-goal: persist a per-project objective across `codex exec` sessions.
External memory, not native Codex state: it stores the goal outside the
directory Codex is allowed to write to, and injects it as plain user-context
text on every `set`/`show`/`run` the human runs. Codex itself never sees a
"mark complete" affordance -- only `set` can change the stored goal, and
`set` is never invoked by codex-goal's own `run` command.
Usage:
codex_goal.py set [-C DIR] GOAL_TEXT...
codex_goal.py show [-C DIR] [--json] [--history]
codex_goal.py run [-C DIR] [--codex-bin PATH] [--resume ID | --last]
[--dry-run] [-- CODEX_ARGS... [PROMPT]]
"""
from __future__ import annotations
import argparse
import contextlib
import hashlib
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
import uuid
try:
import fcntl
except ImportError: # pragma: no cover - POSIX only, matches board brief hosts
fcntl = None
SCHEMA_VERSION = 1
MAX_GOAL_CHARS = 4000
MAX_HISTORY = 50
ENV_HOME = "CODEX_GOAL_HOME"
def state_home() -> str:
override = os.environ.get(ENV_HOME)
if override:
return override
return os.path.join(os.path.expanduser("~"), ".codex-goal")
def project_key(project_dir: str) -> str:
real = os.path.realpath(project_dir)
digest = hashlib.sha256(real.encode("utf-8")).hexdigest()[:16]
base = os.path.basename(real.rstrip("/")) or "root"
safe_base = "".join(c if c.isalnum() or c in "-_." else "-" for c in base)
return f"{safe_base}-{digest}"
def goal_path(home: str, key: str) -> str:
return os.path.join(home, "goals", f"{key}.json")
def runlog_path(home: str, key: str) -> str:
return os.path.join(home, "runs", f"{key}.jsonl")
def lock_path(home: str, key: str) -> str:
return os.path.join(home, "locks", f"{key}.lock")
@contextlib.contextmanager
def locked(home: str, key: str):
"""Exclusive file lock guarding read-modify-write of one project's state."""
path = lock_path(home, key)
os.makedirs(os.path.dirname(path), exist_ok=True)
fh = open(path, "a+")
try:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
yield
finally:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
fh.close()
def atomic_write_json(path: str, payload: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "w") as fh:
json.dump(payload, fh, indent=2, sort_keys=True)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path) # atomic on POSIX: a reader never sees a partial file
except Exception:
with contextlib.suppress(OSError):
os.remove(tmp)
raise
def append_jsonl(path: str, record: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
line = json.dumps(record, sort_keys=True) + "\n"
with open(path, "a") as fh:
fh.write(line)
fh.flush()
os.fsync(fh.fileno())
def now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def load_state(path: str) -> dict | None:
if not os.path.exists(path):
return None
with open(path) as fh:
return json.load(fh)
def cmd_set(args: argparse.Namespace) -> int:
text = " ".join(args.goal).strip()
if not text:
print("codex-goal: refusing an empty goal", file=sys.stderr)
return 2
if len(text) > MAX_GOAL_CHARS:
print(f"codex-goal: goal exceeds {MAX_GOAL_CHARS} characters", file=sys.stderr)
return 2
home = state_home()
key = project_key(args.project)
path = goal_path(home, key)
with locked(home, key):
state = load_state(path)
ts = now_iso()
if state is None:
state = {
"schema_version": SCHEMA_VERSION,
"project": {"path": os.path.realpath(args.project), "key": key},
"current": None,
"history": [],
}
if state["current"] is not None:
state["history"].append(state["current"])
state["history"] = state["history"][-MAX_HISTORY:]
state["current"] = {
"goal_id": f"g-{uuid.uuid4().hex[:12]}",
"text": text,
"created_at": ts,
"updated_at": ts,
}
atomic_write_json(path, state)
print(f"codex-goal: saved goal {state['current']['goal_id']} for {key}")
return 0
def cmd_show(args: argparse.Namespace) -> int:
home = state_home()
key = project_key(args.project)
state = load_state(goal_path(home, key))
if state is None or state.get("current") is None:
if args.json:
print(json.dumps({"project_key": key, "current": None}))
else:
print(f"codex-goal: no goal set for {key}. Run: codex_goal.py set <goal>")
return 1
if args.json:
payload = {"project_key": key, "current": state["current"]}
if args.history:
payload["history"] = state["history"]
print(json.dumps(payload, indent=2, sort_keys=True))
else:
cur = state["current"]
print(f"project {key}")
print(f"goal_id {cur['goal_id']}")
print(f"updated {cur['updated_at']}")
print(f"text {cur['text']}")
if args.history and state["history"]:
print("\nhistory (oldest first):")
for h in state["history"]:
print(f" - [{h['updated_at']}] {h['goal_id']}: {h['text']}")
return 0
INJECT_HEADER = "[codex-goal] Persisted objective for this project (external memory, injected by a wrapper -- not native Codex state):"
INJECT_FOOTER = "[/codex-goal] Continue working toward the objective above unless the user says otherwise."
def build_prompt(goal_text: str, extra_prompt: str | None) -> str:
block = f"{INJECT_HEADER}\n{goal_text}\n{INJECT_FOOTER}"
if extra_prompt:
return f"{block}\n\n{extra_prompt}"
return block
def cmd_run(args: argparse.Namespace) -> int:
home = state_home()
key = project_key(args.project)
state = load_state(goal_path(home, key))
if state is None or state.get("current") is None:
print(f"codex-goal: no goal set for {key}. Run: codex_goal.py set <goal>", file=sys.stderr)
return 2
goal = state["current"]
passthrough = list(args.codex_args)
extra_prompt = None
if passthrough and not passthrough[-1].startswith("-"):
# Last bare token (if any) is the user's extra instruction for this turn;
# everything else is forwarded to `codex exec` untouched.
extra_prompt = passthrough.pop()
prompt = build_prompt(goal["text"], extra_prompt)
argv = [args.codex_bin, "exec"]
mode = "new"
session_arg = None
if args.resume or args.last:
argv.append("resume")
mode = "resume"
if args.last:
argv.append("--last")
session_arg = "last"
else:
argv.append(args.resume)
session_arg = args.resume
argv.extend(passthrough)
argv.append(prompt)
if args.dry_run:
print(json.dumps({"argv": argv, "mode": mode}, indent=2))
return 0
record = {
"ts": now_iso(),
"mode": mode,
"session_arg": session_arg,
"goal_id": goal["goal_id"],
"goal_preview": goal["text"][:120],
"argv": argv,
}
try:
proc = subprocess.Popen(argv, start_new_session=True)
except FileNotFoundError:
print(f"codex-goal: codex binary not found: {args.codex_bin}", file=sys.stderr)
record["exit_code"] = 127
append_jsonl(runlog_path(home, key), record)
return 127
def forward_signal(signum, _frame):
with contextlib.suppress(ProcessLookupError):
os.killpg(proc.pid, signum)
old_sigint = signal.signal(signal.SIGINT, forward_signal)
old_sigterm = signal.signal(signal.SIGTERM, forward_signal)
try:
exit_code = proc.wait()
finally:
signal.signal(signal.SIGINT, old_sigint)
signal.signal(signal.SIGTERM, old_sigterm)
record["exit_code"] = exit_code
append_jsonl(runlog_path(home, key), record)
return exit_code
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="codex_goal.py", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-C", "--project", default=os.getcwd(), help="project directory (default: cwd)")
p_set = sub.add_parser("set", parents=[common], help="save/replace the goal for this project")
p_set.add_argument("goal", nargs="+", help="goal text")
p_set.set_defaults(func=cmd_set)
p_show = sub.add_parser("show", parents=[common], help="print the current goal")
p_show.add_argument("--json", action="store_true")
p_show.add_argument("--history", action="store_true")
p_show.set_defaults(func=cmd_show)
p_run = sub.add_parser("run", parents=[common], help="spawn `codex exec` with the goal injected")
p_run.add_argument("--codex-bin", default=os.environ.get("CODEX_GOAL_BIN", "codex"))
resume_group = p_run.add_mutually_exclusive_group()
resume_group.add_argument("--resume", metavar="SESSION_ID", help="resume this session id")
resume_group.add_argument("--last", action="store_true", help="resume the most recent session")
p_run.add_argument("--dry-run", action="store_true", help="print the argv that would run, do not spawn")
p_run.add_argument("codex_args", nargs=argparse.REMAINDER, metavar="-- CODEX_ARGS...")
p_run.set_defaults(func=cmd_run)
args = parser.parse_args(argv)
if args.command == "run" and args.codex_args and args.codex_args[0] == "--":
args.codex_args = args.codex_args[1:]
return args.func(args)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Recorded, runnable demo for codex-goal.
Uses a disposable project dir, a disposable $CODEX_GOAL_HOME, and the
deterministic tests/fake_codex.py stand-in -- no network calls, no real
Codex account/auth needed, nothing billed. It demonstrates:
1. `set` then `show` (round trip).
2. Run 1: a fresh `codex exec`, goal injected, fake run exits 0.
3. Run 2: `--last` resume, goal injected again -- same goal survives
across the two separate process invocations (the thing upstream
closed as not planned).
4. A run that crashes (killed mid-flight): the stored goal is untouched
afterward, proving the wrapper never half-writes state.
Prints each command and its output, so this file doubles as demo.txt's
generator: `python3 demo.py > demo.txt`.
"""
import json
import os
import shlex
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
TOOL = os.path.join(HERE, "codex_goal.py")
FAKE_CODEX = os.path.join(HERE, "tests", "fake_codex.py")
def display(argv, project):
pretty = []
for a in argv:
if a == sys.executable:
pretty.append("python3")
elif a == TOOL:
pretty.append("codex_goal.py")
elif a == FAKE_CODEX:
pretty.append("tests/fake_codex.py")
elif a == project:
pretty.append("$PROJECT")
else:
pretty.append(a)
return " ".join(shlex.quote(a) for a in pretty)
def run(argv, env, label, project):
print(f"$ {display(argv, project)}")
proc = subprocess.run(argv, env=env, capture_output=True, text=True)
out = (proc.stdout + proc.stderr).rstrip()
if out:
print(out)
print(f"[exit {proc.returncode}] {label}\n")
return proc
def main() -> int:
os.chmod(FAKE_CODEX, 0o755)
with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as parent:
project = os.path.join(parent, "myapp")
os.makedirs(project)
env = dict(os.environ)
env["CODEX_GOAL_HOME"] = home
env.pop("FAKE_CODEX_SLEEP", None)
print("# 1. set + show")
run([sys.executable, TOOL, "set", "-C", project,
"Ship codex-goal's two-run persistence demo"], env, "goal saved", project)
run([sys.executable, TOOL, "show", "-C", project], env, "goal shown", project)
print("# 2. run 1 -- fresh session (fake codex, exit 0)")
env1 = dict(env, FAKE_CODEX_EXIT="0")
run([sys.executable, TOOL, "run", "-C", project,
"--codex-bin", FAKE_CODEX], env1, "run 1 done", project)
print("# 3. run 2 -- resume --last, in a *new* process, goal still there")
run([sys.executable, TOOL, "run", "-C", project, "--last",
"--codex-bin", FAKE_CODEX], env1, "run 2 done", project)
print("# 4. crash mid-run: kill -9 the wrapper while codex is 'working'")
env2 = dict(env, FAKE_CODEX_SLEEP="3")
proc = subprocess.Popen(
[sys.executable, TOOL, "run", "-C", project, "--codex-bin", FAKE_CODEX],
env=env2,
)
import time
time.sleep(0.4)
proc.kill()
proc.wait()
print(f"$ (background run) ; kill -9 {proc.pid}\n[killed] simulated crash\n")
print("# 5. show again -- goal survived the crash, byte for byte")
show = run([sys.executable, TOOL, "show", "-C", project, "--json"],
env, "goal after crash", project)
payload = json.loads(show.stdout)
assert payload["current"]["text"] == "Ship codex-goal's two-run persistence demo", (
"goal did not survive the crash"
)
print("ASSERTION PASSED: goal text identical before and after the crash.")
return 0
if __name__ == "__main__":
sys.exit(main())
+38
View File
@@ -0,0 +1,38 @@
# 1. set + show
$ python3 codex_goal.py set -C '$PROJECT' 'Ship codex-goal'"'"'s two-run persistence demo'
codex-goal: saved goal g-a2c8350ac309 for myapp-58cda05ecfff4ec2
[exit 0] goal saved
$ python3 codex_goal.py show -C '$PROJECT'
project myapp-58cda05ecfff4ec2
goal_id g-a2c8350ac309
updated 2026-10-10T20:16:17Z
text Ship codex-goal's two-run persistence demo
[exit 0] goal shown
# 2. run 1 -- fresh session (fake codex, exit 0)
$ python3 codex_goal.py run -C '$PROJECT' --codex-bin tests/fake_codex.py
[exit 0] run 1 done
# 3. run 2 -- resume --last, in a *new* process, goal still there
$ python3 codex_goal.py run -C '$PROJECT' --last --codex-bin tests/fake_codex.py
[exit 0] run 2 done
# 4. crash mid-run: kill -9 the wrapper while codex is 'working'
$ (background run) ; kill -9 35478
[killed] simulated crash
# 5. show again -- goal survived the crash, byte for byte
$ python3 codex_goal.py show -C '$PROJECT' --json
{
"current": {
"created_at": "2026-10-10T20:16:17Z",
"goal_id": "g-a2c8350ac309",
"text": "Ship codex-goal's two-run persistence demo",
"updated_at": "2026-10-10T20:16:17Z"
},
"project_key": "myapp-58cda05ecfff4ec2"
}
[exit 0] goal after crash
ASSERTION PASSED: goal text identical before and after the crash.
+134
View File
@@ -0,0 +1,134 @@
# codex-goal
A tiny wrapper that remembers one objective per project across separate
`codex exec` invocations, and injects it as plain user-context text every
time you run or resume. Inspired by
[openai/codex#26966: Persist goals across codex exec sessions](https://github.com/openai/codex/issues/26966),
closed as not planned. This is **external memory bolted onto the outside of
Codex**, not a native goal mode: Codex itself is never changed, and never
gets an API to mark the goal "done" or rewrite it.
## Problem
`codex exec` is stateless about *why* you're running it. Resume brings back
the transcript, but nothing re-states the objective, so a long-running or
multi-session task slowly drifts as context gets summarized away. Upstream
closed the feature request; this reconstructs it from outside the agent.
## Install
Requires Python 3.9+, a POSIX host (macOS/Linux — matches the board's hb-5
Mac and Colima container targets), and the `codex` CLI on `PATH` for real
use. No pip install, no dependencies:
```sh
cp codex_goal.py ~/bin/codex_goal.py # or run it in place
chmod +x codex_goal.py
```
## Usage
```sh
# Save (or replace) the objective for the current directory's project.
codex_goal.py set "Migrate the billing module to the new schema, keep tests green"
# Inspect it any time.
codex_goal.py show
codex_goal.py show --json --history
# Start a new codex exec session with the goal injected as user context.
codex_goal.py run
# Resume the most recent session -- goal is re-injected, not just the transcript.
codex_goal.py run --last
# Resume a specific session id, and forward real codex flags/an extra prompt.
codex_goal.py run --resume 3fae2b1e-... -- -s workspace-write "focus on the invoices table next"
```
Everything after `run`'s own flags is forwarded to `codex exec` (or
`codex exec resume ...`) untouched and in order — sandbox mode, model,
`-C`, etc. all still work exactly as they do calling `codex` directly.
A trailing bare argument (not starting with `-`) is treated as this turn's
extra instruction and appended after the injected goal; everything else is
passed straight through.
`-C DIR` (default: cwd) on any subcommand selects the project. The project
key is a hash of its realpath, so `set`/`show`/`run` keep working no matter
which subdirectory you're standing in, and two projects never collide.
## How persistence works
- State lives under `$CODEX_GOAL_HOME` (default `~/.codex-goal`), **outside**
whatever directory `codex exec`'s sandbox would let the agent write to.
Codex running in its default `workspace-write` sandbox cannot see or
modify its own goal file — `set` is a human-run command, never something
the wrapper calls on Codex's behalf.
- Goal records are versioned: each `set` pushes the previous value into a
capped history (50 entries) instead of overwriting it silently.
- Writes are atomic (`tempfile` + `os.replace`) and guarded by an exclusive
file lock (`fcntl.flock`) around the whole read-modify-write, so a crash
or a concurrent `set` can't leave a half-written or torn JSON file.
- `run` never modifies the goal. It only appends one line to a separate
run-log (`$CODEX_GOAL_HOME/runs/<key>.jsonl`) *after* the child process
exits, recording the mode, a goal preview, the exact argv, and the real
exit code. A run that crashes mid-flight (killed, SIGKILL, power loss)
leaves both the goal and the prior run-log lines untouched — there is no
"mark this goal complete" affordance for Codex or the wrapper to call, so
nothing can silently clear it.
- Codex is spawned via `subprocess.Popen([...])` with an argv list, never a
shell string — a goal containing `; rm -rf / #` is passed as one opaque
argv element, not executed. The child gets its own process group, and
`SIGINT`/`SIGTERM` sent to the wrapper are forwarded to it, so Ctrl-C
cancels a run the same way it would cancel `codex` directly. The
wrapper's own exit code is the child's exit code.
## Limits and trust
- One objective per project at a time. It is not a task list, not a
scheduler, and it does not talk to the Codex API directly — it only
shells out to the `codex` binary you already have configured and
authenticated.
- The injected text is plain, visible user context ("[codex-goal] Persisted
objective...") — it carries no special authority inside Codex's own
permission model. A model can still choose to ignore it, same as any
other instruction.
- No enforcement that the agent is *actually* working the stated goal; this
only guarantees the goal text is faithfully re-presented every run. It
does not inspect diffs, run checks, or grade progress — see
[finishline-supervisor](../finishline-supervisor/) for that kind of
evidence wrapper, which composes with this one but is a separate tool.
- `$CODEX_GOAL_HOME` is trusted local storage with your own file
permissions; this tool adds no encryption or extra access control. Don't
put secrets in goal text — it is written to disk in plain JSON.
- Locking is advisory (`flock`) and local-filesystem only; it does not work
over NFS/SMB and does not coordinate across machines.
- `run` passes through whatever `codex exec` flags you give it, including
`--dangerously-bypass-approvals-and-sandbox` if you ask for it — this
wrapper does not add or remove any safety behavior from Codex itself.
## Develop and verify
```sh
python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 demo.py > demo.txt # recorded, runnable, no network/auth needed
```
Tests and the demo use `tests/fake_codex.py`, a deterministic stand-in for
the real `codex` binary (records the exact argv it received, exits with a
configurable code, optionally sleeps so cancellation can be tested) — no
model calls, no API key, nothing billed, per the board's preference for
deterministic, cheap, inspectable checks over live model calls. [Recorded
demo output](demo.txt) covers: `set`/`show` round trip, run 1 (fresh
session), run 2 (`--last` resume, same goal), and a `kill -9` mid-run
followed by a `show` proving the goal survived byte-for-byte.
Verified: all 16 unit tests plus the demo on the local macOS (hb-5) host,
and inside the project's Colima Linux container (same Python 3, same
`tests/fake_codex.py`, no code changes needed — the tool has no
platform-specific dependency beyond POSIX `fcntl`/process groups). Real
`codex` binary integration (the actual CLI shown in Usage above) was
exercised manually against the installed `codex` CLI's `--help` output to
confirm the `exec`/`exec resume --last`/flag-forwarding argv shape this
wrapper builds matches the real CLI; it was not run against a live Codex
backend (that needs your own account/auth and would spend real requests).
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""codex-goal: persist a per-project objective across `codex exec` sessions.
External memory, not native Codex state: it stores the goal outside the
directory Codex is allowed to write to, and injects it as plain user-context
text on every `set`/`show`/`run` the human runs. Codex itself never sees a
"mark complete" affordance -- only `set` can change the stored goal, and
`set` is never invoked by codex-goal's own `run` command.
Usage:
codex_goal.py set [-C DIR] GOAL_TEXT...
codex_goal.py show [-C DIR] [--json] [--history]
codex_goal.py run [-C DIR] [--codex-bin PATH] [--resume ID | --last]
[--dry-run] [-- CODEX_ARGS... [PROMPT]]
"""
from __future__ import annotations
import argparse
import contextlib
import hashlib
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
import uuid
try:
import fcntl
except ImportError: # pragma: no cover - POSIX only, matches board brief hosts
fcntl = None
SCHEMA_VERSION = 1
MAX_GOAL_CHARS = 4000
MAX_HISTORY = 50
ENV_HOME = "CODEX_GOAL_HOME"
def state_home() -> str:
override = os.environ.get(ENV_HOME)
if override:
return override
return os.path.join(os.path.expanduser("~"), ".codex-goal")
def project_key(project_dir: str) -> str:
real = os.path.realpath(project_dir)
digest = hashlib.sha256(real.encode("utf-8")).hexdigest()[:16]
base = os.path.basename(real.rstrip("/")) or "root"
safe_base = "".join(c if c.isalnum() or c in "-_." else "-" for c in base)
return f"{safe_base}-{digest}"
def goal_path(home: str, key: str) -> str:
return os.path.join(home, "goals", f"{key}.json")
def runlog_path(home: str, key: str) -> str:
return os.path.join(home, "runs", f"{key}.jsonl")
def lock_path(home: str, key: str) -> str:
return os.path.join(home, "locks", f"{key}.lock")
@contextlib.contextmanager
def locked(home: str, key: str):
"""Exclusive file lock guarding read-modify-write of one project's state."""
path = lock_path(home, key)
os.makedirs(os.path.dirname(path), exist_ok=True)
fh = open(path, "a+")
try:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
yield
finally:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
fh.close()
def atomic_write_json(path: str, payload: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "w") as fh:
json.dump(payload, fh, indent=2, sort_keys=True)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path) # atomic on POSIX: a reader never sees a partial file
except Exception:
with contextlib.suppress(OSError):
os.remove(tmp)
raise
def append_jsonl(path: str, record: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
line = json.dumps(record, sort_keys=True) + "\n"
with open(path, "a") as fh:
fh.write(line)
fh.flush()
os.fsync(fh.fileno())
def now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def load_state(path: str) -> dict | None:
if not os.path.exists(path):
return None
with open(path) as fh:
return json.load(fh)
def cmd_set(args: argparse.Namespace) -> int:
text = " ".join(args.goal).strip()
if not text:
print("codex-goal: refusing an empty goal", file=sys.stderr)
return 2
if len(text) > MAX_GOAL_CHARS:
print(f"codex-goal: goal exceeds {MAX_GOAL_CHARS} characters", file=sys.stderr)
return 2
home = state_home()
key = project_key(args.project)
path = goal_path(home, key)
with locked(home, key):
state = load_state(path)
ts = now_iso()
if state is None:
state = {
"schema_version": SCHEMA_VERSION,
"project": {"path": os.path.realpath(args.project), "key": key},
"current": None,
"history": [],
}
if state["current"] is not None:
state["history"].append(state["current"])
state["history"] = state["history"][-MAX_HISTORY:]
state["current"] = {
"goal_id": f"g-{uuid.uuid4().hex[:12]}",
"text": text,
"created_at": ts,
"updated_at": ts,
}
atomic_write_json(path, state)
print(f"codex-goal: saved goal {state['current']['goal_id']} for {key}")
return 0
def cmd_show(args: argparse.Namespace) -> int:
home = state_home()
key = project_key(args.project)
state = load_state(goal_path(home, key))
if state is None or state.get("current") is None:
if args.json:
print(json.dumps({"project_key": key, "current": None}))
else:
print(f"codex-goal: no goal set for {key}. Run: codex_goal.py set <goal>")
return 1
if args.json:
payload = {"project_key": key, "current": state["current"]}
if args.history:
payload["history"] = state["history"]
print(json.dumps(payload, indent=2, sort_keys=True))
else:
cur = state["current"]
print(f"project {key}")
print(f"goal_id {cur['goal_id']}")
print(f"updated {cur['updated_at']}")
print(f"text {cur['text']}")
if args.history and state["history"]:
print("\nhistory (oldest first):")
for h in state["history"]:
print(f" - [{h['updated_at']}] {h['goal_id']}: {h['text']}")
return 0
INJECT_HEADER = "[codex-goal] Persisted objective for this project (external memory, injected by a wrapper -- not native Codex state):"
INJECT_FOOTER = "[/codex-goal] Continue working toward the objective above unless the user says otherwise."
def build_prompt(goal_text: str, extra_prompt: str | None) -> str:
block = f"{INJECT_HEADER}\n{goal_text}\n{INJECT_FOOTER}"
if extra_prompt:
return f"{block}\n\n{extra_prompt}"
return block
def cmd_run(args: argparse.Namespace) -> int:
home = state_home()
key = project_key(args.project)
state = load_state(goal_path(home, key))
if state is None or state.get("current") is None:
print(f"codex-goal: no goal set for {key}. Run: codex_goal.py set <goal>", file=sys.stderr)
return 2
goal = state["current"]
passthrough = list(args.codex_args)
extra_prompt = None
if passthrough and not passthrough[-1].startswith("-"):
# Last bare token (if any) is the user's extra instruction for this turn;
# everything else is forwarded to `codex exec` untouched.
extra_prompt = passthrough.pop()
prompt = build_prompt(goal["text"], extra_prompt)
argv = [args.codex_bin, "exec"]
mode = "new"
session_arg = None
if args.resume or args.last:
argv.append("resume")
mode = "resume"
if args.last:
argv.append("--last")
session_arg = "last"
else:
argv.append(args.resume)
session_arg = args.resume
argv.extend(passthrough)
argv.append(prompt)
if args.dry_run:
print(json.dumps({"argv": argv, "mode": mode}, indent=2))
return 0
record = {
"ts": now_iso(),
"mode": mode,
"session_arg": session_arg,
"goal_id": goal["goal_id"],
"goal_preview": goal["text"][:120],
"argv": argv,
}
try:
proc = subprocess.Popen(argv, start_new_session=True)
except FileNotFoundError:
print(f"codex-goal: codex binary not found: {args.codex_bin}", file=sys.stderr)
record["exit_code"] = 127
append_jsonl(runlog_path(home, key), record)
return 127
def forward_signal(signum, _frame):
with contextlib.suppress(ProcessLookupError):
os.killpg(proc.pid, signum)
old_sigint = signal.signal(signal.SIGINT, forward_signal)
old_sigterm = signal.signal(signal.SIGTERM, forward_signal)
try:
exit_code = proc.wait()
finally:
signal.signal(signal.SIGINT, old_sigint)
signal.signal(signal.SIGTERM, old_sigterm)
record["exit_code"] = exit_code
append_jsonl(runlog_path(home, key), record)
return exit_code
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="codex_goal.py", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-C", "--project", default=os.getcwd(), help="project directory (default: cwd)")
p_set = sub.add_parser("set", parents=[common], help="save/replace the goal for this project")
p_set.add_argument("goal", nargs="+", help="goal text")
p_set.set_defaults(func=cmd_set)
p_show = sub.add_parser("show", parents=[common], help="print the current goal")
p_show.add_argument("--json", action="store_true")
p_show.add_argument("--history", action="store_true")
p_show.set_defaults(func=cmd_show)
p_run = sub.add_parser("run", parents=[common], help="spawn `codex exec` with the goal injected")
p_run.add_argument("--codex-bin", default=os.environ.get("CODEX_GOAL_BIN", "codex"))
resume_group = p_run.add_mutually_exclusive_group()
resume_group.add_argument("--resume", metavar="SESSION_ID", help="resume this session id")
resume_group.add_argument("--last", action="store_true", help="resume the most recent session")
p_run.add_argument("--dry-run", action="store_true", help="print the argv that would run, do not spawn")
p_run.add_argument("codex_args", nargs=argparse.REMAINDER, metavar="-- CODEX_ARGS...")
p_run.set_defaults(func=cmd_run)
args = parser.parse_args(argv)
if args.command == "run" and args.codex_args and args.codex_args[0] == "--":
args.codex_args = args.codex_args[1:]
return args.func(args)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+38
View File
@@ -0,0 +1,38 @@
# 1. set + show
$ python3 codex_goal.py set -C '$PROJECT' 'Ship codex-goal'"'"'s two-run persistence demo'
codex-goal: saved goal g-a2c8350ac309 for myapp-58cda05ecfff4ec2
[exit 0] goal saved
$ python3 codex_goal.py show -C '$PROJECT'
project myapp-58cda05ecfff4ec2
goal_id g-a2c8350ac309
updated 2026-10-10T20:16:17Z
text Ship codex-goal's two-run persistence demo
[exit 0] goal shown
# 2. run 1 -- fresh session (fake codex, exit 0)
$ python3 codex_goal.py run -C '$PROJECT' --codex-bin tests/fake_codex.py
[exit 0] run 1 done
# 3. run 2 -- resume --last, in a *new* process, goal still there
$ python3 codex_goal.py run -C '$PROJECT' --last --codex-bin tests/fake_codex.py
[exit 0] run 2 done
# 4. crash mid-run: kill -9 the wrapper while codex is 'working'
$ (background run) ; kill -9 35478
[killed] simulated crash
# 5. show again -- goal survived the crash, byte for byte
$ python3 codex_goal.py show -C '$PROJECT' --json
{
"current": {
"created_at": "2026-10-10T20:16:17Z",
"goal_id": "g-a2c8350ac309",
"text": "Ship codex-goal's two-run persistence demo",
"updated_at": "2026-10-10T20:16:17Z"
},
"project_key": "myapp-58cda05ecfff4ec2"
}
[exit 0] goal after crash
ASSERTION PASSED: goal text identical before and after the crash.
+95
View File
@@ -0,0 +1,95 @@
Copyright (c) 2017, keshikan (http://www.keshikan.net),
with Reserved Font Name "DSEG".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+345
View File
@@ -0,0 +1,345 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="codex-goal: a small wrapper that remembers one objective per project across separate codex exec runs, and injects it back in every time. Free CLI, no account.">
<title>codex-goal · notplanned</title>
<style>
@font-face{
font-family:"DSEG7";
src:url("fonts/DSEG7Classic-Bold.woff2") format("woff2");
font-weight:700;font-style:normal;font-display:swap;
}
:root{
--court:#0a0e14; --court-2:#0f151e;
--panel:#141b26; --panel-line:#232e3f;
--ink:#eef3fa; --mute:#8a97ab;
--amber:#ffb400; --amber-dim:#5a4418;
--green:#28e07a; --green-dim:#123321;
--red:#ff4d5e; --red-dim:#3a1218;
--blue:#39c3ff; --blue-dim:#0f2c3a;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--court);color:var(--ink);overflow-x:hidden}
body{
font-family:ui-sans-serif,system-ui,"Helvetica Neue",Arial,sans-serif;
background-image:
radial-gradient(900px 420px at 15% -8%, rgba(255,180,0,.08), transparent),
radial-gradient(900px 420px at 110% 10%, rgba(57,195,255,.08), transparent),
repeating-linear-gradient(0deg, rgba(255,255,255,.015) 0 1px, transparent 1px 34px),
var(--court);
}
a{color:inherit;text-decoration-color:var(--mute)}
.wrap{width:min(1040px,calc(100% - 28px));margin:0 auto}
.tag{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
.led{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 10px var(--green);animation:blink 1.6s step-end infinite}
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:.25}}
@media (prefers-reduced-motion:reduce){.led{animation:none}}
header.bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 14px;padding:18px 0;border-bottom:1px solid var(--panel-line)}
header.bar .brand{font-weight:900;letter-spacing:-.02em;font-size:15px;text-transform:uppercase}
header.bar .brand span{color:var(--amber)}
header.bar .ticker{display:flex;align-items:center;gap:8px;max-width:100%;overflow-wrap:anywhere}
.marquee{padding:52px 0 18px}
.kicker{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--panel-line);background:var(--panel);border-radius:999px;padding:7px 14px;font-size:11.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute);margin-bottom:22px;max-width:100%;overflow-wrap:anywhere}
h1.title{
font-size:clamp(38px,6.6vw,74px);line-height:.98;letter-spacing:-.03em;margin:0 0 20px;
text-transform:uppercase;font-weight:900;
}
h1.title .hl{color:var(--court);background:var(--amber);padding:0 .12em;box-decoration-break:clone;-webkit-box-decoration-break:clone}
h1.title .hl2{color:var(--court);background:var(--green);padding:0 .12em;box-decoration-break:clone;-webkit-box-decoration-break:clone}
p.lead{max-width:640px;font-size:clamp(16px,2vw,19px);line-height:1.5;color:#c7d0dd;margin:0 0 30px}
p.lead code{background:var(--panel);border:1px solid var(--panel-line);border-radius:5px;padding:1px 6px;font-size:.92em}
.scorerow{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:0 0 34px}
.scorecell{background:#000;border:2px solid var(--panel-line);border-radius:10px;padding:14px 10px;text-align:center;position:relative;overflow:hidden}
.scorecell::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(180deg,rgba(255,255,255,.03) 0 2px,transparent 2px 4px);pointer-events:none}
.scorecell .digits{font-family:"DSEG7",ui-monospace,monospace;font-weight:700;font-size:clamp(26px,4.2vw,40px);letter-spacing:.04em;line-height:1}
.scorecell.amber .digits{color:var(--amber);text-shadow:0 0 14px rgba(255,180,0,.65)}
.scorecell.green .digits{color:var(--green);text-shadow:0 0 14px rgba(40,224,122,.65)}
.scorecell.blue .digits{color:var(--blue);text-shadow:0 0 14px rgba(57,195,255,.65)}
.scorecell .caption{margin-top:8px;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
.panel{background:var(--panel);border:1px solid var(--panel-line);border-radius:16px}
.join-panel{padding:26px;margin-bottom:46px;border-top:4px solid var(--amber)}
.join-panel .label{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--amber);font-weight:800;margin-bottom:12px}
form.join{display:grid;grid-template-columns:1.3fr 1fr auto;gap:10px;align-items:start}
.field{display:flex;flex-direction:column;gap:5px;min-width:0}
.field label{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
input[type=email],input[type=text],select{
width:100%;font-family:inherit;font-size:14px;padding:11px 12px;border-radius:9px;
border:1px solid var(--panel-line);background:var(--court-2);color:var(--ink);
}
input::placeholder{color:#55606f}
.note-row{grid-column:1/-1}
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
button.cta{
align-self:end;padding:12px 20px;border-radius:9px;border:0;background:var(--amber);color:#201400;
font-weight:800;letter-spacing:.03em;font-size:14px;cursor:pointer;white-space:nowrap;
}
button.cta:hover{background:#ffc83d}
.fineprint{font-size:11.5px;color:var(--mute);margin:12px 0 0;grid-column:1/-1}
.thanks{display:none;font-size:15px;font-weight:700;color:var(--green)}
.thanks small{display:block;margin-top:6px;font-weight:400;color:var(--mute);font-size:12px}
.join-panel.joined form.join,.join-panel.joined .label{display:none}
.join-panel.joined .thanks{display:block}
section{margin:54px 0}
h2.h{font-size:clamp(22px,3vw,30px);text-transform:uppercase;letter-spacing:-.01em;font-weight:900;margin:0 0 6px}
p.sub{color:var(--mute);font-size:14.5px;margin:0 0 22px;max-width:620px}
.matchup{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.team{border-radius:14px;padding:20px;border:1px solid var(--panel-line)}
.team.lose{background:var(--red-dim);border-color:#5c1f29}
.team.win{background:var(--green-dim);border-color:#1d5a3b}
.team .score{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px}
.team .name{font-weight:900;text-transform:uppercase;font-size:13.5px;letter-spacing:.05em}
.team.lose .name{color:#ff8a94}
.team.win .name{color:#7cf0ad}
.team .result{font-weight:900;font-size:13px;letter-spacing:.08em;padding:3px 9px;border-radius:5px}
.team.lose .result{color:var(--red);background:rgba(255,77,94,.15);border:1px solid #5c1f29}
.team.win .result{color:var(--green);background:rgba(40,224,122,.15);border:1px solid #1d5a3b}
.team pre{margin:0;background:#000;border:1px solid rgba(255,255,255,.08);border-radius:9px;padding:12px;font-size:12.3px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word;color:#d9e2ee}
.team p.cap{font-size:11.5px;color:var(--mute);margin:10px 0 0}
.replay{position:relative;border-radius:16px;overflow:hidden;border:1px solid var(--panel-line);background:#05070a}
.replay .crt-head{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--court-2);border-bottom:1px solid var(--panel-line);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)}
.replay .crt-head .dot{width:9px;height:9px;border-radius:50%}
.replay .crt-head .dot.r{background:var(--red)}.replay .crt-head .dot.y{background:var(--amber)}.replay .crt-head .dot.g{background:var(--green)}
.replay pre{margin:0;padding:18px;font-size:12.3px;line-height:1.6;overflow:auto;white-space:pre-wrap;word-break:break-word;color:#bfe8c9;max-height:420px}
.replay pre .fx{color:var(--amber)}
.replay::after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(180deg,rgba(255,255,255,.025) 0 1px,transparent 1px 3px);pointer-events:none}
ul.rules{margin:0;padding-left:0;list-style:none;display:grid;gap:10px}
ul.rules li{display:flex;gap:10px;padding:12px 14px;background:var(--panel);border:1px solid var(--panel-line);border-radius:10px;font-size:13.8px;line-height:1.5;color:#d4dce7}
ul.rules li .flag{flex:0 0 auto;font-weight:900;color:var(--red);font-size:11px;letter-spacing:.06em;text-transform:uppercase;border:1px solid #5c1f29;background:var(--red-dim);padding:2px 7px;border-radius:5px;height:fit-content}
ul.rules li .desc{flex:1 1 auto;min-width:0}
ul.howitworks{margin:0;padding-left:0;list-style:none;display:grid;gap:9px}
ul.howitworks li{display:flex;gap:10px;align-items:flex-start;font-size:14px;line-height:1.55;color:#d4dce7}
ul.howitworks li .pt{flex:0 0 auto;font-family:"DSEG7",ui-monospace,monospace;color:var(--green);font-size:15px;min-width:30px}
ul.howitworks li .desc{flex:1 1 auto;min-width:0}
.passes{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.pass{border-radius:14px;padding:22px;border:1px solid var(--panel-line);background:var(--panel);display:flex;flex-direction:column}
.pass .tier{font-weight:900;text-transform:uppercase;font-size:14px;letter-spacing:.04em;margin-bottom:6px}
.pass.free{border-top:4px solid var(--blue)}
.pass.early{border-top:4px solid var(--amber)}
.pass.team{border-top:4px solid var(--green)}
.pass .price{font-size:26px;margin-bottom:4px;font-weight:800}
.pass .price .cur{font-family:inherit}
.pass .price .num{font-family:"DSEG7",ui-monospace,monospace;font-weight:700}
.pass .price .per{font-family:inherit;font-size:14px;font-weight:600;color:var(--mute);margin-left:2px}
.pass.free .price .num{color:var(--blue)}
.pass.early .price .num{color:var(--amber)}
.pass.team .price .num{color:var(--green)}
.pass .for{color:var(--mute);font-size:12.5px;margin-bottom:12px}
.pass ul{margin:0 0 0;padding-left:16px;font-size:13.3px;line-height:1.65;color:#d4dce7}
.pass .founding{margin-top:12px;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
footer.bar{border-top:1px solid var(--panel-line);padding:26px 0 44px;margin-top:60px}
footer.bar .links{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;color:var(--mute);margin-bottom:14px}
footer.bar .score-tape{display:flex;gap:2px;height:7px;margin-bottom:10px}
footer.bar .score-tape span{flex:1;background:var(--panel-line)}
footer.bar .score-tape span:nth-child(3n){background:var(--amber);opacity:.5}
footer.bar .fine{font-size:11px;color:#5c6779;line-height:1.6}
@media (max-width:760px){
form.join{grid-template-columns:1fr}
button.cta{width:100%}
.scorerow{grid-template-columns:1fr 1fr}
.matchup{grid-template-columns:1fr}
.passes{grid-template-columns:1fr}
}
@media (max-width:480px){
.marquee{padding:34px 0 10px}
.scorerow{grid-template-columns:1fr}
h1.title{font-size:clamp(30px,9.5vw,74px)}
}
</style>
</head>
<body>
<div class="wrap">
<header class="bar">
<div class="brand">NOTPLANNED <span>ARCADE</span></div>
<div class="ticker tag"><span class="led" aria-hidden="true"></span> VENTURE 004 · LIVE</div>
</header>
<main>
<section class="marquee">
<div class="kicker">upstream call: openai/codex#26966 · closed &middot; not planned</div>
<h1 class="title">don't let the agent forget <span class="hl">the&nbsp;goal.</span></h1>
<p class="lead"><code>codex exec</code> resumes a transcript but never restates <em>why</em>. codex&#8209;goal is a small wrapper that stores one objective per project outside Codex's own workspace, and hands it back as plain context on every new or resumed run &mdash; <span class="hl2">built anyway</span> after upstream closed the request.</p>
<div class="scorerow" aria-label="Recorded demo tally">
<div class="scorecell amber"><div class="digits">02</div><div class="caption">codex exec runs</div></div>
<div class="scorecell green"><div class="digits">01</div><div class="caption">goal, both runs</div></div>
<div class="scorecell blue"><div class="digits">100</div><div class="caption">% intact after kill -9</div></div>
</div>
<div class="panel join-panel" id="join">
<div class="label">&#9733; join the roster &mdash; be first when hosted tiers ship</div>
<form class="join" action="/api/signup" method="post" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="venture" value="codex-goal">
<input class="hp" type="text" name="website" value="" tabindex="-1" autocomplete="off" aria-hidden="true">
<div class="field">
<label for="email">email</label>
<input id="email" type="email" name="email" required placeholder="you@yourteam.dev">
</div>
<div class="field">
<label for="plan">plan</label>
<select id="plan" name="plan" required>
<option value="free">Free &mdash; just the CLI</option>
<option value="early_access">Early Access &mdash; $6/mo, founding price</option>
<option value="team">Team &mdash; $24/mo, founding price</option>
</select>
</div>
<button class="cta" type="submit">Draft me &rarr;</button>
<div class="note-row field">
<label for="note">note (optional)</label>
<input id="note" type="text" name="note" placeholder="what are you running codex on?">
</div>
</form>
<div class="thanks">&#10003; ON THE ROSTER &mdash; thanks, you're in.<small>We'll only email you about codex-goal. Never shared.</small></div>
<p class="fineprint">One line on your email: used only to reach you about codex-goal, never sold, never shared. The CLI below stays free no matter what you pick.</p>
</div>
</section>
<section>
<h2 class="h">the matchup</h2>
<p class="sub">Same two commands, two sessions apart. One remembers the assignment; one doesn't.</p>
<div class="matchup">
<div class="team lose">
<div class="score"><span class="name">Stock codex exec</span><span class="result">LOST</span></div>
<pre>$ codex exec "migrate billing to new schema"
&hellip; session ends, summarized &hellip;
$ codex exec resume --last "continue"
&gt; continuing. what's the task again?
(transcript survived. the <em>point</em> of it didn't.)</pre>
<p class="cap">Resume brings back messages, not the objective that framed them.</p>
</div>
<div class="team win">
<div class="score"><span class="name">codex&#8209;goal run</span><span class="result">WON</span></div>
<pre>$ codex_goal.py set "migrate billing to new schema"
$ codex_goal.py run
&gt; [codex-goal] persisted objective: migrate billing&hellip;
&hellip; session ends &hellip;
$ codex_goal.py run --last
&gt; [codex-goal] persisted objective: migrate billing&hellip;
(re-injected before Codex sees anything else.)</pre>
<p class="cap">Every new or resumed run gets the same goal text, re-stated, every time.</p>
</div>
</div>
</section>
<section>
<h2 class="h">instant replay</h2>
<p class="sub">Real, runnable output from <code>demo.py</code> &mdash; a disposable project dir, a disposable <code>$CODEX_GOAL_HOME</code>, and a deterministic stand-in for the real <code>codex</code> binary (no network, no account, nothing billed). Two separate process runs see the same goal; a <code>kill -9</code> mid-run doesn't touch it.</p>
<div class="replay">
<div class="crt-head"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span> demo.py &mdash; full output in demo.txt</div>
<pre><code>$ python3 codex_goal.py set -C $PROJECT 'Ship codex-goal&#39;s two-run persistence demo'
codex-goal: saved goal g-a2c8350ac309 for myapp-58cda05ecfff4ec2
[exit 0] goal saved
$ python3 codex_goal.py run -C $PROJECT --codex-bin tests/fake_codex.py
[exit 0] run 1 done <span class="fx">&larr; fresh session, goal injected</span>
$ python3 codex_goal.py run -C $PROJECT --last --codex-bin tests/fake_codex.py
[exit 0] run 2 done <span class="fx">&larr; resumed, SAME goal injected again</span>
$ (background run) ; kill -9 35478
[killed] simulated crash <span class="fx">&larr; mid-run, hard kill</span>
$ python3 codex_goal.py show -C $PROJECT --json
{
"current": {
"text": "Ship codex-goal's two-run persistence demo",
"goal_id": "g-a2c8350ac309"
}
}
ASSERTION PASSED: goal text identical before and after the crash.</code></pre>
</div>
<p class="fineprint" style="margin-top:12px">Full transcript: <a href="demo.txt">demo.txt</a> &middot; run it yourself: <a href="README.md">README</a></p>
</section>
<section>
<h2 class="h">how it scores</h2>
<ul class="howitworks">
<li><span class="pt">01</span> <span class="desc">Goal state lives under <code>~/.codex-goal</code> &mdash; outside the directory Codex's own sandbox lets it write to. <code>set</code> is a human command; the wrapper never calls it on Codex's behalf.</span></li>
<li><span class="pt">02</span> <span class="desc">Every <code>set</code> is versioned: the previous goal moves to a capped history instead of being silently overwritten.</span></li>
<li><span class="pt">03</span> <span class="desc">Writes are atomic (temp file + rename) under an exclusive file lock &mdash; a crash mid-write can't leave torn JSON.</span></li>
<li><span class="pt">04</span> <span class="desc"><code>run</code> never edits the goal. It only appends one line to a separate run-log, after the child exits, with the real exit code.</span></li>
<li><span class="pt">05</span> <span class="desc">Codex is spawned via an argv list, never a shell string &mdash; a goal containing <code>; rm -rf / #</code> is one opaque argument, not a command.</span></li>
<li><span class="pt">06</span> <span class="desc">Ctrl&#8209;C / SIGTERM sent to the wrapper is forwarded to the Codex child, so cancellation behaves exactly like calling <code>codex</code> directly. The wrapper's exit code is the child's.</span></li>
</ul>
</section>
<section>
<h2 class="h">game passes</h2>
<p class="sub">Free tier works offline, forever. Paid tiers are founding-price early access &mdash; there is no checkout yet.</p>
<div class="passes">
<div class="pass free">
<div class="tier">Free</div>
<div class="price"><span class="cur">$</span><span class="num">0</span></div>
<div class="for">anyone with their own Codex auth</div>
<ul><li>Full CLI, source, tests, demo</li><li>Unlimited local projects</li><li>No account, no telemetry</li></ul>
<div class="founding">forever free</div>
</div>
<div class="pass early">
<div class="tier">Early Access</div>
<div class="price"><span class="cur">$</span><span class="num">6</span><span class="per">/mo</span></div>
<div class="for">solo devs juggling several agent projects</div>
<ul><li>Hosted goal + run-log viewer</li><li>Cross-machine goal sync</li><li>Email digest on run failures</li></ul>
<div class="founding">founding price &middot; waitlist, not billed yet</div>
</div>
<div class="pass team">
<div class="tier">Team</div>
<div class="price"><span class="cur">$</span><span class="num">24</span><span class="per">/mo</span></div>
<div class="for">teams sharing Codex objectives across a repo</div>
<ul><li>Shared goals, per-teammate run history</li><li>Slack alert when a run fails</li><li>Audit log export</li></ul>
<div class="founding">founding price &middot; waitlist, not billed yet</div>
</div>
</div>
</section>
<section>
<h2 class="h">rules of the game</h2>
<ul class="rules">
<li><span class="flag">1 goal</span> <span class="desc">One objective per project at a time. Not a task list, not a scheduler, not a replacement for your issue tracker.</span></li>
<li><span class="flag">no force</span> <span class="desc">The injected text is plain, visible user context &mdash; it carries no special authority inside Codex. A model can still choose to ignore it, same as any other instruction.</span></li>
<li><span class="flag">no grading</span> <span class="desc">This only guarantees the goal is faithfully re-stated every run. It does not inspect diffs or grade progress &mdash; pair it with <a href="/v/finishline-supervisor/">finishline-supervisor</a> for that.</span></li>
<li><span class="flag">plain json</span> <span class="desc"><code>$CODEX_GOAL_HOME</code> is ordinary local storage with your file permissions, no encryption. Don't put secrets in goal text.</span></li>
<li><span class="flag">local lock</span> <span class="desc">Locking is advisory and local-filesystem only &mdash; it doesn't coordinate over NFS/SMB or across machines.</span></li>
<li><span class="flag">your flags</span> <span class="desc"><code>run</code> forwards whatever <code>codex exec</code> flags you give it, including dangerous ones if you ask for them. This wrapper adds or removes none of Codex's own safety behavior.</span></li>
</ul>
</section>
</main>
<footer class="bar">
<div class="score-tape" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="links">
<a href="codex_goal.py" download>download the free CLI &darr;</a>
<a href="https://github.com/openai/codex/issues/26966">upstream issue #26966</a>
<a href="README.md">full README</a>
<a href="/">notplanned</a>
</div>
<p class="fine">codex-goal is an independent wrapper around the <code>codex</code> CLI. Not affiliated with or endorsed by OpenAI. "Codex" is referenced only to describe compatibility. DSEG7 Classic font &copy; keshikan, SIL Open Font License 1.1 (<a href="fonts/OFL.txt">license</a>).</p>
</footer>
</div>
<script>
if(new URLSearchParams(location.search).get("joined")==="1"){
var j=document.getElementById("join");
if(j){j.classList.add("joined");}
}
</script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Deterministic stand-in for the real `codex` binary, used only by tests/demo.
Records the exact argv it received (proof there was no shell interpolation),
optionally sleeps so a test can send SIGINT/SIGTERM and check cancellation
is forwarded, then exits with a configurable code.
"""
import json
import os
import signal
import sys
import time
def main() -> int:
record_path = os.environ.get("FAKE_CODEX_RECORD")
if record_path:
with open(record_path, "w") as fh:
json.dump({"argv": sys.argv[1:], "pid": os.getpid()}, fh)
sleep_s = float(os.environ.get("FAKE_CODEX_SLEEP", "0"))
if sleep_s:
caught = {}
def handler(signum, _frame):
caught["signal"] = signum
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGTERM, handler)
end = time.time() + sleep_s
while time.time() < end and "signal" not in caught:
time.sleep(0.02)
if "signal" in caught:
return 130
return int(os.environ.get("FAKE_CODEX_EXIT", "0"))
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,248 @@
import contextlib
import io
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
FAKE_CODEX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fake_codex.py")
import codex_goal # noqa: E402
class TempHome(unittest.TestCase):
def setUp(self):
self._home_dir = tempfile.TemporaryDirectory()
self._project_dir = tempfile.TemporaryDirectory()
os.environ["CODEX_GOAL_HOME"] = self._home_dir.name
os.chmod(FAKE_CODEX, 0o755)
def tearDown(self):
os.environ.pop("CODEX_GOAL_HOME", None)
self._home_dir.cleanup()
self._project_dir.cleanup()
def run_cli(self, *argv):
return codex_goal.main(list(argv))
@property
def project(self):
return self._project_dir.name
class SetShowTests(TempHome):
def test_set_then_show_roundtrip(self):
rc = self.run_cli("set", "-C", self.project, "Ship", "the", "thing")
self.assertEqual(rc, 0)
key = codex_goal.project_key(self.project)
state = codex_goal.load_state(codex_goal.goal_path(codex_goal.state_home(), key))
self.assertEqual(state["current"]["text"], "Ship the thing")
self.assertEqual(state["history"], [])
def test_show_without_goal_fails_clearly(self):
rc = self.run_cli("show", "-C", self.project)
self.assertEqual(rc, 1)
def test_second_set_pushes_history(self):
self.run_cli("set", "-C", self.project, "first goal")
self.run_cli("set", "-C", self.project, "second goal")
key = codex_goal.project_key(self.project)
state = codex_goal.load_state(codex_goal.goal_path(codex_goal.state_home(), key))
self.assertEqual(state["current"]["text"], "second goal")
self.assertEqual(len(state["history"]), 1)
self.assertEqual(state["history"][0]["text"], "first goal")
def test_empty_goal_rejected(self):
rc = self.run_cli("set", "-C", self.project, " ")
self.assertEqual(rc, 2)
def test_two_projects_get_independent_state(self):
with tempfile.TemporaryDirectory() as other:
self.run_cli("set", "-C", self.project, "goal A")
self.run_cli("set", "-C", other, "goal B")
key_a = codex_goal.project_key(self.project)
key_b = codex_goal.project_key(other)
self.assertNotEqual(key_a, key_b)
state_a = codex_goal.load_state(codex_goal.goal_path(codex_goal.state_home(), key_a))
state_b = codex_goal.load_state(codex_goal.goal_path(codex_goal.state_home(), key_b))
self.assertEqual(state_a["current"]["text"], "goal A")
self.assertEqual(state_b["current"]["text"], "goal B")
def test_state_lives_outside_project_dir(self):
self.run_cli("set", "-C", self.project, "stay external")
key = codex_goal.project_key(self.project)
path = codex_goal.goal_path(codex_goal.state_home(), key)
self.assertFalse(path.startswith(os.path.realpath(self.project)))
self.assertTrue(path.startswith(self._home_dir.name))
class RunDryRunTests(TempHome):
def test_run_without_goal_errors_and_does_not_spawn(self):
rc = self.run_cli("run", "-C", self.project, "--dry-run")
self.assertEqual(rc, 2)
def test_dry_run_new_session_injects_goal(self):
self.run_cli("set", "-C", self.project, "win the game")
rc = self.run_cli("run", "-C", self.project, "--codex-bin", "codex", "--dry-run")
self.assertEqual(rc, 0)
def test_dry_run_resume_last_builds_resume_argv(self):
self.run_cli("set", "-C", self.project, "resume goal")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = self.run_cli("run", "-C", self.project, "--last", "--dry-run")
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue())
self.assertEqual(payload["mode"], "resume")
self.assertIn("resume", payload["argv"])
self.assertIn("--last", payload["argv"])
def test_shell_metacharacters_survive_as_one_argv_item(self):
goal = "ship it; rm -rf / #not a shell command"
self.run_cli("set", "-C", self.project, goal)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
self.run_cli("run", "-C", self.project, "--dry-run")
payload = json.loads(buf.getvalue())
self.assertIn(goal, payload["argv"][-1])
# the dangerous text is one argv element, never concatenated into a command string
self.assertEqual(sum(goal in item for item in payload["argv"]), 1)
class RunSpawnTests(TempHome):
def setUp(self):
super().setUp()
self._record = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
self._record.close()
os.environ["FAKE_CODEX_RECORD"] = self._record.name
def tearDown(self):
os.environ.pop("FAKE_CODEX_RECORD", None)
os.environ.pop("FAKE_CODEX_EXIT", None)
os.environ.pop("FAKE_CODEX_SLEEP", None)
with contextlib.suppress(OSError):
os.remove(self._record.name)
super().tearDown()
def test_run_spawns_fake_codex_injects_goal_and_logs(self):
self.run_cli("set", "-C", self.project, "beat the deadline")
os.environ["FAKE_CODEX_EXIT"] = "0"
rc = self.run_cli("run", "-C", self.project, "--codex-bin", FAKE_CODEX)
self.assertEqual(rc, 0)
with open(self._record.name) as fh:
recorded = json.load(fh)
self.assertEqual(recorded["argv"][0], "exec")
self.assertIn("beat the deadline", recorded["argv"][-1])
self.assertIn(codex_goal.INJECT_HEADER, recorded["argv"][-1])
key = codex_goal.project_key(self.project)
log_path = codex_goal.runlog_path(codex_goal.state_home(), key)
with open(log_path) as fh:
lines = [json.loads(l) for l in fh if l.strip()]
self.assertEqual(len(lines), 1)
self.assertEqual(lines[0]["mode"], "new")
self.assertEqual(lines[0]["exit_code"], 0)
def test_run_preserves_nonzero_exit_status(self):
self.run_cli("set", "-C", self.project, "goal")
os.environ["FAKE_CODEX_EXIT"] = "17"
rc = self.run_cli("run", "-C", self.project, "--codex-bin", FAKE_CODEX)
self.assertEqual(rc, 17)
def test_two_runs_both_see_the_same_persisted_goal(self):
self.run_cli("set", "-C", self.project, "persist across sessions")
os.environ["FAKE_CODEX_EXIT"] = "0"
self.run_cli("run", "-C", self.project, "--codex-bin", FAKE_CODEX)
with open(self._record.name) as fh:
first = json.load(fh)
self.run_cli("run", "-C", self.project, "--last", "--codex-bin", FAKE_CODEX)
with open(self._record.name) as fh:
second = json.load(fh)
self.assertIn("persist across sessions", first["argv"][-1])
self.assertIn("persist across sessions", second["argv"][-1])
self.assertIn("resume", second["argv"])
key = codex_goal.project_key(self.project)
log_path = codex_goal.runlog_path(codex_goal.state_home(), key)
with open(log_path) as fh:
lines = [json.loads(l) for l in fh if l.strip()]
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0]["mode"], "new")
self.assertEqual(lines[1]["mode"], "resume")
def test_failed_run_does_not_touch_the_goal(self):
self.run_cli("set", "-C", self.project, "do not corrupt me")
os.environ["FAKE_CODEX_EXIT"] = "1"
self.run_cli("run", "-C", self.project, "--codex-bin", FAKE_CODEX)
key = codex_goal.project_key(self.project)
state = codex_goal.load_state(codex_goal.goal_path(codex_goal.state_home(), key))
self.assertEqual(state["current"]["text"], "do not corrupt me")
def test_sigint_is_forwarded_and_cancels_child(self):
self.run_cli("set", "-C", self.project, "long running")
os.environ["FAKE_CODEX_SLEEP"] = "5"
child_argv = [
sys.executable,
os.path.join(ROOT, "codex_goal.py"),
"run",
"-C",
self.project,
"--codex-bin",
FAKE_CODEX,
]
env = dict(os.environ)
proc = subprocess.Popen(child_argv, env=env)
time.sleep(0.5)
proc.send_signal(signal.SIGINT)
rc = proc.wait(timeout=5)
self.assertEqual(rc, 130)
class AtomicWriteTests(TempHome):
def test_failed_write_leaves_existing_state_intact(self):
self.run_cli("set", "-C", self.project, "original goal")
key = codex_goal.project_key(self.project)
path = codex_goal.goal_path(codex_goal.state_home(), key)
with open(path) as fh:
before = fh.read()
with self.assertRaises(RuntimeError):
with codex_goal.locked(codex_goal.state_home(), key):
state = codex_goal.load_state(path)
state["current"]["text"] = "corrupted"
real_dump = json.dump
def exploding_dump(*a, **k):
raise RuntimeError("simulated crash mid-write")
json.dump = exploding_dump
try:
codex_goal.atomic_write_json(path, state)
finally:
json.dump = real_dump
with open(path) as fh:
after = fh.read()
self.assertEqual(before, after)
self.assertNotIn("corrupted", after)
if __name__ == "__main__":
unittest.main()