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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions adapters/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,32 @@ yourself.
The skill bodies are identical to the claude-code slash commands
(enforced by a sync test), so the flows behave the same on every host.

## Automatic session capture (T4)

`vouch install-mcp codex` (default tier T4) wires automatic capture
through codex's hooks system: `.codex/hooks.json` registers a `Stop`
hook that runs `vouch capture ingest-codex --hook` when a turn
completes. The handler reads the hook payload, resolves the session's
rollout file, and rolls it into ONE pending session-summary proposal —
the same review-gated summary a claude-code session produces. Because
`Stop` fires per turn, re-ingest is idempotent: an unchanged session
is a no-op, a session that grew refreshes its pending proposal in
place, and a proposal you've already reviewed is never resurrected.

Failure semantics match `capture observe`: the `--hook` mode exits 0
no matter what, so a capture problem can never break your codex turn.
Nothing is auto-approved — review with `vouch review`.

Hooks merge, not clobber: if you already have a `.codex/hooks.json`,
the installer deep-merges the vouch hook in next to yours (re-runs
don't duplicate it). Project-local hooks run in trusted projects only,
so trust the project when codex asks.

Why not codex's `notify` setting: codex honours `notify` only in
user-global config (`~/.codex/config.toml`), which a project-scoped
install never touches. If you prefer notify anyway, point it at a
wrapper that calls `vouch capture ingest-codex --hook` yourself.

## Notes

- Codex respects MCP tool naming verbatim, so the tools appear as
Expand Down
15 changes: 15 additions & 0 deletions adapters/codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "vouch capture ingest-codex --hook",
"statusMessage": "vouch capture"
}
]
}
]
}
}
11 changes: 11 additions & 0 deletions adapters/codex/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ tiers:
- { src: ../openclaw/skills/vouch-record/SKILL.md, dst: .codex/skills/vouch-record/SKILL.md }
- { src: ../openclaw/skills/vouch-followup/SKILL.md, dst: .codex/skills/vouch-followup/SKILL.md }
- { src: ../openclaw/skills/vouch-standup/SKILL.md, dst: .codex/skills/vouch-standup/SKILL.md }
# T4 = automatic session capture -- see issue #388. codex's hooks system
# fires Stop when a turn completes; the handler re-ingests the session's
# rollout idempotently (`vouch capture ingest-codex --hook` exits 0 even
# on failure, so capture can never break a codex turn), updating the
# session's single PENDING summary proposal as the session grows.
# hooks live in their own `.codex/hooks.json` file (project-local in
# trusted projects) and json_merge preserves any hooks the user already
# has. note: the legacy `notify` setting can't be used here -- codex only
# honours it in user-global config, which the #179 rule forbids touching.
T4:
- { src: hooks.json, dst: .codex/hooks.json, json_merge: true }
34 changes: 28 additions & 6 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2079,22 +2079,44 @@ def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) ->
"--latest", is_flag=True,
help="Resolve the newest codex rollout recorded for this project (by cwd).",
)
@click.option(
"--hook", "hook_mode", is_flag=True,
help="Read a codex Stop-hook payload from stdin; never fails the host "
"(exits 0 even on errors, like `capture observe`).",
)
@click.option(
"--codex-home", type=click.Path(file_okay=False, path_type=Path), default=None,
help="Codex state dir holding sessions/ (default: $CODEX_HOME or ~/.codex).",
)
def capture_ingest_codex_cmd(
rollout: Path | None, latest: bool, codex_home: Path | None
rollout: Path | None, latest: bool, hook_mode: bool, codex_home: Path | None
) -> None:
"""Ingest one codex session rollout into a PENDING summary proposal.

Codex has no live hook stream; it persists each session as a rollout
jsonl instead. This maps the rollout's tool calls into the same
observation shape `capture observe` produces and reuses the existing
rollup, so the result is the same review-gated summary a claude
session yields. Re-ingesting a session is a no-op; review with
Codex has no live notify stream vouch can use project-locally; it
persists each session as a rollout jsonl instead. This maps the
rollout's tool calls into the same observation shape `capture observe`
produces and reuses the existing rollup, so the result is the same
review-gated summary a claude session yields. Re-ingesting an
unchanged session is a no-op; a session that grew since the last
ingest refreshes its one PENDING proposal in place. Review with
`vouch review`.
"""
if hook_mode:
# Hook wire (codex `Stop` event): parse the stdin payload, resolve
# the session's rollout, ingest idempotently. Exits 0 no matter
# what — capture must never break the user's codex turn.
try:
raw = "" if sys.stdin.isatty() else sys.stdin.read()
payload = json.loads(raw) if raw.strip() else {}
if isinstance(payload, dict):
codex_rollout_mod.ingest_hook_payload(
_capture_store(), payload, codex_home=codex_home
)
except Exception:
# the hook contract is exit 0 — never surface an error here.
pass
return
if (rollout is None) == (not latest):
raise click.ClickException("pass exactly one of ROLLOUT or --latest")
store = _load_store()
Expand Down
119 changes: 110 additions & 9 deletions src/vouch/codex_rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
from pathlib import Path
from typing import Any

from . import capture
from . import audit, capture
from .models import Proposal, ProposalStatus
from .proposals import propose_page
from .storage import KBStore

Expand Down Expand Up @@ -297,14 +298,46 @@ def find_latest_rollout(cwd: Path, *, codex_home: Path | None = None) -> Path |
return best


