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:
1 parent
04efde8b6f
commit
70b7a8bbcd
13 files changed
+1781
No files matched your search
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -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).
|
||||
Executable
+302
@@ -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:]))
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Executable
+302
@@ -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:]))
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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.
|
||||
@@ -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 · not planned</div>
|
||||
<h1 class="title">don't let the agent forget <span class="hl">the goal.</span></h1>
|
||||
<p class="lead"><code>codex exec</code> resumes a transcript but never restates <em>why</em>. codex‑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 — <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">★ join the roster — 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 — just the CLI</option>
|
||||
<option value="early_access">Early Access — $6/mo, founding price</option>
|
||||
<option value="team">Team — $24/mo, founding price</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="cta" type="submit">Draft me →</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">✓ ON THE ROSTER — 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"
|
||||
… session ends, summarized …
|
||||
|
||||
$ codex exec resume --last "continue"
|
||||
> 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‑goal run</span><span class="result">WON</span></div>
|
||||
<pre>$ codex_goal.py set "migrate billing to new schema"
|
||||
$ codex_goal.py run
|
||||
> [codex-goal] persisted objective: migrate billing…
|
||||
|
||||
… session ends …
|
||||
|
||||
$ codex_goal.py run --last
|
||||
> [codex-goal] persisted objective: migrate billing…
|
||||
(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> — 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 — full output in demo.txt</div>
|
||||
<pre><code>$ 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 run -C $PROJECT --codex-bin tests/fake_codex.py
|
||||
[exit 0] run 1 done <span class="fx">← 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">← resumed, SAME goal injected again</span>
|
||||
|
||||
$ (background run) ; kill -9 35478
|
||||
[killed] simulated crash <span class="fx">← 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> · 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> — 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 — 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 — 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‑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 — 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 · 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 · 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 — 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 — 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 — 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 ↓</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 © 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>
|
||||
Executable
+40
@@ -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()
|
||||
Reference in new issue
Block a user