def find_existing_proposal(store: KBStore, session_id: str) -> str | None:
"""Id of any proposal (any status) already filed for this session."""
def find_existing_proposal(store: KBStore, session_id: str) -> Proposal | None:
"""Any proposal (any status) already filed for this session."""
for proposal in store.list_proposals(None):
if proposal.session_id == session_id:
return proposal.id
return proposal
return None


def find_rollout_by_session_id(
session_id: str, *, codex_home: Path | None = None
) -> Path | None:
"""The rollout file for one session id — codex embeds the id in the
filename (``rollout-<stamp>-<uuid>.jsonl``), so no file needs opening.

The session id comes from a hook payload, so it's matched as a literal
filename suffix rather than interpolated into the glob pattern: a
payload carrying glob metacharacters (``*``, ``?``, ``[``) can't widen
the search or change its semantics.
"""
sessions = (codex_home or default_codex_home()) / "sessions"
if not sessions.is_dir() or not session_id.strip():
return None
suffix = f"-{session_id}.jsonl"
best: Path | None = None
for path in sessions.rglob("rollout-*.jsonl"):
if not path.name.endswith(suffix):
continue
if best is None or path.name > best.name:
best = path
return best


def _comparable_body(body: str) -> str:
"""The summary body minus its generation timestamp, so re-ingesting an
unchanged rollout compares equal across runs."""
return "\n".join(
line for line in body.splitlines() if not line.startswith("- generated:")
)


def ingest_rollout(
store: KBStore,
path: Path,
Expand All @@ -316,8 +349,12 @@ def ingest_rollout(

Honours the same ``capture:`` config as live capture (``enabled``,
``min_observations``) so the two front doors gate identically, and
dedups on the rollout's session id: re-ingesting reports the existing
proposal instead of filing a second one.
dedups on the rollout's session id: at most one proposal per session,
ever. Because codex's Stop hook fires per *turn* rather than at session
end, re-ingesting a session that grew since the last ingest refreshes
the still-PENDING proposal in place (same id, updated summary) instead
of filing a duplicate; an unchanged rollout is a flat no-op, and a
decided proposal is history — it blocks re-ingest regardless.
"""
cfg = capture.load_config(store)
session = parse_rollout(path)
Expand All @@ -330,11 +367,11 @@ def ingest_rollout(
}

existing = find_existing_proposal(store, session.session_id)
if existing is not None:
if existing is not None and existing.status != ProposalStatus.PENDING:
return {
"session_id": session.session_id,
"captured": len(session.observations),
"summary_proposal_id": existing,
"summary_proposal_id": existing.id,
"skipped": "already-ingested",
}

Expand All @@ -358,12 +395,42 @@ def ingest_rollout(
generated_at=generated_at or session.started_at,
first_prompt=session.first_prompt,
)
resolved_actor = actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR

if existing is not None:
if _comparable_body(body) == _comparable_body(
str(existing.payload.get("body", ""))
):
return {
"session_id": session.session_id,
"captured": len(session.observations),
"summary_proposal_id": existing.id,
"skipped": "already-ingested",
}
refreshed = existing.model_copy(deep=True)
refreshed.payload["title"] = title.strip()
refreshed.payload["body"] = body
store.update_proposal(refreshed)
audit.log_event(
store.kb_dir,
event="proposal.page.update",
actor=resolved_actor,
object_ids=[existing.id],
data={"reason": "codex rollout re-ingest", "captured": len(session.observations)},
)
return {
"session_id": session.session_id,
"captured": len(session.observations),
"summary_proposal_id": existing.id,
"updated": True,
}

proposal = propose_page(
store,
title=title,
body=body,
page_type=capture.CAPTURE_PAGE_TYPE,
proposed_by=actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR,
proposed_by=resolved_actor,
session_id=session.session_id,
rationale="ingested codex session rollout",
)
Expand All @@ -372,3 +439,37 @@ def ingest_rollout(
"captured": len(session.observations),
"summary_proposal_id": proposal.id,
}


def ingest_hook_payload(
store: KBStore | None,
payload: dict[str, Any],
*,
codex_home: Path | None = None,
) -> dict[str, Any] | None:
"""Handle one codex Stop-hook payload; never raises.

The hook wire (`vouch capture ingest-codex --hook`) must exit 0 even on
failure — a capture problem must never break the user's codex turn,
the same rule ``capture observe`` follows. Returns the ingest result,
or None when there was nothing safe to do.
"""
try:
if store is None:
return None
session_id = str(payload.get("session_id") or "")
if not session_id:
return None
rollout: Path | None = None
transcript = payload.get("transcript_path")
if isinstance(transcript, str) and transcript.endswith(".jsonl"):
candidate = Path(transcript)
if candidate.is_file():
rollout = candidate
if rollout is None:
rollout = find_rollout_by_session_id(session_id, codex_home=codex_home)
if rollout is None:
return None
return ingest_rollout(store, rollout)
except Exception:
return None
14 changes: 14 additions & 0 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,20 @@ def put_proposal(self, proposal: Proposal) -> Proposal:
) from e
return proposal

def update_proposal(self, proposal: Proposal) -> Proposal:
"""Overwrite an existing *pending* proposal file in place.

Pure I/O: the caller decides whether a refresh is legitimate (only
pending proposals may be rewritten — a decided one is history).
"""
path = self._proposal_path(proposal.id)
if not path.exists():
raise ArtifactNotFoundError(f"proposal {proposal.id}")
path.write_text(
_yaml_dump(proposal.model_dump(mode="json")), encoding="utf-8"
)
return proposal

def get_proposal(self, proposal_id: str) -> Proposal:
for path in (self._proposal_path(proposal_id), self._decided_path(proposal_id)):
if path.exists():
Expand Down
Loading
Loading