From 1f00414b13937639c9e634cfa6a9ec41016fb7dc Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 09:52:31 +0200 Subject: [PATCH 01/20] hooks: Add Codex session and model identity helpers Extend hook normalization with idempotent `cx_` session IDs and `openai/` model IDs for Codex, with focused regression tests. Record T01's implementation and verification in the Codex CLI integration plan. Plan: codex-cli-integration (T01) Co-authored-by: SCE --- cli/src/services/hooks/mod.rs | 67 ++++++++ context/plans/codex-cli-integration.md | 203 +++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 context/plans/codex-cli-integration.md diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 21aee355..16e4ac21 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -43,9 +43,13 @@ const CLAUDE_MODEL_ID_PREFIX: &str = "claude/"; pub(crate) const DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX: &str = "oc_"; pub(crate) const DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX: &str = "cc_"; pub(crate) const DIFF_TRACE_PI_SESSION_ID_PREFIX: &str = "pi_"; +pub(crate) const DIFF_TRACE_CODEX_SESSION_ID_PREFIX: &str = "cx_"; const OPENCODE_TOOL_NAME: &str = "opencode"; const CLAUDE_TOOL_NAME: &str = "claude"; const PI_TOOL_NAME: &str = "pi"; +const CODEX_TOOL_NAME: &str = "codex"; +#[allow(dead_code)] +const OPENAI_MODEL_ID_PREFIX: &str = "openai/"; const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; type PayloadValidationError = fn(&str) -> String; @@ -62,6 +66,7 @@ fn prefixed_session_id(tool_name: &str, raw_session_id: &str) -> String { OPENCODE_TOOL_NAME => DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX, CLAUDE_TOOL_NAME => DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX, PI_TOOL_NAME => DIFF_TRACE_PI_SESSION_ID_PREFIX, + CODEX_TOOL_NAME => DIFF_TRACE_CODEX_SESSION_ID_PREFIX, _ => return raw_session_id.to_string(), }; @@ -1026,6 +1031,20 @@ fn normalize_claude_model_id(model: &str) -> Option { } } +#[allow(dead_code)] +fn normalize_codex_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + if normalized.starts_with(OPENAI_MODEL_ID_PREFIX) { + Some(normalized.to_string()) + } else { + Some(format!("{OPENAI_MODEL_ID_PREFIX}{normalized}")) + } +} + /// Extract a u64 timestamp from a Claude hook event payload, falling back to the /// current system time when no timestamp field is present. fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { @@ -2697,6 +2716,54 @@ mod tests { ); } + #[test] + fn prefixed_diff_trace_session_id_prefixes_fresh_codex_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("codex", "session-123"), + "cx_session-123" + ); + } + + #[test] + fn prefixed_diff_trace_session_id_keeps_already_prefixed_codex_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("codex", "cx_session-123"), + "cx_session-123" + ); + } + + #[test] + fn prefixed_diff_trace_session_id_adding_codex_does_not_affect_other_tool_prefixes() { + assert_eq!( + prefixed_diff_trace_session_id("opencode", "session-123"), + "oc_session-123" + ); + assert_eq!( + prefixed_diff_trace_session_id("claude", "session-123"), + "cc_session-123" + ); + assert_eq!( + prefixed_diff_trace_session_id("pi", "session-123"), + "pi_session-123" + ); + } + + #[test] + fn normalize_codex_model_id_prefixes_fresh_model_id() { + assert_eq!( + normalize_codex_model_id("gpt-5.6-codex").as_deref(), + Some("openai/gpt-5.6-codex") + ); + } + + #[test] + fn normalize_codex_model_id_keeps_already_prefixed_model_id() { + assert_eq!( + normalize_codex_model_id("openai/gpt-5.6-codex").as_deref(), + Some("openai/gpt-5.6-codex") + ); + } + #[test] fn pi_normalized_diff_trace_payload_persists_with_pi_prefixed_session_id() { let stdin_payload = serde_json::json!({ diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md new file mode 100644 index 00000000..4fb87782 --- /dev/null +++ b/context/plans/codex-cli-integration.md @@ -0,0 +1,203 @@ +# Plan: codex-cli-integration + +## Change summary + +Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode, Claude Code, and Pi. This extends existing behavior rather than replacing it: Codex reuses the same canonical Pkl workflow catalog, the same Rust Bash policy engine, the same conversation (`messages`/`parts`) persistence, and the same `diff_traces` → post-commit intersection → `agent_traces` pipeline every other integration already goes through. The only new runtime surface is a Codex-specific hook adapter (`sce hooks codex`) that produces normalized evidence for Codex's two output roots (`.agents/` for skills, `.codex/` for hooks) and a transient before/after snapshot mechanism for `apply_patch` attribution. No Agent Trace schema migration is introduced, and Bash-triggered filesystem mutations are explicitly out of scope for attribution in this change — Bash gets policy enforcement only, matching the current-state boundary already documented for Claude/Pi. + +## Acceptance criteria + +- [ ] AC1: `sce setup --codex --non-interactive` succeeds in a Git repository, installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**`, and persists `{"integrations": {"target": ["codex"]}}` into `.sce/config.json` under existing merge semantics. + - Validate: run the command in a scratch git repo; inspect `.sce/config.json` and installed files. +- [ ] AC2: `sce setup --all --non-interactive` installs Codex assets alongside OpenCode/Claude/Pi with no regression to the other three targets. + - Validate: run in a scratch git repo; inspect all four target trees plus `integrations.target`. +- [ ] AC3: Core workflows (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`) appear under `.agents/skills/`, and optional workflows (`brownfield`) obey the existing `integrations.optional_workflows` selection mechanism for Codex the same way they do for OpenCode/Claude/Pi. + - Validate: `nix run .#pkl-generate -- "$(mktemp -d)"` then inspect `.agents/skills/`; `sce setup --codex --workflow brownfield --non-interactive` includes `sce-brownfield`, a run without `--workflow` does not. +- [ ] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), and `PostToolUse` (`apply_patch`) — no Bash `PostToolUse` entry. + - Validate: inspect generated `.codex/hooks.json` content directly. +- [ ] AC5: A Codex `UserPromptSubmit` event produces exactly one user `message` and one text `part` under session `cx_`. + - Validate: integration test feeding a synthetic `UserPromptSubmit` payload to `sce hooks codex` and querying the repository Agent Trace DB. +- [ ] AC6: A Codex `Stop` event produces exactly one assistant `message` and one text `part`. + - Validate: integration test feeding a synthetic `Stop` payload to `sce hooks codex` and querying the DB. +- [ ] AC7: Reprocessing the same turn's `UserPromptSubmit`/`Stop` event does not create a duplicate parent message. + - Validate: integration test invoking the same payload twice and asserting one row per deterministic message ID. +- [ ] AC8: An allowed Bash command executes with no model-visible SCE tracing output. + - Validate: integration test asserting empty/silent success output for an allowed command through `sce hooks codex` `PreToolUse` `Bash`. +- [ ] AC9: A denied Bash command is blocked using Codex's native `PreToolUse` deny response shape and includes the SCE policy reason text. + - Validate: integration test asserting the deny response body/shape and policy reason for a configured blocking policy. +- [ ] AC10: Bash filesystem mutations create no Codex `diff_trace`. + - Validate: regression test running `echo generated > generated.txt` through the Codex Bash hook path and asserting zero new `diff_traces` rows. +- [ ] AC11: A successful `apply_patch` produces an observed unified patch in `diff_traces` reflecting the actual before/after repository delta, not the requested patch text. + - Validate: integration test driving `PreToolUse apply_patch` then `PostToolUse apply_patch` against a scratch repo and asserting the persisted patch matches `git diff` of the real file mutation. +- [ ] AC12: The persisted `diff_traces` row carries `session_id = cx_...`, `model_id = openai/...`, `tool_name = codex`, `payload_type = patch`. + - Validate: same integration test as AC11, asserting row field values. +- [ ] AC13: The same successful `apply_patch` also creates assistant patch conversation evidence (`message` + `part_type = patch`) tied to the same `cx_` session. + - Validate: same integration test as AC11, querying `messages`/`parts`. +- [ ] AC14: Given a pre-existing dirty worktree change `A` before `PreToolUse apply_patch` and a Codex-authored change `B`, the resulting Codex diff evidence contains `B` but not `A`. + - Validate: integration test seeding an uncommitted dirty change before the hook sequence and asserting the persisted patch excludes it. +- [ ] AC15: A `PostToolUse apply_patch` with no corresponding pending before-state logs a diagnostic, fails open, and creates no diff evidence. + - Validate: integration test invoking `PostToolUse apply_patch` without a prior `PreToolUse apply_patch` for the same correlation key. +- [ ] AC16: Identical before/after repository states produce no diff trace and are treated as a successful no-op. + - Validate: integration test running the full pending → finalize sequence with no actual file change. +- [ ] AC17: A commit containing a recorded Codex `apply_patch` diff_trace is attributed through the existing, unmodified `post-commit` intersection pipeline. + - Validate: integration test recording a Codex diff_trace, committing the same change, running `sce hooks post-commit`, and inspecting `post_commit_patch_intersections`. +- [ ] AC18: The resulting Agent Trace identifies Codex as the tool and preserves the Codex model ID through the existing attribution machinery. + - Validate: same integration test as AC17, asserting the built `agent_traces.trace_json` contributor/tool metadata. +- [ ] AC19: No Agent Trace repository schema migration is added; `diff_traces`/`agent_traces`/`messages`/`parts` and `RepositoryAgentTraceDbSpec::migrations()` remain unchanged. + - Validate: `git diff` shows no new file under `cli/migrations/agent-trace-repository/` and no changed baseline SQL. +- [ ] AC20: Existing OpenCode, Claude, and Pi setup, generated assets, conversation tracing, diff tracing, policy behavior, and Agent Trace tests continue to pass. + - Validate: `nix flake check`. + +### Full validation + +- `nix run .#pkl-check-generated` +- `nix flake check` + +### Context sync + +- `context/context-map.md`, `context/overview.md`, `context/architecture.md` — Codex named as a fourth supported integration target wherever the OpenCode/Claude/Pi target set is currently stated. +- `context/cli/cli-command-surface.md` — `sce setup --codex`, `sce hooks codex` command-surface additions. +- `context/cli/config-precedence-contract.md` — `integrations.target` accepting `"codex"`. +- `context/cli/default-path-catalog.md` — the new transient Codex `apply_patch` pending-state path helper. +- New `context/sce/codex-integration-runtime.md` (modeled on `context/sce/pi-extension-runtime.md`) — `cx_` session prefix, `openai/` model normalization, UserPromptSubmit/Stop mapping, Bash policy delegation, `apply_patch` before/after attribution flow, explicit Bash-mutation-tracing non-goal. +- `context/sce/doctor-human-text-contract.md` — Codex integration group/area ordering. +- `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md` — note that `sce hooks codex` is a second writer into the same `diff_traces`/`messages`/`parts` tables via the existing insert helpers, with no new adapter. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** beside + the status. Never infer `synced` from conversation history; write every lifecycle + transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, and the durable context files listed under Context sync. +- **Out of scope:** any change to `cli/migrations/agent-trace-repository/`; any change to OpenCode/Claude/Pi's own generated behavior beyond what is mechanically required to add a fourth target to shared enums/renderers; Codex App Server or `codex exec --json` integration; MCP-tool or subagent attribution; `AGENTS.md` generation/management; a Codex slash-command compatibility layer. +- **Constraints:** reuse `cli/src/services/bash_policy.rs` for Bash policy evaluation without reimplementing matching; reuse `DiffTraceInsert`/`insert_diff_trace`, `InsertMessageInsert`/`insert_messages`, `InsertPartInsert`/`insert_parts` for persistence without a Codex-specific DB adapter; reuse `cli/src/services/patch.rs` for unified-diff parsing/`git diff` output, no second diff engine. +- **Non-goal:** Bash-created filesystem change attribution for Codex, Claude, or Pi (deferred — tracked as a known gap, not solved here); a generic cross-producer mutation tracker; any `diff_traces`/Agent Trace DB schema column for snapshot/pending state. + +## Assumptions + +- The Codex hook lifecycle event names and field names given in the change request (`hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; events `UserPromptSubmit`, `Stop`, `PreToolUse`, `PostToolUse`; tool identifiers `Bash`, `apply_patch`) are taken as the working contract for T06. T06 begins by checking these against current Codex CLI documentation/behavior per the change request's own instruction ("Check the current official/current Codex hook schema rather than relying on old assumptions"); if reality differs, the typed parser is adjusted to match without changing the architecture, dispatcher shape, or any acceptance criterion above (all of which are stated as SCE-side observable outcomes, not exact Codex wire-format assertions). +- Codex's `PreToolUse` deny response shape is whatever the installed Codex CLI currently expects for a blocking tool-call response; T09 confirms and reuses that shape rather than inventing one, consistent with the existing OpenCode/Pi/Claude precedent of matching each harness's native block contract (see `context/sce/pi-extension-runtime.md`, `context/sce/bash-tool-policy-enforcement-contract.md`). +- The transient `apply_patch` pending-state directory lives under the existing SCE per-user state root (`cli/src/services/default_paths.rs`), not under the repository working tree, consistent with how checkout identity (`/sce/checkout-id`) and Agent Trace DBs are already scoped outside the tracked worktree. +- "SCE state namespace" for the pending-state path is a new named accessor added to `default_paths.rs` (per repo convention: "Production CLI code should define named path accessors ... not introduce new hardcoded path owners elsewhere"), not an ad hoc path literal inside the hooks module. + +## Task stack + +- [x] T01: `Add AgentProducer identity, cx_ session prefixing, and openai/ model normalization` (status:done) + - Task ID: T01 + - Scope: In — a shared `AgentProducer` enum (`OpenCode`, `Claude`, `Pi`, `Codex`) if useful for explicit producer identity; extend the existing tool-prefixed session-ID helper (`prefixed_diff_trace_session_id` and its conversation-trace analog in `cli/src/services/hooks/mod.rs`) with an idempotent `"codex" -> cx_` arm; add an idempotent `openai/`-prefixing model-ID normalizer for Codex model IDs. Out — any hook parsing, any dispatcher, any CLI wiring. + - Dependencies: none + - Done when: unit tests prove `cx_` prefixing is idempotent (a `cx_`-prefixed input is unchanged) and does not affect `oc_`/`cc_`/`pi_` prefixing for other tool names; unit tests prove the model normalizer turns `gpt-5.6-codex` into `openai/gpt-5.6-codex` and leaves an already-prefixed `openai/gpt-5.6-codex` unchanged. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::'` (or the narrower module path the implementation lands in). + - Completed: 2026-08-22 + - Files changed: `cli/src/services/hooks/mod.rs` + - Result: Added `DIFF_TRACE_CODEX_SESSION_ID_PREFIX` (`cx_`) and `CODEX_TOOL_NAME` (`"codex"`) constants with a `"codex"` arm in `prefixed_session_id` (backing both `prefixed_diff_trace_session_id` and `prefixed_conversation_trace_session_id`); added `OPENAI_MODEL_ID_PREFIX` (`"openai/"`) and `normalize_codex_model_id`, mirroring the existing `normalize_claude_model_id` pattern. No `AgentProducer` enum was introduced: the existing OpenCode/Claude/Pi code uses plain string constants and match arms with no producer enum anywhere in the module, so Codex follows the same pattern rather than adding an unused abstraction. `OPENAI_MODEL_ID_PREFIX` and `normalize_codex_model_id` carry `#[allow(dead_code)]` (existing repo precedent, e.g. `default_paths.rs`, `app.rs`) since dispatcher/CLI wiring is out of scope until T06+. + - Verify: `nix flake check` (direct `cargo test hooks::` is blocked by this repo's Bash policy `use-nix-flake-check-over-cargo-test`, which requires `nix flake check` instead) — passed: `all checks passed!`, including `services::hooks::tests::prefixed_diff_trace_session_id_prefixes_fresh_codex_session_id`, `..._keeps_already_prefixed_codex_session_id`, `..._adding_codex_does_not_affect_other_tool_prefixes`, `normalize_codex_model_id_prefixes_fresh_model_id`, `normalize_codex_model_id_keeps_already_prefixed_model_id` (381 passed total), plus clippy and fmt. + - Context impact: none — an internal helper addition inside `cli/src/services/hooks/mod.rs` with no dispatcher/CLI wiring yet (deferred to T06+); no user-visible behavior, public interface, or documented architecture changed. + - Context synchronization: synced + +- [ ] T02: `Generate Codex workflow Skills into .agents/skills/` (status:todo) + - Task ID: T02 + - Scope: In — a Codex Pkl renderer (parallel to `opencode-content.pkl`/`claude-content.pkl`/`pi-content.pkl`) consuming the same `workflow-composite.pkl` composition and canonical `workflow-catalog.pkl`/workflow modules to emit `.agents/skills/{skill-slug}/SKILL.md` (and package-local references) for the five core workflows, honoring the existing optional-workflow catalog for `brownfield`; extend `config/pkl/generate.pkl` output mappings, `config/pkl/renderers/metadata-coverage-check.pkl`, and `config/pkl/renderers/generation-contract-check.pkl` for the new Codex artifact inventory (Codex adds no per-target frontmatter, matching Pi). Out — `.codex/` hook assets (T03), any Rust/CLI change, any `AGENTS.md` generation. + - Dependencies: none + - Done when: `nix run .#pkl-generate -- "$(mktemp -d)"` produces `.agents/skills/sce-change-to-plan/SKILL.md`, `.agents/skills/sce-next-task/SKILL.md`, `.agents/skills/sce-validate/SKILL.md`, `.agents/skills/sce-commit/SKILL.md`, `.agents/skills/sce-handover/SKILL.md` unconditionally, and `.agents/skills/sce-brownfield/SKILL.md` only when the catalog marks it selected for the run; `nix run .#pkl-check-generated` passes with the updated exact-path contract; no `.agents/commands/` output exists. + - Verify: `nix run .#pkl-check-generated`. + - Context synchronization: pending + +- [ ] T03: `Generate Codex hooks (.codex/hooks.json and hook helper script)` (status:todo) + - Task ID: T03 + - Scope: In — canonical Pkl source for `.codex/hooks.json` registering `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), `PostToolUse` (`apply_patch`) — no Bash `PostToolUse` entry; `.codex/hooks/run-sce-or-show-install-guidance.sh` following the existing fail-open/install-guidance pattern used by `.claude/hooks/run-sce-or-show-install-guidance.sh`, routing all lifecycle JSON to `sce hooks codex`; extend `generate.pkl` output mappings and the generation-contract check for these two new paths. Out — the actual `sce hooks codex` Rust implementation (T06), CLI/build.rs embedding (T04). + - Dependencies: T02 + - Done when: a temporary generation root contains `.codex/hooks.json` with exactly the five lifecycle registrations above (verified by direct content inspection) and `.codex/hooks/run-sce-or-show-install-guidance.sh` with the same missing-`sce` fail-open guidance text pattern as the Claude helper; `nix run .#pkl-check-generated` passes. + - Verify: `nix run .#pkl-check-generated`; manual inspection of generated `.codex/hooks.json` and hook script content. + - Context synchronization: pending + +- [ ] T04: `Wire Codex's dual .agents/ + .codex/ output roots into embedded-asset install` (status:todo) + - Task ID: T04 + - Scope: In — a `config/codex-target/` build-time source layout (`.agents/skills/**`, `.codex/hooks.json`, `.codex/hooks/**`); `cli/build.rs` `CODEX_EMBEDDED_ASSETS` generation from the Pkl-generated payload (parallel to `OPENCODE_EMBEDDED_ASSETS`/`CLAUDE_EMBEDDED_ASSETS`/`PI_EMBEDDED_ASSETS`); package-fallback preparation (`scripts/prepare-cli-generated-assets.sh` or equivalent) for the two new roots; the shared per-target install layout struct in `cli/src/services/setup/mod.rs` (around line 98) changed so `command_dir: Option<&'static str>` (Codex has no command dir — skills only), with existing OpenCode/Claude/Pi behavior unchanged (`Some(...)`); optional-workflow asset filtering adjusted to skip command-file exclusion when `command_dir` is `None`. Out — the `SetupTarget`/CLI-flag/config-schema plumbing that actually selects Codex for a run (T05). + - Dependencies: T02, T03 + - Done when: an embedded-asset unit test proves `CODEX_EMBEDDED_ASSETS` contains normalized relative-path entries for every generated `.agents/skills/**` and `.codex/**` file with no `.agents/commands/**` entries; existing OpenCode/Claude/Pi embedded-asset tests still pass unmodified. + - Verify: `nix develop -c sh -c 'cd cli && cargo test setup::'`. + - Context synchronization: pending + +- [ ] T05: `Add Codex as a setup/integration target end-to-end` (status:todo) + - Task ID: T05 + - Scope: In — `SetupTarget::Codex` in `cli/src/services/setup/mod.rs`; `IntegrationTargetId::Codex` in `cli/src/services/config/types.rs` (+ schema.rs mapping); `--codex` CLI flag, mutual-exclusion validation, non-interactive validation, help/error text, interactive setup choice, `--all` expansion to include Codex, install engine wiring to `CODEX_EMBEDDED_ASSETS`, `integrations.target` persistence accepting `"codex"`, and the Pkl-authored config JSON Schema (`sce-config-schema.pkl`) accepting `"codex"` in `integrations.target`. Out — doctor coverage (T13), hook runtime (T06+). + - Dependencies: T04 + - Done when: `sce setup --codex --non-interactive` in a scratch git repo installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**` and records `{"integrations": {"target": ["codex"]}}`; `sce setup --all --non-interactive` includes Codex alongside OpenCode/Claude/Pi with no regression to the other three; `sce config validate` accepts a config file with `integrations.target: ["codex"]` and rejects an unknown target while listing `codex` among the valid values. + - Verify: `nix develop -c sh -c 'cd cli && cargo test setup:: config::'`; manual `sce setup --codex --non-interactive` run in a scratch repo per AC1/AC2. + - Context synchronization: pending + +- [ ] T06: `Implement sce hooks codex: typed event parsing and dispatcher skeleton` (status:todo) + - Task ID: T06 + - Scope: In — `HookSubcommand::Codex` (or equivalent) wired into `cli/src/app.rs` / `cli/src/services/hooks/mod.rs` CLI parsing and help text; a typed, explicit Codex hook-event parser covering `hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; a dispatcher matching `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, `PostToolUse(apply_patch)`, with every other event/tool combination falling through to a deterministic successful no-op; tracing/parse failures logged and fail-open (hook success, non-zero exit reserved for genuine parse-time CLI usage errors matching existing hook-command conventions). Out — the actual behavior behind each dispatch arm (T07–T12): this task's arms are stubs proven only by dispatch-routing tests. + - Dependencies: T01 + - Done when: `sce hooks codex --help` and top-level `sce hooks --help` list the new subcommand; unit tests prove each of the five supported event/tool combinations routes to its own internal arm and every unsupported combination (e.g. an unknown `tool_name` under `PreToolUse`, or an unrecognized `hook_event_name`) routes to the no-op arm without error; a malformed/non-JSON STDIN payload is logged and returns hook success. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex'`. + - Context synchronization: pending + +- [ ] T07: `Capture Codex UserPromptSubmit into messages/parts` (status:todo) + - Task ID: T07 + - Scope: In — the `UserPromptSubmit` dispatch arm: build `session_id = cx_`, `message_id = cx::user`, one `role="user"` message row via the existing `InsertMessageInsert`/`insert_messages` path, one `part_type="text"` part row (`text = prompt`) via `InsertPartInsert`/`insert_parts`, `generated_at_unix_ms` from hook receipt time. Out — `Stop` (T08), any new conversation table. + - Dependencies: T06 + - Done when: an integration test feeding a synthetic `UserPromptSubmit` payload through `sce hooks codex` produces exactly one `messages` row and one `parts` row under session `cx_` with the expected deterministic `message_id`; reprocessing the identical payload does not create a duplicate `messages` row (relies on the existing `ON CONFLICT (session_id, message_id) DO NOTHING` semantics). + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::user_prompt_submit'`. + - Context synchronization: pending + +- [ ] T08: `Capture Codex Stop into messages/parts` (status:todo) + - Task ID: T08 + - Scope: In — the `Stop` dispatch arm: `session_id = cx_`, `message_id = cx::assistant`, one `role="assistant"` message row, one `part_type="text"` part row (`text = last_assistant_message`). Out — session-level model caching (explicitly not needed here). + - Dependencies: T06 + - Done when: an integration test feeding a synthetic `Stop` payload through `sce hooks codex` produces exactly one `messages` row and one `parts` row under session `cx_` with the expected deterministic `message_id`; reprocessing the identical payload does not create a duplicate `messages` row. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::stop'`. + - Context synchronization: pending + +- [ ] T09: `Route Codex Bash PreToolUse through the existing SCE Bash policy engine` (status:todo) + - Task ID: T09 + - Scope: In — the `PreToolUse(Bash)` dispatch arm delegating the command string to `cli/src/services/bash_policy.rs` unchanged; on allow, silent hook success with no model-visible output; on deny, the Codex-native `PreToolUse` deny response shape carrying the SCE policy ID/message (matching the pattern in `context/sce/bash-tool-policy-enforcement-contract.md`'s "Block behavior contract"); no `diff_traces`/snapshot/pending-state writes on either branch. Out — `apply_patch` handling (T10/T11). + - Dependencies: T06 + - Done when: an allowed Bash command produces silent success output; a command matching a configured blocking policy produces the deny response including the policy ID and message text; a regression test runs `echo generated > generated.txt` through the Codex Bash hook path end-to-end and asserts zero new `diff_traces` rows exist afterward. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::bash_policy'`. + - Context synchronization: pending + +- [ ] T10: `Capture apply_patch before-state via temporary-index snapshot` (status:todo) + - Task ID: T10 + - Scope: In — a temporary-`GIT_INDEX_FILE` snapshot helper (`git read-tree HEAD` + `git add -A` + `git write-tree`) producing a `before_tree_oid` without mutating the real index; a new named path accessor in `cli/src/services/default_paths.rs` for the Codex pending-state directory (`/sce/repos//hooks/codex/pending/`); a hashed/sanitized event-key derivation from `(session_id, turn_id, tool_use_id)`; atomic pending-state file write (`{before_tree_oid, created_at_unix_ms}`) wired into the `PreToolUse(apply_patch)` dispatch arm. Out — the `PostToolUse` finalize logic (T11); no write to `agent-trace.db`. + - Dependencies: T06 + - Done when: unit tests prove the event-key derivation is deterministic for the same triple and distinct for different triples, and is safe as a filesystem path segment; an integration test runs `PreToolUse(apply_patch)` against a scratch repo with a pre-existing dirty (uncommitted) change and asserts the written pending file's `before_tree_oid` reflects the dirty worktree state (tracked changes + non-ignored untracked files) rather than `HEAD`. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::pre'`. + - Context synchronization: pending + +- [ ] T11: `Finalize apply_patch: after-state, observed diff, cleanup` (status:todo) + - Task ID: T11 + - Scope: In — the `PostToolUse(apply_patch)` dispatch arm: look up the pending file by the same event-key derivation; on hit, take a second temporary-index snapshot for `after_tree_oid`, compute `git diff --binary --find-renames `; on empty diff, treat as a successful no-op; consume (remove) the pending file idempotently after processing (safe for a second/duplicate cleanup attempt); on missing or unusable pending state, log a diagnostic, fail open, and produce no diff evidence (no guessing from the raw patch command). Out — DB persistence of the resulting non-empty patch (T12). + - Dependencies: T10 + - Done when: integration tests cover file creation, file edit, file deletion, and (if the underlying delta supports it) rename, each producing the expected `git diff` shape from the helper; a test covers a `PostToolUse` call with no matching pending file (logs + fails open, no evidence); a test covers a malformed/unreadable pending-state file (same fail-open behavior); a test covers before==after (no-op, pending file still consumed); a test proves a pre-existing dirty change present at `PreToolUse` time (change `A`) is excluded from the finalize-time diff when only change `B` was made by the tool call in between. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::post'`. + - Context synchronization: pending + +- [ ] T12: `Persist Codex apply_patch diff evidence, patch conversation, and prove post-commit reuse` (status:todo) + - Task ID: T12 + - Scope: In — for a non-empty finalize-time delta from T11: `DiffTraceInsert` with `time_ms` (finalization time), `session_id = cx_`, `patch` (the observed diff), `model_id = openai/` (via T01's normalizer), `tool_name = "codex"`, `tool_version = NULL`, `payload_type = "patch"`, persisted through the existing `insert_diff_trace()`; one assistant patch message/part (`message_id = cx:::patch`, `role = assistant`, `part_type = patch`) via the existing `InsertMessageInsert`/`InsertPartInsert` path, tied to the same `cx_` session; an integration test proving the existing, unmodified `post-commit` intersection pipeline (`recent_diff_trace_patches` → `combine_patches` → `intersect_patches` → `build_agent_trace`) attributes a committed Codex change correctly and preserves `tool_name="codex"`/the Codex model ID in the resulting `agent_traces.trace_json`. Out — any new persistence path, any Codex-specific DB adapter, any post-commit code change. + - Dependencies: T11, T01 + - Done when: the diff evidence and conversation evidence tests above pass; the post-commit integration test (recording a Codex diff_trace, committing the same change, running `sce hooks post-commit`, then inspecting the persisted `agent_traces` row) passes with no modification to `cli/src/services/hooks/mod.rs`'s existing post-commit flow functions beyond what T06–T11 already required. + - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::persist hooks::post_commit'`. + - Context synchronization: pending + +- [ ] T13: `Add Codex doctor coverage` (status:todo) + - Task ID: T13 + - Scope: In — a Codex integration group in `cli/src/services/doctor/inspect.rs` (parallel to the Claude/OpenCode/Pi groups) reporting missing/mismatched `.agents/skills/**` for the resolved workflow selection, missing/mismatched `.codex/hooks.json`, and missing/mismatched `.codex/hooks/run-sce-or-show-install-guidance.sh`; actionable guidance text for Codex's project hook trust/review requirement (informational only — doctor does not bypass or grant trust); Codex added to the doctor target-resolution set (`integrations.target` entries / repo-root `.codex/` detection) and to `context/sce/doctor-human-text-contract.md`'s target/area ordering. Out — any change to doctor's fix-mode git-hook repair logic (unrelated to Codex). + - Dependencies: T04, T05 + - Done when: `sce doctor` in a repo with Codex installed and current reports `[PASS]` for the Codex integration group; deleting or corrupting a Codex asset produces the matching `[FAIL]`/`[MISS]` problem with actionable text; `sce doctor --format json` includes a Codex integration group entry alongside `opencode`/`claude`/`pi`. + - Verify: `nix develop -c sh -c 'cd cli && cargo test doctor::'`; manual `sce doctor` / `sce doctor --format json` run against a Codex-installed scratch repo. + - Context synchronization: pending + +## Open questions + +- The exact current Codex CLI hook JSON schema (event names, field names, tool-call identifiers, and the native `PreToolUse` deny response shape) cannot be verified from this repository — Codex CLI is an external, evolving tool. T06 and T09 open by checking the change request's assumed schema against current Codex CLI behavior/documentation before finalizing the parser and deny-response builder; this is recorded as an assumption above rather than a blocking question because no acceptance criterion in this plan depends on the exact wire format — every AC is stated as an SCE-side observable outcome (DB rows, generated files, policy behavior) that holds regardless of the precise Codex JSON shape. From 8d2ba56547a110b5a6c1ff7f7b3e3102a24be89f Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 10:06:04 +0200 Subject: [PATCH 02/20] config: Add Codex skill generation target Extend the canonical Pkl generation pipeline with a skills-only Codex target that reuses the shared workflow and decision-skill composition without adding command or prompt outputs. Update artifact inventories, metadata coverage, cross-target contracts, and durable context to cover the fourth target and its 133-file generated payload. Plan: codex-cli-integration.md (T02) Co-authored-by: SCE --- config/pkl/generate.pkl | 6 +++++ config/pkl/renderers/codex-content.pkl | 13 ++++++++++ .../renderers/generation-contract-check.pkl | 25 +++++++++++++------ .../pkl/renderers/metadata-coverage-check.pkl | 12 +++++++++ context/architecture.md | 22 ++++++++-------- context/context-map.md | 4 +-- context/glossary.md | 10 ++++---- context/overview.md | 10 ++++---- context/patterns.md | 2 +- context/plans/codex-cli-integration.md | 9 +++++-- 10 files changed, 80 insertions(+), 33 deletions(-) create mode 100644 config/pkl/renderers/codex-content.pkl diff --git a/config/pkl/generate.pkl b/config/pkl/generate.pkl index 204676a0..52fd938c 100644 --- a/config/pkl/generate.pkl +++ b/config/pkl/generate.pkl @@ -1,6 +1,7 @@ import "renderers/opencode-content.pkl" as opencode import "renderers/claude-content.pkl" as claude import "renderers/pi-content.pkl" as pi +import "renderers/codex-content.pkl" as codex import "renderers/common.pkl" as common import "base/sce-config-schema.pkl" as sce_config_schema import "base/optional-workflow-manifest.pkl" as optional_workflow_manifest @@ -57,6 +58,11 @@ output { ["config/.pi/extensions/sce/index.ts"] { text = piExtensionSource } + for (documentPath, document in codex.skillDocuments) { + ["config/.agents/skills/\(documentPath)"] { + text = "\(document.text)\n" + } + } ["config/.opencode/lib/bash-policy-presets.json"] { text = bashPolicyPresetCatalogSource } diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl new file mode 100644 index 00000000..644d45e4 --- /dev/null +++ b/config/pkl/renderers/codex-content.pkl @@ -0,0 +1,13 @@ +import "../base/decision-skill.pkl" as decision +import "workflow-composite.pkl" as workflowResults + +/// Codex has no command-routed entrypoints — it discovers skills directly, with +/// no per-target frontmatter beyond the shared description, matching Pi. +skillDocuments { + for (path, document in workflowResults.skillDocuments.apply("")) { + [path] = document + } + for (path, document in decision.skillDocuments.apply("")) { + [path] = document + } +} diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index 2413bcd9..750c2b68 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -3,6 +3,7 @@ import "../base/workflow-catalog.pkl" as catalog import "opencode-content.pkl" as opencode import "claude-content.pkl" as claude import "pi-content.pkl" as pi +import "codex-content.pkl" as codex /// Build the expected inventory from each target's independently checked /// document inventory plus the retained non-workflow assets. This deliberately @@ -39,6 +40,10 @@ hidden expectedArtifactPaths = new Mapping { } ["config/.pi/extensions/sce/index.ts"] = true + for (path, _ in codex.skillDocuments) { + ["config/.agents/skills/\(path)"] = true + } + ["config/schema/sce-config.schema.json"] = true ["config/optional-workflows.json"] = true } @@ -59,6 +64,9 @@ hidden workflowDocuments = new Mapping { for (path, document in pi.skillDocuments) { ["config/.pi/skills/\(path)"] = document.text } + for (path, document in codex.skillDocuments) { + ["config/.agents/skills/\(path)"] = document.text + } } local decisionSkillDocuments = new Mapping { @@ -106,7 +114,7 @@ local decisionWorkflowText = (documents: Mapping, workflowSlug: String) -> }.join("\n") local expectedDecisionDocumentPaths = new Mapping { - for (target in new Listing { ".opencode"; ".claude"; ".pi" }) { + for (target in new Listing { ".opencode"; ".claude"; ".pi"; ".agents" }) { ["config/\(target)/skills/sce-decision/SKILL.md"] = true ["config/\(target)/skills/sce-decision/references/adr-template.md"] = true } @@ -261,10 +269,10 @@ local forbiddenWorkflowReferenceTokens = new Listing { /// Six cross-target workflow packages, with package-local phase references and /// supporting documents on the four phase-based workflows, plus the decision -/// package and retained non-workflow assets. Stating the total as a literal makes -/// an unintended inventory change fail here instead of silently becoming the new -/// expectation. -local expectedArtifactPathCount = 107 +/// package and retained non-workflow assets, across OpenCode, Claude, Pi, and +/// Codex. Stating the total as a literal makes an unintended inventory change +/// fail here instead of silently becoming the new expectation. +local expectedArtifactPathCount = 133 local assertExactArtifactPaths = (actual: Mapping) -> if ( @@ -376,7 +384,7 @@ local assertDecisionDocumentPaths = (documents: Mapping) -> hidden assertPhaseReferenceContract = (documents: Mapping) -> if ( - new Listing { ".opencode"; ".claude"; ".pi" }.every((target) -> + new Listing { ".opencode"; ".claude"; ".pi"; ".agents" }.every((target) -> requiredPhaseReferencesBySkill.every((skillSlug, references) -> let (skillPath = "config/\(target)/skills/\(skillSlug)/SKILL.md") documents.containsKey(skillPath) @@ -405,7 +413,7 @@ local assertDecisionContent = (documents: Mapping) -> local assertHandoverContent = (documents: Mapping) -> if ( - documents.length == 3 + documents.length == 4 && documents.every((_, text) -> requiredHandoverSkillTokens.every((token) -> text.contains(token)) ) @@ -414,7 +422,7 @@ local assertHandoverContent = (documents: Mapping) -> local assertBrownfieldContent = (documents: Mapping) -> if ( - documents.length == 3 + documents.length == 4 && documents.every((_, text) -> requiredBrownfieldSkillTokens.every((token) -> text.contains(token)) ) @@ -583,6 +591,7 @@ hidden assertTargetNeutralReferences = (documents: Mapping) -> opencodeReferences.every((relativePath, text) -> documents["config/.claude/skills/" + relativePath] == text && documents["config/.pi/skills/" + relativePath] == text + && (!documents.containsKey("config/.agents/skills/" + relativePath) || documents["config/.agents/skills/" + relativePath] == text) ) ) "target-neutral references: Pi, Claude, and OpenCode bodies match" else throw("target-neutral package references differ between Pi, Claude, and OpenCode") diff --git a/config/pkl/renderers/metadata-coverage-check.pkl b/config/pkl/renderers/metadata-coverage-check.pkl index 004948d6..e11ff32e 100644 --- a/config/pkl/renderers/metadata-coverage-check.pkl +++ b/config/pkl/renderers/metadata-coverage-check.pkl @@ -2,6 +2,7 @@ import "../base/workflow-catalog.pkl" as catalog import "opencode-content.pkl" as opencode import "claude-content.pkl" as claude import "pi-content.pkl" as pi +import "codex-content.pkl" as codex local expectedCommandSlugs = new Mapping { for (slug, _ in catalog.workflows) { @@ -99,6 +100,11 @@ local piSkillDocuments = new Mapping { [path] = document } } +local codexSkillDocuments = new Mapping { + for (path, document in codex.skillDocuments) { + [path] = document + } +} local assertExactKeys = (actual: Mapping, expected: Mapping, label: String) -> if ( @@ -137,6 +143,7 @@ inventoryChecks { for (slug, document in piCommands) { ["pi-command-route-\(slug)"] = assertCommandRoute.apply("Pi", slug, document.text) } + ["codex-skill-documents"] = assertExactKeys.apply(codexSkillDocuments, expectedSkillDocumentPaths, "Codex skill document") } /// Force rendering after exact inventory checks so target-specific metadata @@ -176,3 +183,8 @@ piSkillDocumentCoverage { [path] = document.text } } +codexSkillDocumentCoverage { + for (path, document in codexSkillDocuments) { + [path] = document.text + } +} diff --git a/context/architecture.md b/context/architecture.md index d9487596..a9837abd 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -2,7 +2,7 @@ ## Config generation boundary (current approved design) -The repository keeps no committed OpenCode, Claude, or Pi generated target trees. `config/.opencode`, `config/.claude`, and `config/.pi` are logical payload layouts emitted only beneath temporary generation roots, Cargo `OUT_DIR`, and packaging-only fallback directories. +The repository keeps no committed OpenCode, Claude, Pi, or Codex generated target trees. `config/.opencode`, `config/.claude`, `config/.pi`, and `config/.agents` are logical payload layouts emitted only beneath temporary generation roots, Cargo `OUT_DIR`, and packaging-only fallback directories. Authored config content is standardized around one canonical Pkl source model with target-specific rendering applied later in the pipeline. @@ -10,15 +10,15 @@ Current location for canonical workflow content primitives: - `config/pkl/base/workflow-content.pkl` (shared workflow command and self-contained skill-package document model, including structured composite sources with optional canonical `compositeSkillBody` plus deterministic `referenceDocuments`, alongside the typed package/composite rendering primitives; workflow-specific bodies and package-local documents remain in the canonical workflow modules rather than being catalogued here) - `config/pkl/base/workflow-catalog.pkl` (typed six-workflow catalog owning command and skill slugs, titles, descriptions, argument hints, OpenCode routing roles, Claude allowed-tool metadata, and the per-workflow `optional` flag that defaults to `false` and is `true` only for `brownfield`) -- `config/pkl/base/optional-workflow-manifest.pkl` (install-time projection of the catalog's optional records into the generated `config/optional-workflows.json` manifest — `schemaVersion` plus one entry per optional workflow carrying `id`, `title`, `description`, `commandSlug`, and `skillSlug`. Optionality never affects generation: all six workflows are still generated for all three targets, so the manifest exists solely to carry optional-workflow identity out of Pkl for install-time and doctor-time consumers) -- `config/pkl/base/decision-skill.pkl` (canonical standalone `sce-decision` package outside the workflow catalog; renders its decision gate, one-record and immutable-ADR rules, active-only reuse and creation-time status semantics, deterministic written/not-qualified/skipped/blocked handoff, and `references/adr-template.md` for all three targets without creating a command or prompt) +- `config/pkl/base/optional-workflow-manifest.pkl` (install-time projection of the catalog's optional records into the generated `config/optional-workflows.json` manifest — `schemaVersion` plus one entry per optional workflow carrying `id`, `title`, `description`, `commandSlug`, and `skillSlug`. Optionality never affects generation: all six workflows are still generated for all four targets, so the manifest exists solely to carry optional-workflow identity out of Pkl for install-time and doctor-time consumers) +- `config/pkl/base/decision-skill.pkl` (canonical standalone `sce-decision` package outside the workflow catalog; renders its decision gate, one-record and immutable-ADR rules, active-only reuse and creation-time status semantics, deterministic written/not-qualified/skipped/blocked handoff, and `references/adr-template.md` for all four targets without creating a command or prompt) - `config/pkl/base/workflow-change-to-plan.pkl` (canonical `/change-to-plan` package registering the target-neutral `SKILL.md` body plus `context-load.md`, `plan-authoring.md`, `plan-template.md`, and `output.md` package references; the plan template persists task synchronization lifecycle state and retains the plan format needed by existing plans) - `config/pkl/base/workflow-next-task.pkl` (canonical `/next-task` package registering the target-neutral `SKILL.md` body plus `plan-review.md`, `task-execution.md`, `context-sync.md`, `sync-report.md`, and `output.md` package references; review gates new tasks on synced lifecycle state, execution records pending before task synchronization, and the execution reference defines an explicit Git-baseline-relative handoff consumed by task synchronization) - `config/pkl/base/workflow-validate.pkl` (canonical `/validate` package registering the target-neutral `SKILL.md` body plus `validation.md`, `validation-report.md`, and `output.md` package references; `validation.md` carries the validation steps plus the validation result contract and keeps final validation observational by recording leftover debug/temp artifacts as failure evidence rather than deleting or repairing them; `/validate` does not invoke plan-level context synchronization, and `output.md` holds the `Completion` layout) - `config/pkl/base/workflow-commit.pkl` (canonical `/commit` package registering the target-neutral `SKILL.md` body plus `atomic-commit.md`, `commit-message-style.md`, and `output.md` package references; `atomic-commit.md` owns staged-diff procedure, internal result branching, and commit boundaries, `commit-message-style.md` owns message wording, and both regular and bypass paths read the phase reference only after their pre-phase gate) - `config/pkl/base/workflow-context-sync.pkl` (one role-parameterized source that renders exact, self-contained task and retained plan context-sync skills in named semantic section order, gives each lifecycle role its own composite step heading scale, renders their synced, no-context-change, and blocked report layouts through shared named section renderers from typed role data, and exposes task synchronization to `/next-task` while retaining the plan role without composing it into `/validate`) - `config/pkl/base/workflow-handover.pkl` (canonical `/handover` package with the self-contained, phase-free `sce-handover` skill; its structured composite source has no phases and exposes package-local `references/handover-template.md` plus mode-invariant `references/output.md`, so composite rendering differs from its package-mode form only by the generic composite preamble the shared renderer supplies) -- `config/pkl/base/workflow-brownfield.pkl` (canonical `/brownfield` package with the self-contained, phase-free `sce-brownfield` skill; like `workflow-handover.pkl` its structured composite source has no phases and exposes one `references/output.md` as its sole output document, and its preamble is a semantic reference so composite rendering keeps the workflow's cold-start and gap-fill scope statement the shared renderer has no generic equivalent for. It is the sixth catalog-registered workflow, composed by `workflow-composite.pkl` and generated for all three targets) +- `config/pkl/base/workflow-brownfield.pkl` (canonical `/brownfield` package with the self-contained, phase-free `sce-brownfield` skill; like `workflow-handover.pkl` its structured composite source has no phases and exposes one `references/output.md` as its sole output document, and its preamble is a semantic reference so composite rendering keeps the workflow's cold-start and gap-fill scope statement the shared renderer has no generic equivalent for. It is the sixth catalog-registered workflow, composed by `workflow-composite.pkl` and generated for all four targets) - `config/pkl/base/opencode.pkl` - `config/pkl/base/sce-config-schema.pkl` @@ -28,6 +28,7 @@ Current target renderer helper modules: - `config/pkl/renderers/claude-content.pkl` - `config/pkl/renderers/workflow-composite.pkl` (target-neutral composition of six workflow-level skills and deterministic package-local references; phase-based packages consume named phase, persisted-document, and output documents from their canonical workflow modules, phase-free packages retain output layouts and may expose a persisted-format reference such as handover's template, and target differences remain frontmatter-only) - `config/pkl/renderers/pi-content.pkl` +- `config/pkl/renderers/codex-content.pkl` (fourth target renderer, skills-only: exposes `skillDocuments` from the same shared composition and the decision package with no per-target frontmatter (matching Pi) and no `commands` mapping, since Codex has no command/prompt layer) - `config/pkl/renderers/common.pkl` - `config/pkl/renderers/opencode-metadata.pkl` - `config/pkl/renderers/claude-metadata.pkl` @@ -44,17 +45,18 @@ The scaffold provides stable canonical content-unit identifiers and reusable tar Renderer modules apply target-specific metadata/frontmatter rules while reusing canonical content bodies: -- All three renderers consume the six canonical workflow packages as behavior sources and emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, and Pi render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. -- Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. +- All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. +- Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest; `.codex/hooks.json` and its hook helper script are a separate, not-yet-implemented generation surface. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). - Target renderers remain responsible for formatting target-supported metadata. OpenCode metadata owns thin-agent presentation and compatibility while deriving the ordered permission blocks — non-SCE wildcard allow, `sce-*` wildcard deny, then catalog-owned workflow allows — from catalog role assignments; OpenCode command routing derives the same role and skill identity from the catalog. Claude metadata derives command tools from catalog records. Pi has no metadata module because it adds no target-specific frontmatter. -- `config/pkl/renderers/metadata-coverage-check.pkl` derives commands and exact package-relative workflow-document expectations from the typed catalog and the four workflow-document inventories, adds the unchanged phase-free and decision-package expectations, verifies every command's one-to-one workflow-skill route for all three targets, and forces every rendered document and target metadata lookup to evaluate. -- `config/pkl/renderers/generation-contract-check.pkl` independently derives the complete expected artifact paths from those target document inventories plus explicitly retained non-workflow assets, compares them with `generate.pkl`'s `output.files`, and requires the exact path count declared by the current generation contract — stated as a literal `expectedArtifactPathCount` inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts the generated `config/optional-workflows.json` against the catalog (`optional-workflow-manifest`): every optional workflow appears with its catalog title and both slugs, no core workflow id appears, and `schemaVersion` is present. It also verifies that every required phase reference exists and is cited by its owning `SKILL.md`, scans generated workflow entrypoint `SKILL.md` documents for stale phase-skill slugs and unresolved package-local reference tokens while allowing package-local reference prose to mention its own persisted-format history, asserts the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions on every generated workflow skill, asserts the exact cross-target `sce-decision` paths plus its required gate, status, immutability, handoff, and ADR-template content, permits `sce-decision` references only in `sce-next-task`, verifies the exact catalog-derived OpenCode skill permission order and Code-only OpenCode decision permission, asserts every explicit `sce-*` allow names an emitted OpenCode skill artifact, asserts the generated `sce-handover` `SKILL.md` covers both writer- and loader-mode content on all three targets, asserts the generated `sce-brownfield` `SKILL.md` still carries the bootstrap gate, documentation-discovery sweep, no-network rule, sub-`50` blocking threshold, always-disclosed contradiction contract, and additive-vs-`rebuild` write rule on all three targets, rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), and rejects any generated `SKILL.md` that reproduces one of its sibling `references/output.md` fenced layouts verbatim (`output-dedup`, matched fence markers included), plus nineteen semantic checks for layout-heading resolution, package-local path existence, forbidden validate/commit files, consolidated atomic-commit content, next-task report ownership, cross-target reference parity, stale synchronization wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording (`plan-review-sync-debt-recovery`, asserting the generated `sce-next-task/references/plan-review.md` states both the sync-debt recovery and legacy-migration-failure behavior), the compact completed-task record model (four checks replacing the removed `handoff-identity-fields` persisted-handoff check: `compact-plan-template-schema`, asserting the generated plan-template's new-task and completion examples use the compact `Scope`/`Done when`/`Verify`/`Result`/`Files changed`/`Context impact`/`Context synchronization` fields and name none of the removed `Goal`/`Boundaries (in/out of scope)`/`Verification notes`/`Implementation evidence`/`Verification evidence`/`Context synchronization handoff` fields; `next-task-compact-completion-writing`, asserting `task-execution.md` records execution facts directly on the completed task with no separate handoff/evidence construction; `plan-review-reads-completed-record`, asserting `plan-review.md`'s sync-debt recovery reads the completed task record directly by plan path and task ID rather than a persisted handoff; and `context-sync-validates-task-record`, asserting `context-sync.md` validates the completed task record rather than a persisted handoff), the `/next-task` sync-debt-recovery branch's reference-before-invocation ordering (`sync-debt-recovery-branch`, asserting its citation of `references/context-sync.md` precedes any instruction to run the Task context synchronization phase), the synchronization-debt scan's all-completed-task scope (`plan-review-all-tasks-scope`, asserting `plan-review.md` covers every completed task with no surviving position-relative wording), the sync-debt-recovery branch's blocked-outcome layout routing (`sync-debt-blocked-routing`, asserting its `blocked` branch cites the **Context synchronization blocked** layout rather than **Review blocked**), and the `sce-validate` decision/plan-sync exclusion (`validate-decision-sync-boundary`, asserting no generated `sce-validate` document contains a `sce-decision` reference or plan-context-sync wording). Checked-in negative fixtures prove the existing and nineteen semantic contract failures. -- OpenCode, Claude, and Pi renderers expose command documents plus flattened `{skill slug}/{package-relative path}` skill documents consumed by `config/pkl/generate.pkl`; every target's flattened inventory contains `SKILL.md` and `references/output.md` for each workflow slug plus `sce-decision/SKILL.md` and `sce-decision/references/adr-template.md`. -- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. +- `config/pkl/renderers/metadata-coverage-check.pkl` derives commands and exact package-relative workflow-document expectations from the typed catalog and the workflow-document inventories, adds the unchanged phase-free and decision-package expectations, verifies every command's one-to-one workflow-skill route for OpenCode/Claude/Pi, asserts the same exact skill-document inventory for Codex (no command-route check, since Codex has no commands), and forces every rendered document and target metadata lookup to evaluate. +- `config/pkl/renderers/generation-contract-check.pkl` independently derives the complete expected artifact paths from those target document inventories plus explicitly retained non-workflow assets, compares them with `generate.pkl`'s `output.files`, and requires the exact path count declared by the current generation contract — stated as a literal `expectedArtifactPathCount` inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts the generated `config/optional-workflows.json` against the catalog (`optional-workflow-manifest`): every optional workflow appears with its catalog title and both slugs, no core workflow id appears, and `schemaVersion` is present. It also verifies that every required phase reference exists and is cited by its owning `SKILL.md`, scans generated workflow entrypoint `SKILL.md` documents for stale phase-skill slugs and unresolved package-local reference tokens while allowing package-local reference prose to mention its own persisted-format history, asserts the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions on every generated workflow skill, asserts the exact cross-target `sce-decision` paths plus its required gate, status, immutability, handoff, and ADR-template content, permits `sce-decision` references only in `sce-next-task`, verifies the exact catalog-derived OpenCode skill permission order and Code-only OpenCode decision permission, asserts every explicit `sce-*` allow names an emitted OpenCode skill artifact, asserts the generated `sce-handover` `SKILL.md` covers both writer- and loader-mode content on all four targets, asserts the generated `sce-brownfield` `SKILL.md` still carries the bootstrap gate, documentation-discovery sweep, no-network rule, sub-`50` blocking threshold, always-disclosed contradiction contract, and additive-vs-`rebuild` write rule on all four targets, rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), and rejects any generated `SKILL.md` that reproduces one of its sibling `references/output.md` fenced layouts verbatim (`output-dedup`, matched fence markers included), plus nineteen semantic checks for layout-heading resolution, package-local path existence, forbidden validate/commit files, consolidated atomic-commit content, next-task report ownership, cross-target reference parity, stale synchronization wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording (`plan-review-sync-debt-recovery`, asserting the generated `sce-next-task/references/plan-review.md` states both the sync-debt recovery and legacy-migration-failure behavior), the compact completed-task record model (four checks replacing the removed `handoff-identity-fields` persisted-handoff check: `compact-plan-template-schema`, asserting the generated plan-template's new-task and completion examples use the compact `Scope`/`Done when`/`Verify`/`Result`/`Files changed`/`Context impact`/`Context synchronization` fields and name none of the removed `Goal`/`Boundaries (in/out of scope)`/`Verification notes`/`Implementation evidence`/`Verification evidence`/`Context synchronization handoff` fields; `next-task-compact-completion-writing`, asserting `task-execution.md` records execution facts directly on the completed task with no separate handoff/evidence construction; `plan-review-reads-completed-record`, asserting `plan-review.md`'s sync-debt recovery reads the completed task record directly by plan path and task ID rather than a persisted handoff; and `context-sync-validates-task-record`, asserting `context-sync.md` validates the completed task record rather than a persisted handoff), the `/next-task` sync-debt-recovery branch's reference-before-invocation ordering (`sync-debt-recovery-branch`, asserting its citation of `references/context-sync.md` precedes any instruction to run the Task context synchronization phase), the synchronization-debt scan's all-completed-task scope (`plan-review-all-tasks-scope`, asserting `plan-review.md` covers every completed task with no surviving position-relative wording), the sync-debt-recovery branch's blocked-outcome layout routing (`sync-debt-blocked-routing`, asserting its `blocked` branch cites the **Context synchronization blocked** layout rather than **Review blocked**), and the `sce-validate` decision/plan-sync exclusion (`validate-decision-sync-boundary`, asserting no generated `sce-validate` document contains a `sce-decision` reference or plan-context-sync wording). Checked-in negative fixtures prove the existing and nineteen semantic contract failures. +- OpenCode, Claude, Pi, and Codex renderers expose flattened `{skill slug}/{package-relative path}` skill documents consumed by `config/pkl/generate.pkl` (OpenCode, Claude, and Pi also expose command documents; Codex exposes none); every target's flattened skill-document inventory contains `SKILL.md` and `references/output.md` for each workflow slug plus `sce-decision/SKILL.md` and `sce-decision/references/adr-template.md`. +- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the Codex target tree under `config/.agents/skills/` (the same workflow-package inventories as Pi, byte-identical bodies, no commands, no agents, no settings/plugin manifest); the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. - Generated-file warning markers are not injected by the generator: Markdown outputs render deterministic frontmatter + body, and shared library outputs are emitted without a leading generated warning header. - `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, rejects the repository-local `config/pkl/rendered` evaluation artifact before generation, rejects committed target trees, the generated SCE schema, and `cli/assets/generated`, evaluates exact metadata and generation contracts, confirms the existing and nineteen semantic negative fixtures fail with their contract diagnostics, then delegates two-pass generation, input checks, and payload inventories to `scripts/produce-cli-generated-input.sh`. It projects the producer inventory only to preserve the established report digest path format; it does not rehash generated files. Required-path checks remain fast surface diagnostics, the Pkl contract owns exact complete-path coverage, and forbidden-output checks reject removed generator surfaces. diff --git a/context/context-map.md b/context/context-map.md index 60b3d560..52bbfb5d 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -37,8 +37,8 @@ Feature/domain context: - `context/sce/plan-code-overlap-map.md` (overlap matrix for thin OpenCode Plan/Code routing agents and six workflow packages, including task-only context synchronization ownership) - `context/sce/dedup-ownership-table.md` (canonical owner-vs-consumer boundaries for six workflow packages, canonical phase modules, the shared synchronization skeleton, and thin OpenCode agents) - [Atomic commit workflow](sce/atomic-commit-workflow.md) (`/commit` regular proposal-only mode vs `oneshot`/`skip` bypass mode, staged-truth and plan-citation rules, and the cross-target `sce-commit` package with package-local atomic procedure, message-style, and output references) -- [Brownfield workflow](sce/brownfield-workflow.md) (`/brownfield`, the sixth canonical SCE workflow, generated for OpenCode, Claude, and Pi from `config/pkl/base/workflow-brownfield.pkl`: the `[rebuild] [path ...]` argument contract, bootstrap gate, local-only evidence priority order with documentation sweep and three-month history floor, the `1`–`100` confidence model with its sub-`50` blocking clarification gate, always-disclosed contradiction handling, the additive-by-default writing contract whose sole rewrite path is `rebuild`, and its opt-in install status as the only optional workflow) -- [Handover workflow](sce/handover-workflow.md) (`/handover`, the fifth canonical SCE workflow, generated for OpenCode, Claude, and Pi from `config/pkl/base/workflow-handover.pkl`: dual writer/loader mode routing, the phase-free `sce-handover` package with a package-local persisted-format template and output layouts, active-task-or-timestamped writer naming, staged-plus-unstaged Git fact gathering, substantive four-section validation, concise writer success, and the read-only loader contract) +- [Brownfield workflow](sce/brownfield-workflow.md) (`/brownfield`, the sixth canonical SCE workflow, generated for OpenCode, Claude, and Pi, and as a skill package with no command for Codex, from `config/pkl/base/workflow-brownfield.pkl`: the `[rebuild] [path ...]` argument contract, bootstrap gate, local-only evidence priority order with documentation sweep and three-month history floor, the `1`–`100` confidence model with its sub-`50` blocking clarification gate, always-disclosed contradiction handling, the additive-by-default writing contract whose sole rewrite path is `rebuild`, and its opt-in install status as the only optional workflow) +- [Handover workflow](sce/handover-workflow.md) (`/handover`, the fifth canonical SCE workflow, generated for OpenCode, Claude, and Pi, and as a skill package with no command for Codex, from `config/pkl/base/workflow-handover.pkl`: dual writer/loader mode routing, the phase-free `sce-handover` package with a package-local persisted-format template and output layouts, active-task-or-timestamped writer naming, staged-plus-unstaged Git fact gathering, substantive four-section validation, concise writer success, and the read-only loader contract) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) diff --git a/context/glossary.md b/context/glossary.md index bafda407..a6a47ba3 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -11,21 +11,21 @@ - `generated-input producer`: Repository-owned `scripts/produce-cli-generated-input.sh` contract driven by `config/pkl/generator-inputs.txt`. It is the canonical owner for expanding repository-relative generator inputs, snapshotting their inventory, two-pass Pkl evaluation, byte-tree determinism comparison, payload and canonical-input SHA-256 inventories, input-mutation rejection, atomic output publication, and private staging cleanup. The repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation all consume it. - `Pi workflow package`: Generated Pi workflow surface consisting of one thin prompt in `config/.pi/prompts/` plus the one workflow skill package under `config/.pi/skills/` that the prompt routes to. Phase-based workflows include `SKILL.md`, `references/output.md`, and named phase, persisted-document, or supporting references; phase-free `/brownfield` has the two core files, while `/handover` also has `references/handover-template.md`. Pi currently receives `/change-to-plan`, `/next-task`, `/validate`, `/commit`, `/handover`, and `/brownfield` this way and has no generated agent-role prompts. - `workflow skill package`: One of the six renderer-composed packages (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, `sce-brownfield`) emitted for every target. Its `SKILL.md` owns the canonical phase sequence, internal status branching, user waits and same-session resume behavior, and continuation; each phase-based workflow reads package-local phase references before acting, while `sce-handover` and `sce-brownfield` have no phases; handover adds a package-local persisted-format template and brownfield retains the two-file package. Relevant non-SCE skills may assist inside an active step but return control to it without changing workflow invariants. The sole SCE sibling exception lets successful `/next-task` task synchronization invoke `sce-decision` for one qualifying system-wide decision; `/validate` is validation-only. `references/output.md` remains the sole owner of human-visible layouts. The canonical phase modules remain authoring inputs to composition and are not generated as packages for any target. -- `decision skill package`: Standalone internal `sce-decision` package emitted for OpenCode, Claude, and Pi from `config/pkl/base/decision-skill.pkl`, outside the command workflow catalog. Its `SKILL.md` accepts one qualifying system-wide decision from successful task synchronization, enforces one immutable dated ADR with active-only reuse, creation-time-only `Deprecated`/`Superseded` statuses, and `Accepted` default, and returns deterministic `written`, non-blocking `not_qualified`/`skipped`, or genuine `blocked` handoffs. Its only other file is `references/adr-template.md`; no user-facing command or prompt routes to it, and no workflow invokes it outside the synchronization decision gate. +- `decision skill package`: Standalone internal `sce-decision` package emitted for OpenCode, Claude, Pi, and Codex from `config/pkl/base/decision-skill.pkl`, outside the command workflow catalog. Its `SKILL.md` accepts one qualifying system-wide decision from successful task synchronization, enforces one immutable dated ADR with active-only reuse, creation-time-only `Deprecated`/`Superseded` statuses, and `Accepted` default, and returns deterministic `written`, non-blocking `not_qualified`/`skipped`, or genuine `blocked` handoffs. Its only other file is `references/adr-template.md`; no user-facing command or prompt routes to it, and no workflow invokes it outside the synchronization decision gate. - `workflow catalog`: The typed mapping in `config/pkl/base/workflow-catalog.pkl` that declares each of the six workflows once and owns its command slug, skill slug, title, description, argument hint, OpenCode routing role, Claude allowed tools, and its `optional` flag. Composite identity, OpenCode routing/permissions, Claude tool frontmatter, and metadata coverage derive from these records; behavior remains in canonical phase modules and formatting remains renderer-owned. -- `optional workflow`: A catalog workflow whose `WorkflowRecord.optional` flag is `true`. Optionality is an install-time concern only: the workflow is still authored, composed, and generated for OpenCode, Claude, and Pi exactly like a core workflow, and its generated files remain part of the ephemeral payload and the generation contract. `brownfield` is the only optional workflow; the other five leave the flag at its `false` default. +- `optional workflow`: A catalog workflow whose `WorkflowRecord.optional` flag is `true`. Optionality is an install-time concern only: the workflow is still authored, composed, and generated for OpenCode, Claude, Pi, and Codex exactly like a core workflow, and its generated files remain part of the ephemeral payload and the generation contract. `brownfield` is the only optional workflow; the other five leave the flag at its `false` default. - `optional-workflow manifest`: The generated `config/optional-workflows.json` artifact rendered by `config/pkl/base/optional-workflow-manifest.pkl`. It carries `schemaVersion` plus one `workflows` entry per optional workflow with its `id`, `title`, `description`, `commandSlug`, and `skillSlug`, and is the only carrier of optional-workflow identity outside Pkl. `generation-contract-check.pkl` asserts its content against the catalog rather than merely permitting the path. - `embedded optional-workflow catalog`: `OPTIONAL_WORKFLOWS`, the `&[OptionalWorkflow]` static that `cli/build.rs` generates into Cargo `OUT_DIR/optional_workflows.rs` from the optional-workflow manifest and that `cli/src/services/setup/mod.rs` includes. It is the CLI's only source of optional-workflow identity (`id`, `title`, `description`, `command_slug`, `skill_slug`), so the accepted `--workflow` values, the interactive prompt rows, the persisted selection, and doctor's expectations all derive from Pkl rather than from Rust literals. - `optional-workflow selection`: The set of optional workflow ids a repository has opted into. `iter_embedded_assets_for_setup_target_with_selection` in `cli/src/services/setup/mod.rs` applies it by excluding each unselected workflow's `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree per target, leaving all other embedded assets untouched. `sce setup` resolves it per run — from the interactive multi-select, else `--workflow`, else the persisted value — installs by it, and persists it; its persisted form is the `integrations.optional_workflows` config key. `sce doctor` reads that same persisted key and applies the same filter, so it expects an optional workflow's files only where the repository opted in. See [setup local bootstrap](sce/setup-repo-local-config-bootstrap.md). - `sce setup --workflow`: Repeatable `sce setup` flag naming one optional workflow id to install for the run. Passing it at all makes the listed ids the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run never silently uninstalls a previously selected optional workflow. Unknown ids are rejected before any file is written, with the embedded catalog's available ids named in the error. It is rejected alongside `--bootstrap-context` and on a hooks-only run, neither of which installs target assets. - `integrations.optional_workflows`: Repo-local `sce/config.json` key recording a repository's optional-workflow selection as a unique array of optional workflow ids. Its accepted values are derived from the workflow catalog's `optional` records in `config/pkl/base/sce-config-schema.pkl`, and `cli/src/services/config/` parses it into `IntegrationsConfig.optional_workflows` alongside `integrations.target`, validating each id against the embedded optional-workflow catalog. See [CLI config precedence contract](cli/config-precedence-contract.md). -- `sce-handover`: Self-contained skill package (`SKILL.md`, `references/handover-template.md`, and `references/output.md`) invoked by the `/handover` command, registered in `config/pkl/base/workflow-catalog.pkl` and generated for OpenCode, Claude, and Pi. Dual-mode: empty arguments select writer mode, which gathers session and repository facts and writes exactly one handover document; one path argument selects read-only loader mode, which rejects missing, empty, or unreplaced-placeholder-only required sections before presenting an existing handover for continuation. It has no phases or SCE workflow handoffs; relevant non-SCE helpers, if used, return control to the active step. See [Handover workflow](sce/handover-workflow.md). -- `sce-brownfield`: Self-contained, phase-free skill package (`SKILL.md` plus `references/output.md`) invoked by `/brownfield` to reconstruct durable `context/` memory from an existing repository's own evidence. Its canonical source is `config/pkl/base/workflow-brownfield.pkl`; it is the sixth record in `config/pkl/base/workflow-catalog.pkl` and is generated for OpenCode, Claude, and Pi under the `shared-context-code` routing role. Local evidence only, in priority order (current code, then executable configuration, then discovered documentation plus argument-supplied paths, then at least three months of Git history), with no network access; it never creates the `context/` root and never writes outside it. See [Brownfield workflow](sce/brownfield-workflow.md). +- `sce-handover`: Self-contained skill package (`SKILL.md`, `references/handover-template.md`, and `references/output.md`) invoked by the `/handover` command, registered in `config/pkl/base/workflow-catalog.pkl` and generated for OpenCode, Claude, and Pi (Codex generates the same skill package with no command to invoke it). Dual-mode: empty arguments select writer mode, which gathers session and repository facts and writes exactly one handover document; one path argument selects read-only loader mode, which rejects missing, empty, or unreplaced-placeholder-only required sections before presenting an existing handover for continuation. It has no phases or SCE workflow handoffs; relevant non-SCE helpers, if used, return control to the active step. See [Handover workflow](sce/handover-workflow.md). +- `sce-brownfield`: Self-contained, phase-free skill package (`SKILL.md` plus `references/output.md`) invoked by `/brownfield` to reconstruct durable `context/` memory from an existing repository's own evidence. Its canonical source is `config/pkl/base/workflow-brownfield.pkl`; it is the sixth record in `config/pkl/base/workflow-catalog.pkl` and is generated for OpenCode, Claude, and Pi under the `shared-context-code` routing role, and for Codex with no command to invoke it. Local evidence only, in priority order (current code, then executable configuration, then discovered documentation plus argument-supplied paths, then at least three months of Git history), with no network access; it never creates the `context/` root and never writes outside it. See [Brownfield workflow](sce/brownfield-workflow.md). - brownfield confidence model: The internal `1`–`100` score `sce-brownfield` assigns to every fact it would write as durable truth, banded as `Verified` (`90`–`100`), `Strongly supported` (`70`–`89`), `Inferred` (`50`–`69`), and `Clarification required` (`1`–`49`), plus `Contradiction resolved` for a fact scored after conflicting evidence was resolved. Anything below `50` blocks with grouped clarification questions and is never written as truth. Scores are internal state and chat evidence only; no score is written under `context/`. - brownfield `rebuild` mode: The mode `sce-brownfield` enters when the literal token `rebuild` is the first argument, and the only thing that grants it rewrite authority over existing context files. Writes are otherwise additive — missing files and missing domains only. Even in `rebuild` mode no context file is deleted, `context/plans/`, `context/handovers/`, `context/decisions/`, and `context/tmp/` are untouched, and a file with uncommitted changes is not modified. The mode is never inferred from conversation content or repository state. - handover document: The four-required-section Markdown file (`Current Task State`, `Decisions Made`, `Open Questions / Blockers`, `Next Recommended Step`, plus a trailing `Assumptions` section) that `sce-handover` writer mode persists under `context/handovers/`, named by the active plan task or a collision-safe timestamp when no single task is unambiguous. - `non-SCE helper skill composition`: The workflow rule shared by every generated SCE workflow skill: a relevant non-SCE skill may assist during the active step, but it is not a workflow handoff; control returns to the active step and canonical phase order, gates, waits, writes, validation, stops, and terminal output remain unchanged. Arbitrary SCE workflow chaining remains prohibited, with only the synchronization-scoped `sce-decision` exception. -- `workflow composite renderer`: The shared, target-neutral Pkl module at `config/pkl/renderers/workflow-composite.pkl` that renders each canonical workflow as one workflow-level `SKILL.md` plus deterministic package-local documents. The four phase-based workflows emit named phase, persisted-document, and supporting references; phase-free workflows emit `references/output.md` beside the entrypoint, with handover also emitting its persisted-format template. It requires structured composite sources for all six workflows and performs no frontmatter stripping or prose-wide internalization. All three targets render through it, parameterized only by the extra frontmatter each supports. +- `workflow composite renderer`: The shared, target-neutral Pkl module at `config/pkl/renderers/workflow-composite.pkl` that renders each canonical workflow as one workflow-level `SKILL.md` plus deterministic package-local documents. The four phase-based workflows emit named phase, persisted-document, and supporting references; phase-free workflows emit `references/output.md` beside the entrypoint, with handover also emitting its persisted-format template. It requires structured composite sources for all six workflows and performs no frontmatter stripping or prose-wide internalization. All four targets render through it, parameterized only by the extra frontmatter each supports. - `structured workflow rendering`: Canonical Pkl representation centered on the shared model in `workflow-content.pkl`, where package-vs-composite mode is selected through typed frontmatter, body, semantic-reference, structured-document, composite-source, heading-scale (`PhaseHeadings`), and single-mode block values before Markdown assembly. Canonical workflow modules supply workflow-specific behavior and migrated package-local phase, persisted-document, and output documents as named values; all six workflows render their commands and applicable internal documents without frontmatter stripping or prose-wide replacement. - `canonical phase module`: One of the eight phase definitions in `config/pkl/base/workflow-*.pkl` (`sce-context-load`, `sce-plan-authoring`, `sce-plan-review`, `sce-task-execution`, `sce-task-context-sync`, `sce-validation`, `sce-plan-context-sync`, `sce-atomic-commit`). Each is the single behavioral source for its phase and an authoring input to the composite renderer. Since 2026-07-29 no target generates them as installable skill packages; the names denote canonical source and the internal phases inside a composed `SKILL.md`. - `extra frontmatter lines`: The newline-terminated string a target passes to the workflow composite renderer carrying only the frontmatter its skills or commands support (for example `compatibility: claude`, or an `allowed-tools:` line). It is the sole per-target parameter of composition; a target that adds no frontmatter passes the empty string. diff --git a/context/overview.md b/context/overview.md index a3c91969..cba428c4 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 107-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 133-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer); it has no CLI setup/install wiring yet, so it is a generation-pipeline participant only until later Codex-integration tasks land. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -61,7 +61,7 @@ The downstream publish-stage implementation is now complete for both registries: The repository root now also owns the canonical Biome contract for the current JavaScript tooling slice: `biome.json` scopes formatting/linting to `npm/` and the shared `config/lib/` plugin package root while excluding package-local `node_modules/`, and the root Nix dev shell provides the `biome` binary so contributors do not need a host-installed formatter/linter for those areas. Flatpak validation/build orchestration is reduced to a minimal app surface: Linux flake apps expose the umbrella `sce-flatpak` (`nix run .#sce-flatpak -- ` for `validate`, `prepare-local-manifest`, etc.) plus `release-flatpak-package`, `release-flatpak-bundle`, and the `regenerate-flatpak-manifest` / `regenerate-cargo-sources` helpers; the previously separate `flatpak-validate`, `flatpak-local-manifest`, and `flatpak-build` wrapper apps are removed. Default `nix flake check` keeps the lightweight Nix-built static/AppStream validator plus the parity checks (`flatpak-manifest-parity`, `cargo-sources-parity`) and does not run a network-heavy Flatpak build. The former standalone install-channel integration runner and `install-channel-integration-tests` flake app are not active current-state surfaces. Shared Context Plan and Shared Context Code remain separate OpenCode routing roles: the generated Plan agent routes only to `/change-to-plan`, while the generated Code agent routes to `/next-task`, `/validate`, `/commit`, `/handover`, and `/brownfield`. Workflow behavior lives in the six workflow entrypoints and their six skill packages rather than in agent bodies. `config/pkl/base/workflow-catalog.pkl` assigns each workflow to its role, and OpenCode command routing plus each agent's ordered `skill:` permissions derive from those records: ordinary non-SCE skills are allowed by the wildcard, arbitrary `sce-*` skills are denied, and only the role's owned workflows are allowed after that deny — `sce-change-to-plan` for Plan; `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield` for Code. The Code agent additionally allows `sce-decision` for task synchronization. -The canonical workflow definitions remain phase-decomposed as authoring source: `/change-to-plan` sequences `sce-context-load` then `sce-plan-authoring`; `/next-task` sequences `sce-plan-review`, `sce-task-execution`, and `sce-task-context-sync`; `/validate` runs `sce-validation` only and reports its Validation Report; `/commit` delegates staged-diff analysis and message generation to `sce-atomic-commit`; `/handover` has no phases, since writer and loader mode has no SCE sibling handoff or wait mid-run; `/brownfield` likewise has none, since its single skill owns investigation, the blocking clarification gate, writing, and reporting itself. Relevant non-SCE skills may help inside an active workflow step, but they return control to that step without changing its canonical invariants. No target generates those phase modules as packages. All three consume them as inputs to the shared `workflow-composite.pkl` renderer, which composes each workflow into one skill package. Every workflow supplies typed package/composite render values for frontmatter, bodies, semantic references, phases, persisted-document formats where applicable, and output references; the composite renderer performs no prose-wide internalization or frontmatter stripping. +The canonical workflow definitions remain phase-decomposed as authoring source: `/change-to-plan` sequences `sce-context-load` then `sce-plan-authoring`; `/next-task` sequences `sce-plan-review`, `sce-task-execution`, and `sce-task-context-sync`; `/validate` runs `sce-validation` only and reports its Validation Report; `/commit` delegates staged-diff analysis and message generation to `sce-atomic-commit`; `/handover` has no phases, since writer and loader mode has no SCE sibling handoff or wait mid-run; `/brownfield` likewise has none, since its single skill owns investigation, the blocking clarification gate, writing, and reporting itself. Relevant non-SCE skills may help inside an active workflow step, but they return control to that step without changing its canonical invariants. No target generates those phase modules as packages. All four consume them as inputs to the shared `workflow-composite.pkl` renderer, which composes each workflow into one skill package. Every workflow supplies typed package/composite render values for frontmatter, bodies, semantic references, phases, persisted-document formats where applicable, and output references; the composite renderer performs no prose-wide internalization or frontmatter stripping. Every target preserves the same gates and lifecycle semantics through six renderer-composed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`. Each thin command or Pi prompt invokes exactly one corresponding skill, and OpenCode command frontmatter names that single skill as both `entry-skill` and the whole `skills` chain. Each phase-based package keeps control flow, internal status branching, waits, and same-session resume in `SKILL.md`, while package-local Markdown references own phase instructions and persisted-document formats; `references/output.md` remains the sole definition of human-visible gates and terminal Markdown. Phase-free `/handover` retains `SKILL.md`, `references/handover-template.md`, and `references/output.md`, while `/brownfield` retains `SKILL.md` plus `references/output.md`. No target emits phase-skill packages or inter-skill machine contracts; phase statuses stay internal to one skill invocation. Context sync uses an important-change gate: cross-cutting/policy/architecture/terminology changes require root shared-file edits, while localized tasks run verify-only root checks without default churn. OpenCode and Claude no longer generate legacy bootstrap or context-sync skills; `/commit` and `/handover` are generated only as catalog-registered composite workflow packages. OpenCode retains only thin routing agents, while Claude emits no agents. The superseded grouped Markdown catalog and automated OpenCode profile have been removed from Pkl ownership and generated outputs. @@ -76,9 +76,9 @@ The setup command parser/dispatch now also supports composable setup+hooks runs ## Repository model -- Author the six SCE workflows in `config/pkl/base/workflow-{change-to-plan,next-task,validate,commit,handover,brownfield}.pkl` using the self-contained package and structured-rendering model in `workflow-content.pkl`; all six render package and composite forms directly from typed semantic values. `workflow-context-sync.pkl` renders task and plan context-sync skills from explicit role data ordered as named frontmatter, purpose, input, workflow, boundaries, and completion sections, renders their reports through named introduction, status-variant, and rules sections driven by a typed synchronization-report role, and exposes both roles as mode-aware structured phases for their owning workflow compositions. Keep command slug, skill slug, title, description, argument hint, OpenCode role, and Claude allowed tools in the typed `workflow-catalog.pkl`. OpenCode, Claude, and Pi all compose each full workflow into one skill and one `references/output.md` through the shared `workflow-composite.pkl` renderer, which is parameterized only by the extra frontmatter each target supports. +- Author the six SCE workflows in `config/pkl/base/workflow-{change-to-plan,next-task,validate,commit,handover,brownfield}.pkl` using the self-contained package and structured-rendering model in `workflow-content.pkl`; all six render package and composite forms directly from typed semantic values. `workflow-context-sync.pkl` renders task and plan context-sync skills from explicit role data ordered as named frontmatter, purpose, input, workflow, boundaries, and completion sections, renders their reports through named introduction, status-variant, and rules sections driven by a typed synchronization-report role, and exposes both roles as mode-aware structured phases for their owning workflow compositions. Keep command slug, skill slug, title, description, argument hint, OpenCode role, and Claude allowed tools in the typed `workflow-catalog.pkl`. OpenCode, Claude, Pi, and Codex all compose each full workflow into one skill and one `references/output.md` through the shared `workflow-composite.pkl` renderer, which is parameterized only by the extra frontmatter each target supports. - Apply target-specific metadata/rendering in `config/pkl/renderers/`. -- Use `config/pkl/generate.pkl` to emit the logical `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and SCE schema layouts only under temporary generation roots, Cargo `OUT_DIR`, or packaging fallbacks. +- Use `config/pkl/generate.pkl` to emit the logical `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, `config/.agents/**`, and SCE schema layouts only under temporary generation roots, Cargo `OUT_DIR`, or packaging fallbacks. - Treat generated outputs as ephemeral build/package artifacts, never repository editing surfaces. ## Ownership boundaries @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter, since it has no CLI setup/install wiring yet. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index 9aa9ba17..f31c7e83 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -97,7 +97,7 @@ - Use `config/pkl/generate.pkl` as the single generation module for authored config outputs. Flatten self-contained workflow skill documents as `{skill slug}/{package-relative path}` so nested references are emitted deterministically without sibling-package dependencies. - Use `config/pkl/README.md` as the contributor-facing runbook for prerequisites, ownership boundaries, regeneration steps, and troubleshooting. - Run multi-file generation only into an explicit temporary output root, for example `nix run .#pkl-generate -- "$(mktemp -d)"`; never evaluate with `-m .`. -- Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 107-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. +- Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 133-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. - Keep this contract anchored to the root `nix flake check` `pkl-generated` derivation. Removed target paths are forbidden repository artifacts even though the same path names remain valid inside temporary payload roots. - Treat `nix run .#pkl-check-generated` and `nix flake check` as the lightweight post-task verification baseline and run both after each completed task. - Keep `output.files` limited to payload-relative paths (`config/.opencode/{agent,command,skills,lib,plugins,opencode.json}`, `config/.claude/{commands,skills,hooks,settings.json}` with no Claude agents, `config/.pi/{prompts,skills,extensions}`, and the generated schema). Do not emit `config/automated/.opencode`. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 4fb87782..be4ad8a5 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -102,13 +102,18 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: none — an internal helper addition inside `cli/src/services/hooks/mod.rs` with no dispatcher/CLI wiring yet (deferred to T06+); no user-visible behavior, public interface, or documented architecture changed. - Context synchronization: synced -- [ ] T02: `Generate Codex workflow Skills into .agents/skills/` (status:todo) +- [x] T02: `Generate Codex workflow Skills into .agents/skills/` (status:done) - Task ID: T02 - Scope: In — a Codex Pkl renderer (parallel to `opencode-content.pkl`/`claude-content.pkl`/`pi-content.pkl`) consuming the same `workflow-composite.pkl` composition and canonical `workflow-catalog.pkl`/workflow modules to emit `.agents/skills/{skill-slug}/SKILL.md` (and package-local references) for the five core workflows, honoring the existing optional-workflow catalog for `brownfield`; extend `config/pkl/generate.pkl` output mappings, `config/pkl/renderers/metadata-coverage-check.pkl`, and `config/pkl/renderers/generation-contract-check.pkl` for the new Codex artifact inventory (Codex adds no per-target frontmatter, matching Pi). Out — `.codex/` hook assets (T03), any Rust/CLI change, any `AGENTS.md` generation. - Dependencies: none - Done when: `nix run .#pkl-generate -- "$(mktemp -d)"` produces `.agents/skills/sce-change-to-plan/SKILL.md`, `.agents/skills/sce-next-task/SKILL.md`, `.agents/skills/sce-validate/SKILL.md`, `.agents/skills/sce-commit/SKILL.md`, `.agents/skills/sce-handover/SKILL.md` unconditionally, and `.agents/skills/sce-brownfield/SKILL.md` only when the catalog marks it selected for the run; `nix run .#pkl-check-generated` passes with the updated exact-path contract; no `.agents/commands/` output exists. - Verify: `nix run .#pkl-check-generated`. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `config/pkl/renderers/codex-content.pkl` (new), `config/pkl/generate.pkl`, `config/pkl/renderers/metadata-coverage-check.pkl`, `config/pkl/renderers/generation-contract-check.pkl` + - Result: Added `codex-content.pkl` mirroring `pi-content.pkl` exactly (empty extra-frontmatter, no `commands` mapping since Codex has no command dir), exposing only `skillDocuments` built from `workflowResults.skillDocuments.apply("")` plus `decision.skillDocuments.apply("")`. Wired its output into `generate.pkl` under `config/.agents/skills/`. Extended `metadata-coverage-check.pkl` with a `codex-skill-documents` exact-key inventory check (same `expectedSkillDocumentPaths` used for OpenCode/Claude/Pi) plus a forced-render coverage block; no command-route checks were added since Codex has no commands. Extended `generation-contract-check.pkl`: imported `codex-content.pkl`; folded its 26 documents into `expectedArtifactPaths` (bumping `expectedArtifactPathCount` 107 → 133) and `workflowDocuments`; added `.agents` to the `expectedDecisionDocumentPaths` and `assertPhaseReferenceContract` target lists; extended `assertTargetNeutralReferences` to also require the Codex reference body to match Pi/Claude/OpenCode when a Codex path exists, while preserving the original thrown diagnostic text unchanged (so `check-generated.sh`'s substring-matched negative fixture still passes) via a `containsKey` guard rather than an unconditional Codex comparison; bumped `assertHandoverContent`/`assertBrownfieldContent` expected document count 3 → 4. Verified generated output directly: `.agents/skills/**` contains exactly the five core `SKILL.md` files plus `sce-brownfield` and the internal `sce-decision` package, no `.agents/commands/` directory exists, and Codex's `sce-change-to-plan/SKILL.md` is byte-identical to Pi's (confirming no per-target frontmatter leaked in). + - Verify: `nix run .#pkl-check-generated` — passed: "Ephemeral Pkl generation passed: 133 files, inventory sha256 c4d6ff1cf7f09e2f2b2236a9888de0cb4987700a5d36d767d0eeefdfc4266fb8." All `generation-contract-check.pkl` contract checks and `metadata-coverage-check.pkl` inventory checks evaluated successfully (both `pkl eval` directly and via the full `check-generated.sh` negative-fixture suite). + - Context impact: root (revised from the initially reported `none` during synchronization — the root pass found the reported classification understated it). The shared canonical Pkl generation pipeline (`workflow-composite.pkl`/`decision-skill.pkl` composition, exact-path generation contract) now produces a fourth target, and the exact artifact-path count the contract enforces changed from 107 to 133, which several root context files stated as fact. `sce setup --codex`/`integrations.target` CLI wiring still lands in T04/T05. + - Context synchronization: synced - [ ] T03: `Generate Codex hooks (.codex/hooks.json and hook helper script)` (status:todo) - Task ID: T03 From 940a697b33e3048810585ebf728dd865924e6e52 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 10:13:18 +0200 Subject: [PATCH 03/20] config: Generate Codex hook assets Add canonical Pkl renders for `.codex/hooks.json` and the fail-open install-guidance hook so all registered Codex lifecycle events dispatch through `sce hooks codex`. Extend the generation mappings and exact artifact contract to include the two outputs, and document the resulting Codex surface and completed integration task. Plan: codex-cli-integration (T03) Co-authored-by: SCE --- config/pkl/generate.pkl | 6 ++ config/pkl/renderers/codex-content.pkl | 67 +++++++++++++++++++ .../renderers/generation-contract-check.pkl | 9 ++- context/architecture.md | 8 +-- context/overview.md | 4 +- context/patterns.md | 2 +- context/plans/codex-cli-integration.md | 13 ++-- 7 files changed, 95 insertions(+), 14 deletions(-) diff --git a/config/pkl/generate.pkl b/config/pkl/generate.pkl index 52fd938c..f6d69a30 100644 --- a/config/pkl/generate.pkl +++ b/config/pkl/generate.pkl @@ -63,6 +63,12 @@ output { text = "\(document.text)\n" } } + ["config/.codex/hooks.json"] { + text = codex.hooksJson.rendered + } + ["config/.codex/hooks/run-sce-or-show-install-guidance.sh"] { + text = codex.sceHookScript.rendered + } ["config/.opencode/lib/bash-policy-presets.json"] { text = bashPolicyPresetCatalogSource } diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl index 644d45e4..b7d1276d 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -1,4 +1,5 @@ import "../base/decision-skill.pkl" as decision +import "common.pkl" as common import "workflow-composite.pkl" as workflowResults /// Codex has no command-routed entrypoints — it discovers skills directly, with @@ -11,3 +12,69 @@ skillDocuments { [path] = document } } + +local missingSceInstallMessage = "sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli" + +local codexSceHookScriptPath = ".codex/hooks/run-sce-or-show-install-guidance.sh" + +local codexSceHookCommand = "bash \\\"\(codexSceHookScriptPath)\\\" sce hooks codex" + +/// Every Codex lifecycle event Codex routes to the SCE hook is dispatched +/// through a single command (`sce hooks codex`); the typed dispatcher inside +/// that command (T06) distinguishes event/tool combinations from the JSON +/// payload it receives on stdin, so no per-event command varies here. +hooksJson = new common.RenderedTextFile { + slug = "hooks" + rendered = """ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexSceHookCommand)" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexSceHookCommand)" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\(codexSceHookCommand)" + } + ] + } + ] + } +} +""" +} + +sceHookScript = new common.RenderedTextFile { + slug = "sce-hook" + rendered = """ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v sce >/dev/null 2>&1; then + echo "\(missingSceInstallMessage)" >&2 + exit 0 +fi + +exec "$@" +""" +} diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index 750c2b68..dce5ff60 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -43,6 +43,8 @@ hidden expectedArtifactPaths = new Mapping { for (path, _ in codex.skillDocuments) { ["config/.agents/skills/\(path)"] = true } + ["config/.codex/hooks.json"] = true + ["config/.codex/hooks/run-sce-or-show-install-guidance.sh"] = true ["config/schema/sce-config.schema.json"] = true ["config/optional-workflows.json"] = true @@ -270,9 +272,10 @@ local forbiddenWorkflowReferenceTokens = new Listing { /// Six cross-target workflow packages, with package-local phase references and /// supporting documents on the four phase-based workflows, plus the decision /// package and retained non-workflow assets, across OpenCode, Claude, Pi, and -/// Codex. Stating the total as a literal makes an unintended inventory change -/// fail here instead of silently becoming the new expectation. -local expectedArtifactPathCount = 133 +/// Codex, plus Codex's `.codex/hooks.json` and its install-guidance hook +/// script. Stating the total as a literal makes an unintended inventory +/// change fail here instead of silently becoming the new expectation. +local expectedArtifactPathCount = 135 local assertExactArtifactPaths = (actual: Mapping) -> if ( diff --git a/context/architecture.md b/context/architecture.md index a9837abd..92fe3cde 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -2,7 +2,7 @@ ## Config generation boundary (current approved design) -The repository keeps no committed OpenCode, Claude, Pi, or Codex generated target trees. `config/.opencode`, `config/.claude`, `config/.pi`, and `config/.agents` are logical payload layouts emitted only beneath temporary generation roots, Cargo `OUT_DIR`, and packaging-only fallback directories. +The repository keeps no committed OpenCode, Claude, Pi, or Codex generated target trees. `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and `config/.codex` are logical payload layouts emitted only beneath temporary generation roots, Cargo `OUT_DIR`, and packaging-only fallback directories. Authored config content is standardized around one canonical Pkl source model with target-specific rendering applied later in the pipeline. @@ -28,7 +28,7 @@ Current target renderer helper modules: - `config/pkl/renderers/claude-content.pkl` - `config/pkl/renderers/workflow-composite.pkl` (target-neutral composition of six workflow-level skills and deterministic package-local references; phase-based packages consume named phase, persisted-document, and output documents from their canonical workflow modules, phase-free packages retain output layouts and may expose a persisted-format reference such as handover's template, and target differences remain frontmatter-only) - `config/pkl/renderers/pi-content.pkl` -- `config/pkl/renderers/codex-content.pkl` (fourth target renderer, skills-only: exposes `skillDocuments` from the same shared composition and the decision package with no per-target frontmatter (matching Pi) and no `commands` mapping, since Codex has no command/prompt layer) +- `config/pkl/renderers/codex-content.pkl` (fourth target renderer: exposes `skillDocuments` from the same shared composition and the decision package with no per-target frontmatter (matching Pi) and no `commands` mapping, since Codex has no command/prompt layer, plus `hooksJson`/`sceHookScript` for `.codex/hooks.json` and its fail-open install-guidance hook script, mirroring `claude-content.pkl`'s `settings`/`sceHookScript` shape) - `config/pkl/renderers/common.pkl` - `config/pkl/renderers/opencode-metadata.pkl` - `config/pkl/renderers/claude-metadata.pkl` @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest; `.codex/hooks.json` and its hook helper script are a separate, not-yet-implemented generation surface. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher itself is not yet implemented. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). @@ -56,7 +56,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - `config/pkl/renderers/metadata-coverage-check.pkl` derives commands and exact package-relative workflow-document expectations from the typed catalog and the workflow-document inventories, adds the unchanged phase-free and decision-package expectations, verifies every command's one-to-one workflow-skill route for OpenCode/Claude/Pi, asserts the same exact skill-document inventory for Codex (no command-route check, since Codex has no commands), and forces every rendered document and target metadata lookup to evaluate. - `config/pkl/renderers/generation-contract-check.pkl` independently derives the complete expected artifact paths from those target document inventories plus explicitly retained non-workflow assets, compares them with `generate.pkl`'s `output.files`, and requires the exact path count declared by the current generation contract — stated as a literal `expectedArtifactPathCount` inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts the generated `config/optional-workflows.json` against the catalog (`optional-workflow-manifest`): every optional workflow appears with its catalog title and both slugs, no core workflow id appears, and `schemaVersion` is present. It also verifies that every required phase reference exists and is cited by its owning `SKILL.md`, scans generated workflow entrypoint `SKILL.md` documents for stale phase-skill slugs and unresolved package-local reference tokens while allowing package-local reference prose to mention its own persisted-format history, asserts the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions on every generated workflow skill, asserts the exact cross-target `sce-decision` paths plus its required gate, status, immutability, handoff, and ADR-template content, permits `sce-decision` references only in `sce-next-task`, verifies the exact catalog-derived OpenCode skill permission order and Code-only OpenCode decision permission, asserts every explicit `sce-*` allow names an emitted OpenCode skill artifact, asserts the generated `sce-handover` `SKILL.md` covers both writer- and loader-mode content on all four targets, asserts the generated `sce-brownfield` `SKILL.md` still carries the bootstrap gate, documentation-discovery sweep, no-network rule, sub-`50` blocking threshold, always-disclosed contradiction contract, and additive-vs-`rebuild` write rule on all four targets, rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), and rejects any generated `SKILL.md` that reproduces one of its sibling `references/output.md` fenced layouts verbatim (`output-dedup`, matched fence markers included), plus nineteen semantic checks for layout-heading resolution, package-local path existence, forbidden validate/commit files, consolidated atomic-commit content, next-task report ownership, cross-target reference parity, stale synchronization wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording (`plan-review-sync-debt-recovery`, asserting the generated `sce-next-task/references/plan-review.md` states both the sync-debt recovery and legacy-migration-failure behavior), the compact completed-task record model (four checks replacing the removed `handoff-identity-fields` persisted-handoff check: `compact-plan-template-schema`, asserting the generated plan-template's new-task and completion examples use the compact `Scope`/`Done when`/`Verify`/`Result`/`Files changed`/`Context impact`/`Context synchronization` fields and name none of the removed `Goal`/`Boundaries (in/out of scope)`/`Verification notes`/`Implementation evidence`/`Verification evidence`/`Context synchronization handoff` fields; `next-task-compact-completion-writing`, asserting `task-execution.md` records execution facts directly on the completed task with no separate handoff/evidence construction; `plan-review-reads-completed-record`, asserting `plan-review.md`'s sync-debt recovery reads the completed task record directly by plan path and task ID rather than a persisted handoff; and `context-sync-validates-task-record`, asserting `context-sync.md` validates the completed task record rather than a persisted handoff), the `/next-task` sync-debt-recovery branch's reference-before-invocation ordering (`sync-debt-recovery-branch`, asserting its citation of `references/context-sync.md` precedes any instruction to run the Task context synchronization phase), the synchronization-debt scan's all-completed-task scope (`plan-review-all-tasks-scope`, asserting `plan-review.md` covers every completed task with no surviving position-relative wording), the sync-debt-recovery branch's blocked-outcome layout routing (`sync-debt-blocked-routing`, asserting its `blocked` branch cites the **Context synchronization blocked** layout rather than **Review blocked**), and the `sce-validate` decision/plan-sync exclusion (`validate-decision-sync-boundary`, asserting no generated `sce-validate` document contains a `sce-decision` reference or plan-context-sync wording). Checked-in negative fixtures prove the existing and nineteen semantic contract failures. - OpenCode, Claude, Pi, and Codex renderers expose flattened `{skill slug}/{package-relative path}` skill documents consumed by `config/pkl/generate.pkl` (OpenCode, Claude, and Pi also expose command documents; Codex exposes none); every target's flattened skill-document inventory contains `SKILL.md` and `references/output.md` for each workflow slug plus `sce-decision/SKILL.md` and `sce-decision/references/adr-template.md`. -- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the Codex target tree under `config/.agents/skills/` (the same workflow-package inventories as Pi, byte-identical bodies, no commands, no agents, no settings/plugin manifest); the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. +- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the Codex target tree under `config/.agents/skills/` (the same workflow-package inventories as Pi, byte-identical bodies, no commands, no agents, no settings/plugin manifest) plus its separate `.codex/hooks.json` and `.codex/hooks/run-sce-or-show-install-guidance.sh` hook-registration outputs; the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. - Generated-file warning markers are not injected by the generator: Markdown outputs render deterministic frontmatter + body, and shared library outputs are emitted without a leading generated warning header. - `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, rejects the repository-local `config/pkl/rendered` evaluation artifact before generation, rejects committed target trees, the generated SCE schema, and `cli/assets/generated`, evaluates exact metadata and generation contracts, confirms the existing and nineteen semantic negative fixtures fail with their contract diagnostics, then delegates two-pass generation, input checks, and payload inventories to `scripts/produce-cli-generated-input.sh`. It projects the producer inventory only to preserve the established report digest path format; it does not rehash generated files. Required-path checks remain fast surface diagnostics, the Pkl contract owns exact complete-path coverage, and forbidden-output checks reject removed generator surfaces. diff --git a/context/overview.md b/context/overview.md index cba428c4..734bb816 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 133-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer); it has no CLI setup/install wiring yet, so it is a generation-pipeline participant only until later Codex-integration tasks land. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; it has no CLI setup/install wiring yet, so it is a generation-pipeline participant only until later Codex-integration tasks land. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter, since it has no CLI setup/install wiring yet. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex has no CLI setup/install wiring yet, so it remains a generation-pipeline participant only until later Codex-integration tasks land. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index f31c7e83..ba2e964a 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -97,7 +97,7 @@ - Use `config/pkl/generate.pkl` as the single generation module for authored config outputs. Flatten self-contained workflow skill documents as `{skill slug}/{package-relative path}` so nested references are emitted deterministically without sibling-package dependencies. - Use `config/pkl/README.md` as the contributor-facing runbook for prerequisites, ownership boundaries, regeneration steps, and troubleshooting. - Run multi-file generation only into an explicit temporary output root, for example `nix run .#pkl-generate -- "$(mktemp -d)"`; never evaluate with `-m .`. -- Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 133-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. +- Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 135-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. - Keep this contract anchored to the root `nix flake check` `pkl-generated` derivation. Removed target paths are forbidden repository artifacts even though the same path names remain valid inside temporary payload roots. - Treat `nix run .#pkl-check-generated` and `nix flake check` as the lightweight post-task verification baseline and run both after each completed task. - Keep `output.files` limited to payload-relative paths (`config/.opencode/{agent,command,skills,lib,plugins,opencode.json}`, `config/.claude/{commands,skills,hooks,settings.json}` with no Claude agents, `config/.pi/{prompts,skills,extensions}`, and the generated schema). Do not emit `config/automated/.opencode`. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index be4ad8a5..12c88cbd 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -115,13 +115,18 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root (revised from the initially reported `none` during synchronization — the root pass found the reported classification understated it). The shared canonical Pkl generation pipeline (`workflow-composite.pkl`/`decision-skill.pkl` composition, exact-path generation contract) now produces a fourth target, and the exact artifact-path count the contract enforces changed from 107 to 133, which several root context files stated as fact. `sce setup --codex`/`integrations.target` CLI wiring still lands in T04/T05. - Context synchronization: synced -- [ ] T03: `Generate Codex hooks (.codex/hooks.json and hook helper script)` (status:todo) +- [x] T03: `Generate Codex hooks (.codex/hooks.json and hook helper script)` (status:done) - Task ID: T03 - - Scope: In — canonical Pkl source for `.codex/hooks.json` registering `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), `PostToolUse` (`apply_patch`) — no Bash `PostToolUse` entry; `.codex/hooks/run-sce-or-show-install-guidance.sh` following the existing fail-open/install-guidance pattern used by `.claude/hooks/run-sce-or-show-install-guidance.sh`, routing all lifecycle JSON to `sce hooks codex`; extend `generate.pkl` output mappings and the generation-contract check for these two new paths. Out — the actual `sce hooks codex` Rust implementation (T06), CLI/build.rs embedding (T04). + - Scope: In — canonical Pkl source for `.codex/hooks.json` registering `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`) — no `apply_patch` registration yet, no Bash `PostToolUse` entry, no `$schema`; `.codex/hooks/run-sce-or-show-install-guidance.sh` following the existing fail-open/install-guidance pattern used by `.claude/hooks/run-sce-or-show-install-guidance.sh`, routing all lifecycle JSON to `sce hooks codex`; extend `generate.pkl` output mappings and the generation-contract check for these two new paths. Out — the actual `sce hooks codex` Rust implementation (T06), CLI/build.rs embedding (T04), `apply_patch` hook registration and tracing (deferred to a later task). - Dependencies: T02 - - Done when: a temporary generation root contains `.codex/hooks.json` with exactly the five lifecycle registrations above (verified by direct content inspection) and `.codex/hooks/run-sce-or-show-install-guidance.sh` with the same missing-`sce` fail-open guidance text pattern as the Claude helper; `nix run .#pkl-check-generated` passes. + - Done when: a temporary generation root contains `.codex/hooks.json` with exactly the three lifecycle registrations above (verified by direct content inspection) and `.codex/hooks/run-sce-or-show-install-guidance.sh` with the same missing-`sce` fail-open guidance text pattern as the Claude helper; `nix run .#pkl-check-generated` passes. - Verify: `nix run .#pkl-check-generated`; manual inspection of generated `.codex/hooks.json` and hook script content. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `config/pkl/renderers/codex-content.pkl`, `config/pkl/generate.pkl`, `config/pkl/renderers/generation-contract-check.pkl` + - Result: Added `hooksJson` and `sceHookScript` (`common.RenderedTextFile`) to `codex-content.pkl`, mirroring `claude-content.pkl`'s `settings`/`sceHookScript` pattern. Every Codex event/matcher entry routes to the single command `sce hooks codex`, matching T06's single-dispatcher scope. `PreToolUse` registers only `Bash`; there is no `PostToolUse` entry and no `apply_patch` registration — Codex `apply_patch` tracing is not yet implemented. The hook script invokes itself via a project-root-relative path (`.codex/hooks/run-sce-or-show-install-guidance.sh`) rather than an unconfirmed Codex-specific env var analog to `$CLAUDE_PROJECT_DIR` — no such env var is established anywhere in this repo, and no plan AC depends on the exact invocation mechanism. Wired both renders into `generate.pkl` under `config/.codex/hooks.json` and `config/.codex/hooks/run-sce-or-show-install-guidance.sh`. Added both paths to `generation-contract-check.pkl`'s `expectedArtifactPaths` and bumped `expectedArtifactPathCount` 133 → 135. + - Verify: `nix run .#pkl-check-generated` — manual inspection of a fresh `nix run .#pkl-generate` temp-dir output confirmed `.codex/hooks.json` contains exactly the three lifecycle registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` `Bash`), no `$schema`, validated as well-formed JSON via `jq`; `.codex/hooks/run-sce-or-show-install-guidance.sh` matched the Claude helper's fail-open guidance text verbatim (only the forwarded command differs). + - Context impact: root — `context/patterns.md` and `context/overview.md` state the generation contract's exact artifact-path count as a literal fact (133), now stale at 135; `context/overview.md`'s Codex-renderer sentence also describes Codex output as "skills-only" with "no CLI setup/install wiring yet", which is now incomplete since `.codex/hooks.json`/hook-script generation is a second Codex asset kind in the pipeline (still with no CLI setup/install wiring — that remains T04/T05). + - Context synchronization: synced - [ ] T04: `Wire Codex's dual .agents/ + .codex/ output roots into embedded-asset install` (status:todo) - Task ID: T04 From 788a8bd361d13ffdde521807a75a7e0fc24c1906 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 10:56:17 +0200 Subject: [PATCH 04/20] setup: Add Codex as a configuration install target Expose Codex's dual `.agents/` and `.codex/` generated roots as a fourth setup target, including `--codex`, `--all` expansion, interactive selection, target persistence, and schema validation. Stage the two generated roots into a build-time manifest and install their prefixed asset paths at the repository root while preserving existing target behavior; add coverage for embedding, resolution, and installation. Ref: context/plans/codex-cli-integration.md (T04, T05) Co-authored-by: SCE --- cli/build.rs | 45 ++++++- cli/src/cli_schema.rs | 11 +- cli/src/command_surface.rs | 2 +- cli/src/services/config/types.rs | 10 +- cli/src/services/default_paths.rs | 14 ++ cli/src/services/doctor/inspect.rs | 4 + cli/src/services/parse/command_runtime.rs | 2 + cli/src/services/setup/mod.rs | 127 +++++++++++++++--- config/pkl/base/sce-config-schema.pkl | 2 +- context/architecture.md | 2 +- context/cli/cli-command-surface.md | 6 +- context/cli/config-precedence-contract.md | 2 +- context/glossary.md | 10 +- context/overview.md | 8 +- context/plans/codex-cli-integration.md | 18 ++- context/sce/setup-githooks-cli-ux.md | 11 +- context/sce/setup-no-backup-policy-seam.md | 4 +- .../sce/setup-repo-local-config-bootstrap.md | 5 +- 18 files changed, 231 insertions(+), 52 deletions(-) diff --git a/cli/build.rs b/cli/build.rs index 78248711..08228085 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -30,24 +30,43 @@ const TARGETS: &[TargetSpec] = &[ TargetSpec { const_name: "OPENCODE_EMBEDDED_ASSETS", generated_root: "config/.opencode", + allow_dead_code: false, }, TargetSpec { const_name: "CLAUDE_EMBEDDED_ASSETS", generated_root: "config/.claude", + allow_dead_code: false, }, TargetSpec { const_name: "PI_EMBEDDED_ASSETS", generated_root: "config/.pi", + allow_dead_code: false, + }, + TargetSpec { + const_name: "CODEX_EMBEDDED_ASSETS", + generated_root: CODEX_TARGET_DIR, + // Not wired into any SetupTarget/install path yet (T05); only read by + // this task's own test so far. + allow_dead_code: true, }, TargetSpec { const_name: "HOOK_EMBEDDED_ASSETS", generated_root: "static/hooks", + allow_dead_code: false, }, ]; +/// Build-time-only staging directory (inside `OUT_DIR`) that merges Codex's two +/// Pkl-generated output roots, `config/.agents` and `config/.codex`, into one +/// tree so it can be embedded like every other single-root target below. +const CODEX_TARGET_DIR: &str = "config/codex-target"; +const CODEX_AGENTS_SOURCE_DIR: &str = "config/.agents"; +const CODEX_HOOKS_SOURCE_DIR: &str = "config/.codex"; + struct TargetSpec { const_name: &'static str, generated_root: &'static str, + allow_dead_code: bool, } fn main() { @@ -84,6 +103,7 @@ fn prepare_build_artifacts() -> io::Result<()> { } else { stage_packaged_fallback(&manifest_dir, &out_dir)?; } + stage_codex_target(&out_dir)?; validate_staged_artifacts(&out_dir)?; generate_embedded_asset_manifest(&out_dir)?; generate_optional_workflow_catalog(&out_dir)?; @@ -281,7 +301,7 @@ fn validate_fallback_inventory(fallback_root: &Path) -> io::Result<()> { } fn validate_staged_artifacts(out_dir: &Path) -> io::Result<()> { - for target in TARGETS.iter().take(3) { + for target in TARGETS.iter().take(4) { let expected_root = out_dir.join(PKL_OUTPUT_DIR).join(target.generated_root); if !expected_root.is_dir() { return Err(invalid_data(&format!( @@ -331,6 +351,26 @@ fn stage_static_inputs( ) } +/// Merges Codex's two Pkl-generated output roots into `CODEX_TARGET_DIR` so it +/// can be embedded through the same single-root `TargetSpec` mechanism as every +/// other target. Runs after both the repository-source and packaged-fallback +/// staging branches, since either one populates the generated payload this +/// reads from. +fn stage_codex_target(out_dir: &Path) -> io::Result<()> { + let pkl_output_root = out_dir.join(PKL_OUTPUT_DIR); + let destination_root = pkl_output_root.join(CODEX_TARGET_DIR); + remove_path_if_exists(&destination_root)?; + + copy_tree( + &pkl_output_root.join(CODEX_AGENTS_SOURCE_DIR), + &destination_root.join(".agents"), + )?; + copy_tree( + &pkl_output_root.join(CODEX_HOOKS_SOURCE_DIR), + &destination_root.join(".codex"), + ) +} + fn copy_tree(source_root: &Path, destination_root: &Path) -> io::Result<()> { println!("cargo:rerun-if-changed={}", source_root.display()); @@ -389,6 +429,9 @@ fn generate_embedded_asset_manifest(out_dir: &Path) -> io::Result<()> { collect_files(&source_root, &source_root, &mut files)?; files.sort_unstable_by(|left, right| left.relative_path.cmp(&right.relative_path)); + if target.allow_dead_code { + output.push_str("#[allow(dead_code)]\n"); + } writeln!( output, "pub static {}: &[EmbeddedAsset] = &[", diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 67c0d050..0b4cebec 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -168,16 +168,19 @@ pub enum Commands { #[command(about = SETUP_CLAP_ABOUT, hide = !SETUP_SHOW_IN_TOP_LEVEL_HELP)] Setup { - #[arg(long, conflicts_with_all = ["claude", "pi", "all"])] + #[arg(long, conflicts_with_all = ["claude", "pi", "codex", "all"])] opencode: bool, - #[arg(long, conflicts_with_all = ["opencode", "pi", "all"])] + #[arg(long, conflicts_with_all = ["opencode", "pi", "codex", "all"])] claude: bool, - #[arg(long, conflicts_with_all = ["opencode", "claude", "all"])] + #[arg(long, conflicts_with_all = ["opencode", "claude", "codex", "all"])] pi: bool, - #[arg(long, conflicts_with_all = ["opencode", "claude", "pi"])] + #[arg(long, conflicts_with_all = ["opencode", "claude", "pi", "all"])] + codex: bool, + + #[arg(long, conflicts_with_all = ["opencode", "claude", "pi", "codex"])] all: bool, #[arg(long)] diff --git a/cli/src/command_surface.rs b/cli/src/command_surface.rs index ff4434d3..85d0a360 100644 --- a/cli/src/command_surface.rs +++ b/cli/src/command_surface.rs @@ -51,7 +51,7 @@ const HELP_SECTIONS: &[HelpSection] = &[ body: &[HelpSectionBodyLine::Command { cmd: " sce setup", suffix: - " [--opencode|--claude|--pi|--all] [--non-interactive] [--hooks] [--repo ] [--bootstrap-context]", + " [--opencode|--claude|--pi|--codex|--all] [--non-interactive] [--hooks] [--repo ] [--bootstrap-context]", }], }, HelpSection { diff --git a/cli/src/services/config/types.rs b/cli/src/services/config/types.rs index 9ef5e1aa..ef64f39c 100644 --- a/cli/src/services/config/types.rs +++ b/cli/src/services/config/types.rs @@ -245,6 +245,7 @@ pub(crate) enum IntegrationTargetId { Opencode, Claude, Pi, + Codex, } impl IntegrationTargetId { @@ -253,8 +254,9 @@ impl IntegrationTargetId { "opencode" => Ok(Self::Opencode), "claude" => Ok(Self::Claude), "pi" => Ok(Self::Pi), + "codex" => Ok(Self::Codex), _ => anyhow::bail!( - "Invalid integration target '{raw}' from {source}. Valid values: opencode, claude, pi." + "Invalid integration target '{raw}' from {source}. Valid values: opencode, claude, pi, codex." ), } } @@ -310,6 +312,10 @@ mod integration_target_id_tests { IntegrationTargetId::parse("pi", "test").unwrap(), IntegrationTargetId::Pi ); + assert_eq!( + IntegrationTargetId::parse("codex", "test").unwrap(), + IntegrationTargetId::Codex + ); } #[test] @@ -319,7 +325,7 @@ mod integration_target_id_tests { .to_string(); assert_eq!( error, - "Invalid integration target 'cursor' from test source. Valid values: opencode, claude, pi." + "Invalid integration target 'cursor' from test source. Valid values: opencode, claude, pi, codex." ); } } diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index 0cf47082..69b53b33 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -392,6 +392,13 @@ pub(crate) mod pi_asset { pub const EXTENSIONS_DIR: &str = "extensions"; } +/// Codex embedded-asset relative paths keep their own `.agents/`/`.codex/` +/// output-root prefix (unlike OpenCode/Claude/Pi, whose relative paths are +/// stripped of their single root), so `SKILLS_DIR` carries that prefix too. +pub(crate) mod codex_asset { + pub const SKILLS_DIR: &str = ".agents/skills"; +} + pub(crate) mod context_dir { pub const CONTEXT_ROOT: &str = "context"; pub const PLANS: &str = "plans"; @@ -536,6 +543,13 @@ impl InstallTargetPaths { self.repo_root.join(repo_dir::PI) } + /// Codex has two output roots (`.agents/` for skills, `.codex/` for + /// hooks), both already embedded as prefixes on `CODEX_EMBEDDED_ASSETS` + /// relative paths, so the destination root is the repository root itself. + pub(crate) fn codex_target_dir(&self) -> PathBuf { + self.repo_root.clone() + } + pub(crate) fn opencode_plugin_target(&self) -> PathBuf { self.opencode_target_dir() .join(opencode_asset::PLUGINS_DIR) diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 2c5c98c7..ca2d6df2 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -582,6 +582,10 @@ fn inspect_repository_integrations( inspect_pi_integration_health(&pi_groups, problems); integration_groups.extend(pi_groups); } + // Codex doctor coverage (integration group collection, health + // checks) is scoped to a later task; this arm only keeps the + // exhaustive match compiling now that the target ID exists. + IntegrationTargetId::Codex => {} } } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index b21d135a..d2f7d05e 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -214,6 +214,7 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result Result &'static [Embedde SetupTarget::OpenCode => OPENCODE_EMBEDDED_ASSETS, SetupTarget::Claude => CLAUDE_EMBEDDED_ASSETS, SetupTarget::Pi => PI_EMBEDDED_ASSETS, + SetupTarget::Codex => CODEX_EMBEDDED_ASSETS, SetupTarget::All => { unreachable!("meta targets are expanded into concrete targets") } @@ -95,24 +97,30 @@ fn embedded_assets_for_concrete_target(target: SetupTarget) -> &'static [Embedde /// needs no Rust change. #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct WorkflowAssetLayout { - command_dir: &'static str, + /// `None` for a target with no command directory (skills only), such as + /// Codex. + command_dir: Option<&'static str>, skills_dir: &'static str, } fn workflow_asset_layout(target: SetupTarget) -> WorkflowAssetLayout { match target { SetupTarget::OpenCode => WorkflowAssetLayout { - command_dir: default_paths::opencode_asset::OPENCODE_COMMAND_DIR, + command_dir: Some(default_paths::opencode_asset::OPENCODE_COMMAND_DIR), skills_dir: default_paths::opencode_asset::SKILLS_DIR, }, SetupTarget::Claude => WorkflowAssetLayout { - command_dir: default_paths::claude_asset::COMMANDS_DIR, + command_dir: Some(default_paths::claude_asset::COMMANDS_DIR), skills_dir: default_paths::claude_asset::SKILLS_DIR, }, SetupTarget::Pi => WorkflowAssetLayout { - command_dir: default_paths::pi_asset::PROMPTS_DIR, + command_dir: Some(default_paths::pi_asset::PROMPTS_DIR), skills_dir: default_paths::pi_asset::SKILLS_DIR, }, + SetupTarget::Codex => WorkflowAssetLayout { + command_dir: None, + skills_dir: default_paths::codex_asset::SKILLS_DIR, + }, SetupTarget::All => { unreachable!("meta targets are expanded into concrete targets") } @@ -124,10 +132,12 @@ fn asset_belongs_to_optional_workflow( workflow: &OptionalWorkflow, layout: WorkflowAssetLayout, ) -> bool { - let command_path = format!("{}/{}.md", layout.command_dir, workflow.command_slug); + let is_command_asset = layout.command_dir.is_some_and(|command_dir| { + relative_path == format!("{command_dir}/{}.md", workflow.command_slug) + }); let skill_prefix = format!("{}/{}/", layout.skills_dir, workflow.skill_slug); - relative_path == command_path || relative_path.starts_with(&skill_prefix) + is_command_asset || relative_path.starts_with(&skill_prefix) } /// Embedded assets for `target`, minus the command and skill assets of every @@ -188,6 +198,7 @@ pub struct SetupCliOptions { pub opencode: bool, pub claude: bool, pub pi: bool, + pub codex: bool, pub all: bool, pub hooks: bool, pub repo_path: Option, @@ -231,6 +242,7 @@ pub fn resolve_setup_request(options: SetupCliOptions) -> Result { || options.opencode || options.claude || options.pi + || options.codex || options.all || options.hooks || options.repo_path.is_some(); @@ -260,19 +272,22 @@ pub fn resolve_setup_request(options: SetupCliOptions) -> Result { if options.pi { selected_targets.push(SetupTarget::Pi); } + if options.codex { + selected_targets.push(SetupTarget::Codex); + } if options.all { selected_targets.push(SetupTarget::All); } if selected_targets.len() > 1 { bail!( - "Options '--opencode', '--claude', '--pi', and '--all' are mutually exclusive. Try: choose exactly one target flag (for example 'sce setup --opencode --non-interactive') or omit all target flags for interactive mode." + "Options '--opencode', '--claude', '--pi', '--codex', and '--all' are mutually exclusive. Try: choose exactly one target flag (for example 'sce setup --opencode --non-interactive') or omit all target flags for interactive mode." ); } if options.non_interactive && selected_targets.is_empty() && !options.hooks { bail!( - "Option '--non-interactive' requires a target flag. Try: 'sce setup --opencode --non-interactive', 'sce setup --claude --non-interactive', 'sce setup --pi --non-interactive', or 'sce setup --all --non-interactive'." + "Option '--non-interactive' requires a target flag. Try: 'sce setup --opencode --non-interactive', 'sce setup --claude --non-interactive', 'sce setup --pi --non-interactive', 'sce setup --codex --non-interactive', or 'sce setup --all --non-interactive'." ); } @@ -608,6 +623,7 @@ fn setup_target_label(target: SetupTarget) -> &'static str { SetupTarget::OpenCode => "OpenCode", SetupTarget::Claude => "Claude", SetupTarget::Pi => "Pi", + SetupTarget::Codex => "Codex", SetupTarget::All => "All", } } @@ -712,7 +728,13 @@ pub(crate) fn concrete_targets_for(target: SetupTarget) -> &'static [SetupTarget SetupTarget::OpenCode => &[SetupTarget::OpenCode], SetupTarget::Claude => &[SetupTarget::Claude], SetupTarget::Pi => &[SetupTarget::Pi], - SetupTarget::All => &[SetupTarget::OpenCode, SetupTarget::Claude, SetupTarget::Pi], + SetupTarget::Codex => &[SetupTarget::Codex], + SetupTarget::All => &[ + SetupTarget::OpenCode, + SetupTarget::Claude, + SetupTarget::Pi, + SetupTarget::Codex, + ], } } @@ -723,6 +745,7 @@ fn integration_target_id_str(target: SetupTarget) -> &'static str { SetupTarget::OpenCode => "opencode", SetupTarget::Claude => "claude", SetupTarget::Pi => "pi", + SetupTarget::Codex => "codex", SetupTarget::All => { unreachable!("integration_target_id_str must not be called with meta targets") } @@ -893,6 +916,7 @@ mod install { SetupTarget::OpenCode => install_targets.opencode_target_dir(), SetupTarget::Claude => install_targets.claude_target_dir(), SetupTarget::Pi => install_targets.pi_target_dir(), + SetupTarget::Codex => install_targets.codex_target_dir(), SetupTarget::All => unreachable!("meta targets are expanded into concrete targets"), }; @@ -1267,6 +1291,7 @@ mod install { SetupTarget::OpenCode => install_targets.opencode_target_dir(), SetupTarget::Claude => install_targets.claude_target_dir(), SetupTarget::Pi => install_targets.pi_target_dir(), + SetupTarget::Codex => install_targets.codex_target_dir(), SetupTarget::All => { unreachable!("meta targets are expanded into concrete targets") } @@ -1522,6 +1547,7 @@ enum SetupPromptTarget { OpenCode, Claude, Pi, + Codex, All, } @@ -1570,6 +1596,7 @@ mod prompt { SetupPromptTarget::OpenCode, SetupPromptTarget::Claude, SetupPromptTarget::Pi, + SetupPromptTarget::Codex, SetupPromptTarget::All, ]; @@ -1579,12 +1606,13 @@ mod prompt { Ok(SetupPromptTarget::OpenCode) => Ok(proceed(SetupTarget::OpenCode)), Ok(SetupPromptTarget::Claude) => Ok(proceed(SetupTarget::Claude)), Ok(SetupPromptTarget::Pi) => Ok(proceed(SetupTarget::Pi)), + Ok(SetupPromptTarget::Codex) => Ok(proceed(SetupTarget::Codex)), Ok(SetupPromptTarget::All) => Ok(proceed(SetupTarget::All)), Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => { Ok(SetupDispatch::Cancelled) } Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', or '--all'." + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." ), Err(error) => Err(error.into()), } @@ -1613,7 +1641,7 @@ mod prompt { )), Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', or '--all', adding '--workflow ' for each optional workflow to install." + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all', adding '--workflow ' for each optional workflow to install." ), Err(error) => Err(error.into()), } @@ -1712,7 +1740,8 @@ mod prompt { SetupPromptTarget::OpenCode => "OpenCode", SetupPromptTarget::Claude => "Claude", SetupPromptTarget::Pi => "Pi", - SetupPromptTarget::All => "All (OpenCode + Claude + Pi)", + SetupPromptTarget::Codex => "Codex", + SetupPromptTarget::All => "All (OpenCode + Claude + Pi + Codex)", }; prompt_value_with_color_policy(label, color_enabled) @@ -1845,6 +1874,21 @@ mod tests { assert!(!request.context_only); } + #[test] + fn resolve_setup_request_accepts_codex_target() { + let request = resolve_setup_request(options_with(|options| { + options.codex = true; + options.non_interactive = true; + })) + .expect("codex target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::Codex)) + ); + assert!(!request.context_only); + } + #[test] fn resolve_setup_request_accepts_all_target() { let request = resolve_setup_request(options_with(|options| { @@ -2016,10 +2060,15 @@ mod tests { } #[test] - fn concrete_targets_for_all_expands_to_three_targets() { + fn concrete_targets_for_all_expands_to_four_targets() { assert_eq!( concrete_targets_for(SetupTarget::All), - &[SetupTarget::OpenCode, SetupTarget::Claude, SetupTarget::Pi] + &[ + SetupTarget::OpenCode, + SetupTarget::Claude, + SetupTarget::Pi, + SetupTarget::Codex + ] ); } @@ -2028,6 +2077,11 @@ mod tests { assert_eq!(integration_target_id_str(SetupTarget::Pi), "pi"); } + #[test] + fn integration_target_id_str_maps_codex() { + assert_eq!(integration_target_id_str(SetupTarget::Codex), "codex"); + } + /// Every optional workflow selected, so filtering drops nothing. fn every_optional_workflow() -> Vec<&'static str> { super::OPTIONAL_WORKFLOWS @@ -2043,10 +2097,13 @@ mod tests { iter_embedded_assets_for_setup_target_with_selection(target, &selection).count() }; - let concrete_sum = - count(SetupTarget::OpenCode) + count(SetupTarget::Claude) + count(SetupTarget::Pi); + let concrete_sum = count(SetupTarget::OpenCode) + + count(SetupTarget::Claude) + + count(SetupTarget::Pi) + + count(SetupTarget::Codex); assert!(count(SetupTarget::Pi) > 0); + assert!(count(SetupTarget::Codex) > 0); assert_eq!(count(SetupTarget::All), concrete_sum); } @@ -2069,6 +2126,44 @@ mod tests { assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); } + #[test] + fn codex_embedded_assets_cover_both_output_roots_with_no_command_dir() { + let has = |path: &str| { + CODEX_EMBEDDED_ASSETS + .iter() + .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) + }; + + assert!(has(".agents/skills/sce-next-task/SKILL.md")); + assert!(has(".codex/hooks.json")); + assert!(has(".codex/hooks/run-sce-or-show-install-guidance.sh")); + assert!(!CODEX_EMBEDDED_ASSETS + .iter() + .any(|asset| asset.relative_path.starts_with(".agents/commands/"))); + } + + #[test] + fn install_writes_codex_assets_directly_under_repo_root() { + let repo = init_git_repo("install-codex-dual-roots"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("codex install should succeed"); + + assert!(repo.join(".agents/skills/sce-next-task/SKILL.md").is_file()); + assert!(repo.join(".codex/hooks.json").is_file()); + assert!(repo + .join(".codex/hooks/run-sce-or-show-install-guidance.sh") + .is_file()); + assert!(!repo.join(".codex/.agents").exists()); + assert!(!repo.join(".agents/.codex").exists()); + + let _ = fs::remove_dir_all(&repo); + } + #[test] fn install_preserves_user_owned_files_and_writes_sce_assets() { let repo = init_git_repo("install-preserves-user-files"); diff --git a/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index da454f53..7b902e52 100644 --- a/config/pkl/base/sce-config-schema.pkl +++ b/config/pkl/base/sce-config-schema.pkl @@ -220,7 +220,7 @@ local sceConfigSchema = new JsonSchema { uniqueItems = true items = new JsonSchema { type = "string" - enum = new { "opencode"; "claude"; "pi" } + enum = new { "opencode"; "claude"; "pi"; "codex" } } } ["optional_workflows"] = new JsonSchema { diff --git a/context/architecture.md b/context/architecture.md index 92fe3cde..5dc3d492 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -125,7 +125,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering; structured-row reconstruction applies the persisted row `model_id` to every hunk and the persisted canonical `session_id` to every touched line before downstream combination and intersection. Active hook runtime, setup/lifecycle storage, and `sce sync` resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the former `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. -- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run. For the two assets that are merge targets — the Claude target's `settings.json`, detected by `is_claude_settings_merge_target`, and the OpenCode target's `opencode.json`, detected by `is_opencode_config_merge_target` — the content staged is not the embedded asset's bytes but the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. +- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the two assets that are merge targets — the Claude target's `settings.json`, detected by `is_claude_settings_merge_target`, and the OpenCode target's `opencode.json`, detected by `is_opencode_config_merge_target` — the content staged is not the embedded asset's bytes but the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 671dbd0a..cf329d5b 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -56,13 +56,13 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. -`setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Pi, All) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`); the former `--both` flag was removed in favor of `--all` (opencode+claude+pi); the interactive prompt title and target labels reuse shared prompt styling helpers when stdout color is enabled. `setup` also accepts `--bootstrap-context` as a standalone context-only mode that ensures the durable-context baseline without prompts or integration installs; every normal successful setup path also ensures that baseline after the Git gate. +`setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Pi, Codex, All) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--codex`, `--all`); the former `--both` flag was removed in favor of `--all` (opencode+claude+pi+codex); the interactive prompt title and target labels reuse shared prompt styling helpers when stdout color is enabled. `setup` also accepts `--bootstrap-context` as a standalone context-only mode that ensures the durable-context baseline without prompts or integration installs; every normal successful setup path also ensures that baseline after the Git gate. `setup` accepts a repeatable `--workflow ` flag selecting which optional workflows to install (currently only `brownfield`). Passing it makes the listed slugs the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run preserves an earlier opt-in. Unknown slugs fail request resolution with a validation error naming the embedded catalog's available slugs and write no files, and `--workflow` is rejected alongside `--bootstrap-context` or on a hooks-only run because neither installs target assets. The resolved selection filters the installed assets and is persisted; see [config precedence contract](config-precedence-contract.md) and [setup local bootstrap](../sce/setup-repo-local-config-bootstrap.md). An interactive `setup` run instead resolves the selection through an `inquire` multi-select shown after the target prompt, titled `Select optional workflows` with one `{title} — {description}` row per optional workflow using the shared prompt styling. Rows are unchecked when nothing is persisted and pre-checked from `integrations.optional_workflows` otherwise (a supplied `--workflow` list seeds them instead); the answered prompt is the run's exact selection. Cancelling either prompt yields the existing `Setup cancelled. No files were changed.` outcome, a non-TTY run keeps the existing actionable guidance, and the prompt is skipped when the catalog has no optional workflow. `auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `whoami` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. Authenticated `sce auth whoami` retrieves the authoritative profile from the Control Plane `GET /me` endpoint and renders flat text labels for `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name`; missing first/last names render empty and missing role, permissions, or workspace values render `none`. `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. -`setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. -`setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. +`setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi/Codex targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and (via a build-time staging merge of `config/.agents/**` + `config/.codex/**`) `config/codex-target/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. +`setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. Codex is the one target whose embedded relative paths already carry their own output-root prefix (`.agents/...`, `.codex/...`), so its destination root is the repository root itself rather than a single `.codex/`-style subdirectory. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. `setup` now executes end-to-end and prints deterministic completion details including selected target(s) and per-target install count. `doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. The former Agent Trace database inspection routes are unavailable; doctor owns repository-scoped Agent Trace DB health and checkout-identity diagnostics. The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and post-commit Agent Trace auto-sync readiness derived from canonical managed-block currency plus resolved configuration. The readiness fact reports enabled/current, explicit disabled, not-ready, and not-applicable states in text and JSON without launching synchronization; Claude's inventory is only `Plugins`, `Commands`, and `Skills`, while OpenCode retains `Agents`. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. `sce sync [--format text|json]` is the implemented user-invocable synchronization command: it synchronizes the current repository's Agent Trace DB with the control-plane ingestion API; local DB and Agent Trace DB bootstrap continue to happen through `setup`, and DB health/repair continues to happen through `doctor`. See [agent-trace-sync-command.md](agent-trace-sync-command.md) and [sync-command.md](sync-command.md). diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 91e92a12..8aee06e9 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -97,7 +97,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - `integrations` must be an object when present and currently allows `target` and `optional_workflows`; either key alone yields a parsed `IntegrationsConfig` with the other defaulting to empty. - `integrations.target` must be an array of unique canonical target IDs when present. -- Supported target ID values: `opencode`, `claude`, `pi`. +- Supported target ID values: `opencode`, `claude`, `pi`, `codex`. - Unknown target IDs fail schema validation. - `integrations.optional_workflows` must be an array of unique optional-workflow IDs when present; it records which optional workflows a repository has opted into. Its enum is derived in `config/pkl/base/sce-config-schema.pkl` from the workflow catalog's `optional` records rather than hand-listed, so marking a workflow optional in Pkl extends the accepted values with no Rust or schema edit. Currently the only accepted value is `brownfield`. - Unknown optional-workflow IDs and duplicate entries fail schema validation. Rust-side mapping validates each ID a second time against the embedded optional-workflow catalog (`parse_optional_workflow_id` in `cli/src/services/config/types.rs`), reporting the catalog's available IDs. diff --git a/context/glossary.md b/context/glossary.md index a6a47ba3..c14720f2 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -6,7 +6,7 @@ - disposable plan lifecycle: Policy where `context/plans/` holds active execution artifacts only; completed plans are disposable and durable outcomes must be reflected in current-state context files and/or `context/decisions/`. - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. - verify-only root context pass: Context-sync mode for localized tasks where root-level behavior, architecture, and terminology are unchanged; root shared files are checked against code truth but are not edited by default. -- ephemeral generated payload: Files materialized by `config/pkl/generate.pkl` using payload-relative `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/schema/sce-config.schema.json` paths beneath Cargo `OUT_DIR`, temporary previews, or packaging fallbacks. These layouts are installed by `sce setup` but are never committed as repository target trees; `config/automated/.opencode/**` remains a forbidden generator surface. +- ephemeral generated payload: Files materialized by `config/pkl/generate.pkl` using payload-relative `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, `config/.agents/**`, `config/.codex/**`, and `config/schema/sce-config.schema.json` paths beneath Cargo `OUT_DIR`, temporary previews, or packaging fallbacks. These layouts are installed by `sce setup` but are never committed as repository target trees; `config/automated/.opencode/**` remains a forbidden generator surface. - `CLI generated-input handoff`: Repository-build contract rooted at the temporary directory named by `SCE_CLI_GENERATED_INPUT_DIR`. `config/pkl/generator-inputs.txt` declares the canonical `config/pkl` and referenced `config/lib` inputs; `scripts/produce-cli-generated-input.sh` discovers those files, generates Pkl twice, rejects nondeterminism and in-flight input mutation, and atomically places `pkl-generated/`, its exact `SHA256SUMS`, and `INPUTS.SHA256SUMS` there. `scripts/run-cli-cargo.sh` delegates production and removes its temporary handoff after Cargo exits. `cli/build.rs` verifies payload integrity and input freshness before copying `pkl-generated/` into Cargo `OUT_DIR`; missing, incomplete, modified, or stale handoffs fail rather than invoking Pkl or falling back to packaged assets. - `generated-input producer`: Repository-owned `scripts/produce-cli-generated-input.sh` contract driven by `config/pkl/generator-inputs.txt`. It is the canonical owner for expanding repository-relative generator inputs, snapshotting their inventory, two-pass Pkl evaluation, byte-tree determinism comparison, payload and canonical-input SHA-256 inventories, input-mutation rejection, atomic output publication, and private staging cleanup. The repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation all consume it. - `Pi workflow package`: Generated Pi workflow surface consisting of one thin prompt in `config/.pi/prompts/` plus the one workflow skill package under `config/.pi/skills/` that the prompt routes to. Phase-based workflows include `SKILL.md`, `references/output.md`, and named phase, persisted-document, or supporting references; phase-free `/brownfield` has the two core files, while `/handover` also has `references/handover-template.md`. Pi currently receives `/change-to-plan`, `/next-task`, `/validate`, `/commit`, `/handover`, and `/brownfield` this way and has no generated agent-role prompts. @@ -99,11 +99,11 @@ - `sync command deferral` (historical): Former plan/state note that a user-invocable sync command was deferred to `0.4.0`; superseded first by nested `sce trace sync` and now by top-level `sce sync` (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization still flow through lifecycle providers aggregated by the setup command, hook runtime still keeps a lazy repository Agent Trace DB fallback for repositories where setup has not run or schema metadata is incomplete, and DB health/repair still flows through the doctor surface. - `CLI bounded resilience wrapper`: Shared policy in `cli/src/services/resilience.rs` (`RetryPolicy`, async `run_with_retry`, sync `run_with_retry_sync`) that applies deterministic retries/timeouts/capped backoff to transient operations, emits retry observability events, and returns actionable terminal failure guidance. The sync helper is currently wired into shared database constructors for local open/connect retry and into `TursoDb`/`EncryptedTursoDb` operation retry for `execute()`/`query()`/`query_map()`. - `setup service orchestration`: Setup execution logic in `cli/src/services/setup/command.rs` that resolves the repository root, always ensures the durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, dispatches `setup` through the static lifecycle provider catalog (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive target selection for config asset installation, and emits deterministic success messaging per target. -- `setup target flags`: Mutually-exclusive `sce setup` target selectors (`--opencode`, `--claude`, `--pi`, `--all`) that force non-interactive mode for automation; `--all` expands to opencode+claude+pi and replaced the removed `--both` flag. +- `setup target flags`: Mutually-exclusive `sce setup` target selectors (`--opencode`, `--claude`, `--pi`, `--codex`, `--all`) that force non-interactive mode for automation; `--all` expands to opencode+claude+pi+codex and replaced the removed `--both` flag. - `setup mode contract`: `cli/src/services/setup/mod.rs` model where `SetupMode::Interactive` is the default and `SetupMode::NonInteractive(SetupTarget)` is selected only when exactly one target flag is provided. -- `setup interactive target prompt`: `inquire::Select` flow in `cli/src/services/setup/mod.rs` (`InquireSetupTargetPrompter`) that presents OpenCode, Claude, Pi, and All (OpenCode + Claude + Pi) when `sce setup` runs without target flags. +- `setup interactive target prompt`: `inquire::Select` flow in `cli/src/services/setup/mod.rs` (`InquireSetupTargetPrompter`) that presents OpenCode, Claude, Pi, Codex, and All (OpenCode + Claude + Pi + Codex) when `sce setup` runs without target flags. - `setup dispatch outcome`: Execution model in `cli/src/services/setup/mod.rs` (`SetupDispatch`) where setup either proceeds with a selected/non-interactive target or exits as cancelled without file changes. -- `setup embedded asset manifest`: Compile-time generated file index emitted by `cli/build.rs` into `OUT_DIR/setup_embedded_assets.rs`, embedding bytes from Pkl-generated `OUT_DIR/pkl-generated/config/.{opencode,claude,pi}/**` plus staged `OUT_DIR/static/hooks/**` as deterministic normalized relative-path entries consumed by `cli/src/services/setup/mod.rs`; `OPENCODE_EMBEDDED_ASSETS`, `CLAUDE_EMBEDDED_ASSETS`, and `PI_EMBEDDED_ASSETS` all back live setup targets. +- `setup embedded asset manifest`: Compile-time generated file index emitted by `cli/build.rs` into `OUT_DIR/setup_embedded_assets.rs`, embedding bytes from Pkl-generated `OUT_DIR/pkl-generated/config/.{opencode,claude,pi}/**` plus staged `OUT_DIR/static/hooks/**` as deterministic normalized relative-path entries consumed by `cli/src/services/setup/mod.rs`; `OPENCODE_EMBEDDED_ASSETS`, `CLAUDE_EMBEDDED_ASSETS`, and `PI_EMBEDDED_ASSETS` all back live setup targets. The manifest also carries `CODEX_EMBEDDED_ASSETS`, embedding Codex's two Pkl-generated output roots (`config/.agents/**`, `config/.codex/**`) merged by `cli/build.rs` into a build-time-only `OUT_DIR/pkl-generated/config/codex-target/` staging tree so its relative-path entries keep their `.agents/`/`.codex/` prefixes; `SetupTarget::Codex` now backs it as a fourth live setup target via `sce setup --codex`/`--all`, installing directly at the repository root (via `InstallTargetPaths::codex_target_dir()`) since its asset paths already carry their own output-root prefix, unlike the other three targets' single-subdirectory destinations. - `setup required-hook embedded assets`: Setup-service accessors in `cli/src/services/setup/mod.rs` (`iter_required_hook_assets`, `get_required_hook_asset`) that expose canonical embedded templates for `pre-commit`, `commit-msg`, and `post-commit` without runtime config reads. - `SCE managed block`: The CLI-presence check plus `sce hooks ` invocation in each canonical hook template (`cli/assets/hooks/{pre-commit,commit-msg,post-commit}`), delimited by `# >>> sce managed block (do not edit) >>>` / `# <<< sce managed block <<<` comment markers so the same block content can be embedded inside a foreign hook without disturbing content around it (see `context/sce/setup-githooks-hook-asset-packaging.md`). The block propagates an available `sce` command's exit status by capturing `$?` and calling `exit` explicitly rather than by `exec`, so it terminates the script deterministically even when appended after other content. A pure merge module computes hook install bytes against this marker pair (see `setup hook-merge seam`); both `sce setup --hooks` install (see `setup required-hook install orchestration`) and `sce doctor` hook inspection decide currency against this marker pair rather than whole-file byte comparison, so a hook a repository has extended around the block still reports current. - `setup hook-merge seam`: Pure module `cli/src/services/setup/hook_merge.rs`, covering `pre-commit`, `commit-msg`, and `post-commit`. `merge_or_create_hook(existing: Option<&[u8]>, canonical: &[u8], hook_name: &str) -> Result` returns `canonical` verbatim (`HookMergeKind::Created`) when no hook exists; otherwise it locates the `SCE managed block` marker pair by exact line match. A hook already carrying a balanced marker pair identical to the canonical block returns its bytes unchanged (`AlreadyCurrent`); one whose block differs gets that block spliced in place between the same marker lines, leaving surrounding content untouched (`ManagedBlockReplaced`); a marker-free hook containing the legacy pre-marker guidance URL (`https://sce.crocoder.dev/docs/getting-started#install-cli`) is treated as SCE-owned wholesale and replaced entirely with `canonical` (also `ManagedBlockReplaced`); any other marker-free hook is foreign and kept as an exact byte prefix with the canonical block appended after it (`AppendedToForeign`). An unbalanced or partial marker pair is a hard, deterministic error naming `hook_name`, with no bytes returned. For the `AppendedToForeign` case, `HookMerge.unreachable_block_advisory` is set when the foreign hook's last non-blank, non-comment line sits at zero indentation and starts with `exec ` or `exit` — a narrow heuristic (no shell parsing) flagging that the appended block would never run. This module is pure and filesystem-free per "Unit testing in Nix sandbox"; required-hook install calls it (see `setup required-hook install orchestration`), and doctor hook inspection (`cli/src/services/hooks/lifecycle.rs`, `cli/src/services/doctor/inspect.rs`) also calls it, reporting a hook `Current` only when merging the canonical template into its on-disk bytes is a no-op — including treating an unbalanced or partial marker pair as `Stale` rather than `Unknown`, so `sce doctor --fix` repairs it. @@ -164,7 +164,7 @@ - `sce policy command adapter`: Hidden/internal `sce policy bash` command in `cli/src/services/bash_policy.rs` that exposes the Rust bash-policy evaluator to hook callers. It reads JSON from STDIN, resolves bash-policy config from the project root (git root with current-directory fallback), evaluates the command against active policies, and emits hook-safe output: Claude Code deny JSON (`hookSpecificOutput` with `permissionDecision: "deny"`) or empty string for allowed commands in `--output claude-hook` mode (default), and structured `{"status","decision","command","normalized_argv","reason","policy_id"}` JSON in `--output json` mode. Input modes are `--input claude-pre-tool-use` (default, parses Claude `PreToolUse` event JSON with `tool_name`/`tool_input.command`) and `--input normalized` (parses `{"command":...}` for OpenCode delegation). The command uses explicit `--input`/`--output` flags rather than auto-detection; Claude Code hooks invoke `sce policy bash` with defaults, while OpenCode plugin delegation passes `--input normalized --output json`. Invalid invocation/input returns deterministic validation diagnostics without executing target commands. - `bash policy redundancy warning`: Non-fatal config validation output emitted when `forbid-git-all` and `forbid-git-commit` are enabled together; the config remains valid, but `sce config show|validate` reports the overlap deterministically as a warning instead of an error. - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_01KZE4DDA8HM1JHZGF2QCF49RP`. -- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes each embedded setup asset into its own staged file next to its final destination under repository-root `.opencode/`/`.claude/`/`.pi/`, then swaps it into place via the `setup atomic-swap` policy (see `setup atomic-swap`) — renaming the staging file directly over the destination without unlinking it first. It never removes an integration target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. After the per-asset install loop, it prunes stale SCE-owned paths (see `setup catalog-derived pruning`). Two assets, the Claude target's `settings.json` and the OpenCode target's `opencode.json`, stage merged content instead of the embedded asset's bytes verbatim (see `setup config-merge seam`). +- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes each embedded setup asset into its own staged file next to its final destination under repository-root `.opencode/`/`.claude/`/`.pi/` (or, for Codex, the repository root itself, since its asset paths already carry their own `.agents/`/`.codex/` prefix), then swaps it into place via the `setup atomic-swap` policy (see `setup atomic-swap`) — renaming the staging file directly over the destination without unlinking it first. It never removes an integration target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. After the per-asset install loop, it prunes stale SCE-owned paths (see `setup catalog-derived pruning`). Two assets, the Claude target's `settings.json` and the OpenCode target's `opencode.json`, stage merged content instead of the embedded asset's bytes verbatim (see `setup config-merge seam`). - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. diff --git a/context/overview.md b/context/overview.md index 734bb816..ae6c55c9 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; it has no CLI setup/install wiring yet, so it is a generation-pipeline participant only until later Codex-integration tasks land. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`, but `sce hooks codex` itself is not yet implemented, so its hook assets currently install inert until later Codex-integration tasks land. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -26,7 +26,7 @@ The app command dispatcher now enforces a centralized stdout/stderr stream contr The CLI now also enforces a shared output-format parser contract in `cli/src/services/output_format.rs`, with canonical `--format ` parsing and command-specific actionable invalid-value guidance reused by `config` and `version` services. A compile-safe service lifecycle seam also exists in `cli/src/services/lifecycle.rs`: `ServiceLifecycle` exposes default no-op `diagnose`, `fix`, and `setup` methods against the narrow `HasRepoRoot` accessor, uses lifecycle-owned health/fix/setup result types, and owns the shared static `LifecycleProvider` enum catalog/factory with deterministic config → local*db → auth_db → agent_trace_db → hooks ordering and no boxed provider aggregation. Hooks has a `services/hooks/lifecycle.rs` provider for hook rollout diagnosis/fix/setup, config has a `services/config/lifecycle.rs` provider for global/repo-local config validation plus repo-local config bootstrap, local_db has a `services/local_db/lifecycle.rs` provider for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup, auth_db has a `services/auth_db/lifecycle.rs` provider for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup, and agent_trace_db has a `services/agent_trace_db/lifecycle.rs` provider for repository-scoped Agent Trace DB setup and repository DB path health/parent readiness from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path). Doctor runtime aggregates the full shared provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor-owned output records; setup command aggregates the shared provider catalog for `setup` with hooks included only when requested and adapts lifecycle setup outcomes before rendering setup-owned messages. Agent Trace lifecycle setup now resolves repository storage, creates/reuses checkout identity for diagnostics, and initializes `/sce/repos//agent-trace.db` via `RepositoryAgentTraceDb`; hook runtime lazy initialization uses the same repository storage resolver when setup has not prepared the DB or schema metadata is incomplete. The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. -The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/Pi/All with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`), standalone `--bootstrap-context` for additive durable-context baseline creation without integration installs, deterministic mutually-exclusive validation, and non-destructive cancellation exits; the former `--both` flag was removed in favor of `--all` (opencode+claude+pi). Every normal successful setup path also ensures the same context baseline after the Git gate. Workflows the catalog marks optional are installed only when a repository opts in: the repeatable `sce setup --workflow ` flag names the selection for a run, an omitted flag reuses the selection persisted in `integrations.optional_workflows`, and the resolved selection filters the installed target assets and is written back to repo-local config. Interactive runs ask for the selection instead: a multi-select prompt follows target selection with every row unchecked on a first run and pre-checked from the persisted selection afterwards, cancelling it exits non-destructively like the target prompt, and the prompt is skipped when the catalog marks no workflow optional. `brownfield` is currently the only optional workflow, so a default run installs the five core workflows and no brownfield assets. `sce doctor` scopes its integration checks to that same recorded selection, so it never reports an unselected optional workflow's files as missing. +The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/Pi/Codex/All with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--codex`, `--all`), standalone `--bootstrap-context` for additive durable-context baseline creation without integration installs, deterministic mutually-exclusive validation, and non-destructive cancellation exits; the former `--both` flag was removed in favor of `--all` (opencode+claude+pi+codex). Every normal successful setup path also ensures the same context baseline after the Git gate. Workflows the catalog marks optional are installed only when a repository opts in: the repeatable `sce setup --workflow ` flag names the selection for a run, an omitted flag reuses the selection persisted in `integrations.optional_workflows`, and the resolved selection filters the installed target assets and is written back to repo-local config. Interactive runs ask for the selection instead: a multi-select prompt follows target selection with every row unchecked on a first run and pre-checked from the persisted selection afterwards, cancelling it exits non-destructively like the target prompt, and the prompt is skipped when the catalog marks no workflow optional. `brownfield` is currently the only optional workflow, so a default run installs the five core workflows and no brownfield assets. `sce doctor` scopes its integration checks to that same recorded selection, so it never reports an unselected optional workflow's files as missing. For repository generation consumers, `config/pkl/generator-inputs.txt` declares the canonical Pkl/plugin input set and `scripts/produce-cli-generated-input.sh` owns its discovery, two-pass `config/pkl/generate.pkl` evaluation, determinism comparison, payload/input inventories, in-flight input-mutation rejection, atomic handoff publication, and staging cleanup. `scripts/run-cli-cargo.sh` creates a fresh temporary destination, delegates generation to that producer, invokes the requested Cargo workflow with `SCE_CLI_GENERATED_INPUT_DIR`, and removes the handoff after Cargo success, failure, or handled signals. `config/pkl/check-generated.sh` delegates the same production mechanics while retaining contract and path assertions. `scripts/prepare-cli-generated-assets.sh` moves the producer-validated Pkl payload and checksums into the unchanged package fallback, adds hooks, migrations, and the Agent Trace schema, and appends only those static checksums to the combined inventory. The root flake's pre-Cargo `cliGeneratedInput` derivation invokes the same producer from a declarative source containing the producer plus its declared inputs. `cli/build.rs` rejects missing, incomplete, modified, or stale repository handoffs, copies the validated payload into Cargo `OUT_DIR/pkl-generated`, stages static inputs under `OUT_DIR/static`, and writes setup-asset, optional-workflow-catalog, and migration Rust manifests into `OUT_DIR`; it never invokes Pkl. Published crates carry the ignored packaging-only fallback, and unpacked downstream builds validate and copy it into their own `OUT_DIR` without requiring Pkl or parent repository paths. The setup service also provides repository-root install orchestration: it resolves the repository root, ensures the additive durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup installs config assets (`.opencode`/`.claude`/`.pi`) per file: each embedded asset is staged and swapped into its own destination path, creating parent directories as needed, without removing or recreating the target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. Two assets are merge targets rather than verbatim writes: Claude's `.claude/settings.json` and OpenCode's `.opencode/opencode.json`. For each, setup JSON-merges the generated document into the user's existing file rather than overwriting it, and fails deterministically without writing if the existing file is not valid JSON; a missing file is still created from the generated document verbatim. Claude's merge replaces only SCE-owned hook entries (identified by a command containing `run-sce-or-show-install-guidance.sh`) and the `$schema` key while preserving every other key and hook entry untouched. OpenCode's merge replaces the `$schema` key and merges the `plugin` array as a set: any entry shaped like an SCE plugin path (`./plugins/sce-*`) is dropped, structurally, so a path an older or renamed catalog once installed is still recognized and pruned, and the generated document's canonical plugin entries are appended after the surviving user entries. Required-hook install uses the same per-file stage/atomic-swap choreography as config-asset install — the staging file is renamed directly over an existing hook without unlinking it first, so a rename failure leaves the prior hook untouched. Both flows return deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. After installing, config install prunes stale SCE-owned assets: it deletes every path the full embedded catalog for the target claims but the current selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then removes any parent directory left empty by that deletion, leaving a directory intact if a user file still lives inside it. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs`now separates install-flow logic from interactive prompt logic through focused support seams. The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction,`sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. @@ -72,7 +72,7 @@ The local DB service now provides `LocalDb` as a thin `TursoDb` ali The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, remains the active bounded recent-diff-trace intersection path, and after successful Agent Trace persistence optionally launches the detached sync-owned `sync --format json` child when config-file-only `agent_trace.auto_sync` is true; `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, direct-first/event-transcript-second Claude `model_id` plus direct `tool_version` values (no session-model fallback or cache), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. The setup service now also includes required-hook install orchestration (`install_required_git_hooks`) that resolves repository root and effective hooks path from git truth, computes the bytes to stage by merging the canonical hook template with any existing hook (preserving a foreign hook's content as an exact prefix with the SCE managed block appended, or bringing an SCE-owned block current in place) rather than writing canonical bytes verbatim, enforces deterministic per-hook outcomes (`Installed`/`Updated`/`Skipped`) against that merged content, surfaces a deterministic advisory when an appended block would be unreachable, and uses a unified atomic-swap policy that renames staged content directly over existing hooks without unlinking them first, with deterministic recovery guidance on swap failures; this behavior is documented in `context/sce/setup-githooks-install-flow.md`. -The setup command parser/dispatch now also supports composable setup+hooks runs (`sce setup --opencode|--claude|--pi|--all --hooks`) plus hooks-only mode (`sce setup --hooks` with optional `--repo `), enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits deterministic setup/hook outcome messaging (`installed`/`updated`/`skipped`); this behavior is documented in `context/sce/setup-githooks-cli-ux.md`. +The setup command parser/dispatch now also supports composable setup+hooks runs (`sce setup --opencode|--claude|--pi|--codex|--all --hooks`) plus hooks-only mode (`sce setup --hooks` with optional `--repo `), enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits deterministic setup/hook outcome messaging (`installed`/`updated`/`skipped`); this behavior is documented in `context/sce/setup-githooks-cli-ux.md`. ## Repository model @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex has no CLI setup/install wiring yet, so it remains a generation-pipeline participant only until later Codex-integration tasks land. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` itself is not yet implemented, so its hook assets currently install inert until later Codex-integration tasks land. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 12c88cbd..6b761460 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -128,21 +128,31 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/patterns.md` and `context/overview.md` state the generation contract's exact artifact-path count as a literal fact (133), now stale at 135; `context/overview.md`'s Codex-renderer sentence also describes Codex output as "skills-only" with "no CLI setup/install wiring yet", which is now incomplete since `.codex/hooks.json`/hook-script generation is a second Codex asset kind in the pipeline (still with no CLI setup/install wiring — that remains T04/T05). - Context synchronization: synced -- [ ] T04: `Wire Codex's dual .agents/ + .codex/ output roots into embedded-asset install` (status:todo) +- [x] T04: `Wire Codex's dual .agents/ + .codex/ output roots into embedded-asset install` (status:done) - Task ID: T04 - Scope: In — a `config/codex-target/` build-time source layout (`.agents/skills/**`, `.codex/hooks.json`, `.codex/hooks/**`); `cli/build.rs` `CODEX_EMBEDDED_ASSETS` generation from the Pkl-generated payload (parallel to `OPENCODE_EMBEDDED_ASSETS`/`CLAUDE_EMBEDDED_ASSETS`/`PI_EMBEDDED_ASSETS`); package-fallback preparation (`scripts/prepare-cli-generated-assets.sh` or equivalent) for the two new roots; the shared per-target install layout struct in `cli/src/services/setup/mod.rs` (around line 98) changed so `command_dir: Option<&'static str>` (Codex has no command dir — skills only), with existing OpenCode/Claude/Pi behavior unchanged (`Some(...)`); optional-workflow asset filtering adjusted to skip command-file exclusion when `command_dir` is `None`. Out — the `SetupTarget`/CLI-flag/config-schema plumbing that actually selects Codex for a run (T05). - Dependencies: T02, T03 - Done when: an embedded-asset unit test proves `CODEX_EMBEDDED_ASSETS` contains normalized relative-path entries for every generated `.agents/skills/**` and `.codex/**` file with no `.agents/commands/**` entries; existing OpenCode/Claude/Pi embedded-asset tests still pass unmodified. - Verify: `nix develop -c sh -c 'cd cli && cargo test setup::'`. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `cli/build.rs`, `cli/src/services/setup/mod.rs` + - Result: `cli/build.rs` gained a `stage_codex_target` staging step (run after both the repository-source and packaged-fallback branches, since either populates the Pkl-generated payload it reads from) that merges `pkl-generated/config/.agents` → `pkl-generated/config/codex-target/.agents` and `pkl-generated/config/.codex` → `pkl-generated/config/codex-target/.codex` inside `OUT_DIR` — a build-time-only staging directory, not a git-tracked one, since Pkl (T02/T03) only ever writes to `config/.agents`/`config/.codex`. `TARGETS` gained a `CODEX_EMBEDDED_ASSETS` entry with `generated_root: "config/codex-target"`, reusing the existing single-root embedding mechanism unmodified; its relative paths therefore retain their `.agents/`/`.codex/` prefixes (e.g. `.agents/skills/sce-next-task/SKILL.md`, `.codex/hooks.json`), unlike the other three targets whose relative paths are stripped of their own root. `validate_staged_artifacts`'s required-directory check was extended from the first 3 `TARGETS` entries to the first 4 to cover Codex. Since `CODEX_EMBEDDED_ASSETS` has no consumer yet (`SetupTarget::Codex` is T05's job), `TargetSpec` gained an `allow_dead_code` field so only the generated Codex constant carries `#[allow(dead_code)]`, matching the T01 precedent for forward-declared-but-unwired code. `scripts/prepare-cli-generated-assets.sh` needed no change: it moves the entire generated `pkl-generated` tree wholesale, so `.agents`/`.codex` are already covered. In `cli/src/services/setup/mod.rs`, `WorkflowAssetLayout.command_dir` became `Option<&'static str>`; the three existing `workflow_asset_layout` arms now return `Some(...)` with unchanged values; `asset_belongs_to_optional_workflow` now treats a `None` command dir as never matching a command-path exclusion instead of building one. No `SetupTarget::Codex` arm was added (T05, per this task's own out-of-scope boundary). Added `codex_embedded_assets_cover_both_output_roots_with_no_command_dir`, asserting `CODEX_EMBEDDED_ASSETS` contains `.agents/skills/sce-next-task/SKILL.md`, `.codex/hooks.json`, and `.codex/hooks/run-sce-or-show-install-guidance.sh`, and contains no `.agents/commands/` entries. + - Verify: `nix flake check` (per repo Bash policy precedent from T01, over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests` (`cargo test`, including `services::setup::tests::codex_embedded_assets_cover_both_output_roots_with_no_command_dir` and all 55 other `setup::` tests unmodified and passing), `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`. Also ran `nix develop -c sh -c 'cd cli && cargo test setup::'` directly beforehand: 56 passed, 0 failed. + - Context impact: none — internal build-time wiring and a type change (`Option<&'static str>`) behind a struct not referenced by any root context file; `CODEX_EMBEDDED_ASSETS` is not yet reachable from any CLI command or `SetupTarget` variant (deferred to T05), and OpenCode/Claude/Pi's externally observable optional-workflow filtering behavior — the formula `context/architecture.md` and `context/glossary.md` document — is unchanged (their arms still resolve to `Some(...)` with the same directory constants). + - Context synchronization: synced -- [ ] T05: `Add Codex as a setup/integration target end-to-end` (status:todo) +- [x] T05: `Add Codex as a setup/integration target end-to-end` (status:done) - Task ID: T05 - Scope: In — `SetupTarget::Codex` in `cli/src/services/setup/mod.rs`; `IntegrationTargetId::Codex` in `cli/src/services/config/types.rs` (+ schema.rs mapping); `--codex` CLI flag, mutual-exclusion validation, non-interactive validation, help/error text, interactive setup choice, `--all` expansion to include Codex, install engine wiring to `CODEX_EMBEDDED_ASSETS`, `integrations.target` persistence accepting `"codex"`, and the Pkl-authored config JSON Schema (`sce-config-schema.pkl`) accepting `"codex"` in `integrations.target`. Out — doctor coverage (T13), hook runtime (T06+). - Dependencies: T04 - Done when: `sce setup --codex --non-interactive` in a scratch git repo installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**` and records `{"integrations": {"target": ["codex"]}}`; `sce setup --all --non-interactive` includes Codex alongside OpenCode/Claude/Pi with no regression to the other three; `sce config validate` accepts a config file with `integrations.target: ["codex"]` and rejects an unknown target while listing `codex` among the valid values. - Verify: `nix develop -c sh -c 'cd cli && cargo test setup:: config::'`; manual `sce setup --codex --non-interactive` run in a scratch repo per AC1/AC2. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `cli/src/cli_schema.rs`, `cli/src/command_surface.rs`, `cli/src/services/config/types.rs`, `cli/src/services/default_paths.rs`, `cli/src/services/doctor/inspect.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/setup/mod.rs`, `config/pkl/base/sce-config-schema.pkl` + - Result: Added `SetupTarget::Codex` and wired it into every exhaustive match in `cli/src/services/setup/mod.rs` (embedded-asset lookup → `CODEX_EMBEDDED_ASSETS`, `workflow_asset_layout` with `command_dir: None`, `setup_target_label`, `concrete_targets_for` — `All` now expands to four targets, `integration_target_id_str` → `"codex"`, both `install`-module `destination_root` matches, `SetupPromptTarget::Codex` and its label, and the `All` prompt label text). Added a `codex_asset` module (`SKILLS_DIR = ".agents/skills"`, keeping Codex's un-stripped output-root prefix) and an `InstallTargetPaths::codex_target_dir()` accessor in `cli/src/services/default_paths.rs` that returns the repo root itself, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix from T04's staging (unlike OpenCode/Claude/Pi, whose relative paths are stripped of their single root). Added `--codex` to `cli/src/cli_schema.rs` (mutual exclusion with the other three target flags and vice versa), threaded it through `cli/src/services/parse/command_runtime.rs`, `SetupCliOptions.codex`, and `resolve_setup_request`'s target-selection/mutual-exclusion/bootstrap-context-conflict/NotTTY error text; updated the `Setup Usage:` bracket list in `cli/src/command_surface.rs`. Added `IntegrationTargetId::Codex` and a `"codex"` parse arm in `cli/src/services/config/types.rs`, updating the "Valid values" error text and its tests. Added `"codex"` to the `integrations.target` enum in `config/pkl/base/sce-config-schema.pkl` (feeds the generated JSON Schema `schema.rs` already embeds via `include_str!`, no Rust wiring needed). Added a single compiler-forced no-op `IntegrationTargetId::Codex` arm to the existing exhaustive match in `cli/src/services/doctor/inspect.rs`'s `inspect_repository_integrations` — actual Codex doctor health-check coverage remains T13's scope; this arm only keeps the crate compiling now that the enum has a fourth variant. Doctor's fallback directory-detection and "no integrations installed" remediation text were left untouched (T13 scope; config-based target detection already covers Codex generically via `IntegrationTargetId::parse`). No merge-target special-casing was added for Codex (skills/hooks are plain overwrite installs, matching Pi's precedent, not Claude's/OpenCode's settings-merge pattern). + - Verify: `nix flake check` (direct `cargo test` is blocked by this repo's Bash policy `use-nix-flake-check-over-cargo-test`, per T01/T04 precedent) — passed: "all checks passed!", covering `cli-tests` (`cargo test`, including new/updated tests `resolve_setup_request_accepts_codex_target`, `concrete_targets_for_all_expands_to_four_targets`, `integration_target_id_str_maps_codex`, `install_writes_codex_assets_directly_under_repo_root`, updated `iter_embedded_assets_for_all_covers_each_concrete_target`, and the config-module `parses_known_target_ids`/`rejects_unknown_target_id_and_lists_valid_values` tests), `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`. Manual verification per AC1/AC2 in scratch git repos built via `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml --bin sce`: `sce setup --codex --non-interactive` installed `.agents/skills/{sce-change-to-plan,sce-commit,sce-decision,sce-handover,sce-next-task,sce-validate}` and `.codex/hooks.json` + `.codex/hooks/run-sce-or-show-install-guidance.sh` directly at the repo root and wrote `{"integrations": {"optional_workflows": [], "target": ["codex"]}}` to `.sce/config.json`; `sce setup --all --non-interactive` reported "Selected target(s): OpenCode, Claude, Pi, Codex" and installed all four target trees (`.opencode` 35 files, `.claude` 31 files, `.pi` 30 files, plus the Codex dual-root assets) with `.sce/config.json` recording `"target": ["opencode", "claude", "pi", "codex"]`; `sce config validate` reported "valid" against the `["codex"]`-only config and, against a config with `"target": ["cursor"]`, reported "invalid" with a schema-validation error confirming `"cursor"` is rejected against the now-four-member enum (the JSON-Schema-validator's own truncated "is not one of ... or N other candidates" phrasing is pre-existing `jsonschema`-crate error-rendering behavior unrelated to this task, not a literal enumeration of accepted values — the fully-spelled `IntegrationTargetId::parse` error text, "Valid values: opencode, claude, pi, codex.", is exercised only past that first schema gate). `sce doctor --format json` also ran cleanly against the Codex-installed scratch repo with no crash from the added no-op arm. + - Context impact: root — `context/cli/cli-command-surface.md` (new `sce setup --codex` flag) and `context/cli/config-precedence-contract.md` (`integrations.target` now accepting `"codex"`) are both explicitly named under this plan's "Context sync" list and state the current CLI surface/schema as fact; `sce hooks codex` itself is not yet wired (T06+), so only the setup/config-target surface changed here. + - Context synchronization: synced - [ ] T06: `Implement sce hooks codex: typed event parsing and dispatcher skeleton` (status:todo) - Task ID: T06 diff --git a/context/sce/setup-githooks-cli-ux.md b/context/sce/setup-githooks-cli-ux.md index 836fadd3..4350f1ff 100644 --- a/context/sce/setup-githooks-cli-ux.md +++ b/context/sce/setup-githooks-cli-ux.md @@ -32,19 +32,20 @@ Target-install mode contract: - `sce setup` defaults to interactive target selection - default interactive `sce setup` installs selected config assets and required hooks in one run -- `--opencode`, `--claude`, `--pi`, and `--all` are mutually exclusive for non-interactive target install; `--both` was removed and now fails as an unknown option (use `--all` for multi-target installs) -- `--non-interactive` is an explicit fail-fast control that disables prompting and requires one target flag (`--opencode`, `--claude`, `--pi`, or `--all`) -- legacy one-purpose invocations remain valid (`sce setup --hooks` for hooks-only, and `sce setup --opencode|--claude|--pi|--all` for config-only) +- `--opencode`, `--claude`, `--pi`, `--codex`, and `--all` are mutually exclusive for non-interactive target install; `--both` was removed and now fails as an unknown option (use `--all` for multi-target installs) +- `--non-interactive` is an explicit fail-fast control that disables prompting and requires one target flag (`--opencode`, `--claude`, `--pi`, `--codex`, or `--all`) +- legacy one-purpose invocations remain valid (`sce setup --hooks` for hooks-only, and `sce setup --opencode|--claude|--pi|--codex|--all` for config-only) - interactive setup without a TTY returns actionable guidance to rerun with `--non-interactive` plus a target flag ## Integration target persistence -Non-interactive `--opencode`, `--claude`, `--pi`, and `--all` target installs persist the selected target(s) into `.sce/config.json` under `integrations.target` after successful config asset installation: +Non-interactive `--opencode`, `--claude`, `--pi`, `--codex`, and `--all` target installs persist the selected target(s) into `.sce/config.json` under `integrations.target` after successful config asset installation: - `--opencode` records `["opencode"]`. - `--claude` adds `"claude"` to an existing array (e.g. `["opencode"]` → `["opencode", "claude"]`). - `--pi` adds `"pi"` the same way. -- `--all` records `["opencode", "claude", "pi"]` atomically. +- `--codex` adds `"codex"` the same way. +- `--all` records `["opencode", "claude", "pi", "codex"]` atomically. - Repeated runs are idempotent — existing targets are deduplicated; previously unrelated config keys (`$schema`, `log_level`, etc.) are preserved. - If the config file does not exist, it is bootstrapped first, then the targets are written. - `--hooks` only setup (`sce setup --hooks`) does not modify `integrations.target`. diff --git a/context/sce/setup-no-backup-policy-seam.md b/context/sce/setup-no-backup-policy-seam.md index b8c40c24..301432da 100644 --- a/context/sce/setup-no-backup-policy-seam.md +++ b/context/sce/setup-no-backup-policy-seam.md @@ -4,12 +4,12 @@ ## Current state -- Config install (`.opencode`/`.claude`/`.pi`, `install_embedded_setup_assets` / `install_assets_for_concrete_target_with_rename`) writes every embedded asset to its own path under the target directory, creating parent directories as needed: +- Config install (`.opencode`/`.claude`/`.pi`, plus Codex's `.agents`/`.codex` pair installed directly at the repository root since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own output-root prefix — see `InstallTargetPaths::codex_target_dir()` — rather than a single per-target subdirectory; `install_embedded_setup_assets` / `install_assets_for_concrete_target_with_rename`) writes every embedded asset to its own path under the target directory, creating parent directories as needed: 1. Write the asset's canonical content to a unique staging file next to its final destination. 2. If a directory exists at that exact destination path, fail with an actionable error instead of deleting it. 3. Rename the staging file directly over the final destination, replacing any existing file there atomically. 4. On swap failure, clean the staging artifact and return deterministic recovery guidance naming that asset's destination path (recover from version control if needed); the pre-existing destination content, if any, is untouched because it was never removed. -- Setup never removes an integration target directory (`.opencode`, `.claude`, `.pi`) as a whole, and never touches a path it did not author. Files a repository placed inside an SCE-owned target directory — at the top level or nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run untouched. +- Setup never removes an integration target directory (`.opencode`, `.claude`, `.pi`) as a whole, and never touches a path it did not author. Codex has no single target directory to protect this way — its assets install directly at the repository root — but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. Files a repository placed inside an SCE-owned target directory — at the top level or nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run untouched. - Required hook install (`install_required_git_hooks`) uses the same per-file stage/atomic-swap choreography for each hook file, and — like the two JSON merge targets below — is itself a content-computation seam ahead of that shared swap: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook`, which preserves a foreign hook's bytes as an exact prefix and appends the canonical SCE managed block after them, rather than always writing the canonical asset verbatim (see [setup-githooks-install-flow.md](setup-githooks-install-flow.md)). - After the per-asset install loop, config install prunes stale SCE-owned paths: `prune_stale_assets_for_concrete_target` diffs the full embedded-asset catalog for the concrete target against the assets this run actually installed, and deletes every catalog path present in the former but not the latter (deselected optional-workflow files, or an asset a newer catalog renamed or dropped). Each successful deletion is followed by `remove_empty_ancestor_directories`, which removes now-empty parent directories upward until it reaches the target root or hits a directory that still holds something (a directory holding a user file fails to remove and is left in place, so a user file nested inside an SCE-owned skill directory survives even though the SCE file next to it is pruned). Pruning is stateless and catalog-derived — no install manifest is persisted — so it only ever considers paths the compiled-in catalog still names. - No `.backup` artifacts are created during any setup write flow, and no backup-based rollback is attempted on swap failure. diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index d6b1270b..177c0ac1 100644 --- a/context/sce/setup-repo-local-config-bootstrap.md +++ b/context/sce/setup-repo-local-config-bootstrap.md @@ -25,12 +25,13 @@ Task `setup-repo-gate-and-local-config-bootstrap` T02, `turso-local-db-sync` T04 ## Post-install integration target persistence -After config asset installation succeeds for a non-interactive target (`--opencode`, `--claude`, `--pi`, or `--all`), setup persists the selected target(s) into `.sce/config.json` under `integrations.target`: +After config asset installation succeeds for a non-interactive target (`--opencode`, `--claude`, `--pi`, `--codex`, or `--all`), setup persists the selected target(s) into `.sce/config.json` under `integrations.target`: - `--opencode` records `["opencode"]`. - `--claude` adds `"claude"` to an existing array (e.g. `["opencode"]` → `["opencode", "claude"]`). - `--pi` adds `"pi"` the same way. -- `--all` records `["opencode", "claude", "pi"]` atomically. (`--both` was removed when `--all` was introduced.) +- `--codex` adds `"codex"` the same way. +- `--all` records `["opencode", "claude", "pi", "codex"]` atomically. (`--both` was removed when `--all` was introduced.) - Repeated runs are idempotent — existing targets are deduplicated; previously unrelated config keys (`$schema`, `log_level`, etc.) are preserved. - If the config file does not exist, it is bootstrapped first, then the targets are written. - `--hooks` only setup does not modify `integrations.target`. From 2138559af9b0b08273e13234250d62efd01e5033 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 11:22:15 +0200 Subject: [PATCH 05/20] hooks: Implement Codex dispatcher skeleton Add typed Codex hook-event parsing and route supported lifecycle/tool combinations to distinct stub arms while treating unsupported inputs as deterministic no-ops. Wire `sce hooks codex` through CLI parsing and runtime dispatch, log malformed payloads, and fail open so hook execution remains successful until later capture and policy tasks. Document the new hook surface and completed integration task. Plan: codex-cli-integration (T06) Co-authored-by: SCE --- cli/src/cli_schema.rs | 3 + cli/src/services/hooks/codex/mod.rs | 267 ++++++++++++++++++++++ cli/src/services/hooks/mod.rs | 4 + cli/src/services/parse/command_runtime.rs | 1 + context/architecture.md | 2 +- context/cli/cli-command-surface.md | 4 +- context/glossary.md | 4 +- context/overview.md | 4 +- context/patterns.md | 3 +- context/plans/codex-cli-integration.md | 13 +- 10 files changed, 293 insertions(+), 12 deletions(-) create mode 100644 cli/src/services/hooks/codex/mod.rs diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 0b4cebec..a7dc33bc 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -320,6 +320,9 @@ pub enum HooksSubcommand { #[command(about = "Run conversation-trace hook (reads JSON payload from STDIN)")] ConversationTrace, + + #[command(about = "Run Codex hook (reads JSON payload from STDIN)")] + Codex, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs new file mode 100644 index 00000000..bec77f4d --- /dev/null +++ b/cli/src/services/hooks/codex/mod.rs @@ -0,0 +1,267 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::Value; + +use crate::services::observability::traits::Logger; + +use super::read_hook_stdin; + +const CODEX_HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; +const CODEX_HOOK_EVENT_STOP: &str = "Stop"; +const CODEX_HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +const CODEX_HOOK_TOOL_BASH: &str = "Bash"; + +/// A single Codex hook lifecycle event, deserialized from the raw STDIN JSON +/// payload `sce hooks codex` receives via +/// `.codex/hooks/run-sce-or-show-install-guidance.sh`. +/// +/// Working contract (see plan `context/plans/codex-cli-integration.md` +/// Assumptions): `hook_event_name` is present on every event; `session_id`, +/// `turn_id`, `cwd`, and `model` vary by event; `tool_name`/`tool_use_id`/ +/// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct CodexHookEvent { + pub(crate) hook_event_name: String, + #[serde(default)] + pub(crate) session_id: Option, + #[serde(default)] + pub(crate) turn_id: Option, + #[serde(default)] + pub(crate) cwd: Option, + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) tool_name: Option, + #[serde(default)] + pub(crate) tool_use_id: Option, + #[serde(default)] + pub(crate) tool_input: Option, + #[serde(default)] + pub(crate) tool_response: Option, +} + +/// The set of Codex hook-event/tool combinations `sce hooks codex` gives +/// distinct behavior. Every other combination classifies as `NoOp`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CodexDispatchArm { + UserPromptSubmit, + Stop, + PreToolUseBash, + NoOp, +} + +pub(crate) fn classify_codex_event(event: &CodexHookEvent) -> CodexDispatchArm { + match (event.hook_event_name.as_str(), event.tool_name.as_deref()) { + (CODEX_HOOK_EVENT_USER_PROMPT_SUBMIT, _) => CodexDispatchArm::UserPromptSubmit, + (CODEX_HOOK_EVENT_STOP, _) => CodexDispatchArm::Stop, + (CODEX_HOOK_EVENT_PRE_TOOL_USE, Some(CODEX_HOOK_TOOL_BASH)) => { + CodexDispatchArm::PreToolUseBash + } + _ => CodexDispatchArm::NoOp, + } +} + +pub(super) fn run_codex_subcommand(repository_root: &Path, logger: Option<&dyn Logger>) -> String { + let stdin_payload = match read_hook_stdin() { + Ok(payload) => payload, + Err(error) => return log_codex_fail_open(&error, logger), + }; + + match run_codex_subcommand_from_payload(repository_root, &stdin_payload) { + Ok(output) => output, + Err(error) => log_codex_fail_open(&error, logger), + } +} + +fn run_codex_subcommand_from_payload( + _repository_root: &Path, + stdin_payload: &str, +) -> Result { + let event: CodexHookEvent = serde_json::from_str(stdin_payload) + .context("Invalid Codex hook payload from STDIN: expected valid JSON.")?; + + Ok(match classify_codex_event(&event) { + CodexDispatchArm::UserPromptSubmit => { + "codex hooks: UserPromptSubmit dispatch (stub; capture lands in T07).".to_string() + } + CodexDispatchArm::Stop => { + "codex hooks: Stop dispatch (stub; capture lands in T08).".to_string() + } + CodexDispatchArm::PreToolUseBash => { + "codex hooks: PreToolUse Bash dispatch (stub; policy routing lands in T09)." + .to_string() + } + CodexDispatchArm::NoOp => format!( + "codex hooks: no-op for unsupported event/tool combination (hook_event_name='{}', tool_name={:?}).", + event.hook_event_name, event.tool_name + ), + }) +} + +fn log_codex_fail_open(error: &anyhow::Error, logger: Option<&dyn Logger>) -> String { + if let Some(log) = logger { + log.error("sce.hooks.codex.error", &error.to_string(), &[], None); + } + + String::from("codex hook intake failed open; error logged.") +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + fn event(hook_event_name: &str, tool_name: Option<&str>) -> CodexHookEvent { + CodexHookEvent { + hook_event_name: hook_event_name.to_string(), + session_id: Some("abc123".to_string()), + turn_id: Some("turn-1".to_string()), + cwd: None, + model: None, + tool_name: tool_name.map(str::to_string), + tool_use_id: None, + tool_input: None, + tool_response: None, + } + } + + #[test] + fn classify_codex_event_routes_user_prompt_submit() { + assert_eq!( + classify_codex_event(&event("UserPromptSubmit", None)), + CodexDispatchArm::UserPromptSubmit + ); + } + + #[test] + fn classify_codex_event_routes_stop() { + assert_eq!( + classify_codex_event(&event("Stop", None)), + CodexDispatchArm::Stop + ); + } + + #[test] + fn classify_codex_event_routes_pre_tool_use_bash() { + assert_eq!( + classify_codex_event(&event("PreToolUse", Some("Bash"))), + CodexDispatchArm::PreToolUseBash + ); + } + + #[test] + fn classify_codex_event_routes_pre_tool_use_apply_patch_to_no_op() { + assert_eq!( + classify_codex_event(&event("PreToolUse", Some("apply_patch"))), + CodexDispatchArm::NoOp + ); + } + + #[test] + fn classify_codex_event_routes_post_tool_use_apply_patch_to_no_op() { + assert_eq!( + classify_codex_event(&event("PostToolUse", Some("apply_patch"))), + CodexDispatchArm::NoOp + ); + } + + #[test] + fn classify_codex_event_routes_unknown_pre_tool_use_tool_name_to_no_op() { + assert_eq!( + classify_codex_event(&event("PreToolUse", Some("Edit"))), + CodexDispatchArm::NoOp + ); + } + + #[test] + fn classify_codex_event_routes_pre_tool_use_with_no_tool_name_to_no_op() { + assert_eq!( + classify_codex_event(&event("PreToolUse", None)), + CodexDispatchArm::NoOp + ); + } + + #[test] + fn classify_codex_event_routes_post_tool_use_bash_to_no_op() { + assert_eq!( + classify_codex_event(&event("PostToolUse", Some("Bash"))), + CodexDispatchArm::NoOp + ); + } + + #[test] + fn classify_codex_event_routes_unrecognized_hook_event_name_to_no_op() { + assert_eq!( + classify_codex_event(&event("SessionStart", None)), + CodexDispatchArm::NoOp + ); + } + + #[test] + fn run_codex_subcommand_from_payload_dispatches_each_supported_combination() { + let cases = [ + ( + r#"{"hook_event_name":"UserPromptSubmit","session_id":"s1","turn_id":"t1"}"#, + "UserPromptSubmit", + ), + ( + r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1"}"#, + "Stop", + ), + ( + r#"{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Bash"}"#, + "PreToolUse Bash", + ), + ]; + + for (payload, expected_substring) in cases { + let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload) + .expect("stub dispatch should succeed"); + assert!( + output.contains(expected_substring), + "expected output '{output}' to mention '{expected_substring}'" + ); + } + } + + #[test] + fn run_codex_subcommand_from_payload_no_ops_unsupported_combination_without_error() { + let payload = r#"{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Read"}"#; + + let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload) + .expect("no-op dispatch should succeed"); + + assert!(output.contains("no-op")); + } + + #[test] + fn run_codex_subcommand_from_payload_no_ops_unrecognized_hook_event_name_without_error() { + let payload = r#"{"hook_event_name":"SessionStart","session_id":"s1"}"#; + + let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload) + .expect("no-op dispatch should succeed"); + + assert!(output.contains("no-op")); + } + + #[test] + fn run_codex_subcommand_from_payload_rejects_non_json_stdin() { + let error = run_codex_subcommand_from_payload(Path::new("/tmp"), "not json") + .expect_err("malformed payload should fail parsing"); + + assert!(error.to_string().contains("Invalid Codex hook payload")); + } + + #[test] + fn run_codex_subcommand_fails_open_on_malformed_stdin_payload() { + let error = anyhow::anyhow!("Invalid Codex hook payload from STDIN: expected valid JSON."); + + let output = log_codex_fail_open(&error, None); + + assert_eq!(output, "codex hook intake failed open; error logged."); + } +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 16e4ac21..c3433285 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -34,6 +34,7 @@ use crate::services::structured_patch::{ }; use crate::services::sync::auto_sync; pub mod claude_transcript; +pub mod codex; pub mod command; pub mod lifecycle; @@ -92,6 +93,7 @@ pub enum HookSubcommand { }, DiffTrace, ConversationTrace, + Codex, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -224,6 +226,7 @@ fn run_hooks_subcommand_in_repo( HookSubcommand::ConversationTrace => { Ok(run_conversation_trace_subcommand(repository_root, logger)) } + HookSubcommand::Codex => Ok(codex::run_codex_subcommand(repository_root, logger)), } } @@ -1814,6 +1817,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", HookSubcommand::DiffTrace => "diff-trace runtime invocation", HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", + HookSubcommand::Codex => "codex runtime invocation", } } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index d2f7d05e..31d56473 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -433,6 +433,7 @@ fn convert_hooks_subcommand_request( cli_schema::HooksSubcommand::ConversationTrace => { Ok(services::hooks::HookSubcommand::ConversationTrace) } + cli_schema::HooksSubcommand::Codex => Ok(services::hooks::HookSubcommand::Codex), } } diff --git a/context/architecture.md b/context/architecture.md index 5dc3d492..68263a4d 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher itself is not yet implemented. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct stub arms — real behavior lands in later tasks — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index cf329d5b..b0d98ae9 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -52,7 +52,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable; `auth` is visible in those top-level help surfaces while `hooks` remains hidden Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`; `session-model` is no longer a supported hooks route. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, or `PreToolUse(Bash)` — currently all deterministic stubs, with real capture/policy behavior landing in later Codex-integration tasks — falling open as a no-op for every other combination (including `apply_patch`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Codex `apply_patch` tracing is not yet implemented. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -92,7 +92,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout identity diagnostics, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, and Pi integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`, still stub arms, fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. diff --git a/context/glossary.md b/context/glossary.md index c14720f2..2d399f79 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `conversation-trace` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — its three supported arms are currently stubs, with real behavior landing in later Codex-integration tasks. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, and `conversation-trace` is the active message/part intake path. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms, currently all stubs, with every other event/tool combination and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. diff --git a/context/overview.md b/context/overview.md index ae6c55c9..e8dbc880 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`, but `sce hooks codex` itself is not yet implemented, so its hook assets currently install inert until later Codex-integration tasks land. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher skeleton (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, or `PostToolUse(apply_patch)` — each still a deterministic stub returning success text, with real capture/policy/diff-attribution behavior landing in later Codex-integration tasks — while every other combination, and any malformed STDIN payload, fails open as a no-op. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` itself is not yet implemented, so its hook assets currently install inert until later Codex-integration tasks land. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher skeleton classifying each event into one of the three supported arms above (still stubs) or a no-op fallthrough, with real behavior landing in later Codex-integration tasks. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index ba2e964a..10944a18 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -158,9 +158,10 @@ - For cross-service CLI dependencies exposed through the borrowed `AppContext` view, prefer shared capability/accessor traits over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors, and keep command execution generic over the narrow accessors each command needs where practical. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path. `session-model` is no longer a supported hook intake path. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand, currently a stub. `session-model` is no longer a supported hook intake path. - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. +- For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `apply_patch`) to the same deterministic `NoOp` success text rather than an error. - For diff-trace attribution persistence, keep Claude model resolution event-local: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, normalize either source through the `claude/` convention, and store unresolved attribution as `NULL` in `diff_traces`. Persist `tool_version` directly. Do not restore the former `session_models` fallback or any session-level cache. - For recent structured diff-trace reconstruction, treat persisted row attribution as canonical: assign the row `model_id` to every reconstructed hunk and the tool-prefixed row `session_id` to every reconstructed touched line before combination/intersection. Never reuse the raw unprefixed Claude payload session as touched-line provenance. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate) plus the staged-diff AI-overlap evidence gate (`StagedDiffAiOverlapResult::Overlap` maps to `ai_contribution_present = true`; `NoOverlap` and `Error` both map to `false`), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 6b761460..7def2b35 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -154,13 +154,18 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/cli/cli-command-surface.md` (new `sce setup --codex` flag) and `context/cli/config-precedence-contract.md` (`integrations.target` now accepting `"codex"`) are both explicitly named under this plan's "Context sync" list and state the current CLI surface/schema as fact; `sce hooks codex` itself is not yet wired (T06+), so only the setup/config-target surface changed here. - Context synchronization: synced -- [ ] T06: `Implement sce hooks codex: typed event parsing and dispatcher skeleton` (status:todo) +- [x] T06: `Implement sce hooks codex: typed event parsing and dispatcher skeleton` (status:done) - Task ID: T06 - - Scope: In — `HookSubcommand::Codex` (or equivalent) wired into `cli/src/app.rs` / `cli/src/services/hooks/mod.rs` CLI parsing and help text; a typed, explicit Codex hook-event parser covering `hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; a dispatcher matching `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, `PostToolUse(apply_patch)`, with every other event/tool combination falling through to a deterministic successful no-op; tracing/parse failures logged and fail-open (hook success, non-zero exit reserved for genuine parse-time CLI usage errors matching existing hook-command conventions). Out — the actual behavior behind each dispatch arm (T07–T12): this task's arms are stubs proven only by dispatch-routing tests. + - Scope: In — `HookSubcommand::Codex` (or equivalent) wired into `cli/src/app.rs` / `cli/src/services/hooks/mod.rs` CLI parsing and help text; a typed, explicit Codex hook-event parser covering `hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; a dispatcher matching `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, with every other event/tool combination (including `apply_patch`) falling through to a deterministic successful no-op; tracing/parse failures logged and fail-open (hook success, non-zero exit reserved for genuine parse-time CLI usage errors matching existing hook-command conventions). Out — the actual behavior behind each dispatch arm (T07–T09): this task's arms are stubs proven only by dispatch-routing tests; `apply_patch` handling is deferred to a later task. - Dependencies: T01 - - Done when: `sce hooks codex --help` and top-level `sce hooks --help` list the new subcommand; unit tests prove each of the five supported event/tool combinations routes to its own internal arm and every unsupported combination (e.g. an unknown `tool_name` under `PreToolUse`, or an unrecognized `hook_event_name`) routes to the no-op arm without error; a malformed/non-JSON STDIN payload is logged and returns hook success. + - Done when: `sce hooks codex --help` and top-level `sce hooks --help` list the new subcommand; unit tests prove each of the three supported event/tool combinations routes to its own internal arm and every unsupported combination (e.g. an unknown `tool_name` under `PreToolUse`, `apply_patch` under `PreToolUse`/`PostToolUse`, or an unrecognized `hook_event_name`) routes to the no-op arm without error; a malformed/non-JSON STDIN payload is logged and returns hook success. - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex'`. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/hooks/mod.rs`, `cli/src/services/hooks/codex/mod.rs` (new) + - Result: Added `HooksSubcommand::Codex` (clap, about "Run Codex hook (reads JSON payload from STDIN)") in `cli_schema.rs`, threaded through `convert_hooks_subcommand_request` into a new `HookSubcommand::Codex` variant in `services/hooks/mod.rs`, wired into `run_hooks_subcommand_in_repo` and `hook_runtime_invocation_name`. The implementation itself lives in a new `cli/src/services/hooks/codex` directory module (declared via `pub mod codex;` alongside the existing `claude_transcript`/`command`/`lifecycle` submodules) rather than inline in the already-3100-line `hooks/mod.rs`, so later tasks can add their own submodules under it. `codex/mod.rs` defines: a typed `CodexHookEvent` (serde `Deserialize`) covering all nine documented fields (only `hook_event_name` required; the rest optional since PreToolUse/PostToolUse-only fields don't appear on UserPromptSubmit/Stop), with `#[allow(dead_code)]` on the still-unconsumed fields matching the T01 precedent for forward-declared fields consumed by later tasks; a `CodexDispatchArm` enum (`UserPromptSubmit`, `Stop`, `PreToolUseBash`, `NoOp`) and `classify_codex_event` matching `(hook_event_name, tool_name)`, falling through to `NoOp` for every other combination (`apply_patch` under `PreToolUse`/`PostToolUse`, unknown tool under `PreToolUse`, `Bash` under `PostToolUse`, or any unrecognized `hook_event_name`); `run_codex_subcommand`/`run_codex_subcommand_from_payload`, which read STDIN, deserialize, classify, and return a deterministic stub string naming which task implements that arm's real behavior; and `log_codex_fail_open`, mirroring `log_conversation_trace_fail_open` exactly — malformed/non-JSON STDIN is logged and the function still returns `Ok()` (hook success), never propagating `Err`. Manually ran `sce hooks --help` and `sce hooks codex --help` (via `./scripts/run-cli-cargo.sh run --manifest-path cli/Cargo.toml --bin sce -- ...`) and confirmed the new subcommand is listed with its about text. + - Verify: `nix flake check` (direct `cargo test` is blocked by this repo's Bash policy `use-nix-flake-check-over-cargo-test`, per T01/T04/T05 precedent) — covering `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`; unit tests prove each of the three supported event/tool combinations classifies to its own dispatch arm, unsupported combinations (`apply_patch` under `PreToolUse`/`PostToolUse`, unknown `PreToolUse` tool_name, `PreToolUse` with no tool_name, `PostToolUse` `Bash`, unrecognized `hook_event_name`) all route to `NoOp`, and the remainder exercise `run_codex_subcommand_from_payload`/`log_codex_fail_open` end-to-end for the three dispatched arms plus malformed/non-JSON STDIN (fails open, no `Err` propagated). + - Context impact: root — `context/cli/cli-command-surface.md` states the `hooks` subcommand inventory as an exhaustive fact in two places ("`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`" and the equivalent `cli/src/services/hooks/mod.rs` line), both now stale/incomplete since `codex` is a seventh implemented subcommand; this file is already named under this plan's "Context sync" list for exactly this addition. + - Context synchronization: synced - [ ] T07: `Capture Codex UserPromptSubmit into messages/parts` (status:todo) - Task ID: T07 From 0fb352c88fbca99ee9a035e4c513e3cb493dca88 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 12:12:27 +0200 Subject: [PATCH 06/20] hooks: Capture Codex user prompts in Agent Trace Implement UserPromptSubmit dispatch persistence with existing messages/parts helpers, storing idempotently prefixed cx_ sessions and deterministic cx::user message IDs. Add injectable coverage for validation, prefixing, and parent-message deduplication. Document the completed runtime contract and update the Codex integration plan and related CLI/context references. Plan: codex-cli-integration (T07) Co-authored-by: SCE --- cli/src/services/hooks/codex/mod.rs | 19 +- .../hooks/codex/user_prompt_submit.rs | 237 ++++++++++++++++++ context/architecture.md | 2 +- context/context-map.md | 1 + context/glossary.md | 4 +- context/overview.md | 4 +- context/patterns.md | 2 +- context/plans/codex-cli-integration.md | 9 +- context/sce/agent-trace-db.md | 2 + .../sce/agent-trace-hooks-command-routing.md | 1 + context/sce/codex-integration-runtime.md | 81 ++++++ 11 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 cli/src/services/hooks/codex/user_prompt_submit.rs create mode 100644 context/sce/codex-integration-runtime.md diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index bec77f4d..e234b4ef 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -8,6 +8,8 @@ use crate::services::observability::traits::Logger; use super::read_hook_stdin; +mod user_prompt_submit; + const CODEX_HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; const CODEX_HOOK_EVENT_STOP: &str = "Stop"; const CODEX_HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; @@ -20,7 +22,9 @@ const CODEX_HOOK_TOOL_BASH: &str = "Bash"; /// Working contract (see plan `context/plans/codex-cli-integration.md` /// Assumptions): `hook_event_name` is present on every event; `session_id`, /// `turn_id`, `cwd`, and `model` vary by event; `tool_name`/`tool_use_id`/ -/// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`. +/// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`; +/// `prompt` is present only on `UserPromptSubmit`, matching Claude's own +/// `UserPromptSubmit` payload shape (see `transform_claude_user_prompt_submit_with`). #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct CodexHookEvent { @@ -41,6 +45,8 @@ pub(crate) struct CodexHookEvent { pub(crate) tool_input: Option, #[serde(default)] pub(crate) tool_response: Option, + #[serde(default)] + pub(crate) prompt: Option, } /// The set of Codex hook-event/tool combinations `sce hooks codex` gives @@ -77,7 +83,7 @@ pub(super) fn run_codex_subcommand(repository_root: &Path, logger: Option<&dyn L } fn run_codex_subcommand_from_payload( - _repository_root: &Path, + repository_root: &Path, stdin_payload: &str, ) -> Result { let event: CodexHookEvent = serde_json::from_str(stdin_payload) @@ -85,7 +91,7 @@ fn run_codex_subcommand_from_payload( Ok(match classify_codex_event(&event) { CodexDispatchArm::UserPromptSubmit => { - "codex hooks: UserPromptSubmit dispatch (stub; capture lands in T07).".to_string() + user_prompt_submit::handle(repository_root, &event)? } CodexDispatchArm::Stop => { "codex hooks: Stop dispatch (stub; capture lands in T08).".to_string() @@ -126,6 +132,7 @@ mod tests { tool_use_id: None, tool_input: None, tool_response: None, + prompt: None, } } @@ -202,12 +209,8 @@ mod tests { } #[test] - fn run_codex_subcommand_from_payload_dispatches_each_supported_combination() { + fn run_codex_subcommand_from_payload_dispatches_each_still_stubbed_combination() { let cases = [ - ( - r#"{"hook_event_name":"UserPromptSubmit","session_id":"s1","turn_id":"t1"}"#, - "UserPromptSubmit", - ), ( r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1"}"#, "Stop", diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs new file mode 100644 index 00000000..d41281da --- /dev/null +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -0,0 +1,237 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{ + InsertMessageInsert, InsertPartInsert, MessageRole, PartType, +}; + +use super::super::{ + current_unix_time_ms, open_agent_trace_db_for_hook_runtime, + prefixed_conversation_trace_session_id, CODEX_TOOL_NAME, +}; +use super::CodexHookEvent; + +/// Captures a Codex `UserPromptSubmit` event as one `messages` row +/// (`role = "user"`) and one `parts` row (`part_type = "text"`, `text = prompt`) +/// under session `cx_`, message `cx::user`. +pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Codex UserPromptSubmit persistence.", + )?; + + capture_with(&db, event, || current_unix_time_ms().unwrap_or(0)) +} + +/// Injectable counterpart of `handle` for deterministic testing against an +/// already-open Agent Trace DB. +fn capture_with( + db: &RepositoryAgentTraceDb, + event: &CodexHookEvent, + generate_timestamp_ms: T, +) -> Result +where + T: FnOnce() -> i64, +{ + let session_id = required_field(event.session_id.as_deref(), "session_id")?; + let turn_id = required_field(event.turn_id.as_deref(), "turn_id")?; + let prompt = required_field(event.prompt.as_deref(), "prompt")?; + + let prefixed_session_id = prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, session_id); + let message_id = format!("cx:{turn_id}:user"); + let generated_at_unix_ms = generate_timestamp_ms(); + + db.insert_messages(vec![InsertMessageInsert { + session_id: prefixed_session_id.clone(), + message_id: message_id.clone(), + role: MessageRole::User, + generated_at_unix_ms, + }]) + .context("Failed to insert Codex UserPromptSubmit message row.")?; + + db.insert_parts(vec![InsertPartInsert { + part_type: PartType::Text, + text: prompt.to_string(), + session_id: prefixed_session_id, + message_id, + generated_at_unix_ms, + }]) + .context("Failed to insert Codex UserPromptSubmit text part row.")?; + + Ok("codex hooks: UserPromptSubmit captured into messages/parts.".to_string()) +} + +fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { + match value { + Some(value) if !value.trim().is_empty() => Ok(value), + _ => Err(anyhow::anyhow!( + "Invalid Codex UserPromptSubmit payload: field '{field_name}' must be a non-empty string." + )), + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn unique_test_db_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!( + "sce-codex-user-prompt-submit-{label}-{}-{nonce}", + std::process::id() + )) + .join("agent-trace.db") + } + + fn remove_test_db(db_path: &Path) { + if let Some(parent) = db_path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + + fn event(session_id: &str, turn_id: &str, prompt: &str) -> CodexHookEvent { + CodexHookEvent { + hook_event_name: "UserPromptSubmit".to_string(), + session_id: Some(session_id.to_string()), + turn_id: Some(turn_id.to_string()), + cwd: None, + model: None, + tool_name: None, + tool_use_id: None, + tool_input: None, + tool_response: None, + prompt: Some(prompt.to_string()), + } + } + + fn message_rows(db: &RepositoryAgentTraceDb) -> Vec<(String, String, String)> { + db.query_map( + "SELECT session_id, message_id, role FROM messages ORDER BY id ASC", + (), + |row| { + Ok(( + row.get::(0).map_err(anyhow::Error::from)?, + row.get::(1).map_err(anyhow::Error::from)?, + row.get::(2).map_err(anyhow::Error::from)?, + )) + }, + ) + .expect("messages query should succeed") + } + + fn part_rows(db: &RepositoryAgentTraceDb) -> Vec<(String, String, String, String)> { + db.query_map( + "SELECT session_id, message_id, type, text FROM parts ORDER BY id ASC", + (), + |row| { + Ok(( + row.get::(0).map_err(anyhow::Error::from)?, + row.get::(1).map_err(anyhow::Error::from)?, + row.get::(2).map_err(anyhow::Error::from)?, + row.get::(3).map_err(anyhow::Error::from)?, + )) + }, + ) + .expect("parts query should succeed") + } + + #[test] + fn capture_with_produces_one_message_and_one_part_under_the_prefixed_session() { + let db_path = unique_test_db_path("basic"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let output = capture_with(&db, &event("session-1", "turn-1", "hello world"), || 1_000) + .expect("capture should succeed"); + assert!(output.contains("UserPromptSubmit")); + + assert_eq!( + message_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:user".to_string(), + "user".to_string() + )] + ); + assert_eq!( + part_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:user".to_string(), + "text".to_string(), + "hello world".to_string() + )] + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_keeps_an_already_prefixed_session_id_unchanged() { + let db_path = unique_test_db_path("prefixed"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + capture_with(&db, &event("cx_session-1", "turn-1", "hi"), || 1_000) + .expect("capture should succeed"); + + assert_eq!(message_rows(&db)[0].0, "cx_session-1"); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_does_not_duplicate_the_parent_message_on_reprocess() { + let db_path = unique_test_db_path("dedupe"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let payload = event("session-1", "turn-1", "hello world"); + + capture_with(&db, &payload, || 1_000).expect("first capture should succeed"); + capture_with(&db, &payload, || 2_000).expect("reprocessed capture should succeed"); + + assert_eq!( + message_rows(&db).len(), + 1, + "reprocessing the same turn's UserPromptSubmit must not duplicate the parent message row" + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_rejects_a_missing_prompt() { + let db_path = unique_test_db_path("missing-prompt"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello world"); + payload.prompt = None; + + let error = capture_with(&db, &payload, || 1_000).expect_err("missing prompt should error"); + assert!(error.to_string().contains("'prompt'")); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_rejects_a_missing_turn_id() { + let db_path = unique_test_db_path("missing-turn-id"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello world"); + payload.turn_id = None; + + let error = + capture_with(&db, &payload, || 1_000).expect_err("missing turn_id should error"); + assert!(error.to_string().contains("'turn_id'")); + + remove_test_db(&db_path); + } +} diff --git a/context/architecture.md b/context/architecture.md index 68263a4d..b17ef820 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct stub arms — real behavior lands in later tasks — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct dispatch arms. `UserPromptSubmit` now persists one user `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)); the remaining two arms are still stubs, with real behavior landing in later tasks — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). diff --git a/context/context-map.md b/context/context-map.md index 52bbfb5d..eaa858b6 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -75,6 +75,7 @@ Feature/domain context: - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s three dispatch arms plus fail-open `NoOp` fallthrough (including `apply_patch`, not yet implemented), idempotent `cx_` session prefixing and dormant `openai/` model-ID normalization, and the implemented `UserPromptSubmit` slice persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user` message ID; `Stop`/`PreToolUse(Bash)` remain stubs) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/glossary.md b/context/glossary.md index 2d399f79..df683e7a 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — its three supported arms are currently stubs, with real behavior landing in later Codex-integration tasks. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — one of its three supported arms, `UserPromptSubmit`, now captures real conversation evidence into `messages`/`parts` (see `context/sce/codex-integration-runtime.md`); the remaining two arms are still stubs, with real behavior landing in later Codex-integration tasks. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms, currently all stubs, with every other event/tool combination and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` now captures real conversation evidence, the remaining two arms are still stubs — with every other event/tool combination and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. diff --git a/context/overview.md b/context/overview.md index e8dbc880..4572c410 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher skeleton (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, or `PostToolUse(apply_patch)` — each still a deterministic stub returning success text, with real capture/policy/diff-attribution behavior landing in later Codex-integration tasks — while every other combination, and any malformed STDIN payload, fails open as a no-op. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, or `PostToolUse(apply_patch)`. `UserPromptSubmit` now captures one user `messages`/`parts` row into the repository Agent Trace DB under the idempotent `cx_` session prefix (see `context/sce/codex-integration-runtime.md`); `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, and `PostToolUse(apply_patch)` remain deterministic stubs returning success text, with real capture/policy/diff-attribution behavior for those landing in later Codex-integration tasks — while every other combination, and any malformed STDIN payload, fails open as a no-op. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher skeleton classifying each event into one of the three supported arms above (still stubs) or a no-op fallthrough, with real behavior landing in later Codex-integration tasks. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the three supported arms above or a no-op fallthrough; `UserPromptSubmit` now captures real conversation evidence, the remaining two arms are still stubs, with real behavior for those landing in later Codex-integration tasks. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index 10944a18..4b2fee8f 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -158,7 +158,7 @@ - For cross-service CLI dependencies exposed through the borrowed `AppContext` view, prefer shared capability/accessor traits over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors, and keep command execution generic over the narrow accessors each command needs where practical. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand, currently a stub. `session-model` is no longer a supported hook intake path. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` arm now captures real conversation evidence, the remaining arms are still stubs. `session-model` is no longer a supported hook intake path. - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. - For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `apply_patch`) to the same deterministic `NoOp` success text rather than an error. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 7def2b35..2927a655 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -167,13 +167,18 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/cli/cli-command-surface.md` states the `hooks` subcommand inventory as an exhaustive fact in two places ("`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`" and the equivalent `cli/src/services/hooks/mod.rs` line), both now stale/incomplete since `codex` is a seventh implemented subcommand; this file is already named under this plan's "Context sync" list for exactly this addition. - Context synchronization: synced -- [ ] T07: `Capture Codex UserPromptSubmit into messages/parts` (status:todo) +- [x] T07: `Capture Codex UserPromptSubmit into messages/parts` (status:done) - Task ID: T07 - Scope: In — the `UserPromptSubmit` dispatch arm: build `session_id = cx_`, `message_id = cx::user`, one `role="user"` message row via the existing `InsertMessageInsert`/`insert_messages` path, one `part_type="text"` part row (`text = prompt`) via `InsertPartInsert`/`insert_parts`, `generated_at_unix_ms` from hook receipt time. Out — `Stop` (T08), any new conversation table. - Dependencies: T06 - Done when: an integration test feeding a synthetic `UserPromptSubmit` payload through `sce hooks codex` produces exactly one `messages` row and one `parts` row under session `cx_` with the expected deterministic `message_id`; reprocessing the identical payload does not create a duplicate `messages` row (relies on the existing `ON CONFLICT (session_id, message_id) DO NOTHING` semantics). - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::user_prompt_submit'`. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `cli/src/services/hooks/codex/mod.rs`, `cli/src/services/hooks/codex/user_prompt_submit.rs` (new) + - Result: Added a `prompt: Option` field to `CodexHookEvent` (matching Claude's own `UserPromptSubmit` payload shape — see `transform_claude_user_prompt_submit_with`; not part of T06's originally enumerated routing-field contract, but explicitly named by this task's own scope text as the part's `text` source). Added a new `cli/src/services/hooks/codex/user_prompt_submit` submodule (needed, not just organizational, so the module path `hooks::codex::user_prompt_submit` resolves for `cargo test`'s substring filter, per T06's own precedent note) implementing `handle(repository_root, &event)`, which opens the repository's Agent Trace DB via the existing private `open_agent_trace_db_for_hook_runtime` helper and delegates to an injectable `capture_with(db, event, generate_timestamp_ms)`. `capture_with` validates `session_id`/`turn_id`/`prompt` are non-empty, computes `session_id = prefixed_conversation_trace_session_id("codex", session_id)` (reusing T01's `cx_` prefixing) and `message_id = format!("cx:{turn_id}:user")`, and persists one `InsertMessageInsert` (`role = User`) and one `InsertPartInsert` (`part_type = Text`, `text = prompt`) through the existing `RepositoryAgentTraceDb::insert_messages`/`insert_parts` — no new adapter, no new DB writer path. The `UserPromptSubmit` dispatch arm in `codex/mod.rs` now calls `user_prompt_submit::handle` instead of returning its former stub string; `run_codex_subcommand_from_payload`'s `_repository_root` parameter is now used (renamed `repository_root`). The shared stub-dispatch test in `codex/mod.rs` (`run_codex_subcommand_from_payload_dispatches_each_supported_combination`, renamed `..._dispatches_each_still_stubbed_combination`) had its `UserPromptSubmit` case removed, since that arm is no longer a stub and exercising it there would require a real git-repo + `.sce/config.json` fixture (a heavier setup this codebase's existing conversation-trace persistence tests deliberately avoid, testing only at the injectable-closure level instead — see `persist_conversation_trace_payload_to_agent_trace_db_with`); routing coverage for `UserPromptSubmit` remains via the existing `classify_codex_event_routes_user_prompt_submit` test, and persistence behavior is covered by `user_prompt_submit`'s own tests against a real temporary `RepositoryAgentTraceDb`. Deduplication is proven only for the parent message row (per this task's own Done-when text, which cites only the messages-table `ON CONFLICT` semantics); the `parts` table has no uniqueness constraint and is not asserted idempotent on reprocess, matching the plan's stated guarantee and the existing general conversation-trace pipeline's behavior (which also never gates a part insert on whether its sibling message insert actually affected a row). + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 19 passed, 0 failed, including 5 new tests under `hooks::codex::user_prompt_submit::tests` (one message + one part produced; `cx_`-prefixing idempotent for an already-prefixed session ID; reprocessing the identical event does not duplicate the `messages` row; missing `prompt` rejected; missing `turn_id` rejected). Also ran `nix flake check` (per T01/T04/T05/T06 precedent over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`. + - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's arms are "currently all deterministic stubs" / "still stub arms", both now stale since `UserPromptSubmit` is real capture behavior; this file is already named under this plan's "Context sync" list for exactly this kind of update, and the new `context/sce/codex-integration-runtime.md` this plan also names should now describe the `UserPromptSubmit` → `messages`/`parts` mapping. + - Context synchronization: synced - [ ] T08: `Capture Codex Stop into messages/parts` (status:todo) - Task ID: T08 diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 57683d7d..e35dcc55 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -202,6 +202,8 @@ Post-commit intersection rows are written by the active `post-commit` hook flow `sce hooks session-model` is no longer a supported command route, generated Claude settings no longer produce `SessionStart` model-attribution events, and the Agent Trace DB adapter no longer exposes a `session_models` API or fresh-schema table. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). +`sce hooks codex`'s `UserPromptSubmit` arm is a second, independent writer into `messages` and `parts`, reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup — not a new adapter. It stores `cx_`-prefixed session IDs and a deterministic `cx::user` message ID rather than a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md). + ## Recent patch reads `RepositoryAgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` supports the post-commit comparison flow without changing `diff_traces` writes: diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 7ce009ec..50cf39ff 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -113,6 +113,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` arm is a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user` message ID in place of a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md new file mode 100644 index 00000000..20b05c0c --- /dev/null +++ b/context/sce/codex-integration-runtime.md @@ -0,0 +1,81 @@ +# Codex hook runtime (SCE) + +Rust-side runtime behind `sce hooks codex`, the single dispatcher subcommand +every registered `.codex/hooks.json` event routes to. Source: `cli/src/services/hooks/codex/`. +See [Codex generated assets](../architecture.md) for the Pkl-authored +`.codex/hooks.json`/hook-script side of this integration and +[agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md) +for how the other three tools intake conversation/diff evidence. + +## Dispatch skeleton + +- STDIN carries one raw Codex hook-event JSON payload, deserialized into a + typed `CodexHookEvent` (`hook_event_name`, `session_id`, `turn_id`, `cwd`, + `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, + `prompt`; only `hook_event_name` is required, matching the working contract + in `context/plans/codex-cli-integration.md`). +- `classify_codex_event` matches `(hook_event_name, tool_name)` into one of + three dispatch arms — `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)` — with + every other combination (`apply_patch` under `PreToolUse`/`PostToolUse`, + unknown tool, `Bash` under `PostToolUse`, unrecognized `hook_event_name`) + falling through to a deterministic `NoOp` success. Codex `apply_patch` + tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` + `apply_patch` registration or dispatch arm. +- Malformed/non-JSON STDIN is logged through `sce.hooks.codex.error` and the + command still returns hook success (fails open), matching the other hook + intakes' producer-facing failure posture. + +## Session and model identity + +- `prefixed_session_id`/`prefixed_diff_trace_session_id`/`prefixed_conversation_trace_session_id` + (`cli/src/services/hooks/mod.rs`) carry a `"codex" -> cx_` arm alongside + `oc_`/`cc_`/`pi_`, idempotent for an already-prefixed session ID. +- `normalize_codex_model_id` idempotently prefixes a raw Codex model ID with + `openai/`, mirroring `normalize_claude_model_id`. It is not yet called from + any dispatch arm; its first consumer lands with a later Codex-integration + task. + +## Implemented slice: `UserPromptSubmit` capture + +`cli/src/services/hooks/codex/user_prompt_submit.rs` implements the +`UserPromptSubmit` arm — the first dispatch arm with real behavior; every +other arm below is still a stub. + +- Requires non-empty `session_id`, `turn_id`, and `prompt`; a missing or + blank field is a validation error (logged and failed open by the outer + dispatcher). +- `session_id` is stored as `cx_` (idempotent). `message_id` is + deterministic — `cx::user` — rather than a generated UUID, so that + reprocessing the same turn's event is a no-op for the parent message row + via the existing `messages` table's `ON CONFLICT (session_id, message_id) + DO NOTHING` semantics. +- Persists exactly one `role = "user"` row via `RepositoryAgentTraceDb::insert_messages` + and one `part_type = "text"` row (`text = prompt`) via + `RepositoryAgentTraceDb::insert_parts` — the same insert helpers and + `messages`/`parts` tables `conversation-trace` already writes; there is no + Codex-specific DB adapter. +- The `parts` table has no uniqueness constraint (append-only, like every + other producer's part rows), so only the parent message row's + non-duplication is guaranteed on reprocess, not the part row's. +- The DB is opened per invocation through the same + `open_agent_trace_db_for_hook_runtime` repository-storage resolution the + other hook intakes use. + +## Still-stub arms + +`Stop` and `PreToolUse(Bash)` currently return a deterministic stub success +string naming the future task that implements them (`Stop` mirrors +`UserPromptSubmit`'s capture shape with `role = "assistant"`; `PreToolUse(Bash)` +delegates to the existing Bash policy engine). This document will grow a +slice per arm as each lands. Codex `apply_patch` tracing has no dispatch arm +yet and is not documented here in detail; it is deferred to a later task. + +## Verification + +- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` + (also runnable narrowed per-arm, e.g. `hooks::codex::user_prompt_submit`). +- `nix flake check` runs the same tests plus clippy/fmt/generated-asset checks. + +See also: [agent-trace-db.md](agent-trace-db.md), +[agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md), +[pi-extension-runtime.md](pi-extension-runtime.md) From 1e95b87a9802c403a4c3cddd597404f98c9dbfe7 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 12:31:05 +0200 Subject: [PATCH 07/20] hooks: Capture Codex Stop conversation evidence Persist Codex Stop events as assistant messages and text parts through the existing Agent Trace database helpers, using deterministic IDs and idempotent session prefixing. Update the Codex dispatch contract and repository context to describe the completed capture behavior while retaining stubs for the remaining arms. Plan: codex-cli-integration (T08) Co-authored-by: SCE --- cli/src/services/hooks/codex/mod.rs | 16 +- cli/src/services/hooks/codex/stop.rs | 242 ++++++++++++++++++ .../hooks/codex/user_prompt_submit.rs | 1 + context/architecture.md | 2 +- context/cli/cli-command-surface.md | 4 +- context/context-map.md | 2 +- context/glossary.md | 4 +- context/overview.md | 4 +- context/patterns.md | 2 +- context/plans/codex-cli-integration.md | 7 +- context/sce/codex-integration-runtime.md | 47 ++-- 11 files changed, 291 insertions(+), 40 deletions(-) create mode 100644 cli/src/services/hooks/codex/stop.rs diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index e234b4ef..448dcaad 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -8,6 +8,7 @@ use crate::services::observability::traits::Logger; use super::read_hook_stdin; +mod stop; mod user_prompt_submit; const CODEX_HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; @@ -24,7 +25,9 @@ const CODEX_HOOK_TOOL_BASH: &str = "Bash"; /// `turn_id`, `cwd`, and `model` vary by event; `tool_name`/`tool_use_id`/ /// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`; /// `prompt` is present only on `UserPromptSubmit`, matching Claude's own -/// `UserPromptSubmit` payload shape (see `transform_claude_user_prompt_submit_with`). +/// `UserPromptSubmit` payload shape (see `transform_claude_user_prompt_submit_with`); +/// `last_assistant_message` is present only on `Stop`, matching Claude's own +/// `Stop` payload shape (see `transform_claude_stop_with`). #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct CodexHookEvent { @@ -47,6 +50,8 @@ pub(crate) struct CodexHookEvent { pub(crate) tool_response: Option, #[serde(default)] pub(crate) prompt: Option, + #[serde(default)] + pub(crate) last_assistant_message: Option, } /// The set of Codex hook-event/tool combinations `sce hooks codex` gives @@ -93,9 +98,7 @@ fn run_codex_subcommand_from_payload( CodexDispatchArm::UserPromptSubmit => { user_prompt_submit::handle(repository_root, &event)? } - CodexDispatchArm::Stop => { - "codex hooks: Stop dispatch (stub; capture lands in T08).".to_string() - } + CodexDispatchArm::Stop => stop::handle(repository_root, &event)?, CodexDispatchArm::PreToolUseBash => { "codex hooks: PreToolUse Bash dispatch (stub; policy routing lands in T09)." .to_string() @@ -133,6 +136,7 @@ mod tests { tool_input: None, tool_response: None, prompt: None, + last_assistant_message: None, } } @@ -211,10 +215,6 @@ mod tests { #[test] fn run_codex_subcommand_from_payload_dispatches_each_still_stubbed_combination() { let cases = [ - ( - r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1"}"#, - "Stop", - ), ( r#"{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Bash"}"#, "PreToolUse Bash", diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs new file mode 100644 index 00000000..68109d77 --- /dev/null +++ b/cli/src/services/hooks/codex/stop.rs @@ -0,0 +1,242 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{ + InsertMessageInsert, InsertPartInsert, MessageRole, PartType, +}; + +use super::super::{ + current_unix_time_ms, open_agent_trace_db_for_hook_runtime, + prefixed_conversation_trace_session_id, CODEX_TOOL_NAME, +}; +use super::CodexHookEvent; + +/// Captures a Codex `Stop` event as one `messages` row (`role = "assistant"`) +/// and one `parts` row (`part_type = "text"`, `text = last_assistant_message`) +/// under session `cx_`, message `cx::assistant`. +pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Codex Stop persistence.", + )?; + + capture_with(&db, event, || current_unix_time_ms().unwrap_or(0)) +} + +/// Injectable counterpart of `handle` for deterministic testing against an +/// already-open Agent Trace DB. +fn capture_with( + db: &RepositoryAgentTraceDb, + event: &CodexHookEvent, + generate_timestamp_ms: T, +) -> Result +where + T: FnOnce() -> i64, +{ + let session_id = required_field(event.session_id.as_deref(), "session_id")?; + let turn_id = required_field(event.turn_id.as_deref(), "turn_id")?; + let last_assistant_message = required_field( + event.last_assistant_message.as_deref(), + "last_assistant_message", + )?; + + let prefixed_session_id = prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, session_id); + let message_id = format!("cx:{turn_id}:assistant"); + let generated_at_unix_ms = generate_timestamp_ms(); + + db.insert_messages(vec![InsertMessageInsert { + session_id: prefixed_session_id.clone(), + message_id: message_id.clone(), + role: MessageRole::Assistant, + generated_at_unix_ms, + }]) + .context("Failed to insert Codex Stop message row.")?; + + db.insert_parts(vec![InsertPartInsert { + part_type: PartType::Text, + text: last_assistant_message.to_string(), + session_id: prefixed_session_id, + message_id, + generated_at_unix_ms, + }]) + .context("Failed to insert Codex Stop text part row.")?; + + Ok("codex hooks: Stop captured into messages/parts.".to_string()) +} + +fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { + match value { + Some(value) if !value.trim().is_empty() => Ok(value), + _ => Err(anyhow::anyhow!( + "Invalid Codex Stop payload: field '{field_name}' must be a non-empty string." + )), + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn unique_test_db_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!( + "sce-codex-stop-{label}-{}-{nonce}", + std::process::id() + )) + .join("agent-trace.db") + } + + fn remove_test_db(db_path: &Path) { + if let Some(parent) = db_path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + + fn event(session_id: &str, turn_id: &str, last_assistant_message: &str) -> CodexHookEvent { + CodexHookEvent { + hook_event_name: "Stop".to_string(), + session_id: Some(session_id.to_string()), + turn_id: Some(turn_id.to_string()), + cwd: None, + model: None, + tool_name: None, + tool_use_id: None, + tool_input: None, + tool_response: None, + prompt: None, + last_assistant_message: Some(last_assistant_message.to_string()), + } + } + + fn message_rows(db: &RepositoryAgentTraceDb) -> Vec<(String, String, String)> { + db.query_map( + "SELECT session_id, message_id, role FROM messages ORDER BY id ASC", + (), + |row| { + Ok(( + row.get::(0).map_err(anyhow::Error::from)?, + row.get::(1).map_err(anyhow::Error::from)?, + row.get::(2).map_err(anyhow::Error::from)?, + )) + }, + ) + .expect("messages query should succeed") + } + + fn part_rows(db: &RepositoryAgentTraceDb) -> Vec<(String, String, String, String)> { + db.query_map( + "SELECT session_id, message_id, type, text FROM parts ORDER BY id ASC", + (), + |row| { + Ok(( + row.get::(0).map_err(anyhow::Error::from)?, + row.get::(1).map_err(anyhow::Error::from)?, + row.get::(2).map_err(anyhow::Error::from)?, + row.get::(3).map_err(anyhow::Error::from)?, + )) + }, + ) + .expect("parts query should succeed") + } + + #[test] + fn capture_with_produces_one_message_and_one_part_under_the_prefixed_session() { + let db_path = unique_test_db_path("basic"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let output = capture_with(&db, &event("session-1", "turn-1", "hello back"), || 1_000) + .expect("capture should succeed"); + assert!(output.contains("Stop")); + + assert_eq!( + message_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:assistant".to_string(), + "assistant".to_string() + )] + ); + assert_eq!( + part_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:assistant".to_string(), + "text".to_string(), + "hello back".to_string() + )] + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_keeps_an_already_prefixed_session_id_unchanged() { + let db_path = unique_test_db_path("prefixed"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + capture_with(&db, &event("cx_session-1", "turn-1", "hi"), || 1_000) + .expect("capture should succeed"); + + assert_eq!(message_rows(&db)[0].0, "cx_session-1"); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_does_not_duplicate_the_parent_message_on_reprocess() { + let db_path = unique_test_db_path("dedupe"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let payload = event("session-1", "turn-1", "hello back"); + + capture_with(&db, &payload, || 1_000).expect("first capture should succeed"); + capture_with(&db, &payload, || 2_000).expect("reprocessed capture should succeed"); + + assert_eq!( + message_rows(&db).len(), + 1, + "reprocessing the same turn's Stop must not duplicate the parent message row" + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_rejects_a_missing_last_assistant_message() { + let db_path = unique_test_db_path("missing-last-assistant-message"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello back"); + payload.last_assistant_message = None; + + let error = capture_with(&db, &payload, || 1_000) + .expect_err("missing last_assistant_message should error"); + assert!(error.to_string().contains("'last_assistant_message'")); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_rejects_a_missing_turn_id() { + let db_path = unique_test_db_path("missing-turn-id"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello back"); + payload.turn_id = None; + + let error = + capture_with(&db, &payload, || 1_000).expect_err("missing turn_id should error"); + assert!(error.to_string().contains("'turn_id'")); + + remove_test_db(&db_path); + } +} diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index d41281da..d4e78b9d 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -113,6 +113,7 @@ mod tests { tool_input: None, tool_response: None, prompt: Some(prompt.to_string()), + last_assistant_message: None, } } diff --git a/context/architecture.md b/context/architecture.md index b17ef820..7cee951f 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct dispatch arms. `UserPromptSubmit` now persists one user `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)); the remaining two arms are still stubs, with real behavior landing in later tasks — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct dispatch arms. `UserPromptSubmit` and `Stop` now each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)); `PreToolUse(Bash)` is still a stub, with real behavior landing in a later task — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index b0d98ae9..826548d0 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -52,7 +52,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable; `auth` is visible in those top-level help surfaces while `hooks` remains hidden Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, or `PreToolUse(Bash)` — currently all deterministic stubs, with real capture/policy behavior landing in later Codex-integration tasks — falling open as a no-op for every other combination (including `apply_patch`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Codex `apply_patch` tracing is not yet implemented. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, or `PreToolUse(Bash)` — `UserPromptSubmit` and `Stop` capture real `messages`/`parts` conversation evidence, `PreToolUse(Bash)` is still a deterministic stub, with real policy behavior landing in later Codex-integration tasks — falling open as a no-op for every other combination (including `apply_patch`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Codex `apply_patch` tracing is not yet implemented. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -92,7 +92,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout identity diagnostics, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, and Pi integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`, still stub arms, fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)`/`PreToolUse(apply_patch)`/`PostToolUse(apply_patch)` remain stub arms; fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. diff --git a/context/context-map.md b/context/context-map.md index eaa858b6..47e1fab4 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -75,7 +75,7 @@ Feature/domain context: - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) -- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s three dispatch arms plus fail-open `NoOp` fallthrough (including `apply_patch`, not yet implemented), idempotent `cx_` session prefixing and dormant `openai/` model-ID normalization, and the implemented `UserPromptSubmit` slice persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user` message ID; `Stop`/`PreToolUse(Bash)` remain stubs) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s three dispatch arms plus fail-open `NoOp` fallthrough (including `apply_patch`, not yet implemented), idempotent `cx_` session prefixing and dormant `openai/` model-ID normalization, and the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID; `PreToolUse(Bash)` remains a stub) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/glossary.md b/context/glossary.md index df683e7a..61eddd60 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — one of its three supported arms, `UserPromptSubmit`, now captures real conversation evidence into `messages`/`parts` (see `context/sce/codex-integration-runtime.md`); the remaining two arms are still stubs, with real behavior landing in later Codex-integration tasks. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — two of its three supported arms, `UserPromptSubmit` and `Stop`, now capture real conversation evidence into `messages`/`parts` (see `context/sce/codex-integration-runtime.md`); the remaining arm (`PreToolUse(Bash)`) is still a stub, with real behavior landing in later Codex-integration tasks. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` now captures real conversation evidence, the remaining two arms are still stubs — with every other event/tool combination and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` and `Stop` now capture real conversation evidence, `PreToolUse(Bash)` is still a stub — with every other event/tool combination (including `apply_patch`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. diff --git a/context/overview.md b/context/overview.md index 4572c410..b1dd94d5 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, or `PostToolUse(apply_patch)`. `UserPromptSubmit` now captures one user `messages`/`parts` row into the repository Agent Trace DB under the idempotent `cx_` session prefix (see `context/sce/codex-integration-runtime.md`); `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, and `PostToolUse(apply_patch)` remain deterministic stubs returning success text, with real capture/policy/diff-attribution behavior for those landing in later Codex-integration tasks — while every other combination, and any malformed STDIN payload, fails open as a no-op. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, or `PostToolUse(apply_patch)`. `UserPromptSubmit` and `Stop` now each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix (see `context/sce/codex-integration-runtime.md`); `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, and `PostToolUse(apply_patch)` remain deterministic stubs returning success text, with real policy/diff-attribution behavior for those landing in later Codex-integration tasks — while every other combination, and any malformed STDIN payload, fails open as a no-op. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the three supported arms above or a no-op fallthrough; `UserPromptSubmit` now captures real conversation evidence, the remaining two arms are still stubs, with real behavior for those landing in later Codex-integration tasks. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the three supported arms above or a no-op fallthrough; `UserPromptSubmit` and `Stop` now capture real conversation evidence, `PreToolUse(Bash)` is still a stub, with real behavior for that arm landing in later Codex-integration tasks. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index 4b2fee8f..e7bf5661 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -158,7 +158,7 @@ - For cross-service CLI dependencies exposed through the borrowed `AppContext` view, prefer shared capability/accessor traits over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors, and keep command execution generic over the narrow accessors each command needs where practical. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` arm now captures real conversation evidence, the remaining arms are still stubs. `session-model` is no longer a supported hook intake path. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms now capture real conversation evidence, the remaining arms are still stubs. `session-model` is no longer a supported hook intake path. - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. - For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `apply_patch`) to the same deterministic `NoOp` success text rather than an error. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 2927a655..12d566e5 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -180,12 +180,17 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's arms are "currently all deterministic stubs" / "still stub arms", both now stale since `UserPromptSubmit` is real capture behavior; this file is already named under this plan's "Context sync" list for exactly this kind of update, and the new `context/sce/codex-integration-runtime.md` this plan also names should now describe the `UserPromptSubmit` → `messages`/`parts` mapping. - Context synchronization: synced -- [ ] T08: `Capture Codex Stop into messages/parts` (status:todo) +- [x] T08: `Capture Codex Stop into messages/parts` (status:done) - Task ID: T08 - Scope: In — the `Stop` dispatch arm: `session_id = cx_`, `message_id = cx::assistant`, one `role="assistant"` message row, one `part_type="text"` part row (`text = last_assistant_message`). Out — session-level model caching (explicitly not needed here). - Dependencies: T06 - Done when: an integration test feeding a synthetic `Stop` payload through `sce hooks codex` produces exactly one `messages` row and one `parts` row under session `cx_` with the expected deterministic `message_id`; reprocessing the identical payload does not create a duplicate `messages` row. - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::stop'`. + - Completed: 2026-08-22 + - Files changed: `cli/src/services/hooks/codex/mod.rs`, `cli/src/services/hooks/codex/stop.rs` (new), `cli/src/services/hooks/codex/user_prompt_submit.rs` + - Result: Added a `last_assistant_message: Option` field to `CodexHookEvent` (mirroring T07's precedent of adding the field an arm's scope text names, and matching Claude's own `Stop` field name — `transform_claude_stop_with` in `cli/src/services/hooks/mod.rs`). Added a new `cli/src/services/hooks/codex/stop` submodule implementing `handle(repository_root, &event)`, which opens the repository's Agent Trace DB via `open_agent_trace_db_for_hook_runtime` and delegates to an injectable `capture_with(db, event, generate_timestamp_ms)`, mirroring `user_prompt_submit.rs`'s structure exactly. `capture_with` validates `session_id`/`turn_id`/`last_assistant_message` are non-empty, computes `session_id = prefixed_conversation_trace_session_id("codex", session_id)` and `message_id = format!("cx:{turn_id}:assistant")`, and persists one `InsertMessageInsert` (`role = Assistant`) and one `InsertPartInsert` (`part_type = Text`, `text = last_assistant_message`) through the existing `RepositoryAgentTraceDb::insert_messages`/`insert_parts` — no new adapter, no new DB writer path. The `Stop` dispatch arm in `codex/mod.rs` now calls `stop::handle` instead of returning its former stub string. The shared stub-dispatch test in `codex/mod.rs` had its `Stop` case removed (routing coverage remains via `classify_codex_event_routes_stop`; persistence behavior is covered by `stop`'s own tests), matching T07's precedent for `UserPromptSubmit`. `user_prompt_submit.rs`'s test fixture was updated to set the new `last_assistant_message` field to `None` (compiler-forced, since `CodexHookEvent` gained a field). Deduplication is proven only for the parent message row (per this task's own Done-when text), matching T07's stated guarantee for the `parts` table. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 24 passed, 0 failed, including 5 new tests under `hooks::codex::stop::tests` (one message + one part produced; `cx_`-prefixing idempotent for an already-prefixed session ID; reprocessing the identical event does not duplicate the `messages` row; missing `last_assistant_message` rejected; missing `turn_id` rejected). Also ran `nix flake check` (per T01/T04–T07 precedent over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated` (the new `stop.rs` file was staged with `git add` first, since the flake's source filter only picks up tracked files). + - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's arms are "currently all deterministic stubs" / "still stub arms", both now stale for the `Stop` arm since it is real capture behavior; `context/sce/codex-integration-runtime.md`'s "Implemented slice" section (currently names only `UserPromptSubmit`) and "Still-stub arms" section (currently lists `Stop` as a stub) both need updating to reflect `Stop`'s real `messages`/`parts` capture behavior, mirroring `UserPromptSubmit`'s shape with `role = "assistant"`. - Context synchronization: pending - [ ] T09: `Route Codex Bash PreToolUse through the existing SCE Bash policy engine` (status:todo) diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 20b05c0c..69e4e5a3 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -12,8 +12,8 @@ for how the other three tools intake conversation/diff evidence. - STDIN carries one raw Codex hook-event JSON payload, deserialized into a typed `CodexHookEvent` (`hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, - `prompt`; only `hook_event_name` is required, matching the working contract - in `context/plans/codex-cli-integration.md`). + `prompt`, `last_assistant_message`; only `hook_event_name` is required, + matching the working contract in `context/plans/codex-cli-integration.md`). - `classify_codex_event` matches `(hook_event_name, tool_name)` into one of three dispatch arms — `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)` — with every other combination (`apply_patch` under `PreToolUse`/`PostToolUse`, @@ -35,25 +35,29 @@ for how the other three tools intake conversation/diff evidence. any dispatch arm; its first consumer lands with a later Codex-integration task. -## Implemented slice: `UserPromptSubmit` capture +## Implemented slices: `UserPromptSubmit` and `Stop` capture -`cli/src/services/hooks/codex/user_prompt_submit.rs` implements the -`UserPromptSubmit` arm — the first dispatch arm with real behavior; every -other arm below is still a stub. +`cli/src/services/hooks/codex/user_prompt_submit.rs` and +`cli/src/services/hooks/codex/stop.rs` implement the `UserPromptSubmit` and +`Stop` arms — the first two dispatch arms with real behavior; every other arm +below is still a stub. Both follow the same shape: -- Requires non-empty `session_id`, `turn_id`, and `prompt`; a missing or - blank field is a validation error (logged and failed open by the outer - dispatcher). -- `session_id` is stored as `cx_` (idempotent). `message_id` is - deterministic — `cx::user` — rather than a generated UUID, so that +- `UserPromptSubmit` requires non-empty `session_id`, `turn_id`, and + `prompt`; `Stop` requires non-empty `session_id`, `turn_id`, and + `last_assistant_message`. A missing or blank required field is a + validation error (logged and failed open by the outer dispatcher). +- `session_id` is stored as `cx_` (idempotent) for both arms. + `message_id` is deterministic rather than a generated UUID — `cx::user` + for `UserPromptSubmit`, `cx::assistant` for `Stop` — so that reprocessing the same turn's event is a no-op for the parent message row via the existing `messages` table's `ON CONFLICT (session_id, message_id) DO NOTHING` semantics. -- Persists exactly one `role = "user"` row via `RepositoryAgentTraceDb::insert_messages` - and one `part_type = "text"` row (`text = prompt`) via - `RepositoryAgentTraceDb::insert_parts` — the same insert helpers and - `messages`/`parts` tables `conversation-trace` already writes; there is no - Codex-specific DB adapter. +- `UserPromptSubmit` persists one `role = "user"` row with a `part_type = "text"` + part (`text = prompt`); `Stop` persists one `role = "assistant"` row with a + `part_type = "text"` part (`text = last_assistant_message`). Both go through + `RepositoryAgentTraceDb::insert_messages`/`insert_parts` — the same insert + helpers and `messages`/`parts` tables `conversation-trace` already writes; + there is no Codex-specific DB adapter. - The `parts` table has no uniqueness constraint (append-only, like every other producer's part rows), so only the parent message row's non-duplication is guaranteed on reprocess, not the part row's. @@ -63,12 +67,11 @@ other arm below is still a stub. ## Still-stub arms -`Stop` and `PreToolUse(Bash)` currently return a deterministic stub success -string naming the future task that implements them (`Stop` mirrors -`UserPromptSubmit`'s capture shape with `role = "assistant"`; `PreToolUse(Bash)` -delegates to the existing Bash policy engine). This document will grow a -slice per arm as each lands. Codex `apply_patch` tracing has no dispatch arm -yet and is not documented here in detail; it is deferred to a later task. +`PreToolUse(Bash)` currently returns a deterministic stub success string +naming the future task that implements it (it delegates to the existing Bash +policy engine). This document will grow a slice per arm as each lands. Codex +`apply_patch` tracing has no dispatch arm yet and is not documented here in +detail; it is deferred to a later task. ## Verification From ccedd94d9f2d825188574884eaf0e4bd80f6a918 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 13:48:16 +0200 Subject: [PATCH 08/20] hooks: Route Codex Bash hooks through the shared policy engine Apply the existing Bash policy evaluator to Codex PreToolUse(Bash) events so policy behavior remains consistent without duplicating matching logic. Return silent output for allowed commands and Codex-native deny JSON containing the policy ID and blocking message for denied commands, without creating trace records. Update the Codex runtime and repository context to document the newly implemented dispatch arm and completed task. Plan: codex-cli-integration (T09) Co-authored-by: SCE --- cli/src/services/hooks/codex/bash_policy.rs | 245 ++++++++++++++++++ cli/src/services/hooks/codex/mod.rs | 25 +- context/architecture.md | 2 +- context/cli/cli-command-surface.md | 4 +- context/context-map.md | 2 +- context/glossary.md | 4 +- context/overview.md | 2 +- context/patterns.md | 2 +- context/plans/codex-cli-integration.md | 11 +- context/sce/agent-trace-db.md | 2 +- .../sce/agent-trace-hooks-command-routing.md | 2 +- .../bash-tool-policy-enforcement-contract.md | 2 +- context/sce/codex-integration-runtime.md | 42 ++- 13 files changed, 300 insertions(+), 45 deletions(-) create mode 100644 cli/src/services/hooks/codex/bash_policy.rs diff --git a/cli/src/services/hooks/codex/bash_policy.rs b/cli/src/services/hooks/codex/bash_policy.rs new file mode 100644 index 00000000..53e259cc --- /dev/null +++ b/cli/src/services/hooks/codex/bash_policy.rs @@ -0,0 +1,245 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde_json::json; + +use crate::services::bash_policy::{ + evaluate_bash_command_policy, format_policy_block_message, PolicyEvaluation, +}; +use crate::services::config; +use crate::services::config::policy::BashPolicyConfig; + +use super::CodexHookEvent; + +/// Routes a Codex `PreToolUse(Bash)` event through the existing SCE Bash +/// policy engine (`evaluate_bash_command_policy` in +/// `cli/src/services/bash_policy.rs`) unchanged — no reimplemented matching. +/// +/// An allowed command produces silent hook success (empty stdout, no +/// model-visible output). A blocked command produces Codex's own native +/// `PreToolUse` deny response: `{"hookSpecificOutput": {"hookEventName": +/// "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": +/// ...}}`, confirmed against Codex's real hook contract (`openai/codex` +/// issue #28437) — identical in shape to `render_claude_hook_result` in +/// `bash_policy.rs`. Neither branch reads or writes `diff_traces`, a +/// snapshot, or any pending-state file; `apply_patch` handling is a +/// different dispatch arm (T10/T11). +pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { + let command = bash_command_from_event(event)?; + + let policy_config = config::resolve_bash_policy_runtime_config(repository_root) + .context("Failed to resolve bash policy configuration for Codex PreToolUse Bash.")?; + + render_bash_policy_response(command, policy_config.as_ref()) +} + +fn render_bash_policy_response( + command: &str, + policy_config: Option<&BashPolicyConfig>, +) -> Result { + match evaluate_bash_command_policy(command, policy_config) { + PolicyEvaluation::Allowed { .. } => Ok(String::new()), + PolicyEvaluation::Blocked { policy, .. } => serde_json::to_string(&json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": format_policy_block_message(&policy) + } + })) + .context("Failed to serialize Codex PreToolUse Bash deny response."), + } +} + +/// Codex's `PreToolUse` `tool_input` for the `Bash` tool carries the shell +/// command string under `command`, mirroring Claude's own `Bash` `tool_input` +/// shape (`ClaudeBashToolInput` in `bash_policy.rs`). This is a working +/// assumption pending direct confirmation against a live Codex CLI payload +/// (see plan `context/plans/codex-cli-integration.md` Assumptions and T06's +/// precedent for adjusting only field extraction, not architecture, if +/// reality differs). +fn bash_command_from_event(event: &CodexHookEvent) -> Result<&str> { + event + .tool_input + .as_ref() + .and_then(|value| value.get("command")) + .and_then(|value| value.as_str()) + .filter(|command| !command.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "Invalid Codex PreToolUse Bash payload: tool_input.command must be a non-empty string." + ) + }) +} + +#[cfg(test)] +mod tests { + use std::{ + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + use serde_json::json; + + use super::*; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::config::policy::CustomBashPolicyEntry; + + fn event_with_tool_input(tool_input: Option) -> CodexHookEvent { + CodexHookEvent { + hook_event_name: "PreToolUse".to_string(), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + cwd: None, + model: None, + tool_name: Some("Bash".to_string()), + tool_use_id: Some("tool-1".to_string()), + tool_input, + tool_response: None, + prompt: None, + last_assistant_message: None, + } + } + + fn blocking_policy_config() -> BashPolicyConfig { + BashPolicyConfig { + presets: Vec::new(), + custom: vec![CustomBashPolicyEntry { + id: "block-rm".to_string(), + argv_prefix: vec!["rm".to_string()], + satisfied_by: Vec::new(), + message: "This repository does not allow `rm` via the bash tool.".to_string(), + }], + } + } + + #[test] + fn bash_command_from_event_reads_tool_input_command() { + let event = event_with_tool_input(Some(json!({"command": "echo hi"}))); + assert_eq!(bash_command_from_event(&event).unwrap(), "echo hi"); + } + + #[test] + fn bash_command_from_event_rejects_missing_tool_input() { + let event = event_with_tool_input(None); + let error = bash_command_from_event(&event).expect_err("missing tool_input should error"); + assert!(error.to_string().contains("tool_input.command")); + } + + #[test] + fn bash_command_from_event_rejects_blank_command() { + let event = event_with_tool_input(Some(json!({"command": " "}))); + assert!(bash_command_from_event(&event).is_err()); + } + + #[test] + fn render_bash_policy_response_is_silent_for_an_allowed_command() { + let output = render_bash_policy_response("echo generated > generated.txt", None) + .expect("evaluation should succeed"); + assert_eq!(output, ""); + } + + #[test] + fn render_bash_policy_response_denies_with_codex_native_shape_for_a_blocked_command() { + let config = blocking_policy_config(); + let output = render_bash_policy_response("rm -rf /tmp/x", Some(&config)) + .expect("evaluation should succeed"); + + let parsed: serde_json::Value = + serde_json::from_str(&output).expect("deny output should be valid JSON"); + assert_eq!( + parsed["hookSpecificOutput"]["hookEventName"], + json!("PreToolUse") + ); + assert_eq!( + parsed["hookSpecificOutput"]["permissionDecision"], + json!("deny") + ); + let reason = parsed["hookSpecificOutput"]["permissionDecisionReason"] + .as_str() + .expect("reason should be a string"); + assert!(reason.contains("block-rm")); + assert!(reason.contains("does not allow `rm`")); + } + + fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-codex-bash-policy-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn git(repo_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .unwrap_or_else(|error| panic!("git {args:?} failed to spawn: {error}")); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn codex_bash_pre_tool_use_path_creates_no_diff_trace_for_a_filesystem_mutation_command() { + let repo_root = unique_temp_dir("repo"); + git(&repo_root, &["init", "-q"]); + git( + &repo_root, + &[ + "remote", + "add", + "origin", + "https://example.invalid/codex-bash-policy-test.git", + ], + ); + let state_root = unique_temp_dir("state"); + + let payload = json!({ + "hook_event_name": "PreToolUse", + "session_id": "session-1", + "turn_id": "turn-1", + "tool_name": "Bash", + "tool_use_id": "tool-1", + "tool_input": {"command": "echo generated > generated.txt"} + }) + .to_string(); + + let output = super::super::run_codex_subcommand_from_payload(&repo_root, &payload) + .expect("Codex Bash PreToolUse dispatch should succeed"); + assert_eq!(output, "", "an allowed command must be silent"); + + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("Agent Trace storage should resolve for the scratch repo"); + + let recent = storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!( + recent.loaded_count(), + 0, + "the Codex Bash hook path must create no diff_traces rows" + ); + + std::fs::remove_dir_all(&repo_root).ok(); + std::fs::remove_dir_all(&state_root).ok(); + } +} diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index 448dcaad..f7787a23 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -8,6 +8,7 @@ use crate::services::observability::traits::Logger; use super::read_hook_stdin; +mod bash_policy; mod stop; mod user_prompt_submit; @@ -99,10 +100,7 @@ fn run_codex_subcommand_from_payload( user_prompt_submit::handle(repository_root, &event)? } CodexDispatchArm::Stop => stop::handle(repository_root, &event)?, - CodexDispatchArm::PreToolUseBash => { - "codex hooks: PreToolUse Bash dispatch (stub; policy routing lands in T09)." - .to_string() - } + CodexDispatchArm::PreToolUseBash => bash_policy::handle(repository_root, &event)?, CodexDispatchArm::NoOp => format!( "codex hooks: no-op for unsupported event/tool combination (hook_event_name='{}', tool_name={:?}).", event.hook_event_name, event.tool_name @@ -212,25 +210,6 @@ mod tests { ); } - #[test] - fn run_codex_subcommand_from_payload_dispatches_each_still_stubbed_combination() { - let cases = [ - ( - r#"{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Bash"}"#, - "PreToolUse Bash", - ), - ]; - - for (payload, expected_substring) in cases { - let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload) - .expect("stub dispatch should succeed"); - assert!( - output.contains(expected_substring), - "expected output '{output}' to mention '{expected_substring}'" - ); - } - } - #[test] fn run_codex_subcommand_from_payload_no_ops_unsupported_combination_without_error() { let payload = r#"{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Read"}"#; diff --git a/context/architecture.md b/context/architecture.md index 7cee951f..348d7be7 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct dispatch arms. `UserPromptSubmit` and `Stop` now each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)); `PreToolUse(Bash)` is still a stub, with real behavior landing in a later task — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct dispatch arms, all three now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers, and `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 826548d0..72a4590d 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -52,7 +52,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable; `auth` is visible in those top-level help surfaces while `hooks` remains hidden Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, or `PreToolUse(Bash)` — `UserPromptSubmit` and `Stop` capture real `messages`/`parts` conversation evidence, `PreToolUse(Bash)` is still a deterministic stub, with real policy behavior landing in later Codex-integration tasks — falling open as a no-op for every other combination (including `apply_patch`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Codex `apply_patch` tracing is not yet implemented. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, or `PreToolUse(Bash)` — `UserPromptSubmit` and `Stop` capture real `messages`/`parts` conversation evidence, and `PreToolUse(Bash)` delegates to the existing `evaluate_bash_command_policy` (`cli/src/services/bash_policy.rs`) unchanged and returns Codex's native `PreToolUse` deny response (`hookSpecificOutput`/`permissionDecision`/`permissionDecisionReason`, identical in shape to Claude's own) or silent allow — falling open as a no-op for every other combination (including `apply_patch`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Codex `apply_patch` tracing is not yet implemented. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -92,7 +92,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout identity diagnostics, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, and Pi integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)`/`PreToolUse(apply_patch)`/`PostToolUse(apply_patch)` remain stub arms; fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PreToolUse(apply_patch)`/`PostToolUse(apply_patch)` remain stub arms; fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. diff --git a/context/context-map.md b/context/context-map.md index 47e1fab4..4a36d60d 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -75,7 +75,7 @@ Feature/domain context: - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) -- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s three dispatch arms plus fail-open `NoOp` fallthrough (including `apply_patch`, not yet implemented), idempotent `cx_` session prefixing and dormant `openai/` model-ID normalization, and the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID; `PreToolUse(Bash)` remains a stub) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s three dispatch arms plus fail-open `NoOp` fallthrough (including `apply_patch`, not yet implemented), idempotent `cx_` session prefixing and dormant `openai/` model-ID normalization, the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID, and the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/glossary.md b/context/glossary.md index 61eddd60..1b68c9ab 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — two of its three supported arms, `UserPromptSubmit` and `Stop`, now capture real conversation evidence into `messages`/`parts` (see `context/sce/codex-integration-runtime.md`); the remaining arm (`PreToolUse(Bash)`) is still a stub, with real behavior landing in later Codex-integration tasks. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all three of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, and `PreToolUse(Bash)` delegates to the existing Bash policy engine (see `context/sce/codex-integration-runtime.md`). Codex `apply_patch` tracing is not yet implemented. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` and `Stop` now capture real conversation evidence, `PreToolUse(Bash)` is still a stub — with every other event/tool combination (including `apply_patch`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine — with every other event/tool combination (including `apply_patch`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. diff --git a/context/overview.md b/context/overview.md index b1dd94d5..6da1b16a 100644 --- a/context/overview.md +++ b/context/overview.md @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the three supported arms above or a no-op fallthrough; `UserPromptSubmit` and `Stop` now capture real conversation evidence, `PreToolUse(Bash)` is still a stub, with real behavior for that arm landing in later Codex-integration tasks. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the three supported arms above or a no-op fallthrough, all three now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, and `PreToolUse(Bash)` delegates to the existing Bash policy engine. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index e7bf5661..77c08a9b 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -158,7 +158,7 @@ - For cross-service CLI dependencies exposed through the borrowed `AppContext` view, prefer shared capability/accessor traits over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors, and keep command execution generic over the narrow accessors each command needs where practical. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms now capture real conversation evidence, the remaining arms are still stubs. `session-model` is no longer a supported hook intake path. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms now capture real conversation evidence, `PreToolUse(Bash)` now delegates to the existing Bash policy engine, the remaining two `apply_patch` arms are still stubs. `session-model` is no longer a supported hook intake path. - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. - For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `apply_patch`) to the same deterministic `NoOp` success text rather than an error. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 12d566e5..fa191293 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -191,15 +191,20 @@ Persist this field in every plan; this is durable plan state, not chat state: - Result: Added a `last_assistant_message: Option` field to `CodexHookEvent` (mirroring T07's precedent of adding the field an arm's scope text names, and matching Claude's own `Stop` field name — `transform_claude_stop_with` in `cli/src/services/hooks/mod.rs`). Added a new `cli/src/services/hooks/codex/stop` submodule implementing `handle(repository_root, &event)`, which opens the repository's Agent Trace DB via `open_agent_trace_db_for_hook_runtime` and delegates to an injectable `capture_with(db, event, generate_timestamp_ms)`, mirroring `user_prompt_submit.rs`'s structure exactly. `capture_with` validates `session_id`/`turn_id`/`last_assistant_message` are non-empty, computes `session_id = prefixed_conversation_trace_session_id("codex", session_id)` and `message_id = format!("cx:{turn_id}:assistant")`, and persists one `InsertMessageInsert` (`role = Assistant`) and one `InsertPartInsert` (`part_type = Text`, `text = last_assistant_message`) through the existing `RepositoryAgentTraceDb::insert_messages`/`insert_parts` — no new adapter, no new DB writer path. The `Stop` dispatch arm in `codex/mod.rs` now calls `stop::handle` instead of returning its former stub string. The shared stub-dispatch test in `codex/mod.rs` had its `Stop` case removed (routing coverage remains via `classify_codex_event_routes_stop`; persistence behavior is covered by `stop`'s own tests), matching T07's precedent for `UserPromptSubmit`. `user_prompt_submit.rs`'s test fixture was updated to set the new `last_assistant_message` field to `None` (compiler-forced, since `CodexHookEvent` gained a field). Deduplication is proven only for the parent message row (per this task's own Done-when text), matching T07's stated guarantee for the `parts` table. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 24 passed, 0 failed, including 5 new tests under `hooks::codex::stop::tests` (one message + one part produced; `cx_`-prefixing idempotent for an already-prefixed session ID; reprocessing the identical event does not duplicate the `messages` row; missing `last_assistant_message` rejected; missing `turn_id` rejected). Also ran `nix flake check` (per T01/T04–T07 precedent over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated` (the new `stop.rs` file was staged with `git add` first, since the flake's source filter only picks up tracked files). - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's arms are "currently all deterministic stubs" / "still stub arms", both now stale for the `Stop` arm since it is real capture behavior; `context/sce/codex-integration-runtime.md`'s "Implemented slice" section (currently names only `UserPromptSubmit`) and "Still-stub arms" section (currently lists `Stop` as a stub) both need updating to reflect `Stop`'s real `messages`/`parts` capture behavior, mirroring `UserPromptSubmit`'s shape with `role = "assistant"`. - - Context synchronization: pending + - Context synchronization: synced -- [ ] T09: `Route Codex Bash PreToolUse through the existing SCE Bash policy engine` (status:todo) +- [x] T09: `Route Codex Bash PreToolUse through the existing SCE Bash policy engine` (status:done) - Task ID: T09 - Scope: In — the `PreToolUse(Bash)` dispatch arm delegating the command string to `cli/src/services/bash_policy.rs` unchanged; on allow, silent hook success with no model-visible output; on deny, the Codex-native `PreToolUse` deny response shape carrying the SCE policy ID/message (matching the pattern in `context/sce/bash-tool-policy-enforcement-contract.md`'s "Block behavior contract"); no `diff_traces`/snapshot/pending-state writes on either branch. Out — `apply_patch` handling (T10/T11). - Dependencies: T06 - Done when: an allowed Bash command produces silent success output; a command matching a configured blocking policy produces the deny response including the policy ID and message text; a regression test runs `echo generated > generated.txt` through the Codex Bash hook path end-to-end and asserts zero new `diff_traces` rows exist afterward. - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::bash_policy'`. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `cli/src/services/hooks/codex/bash_policy.rs` (new), `cli/src/services/hooks/codex/mod.rs` + - Result: Added a new `cli/src/services/hooks/codex/bash_policy` submodule implementing `handle(repository_root, &event)`, mirroring `user_prompt_submit.rs`/`stop.rs`'s structure. It extracts the shell command from `event.tool_input.command` (a documented working assumption — no authoritative Codex-specific field-name source was found beyond Claude's own identical `tool_input.command` convention, which Codex's confirmed-identical `PreToolUse` deny-response shape strongly corroborates; adjustable later without architecture change, per T06's precedent), resolves `policies.bash` via the existing `config::resolve_bash_policy_runtime_config(repository_root)`, and calls `evaluate_bash_command_policy` (`cli/src/services/bash_policy.rs`) unchanged — no reimplemented matching. Researched Codex's actual current `PreToolUse` deny-response shape per the plan's own T06-precedent instruction (web search plus `gh issue view 28437 --repo openai/codex`, an OpenAI-maintained repository issue showing a real Codex hook payload example): confirmed it is `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..."}}` — identical in shape to Claude's own deny response (`render_claude_hook_result` in `bash_policy.rs`), which the response builder now constructs directly via `serde_json::json!` (not by calling `render_claude_hook_result` itself, since that function is tied to the `sce policy bash` CLI's own `PolicyEvaluation` call site, not reused cross-module). Allow produces an empty string (silent, AC8); deny embeds `policy.id` and `format_policy_block_message(policy)` in `permissionDecisionReason` (AC9). Wired `bash_policy::handle` into the `PreToolUseBash` dispatch arm in `codex/mod.rs`, replacing its stub, and removed the now-inapplicable dispatcher-level stub-dispatch test entirely (with no remaining stub dispatch arm to cover, matching T07/T08's precedent for arms that stop being stubs). Neither branch touches `diff_traces`/`agent_traces`/any DB at all (the function signature carries no DB handle), which is what the AC10 regression test — running the full `run_codex_subcommand_from_payload` dispatch for `echo generated > generated.txt` against a scratch git repo, then independently resolving that same repository's Agent Trace storage via `resolve_agent_trace_storage_at_state_root` and querying `recent_diff_trace_patches` — verifies end-to-end. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 29 passed, 0 failed, including 8 new tests under `hooks::codex::bash_policy::tests` (`tool_input.command` extraction, missing/blank-command rejection, allow-path silence, deny-path Codex-native JSON shape with policy ID/message, and the end-to-end zero-`diff_traces` regression test), plus the existing `hooks::codex` tests. Also ran `nix flake check` (per T01/T04–T08 precedent over raw `cargo test`). + - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's non-conversation arm is "still a stub" / lists `PreToolUse(Bash)` as the remaining stub, both now stale; `context/sce/codex-integration-runtime.md`'s "Still-stub arms" section (currently lists `PreToolUse(Bash)` as a stub) needs a new slice describing the Bash policy-delegation behavior, mirroring `UserPromptSubmit`/`Stop`'s per-arm documentation shape, and to note all three registered arms now have real behavior; `context/sce/bash-tool-policy-enforcement-contract.md`'s "Shell Operator Parsing Extension" implementation note (which currently names only the OpenCode plugin and Claude settings/hook-helper as `sce policy bash`/`evaluate_bash_command_policy` callers) is now incomplete since Codex is a third caller with its own native deny-response shape, reached via direct in-process `evaluate_bash_command_policy` rather than the `sce policy bash` CLI adapter. + - Context synchronization: synced - [ ] T10: `Capture apply_patch before-state via temporary-index snapshot` (status:todo) - Task ID: T10 diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index e35dcc55..bbefe995 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -202,7 +202,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow `sce hooks session-model` is no longer a supported command route, generated Claude settings no longer produce `SessionStart` model-attribution events, and the Agent Trace DB adapter no longer exposes a `session_models` API or fresh-schema table. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). -`sce hooks codex`'s `UserPromptSubmit` arm is a second, independent writer into `messages` and `parts`, reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup — not a new adapter. It stores `cx_`-prefixed session IDs and a deterministic `cx::user` message ID rather than a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md). +`sce hooks codex`'s `UserPromptSubmit` and `Stop` arms are each a second, independent writer into `messages` and `parts`, reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup — not a new adapter. They store `cx_`-prefixed session IDs and a deterministic `cx::user`/`cx::assistant` message ID rather than a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md). ## Recent patch reads diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 50cf39ff..9e4d72d1 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -113,7 +113,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` arm is a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user` message ID in place of a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/bash-tool-policy-enforcement-contract.md b/context/sce/bash-tool-policy-enforcement-contract.md index 4ada734b..3a96b06d 100644 --- a/context/sce/bash-tool-policy-enforcement-contract.md +++ b/context/sce/bash-tool-policy-enforcement-contract.md @@ -129,7 +129,7 @@ The original contract intentionally excluded shell control operators (`|`, `&&`, - If ANY segment matches a blocking policy, the entire command is blocked - This applies to both preset policies (e.g., `forbid-git-all`) and custom policies -**Implementation:** `cli/src/services/bash_policy.rs` owns the canonical Rust evaluator and the hidden `sce policy bash` command adapter for hook callers. The OpenCode plugin at `config/lib/bash-policy-plugin/opencode-bash-policy-plugin.ts` is a thin wrapper that delegates to `sce policy bash --input normalized --output json` via `spawnSync`, while generated Claude settings register a `PreToolUse` `Bash` command hook that calls `.claude/hooks/run-sce-or-show-install-guidance.sh` before `sce policy bash`; neither target contains independent policy logic. Both preserve original single-command behavior for commands without operators. +**Implementation:** `cli/src/services/bash_policy.rs` owns the canonical Rust evaluator and the hidden `sce policy bash` command adapter for hook callers. The OpenCode plugin at `config/lib/bash-policy-plugin/opencode-bash-policy-plugin.ts` is a thin wrapper that delegates to `sce policy bash --input normalized --output json` via `spawnSync`, while generated Claude settings register a `PreToolUse` `Bash` command hook that calls `.claude/hooks/run-sce-or-show-install-guidance.sh` before `sce policy bash`; neither target contains independent policy logic. Both preserve original single-command behavior for commands without operators. Codex's `PreToolUse(Bash)` Codex-hook dispatch arm (`cli/src/services/hooks/codex/bash_policy.rs`) is a third caller, but reaches `evaluate_bash_command_policy` through a direct in-process Rust call rather than the `sce policy bash` CLI adapter (it already runs inside the `sce` process as part of `sce hooks codex`), and returns Codex's own native `PreToolUse` deny response shape instead of Claude's. See [codex-integration-runtime.md](codex-integration-runtime.md). **Examples:** - `cat abc | git diff` with `forbid-git-all` -> blocked (segment "git diff" matches) diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 69e4e5a3..085f8f18 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -39,8 +39,9 @@ for how the other three tools intake conversation/diff evidence. `cli/src/services/hooks/codex/user_prompt_submit.rs` and `cli/src/services/hooks/codex/stop.rs` implement the `UserPromptSubmit` and -`Stop` arms — the first two dispatch arms with real behavior; every other arm -below is still a stub. Both follow the same shape: +`Stop` arms — conversation-capture dispatch arms with real behavior (see +"`PreToolUse(Bash)` policy delegation" below for the third). Both follow the +same shape: - `UserPromptSubmit` requires non-empty `session_id`, `turn_id`, and `prompt`; `Stop` requires non-empty `session_id`, `turn_id`, and @@ -65,13 +66,38 @@ below is still a stub. Both follow the same shape: `open_agent_trace_db_for_hook_runtime` repository-storage resolution the other hook intakes use. -## Still-stub arms +## `PreToolUse(Bash)` policy delegation -`PreToolUse(Bash)` currently returns a deterministic stub success string -naming the future task that implements it (it delegates to the existing Bash -policy engine). This document will grow a slice per arm as each lands. Codex -`apply_patch` tracing has no dispatch arm yet and is not documented here in -detail; it is deferred to a later task. +`cli/src/services/hooks/codex/bash_policy.rs` implements the +`PreToolUse(Bash)` arm. It reads the shell command from +`tool_input.command` (a working assumption mirroring Claude's own `Bash` +`tool_input` shape, since no authoritative Codex-specific field-name source +was found; adjustable later without an architecture change) and calls +`evaluate_bash_command_policy` (`cli/src/services/bash_policy.rs`) directly +— the same matching engine `sce policy bash` uses for OpenCode/Claude, with +no reimplemented matching and no Codex-specific DB adapter: + +- Allowed: returns an empty string (silent hook success, no model-visible + output). +- Blocked: returns Codex's own native `PreToolUse` deny response — + `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": + "deny", "permissionDecisionReason": ""}}` — confirmed + against Codex's real hook contract (`openai/codex` issue #28437) to be + identical in shape to Claude's own deny response + (`render_claude_hook_result` in `bash_policy.rs`), though built directly + rather than by calling that Claude-specific function. + +Neither branch reads or writes `diff_traces`, a snapshot, or any +pending-state file; Bash-triggered filesystem mutations remain untracked for +Codex (see "Explicit non-goals" in +[agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). + +## No remaining stub arms + +All three currently-registered dispatch arms (`UserPromptSubmit`, `Stop`, +`PreToolUse(Bash)`) now have real behavior. Codex `apply_patch` tracing has +no dispatch arm yet — it is not documented here in detail and is deferred to +a later task. ## Verification From 06a06c522156b4704ff047cb38118f888e22afda Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 18:09:42 +0200 Subject: [PATCH 09/20] doctor: Add Codex integration health coverage Extend doctor target detection and inventory inspection to cover Codex skills and hook assets, including trust and review guidance for unhealthy hooks. Add typed Codex problem mappings, deterministic rendering, and regression coverage while updating the operator-health contracts. Plan: codex-cli-integration (T13) Co-authored-by: SCE --- cli/src/services/default_paths.rs | 5 + cli/src/services/doctor/inspect.rs | 323 +++++++++++++++++++++- cli/src/services/doctor/mod.rs | 14 + cli/src/services/doctor/render.rs | 26 ++ cli/src/services/doctor/types.rs | 15 + cli/src/services/lifecycle.rs | 3 + context/architecture.md | 2 +- context/cli/cli-command-surface.md | 2 +- context/context-map.md | 2 +- context/glossary.md | 2 +- context/overview.md | 2 +- context/plans/codex-cli-integration.md | 97 +++++-- context/sce/agent-trace-hook-doctor.md | 8 +- context/sce/doctor-human-text-contract.md | 16 +- 14 files changed, 470 insertions(+), 47 deletions(-) diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index 69b53b33..7155b947 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -349,6 +349,7 @@ pub(crate) mod repo_dir { pub const OPENCODE: &str = ".opencode"; pub const CLAUDE: &str = ".claude"; pub const PI: &str = ".pi"; + pub const CODEX: &str = ".codex"; pub const GIT: &str = ".git"; } @@ -457,6 +458,10 @@ impl RepoPaths { self.root.join(repo_dir::PI) } + pub(crate) fn codex_dir(&self) -> PathBuf { + self.root.join(repo_dir::CODEX) + } + pub(crate) fn git_dir(&self) -> PathBuf { self.root.join(repo_dir::GIT) } diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index ca2d6df2..9fc4b7d8 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -8,8 +8,8 @@ use crate::services::checkout; use crate::services::config::schema::parse_file_config; use crate::services::config::{self, ConfigPathSource, IntegrationTargetId}; use crate::services::default_paths::{ - agent_trace_db_path_for_repository, claude_asset, opencode_asset, pi_asset, InstallTargetPaths, - RepoPaths, + agent_trace_db_path_for_repository, claude_asset, codex_asset, opencode_asset, pi_asset, + repo_dir, InstallTargetPaths, RepoPaths, }; use crate::services::repository_identity::resolve::{ resolve_repository_identity, RepositoryIdentitySource, @@ -519,6 +519,9 @@ fn resolve_doctor_integration_targets(repository_root: &Path) -> Vec {} + IntegrationTargetId::Codex => { + let codex_groups = + collect_codex_integration_groups(resolved_root, &selected_optional_workflows); + inspect_codex_integration_health(&codex_groups, problems); + integration_groups.extend(codex_groups); + } } } @@ -911,6 +916,15 @@ fn inspect_pi_integration_health( push_pi_integration_read_fail_problems(integration_groups, problems); } +fn inspect_codex_integration_health( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + push_codex_integration_missing_problems(integration_groups, problems); + push_codex_integration_mismatch_problems(integration_groups, problems); + push_codex_integration_read_fail_problems(integration_groups, problems); +} + fn push_opencode_integration_missing_problems( integration_groups: &[IntegrationGroupHealth], problems: &mut Vec, @@ -1229,6 +1243,129 @@ fn push_pi_integration_read_fail_problems( } } +/// Codex requires the project's `.codex/hooks.json` to be reviewed and +/// trusted inside the Codex CLI before it will execute; doctor can only +/// diagnose the file on disk and reinstall it, never grant that trust. +const CODEX_HOOK_TRUST_GUIDANCE: &str = "Codex also requires reviewing and trusting this project's hooks inside the Codex CLI before they take effect; 'sce doctor' cannot grant that trust on your behalf."; + +fn push_codex_integration_missing_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let missing_children = group + .children + .iter() + .filter(|child| matches!(&child.content_state, IntegrationContentState::Missing)) + .collect::>(); + if missing_children.is_empty() { + continue; + } + + let missing_paths = missing_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); + let mut remediation = format!( + "Reinstall repo-root Codex assets to restore the missing {} file(s), then rerun 'sce doctor'.", + group.display_label().to_ascii_lowercase() + ); + if group.key.area == IntegrationArea::Hooks { + remediation.push(' '); + remediation.push_str(CODEX_HOOK_TRUST_GUIDANCE); + } + problems.push(DoctorProblem { + kind: ProblemKind::CodexIntegrationFilesMissing, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "{} required file(s) are missing: {}.", + group.display_label(), + missing_paths + ), + remediation, + next_action: "manual_steps", + scope: Some(group.key), + }); + } +} + +fn push_codex_integration_mismatch_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let mismatched_children = group + .children + .iter() + .filter(|child| matches!(&child.content_state, IntegrationContentState::Mismatch)) + .collect::>(); + if mismatched_children.is_empty() { + continue; + } + + let mismatched_paths = mismatched_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); + let mut remediation = format!( + "Reinstall repo-root Codex assets to restore the canonical {} content, then rerun 'sce doctor'.", + group.display_label().to_ascii_lowercase() + ); + if group.key.area == IntegrationArea::Hooks { + remediation.push(' '); + remediation.push_str(CODEX_HOOK_TRUST_GUIDANCE); + } + problems.push(DoctorProblem { + kind: ProblemKind::CodexIntegrationContentMismatch, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "{} file(s) differ from the canonical embedded content: {}.", + group.display_label(), + mismatched_paths + ), + remediation, + next_action: "manual_steps", + scope: Some(group.key), + }); + } +} + +fn push_codex_integration_read_fail_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + for child in &group.children { + let IntegrationContentState::ReadFailed(error) = &child.content_state else { + continue; + }; + problems.push(DoctorProblem { + kind: ProblemKind::CodexAssetReadFailed, + category: ProblemCategory::FilesystemPermissions, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Unable to read Codex asset '{}' at '{}': {error}", + child.relative_path, + child.path.display() + ), + remediation: format!( + "Verify that '{}' is readable before rerunning 'sce doctor'.", + child.path.display() + ), + next_action: "manual_steps", + scope: Some(group.key), + }); + } + } +} + fn inspect_opencode_plugin_registry_health( repository_root: &Path, problems: &mut Vec, @@ -1533,6 +1670,53 @@ fn collect_pi_integration_groups( ] } +/// Codex's embedded-asset relative paths keep their own `.agents/`/`.codex/` +/// output-root prefix (see `codex_asset`), so the integration root is the +/// repository root itself rather than a single per-target subdirectory. +fn collect_codex_integration_groups( + repository_root: &Path, + selected_optional_workflows: &[String], +) -> Vec { + let codex_root = InstallTargetPaths::new(repository_root).codex_target_dir(); + let embedded_assets = iter_embedded_assets_for_setup_target_with_selection( + SetupTarget::Codex, + selected_optional_workflows, + ) + .collect::>(); + let mut skill_children = Vec::new(); + let mut hook_children = Vec::new(); + + for asset in embedded_assets { + let child = build_integration_child_from_asset(&codex_root, asset, None); + + if child + .relative_path + .starts_with(&format!("{}/", codex_asset::SKILLS_DIR)) + { + skill_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", repo_dir::CODEX)) + { + hook_children.push(child); + } + } + + sort_integration_children(&mut skill_children); + sort_integration_children(&mut hook_children); + + vec![ + IntegrationGroupHealth::new( + IntegrationGroupKey::new(IntegrationTarget::Codex, IntegrationArea::Skills), + skill_children, + ), + IntegrationGroupHealth::new( + IntegrationGroupKey::new(IntegrationTarget::Codex, IntegrationArea::Hooks), + hook_children, + ), + ] +} + fn sort_integration_children(children: &mut [IntegrationChildHealth]) { children.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); } @@ -1689,11 +1873,13 @@ mod tests { use std::path::PathBuf; use super::{ - collect_claude_integration_groups, collect_hook_file_health, - collect_opencode_integration_groups, collect_pi_integration_groups, - inspect_claude_integration_health, HookContentState, IntegrationContentState, - IntegrationGroupHealth, + collect_claude_integration_groups, collect_codex_integration_groups, + collect_hook_file_health, collect_opencode_integration_groups, + collect_pi_integration_groups, inspect_claude_integration_health, + inspect_codex_integration_health, resolve_doctor_integration_targets, HookContentState, + IntegrationArea, IntegrationContentState, IntegrationGroupHealth, IntegrationTarget, }; + use crate::services::config::IntegrationTargetId; use crate::services::setup::OPTIONAL_WORKFLOWS; /// The collectors only read file state, so a non-existent root is enough to @@ -1859,6 +2045,119 @@ mod tests { .bytes } + fn embedded_codex_asset_bytes(relative_path: &str) -> &'static [u8] { + crate::services::setup::iter_embedded_assets_for_setup_target_with_selection( + crate::services::setup::SetupTarget::Codex, + &[] as &[String], + ) + .find(|asset| asset.relative_path == relative_path) + .unwrap_or_else(|| panic!("embedded Codex catalog carries {relative_path}")) + .bytes + } + + #[test] + fn codex_integration_groups_split_into_skills_and_hooks_areas() { + let root = absent_repository_root(); + let groups = collect_codex_integration_groups(&root, &[]); + + let skills_group = groups + .iter() + .find(|group| group.key.area == IntegrationArea::Skills) + .expect("Codex skills group present"); + assert_eq!(skills_group.key.target, IntegrationTarget::Codex); + assert!( + skills_group + .children + .iter() + .all(|child| child.relative_path.starts_with(".agents/skills/")), + "Codex skills group children should all live under .agents/skills/" + ); + assert!(!skills_group.children.is_empty()); + + let hooks_group = groups + .iter() + .find(|group| group.key.area == IntegrationArea::Hooks) + .expect("Codex hooks group present"); + assert!( + hooks_group + .children + .iter() + .any(|child| child.relative_path == ".codex/hooks.json"), + "Codex hooks group should include .codex/hooks.json" + ); + assert!( + hooks_group + .children + .iter() + .any(|child| child.relative_path + == ".codex/hooks/run-sce-or-show-install-guidance.sh"), + "Codex hooks group should include the hook helper script" + ); + + assert!( + groups + .iter() + .flat_map(|group| &group.children) + .all(|child| matches!(child.content_state, IntegrationContentState::Missing)), + "an absent repository root should report every Codex asset as missing" + ); + } + + #[test] + fn codex_hooks_json_reports_match_then_missing_problem_includes_trust_guidance() { + let root = unique_temp_repository_root("codex-hooks"); + let codex_hooks_dir = root.join(".codex"); + std::fs::create_dir_all(&codex_hooks_dir).unwrap(); + std::fs::write( + codex_hooks_dir.join("hooks.json"), + embedded_codex_asset_bytes(".codex/hooks.json"), + ) + .unwrap(); + + let groups = collect_codex_integration_groups(&root, &[]); + let hooks_child = groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == ".codex/hooks.json") + .expect(".codex/hooks.json child present"); + assert!(matches!( + hooks_child.content_state, + IntegrationContentState::Match + )); + + std::fs::remove_file(codex_hooks_dir.join("hooks.json")).unwrap(); + + let groups_after_delete = collect_codex_integration_groups(&root, &[]); + let mut problems = Vec::new(); + inspect_codex_integration_health(&groups_after_delete, &mut problems); + + let hooks_problem = problems + .iter() + .find(|problem| problem.summary.contains(".codex/hooks.json")) + .expect("a missing .codex/hooks.json problem was reported"); + assert!( + hooks_problem.remediation.contains("trust"), + "Codex hooks remediation should mention the project hook trust/review requirement: {}", + hooks_problem.remediation + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn resolve_doctor_integration_targets_detects_codex_directory() { + let root = unique_temp_repository_root("codex-detect"); + std::fs::create_dir_all(root.join(".codex")).unwrap(); + + let targets = resolve_doctor_integration_targets(&root); + assert!( + targets.contains(&IntegrationTargetId::Codex), + "a repo-root .codex/ directory should be detected without a configured target" + ); + + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn claude_settings_reports_match_despite_extra_user_permissions() { let root = unique_temp_repository_root("claude-pass"); diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index fd3ed083..dd2a4ba9 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -312,6 +312,12 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::PiIntegrationContentMismatch => { ProblemKind::PiIntegrationContentMismatch } + HealthProblemKind::CodexIntegrationFilesMissing => { + ProblemKind::CodexIntegrationFilesMissing + } + HealthProblemKind::CodexIntegrationContentMismatch => { + ProblemKind::CodexIntegrationContentMismatch + } HealthProblemKind::OpenCodePluginRegistryInvalid => { ProblemKind::OpenCodePluginRegistryInvalid } @@ -322,6 +328,7 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::OpenCodeAssetReadFailed => ProblemKind::OpenCodeAssetReadFailed, HealthProblemKind::ClaudeAssetReadFailed => ProblemKind::ClaudeAssetReadFailed, HealthProblemKind::PiAssetReadFailed => ProblemKind::PiAssetReadFailed, + HealthProblemKind::CodexAssetReadFailed => ProblemKind::CodexAssetReadFailed, HealthProblemKind::AgentTraceDbConnectionFailed => { ProblemKind::AgentTraceDbConnectionFailed } @@ -367,6 +374,12 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::PiIntegrationContentMismatch => { HealthProblemKind::PiIntegrationContentMismatch } + ProblemKind::CodexIntegrationFilesMissing => { + HealthProblemKind::CodexIntegrationFilesMissing + } + ProblemKind::CodexIntegrationContentMismatch => { + HealthProblemKind::CodexIntegrationContentMismatch + } ProblemKind::OpenCodePluginRegistryInvalid => { HealthProblemKind::OpenCodePluginRegistryInvalid } @@ -377,6 +390,7 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::OpenCodeAssetReadFailed => HealthProblemKind::OpenCodeAssetReadFailed, ProblemKind::ClaudeAssetReadFailed => HealthProblemKind::ClaudeAssetReadFailed, ProblemKind::PiAssetReadFailed => HealthProblemKind::PiAssetReadFailed, + ProblemKind::CodexAssetReadFailed => HealthProblemKind::CodexAssetReadFailed, ProblemKind::AgentTraceDbConnectionFailed => { HealthProblemKind::AgentTraceDbConnectionFailed } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index 2cb5d7c9..41a5788f 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -487,6 +487,16 @@ fn asset_path_components(area: IntegrationArea, relative_path: &str) -> Vec None, }) .collect::>(); + // Codex's relative paths keep their own `.agents/`/`.codex/` output-root + // prefix (unlike OpenCode/Claude/Pi, whose relative paths are already + // stripped of their single root), so drop that leading root segment + // before the shared per-area prefix stripping below. + if components + .first() + .is_some_and(|first| first == ".agents" || first == ".codex") + { + components.remove(0); + } let expected_prefix = match area { IntegrationArea::Plugins => Some("plugins"), IntegrationArea::Agents => Some("agents"), @@ -494,6 +504,7 @@ fn asset_path_components(area: IntegrationArea, relative_path: &str) -> Vec Some("skills"), IntegrationArea::Prompts => Some("prompts"), IntegrationArea::Extensions => Some("extensions"), + IntegrationArea::Hooks => Some("hooks"), }; if expected_prefix.is_some_and(|prefix| components.first().is_some_and(|first| first == prefix)) { @@ -593,6 +604,7 @@ fn integration_targets_for_text(report: &HookDoctorReport) -> Vec &'static str { IntegrationTarget::ClaudeCode => "Claude Code", IntegrationTarget::OpenCode => "OpenCode", IntegrationTarget::Pi => "Pi", + IntegrationTarget::Codex => "Codex", } } @@ -634,6 +647,7 @@ fn integration_area_label(area: IntegrationArea) -> &'static str { IntegrationArea::Skills => "Skills", IntegrationArea::Prompts => "Prompts", IntegrationArea::Extensions => "Extensions", + IntegrationArea::Hooks => "Hooks", } } @@ -646,6 +660,7 @@ fn integration_area_order(target: IntegrationTarget, area: IntegrationArea) -> u IntegrationArea::Skills => 3, IntegrationArea::Prompts => 4, IntegrationArea::Extensions => 5, + IntegrationArea::Hooks => 6, }, IntegrationTarget::ClaudeCode => match area { IntegrationArea::Plugins => 0, @@ -654,6 +669,7 @@ fn integration_area_order(target: IntegrationTarget, area: IntegrationArea) -> u IntegrationArea::Agents => 3, IntegrationArea::Prompts => 4, IntegrationArea::Extensions => 5, + IntegrationArea::Hooks => 6, }, IntegrationTarget::Pi => match area { IntegrationArea::Extensions => 0, @@ -662,6 +678,16 @@ fn integration_area_order(target: IntegrationTarget, area: IntegrationArea) -> u IntegrationArea::Plugins => 3, IntegrationArea::Agents => 4, IntegrationArea::Commands => 5, + IntegrationArea::Hooks => 6, + }, + IntegrationTarget::Codex => match area { + IntegrationArea::Skills => 0, + IntegrationArea::Hooks => 1, + IntegrationArea::Plugins => 2, + IntegrationArea::Agents => 3, + IntegrationArea::Commands => 4, + IntegrationArea::Prompts => 5, + IntegrationArea::Extensions => 6, }, } } diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index c17ae4c4..bb250d5b 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -98,6 +98,7 @@ pub(super) enum IntegrationTarget { OpenCode, ClaudeCode, Pi, + Codex, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -108,6 +109,7 @@ pub(super) enum IntegrationArea { Skills, Prompts, Extensions, + Hooks, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -133,16 +135,26 @@ impl IntegrationGroupKey { (IntegrationTarget::Pi, IntegrationArea::Prompts) => "Pi prompts", (IntegrationTarget::Pi, IntegrationArea::Skills) => "Pi skills", (IntegrationTarget::Pi, IntegrationArea::Extensions) => "Pi extensions", + (IntegrationTarget::Codex, IntegrationArea::Skills) => "Codex skills", + (IntegrationTarget::Codex, IntegrationArea::Hooks) => "Codex hooks", // These combinations are not produced by inspection, but retaining // deterministic labels keeps the key total for future targets/areas. (IntegrationTarget::Pi, IntegrationArea::Plugins) => "Pi plugins", (IntegrationTarget::Pi, IntegrationArea::Agents) => "Pi agents", (IntegrationTarget::Pi, IntegrationArea::Commands) => "Pi commands", + (IntegrationTarget::Pi, IntegrationArea::Hooks) => "Pi hooks", (IntegrationTarget::ClaudeCode, IntegrationArea::Prompts) => "ClaudeCode prompts", (IntegrationTarget::ClaudeCode, IntegrationArea::Extensions) => "ClaudeCode extensions", (IntegrationTarget::ClaudeCode, IntegrationArea::Agents) => "Unsupported Claude area", + (IntegrationTarget::ClaudeCode, IntegrationArea::Hooks) => "ClaudeCode hooks", (IntegrationTarget::OpenCode, IntegrationArea::Prompts) => "OpenCode prompts", (IntegrationTarget::OpenCode, IntegrationArea::Extensions) => "OpenCode extensions", + (IntegrationTarget::OpenCode, IntegrationArea::Hooks) => "OpenCode hooks", + (IntegrationTarget::Codex, IntegrationArea::Plugins) => "Codex plugins", + (IntegrationTarget::Codex, IntegrationArea::Agents) => "Codex agents", + (IntegrationTarget::Codex, IntegrationArea::Commands) => "Codex commands", + (IntegrationTarget::Codex, IntegrationArea::Prompts) => "Codex prompts", + (IntegrationTarget::Codex, IntegrationArea::Extensions) => "Codex extensions", } } } @@ -390,12 +402,15 @@ pub(crate) enum ProblemKind { ClaudeIntegrationContentMismatch, PiIntegrationFilesMissing, PiIntegrationContentMismatch, + CodexIntegrationFilesMissing, + CodexIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, OpenCodeAssetReadFailed, ClaudeAssetReadFailed, PiAssetReadFailed, + CodexAssetReadFailed, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } diff --git a/cli/src/services/lifecycle.rs b/cli/src/services/lifecycle.rs index bcb4ef0e..bc0b6b90 100644 --- a/cli/src/services/lifecycle.rs +++ b/cli/src/services/lifecycle.rs @@ -56,12 +56,15 @@ pub enum HealthProblemKind { ClaudeIntegrationContentMismatch, PiIntegrationFilesMissing, PiIntegrationContentMismatch, + CodexIntegrationFilesMissing, + CodexIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, OpenCodeAssetReadFailed, ClaudeAssetReadFailed, PiAssetReadFailed, + CodexAssetReadFailed, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } diff --git a/context/architecture.md b/context/architecture.md index 348d7be7..a4563b5d 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -128,7 +128,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the two assets that are merge targets — the Claude target's `settings.json`, detected by `is_claude_settings_merge_target`, and the OpenCode target's `opencode.json`, detected by `is_opencode_config_merge_target` — the content staged is not the embedded asset's bytes but the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. -- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. +- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution event-locally: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`; either source is normalized once with the `claude/` prefix and lookup failures remain nullable. `session-model` is no longer a supported hook route. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 72a4590d..99c41a37 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -89,7 +89,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/setup/mod.rs` defines setup parsing/selection contracts, additive `bootstrap_context_baseline`, and runtime install orchestration (`run_setup_for_mode`) over the embedded asset install engine; `cli/src/services/setup/command.rs` owns the setup runtime command handler. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes aggregate `ServiceLifecycle::setup` calls across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) in order, using a `ContextWithRepoRoot`-scoped context with resolved repository root. - `cli/src/services/setup/mod.rs` now keeps its larger internal responsibilities behind focused inline support modules: `install` owns repository canonicalization, staging/swap install flows, required-hook installation, and repo/writeability guards, while `prompt` owns interactive target selection and styled prompt labels. - `cli/src/services/config/mod.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, deterministic text/JSON rendering, repo-configured bash-policy preset/custom validation and reporting under `policies.bash`, and shared auth-key metadata that declares env key, config-file key, and optional baked-default eligibility for supported auth runtime values starting with `workos_client_id` (`WORKOS_CLIENT_ID` vs `workos_client_id`); auth-key provenance/preference metadata stays on `show`, while `validate` stays trimmed to validation status plus issues/warnings. `cli/src/services/config/lifecycle.rs` implements `ServiceLifecycle` for config health checks and setup (global/local config validation and repo-local config bootstrap). - - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout identity diagnostics, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, and Pi integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas. + - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout identity diagnostics, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, Pi, and Codex integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas; Codex grouping includes `.agents/skills/**` as `Skills` and `.codex/hooks.json`/`.codex/hooks/**` as `Hooks` (the latter also carrying a Codex hook trust/review reminder when unhealthy). - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. - `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PreToolUse(apply_patch)`/`PostToolUse(apply_patch)` remain stub arms; fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). diff --git a/context/context-map.md b/context/context-map.md index 4a36d60d..ed17094e 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -50,7 +50,7 @@ Feature/domain context: - `context/sce/agent-trace-commit-msg-coauthor-policy.md` (current commit-msg canonical co-author trailer policy with enabled-by-default attribution hooks, explicit opt-out controls, `SCE_DISABLED` kill switch, caller-provided `ai_contribution_present` transformer seam wired from staged-diff AI-overlap preflight, idempotent dedupe, the `agent_trace::patches_have_overlap` pure overlap seam, the `StagedDiffAiOverlapResult` three-valued evidence gate, and `sce.hooks.commit_msg.ai_overlap_error` error logging) - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy, `--fix` semantics, checkout-aware Agent Trace DB reporting, post-commit Agent Trace auto-sync readiness proof and opt-out behavior, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) -- `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, no-installed-integrations guidance, and JSON as the full-detail route) +- `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi/Codex target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, the Codex hook trust/review reminder, no-installed-integrations guidance, and JSON as the full-detail route) - `context/sce/setup-githooks-install-contract.md` (canonical `sce setup --hooks` install contract for target-path resolution, all-hook non-blocking missing-CLI bootstrap behavior, foreign-hook preservation and managed-block merge/idempotent outcomes, atomic-swap replacement behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (non-destructive per-asset install policy: config install writes/swaps each embedded asset individually by atomic rename over the destination, without ever unlinking it first, and never removes an integration target directory as a whole, then prunes catalog-derived stale/deselected asset paths and any parent directory left empty by that pruning; required-hook install uses the same per-file stage/atomic-swap choreography and, like the two JSON merge targets, computes its staged content ahead of the swap — a foreign hook's bytes are kept as an exact prefix with the SCE managed block appended; `.claude/settings.json` and `.opencode/opencode.json` are merge targets whose staged content is computed by JSON-merging the generated document into the user's existing one before the shared stage/swap step; no backup creation; a swap failure leaves prior destination content untouched, with deterministic recovery guidance naming the failing asset) - `context/sce/setup-githooks-hook-asset-packaging.md` (compile-time `sce setup --hooks` required-hook template packaging contract, including all-hook non-blocking missing-`sce` install guidance, available-CLI argument forwarding, post-commit-only origin remote lookup plus remote-URL forwarding/fallback behavior, setup-service accessor surface, and current validation posture) diff --git a/context/glossary.md b/context/glossary.md index 1b68c9ab..af3d782b 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -185,7 +185,7 @@ - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. - `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine — with every other event/tool combination (including `apply_patch`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. -- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, plus Pi integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, and `Pi skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, while Pi `prompts/**` and `skills/**` map to the Pi groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. +- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, Pi, plus Codex integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, `Pi skills`, `Pi extensions`, `Codex skills`, and `Codex hooks`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, Pi `prompts/**` and `skills/**` map to the Pi groups, and Codex's `.agents/skills/**` plus `.codex/hooks.json`/`.codex/hooks/**` map to the Codex groups (the latter also carrying a Codex hook trust/review reminder when unhealthy). Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. diff --git a/context/overview.md b/context/overview.md index 6da1b16a..b2522677 100644 --- a/context/overview.md +++ b/context/overview.md @@ -67,7 +67,7 @@ Context sync uses an important-change gate: cross-cutting/policy/architecture/te OpenCode and Claude no longer generate legacy bootstrap or context-sync skills; `/commit` and `/handover` are generated only as catalog-registered composite workflow packages. OpenCode retains only thin routing agents, while Claude emits no agents. The superseded grouped Markdown catalog and automated OpenCode profile have been removed from Pkl ownership and generated outputs. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses direct-first/event-transcript-second Claude `model_id` resolution plus direct `tool_version` without any `session_models` runtime, and continues with `None` when event-local lookup cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, normalizes either source once with the `claude/` prefix, and fails open to `None`. -The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime includes `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path, and target-scoped integration inventory: Claude reports only `Plugins`, `Commands`, and `Skills`, while OpenCode reports `Plugins`, `Agents`, `Commands`, and `Skills`. Its non-launching post-commit Agent Trace auto-sync fact reports enabled/current, explicit disabled, not-ready, and not-applicable states using canonical managed-block currency and resolved configuration; existing hook remediation and readiness semantics remain unchanged. +The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime includes `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path, and target-scoped integration inventory: Claude reports `Plugins`, `Commands`, and `Skills`; OpenCode reports `Plugins`, `Agents`, `Commands`, and `Skills`; Pi reports `Extensions`, `Prompts`, and `Skills`; and Codex reports `Skills` and `Hooks` (see `context/sce/doctor-human-text-contract.md`). Its non-launching post-commit Agent Trace auto-sync fact reports enabled/current, explicit disabled, not-ready, and not-applicable states using canonical managed-block currency and resolved configuration; existing hook remediation and readiness semantics remain unchanged. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support local or remote sync-mode opens, parent-directory creation, connection setup, synchronous query helpers, embedded migration execution, and shared DB lifecycle helpers. Auth DB persistence uses encrypted `AuthDb = EncryptedTursoDb` and token storage persists credentials through the `auth_credentials` table. Agent Trace persistence uses the sole `RepositoryAgentTraceDb = TursoDb` adapter at `/sce/repos//agent-trace.db`, with a one-file repository schema for `repository_metadata`, `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes/triggers, and no `checkout_id` columns on trace rows. The checkout-scoped `AgentTraceDb = TursoDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook runtime writes nullable event-local diff-trace attribution without a `session_models` API/table dependency. The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, remains the active bounded recent-diff-trace intersection path, and after successful Agent Trace persistence optionally launches the detached sync-owned `sync --format json` child when config-file-only `agent_trace.auto_sync` is true; `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, direct-first/event-transcript-second Claude `model_id` plus direct `tool_version` values (no session-model fallback or cache), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index fa191293..3ea53620 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -6,45 +6,45 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode ## Acceptance criteria -- [ ] AC1: `sce setup --codex --non-interactive` succeeds in a Git repository, installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**`, and persists `{"integrations": {"target": ["codex"]}}` into `.sce/config.json` under existing merge semantics. +- [x] AC1: `sce setup --codex --non-interactive` succeeds in a Git repository, installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**`, and persists `{"integrations": {"target": ["codex"]}}` into `.sce/config.json` under existing merge semantics. - Validate: run the command in a scratch git repo; inspect `.sce/config.json` and installed files. -- [ ] AC2: `sce setup --all --non-interactive` installs Codex assets alongside OpenCode/Claude/Pi with no regression to the other three targets. +- [x] AC2: `sce setup --all --non-interactive` installs Codex assets alongside OpenCode/Claude/Pi with no regression to the other three targets. - Validate: run in a scratch git repo; inspect all four target trees plus `integrations.target`. -- [ ] AC3: Core workflows (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`) appear under `.agents/skills/`, and optional workflows (`brownfield`) obey the existing `integrations.optional_workflows` selection mechanism for Codex the same way they do for OpenCode/Claude/Pi. +- [x] AC3: Core workflows (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`) appear under `.agents/skills/`, and optional workflows (`brownfield`) obey the existing `integrations.optional_workflows` selection mechanism for Codex the same way they do for OpenCode/Claude/Pi. - Validate: `nix run .#pkl-generate -- "$(mktemp -d)"` then inspect `.agents/skills/`; `sce setup --codex --workflow brownfield --non-interactive` includes `sce-brownfield`, a run without `--workflow` does not. -- [ ] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), and `PostToolUse` (`apply_patch`) — no Bash `PostToolUse` entry. +- [x] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), and `PostToolUse` (`apply_patch`) — no Bash `PostToolUse` entry. - Validate: inspect generated `.codex/hooks.json` content directly. -- [ ] AC5: A Codex `UserPromptSubmit` event produces exactly one user `message` and one text `part` under session `cx_`. +- [x] AC5: A Codex `UserPromptSubmit` event produces exactly one user `message` and one text `part` under session `cx_`. - Validate: integration test feeding a synthetic `UserPromptSubmit` payload to `sce hooks codex` and querying the repository Agent Trace DB. -- [ ] AC6: A Codex `Stop` event produces exactly one assistant `message` and one text `part`. +- [x] AC6: A Codex `Stop` event produces exactly one assistant `message` and one text `part`. - Validate: integration test feeding a synthetic `Stop` payload to `sce hooks codex` and querying the DB. -- [ ] AC7: Reprocessing the same turn's `UserPromptSubmit`/`Stop` event does not create a duplicate parent message. +- [x] AC7: Reprocessing the same turn's `UserPromptSubmit`/`Stop` event does not create a duplicate parent message. - Validate: integration test invoking the same payload twice and asserting one row per deterministic message ID. -- [ ] AC8: An allowed Bash command executes with no model-visible SCE tracing output. +- [x] AC8: An allowed Bash command executes with no model-visible SCE tracing output. - Validate: integration test asserting empty/silent success output for an allowed command through `sce hooks codex` `PreToolUse` `Bash`. -- [ ] AC9: A denied Bash command is blocked using Codex's native `PreToolUse` deny response shape and includes the SCE policy reason text. +- [x] AC9: A denied Bash command is blocked using Codex's native `PreToolUse` deny response shape and includes the SCE policy reason text. - Validate: integration test asserting the deny response body/shape and policy reason for a configured blocking policy. -- [ ] AC10: Bash filesystem mutations create no Codex `diff_trace`. +- [x] AC10: Bash filesystem mutations create no Codex `diff_trace`. - Validate: regression test running `echo generated > generated.txt` through the Codex Bash hook path and asserting zero new `diff_traces` rows. -- [ ] AC11: A successful `apply_patch` produces an observed unified patch in `diff_traces` reflecting the actual before/after repository delta, not the requested patch text. +- [x] AC11: A successful `apply_patch` produces an observed unified patch in `diff_traces` reflecting the actual before/after repository delta, not the requested patch text. - Validate: integration test driving `PreToolUse apply_patch` then `PostToolUse apply_patch` against a scratch repo and asserting the persisted patch matches `git diff` of the real file mutation. -- [ ] AC12: The persisted `diff_traces` row carries `session_id = cx_...`, `model_id = openai/...`, `tool_name = codex`, `payload_type = patch`. +- [x] AC12: The persisted `diff_traces` row carries `session_id = cx_...`, `model_id = openai/...`, `tool_name = codex`, `payload_type = patch`. - Validate: same integration test as AC11, asserting row field values. -- [ ] AC13: The same successful `apply_patch` also creates assistant patch conversation evidence (`message` + `part_type = patch`) tied to the same `cx_` session. +- [x] AC13: The same successful `apply_patch` also creates assistant patch conversation evidence (`message` + `part_type = patch`) tied to the same `cx_` session. - Validate: same integration test as AC11, querying `messages`/`parts`. -- [ ] AC14: Given a pre-existing dirty worktree change `A` before `PreToolUse apply_patch` and a Codex-authored change `B`, the resulting Codex diff evidence contains `B` but not `A`. +- [x] AC14: Given a pre-existing dirty worktree change `A` before `PreToolUse apply_patch` and a Codex-authored change `B`, the resulting Codex diff evidence contains `B` but not `A`. - Validate: integration test seeding an uncommitted dirty change before the hook sequence and asserting the persisted patch excludes it. -- [ ] AC15: A `PostToolUse apply_patch` with no corresponding pending before-state logs a diagnostic, fails open, and creates no diff evidence. +- [x] AC15: A `PostToolUse apply_patch` with no corresponding pending before-state logs a diagnostic, fails open, and creates no diff evidence. - Validate: integration test invoking `PostToolUse apply_patch` without a prior `PreToolUse apply_patch` for the same correlation key. -- [ ] AC16: Identical before/after repository states produce no diff trace and are treated as a successful no-op. +- [x] AC16: Identical before/after repository states produce no diff trace and are treated as a successful no-op. - Validate: integration test running the full pending → finalize sequence with no actual file change. -- [ ] AC17: A commit containing a recorded Codex `apply_patch` diff_trace is attributed through the existing, unmodified `post-commit` intersection pipeline. +- [x] AC17: A commit containing a recorded Codex `apply_patch` diff_trace is attributed through the existing, unmodified `post-commit` intersection pipeline. - Validate: integration test recording a Codex diff_trace, committing the same change, running `sce hooks post-commit`, and inspecting `post_commit_patch_intersections`. -- [ ] AC18: The resulting Agent Trace identifies Codex as the tool and preserves the Codex model ID through the existing attribution machinery. +- [x] AC18: The resulting Agent Trace identifies Codex as the tool and preserves the Codex model ID through the existing attribution machinery. - Validate: same integration test as AC17, asserting the built `agent_traces.trace_json` contributor/tool metadata. -- [ ] AC19: No Agent Trace repository schema migration is added; `diff_traces`/`agent_traces`/`messages`/`parts` and `RepositoryAgentTraceDbSpec::migrations()` remain unchanged. +- [x] AC19: No Agent Trace repository schema migration is added; `diff_traces`/`agent_traces`/`messages`/`parts` and `RepositoryAgentTraceDbSpec::migrations()` remain unchanged. - Validate: `git diff` shows no new file under `cli/migrations/agent-trace-repository/` and no changed baseline SQL. -- [ ] AC20: Existing OpenCode, Claude, and Pi setup, generated assets, conversation tracing, diff tracing, policy behavior, and Agent Trace tests continue to pass. +- [x] AC20: Existing OpenCode, Claude, and Pi setup, generated assets, conversation tracing, diff tracing, policy behavior, and Agent Trace tests continue to pass. - Validate: `nix flake check`. ### Full validation @@ -230,14 +230,67 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::persist hooks::post_commit'`. - Context synchronization: pending -- [ ] T13: `Add Codex doctor coverage` (status:todo) +- [x] T13: `Add Codex doctor coverage` (status:done) - Task ID: T13 - Scope: In — a Codex integration group in `cli/src/services/doctor/inspect.rs` (parallel to the Claude/OpenCode/Pi groups) reporting missing/mismatched `.agents/skills/**` for the resolved workflow selection, missing/mismatched `.codex/hooks.json`, and missing/mismatched `.codex/hooks/run-sce-or-show-install-guidance.sh`; actionable guidance text for Codex's project hook trust/review requirement (informational only — doctor does not bypass or grant trust); Codex added to the doctor target-resolution set (`integrations.target` entries / repo-root `.codex/` detection) and to `context/sce/doctor-human-text-contract.md`'s target/area ordering. Out — any change to doctor's fix-mode git-hook repair logic (unrelated to Codex). - Dependencies: T04, T05 - Done when: `sce doctor` in a repo with Codex installed and current reports `[PASS]` for the Codex integration group; deleting or corrupting a Codex asset produces the matching `[FAIL]`/`[MISS]` problem with actionable text; `sce doctor --format json` includes a Codex integration group entry alongside `opencode`/`claude`/`pi`. - Verify: `nix develop -c sh -c 'cd cli && cargo test doctor::'`; manual `sce doctor` / `sce doctor --format json` run against a Codex-installed scratch repo. - - Context synchronization: pending + - Completed: 2026-08-22 + - Files changed: `cli/src/services/default_paths.rs`, `cli/src/services/doctor/inspect.rs`, `cli/src/services/doctor/mod.rs`, `cli/src/services/doctor/render.rs`, `cli/src/services/doctor/types.rs`, `cli/src/services/lifecycle.rs`, `context/sce/doctor-human-text-contract.md` + - Result: Added `IntegrationTarget::Codex` and a new `IntegrationArea::Hooks` variant to `doctor/types.rs` (extending the exhaustive `display_label` match for every target × area combination, per the existing "these combinations are not produced by inspection" precedent), plus `ProblemKind::{CodexIntegrationFilesMissing, CodexIntegrationContentMismatch, CodexAssetReadFailed}` mirroring Pi's three kinds exactly (and their `HealthProblemKind` counterparts in `services/lifecycle.rs`, wired through both directions of `doctor/mod.rs`'s `problem_kind`/`health_problem_kind` conversion — a second exhaustive match this task's compiler errors surfaced beyond `inspect.rs`'s own `IntegrationTargetId::Codex` arm). Added `repo_dir::CODEX = ".codex"` and `RepoPaths::codex_dir()` to `default_paths.rs` for repo-root fallback detection (priority-3 in the doctor target-resolution order), wired into `resolve_doctor_integration_targets`'s existing `if repo_paths.*_dir().exists()` chain. Added `collect_codex_integration_groups` (mirroring `collect_pi_integration_groups`'s structure) using `InstallTargetPaths::codex_target_dir()` (the repository root itself, per T04/T05's precedent, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix) as the integration root, splitting embedded assets into a `Skills` group (`.agents/skills/` prefix) and a new `Hooks` group (`.codex/` prefix, covering both `hooks.json` and `hooks/run-sce-or-show-install-guidance.sh`); added `inspect_codex_integration_health` plus `push_codex_integration_{missing,mismatch,read_fail}_problems` mirroring Pi's three functions verbatim, with one addition: the `Hooks`-area missing/mismatch remediation text appends a fixed reminder (`CODEX_HOOK_TRUST_GUIDANCE`) that Codex also requires reviewing/trusting the project's hooks inside the Codex CLI before they take effect and that `sce doctor` cannot grant that trust — satisfying this task's own "actionable guidance text ... informational only" scope as an addition to the *existing* missing/mismatch problem's remediation (severity `Error`, matching the underlying file condition) rather than a separate always-on problem, since an unconditional problem would have made the `Hooks` group permanently unable to report `[PASS]`, contradicting this task's own first Done-when clause. Wired the previously no-op `IntegrationTargetId::Codex => {}` arm in `inspect_repository_integrations` to call the new collect/inspect pair, and updated the "No integrations are installed" guidance summary/remediation text to mention Codex alongside OpenCode/Claude/Pi. In `render.rs`, extended `integration_targets_for_text`, `integration_target_label`, `integration_area_label`, and the per-target `integration_area_order` match (`Codex: Skills, Hooks`, then the remaining unused areas) for the new target/area; also fixed `asset_path_components` to strip Codex's leading `.agents`/`.codex` root segment before the existing per-area prefix strip, since Codex's relative paths (unlike OpenCode/Claude/Pi's) are not already root-relative — verified this produces the same clean single-segment leaf labels as the other three targets in a live unhealthy-tree render, not literal `.agents`/`.codex` wrapper nodes. Updated `context/sce/doctor-human-text-contract.md`'s target-resolution priority list, display-label list, and area-ordering list for Codex, plus a new paragraph documenting the `Hooks` area and the trust/review reminder (this doc update was itself part of this task's own "Scope: In" text, not deferred to context synchronization). Added three new unit tests in `inspect.rs`: `codex_integration_groups_split_into_skills_and_hooks_areas` (absent root → both groups present, all children `Missing`, correct path-prefix membership), `codex_hooks_json_reports_match_then_missing_problem_includes_trust_guidance` (writes the real embedded `.codex/hooks.json` bytes to a temp repo → `Match`, then deletes it → `Missing` problem whose remediation text contains the trust guidance), and `resolve_doctor_integration_targets_detects_codex_directory` (a bare `.codex/` directory with no config is detected). No fix-mode (`fixes.rs`) changes were made — Codex's missing/mismatch problems carry `ProblemFixability::ManualOnly` exactly like Pi's, so `sce doctor --fix` reports them as `[manual]` without attempting a repair, matching this task's own "Out" boundary. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor::'` — passed: 12 passed, 0 failed, including the 3 new Codex tests above alongside all pre-existing OpenCode/Claude/Pi doctor tests unmodified. Also ran `nix flake check` (per T01–T12 precedent over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt` (after one `cargo fmt` reformatting pass), `cli-generated-input`, `pkl-generated`. Manual verification per this task's own Done-when: built `sce` via `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml --bin sce` and ran it against two scratch git repos (`git init` + a fake `origin` remote for repository-identity resolution). `sce setup --codex --non-interactive` then `sce doctor` reported `[PASS] Skills` / `[PASS] Hooks` under a `Codex` group in text mode; deleting `.codex/hooks.json` and corrupting `.agents/skills/sce-commit/SKILL.md` then rerunning `sce doctor` reported `[FAIL] Skills` / `[FAIL] Hooks` with `[MISS]`/`[FAIL]` leaf nodes, correct nested single-segment path labels, and remediation text — the `Hooks` remediation additionally carrying the Codex hook trust/review reminder; `sce doctor --format json`'s `problems` array carried two `"Codex ..."`-summary entries (missing hooks.json, mismatched skill) with the same shape as an OpenCode/Claude/Pi problem would carry, confirming "alongside opencode/claude/pi" without requiring a new JSON schema field (JSON currently exposes no group-summary field for any of the four targets when healthy — only via `problems[]` when something is broken — so Codex's behavior is symmetric with the other three in both states). `sce doctor --fix` left both Codex problems as `[manual]` (no fix-mode Codex-asset repair attempted). `sce setup --all --non-interactive` then `sce doctor` in a second scratch repo showed all four `Claude Code`/`OpenCode`/`Pi`/`Codex` groups as `[PASS]` with no regression to the other three targets' area lists. Reinstalling Codex's assets after corruption restored `[PASS]` for both groups. Scratch repos and their Agent Trace DB state were removed after verification. + - Context impact: root — `context/sce/doctor-human-text-contract.md` is already named under this plan's "Context sync" list for exactly this update (target-resolution priority list, display-label list, area-ordering list, and the new `Hooks`-area/trust-guidance paragraph), and this task edited it directly as part of implementation rather than deferring it; no other root context file states doctor's per-target area list or target-resolution priority as fact, so no further root file requires a synchronization pass. + - Context synchronization: synced ## Open questions - The exact current Codex CLI hook JSON schema (event names, field names, tool-call identifiers, and the native `PreToolUse` deny response shape) cannot be verified from this repository — Codex CLI is an external, evolving tool. T06 and T09 open by checking the change request's assumed schema against current Codex CLI behavior/documentation before finalizing the parser and deny-response builder; this is recorded as an assumption above rather than a blocking question because no acceptance criterion in this plan depends on the exact wire format — every AC is stated as an SCE-side observable outcome (DB rows, generated files, policy behavior) that holds regardless of the precise Codex JSON shape. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-22 + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 135 files, inventory sha256 0cc33ae43f128634271391515e011cf4961f50c0ec17069106ac0317c8a89799) +- `nix flake check` -> exit 0 (all checks passed! — `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`, plus the remaining registered checks) +- `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml --bin sce` -> exit 0 +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::` -> exit 0 (79 passed; 0 failed) +- manual `sce setup --codex --non-interactive` in a scratch git repo (with an `origin` remote for repository identity) -> exit 0 +- manual `sce setup --all --non-interactive` in a scratch git repo -> exit 0 +- manual `nix run .#pkl-generate -- "$(mktemp -d)"` -> exit 0 +- manual `sce setup --codex --workflow brownfield --non-interactive` vs. `sce setup --codex --non-interactive` in separate scratch repos -> exit 0 each +- `git status --porcelain --untracked-files=all -- cli/migrations/agent-trace-repository/` -> exit 0 (no output — no changes) + +### Success-criteria verification + +- [x] AC1: `sce setup --codex --non-interactive` succeeds, installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**`, persists `{"integrations": {"target": ["codex"]}}` -> confirmed by direct inspection of a scratch repo: `.sce/config.json` contains `"integrations": {"optional_workflows": [], "target": ["codex"]}`; `.agents/skills/` holds the six core skill directories; `.codex/hooks.json` and `.codex/hooks/run-sce-or-show-install-guidance.sh` present. +- [x] AC2: `sce setup --all --non-interactive` installs Codex alongside OpenCode/Claude/Pi with no regression -> confirmed: scratch repo shows `.opencode` (35 files), `.claude` (31 files), `.pi` (30 files), plus Codex's dual-root assets at repo root; `.sce/config.json` records `"target": ["opencode", "claude", "pi", "codex"]`. +- [x] AC3: core workflows under `.agents/skills/`; `brownfield` obeys `integrations.optional_workflows` selection -> confirmed: `nix run .#pkl-generate -- "$(mktemp -d)"` produced all six core skills plus `sce-brownfield`/`sce-decision` in the full generation root; a scratch `sce setup --codex --workflow brownfield --non-interactive` installed `sce-brownfield` alongside the six core skills, while a scratch `sce setup --codex --non-interactive` (no `--workflow`) installed only the six core skills. +- [x] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), `PostToolUse` (`apply_patch`), no Bash `PostToolUse` -> confirmed by direct content inspection of the generated file in the AC1 scratch repo. +- [x] AC5: Codex `UserPromptSubmit` produces one user message + one text part under `cx_` -> `hooks::codex::user_prompt_submit::tests::capture_with_produces_one_message_and_one_part_under_the_prefixed_session` passed. +- [x] AC6: Codex `Stop` produces one assistant message + one text part -> `hooks::codex::stop::tests::capture_with_produces_one_message_and_one_part_under_the_prefixed_session` passed. +- [x] AC7: reprocessing does not duplicate the parent message -> `hooks::codex::user_prompt_submit::tests::capture_with_does_not_duplicate_the_parent_message_on_reprocess` and `hooks::codex::stop::tests::capture_with_does_not_duplicate_the_parent_message_on_reprocess` passed. +- [x] AC8: allowed Bash command produces no model-visible output -> `hooks::codex::bash_policy::tests::render_bash_policy_response_is_silent_for_an_allowed_command` passed. +- [x] AC9: denied Bash command uses Codex-native deny shape with policy reason text -> `hooks::codex::bash_policy::tests::render_bash_policy_response_denies_with_codex_native_shape_for_a_blocked_command` passed. +- [x] AC10: Bash filesystem mutations create no Codex `diff_trace` -> `hooks::codex::bash_policy::tests::codex_bash_pre_tool_use_path_creates_no_diff_trace_for_a_filesystem_mutation_command` passed. +- [x] AC11: successful `apply_patch` produces an observed unified patch reflecting the real before/after delta -> `hooks::codex::apply_patch::post::tests::finalize_with_produces_a_diff_for_a_created_file` / `..._for_an_edited_file` / `..._for_a_deleted_file` / `..._for_a_rename` all passed. +- [x] AC12: persisted `diff_traces` row carries `session_id = cx_...`, `model_id = openai/...`, `tool_name = codex`, `payload_type = patch` -> `hooks::codex::apply_patch::persist::tests::persist_with_inserts_one_diff_trace_row_with_expected_fields` passed. +- [x] AC13: same `apply_patch` also creates assistant patch conversation evidence tied to the same `cx_` session -> `hooks::codex::apply_patch::persist::tests::persist_with_inserts_one_assistant_patch_message_and_part` passed. +- [x] AC14: pre-existing dirty change `A` excluded, Codex-authored change `B` included -> `hooks::codex::apply_patch::post::tests::finalize_with_excludes_a_pre_existing_dirty_change_from_the_observed_diff` passed. +- [x] AC15: `PostToolUse apply_patch` with no pending before-state fails open, no diff evidence -> `hooks::codex::apply_patch::post::tests::finalize_with_fails_open_when_no_pending_file_exists` and `..._fails_open_and_removes_a_malformed_pending_file` passed. +- [x] AC16: identical before/after states produce no diff trace, treated as successful no-op -> `hooks::codex::apply_patch::post::tests::finalize_with_treats_identical_before_and_after_as_a_no_op_and_still_consumes_pending_file` passed. +- [x] AC17: committed Codex `apply_patch` diff_trace attributed through the unmodified post-commit intersection pipeline -> `hooks::tests::codex_diff_trace_is_attributed_through_the_post_commit_pipeline` passed. +- [x] AC18: resulting Agent Trace identifies Codex as tool and preserves the Codex model ID -> same `hooks::tests::codex_diff_trace_is_attributed_through_the_post_commit_pipeline` test asserts `tool.name == Some("codex")` and `contributor.model_id == "openai/gpt-5.6-codex"`. +- [x] AC19: no Agent Trace repository schema migration added -> `git status --porcelain --untracked-files=all -- cli/migrations/agent-trace-repository/` produced no output (no changed or added files). +- [x] AC20: existing OpenCode/Claude/Pi setup, generated assets, tracing, policy, and Agent Trace tests continue to pass -> `nix flake check` passed in full (`cli-tests` includes all pre-existing OpenCode/Claude/Pi test modules, unmodified). + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- The Codex hook JSON schema (event/field names, native `PreToolUse` deny shape) was confirmed against a real GitHub-hosted Codex payload example at implementation time (T06/T09), but Codex CLI is an external, evolving tool; a future schema change could require parser/response-shape adjustments. This is a pre-existing, plan-documented open question, not a validation gap — every AC here is an SCE-side observable outcome independent of the exact wire format. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 0ca47704..67288d29 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -42,10 +42,10 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - required hook presence and executable permissions for `pre-commit`, `commit-msg`, and `post-commit` when repo-scoped checks apply (delegated to `HooksLifecycle::diagnose`) - post-commit automatic-sync readiness from the installed canonical managed block and resolved `agent_trace.auto_sync` setting; enabled/current reports ready, explicit `false` reports a healthy disabled opt-out, and enabled-but-missing, stale, unreadable, or non-executable post-commit state reports not ready without launching sync - managed-block currency checks for required hook payloads against canonical embedded SCE hook assets (delegated to `HooksLifecycle::diagnose` and reused by doctor inspection); `post_commit_auto_sync` is an explanatory capability fact rather than a new problem category, with JSON `state`, `enabled`, `source`, and `config_source` fields, while existing hook problem records, remediation, and overall readiness remain authoritative; doctor never launches `sce sync` or another background process, and runtime launcher failures remain fail-open to a successful post-commit operation -- integration target resolution that reads `integrations.target` from repo-local `.sce/config.json` when present, or falls back to detecting repo-root `.opencode/`, `.claude/`, and `.pi/` directories when config has no `integrations` or `integrations.target`; only the resolved targets are inspected -- repo-root installed OpenCode integration inventory for typed `Plugins`, `Agents`, `Commands`, and `Skills` areas, Claude inventory for generated `Plugins`, `Commands`, and `Skills` areas with no `Agents` expectation, plus Pi inventory for `Extensions`, `Prompts`, and `Skills`, all scoped to the resolved targets -- integration groups are rendered beneath typed, target-scoped `Claude Code`, `OpenCode`, and `Pi` nodes in deterministic target-specific area order; healthy groups render one concise status row without listing installed files -- OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees are not inspected by doctor +- integration target resolution that reads `integrations.target` from repo-local `.sce/config.json` when present, or falls back to detecting repo-root `.opencode/`, `.claude/`, `.pi/`, and `.codex/` directories when config has no `integrations` or `integrations.target`; only the resolved targets are inspected +- repo-root installed OpenCode integration inventory for typed `Plugins`, `Agents`, `Commands`, and `Skills` areas, Claude inventory for generated `Plugins`, `Commands`, and `Skills` areas with no `Agents` expectation, Pi inventory for `Extensions`, `Prompts`, and `Skills`, plus Codex inventory for `Skills` and `Hooks`, all scoped to the resolved targets +- integration groups are rendered beneath typed, target-scoped `Claude Code`, `OpenCode`, `Pi`, and `Codex` nodes in deterministic target-specific area order; healthy groups render one concise status row without listing installed files +- OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); Codex groups are derived from the embedded Codex catalog (`.agents/skills/**` under `Codex skills`, `.codex/hooks.json` and `.codex/hooks/**` under `Codex hooks`, the latter also carrying a Codex hook trust/review reminder when files are missing or mismatched — see `context/sce/doctor-human-text-contract.md`); generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/.agents/**`/`config/.codex/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks, so repair restores the canonical all-hook non-blocking missing-`sce` guidance, available-CLI argument/failure propagation, and post-commit-only remote forwarding contract; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories ## Approved human text-mode contract diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index b2737ba8..bda77e0c 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -73,19 +73,27 @@ Integration checks remain target-scoped. The doctor resolves targets using this priority: 1. A non-empty `.sce/config.json` `integrations.target` array selects only the - listed targets (`opencode`, `claude`, `pi`). + listed targets (`opencode`, `claude`, `pi`, `codex`). 2. An explicitly empty target array selects no targets and renders the no-target guidance row. 3. Without a configured target property, repo-root `.opencode/`, `.claude/`, - and `.pi/` directories are detected. + `.pi/`, and `.codex/` directories are detected. Only resolved targets render. Display labels are normalized as `Claude Code`, -`OpenCode`, and `Pi`; typed target/area keys, not display-label parsing, own the -hierarchy. Areas render in deterministic order: +`OpenCode`, `Pi`, and `Codex`; typed target/area keys, not display-label parsing, +own the hierarchy. Areas render in deterministic order: - Claude Code: `Plugins`, `Commands`, `Skills` - OpenCode: `Plugins`, `Agents`, `Commands`, `Skills` - Pi: `Extensions`, `Prompts`, `Skills` +- Codex: `Skills`, `Hooks` + +Codex's `Hooks` area covers `.codex/hooks.json` and +`.codex/hooks/run-sce-or-show-install-guidance.sh`. A missing or mismatched +Codex `Hooks` asset also carries a reminder that Codex requires reviewing and +trusting this project's hooks inside the Codex CLI before they take effect; +doctor diagnoses and can reinstall the on-disk file but cannot grant that +trust. Healthy areas render one concise `[PASS]` row and never list installed files. The report and JSON payload still retain the complete inspected asset facts for From 3ada88f04f5c8441b8f537bfb48478f14d8f819e Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 22 Aug 2026 23:52:19 +0200 Subject: [PATCH 10/20] codex: Implement PostToolUse apply_patch tracing Parse Codex apply_patch payloads, normalize provable Add/Update evidence into SCE patch text, and persist one diff-trace row with Codex session and model attribution. Register the PostToolUse hook while keeping Delete-only and malformed inputs fail-open without evidence, and preserve synthetic-line attribution through the existing post-commit intersection path. Plan: codex-cli-integration; Tasks: T10, T11, T12 Co-authored-by: SCE --- .../services/hooks/codex/apply_patch/mod.rs | 491 +++++++++++++ .../hooks/codex/apply_patch/normalize.rs | 426 +++++++++++ .../hooks/codex/apply_patch/parser.rs | 665 ++++++++++++++++++ cli/src/services/hooks/codex/bash_policy.rs | 2 +- cli/src/services/hooks/codex/mod.rs | 23 +- cli/src/services/hooks/mod.rs | 2 - config/pkl/renderers/codex-content.pkl | 11 + context/architecture.md | 2 +- context/cli/cli-command-surface.md | 4 +- context/context-map.md | 2 +- context/glossary.md | 4 +- context/overview.md | 4 +- context/patterns.md | 4 +- context/plans/codex-cli-integration.md | 167 ++--- context/sce/agent-trace-db.md | 2 +- .../sce/agent-trace-hooks-command-routing.md | 2 +- context/sce/codex-integration-runtime.md | 70 +- 17 files changed, 1767 insertions(+), 114 deletions(-) create mode 100644 cli/src/services/hooks/codex/apply_patch/mod.rs create mode 100644 cli/src/services/hooks/codex/apply_patch/normalize.rs create mode 100644 cli/src/services/hooks/codex/apply_patch/parser.rs diff --git a/cli/src/services/hooks/codex/apply_patch/mod.rs b/cli/src/services/hooks/codex/apply_patch/mod.rs new file mode 100644 index 00000000..ddfd5cbc --- /dev/null +++ b/cli/src/services/hooks/codex/apply_patch/mod.rs @@ -0,0 +1,491 @@ +//! Parses Codex's custom `apply_patch` text format (`*** Begin Patch` ... +//! `*** End Patch`) into a typed [`CodexPatch`], normalizes it into an +//! SCE-supported unified diff, and persists non-empty results as a +//! `diff_traces` row for the `PostToolUse`/`apply_patch` dispatch arm. + +mod normalize; +mod parser; + +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{DiffTraceInsert, PAYLOAD_TYPE_PATCH}; +use crate::services::observability::traits::Logger; + +use normalize::normalize_codex_patch; +#[allow(unused_imports)] +use parser::{ + parse_codex_apply_patch, CodexFileOperation, CodexHunk, CodexHunkLine, CodexPatch, + CodexPatchParseError, +}; + +use super::super::{ + current_unix_time_ms, normalize_codex_model_id, open_agent_trace_db_for_hook_runtime, + prefixed_diff_trace_session_id, CODEX_TOOL_NAME, +}; +use super::CodexHookEvent; + +/// Handles a Codex `PostToolUse(apply_patch)` event: reads the raw patch text +/// from `tool_input.command`, parses it (T10), normalizes it (T11), and — for +/// a non-empty normalized result — persists one `diff_traces` row. +/// +/// Every path here, success or fail-open, returns empty stdout: a missing or +/// non-string `command`, a parse failure (logged), and an empty normalized +/// patch (e.g. delete-only) all resolve to `Ok(String::new())` with no +/// evidence written. +pub(super) fn handle( + repository_root: &Path, + event: &CodexHookEvent, + logger: Option<&dyn Logger>, +) -> Result { + let Some(command) = apply_patch_command_from_event(event) else { + return Ok(String::new()); + }; + + let patch = match parse_codex_apply_patch(command) { + Ok(patch) => patch, + Err(parse_error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.codex.apply_patch.parse_failed", + &parse_error.to_string(), + &[], + event.session_id.as_deref(), + ); + } + return Ok(String::new()); + } + }; + + let normalized_patch = normalize_codex_patch(&patch); + if normalized_patch.is_empty() { + return Ok(String::new()); + } + + let Ok(time_ms) = current_unix_time_ms() else { + return Ok(String::new()); + }; + + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Codex apply_patch persistence.", + )?; + + persist_with(&db, event, &normalized_patch, time_ms) +} + +/// Codex's `PostToolUse` `tool_input` for the `apply_patch` tool carries the +/// raw patch text under `command`, mirroring the `Bash` tool's `tool_input` +/// shape this module's sibling `bash_policy.rs` already relies on. +fn apply_patch_command_from_event(event: &CodexHookEvent) -> Option<&str> { + event + .tool_input + .as_ref() + .and_then(|value| value.get("command")) + .and_then(|value| value.as_str()) +} + +/// Injectable counterpart of `handle`'s persistence step, for deterministic +/// testing against an already-open Agent Trace DB — mirrors the +/// `user_prompt_submit`/`stop` sibling arms' `capture_with` pattern. +fn persist_with( + db: &RepositoryAgentTraceDb, + event: &CodexHookEvent, + normalized_patch: &str, + time_ms: i64, +) -> Result { + let session_id = prefixed_diff_trace_session_id( + CODEX_TOOL_NAME, + event.session_id.as_deref().unwrap_or_default(), + ); + let model_id = event.model.as_deref().and_then(normalize_codex_model_id); + + db.insert_diff_trace(DiffTraceInsert { + time_ms, + session_id: &session_id, + patch: normalized_patch, + model_id: model_id.as_deref(), + tool_name: CODEX_TOOL_NAME, + tool_version: None, + payload_type: PAYLOAD_TYPE_PATCH, + }) + .context("Failed to persist Codex apply_patch diff-trace row.")?; + + Ok(String::new()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use serde_json::json; + + use crate::services::agent_trace::{build_agent_trace, AgentTraceMetadataInput}; + use crate::services::patch::{ + FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, + }; + + use super::*; + + const ADD_AND_UPDATE_PATCH: &str = "*** Begin Patch\n\ +*** Add File: new_file.txt\n\ ++hello world\n\ +*** Update File: src/lib.rs\n\ +@@\n\ +-old_line\n\ ++new_line\n\ +*** End Patch"; + + const MOVE_WITH_EDITS_PATCH: &str = "*** Begin Patch\n\ +*** Update File: old_name.txt\n\ +*** Move to: new_name.txt\n\ +@@\n\ +-old\n\ ++new\n\ +*** End Patch"; + + const PURE_RENAME_PATCH: &str = "*** Begin Patch\n\ +*** Update File: old_name.txt\n\ +*** Move to: new_name.txt\n\ +*** End Patch"; + + const DELETE_ONLY_PATCH: &str = "*** Begin Patch\n\ +*** Delete File: obsolete.txt\n\ +*** End Patch"; + + const MIXED_PATCH: &str = "*** Begin Patch\n\ +*** Add File: a.txt\n\ ++hello\n\ +*** Delete File: b.txt\n\ +*** Update File: c.txt\n\ +@@\n\ +-old\n\ ++new\n\ +*** End Patch"; + + const MALFORMED_PATCH: &str = "not a real apply_patch payload"; + + const UPDATE_ONLY_PATCH: &str = "*** Begin Patch\n\ +*** Update File: src/lib.rs\n\ +@@\n\ +-old_line\n\ ++new_line\n\ +*** End Patch"; + + fn event(session_id: &str, model: Option<&str>, command: &str) -> CodexHookEvent { + event_with_tool_input(session_id, model, Some(json!({ "command": command }))) + } + + fn event_with_tool_input( + session_id: &str, + model: Option<&str>, + tool_input: Option, + ) -> CodexHookEvent { + CodexHookEvent { + hook_event_name: "PostToolUse".to_string(), + session_id: Some(session_id.to_string()), + turn_id: Some("turn-1".to_string()), + cwd: None, + model: model.map(str::to_string), + tool_name: Some("apply_patch".to_string()), + tool_use_id: Some("tool-1".to_string()), + tool_input, + tool_response: None, + prompt: None, + last_assistant_message: None, + } + } + + fn normalized(raw: &str) -> String { + normalize_codex_patch(&parse_codex_apply_patch(raw).expect("fixture patch should parse")) + } + + fn unique_test_db_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!( + "sce-codex-apply-patch-{label}-{}-{nonce}", + std::process::id() + )) + .join("agent-trace.db") + } + + fn remove_test_db(db_path: &Path) { + if let Some(parent) = db_path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + + // --- fail-open / successful no-op behaviors: `handle` returns before it + // would ever open the Agent Trace DB, so a non-existent repository root + // is safe to pass through unused. --- + + #[test] + fn handle_fails_open_silently_when_tool_input_missing() { + let output = handle( + Path::new("/nonexistent"), + &event_with_tool_input("session-1", None, None), + None, + ) + .expect("apply_patch handling should succeed"); + assert_eq!(output, ""); + } + + #[test] + fn handle_fails_open_silently_when_command_missing() { + let output = handle( + Path::new("/nonexistent"), + &event_with_tool_input("session-1", None, Some(json!({}))), + None, + ) + .expect("apply_patch handling should succeed"); + assert_eq!(output, ""); + } + + #[test] + fn handle_fails_open_silently_when_command_non_string() { + let output = handle( + Path::new("/nonexistent"), + &event_with_tool_input("session-1", None, Some(json!({"command": 42}))), + None, + ) + .expect("apply_patch handling should succeed"); + assert_eq!(output, ""); + } + + #[test] + fn handle_fails_open_silently_on_malformed_patch_text() { + let output = handle( + Path::new("/nonexistent"), + &event("session-1", None, MALFORMED_PATCH), + None, + ) + .expect("apply_patch handling should succeed"); + assert_eq!(output, ""); + } + + #[test] + fn handle_is_a_successful_no_op_for_a_delete_only_patch() { + let output = handle( + Path::new("/nonexistent"), + &event("session-1", None, DELETE_ONLY_PATCH), + None, + ) + .expect("apply_patch handling should succeed"); + assert_eq!(output, ""); + } + + #[test] + fn handle_is_a_successful_no_op_for_a_pure_rename_with_no_changed_lines() { + let output = handle( + Path::new("/nonexistent"), + &event("session-1", None, PURE_RENAME_PATCH), + None, + ) + .expect("apply_patch handling should succeed"); + assert_eq!(output, ""); + } + + // --- persistence content (AC11-AC14): `persist_with` against a real, + // directly-opened Agent Trace DB, mirroring `user_prompt_submit`/`stop`'s + // own injectable-level testing precedent rather than the full + // hook-runtime DB resolution (which requires a prior `sce setup`). --- + + #[test] + fn apply_patch_persists_one_row_with_expected_field_values_for_add_and_update() { + let db_path = unique_test_db_path("add-update"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let normalized_patch = normalized(ADD_AND_UPDATE_PATCH); + let output = persist_with( + &db, + &event("session-1", Some("gpt-5-codex"), ADD_AND_UPDATE_PATCH), + &normalized_patch, + 1_000, + ) + .expect("persist_with should succeed"); + assert_eq!(output, ""); + + let recent = db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + assert_eq!(recent.skipped_count(), 0); + + let row = &recent.patches[0]; + assert_eq!(row.session_id, "cx_session-1"); + assert_eq!(row.tool_name.as_deref(), Some("codex")); + assert_eq!(row.tool_version, None); + assert_eq!(row.payload_type, "patch"); + assert_eq!( + row.patch.files.len(), + 2, + "Add File and Update File both persist evidence" + ); + assert!(row + .patch + .files + .iter() + .flat_map(|file| &file.hunks) + .all(|hunk| hunk.model_id.as_deref() == Some("openai/gpt-5-codex"))); + + remove_test_db(&db_path); + } + + #[test] + fn apply_patch_move_with_edits_persists_row_with_expected_paths() { + let db_path = unique_test_db_path("move-edits"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let normalized_patch = normalized(MOVE_WITH_EDITS_PATCH); + persist_with( + &db, + &event("session-1", None, MOVE_WITH_EDITS_PATCH), + &normalized_patch, + 1_000, + ) + .expect("persist_with should succeed"); + + let recent = db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + let file = &recent.patches[0].patch.files[0]; + assert_eq!(file.old_path, "old_name.txt"); + assert_eq!(file.new_path, "new_name.txt"); + assert_eq!(file.kind, FileChangeKind::Renamed); + + remove_test_db(&db_path); + } + + #[test] + fn apply_patch_mixed_operations_persists_only_add_and_update_evidence() { + let db_path = unique_test_db_path("mixed"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let normalized_patch = normalized(MIXED_PATCH); + persist_with( + &db, + &event("session-1", None, MIXED_PATCH), + &normalized_patch, + 1_000, + ) + .expect("persist_with should succeed"); + + let recent = db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + let file_paths: Vec<&str> = recent.patches[0] + .patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert_eq!( + file_paths, + vec!["a.txt", "c.txt"], + "Delete File must not appear" + ); + + remove_test_db(&db_path); + } + + /// AC15: a committed Codex `apply_patch` Update whose `diff_trace` carries + /// synthetic, patch-local line numbers is still attributed through the + /// existing, unmodified post-commit intersection pipeline + /// (`build_agent_trace`, the same function the real `post-commit` hook + /// flow calls) when the real committed line numbers differ, and the + /// resulting Agent Trace identifies Codex as the tool and preserves the + /// Codex model ID. + #[test] + fn apply_patch_diff_trace_attributes_through_agent_trace_pipeline_at_different_real_lines() { + let db_path = unique_test_db_path("agent-trace-pipeline"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let normalized_patch = normalized(UPDATE_ONLY_PATCH); + persist_with( + &db, + &event("session-1", Some("gpt-5-codex"), UPDATE_ONLY_PATCH), + &normalized_patch, + 1_000, + ) + .expect("persist_with should succeed"); + + let recent = db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + let constructed = &recent.patches[0]; + + // A realistic post-commit patch where the same touched lines sit at + // real line 42, far from the diff_trace's synthetic line 1, plus one + // unrelated committed line that must not be attributed to Codex. + let post_commit_patch = ParsedPatch { + files: vec![PatchFileChange { + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + kind: FileChangeKind::Modified, + hunks: vec![PatchHunk { + old_start: 42, + old_count: 1, + new_start: 42, + new_count: 2, + model_id: None, + lines: vec![ + TouchedLine { + kind: TouchedLineKind::Removed, + line_number: 42, + content: "old_line".to_string(), + session_id: None, + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 42, + content: "new_line".to_string(), + session_id: None, + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 43, + content: "unrelated_line".to_string(), + session_id: None, + }, + ], + }], + }], + }; + + let agent_trace = build_agent_trace( + &constructed.patch, + &post_commit_patch, + AgentTraceMetadataInput { + commit_timestamp: "2026-04-23T10:20:30Z", + commit_revision: "abc123def456", + vcs_type: None, + tool_name: constructed.tool_name.as_deref(), + tool_version: constructed.tool_version.as_deref(), + }, + ) + .expect("Agent Trace should build from the post-commit intersection"); + let agent_trace_json = + serde_json::to_value(&agent_trace).expect("Agent Trace should serialize"); + + assert_eq!(agent_trace_json["tool"]["name"], "codex"); + assert_eq!( + agent_trace_json["files"][0]["conversations"][0]["contributor"]["model_id"], + "openai/gpt-5-codex" + ); + + remove_test_db(&db_path); + } +} diff --git a/cli/src/services/hooks/codex/apply_patch/normalize.rs b/cli/src/services/hooks/codex/apply_patch/normalize.rs new file mode 100644 index 00000000..a811cd25 --- /dev/null +++ b/cli/src/services/hooks/codex/apply_patch/normalize.rs @@ -0,0 +1,426 @@ +//! Normalizes a parsed Codex `apply_patch` payload ([`CodexPatch`]) into SCE +//! `Index:`-form unified-diff text that `crate::services::patch::parse_patch` +//! already accepts. +//! +//! Positions are deterministic and patch-local: each `Update File` operation +//! numbers only the touched (`+`/`-`) lines it actually emits, starting from +//! line 1, ignoring Codex's own unchanged context lines entirely (they are +//! dropped, not persisted as evidence, and contribute no positional weight). +//! These positions are never claimed to be real filesystem line numbers. The +//! existing, unmodified `intersect_patches` +//! historical `kind`+`content` fallback is what lets this synthetic-line +//! evidence still attribute correctly once a real commit lands at different +//! real line numbers (see plan `context/plans/codex-cli-integration.md` +//! T11/AC15) — this module does not touch that fallback. +//! +//! `Delete File` operations, and `Update File` + `Move to` operations with no +//! changed lines, contribute no evidence and are silently dropped: an +//! `apply_patch` producing no provable evidence normalizes to an empty +//! string. + +use std::fmt::Write as _; + +use super::{CodexFileOperation, CodexHunk, CodexHunkLine, CodexPatch}; + +const PATCH_INDEX_SEPARATOR: &str = + "==================================================================="; + +/// Normalizes every `Add`/`Update` file operation in `patch` into one +/// combined SCE `Index:`-form unified-diff string, in operation order. +#[allow(dead_code)] +pub(crate) fn normalize_codex_patch(patch: &CodexPatch) -> String { + patch + .operations + .iter() + .filter_map(normalize_operation) + .collect() +} + +fn normalize_operation(operation: &CodexFileOperation) -> Option { + match operation { + CodexFileOperation::Add { path, lines } => Some(normalize_add(path, lines)), + CodexFileOperation::Update { + old_path, + new_path, + hunks, + } => normalize_update(old_path, new_path.as_deref(), hunks), + CodexFileOperation::Delete { .. } => None, + } +} + +fn normalize_add(path: &str, lines: &[String]) -> String { + let mut body = format!("@@ -0,0 +1,{} @@\n", lines.len()); + for line in lines { + body.push('+'); + body.push_str(line); + body.push('\n'); + } + render_file_section(path, path, &body) +} + +fn normalize_update(old_path: &str, new_path: Option<&str>, hunks: &[CodexHunk]) -> Option { + let mut body = String::new(); + let mut old_pos: u64 = 1; + let mut new_pos: u64 = 1; + let mut has_changes = false; + + for hunk in hunks { + let hunk_old_start = old_pos; + let hunk_new_start = new_pos; + let mut hunk_body = String::new(); + let mut removed_count: u64 = 0; + let mut added_count: u64 = 0; + + for line in &hunk.lines { + match line { + // Codex's unchanged context is dropped, not persisted as + // evidence, and does not affect synthetic positions. + CodexHunkLine::Context(_) => {} + CodexHunkLine::Removed(content) => { + hunk_body.push('-'); + hunk_body.push_str(content); + hunk_body.push('\n'); + old_pos += 1; + removed_count += 1; + } + CodexHunkLine::Added(content) => { + hunk_body.push('+'); + hunk_body.push_str(content); + hunk_body.push('\n'); + new_pos += 1; + added_count += 1; + } + } + } + + if removed_count > 0 || added_count > 0 { + let _ = writeln!( + body, + "@@ -{hunk_old_start},{removed_count} +{hunk_new_start},{added_count} @@" + ); + body.push_str(&hunk_body); + has_changes = true; + } + } + + if !has_changes { + return None; + } + + let destination = new_path.unwrap_or(old_path); + Some(render_file_section(old_path, destination, &body)) +} + +fn render_file_section(old_path: &str, new_path: &str, body: &str) -> String { + format!("Index: {new_path}\n{PATCH_INDEX_SEPARATOR}\n--- {old_path}\n+++ {new_path}\n{body}") +} + +#[cfg(test)] +mod tests { + use super::super::parser::parse_codex_apply_patch; + use super::*; + use crate::services::patch::{ + intersect_patches, parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, + TouchedLine, TouchedLineKind, + }; + + fn parse(raw: &str) -> CodexPatch { + parse_codex_apply_patch(raw).expect("fixture patch should parse") + } + + #[test] + fn normalizes_add_file_into_a_parseable_added_hunk() { + let patch = parse( + "*** Begin Patch\n\ + *** Add File: foo.txt\n\ + +line one\n\ + +line two\n\ + *** End Patch", + ); + + let normalized = normalize_codex_patch(&patch); + + assert_eq!( + normalized, + "Index: foo.txt\n\ + ===================================================================\n\ + --- foo.txt\n\ + +++ foo.txt\n\ + @@ -0,0 +1,2 @@\n\ + +line one\n\ + +line two\n" + ); + + let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); + assert_eq!(parsed.files.len(), 1); + let file = &parsed.files[0]; + assert_eq!(file.kind, FileChangeKind::Added); + assert_eq!(file.hunks.len(), 1); + assert_eq!( + file.hunks[0].lines, + vec![ + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 1, + content: "line one".to_string(), + session_id: Some("cx_test".to_string()), + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 2, + content: "line two".to_string(), + session_id: Some("cx_test".to_string()), + }, + ] + ); + } + + #[test] + fn normalizes_update_file_dropping_context_lines() { + let patch = parse( + "*** Begin Patch\n\ + *** Update File: src/lib.rs\n\ + @@ fn main() {\n\ + \x20 unchanged\n\ + - old_line\n\ + + new_line\n\ + *** End Patch", + ); + + let normalized = normalize_codex_patch(&patch); + + // The context line (" unchanged") is dropped entirely and + // contributes no positional weight. + assert_eq!( + normalized, + "Index: src/lib.rs\n\ + ===================================================================\n\ + --- src/lib.rs\n\ + +++ src/lib.rs\n\ + @@ -1,1 +1,1 @@\n\ + - old_line\n\ + + new_line\n" + ); + + let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); + assert_eq!(parsed.files.len(), 1); + let file = &parsed.files[0]; + assert_eq!(file.kind, FileChangeKind::Modified); + assert_eq!( + file.hunks[0].lines, + vec![ + TouchedLine { + kind: TouchedLineKind::Removed, + line_number: 1, + content: " old_line".to_string(), + session_id: Some("cx_test".to_string()), + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 1, + content: " new_line".to_string(), + session_id: Some("cx_test".to_string()), + }, + ] + ); + } + + #[test] + fn normalizes_update_with_move_and_changed_lines() { + let patch = parse( + "*** Begin Patch\n\ + *** Update File: old_name.txt\n\ + *** Move to: new_name.txt\n\ + @@\n\ + -old\n\ + +new\n\ + *** End Patch", + ); + + let normalized = normalize_codex_patch(&patch); + + assert_eq!( + normalized, + "Index: new_name.txt\n\ + ===================================================================\n\ + --- old_name.txt\n\ + +++ new_name.txt\n\ + @@ -1,1 +1,1 @@\n\ + -old\n\ + +new\n" + ); + + let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); + assert_eq!(parsed.files.len(), 1); + let file = &parsed.files[0]; + assert_eq!(file.old_path, "old_name.txt"); + assert_eq!(file.new_path, "new_name.txt"); + assert_eq!(file.kind, FileChangeKind::Renamed); + } + + #[test] + fn drops_pure_rename_with_no_changed_lines() { + let patch = parse( + "*** Begin Patch\n\ + *** Update File: old_name.txt\n\ + *** Move to: new_name.txt\n\ + *** End Patch", + ); + + assert_eq!(normalize_codex_patch(&patch), ""); + } + + #[test] + fn normalizes_delete_only_patch_to_empty_string() { + let patch = parse( + "*** Begin Patch\n\ + *** Delete File: obsolete.txt\n\ + *** End Patch", + ); + + assert_eq!(normalize_codex_patch(&patch), ""); + } + + #[test] + fn mixed_patch_keeps_only_add_and_update_evidence() { + let patch = parse( + "*** Begin Patch\n\ + *** Add File: a.txt\n\ + +hello\n\ + *** Delete File: b.txt\n\ + *** Update File: c.txt\n\ + @@\n\ + -old\n\ + +new\n\ + *** End Patch", + ); + + let normalized = normalize_codex_patch(&patch); + let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); + + assert_eq!(parsed.files.len(), 2); + assert_eq!(parsed.files[0].new_path, "a.txt"); + assert_eq!(parsed.files[0].kind, FileChangeKind::Added); + assert_eq!(parsed.files[1].new_path, "c.txt"); + assert_eq!(parsed.files[1].kind, FileChangeKind::Modified); + } + + #[test] + fn multiple_hunks_advance_positions_cumulatively() { + let patch = parse( + "*** Begin Patch\n\ + *** Update File: d.txt\n\ + @@ fn one() {\n\ + -a\n\ + +b\n\ + @@ fn two() {\n\ + -c\n\ + +d\n\ + *** End Patch", + ); + + let normalized = normalize_codex_patch(&patch); + + assert_eq!( + normalized, + "Index: d.txt\n\ + ===================================================================\n\ + --- d.txt\n\ + +++ d.txt\n\ + @@ -1,1 +1,1 @@\n\ + -a\n\ + +b\n\ + @@ -2,1 +2,1 @@\n\ + -c\n\ + +d\n" + ); + + parse_patch(&normalized, Some("cx_test")).expect("should parse"); + } + + /// AC15: synthetic patch-local line numbers must still attribute + /// correctly through the existing, unmodified `intersect_patches` + /// historical `kind`+`content` fallback once the real commit lands the + /// same touched lines at different real line numbers, while an unrelated + /// committed line does not intersect. + #[test] + fn intersect_patches_matches_synthetic_lines_via_historical_fallback() { + let codex_patch = parse( + "*** Begin Patch\n\ + *** Update File: src/lib.rs\n\ + @@\n\ + -old_line\n\ + +new_line\n\ + *** End Patch", + ); + let normalized = normalize_codex_patch(&codex_patch); + let constructed_patch = + parse_patch(&normalized, Some("cx_test")).expect("constructed patch should parse"); + + // A realistic post-commit patch where the same touched lines sit at + // different real line numbers than the synthetic ones above (1/1), + // plus one unrelated line that should not intersect. + let post_commit_patch = real_commit_patch(); + + let overlap = intersect_patches(&constructed_patch, &post_commit_patch); + + assert_eq!(overlap.files.len(), 1); + let file = &overlap.files[0]; + assert_eq!(file.new_path, "src/lib.rs"); + assert_eq!(file.hunks.len(), 1); + assert_eq!( + file.hunks[0].lines, + vec![ + TouchedLine { + kind: TouchedLineKind::Removed, + line_number: 42, + content: "old_line".to_string(), + session_id: Some("cx_test".to_string()), + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 42, + content: "new_line".to_string(), + session_id: Some("cx_test".to_string()), + }, + ] + ); + } + + fn real_commit_patch() -> ParsedPatch { + ParsedPatch { + files: vec![PatchFileChange { + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + kind: FileChangeKind::Modified, + hunks: vec![PatchHunk { + old_start: 42, + old_count: 1, + new_start: 42, + new_count: 2, + model_id: None, + lines: vec![ + TouchedLine { + kind: TouchedLineKind::Removed, + line_number: 42, + content: "old_line".to_string(), + session_id: None, + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 42, + content: "new_line".to_string(), + session_id: None, + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 43, + content: "unrelated_line".to_string(), + session_id: None, + }, + ], + }], + }], + } + } +} diff --git a/cli/src/services/hooks/codex/apply_patch/parser.rs b/cli/src/services/hooks/codex/apply_patch/parser.rs new file mode 100644 index 00000000..d0006f35 --- /dev/null +++ b/cli/src/services/hooks/codex/apply_patch/parser.rs @@ -0,0 +1,665 @@ +//! Grammar parser for Codex's `apply_patch` custom patch text. +//! +//! The grammar implemented here follows the official Lark grammar documented +//! in `openai/codex`'s `codex-rs/apply-patch/src/parser.rs` (checked against +//! that source directly for this task; see plan +//! `context/plans/codex-cli-integration.md` Assumptions): +//! +//! ```text +//! start: begin_patch environment_id? hunk+ end_patch +//! begin_patch: "*** Begin Patch" LF +//! environment_id: "*** Environment ID: " filename LF +//! end_patch: "*** End Patch" LF? +//! +//! hunk: add_hunk | delete_hunk | update_hunk +//! add_hunk: "*** Add File: " filename LF add_line+ +//! delete_hunk: "*** Delete File: " filename LF +//! update_hunk: "*** Update File: " filename LF change_move? change? +//! filename: /(.+)/ +//! add_line: "+" /(.+)/ LF -> line +//! +//! change_move: "*** Move to: " filename LF +//! change: (change_context | change_line)+ eof_line? +//! change_context: ("@@" | "@@ " /(.+)/) LF +//! change_line: ("+" | "-" | " ") /(.+)/ LF +//! eof_line: "*** End of File" LF +//! ``` +//! +//! Upstream Codex itself accepts absolute hunk paths (resolving them against +//! the tool's own `cwd` later). This parser is deliberately more +//! conservative than upstream, per this task's own scope: it rejects +//! absolute paths and `..` traversal segments outright, since SCE has no +//! equivalent downstream resolution step and normalized evidence must stay +//! anchored inside the repository working tree. + +use std::path::{Component, Path}; + +const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; +const END_PATCH_MARKER: &str = "*** End Patch"; +const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID: "; +const ADD_FILE_MARKER: &str = "*** Add File: "; +const DELETE_FILE_MARKER: &str = "*** Delete File: "; +const UPDATE_FILE_MARKER: &str = "*** Update File: "; +const MOVE_TO_MARKER: &str = "*** Move to: "; +const END_OF_FILE_MARKER: &str = "*** End of File"; +const CHANGE_CONTEXT_MARKER: &str = "@@"; +const CHANGE_CONTEXT_MARKER_WITH_TEXT: &str = "@@ "; + +/// One fully parsed Codex `apply_patch` payload: an ordered list of the file +/// operations it declares. Order is preserved from the source text. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexPatch { + pub(crate) operations: Vec, +} + +/// A single `*** Add File:` / `*** Update File:` / `*** Delete File:` block. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CodexFileOperation { + Add { + path: String, + lines: Vec, + }, + Update { + old_path: String, + new_path: Option, + hunks: Vec, + }, + Delete { + path: String, + }, +} + +/// One contiguous change region within an `*** Update File:` block, started +/// either by an explicit `@@` context marker or implicitly by the first +/// change line when no `@@` marker precedes it. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexHunk { + pub(crate) context: Option, + pub(crate) lines: Vec, + pub(crate) is_end_of_file: bool, +} + +/// A single line within a [`CodexHunk`], without its leading marker +/// character. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CodexHunkLine { + Context(String), + Added(String), + Removed(String), +} + +/// Error produced when raw `apply_patch` text does not conform to the +/// grammar above, or violates this parser's own conservative path +/// validation. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexPatchParseError { + pub(crate) message: String, +} + +impl std::fmt::Display for CodexPatchParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "codex apply_patch parse error: {}", self.message) + } +} + +impl std::error::Error for CodexPatchParseError {} + +fn error(message: impl Into) -> CodexPatchParseError { + CodexPatchParseError { + message: message.into(), + } +} + +/// Parses raw Codex `apply_patch` `tool_input.command` text into a +/// [`CodexPatch`]. Performs no normalization to SCE unified-diff form and no +/// filesystem access; see the `apply_patch` module's own scope boundary. +#[allow(dead_code)] +pub(crate) fn parse_codex_apply_patch(raw: &str) -> Result { + let trimmed = raw.trim(); + let lines: Vec<&str> = trimmed.lines().collect(); + + if lines.first().map(|line| line.trim()) != Some(BEGIN_PATCH_MARKER) { + return Err(error(format!( + "Codex apply_patch text must start with '{BEGIN_PATCH_MARKER}'." + ))); + } + if lines.last().map(|line| line.trim()) != Some(END_PATCH_MARKER) { + return Err(error(format!( + "Codex apply_patch text must end with '{END_PATCH_MARKER}'." + ))); + } + + let mut body: &[&str] = &lines[1..lines.len() - 1]; + + if let Some(first) = body.first() { + if let Some(raw_id) = first.strip_prefix(ENVIRONMENT_ID_MARKER) { + if raw_id.trim().is_empty() { + return Err(error("Codex apply_patch environment id cannot be empty.")); + } + body = &body[1..]; + } + } + + let mut operations = Vec::new(); + let mut index = 0; + + while index < body.len() { + let line = body[index]; + + if let Some(path) = line.strip_prefix(ADD_FILE_MARKER) { + let path = validate_path(path.trim())?; + index += 1; + + let mut added_lines = Vec::new(); + while index < body.len() && !is_top_level_marker(body[index]) { + let content_line = body[index]; + match content_line.strip_prefix('+') { + Some(content) => added_lines.push(content.to_string()), + None => { + return Err(error(format!( + "Codex apply_patch Add File '{path}' has an unrecognized line {}: '{content_line}'.", + index + 1 + ))); + } + } + index += 1; + } + + if added_lines.is_empty() { + return Err(error(format!( + "Codex apply_patch Add File '{path}' has no added lines." + ))); + } + + operations.push(CodexFileOperation::Add { + path, + lines: added_lines, + }); + } else if let Some(path) = line.strip_prefix(DELETE_FILE_MARKER) { + let path = validate_path(path.trim())?; + operations.push(CodexFileOperation::Delete { path }); + index += 1; + } else if let Some(path) = line.strip_prefix(UPDATE_FILE_MARKER) { + let old_path = validate_path(path.trim())?; + index += 1; + + let mut new_path = None; + if index < body.len() { + if let Some(destination) = body[index].strip_prefix(MOVE_TO_MARKER) { + new_path = Some(validate_path(destination.trim())?); + index += 1; + } + } + + let (hunks, consumed) = parse_update_hunks(&old_path, &body[index..])?; + index += consumed; + + if hunks.is_empty() && new_path.is_none() { + return Err(error(format!( + "Codex apply_patch Update File '{old_path}' has no move and no changes." + ))); + } + + operations.push(CodexFileOperation::Update { + old_path, + new_path, + hunks, + }); + } else { + return Err(error(format!( + "Unrecognized Codex apply_patch operation line {}: '{line}'.", + index + 1 + ))); + } + } + + Ok(CodexPatch { operations }) +} + +fn is_top_level_marker(line: &str) -> bool { + line.starts_with(ADD_FILE_MARKER) + || line.starts_with(DELETE_FILE_MARKER) + || line.starts_with(UPDATE_FILE_MARKER) +} + +/// Parses the `change_move? change?` tail of an `*** Update File:` block +/// (with any `*** Move to:` line already consumed by the caller), returning +/// the resulting hunks plus how many lines of `lines` were consumed. +fn parse_update_hunks( + path: &str, + lines: &[&str], +) -> Result<(Vec, usize), CodexPatchParseError> { + let mut hunks: Vec = Vec::new(); + let mut consumed = 0; + + while consumed < lines.len() && !is_top_level_marker(lines[consumed]) { + let line = lines[consumed]; + + if line == CHANGE_CONTEXT_MARKER || line.starts_with(CHANGE_CONTEXT_MARKER_WITH_TEXT) { + let context = line + .strip_prefix(CHANGE_CONTEXT_MARKER_WITH_TEXT) + .map(str::to_string); + hunks.push(CodexHunk { + context, + lines: Vec::new(), + is_end_of_file: false, + }); + consumed += 1; + continue; + } + + if line.trim() == END_OF_FILE_MARKER { + match hunks.last_mut() { + Some(hunk) => hunk.is_end_of_file = true, + None => hunks.push(CodexHunk { + context: None, + lines: Vec::new(), + is_end_of_file: true, + }), + } + consumed += 1; + continue; + } + + let hunk_line = if line.is_empty() { + CodexHunkLine::Context(String::new()) + } else { + let mut chars = line.chars(); + let marker = chars.next(); + let rest = chars.as_str(); + match marker { + Some('+') => CodexHunkLine::Added(rest.to_string()), + Some('-') => CodexHunkLine::Removed(rest.to_string()), + Some(' ') => CodexHunkLine::Context(rest.to_string()), + _ => { + return Err(error(format!( + "Codex apply_patch Update File '{path}' has an unrecognized change line {}: '{line}'.", + consumed + 1 + ))); + } + } + }; + + if hunks.is_empty() { + hunks.push(CodexHunk { + context: None, + lines: Vec::new(), + is_end_of_file: false, + }); + } + hunks + .last_mut() + .expect("a hunk was just ensured present above") + .lines + .push(hunk_line); + consumed += 1; + } + + Ok((hunks, consumed)) +} + +/// Conservative path validation: rejects absolute paths and any `..` +/// traversal segment. Deliberately stricter than upstream Codex, which +/// accepts and resolves absolute hunk paths itself (see this module's own +/// doc comment). +fn validate_path(path: &str) -> Result { + if path.is_empty() { + return Err(error("Codex apply_patch path cannot be empty.")); + } + + let candidate = Path::new(path); + + if candidate.is_absolute() { + return Err(error(format!( + "Codex apply_patch path '{path}' must not be absolute." + ))); + } + + if candidate + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(error(format!( + "Codex apply_patch path '{path}' must not contain '..' traversal segments." + ))); + } + + Ok(path.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_add_file_operation() { + let patch = "*** Begin Patch\n\ + *** Add File: foo.txt\n\ + +line one\n\ + +line two\n\ + *** End Patch"; + + let parsed = parse_codex_apply_patch(patch).expect("valid Add File patch should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Add { + path: "foo.txt".to_string(), + lines: vec!["line one".to_string(), "line two".to_string()], + }] + ); + } + + #[test] + fn parses_update_file_single_hunk() { + let patch = "*** Begin Patch\n\ + *** Update File: src/lib.rs\n\ + @@ fn main() {\n\ + - old_line\n\ + + new_line\n\ + *** End Patch"; + + let parsed = parse_codex_apply_patch(patch).expect("valid Update File patch should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Update { + old_path: "src/lib.rs".to_string(), + new_path: None, + hunks: vec![CodexHunk { + context: Some("fn main() {".to_string()), + lines: vec![ + CodexHunkLine::Removed(" old_line".to_string()), + CodexHunkLine::Added(" new_line".to_string()), + ], + is_end_of_file: false, + }], + }] + ); + } + + #[test] + fn parses_delete_file_operation() { + let patch = "*** Begin Patch\n\ + *** Delete File: obsolete.txt\n\ + *** End Patch"; + + let parsed = parse_codex_apply_patch(patch).expect("valid Delete File patch should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Delete { + path: "obsolete.txt".to_string(), + }] + ); + } + + #[test] + fn parses_update_file_with_move_to_and_changes() { + let patch = "*** Begin Patch\n\ + *** Update File: old_name.txt\n\ + *** Move to: new_name.txt\n\ + @@\n\ + -old\n\ + +new\n\ + *** End Patch"; + + let parsed = + parse_codex_apply_patch(patch).expect("valid Update File + Move to patch should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Update { + old_path: "old_name.txt".to_string(), + new_path: Some("new_name.txt".to_string()), + hunks: vec![CodexHunk { + context: None, + lines: vec![ + CodexHunkLine::Removed("old".to_string()), + CodexHunkLine::Added("new".to_string()), + ], + is_end_of_file: false, + }], + }] + ); + } + + #[test] + fn parses_pure_rename_with_no_changed_lines() { + let patch = "*** Begin Patch\n\ + *** Update File: old_name.txt\n\ + *** Move to: new_name.txt\n\ + *** End Patch"; + + let parsed = + parse_codex_apply_patch(patch).expect("a move with no changes should still parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Update { + old_path: "old_name.txt".to_string(), + new_path: Some("new_name.txt".to_string()), + hunks: Vec::new(), + }] + ); + } + + #[test] + fn parses_multiple_operations_in_one_patch() { + let patch = "*** Begin Patch\n\ + *** Add File: a.txt\n\ + +hello\n\ + *** Delete File: b.txt\n\ + *** Update File: c.txt\n\ + @@\n\ + -old\n\ + +new\n\ + *** End Patch"; + + let parsed = parse_codex_apply_patch(patch).expect("multi-operation patch should parse"); + + assert_eq!( + parsed.operations, + vec![ + CodexFileOperation::Add { + path: "a.txt".to_string(), + lines: vec!["hello".to_string()], + }, + CodexFileOperation::Delete { + path: "b.txt".to_string(), + }, + CodexFileOperation::Update { + old_path: "c.txt".to_string(), + new_path: None, + hunks: vec![CodexHunk { + context: None, + lines: vec![ + CodexHunkLine::Removed("old".to_string()), + CodexHunkLine::Added("new".to_string()), + ], + is_end_of_file: false, + }], + }, + ] + ); + } + + #[test] + fn parses_multiple_hunks_within_one_update_file() { + let patch = "*** Begin Patch\n\ + *** Update File: d.txt\n\ + @@ fn one() {\n\ + -a\n\ + +b\n\ + @@ fn two() {\n\ + -c\n\ + +d\n\ + *** End Patch"; + + let parsed = + parse_codex_apply_patch(patch).expect("multi-hunk Update File patch should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Update { + old_path: "d.txt".to_string(), + new_path: None, + hunks: vec![ + CodexHunk { + context: Some("fn one() {".to_string()), + lines: vec![ + CodexHunkLine::Removed("a".to_string()), + CodexHunkLine::Added("b".to_string()), + ], + is_end_of_file: false, + }, + CodexHunk { + context: Some("fn two() {".to_string()), + lines: vec![ + CodexHunkLine::Removed("c".to_string()), + CodexHunkLine::Added("d".to_string()), + ], + is_end_of_file: false, + }, + ], + }] + ); + } + + #[test] + fn sets_end_of_file_flag_on_trailing_marker() { + let patch = "*** Begin Patch\n\ + *** Update File: e.txt\n\ + @@\n\ + +tail line\n\ + *** End of File\n\ + *** End Patch"; + + let parsed = parse_codex_apply_patch(patch).expect("End of File marker should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Update { + old_path: "e.txt".to_string(), + new_path: None, + hunks: vec![CodexHunk { + context: None, + lines: vec![CodexHunkLine::Added("tail line".to_string())], + is_end_of_file: true, + }], + }] + ); + } + + #[test] + fn accepts_environment_id_preamble() { + let patch = "*** Begin Patch\n\ + *** Environment ID: remote\n\ + *** Add File: hello.txt\n\ + +hello\n\ + *** End Patch"; + + let parsed = parse_codex_apply_patch(patch).expect("Environment ID preamble should parse"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Add { + path: "hello.txt".to_string(), + lines: vec!["hello".to_string()], + }] + ); + } + + #[test] + fn rejects_empty_environment_id() { + let patch = "*** Begin Patch\n\ + *** Environment ID: \n\ + *** Add File: hello.txt\n\ + +hello\n\ + *** End Patch"; + + let error = parse_codex_apply_patch(patch).expect_err("empty environment id is invalid"); + + assert!(error.message.contains("environment id cannot be empty")); + } + + #[test] + fn rejects_missing_begin_marker() { + let patch = "not a patch\n*** End Patch"; + + let error = + parse_codex_apply_patch(patch).expect_err("missing Begin Patch marker is invalid"); + + assert!(error.message.contains("must start with")); + } + + #[test] + fn rejects_missing_end_marker() { + let patch = "*** Begin Patch\n*** Add File: a.txt\n+x"; + + let error = + parse_codex_apply_patch(patch).expect_err("missing End Patch marker is invalid"); + + assert!(error.message.contains("must end with")); + } + + #[test] + fn rejects_malformed_operation_line() { + let patch = "*** Begin Patch\n*** Bogus Section\n*** End Patch"; + + let error = + parse_codex_apply_patch(patch).expect_err("unrecognized operation line is invalid"); + + assert!(error + .message + .contains("Unrecognized Codex apply_patch operation line")); + } + + #[test] + fn accepts_nested_relative_path() { + let patch = "*** Begin Patch\n\ + *** Add File: src/nested/file.txt\n\ + +content\n\ + *** End Patch"; + + let parsed = + parse_codex_apply_patch(patch).expect("a nested relative path should be accepted"); + + assert_eq!( + parsed.operations, + vec![CodexFileOperation::Add { + path: "src/nested/file.txt".to_string(), + lines: vec!["content".to_string()], + }] + ); + } + + #[test] + fn rejects_absolute_path() { + let patch = "*** Begin Patch\n\ + *** Add File: /etc/passwd\n\ + +x\n\ + *** End Patch"; + + let error = parse_codex_apply_patch(patch).expect_err("absolute path is rejected"); + + assert!(error.message.contains("must not be absolute")); + } + + #[test] + fn rejects_traversal_path() { + let patch = "*** Begin Patch\n\ + *** Add File: ../../etc/passwd\n\ + +x\n\ + *** End Patch"; + + let error = parse_codex_apply_patch(patch).expect_err("traversal path is rejected"); + + assert!(error.message.contains("traversal")); + } +} diff --git a/cli/src/services/hooks/codex/bash_policy.rs b/cli/src/services/hooks/codex/bash_policy.rs index 53e259cc..e5d656c6 100644 --- a/cli/src/services/hooks/codex/bash_policy.rs +++ b/cli/src/services/hooks/codex/bash_policy.rs @@ -215,7 +215,7 @@ mod tests { }) .to_string(); - let output = super::super::run_codex_subcommand_from_payload(&repo_root, &payload) + let output = super::super::run_codex_subcommand_from_payload(&repo_root, &payload, None) .expect("Codex Bash PreToolUse dispatch should succeed"); assert_eq!(output, "", "an allowed command must be silent"); diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index f7787a23..8dcc2a56 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -8,6 +8,7 @@ use crate::services::observability::traits::Logger; use super::read_hook_stdin; +mod apply_patch; mod bash_policy; mod stop; mod user_prompt_submit; @@ -15,7 +16,9 @@ mod user_prompt_submit; const CODEX_HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; const CODEX_HOOK_EVENT_STOP: &str = "Stop"; const CODEX_HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +const CODEX_HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; const CODEX_HOOK_TOOL_BASH: &str = "Bash"; +const CODEX_HOOK_TOOL_APPLY_PATCH: &str = "apply_patch"; /// A single Codex hook lifecycle event, deserialized from the raw STDIN JSON /// payload `sce hooks codex` receives via @@ -62,6 +65,7 @@ pub(crate) enum CodexDispatchArm { UserPromptSubmit, Stop, PreToolUseBash, + PostToolUseApplyPatch, NoOp, } @@ -72,6 +76,9 @@ pub(crate) fn classify_codex_event(event: &CodexHookEvent) -> CodexDispatchArm { (CODEX_HOOK_EVENT_PRE_TOOL_USE, Some(CODEX_HOOK_TOOL_BASH)) => { CodexDispatchArm::PreToolUseBash } + (CODEX_HOOK_EVENT_POST_TOOL_USE, Some(CODEX_HOOK_TOOL_APPLY_PATCH)) => { + CodexDispatchArm::PostToolUseApplyPatch + } _ => CodexDispatchArm::NoOp, } } @@ -82,7 +89,7 @@ pub(super) fn run_codex_subcommand(repository_root: &Path, logger: Option<&dyn L Err(error) => return log_codex_fail_open(&error, logger), }; - match run_codex_subcommand_from_payload(repository_root, &stdin_payload) { + match run_codex_subcommand_from_payload(repository_root, &stdin_payload, logger) { Ok(output) => output, Err(error) => log_codex_fail_open(&error, logger), } @@ -91,6 +98,7 @@ pub(super) fn run_codex_subcommand(repository_root: &Path, logger: Option<&dyn L fn run_codex_subcommand_from_payload( repository_root: &Path, stdin_payload: &str, + logger: Option<&dyn Logger>, ) -> Result { let event: CodexHookEvent = serde_json::from_str(stdin_payload) .context("Invalid Codex hook payload from STDIN: expected valid JSON.")?; @@ -101,6 +109,9 @@ fn run_codex_subcommand_from_payload( } CodexDispatchArm::Stop => stop::handle(repository_root, &event)?, CodexDispatchArm::PreToolUseBash => bash_policy::handle(repository_root, &event)?, + CodexDispatchArm::PostToolUseApplyPatch => { + apply_patch::handle(repository_root, &event, logger)? + } CodexDispatchArm::NoOp => format!( "codex hooks: no-op for unsupported event/tool combination (hook_event_name='{}', tool_name={:?}).", event.hook_event_name, event.tool_name @@ -171,10 +182,10 @@ mod tests { } #[test] - fn classify_codex_event_routes_post_tool_use_apply_patch_to_no_op() { + fn classify_codex_event_routes_post_tool_use_apply_patch() { assert_eq!( classify_codex_event(&event("PostToolUse", Some("apply_patch"))), - CodexDispatchArm::NoOp + CodexDispatchArm::PostToolUseApplyPatch ); } @@ -214,7 +225,7 @@ mod tests { fn run_codex_subcommand_from_payload_no_ops_unsupported_combination_without_error() { let payload = r#"{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Read"}"#; - let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload) + let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload, None) .expect("no-op dispatch should succeed"); assert!(output.contains("no-op")); @@ -224,7 +235,7 @@ mod tests { fn run_codex_subcommand_from_payload_no_ops_unrecognized_hook_event_name_without_error() { let payload = r#"{"hook_event_name":"SessionStart","session_id":"s1"}"#; - let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload) + let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload, None) .expect("no-op dispatch should succeed"); assert!(output.contains("no-op")); @@ -232,7 +243,7 @@ mod tests { #[test] fn run_codex_subcommand_from_payload_rejects_non_json_stdin() { - let error = run_codex_subcommand_from_payload(Path::new("/tmp"), "not json") + let error = run_codex_subcommand_from_payload(Path::new("/tmp"), "not json", None) .expect_err("malformed payload should fail parsing"); assert!(error.to_string().contains("Invalid Codex hook payload")); diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index c3433285..e87ac7e7 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -49,7 +49,6 @@ const OPENCODE_TOOL_NAME: &str = "opencode"; const CLAUDE_TOOL_NAME: &str = "claude"; const PI_TOOL_NAME: &str = "pi"; const CODEX_TOOL_NAME: &str = "codex"; -#[allow(dead_code)] const OPENAI_MODEL_ID_PREFIX: &str = "openai/"; const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; type PayloadValidationError = fn(&str) -> String; @@ -1034,7 +1033,6 @@ fn normalize_claude_model_id(model: &str) -> Option { } } -#[allow(dead_code)] fn normalize_codex_model_id(model: &str) -> Option { let normalized = model.trim(); if normalized.is_empty() { diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl index b7d1276d..2ba1c6a5 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -58,6 +58,17 @@ hooksJson = new common.RenderedTextFile { } ] } + ], + "PostToolUse": [ + { + "matcher": "apply_patch", + "hooks": [ + { + "type": "command", + "command": "\(codexSceHookCommand)" + } + ] + } ] } } diff --git a/context/architecture.md b/context/architecture.md index a4563b5d..071d927e 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, and `PreToolUse(Bash)` to distinct dispatch arms, all three now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers, and `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Codex `apply_patch` tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` `apply_patch` registration or handling. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers; `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, normalizes Add/Update evidence into an SCE unified diff under deterministic patch-local synthetic line numbers, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — Delete-File operations and a `Move to` with no changed lines produce no evidence (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Bash-triggered filesystem mutations remain untracked for Codex. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 99c41a37..f44b4de4 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -52,7 +52,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable; `auth` is visible in those top-level help surfaces while `hooks` remains hidden Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, or `PreToolUse(Bash)` — `UserPromptSubmit` and `Stop` capture real `messages`/`parts` conversation evidence, and `PreToolUse(Bash)` delegates to the existing `evaluate_bash_command_policy` (`cli/src/services/bash_policy.rs`) unchanged and returns Codex's native `PreToolUse` deny response (`hookSpecificOutput`/`permissionDecision`/`permissionDecisionReason`, identical in shape to Claude's own) or silent allow — falling open as a no-op for every other combination (including `apply_patch`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Codex `apply_patch` tracing is not yet implemented. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `session-model` is no longer a supported hooks route. `codex` (`cli/src/services/hooks/codex/`) is Codex's own single dispatcher subcommand: it parses raw hook JSON into a typed `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, or `PostToolUse(apply_patch)` — `UserPromptSubmit` and `Stop` capture real `messages`/`parts` conversation evidence, `PreToolUse(Bash)` delegates to the existing `evaluate_bash_command_policy` (`cli/src/services/bash_policy.rs`) unchanged and returns Codex's native `PreToolUse` deny response (`hookSpecificOutput`/`permissionDecision`/`permissionDecisionReason`, identical in shape to Claude's own) or silent allow, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text (`cli/src/services/hooks/codex/apply_patch/`), normalizes Add/Update evidence into an SCE unified diff under deterministic patch-local synthetic line numbers, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty (Delete-File operations and a pure `Move to` rename never produce evidence) — falling open as a no-op for every other combination (including `PreToolUse(apply_patch)`) or malformed STDIN, unlike the other three tools which route through the shared `diff-trace`/`conversation-trace` intakes. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -92,7 +92,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout identity diagnostics, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, Pi, and Codex integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas; Codex grouping includes `.agents/skills/**` as `Skills` and `.codex/hooks.json`/`.codex/hooks/**` as `Hooks` (the latter also carrying a Codex hook trust/review reminder when unhealthy). - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PreToolUse(apply_patch)`/`PostToolUse(apply_patch)` remain stub arms; fail-open on any other combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence; `PreToolUse(apply_patch)` is unregistered and falls open as a no-op like every other unsupported combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. diff --git a/context/context-map.md b/context/context-map.md index ed17094e..fa70694e 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -75,7 +75,7 @@ Feature/domain context: - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) -- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s three dispatch arms plus fail-open `NoOp` fallthrough (including `apply_patch`, not yet implemented), idempotent `cx_` session prefixing and dormant `openai/` model-ID normalization, the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID, and the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, unregistered), idempotent `cx_` session prefixing and `openai/` model-ID normalization, the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice parsing/normalizing/persisting a `diff_traces` row for provable Add/Update evidence under deterministic patch-local synthetic line numbers) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/glossary.md b/context/glossary.md index af3d782b..decb11c1 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all three of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, and `PreToolUse(Bash)` delegates to the existing Bash policy engine (see `context/sce/codex-integration-runtime.md`). Codex `apply_patch` tracing is not yet implemented. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence (see `context/sce/codex-integration-runtime.md`). - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into three supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine — with every other event/tool combination (including `apply_patch`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into four supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row — with every other event/tool combination (including `PreToolUse(apply_patch)`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, Pi, plus Codex integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, `Pi skills`, `Pi extensions`, `Codex skills`, and `Codex hooks`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, Pi `prompts/**` and `skills/**` map to the Pi groups, and Codex's `.agents/skills/**` plus `.codex/hooks.json`/`.codex/hooks/**` map to the Codex groups (the latter also carrying a Codex hook trust/review reminder when unhealthy). Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. diff --git a/context/overview.md b/context/overview.md index b2522677..f1b376ac 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, or `PostToolUse(apply_patch)`. `UserPromptSubmit` and `Stop` now each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix (see `context/sce/codex-integration-runtime.md`); `PreToolUse(Bash)`, `PreToolUse(apply_patch)`, and `PostToolUse(apply_patch)` remain deterministic stubs returning success text, with real policy/diff-attribution behavior for those landing in later Codex-integration tasks — while every other combination, and any malformed STDIN payload, fails open as a no-op. +This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, normalizes Add/Update evidence into an SCE unified diff under deterministic patch-local synthetic line numbers, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while any malformed STDIN payload also fails open as a no-op. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, and `PreToolUse` for `Bash` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex `apply_patch` tracing is not yet implemented. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the three supported arms above or a no-op fallthrough, all three now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, and `PreToolUse(Bash)` delegates to the existing Bash policy engine. +- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index 77c08a9b..11dfeb4f 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -158,10 +158,10 @@ - For cross-service CLI dependencies exposed through the borrowed `AppContext` view, prefer shared capability/accessor traits over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors, and keep command execution generic over the narrow accessors each command needs where practical. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms now capture real conversation evidence, `PreToolUse(Bash)` now delegates to the existing Bash policy engine, the remaining two `apply_patch` arms are still stubs. `session-model` is no longer a supported hook intake path. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence; `PreToolUse(apply_patch)` remains an unregistered no-op. `session-model` is no longer a supported hook intake path. - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. -- For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `apply_patch`) to the same deterministic `NoOp` success text rather than an error. +- For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`PostToolUse(apply_patch)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `PreToolUse(apply_patch)`) to the same deterministic `NoOp` success text rather than an error. - For diff-trace attribution persistence, keep Claude model resolution event-local: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, normalize either source through the `claude/` convention, and store unresolved attribution as `NULL` in `diff_traces`. Persist `tool_version` directly. Do not restore the former `session_models` fallback or any session-level cache. - For recent structured diff-trace reconstruction, treat persisted row attribution as canonical: assign the row `model_id` to every reconstructed hunk and the tool-prefixed row `session_id` to every reconstructed touched line before combination/intersection. Never reuse the raw unprefixed Claude payload session as touched-line provenance. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate) plus the staged-diff AI-overlap evidence gate (`StagedDiffAiOverlapResult::Overlap` maps to `ai_contribution_present = true`; `NoOverlap` and `Error` both map to `false`), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 3ea53620..3d9e9a80 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -2,7 +2,9 @@ ## Change summary -Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode, Claude Code, and Pi. This extends existing behavior rather than replacing it: Codex reuses the same canonical Pkl workflow catalog, the same Rust Bash policy engine, the same conversation (`messages`/`parts`) persistence, and the same `diff_traces` → post-commit intersection → `agent_traces` pipeline every other integration already goes through. The only new runtime surface is a Codex-specific hook adapter (`sce hooks codex`) that produces normalized evidence for Codex's two output roots (`.agents/` for skills, `.codex/` for hooks) and a transient before/after snapshot mechanism for `apply_patch` attribution. No Agent Trace schema migration is introduced, and Bash-triggered filesystem mutations are explicitly out of scope for attribution in this change — Bash gets policy enforcement only, matching the current-state boundary already documented for Claude/Pi. +Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode, Claude Code, and Pi. This extends existing behavior rather than replacing it: Codex reuses the same canonical Pkl workflow catalog, the same Rust Bash policy engine, the same conversation (`messages`/`parts`) persistence, and the same `diff_traces` → post-commit intersection → `agent_traces` pipeline every other integration already goes through. The only new runtime surface is a Codex-specific hook adapter (`sce hooks codex`) that produces normalized evidence for Codex's two output roots (`.agents/` for skills, `.codex/` for hooks). + +`apply_patch` attribution was originally scoped around a transient before/after Git-index snapshot mechanism (T10–T12 below). That implementation was built, validated, and then removed from this branch before this revision; its task and acceptance-criteria text is replaced here with a no-snapshot design: `PostToolUse apply_patch` only (no `PreToolUse apply_patch` registration, no snapshots, no temporary Git indexes, no pending tool state) parses Codex's own `tool_input.command` apply_patch text, normalizes it into an SCE-supported unified diff with deterministic patch-local synthetic line numbers, and persists it as a `diff_traces` row. The existing, unmodified `intersect_patches` historical `kind`+`content` fallback is what lets that synthetic-line evidence still attribute correctly once the real commit lands at different line numbers — this plan does not touch that fallback. Bash-triggered filesystem mutations remain explicitly out of scope for attribution — Bash gets policy enforcement only, matching the current-state boundary already documented for Claude/Pi. ## Acceptance criteria @@ -12,8 +14,8 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode - Validate: run in a scratch git repo; inspect all four target trees plus `integrations.target`. - [x] AC3: Core workflows (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`) appear under `.agents/skills/`, and optional workflows (`brownfield`) obey the existing `integrations.optional_workflows` selection mechanism for Codex the same way they do for OpenCode/Claude/Pi. - Validate: `nix run .#pkl-generate -- "$(mktemp -d)"` then inspect `.agents/skills/`; `sce setup --codex --workflow brownfield --non-interactive` includes `sce-brownfield`, a run without `--workflow` does not. -- [x] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), and `PostToolUse` (`apply_patch`) — no Bash `PostToolUse` entry. - - Validate: inspect generated `.codex/hooks.json` content directly. +- [x] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash` only), and `PostToolUse` (`apply_patch`) — no `PreToolUse apply_patch` entry and no Bash `PostToolUse` entry. + - Validate: inspect generated `.codex/hooks.json` content directly; `nix run .#pkl-check-generated`. - [x] AC5: A Codex `UserPromptSubmit` event produces exactly one user `message` and one text `part` under session `cx_`. - Validate: integration test feeding a synthetic `UserPromptSubmit` payload to `sce hooks codex` and querying the repository Agent Trace DB. - [x] AC6: A Codex `Stop` event produces exactly one assistant `message` and one text `part`. @@ -26,25 +28,19 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode - Validate: integration test asserting the deny response body/shape and policy reason for a configured blocking policy. - [x] AC10: Bash filesystem mutations create no Codex `diff_trace`. - Validate: regression test running `echo generated > generated.txt` through the Codex Bash hook path and asserting zero new `diff_traces` rows. -- [x] AC11: A successful `apply_patch` produces an observed unified patch in `diff_traces` reflecting the actual before/after repository delta, not the requested patch text. - - Validate: integration test driving `PreToolUse apply_patch` then `PostToolUse apply_patch` against a scratch repo and asserting the persisted patch matches `git diff` of the real file mutation. -- [x] AC12: The persisted `diff_traces` row carries `session_id = cx_...`, `model_id = openai/...`, `tool_name = codex`, `payload_type = patch`. +- [x] AC11: A successful Codex `apply_patch` containing an Add File and/or Update File operation produces exactly one `diff_traces` row whose patch text is a valid SCE unified diff carrying only that file's added/removed lines (not unrelated Codex context), under deterministic patch-local synthetic hunk positions. + - Validate: integration test driving `PostToolUse apply_patch` with Add/Update operations against a scratch repo and asserting one inserted `diff_traces` row whose stored patch parses via `parse_patch`. +- [x] AC12: The persisted `diff_traces` row for a successful `apply_patch` carries `session_id = cx_`, `model_id = openai/` when the event reports a model, `tool_name = codex`, `tool_version = NULL`, and `payload_type = patch`. - Validate: same integration test as AC11, asserting row field values. -- [x] AC13: The same successful `apply_patch` also creates assistant patch conversation evidence (`message` + `part_type = patch`) tied to the same `cx_` session. - - Validate: same integration test as AC11, querying `messages`/`parts`. -- [x] AC14: Given a pre-existing dirty worktree change `A` before `PreToolUse apply_patch` and a Codex-authored change `B`, the resulting Codex diff evidence contains `B` but not `A`. - - Validate: integration test seeding an uncommitted dirty change before the hook sequence and asserting the persisted patch excludes it. -- [x] AC15: A `PostToolUse apply_patch` with no corresponding pending before-state logs a diagnostic, fails open, and creates no diff evidence. - - Validate: integration test invoking `PostToolUse apply_patch` without a prior `PreToolUse apply_patch` for the same correlation key. -- [x] AC16: Identical before/after repository states produce no diff trace and are treated as a successful no-op. - - Validate: integration test running the full pending → finalize sequence with no actual file change. -- [x] AC17: A commit containing a recorded Codex `apply_patch` diff_trace is attributed through the existing, unmodified `post-commit` intersection pipeline. - - Validate: integration test recording a Codex diff_trace, committing the same change, running `sce hooks post-commit`, and inspecting `post_commit_patch_intersections`. -- [x] AC18: The resulting Agent Trace identifies Codex as the tool and preserves the Codex model ID through the existing attribution machinery. - - Validate: same integration test as AC17, asserting the built `agent_traces.trace_json` contributor/tool metadata. -- [x] AC19: No Agent Trace repository schema migration is added; `diff_traces`/`agent_traces`/`messages`/`parts` and `RepositoryAgentTraceDbSpec::migrations()` remain unchanged. +- [x] AC13: A Codex `apply_patch` `Update File` with a `Move to` destination normalizes with `old_path`/`new_path` matching the source/destination paths, and persists any changed lines as evidence; a move with no changed lines persists no `diff_traces` row. + - Validate: integration tests covering a move-with-edits and a pure rename with no line changes. +- [x] AC14: Delete File operations never produce line-level diff evidence: an `apply_patch` consisting solely of Delete File operations succeeds with no `diff_traces` row, and a mixed `apply_patch` (Update + Delete + Add) persists evidence only for the Update and Add operations. + - Validate: integration tests for a delete-only payload and a mixed-operation payload, asserting `diff_traces` row presence/absence and content. +- [x] AC15: A committed Codex `apply_patch` Update whose `diff_trace` carries synthetic, patch-local line numbers is still attributed through the existing, unmodified post-commit intersection pipeline when the real committed line numbers differ, via `intersect_patches`' existing historical `kind`+`content` fallback; unrelated committed lines outside the Codex evidence do not intersect; the resulting Agent Trace identifies Codex as the tool and preserves the Codex model ID. + - Validate: integration test recording a Codex `apply_patch` `diff_trace` with deliberately offset synthetic line numbers, committing the real change at different real line numbers, running the existing `post-commit` hook flow, and inspecting `post_commit_patch_intersections` and `agent_traces.trace_json`. +- [x] AC16: No Agent Trace repository schema migration is added; `diff_traces`/`agent_traces`/`messages`/`parts` and `RepositoryAgentTraceDbSpec::migrations()` remain unchanged. - Validate: `git diff` shows no new file under `cli/migrations/agent-trace-repository/` and no changed baseline SQL. -- [x] AC20: Existing OpenCode, Claude, and Pi setup, generated assets, conversation tracing, diff tracing, policy behavior, and Agent Trace tests continue to pass. +- [x] AC17: Existing OpenCode, Claude, and Pi setup, generated assets, conversation tracing, diff tracing, policy behavior, and Agent Trace tests continue to pass. - Validate: `nix flake check`. ### Full validation @@ -57,8 +53,8 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode - `context/context-map.md`, `context/overview.md`, `context/architecture.md` — Codex named as a fourth supported integration target wherever the OpenCode/Claude/Pi target set is currently stated. - `context/cli/cli-command-surface.md` — `sce setup --codex`, `sce hooks codex` command-surface additions. - `context/cli/config-precedence-contract.md` — `integrations.target` accepting `"codex"`. -- `context/cli/default-path-catalog.md` — the new transient Codex `apply_patch` pending-state path helper. -- New `context/sce/codex-integration-runtime.md` (modeled on `context/sce/pi-extension-runtime.md`) — `cx_` session prefix, `openai/` model normalization, UserPromptSubmit/Stop mapping, Bash policy delegation, `apply_patch` before/after attribution flow, explicit Bash-mutation-tracing non-goal. +- `context/overview.md` — remove the "Codex `apply_patch` tracing is not yet implemented" sentence and describe the implemented `PostToolUse`-only pipeline instead. +- `context/sce/codex-integration-runtime.md` (modeled on `context/sce/pi-extension-runtime.md`) — `cx_` session prefix, `openai/` model normalization, UserPromptSubmit/Stop mapping, Bash policy delegation, the `PostToolUse apply_patch` parse/normalize/persist pipeline and its boundary (Add/Update produce line-level evidence, Update+Move preserves the destination path, Delete produces none, Bash mutation attribution remains unsupported, final attribution is always the existing post-commit intersection), replacing the "not yet implemented" framing. - `context/sce/doctor-human-text-contract.md` — Codex integration group/area ordering. - `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md` — note that `sce hooks codex` is a second writer into the same `diff_traces`/`messages`/`parts` tables via the existing insert helpers, with no new adapter. @@ -75,17 +71,16 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Constraints and non-goals -- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, and the durable context files listed under Context sync. -- **Out of scope:** any change to `cli/migrations/agent-trace-repository/`; any change to OpenCode/Claude/Pi's own generated behavior beyond what is mechanically required to add a fourth target to shared enums/renderers; Codex App Server or `codex exec --json` integration; MCP-tool or subagent attribution; `AGENTS.md` generation/management; a Codex slash-command compatibility layer. -- **Constraints:** reuse `cli/src/services/bash_policy.rs` for Bash policy evaluation without reimplementing matching; reuse `DiffTraceInsert`/`insert_diff_trace`, `InsertMessageInsert`/`insert_messages`, `InsertPartInsert`/`insert_parts` for persistence without a Codex-specific DB adapter; reuse `cli/src/services/patch.rs` for unified-diff parsing/`git diff` output, no second diff engine. -- **Non-goal:** Bash-created filesystem change attribution for Codex, Claude, or Pi (deferred — tracked as a known gap, not solved here); a generic cross-producer mutation tracker; any `diff_traces`/Agent Trace DB schema column for snapshot/pending state. +- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, a new Codex `apply_patch` parser/normalizer module under `cli/src/services/hooks/codex/`, and the durable context files listed under Context sync. +- **Out of scope:** any change to `cli/migrations/agent-trace-repository/`; any change to OpenCode/Claude/Pi's own generated behavior beyond what is mechanically required to add a fourth target to shared enums/renderers; Codex App Server or `codex exec --json` integration; MCP-tool or subagent attribution; `AGENTS.md` generation/management; a Codex slash-command compatibility layer; any change to `intersect_patches`/`combine_patches` in `cli/src/services/patch.rs` unless a test demonstrates the normalized Codex evidence cannot flow through the existing contract. +- **Constraints:** reuse `cli/src/services/bash_policy.rs` for Bash policy evaluation without reimplementing matching; reuse `DiffTraceInsert`/`insert_diff_trace` for persistence without a Codex-specific DB adapter; reuse `cli/src/services/patch.rs`'s existing, unmodified `parse_patch`/`intersect_patches`/`combine_patches` — the Codex apply_patch normalizer must produce text `parse_patch` already accepts, and `intersect_patches`' existing historical `kind`+`content` fallback is the sole mechanism for reconciling Codex's synthetic line numbers against real post-commit line numbers; no second diff engine. +- **Non-goal:** Bash-created filesystem change attribution for Codex, Claude, or Pi (deferred — tracked as a known gap, not solved here); a generic cross-producer mutation tracker; any `diff_traces`/Agent Trace DB schema column for snapshot/pending state; filesystem snapshots, temporary Git indexes, or pending tool state for Codex `apply_patch` (the removed design, deliberately not reintroduced); Delete-File line-level attribution for Codex `apply_patch` (no before-state snapshot exists to prove removed content, and this plan does not add one). ## Assumptions -- The Codex hook lifecycle event names and field names given in the change request (`hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; events `UserPromptSubmit`, `Stop`, `PreToolUse`, `PostToolUse`; tool identifiers `Bash`, `apply_patch`) are taken as the working contract for T06. T06 begins by checking these against current Codex CLI documentation/behavior per the change request's own instruction ("Check the current official/current Codex hook schema rather than relying on old assumptions"); if reality differs, the typed parser is adjusted to match without changing the architecture, dispatcher shape, or any acceptance criterion above (all of which are stated as SCE-side observable outcomes, not exact Codex wire-format assertions). -- Codex's `PreToolUse` deny response shape is whatever the installed Codex CLI currently expects for a blocking tool-call response; T09 confirms and reuses that shape rather than inventing one, consistent with the existing OpenCode/Pi/Claude precedent of matching each harness's native block contract (see `context/sce/pi-extension-runtime.md`, `context/sce/bash-tool-policy-enforcement-contract.md`). -- The transient `apply_patch` pending-state directory lives under the existing SCE per-user state root (`cli/src/services/default_paths.rs`), not under the repository working tree, consistent with how checkout identity (`/sce/checkout-id`) and Agent Trace DBs are already scoped outside the tracked worktree. -- "SCE state namespace" for the pending-state path is a new named accessor added to `default_paths.rs` (per repo convention: "Production CLI code should define named path accessors ... not introduce new hardcoded path owners elsewhere"), not an ad hoc path literal inside the hooks module. +- The Codex hook lifecycle event names and field names given in the change request (`hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; events `UserPromptSubmit`, `Stop`, `PreToolUse`, `PostToolUse`; tool identifiers `Bash`, `apply_patch`) are taken as the working contract. T06/T09 already checked the `Bash`-related shape against Codex CLI reality; T10 opens by checking the `apply_patch` `tool_input.command` grammar the same way against current `openai/codex` source before finalizing the parser, adjusting field/marker details to match reality without changing the architecture, dispatcher shape, or any acceptance criterion above (all stated as SCE-side observable outcomes, not exact Codex wire-format assertions). +- Codex's `PreToolUse` deny response shape (confirmed in T09) is unaffected by this revision; `apply_patch` tracing is `PostToolUse`-only and never returns a deny response, only empty stdout on success (including every fail-open branch). +- `apply_patch` tracing departs from this codebase's existing `current_unix_time_ms().unwrap_or(0)` pattern (used elsewhere in `cli/src/services/hooks/mod.rs` and by the Codex `UserPromptSubmit`/`Stop` arms) by design: a time-acquisition failure skips the `diff_traces` insert entirely (fails open) rather than substituting a fabricated epoch-zero timestamp, per the change request's explicit instruction for this one code path. ## Task stack @@ -195,7 +190,7 @@ Persist this field in every plan; this is durable plan state, not chat state: - [x] T09: `Route Codex Bash PreToolUse through the existing SCE Bash policy engine` (status:done) - Task ID: T09 - - Scope: In — the `PreToolUse(Bash)` dispatch arm delegating the command string to `cli/src/services/bash_policy.rs` unchanged; on allow, silent hook success with no model-visible output; on deny, the Codex-native `PreToolUse` deny response shape carrying the SCE policy ID/message (matching the pattern in `context/sce/bash-tool-policy-enforcement-contract.md`'s "Block behavior contract"); no `diff_traces`/snapshot/pending-state writes on either branch. Out — `apply_patch` handling (T10/T11). + - Scope: In — the `PreToolUse(Bash)` dispatch arm delegating the command string to `cli/src/services/bash_policy.rs` unchanged; on allow, silent hook success with no model-visible output; on deny, the Codex-native `PreToolUse` deny response shape carrying the SCE policy ID/message (matching the pattern in `context/sce/bash-tool-policy-enforcement-contract.md`'s "Block behavior contract"); no `diff_traces`/snapshot/pending-state writes on either branch. Out — `apply_patch` handling (T10-T12). - Dependencies: T06 - Done when: an allowed Bash command produces silent success output; a command matching a configured blocking policy produces the deny response including the policy ID and message text; a regression test runs `echo generated > generated.txt` through the Codex Bash hook path end-to-end and asserts zero new `diff_traces` rows exist afterward. - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::bash_policy'`. @@ -206,29 +201,44 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's non-conversation arm is "still a stub" / lists `PreToolUse(Bash)` as the remaining stub, both now stale; `context/sce/codex-integration-runtime.md`'s "Still-stub arms" section (currently lists `PreToolUse(Bash)` as a stub) needs a new slice describing the Bash policy-delegation behavior, mirroring `UserPromptSubmit`/`Stop`'s per-arm documentation shape, and to note all three registered arms now have real behavior; `context/sce/bash-tool-policy-enforcement-contract.md`'s "Shell Operator Parsing Extension" implementation note (which currently names only the OpenCode plugin and Claude settings/hook-helper as `sce policy bash`/`evaluate_bash_command_policy` callers) is now incomplete since Codex is a third caller with its own native deny-response shape, reached via direct in-process `evaluate_bash_command_policy` rather than the `sce policy bash` CLI adapter. - Context synchronization: synced -- [ ] T10: `Capture apply_patch before-state via temporary-index snapshot` (status:todo) +- [x] T10: `Add a Codex apply_patch grammar parser` (status:done) - Task ID: T10 - - Scope: In — a temporary-`GIT_INDEX_FILE` snapshot helper (`git read-tree HEAD` + `git add -A` + `git write-tree`) producing a `before_tree_oid` without mutating the real index; a new named path accessor in `cli/src/services/default_paths.rs` for the Codex pending-state directory (`/sce/repos//hooks/codex/pending/`); a hashed/sanitized event-key derivation from `(session_id, turn_id, tool_use_id)`; atomic pending-state file write (`{before_tree_oid, created_at_unix_ms}`) wired into the `PreToolUse(apply_patch)` dispatch arm. Out — the `PostToolUse` finalize logic (T11); no write to `agent-trace.db`. - - Dependencies: T06 - - Done when: unit tests prove the event-key derivation is deterministic for the same triple and distinct for different triples, and is safe as a filesystem path segment; an integration test runs `PreToolUse(apply_patch)` against a scratch repo with a pre-existing dirty (uncommitted) change and asserts the written pending file's `before_tree_oid` reflects the dirty worktree state (tracked changes + non-ignored untracked files) rather than `HEAD`. - - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::pre'`. - - Context synchronization: pending + - Scope: In — a new module under `cli/src/services/hooks/codex/apply_patch/` (for example `mod.rs` + `parser.rs`) defining `CodexPatch { operations: Vec }` and `CodexFileOperation::{Add { path, lines }, Update { old_path, new_path, hunks }, Delete { path }}`, and `parse_codex_apply_patch(raw: &str) -> Result` covering `*** Begin Patch` / `*** Add File:` / `*** Delete File:` / `*** Update File:` / optional `*** Move to:` / `@@` context markers / `*** End Patch`, plus an optional `*** Environment ID:` line if current upstream grammar allows it on a successful input. Before finalizing field/marker details, check this grammar against current `openai/codex` source (`codex-rs/core/src/tools/handlers/apply_patch.rs`, `codex-rs/core/src/hook_runtime.rs`, `codex-rs/apply-patch/src/parser.rs`) and record any correction under Assumptions rather than guessing. Conservative path validation rejects absolute paths and `..` traversal segments. No global string replacement. Out — normalization to SCE patch text (T11), dispatcher wiring, persistence (T12). + - Dependencies: none + - Done when: unit tests cover Add File, Update File, Delete File, Update File + Move to, multiple operations in one patch, multiple hunks within one Update File, malformed Begin/End markers, a malformed operation line, an accepted relative path, and a rejected absolute or traversal path. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch::parser'`. + - Completed: 2026-08-22 + - Files changed: `cli/src/services/hooks/codex/apply_patch/mod.rs` (new), `cli/src/services/hooks/codex/apply_patch/parser.rs` (new), `cli/src/services/hooks/codex/mod.rs` + - Result: Fetched and read `openai/codex`'s actual `codex-rs/apply-patch/src/parser.rs` directly (via `gh api repos/openai/codex/contents/...`) before finalizing the grammar, per this task's own instruction. It confirms the plan's working markers/fields exactly (`*** Begin Patch`/`*** Add File: `/`*** Delete File: `/`*** Update File: `/`*** Move to: `/`@@`/`*** End Patch`, plus an optional `*** Environment ID: ` preamble line — patch-level, appearing once before the first hunk, not per-operation, and rejected if its id is empty after trimming) — no correction to any field name, marker, architecture, dispatcher shape, or acceptance criterion was needed, so no edit to the Assumptions section was warranted (the plan's own instruction to record corrections there is conditional on a correction existing). One clarifying (non-blocking) divergence worth noting: upstream Codex's own parser accepts absolute hunk paths and resolves them later against the tool's own `cwd`; this task's own scope explicitly requires stricter behavior here — rejecting absolute paths and `..` traversal segments outright, since SCE has no equivalent downstream resolution step. Implemented `cli/src/services/hooks/codex/apply_patch/{mod.rs,parser.rs}` (nested module per the task's own example layout) defining `CodexPatch`, `CodexFileOperation::{Add,Update,Delete}`, `CodexHunk { context, lines, is_end_of_file }`, `CodexHunkLine::{Context,Added,Removed}`, and `CodexPatchParseError` (a `{ message: String }` struct with manual `Display`/`Error` impls, matching this codebase's existing `patch.rs::ParseError` convention rather than introducing `thiserror`, which is not a dependency of this crate). `parse_codex_apply_patch` is a hand-written line-oriented parser (not a grammar-library port) that validates Begin/End markers, consumes an optional Environment ID preamble, and dispatches each `*** Add/Delete/Update File:` block; Update File blocks support an optional `*** Move to:` line and zero or more `@@`-delimited hunks (a hunk with no explicit `@@` header is created implicitly for leading change lines, matching upstream's own documented leniency), reject when neither a move nor any hunk is present, and track `*** End of File` as a flag on the hunk it terminates. All five items in `apply_patch/mod.rs` and the `parse_codex_apply_patch` function carry `#[allow(dead_code)]`/`#[allow(unused_imports)]` (T01/T06 precedent for forward-declared-but-unwired code), since T11/T12 are the tasks that consume this module; `mod apply_patch;` was added to `cli/src/services/hooks/codex/mod.rs` with no dispatcher wiring, matching this task's own out-of-scope boundary. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch::parser'` — passed: 16 passed, 0 failed, covering Add File, Update File (single and multi-hunk), Delete File, Update File + Move to (with and without changed lines), multiple operations in one patch, an `*** End of File` marker, an Environment ID preamble (accepted and empty-rejected), malformed Begin/End markers, a malformed operation line, an accepted nested relative path, and a rejected absolute path and a rejected traversal path. Also ran `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` (45 passed, 0 failed — the 29 pre-existing Codex hook tests plus these 16 new ones, no regressions) and `nix flake check` (per T01/T04–T09/T13 precedent over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt` (one `cargo fmt` pass was needed after the initial write; the new files were staged with `git add` first, since the flake's source filter only picks up tracked files, per T08's precedent), `cli-generated-input`, `pkl-generated`. + - Context impact: none — a new, still-unwired parser module reachable only from its own unit tests (`mod apply_patch;` in `cli/src/services/hooks/codex/mod.rs` declares it but no dispatch arm calls it yet); no user-visible behavior, public interface, or documented architecture changed. `context/sce/codex-integration-runtime.md`'s "Still-stub arms"/pipeline description continues to state that `apply_patch` tracing is not yet implemented, which remains accurate until T12 wires the dispatcher. The mandatory root-context pass run during synchronization found and corrected an unrelated pre-existing staleness in `context/overview.md` (see Context synchronization note below). + - Context synchronization: synced -- [ ] T11: `Finalize apply_patch: after-state, observed diff, cleanup` (status:todo) +- [x] T11: `Normalize parsed Codex apply_patch operations into an SCE-supported patch` (status:done) - Task ID: T11 - - Scope: In — the `PostToolUse(apply_patch)` dispatch arm: look up the pending file by the same event-key derivation; on hit, take a second temporary-index snapshot for `after_tree_oid`, compute `git diff --binary --find-renames `; on empty diff, treat as a successful no-op; consume (remove) the pending file idempotently after processing (safe for a second/duplicate cleanup attempt); on missing or unusable pending state, log a diagnostic, fail open, and produce no diff evidence (no guessing from the raw patch command). Out — DB persistence of the resulting non-empty patch (T12). + - Scope: In — a normalizer consuming T10's `CodexPatch` and producing SCE `Index:`-form unified-diff text per file operation: Add File → an added-file hunk (`-0,0`/`+1,N`) carrying the full added content; Update File → only the touched `-`/`+` lines (Codex's unchanged context is dropped, not persisted as evidence) under deterministic patch-local synthetic hunk positions, never claimed as real filesystem line numbers, preserving removed/added-line order and multiplicity; Update File + `Move to` → `old_path`/`new_path` set from the source/destination, changed lines persisted as evidence when present, no hunk emitted for a move with no changed lines; Delete File → recognized but emits no hunk/line evidence, never synthesizing removed content; an `apply_patch` consisting solely of Delete File operations normalizes to an empty result; a mixed `apply_patch` keeps only the Add/Update evidence and drops Delete. Out — dispatcher wiring, DB persistence (T12). - Dependencies: T10 - - Done when: integration tests cover file creation, file edit, file deletion, and (if the underlying delta supports it) rename, each producing the expected `git diff` shape from the helper; a test covers a `PostToolUse` call with no matching pending file (logs + fails open, no evidence); a test covers a malformed/unreadable pending-state file (same fail-open behavior); a test covers before==after (no-op, pending file still consumed); a test proves a pre-existing dirty change present at `PreToolUse` time (change `A`) is excluded from the finalize-time diff when only change `B` was made by the tool call in between. - - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::post'`. - - Context synchronization: pending + - Done when: every non-empty normalized result parses successfully via `parse_patch(normalized, Some("cx_test"))`; unit tests cover Add File, Update File, Update+Move (with and without changed lines), Delete-only (empty result), and mixed Update/Delete/Add (only provable evidence survives); a dedicated test builds a normalized Codex Update patch with synthetic line numbers and a distinct realistic post-commit `ParsedPatch` where the same touched lines sit at different real line numbers, and asserts the existing, unmodified `intersect_patches` still matches them through its historical `kind`+`content` fallback while an unrelated committed line does not intersect. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch::normalize'`. + - Completed: 2026-08-22 + - Files changed: `cli/src/services/hooks/codex/apply_patch/mod.rs`, `cli/src/services/hooks/codex/apply_patch/normalize.rs` (new) + - Result: Added `cli/src/services/hooks/codex/apply_patch/normalize.rs` implementing `normalize_codex_patch(patch: &CodexPatch) -> String`, which maps each `CodexFileOperation` to zero or one SCE `Index:`-form file section and concatenates the non-empty ones in operation order (Delete always contributes nothing). Add File emits a single `-0,0`/`+1,N` hunk carrying every added line. Update File walks each `CodexHunk`'s own line sequence with two running patch-local counters (`old_pos`/`new_pos`, both starting at 1 per file): `Context` lines are skipped entirely with no positional effect (matching this task's own "dropped, not persisted as evidence" scope — deliberately not given positional weight, since giving it weight would desync the emitted body's line numbers from what `parse_patch` recomputes on reparse, as an early draft's failing tests demonstrated), `Removed`/`Added` lines are appended to the hunk body and advance their respective counter; a hunk that ends up with zero removed/added lines (context-only) contributes no `@@` header. Update+`Move to` renders `Index:`/`+++` under the destination path and `---` under the source path (so `intersect_patches`' post-change-path file matching keys on the real post-commit path), and returns `None` (no file section at all) when it has no changed lines, matching AC13's "no `diff_traces` row" requirement one level up in T12. `render_file_section`/`PATCH_INDEX_SEPARATOR` reuse the exact `Index: {path}\n===...\n--- {path}\n+++ {path}\n` convention already used elsewhere in this codebase's own test fixtures (`hooks/mod.rs`, `agent_trace_db/mod.rs`) rather than inventing a new one. Wired `mod normalize;` and a `#[allow(dead_code)] pub(crate) use normalize::normalize_codex_patch;` into `apply_patch/mod.rs` (still unreachable outside its own tests until T12 wires dispatcher routing, per this task's own out-of-scope boundary, matching T10's precedent for forward-declared-but-unwired modules). + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch::normalize'` — passed: 8 passed, 0 failed, covering Add File, Update File (context-dropping), Update+Move (with and without changed lines), Delete-only (empty result), mixed Update/Delete/Add, multiple hunks advancing positions cumulatively, and the dedicated `intersect_patches` historical-fallback test (synthetic Codex positions at line 1 vs. a distinct realistic post-commit patch with the same touched lines at line 42, plus one unrelated committed line at line 43 that does not intersect — confirmed absent from the result). Also ran `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch'` (24 passed, 0 failed — the 16 pre-existing T10 parser tests plus these 8 new ones, no regressions) and `nix flake check` (per T01/T04–T10/T13 precedent over raw `cargo test`) — passed: "all checks passed!", covering `cli-tests`, `cli-clippy` (one fix needed: `push_str(&format!(...))` triggered `clippy::format_push_string` under this crate's `-D clippy::pedantic`; switched to `write!`/`writeln!` via `std::fmt::Write`), `cli-fmt` (one `cargo fmt` pass needed after the initial write), `cli-generated-input`, `pkl-generated`. The new/modified files were staged with `git add` first, since the flake's source filter only picks up tracked files, per T08/T10 precedent. + - Context impact: none — a new, still-unwired normalizer module reachable only from its own unit tests (`apply_patch/mod.rs` re-exports `normalize_codex_patch` but no dispatch arm calls it yet, matching T10's precedent); no user-visible behavior, public interface, or documented architecture changed. `context/sce/codex-integration-runtime.md`'s "Still-stub arms"/pipeline description continues to state that `apply_patch` tracing is not yet implemented, which remains accurate until T12 wires the dispatcher and persistence. + - Context synchronization: synced -- [ ] T12: `Persist Codex apply_patch diff evidence, patch conversation, and prove post-commit reuse` (status:todo) +- [x] T12: `Wire PostToolUse apply_patch tracing end to end` (status:done) - Task ID: T12 - - Scope: In — for a non-empty finalize-time delta from T11: `DiffTraceInsert` with `time_ms` (finalization time), `session_id = cx_`, `patch` (the observed diff), `model_id = openai/` (via T01's normalizer), `tool_name = "codex"`, `tool_version = NULL`, `payload_type = "patch"`, persisted through the existing `insert_diff_trace()`; one assistant patch message/part (`message_id = cx:::patch`, `role = assistant`, `part_type = patch`) via the existing `InsertMessageInsert`/`InsertPartInsert` path, tied to the same `cx_` session; an integration test proving the existing, unmodified `post-commit` intersection pipeline (`recent_diff_trace_patches` → `combine_patches` → `intersect_patches` → `build_agent_trace`) attributes a committed Codex change correctly and preserves `tool_name="codex"`/the Codex model ID in the resulting `agent_traces.trace_json`. Out — any new persistence path, any Codex-specific DB adapter, any post-commit code change. - - Dependencies: T11, T01 - - Done when: the diff evidence and conversation evidence tests above pass; the post-commit integration test (recording a Codex diff_trace, committing the same change, running `sce hooks post-commit`, then inspecting the persisted `agent_traces` row) passes with no modification to `cli/src/services/hooks/mod.rs`'s existing post-commit flow functions beyond what T06–T11 already required. - - Verify: `nix develop -c sh -c 'cd cli && cargo test hooks::codex::apply_patch::persist hooks::post_commit'`. - - Context synchronization: pending + - Scope: In — `config/pkl/renderers/codex-content.pkl`: add a `PostToolUse` block with `matcher: "apply_patch"` routed through the same single `sce hooks codex` command, with no `PreToolUse apply_patch` entry; regenerate via the normal `nix run .#pkl-generate` / `nix run .#pkl-check-generated` workflow. `cli/src/services/hooks/codex/mod.rs`: `CODEX_HOOK_EVENT_POST_TOOL_USE`, `CODEX_HOOK_TOOL_APPLY_PATCH` constants, a `CodexDispatchArm::PostToolUseApplyPatch` variant, and a `("PostToolUse", Some("apply_patch")) => PostToolUseApplyPatch` classification arm routed to a new `apply_patch::handle(repository_root, &event)`. The handler reads `tool_input.command` (fails open with no evidence and empty stdout when absent or non-string), parses it with T10's parser (fails open, logged, no evidence on parse failure), and normalizes it with T11; when the normalized patch is non-empty, it opens the repository Agent Trace DB the same way the other Codex arms do and inserts one `DiffTraceInsert` row (`session_id = cx_`, `patch = `, `model_id = normalize_codex_model_id(event.model)` when a model is present, `tool_name = "codex"`, `tool_version = None`, `payload_type = PAYLOAD_TYPE_PATCH`) via the existing `insert_diff_trace`; the timestamp comes from `current_unix_time_ms()`, and a failure there skips the insert (fails open) rather than substituting an epoch-zero fallback. An empty normalized patch (delete-only or no operations) is a successful no-op with no insert. Every success path, including every fail-open branch, returns empty stdout. Out — any change to `intersect_patches`/`combine_patches`/the post-commit hook flow itself; Bash or MCP mutation tracing; a new conversation `message`/`part` for `apply_patch`. + - Dependencies: T10, T11 + - Done when: routing unit tests prove `("PostToolUse", Some("apply_patch"))` classifies to `PostToolUseApplyPatch`, `("PreToolUse", Some("apply_patch"))` still classifies to `NoOp`, and `("PostToolUse", Some("Bash"))` still classifies to `NoOp`; a generated-config check confirms `.codex/hooks.json` has a `PostToolUse` entry matching `apply_patch` and no `PreToolUse` entry matching `apply_patch`; payload-extraction tests prove `tool_input.command` is read and that a missing/non-string command fails open with no evidence; persistence tests prove one successful non-empty `apply_patch` produces exactly one `diff_traces` row with the expected `payload_type`/`tool_name`/`cx_`-prefixed `session_id`, a malformed patch produces no row, and a delete-only patch produces no row; an integration test records a Codex Update `apply_patch` diff_trace with synthetic line numbers, commits the real change at different real line numbers, runs the existing unmodified `post-commit` hook flow, and asserts the resulting `agent_traces.trace_json` attributes the change with `tool.name == "codex"` and the Codex model ID. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'`; `nix run .#pkl-check-generated`; `nix flake check`. + - Completed: 2026-08-22 + - Files changed: `config/pkl/renderers/codex-content.pkl`, `cli/src/services/hooks/codex/mod.rs`, `cli/src/services/hooks/codex/apply_patch/mod.rs`, `cli/src/services/hooks/codex/bash_policy.rs`, `cli/src/services/hooks/mod.rs` + - Result: Added a `PostToolUse` block (`matcher: "apply_patch"`, same `sce hooks codex` command) to `codex-content.pkl`'s `hooksJson`, with no `PreToolUse apply_patch` entry — confirmed by direct inspection of a fresh `nix run .#pkl-generate` temp-dir `.codex/hooks.json` (exactly `UserPromptSubmit`, `Stop`, `PreToolUse` `Bash`, `PostToolUse` `apply_patch`). Added `CODEX_HOOK_EVENT_POST_TOOL_USE`/`CODEX_HOOK_TOOL_APPLY_PATCH` constants and a `CodexDispatchArm::PostToolUseApplyPatch` classification arm in `codex/mod.rs`, routed to a new `apply_patch::handle`. Threaded `logger: Option<&dyn Logger>` through `run_codex_subcommand`/`run_codex_subcommand_from_payload` into `apply_patch::handle` — the only Codex arm needing in-arm logging, since this task's own scope requires a parse failure to log and still return empty stdout, unlike the top-level fail-open path (which logs but returns a non-empty diagnostic string); every other existing call site of `run_codex_subcommand_from_payload` (in `codex/mod.rs`'s and `bash_policy.rs`'s own tests) was updated to pass `None`. `apply_patch::handle` reads `tool_input.command` (`apply_patch_command_from_event`, mirroring `bash_policy.rs`'s `bash_command_from_event`), parses via T10's `parse_codex_apply_patch` (logging and returning `Ok(String::new())` on failure), normalizes via T11's `normalize_codex_patch`, and — for a non-empty result — reads `current_unix_time_ms()` (fails open with no insert on error, per the plan's own Assumptions departure from the `unwrap_or(0)` pattern) and delegates to a new injectable `persist_with(db, event, normalized_patch, time_ms)` that builds `session_id = cx_` via the existing `prefixed_diff_trace_session_id`, `model_id = normalize_codex_model_id(event.model)`, and calls `insert_diff_trace` with `tool_name = "codex"`, `tool_version = None`, `payload_type = PAYLOAD_TYPE_PATCH` — no new persistence adapter. `persist_with` mirrors `user_prompt_submit.rs`/`stop.rs`'s own `capture_with` injectable-testing pattern (open real DB only in `handle`; test the persistence logic directly against a `RepositoryAgentTraceDb::new_at` test DB) rather than driving the full dispatcher against a scratch git repo for persistence-content assertions: `resolve_agent_trace_storage_for_hook_runtime` (behind `open_agent_trace_db_for_hook_runtime`) never runs migrations and requires a prior `sce setup` against the real canonical (XDG) state root, making it unsuitable for ad-hoc scratch-repo persistence tests — the same constraint T07/T08 already documented for choosing this pattern. Removed the now-stale `#[allow(dead_code)]` on `OPENAI_MODEL_ID_PREFIX`/`normalize_codex_model_id` in `hooks/mod.rs` since this task is what first calls them. The AC15 pipeline test persists a Codex Update `apply_patch` diff_trace with synthetic line 1 positions, reconstructs it via `db.recent_diff_trace_patches`, and calls `build_agent_trace` (the same function the real `post-commit` hook flow calls, per `run_post_commit_agent_trace_flow_with`) against a hand-built post-commit `ParsedPatch` with the same touched lines at real line 42 plus one unrelated line at 43 — mirroring this codebase's own existing `claude_model_attribution_flows_from_persisted_structured_row_to_agent_trace` precedent test in `agent_trace_db/mod.rs` rather than driving an actual `git commit` + `run_post_commit_subcommand`, since `build_agent_trace` is the exact unmodified function that flow calls and this avoids re-deriving git-commit plumbing this codebase's existing tests don't otherwise exercise directly. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 63 passed, 0 failed (29 pre-existing plus 34 new/updated: routing arm update, dispatch-signature test-site updates, and 15 new `apply_patch::tests` covering fail-open/no-op behaviors, AC11/AC12 field-value persistence, AC13 move-with-edits, AC14 mixed-operations evidence filtering, and the AC15 `build_agent_trace` pipeline test). `nix run .#pkl-check-generated` — passed: "Ephemeral Pkl generation passed: 135 files" (file count and inventory hash unchanged from T03, since this task only added JSON content to an already-counted generated file, not a new artifact path); manual inspection of the generated `.codex/hooks.json` confirmed the exact four registrations. `nix flake check` — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt` (one `cargo fmt` pass needed after the initial write), `cli-generated-input`, `pkl-generated`. + - Context impact: root — `context/cli/cli-command-surface.md` states, in two places, that the Codex dispatcher's remaining arm is "still a stub", now stale since `PostToolUse(apply_patch)` is real capture behavior and every registered Codex arm now has real behavior; `context/overview.md`'s "Codex `apply_patch` tracing is not yet implemented" sentence (already named under this plan's own Context sync list, item 4) is now false and must describe the implemented `PostToolUse`-only pipeline instead; `context/sce/codex-integration-runtime.md` (named under this plan's Context sync list, item 5) still frames `apply_patch` as not-yet-implemented in its "Still-stub arms" section and needs the full pipeline description (parse/normalize/persist boundary, Add/Update evidence, Move preserving destination path, Delete producing none, Bash mutation attribution remaining unsupported, final attribution via the existing post-commit intersection) this plan's change summary already specifies; `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md` (Context sync list, item 6) should now note `sce hooks codex` `apply_patch` as a concrete second `diff_traces` writer path, not just conversation evidence. + - Context synchronization: synced - [x] T13: `Add Codex doctor coverage` (status:done) - Task ID: T13 @@ -245,7 +255,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Open questions -- The exact current Codex CLI hook JSON schema (event names, field names, tool-call identifiers, and the native `PreToolUse` deny response shape) cannot be verified from this repository — Codex CLI is an external, evolving tool. T06 and T09 open by checking the change request's assumed schema against current Codex CLI behavior/documentation before finalizing the parser and deny-response builder; this is recorded as an assumption above rather than a blocking question because no acceptance criterion in this plan depends on the exact wire format — every AC is stated as an SCE-side observable outcome (DB rows, generated files, policy behavior) that holds regardless of the precise Codex JSON shape. +- The exact current Codex CLI hook JSON schema (event names, field names, tool-call identifiers, the native `PreToolUse` deny response shape, and the `apply_patch` `tool_input.command` grammar) cannot be verified from this repository — Codex CLI is an external, evolving tool. T06/T09 already checked the `Bash`-related shape; T10 opens by checking the `apply_patch` grammar the same way before finalizing the parser. This is recorded as an assumption above rather than a blocking question because no acceptance criterion in this plan depends on the exact wire format — every AC is stated as an SCE-side observable outcome (DB rows, generated files, policy behavior) that holds regardless of the precise Codex JSON shape. ## Validation Report @@ -254,38 +264,34 @@ Persist this field in every plan; this is durable plan state, not chat state: ### Commands run -- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 135 files, inventory sha256 0cc33ae43f128634271391515e011cf4961f50c0ec17069106ac0317c8a89799) -- `nix flake check` -> exit 0 (all checks passed! — `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`, plus the remaining registered checks) -- `./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml --bin sce` -> exit 0 -- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::` -> exit 0 (79 passed; 0 failed) -- manual `sce setup --codex --non-interactive` in a scratch git repo (with an `origin` remote for repository identity) -> exit 0 -- manual `sce setup --all --non-interactive` in a scratch git repo -> exit 0 -- manual `nix run .#pkl-generate -- "$(mktemp -d)"` -> exit 0 -- manual `sce setup --codex --workflow brownfield --non-interactive` vs. `sce setup --codex --non-interactive` in separate scratch repos -> exit 0 each -- `git status --porcelain --untracked-files=all -- cli/migrations/agent-trace-repository/` -> exit 0 (no output — no changes) +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 135 files, inventory sha256 8be0ee0f495048f048317d2bd9d8e0ebc11120e12eba16e472dc7ed0b929a033) +- `nix flake check` -> exit 0 (all checks passed!: cli-tests, cli-clippy, cli-fmt, cli-generated-input, pkl-generated, npm-bun-tests, npm-biome-check, npm-biome-format, config-lib-bun-tests, config-lib-biome-check, config-lib-biome-format, workflow-actionlint, native-portability-audit, flatpak-static-validation, cargo-sources-parity, flatpak-manifest-parity) +- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex` -> exit 0 (63 passed, 0 failed, 0 ignored — covers all `hooks::codex::*` unit/integration tests including `apply_patch`, `bash_policy`, `stop`, `user_prompt_submit`) +- `nix run .#pkl-generate -- ` + manual inspection of `.agents/skills/` and `.codex/hooks.json` -> exit 0; `.agents/skills/` contains the five core skills plus `sce-brownfield`/`sce-decision`; `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`), `PostToolUse` (`apply_patch`), no `PreToolUse apply_patch`, no `PostToolUse Bash`, no `$schema` +- `sce setup --codex --non-interactive` in a scratch git repo (fake `origin` remote) -> exit 0; installed `.agents/skills/**` (6 core skills, no `sce-brownfield`) and `.codex/hooks.json` + `.codex/hooks/run-sce-or-show-install-guidance.sh`; `.sce/config.json` recorded `{"integrations": {"optional_workflows": [], "target": ["codex"]}}` +- `sce setup --all --non-interactive` in a scratch git repo -> exit 0; reported "Selected target(s): OpenCode, Claude, Pi, Codex" and installed all four target trees (`.opencode` 35, `.claude` 31, `.pi` 30, `.agents` 24 + `.codex` 2) with no regression to the other three; `.sce/config.json` recorded `"target": ["opencode", "claude", "pi", "codex"]` +- `sce setup --codex --workflow brownfield --non-interactive` vs `sce setup --codex --non-interactive` in separate scratch repos -> exit 0 each; `sce-brownfield` present only in the `--workflow brownfield` run's `.agents/skills/` +- `git diff --stat -- cli/migrations/agent-trace-repository/` -> empty (no changes); `git status --short -- cli/migrations/agent-trace-repository/` -> empty; directory contains only the two pre-existing baseline files ### Success-criteria verification -- [x] AC1: `sce setup --codex --non-interactive` succeeds, installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**`, persists `{"integrations": {"target": ["codex"]}}` -> confirmed by direct inspection of a scratch repo: `.sce/config.json` contains `"integrations": {"optional_workflows": [], "target": ["codex"]}`; `.agents/skills/` holds the six core skill directories; `.codex/hooks.json` and `.codex/hooks/run-sce-or-show-install-guidance.sh` present. -- [x] AC2: `sce setup --all --non-interactive` installs Codex alongside OpenCode/Claude/Pi with no regression -> confirmed: scratch repo shows `.opencode` (35 files), `.claude` (31 files), `.pi` (30 files), plus Codex's dual-root assets at repo root; `.sce/config.json` records `"target": ["opencode", "claude", "pi", "codex"]`. -- [x] AC3: core workflows under `.agents/skills/`; `brownfield` obeys `integrations.optional_workflows` selection -> confirmed: `nix run .#pkl-generate -- "$(mktemp -d)"` produced all six core skills plus `sce-brownfield`/`sce-decision` in the full generation root; a scratch `sce setup --codex --workflow brownfield --non-interactive` installed `sce-brownfield` alongside the six core skills, while a scratch `sce setup --codex --non-interactive` (no `--workflow`) installed only the six core skills. -- [x] AC4: `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`, `apply_patch`), `PostToolUse` (`apply_patch`), no Bash `PostToolUse` -> confirmed by direct content inspection of the generated file in the AC1 scratch repo. -- [x] AC5: Codex `UserPromptSubmit` produces one user message + one text part under `cx_` -> `hooks::codex::user_prompt_submit::tests::capture_with_produces_one_message_and_one_part_under_the_prefixed_session` passed. -- [x] AC6: Codex `Stop` produces one assistant message + one text part -> `hooks::codex::stop::tests::capture_with_produces_one_message_and_one_part_under_the_prefixed_session` passed. -- [x] AC7: reprocessing does not duplicate the parent message -> `hooks::codex::user_prompt_submit::tests::capture_with_does_not_duplicate_the_parent_message_on_reprocess` and `hooks::codex::stop::tests::capture_with_does_not_duplicate_the_parent_message_on_reprocess` passed. -- [x] AC8: allowed Bash command produces no model-visible output -> `hooks::codex::bash_policy::tests::render_bash_policy_response_is_silent_for_an_allowed_command` passed. -- [x] AC9: denied Bash command uses Codex-native deny shape with policy reason text -> `hooks::codex::bash_policy::tests::render_bash_policy_response_denies_with_codex_native_shape_for_a_blocked_command` passed. -- [x] AC10: Bash filesystem mutations create no Codex `diff_trace` -> `hooks::codex::bash_policy::tests::codex_bash_pre_tool_use_path_creates_no_diff_trace_for_a_filesystem_mutation_command` passed. -- [x] AC11: successful `apply_patch` produces an observed unified patch reflecting the real before/after delta -> `hooks::codex::apply_patch::post::tests::finalize_with_produces_a_diff_for_a_created_file` / `..._for_an_edited_file` / `..._for_a_deleted_file` / `..._for_a_rename` all passed. -- [x] AC12: persisted `diff_traces` row carries `session_id = cx_...`, `model_id = openai/...`, `tool_name = codex`, `payload_type = patch` -> `hooks::codex::apply_patch::persist::tests::persist_with_inserts_one_diff_trace_row_with_expected_fields` passed. -- [x] AC13: same `apply_patch` also creates assistant patch conversation evidence tied to the same `cx_` session -> `hooks::codex::apply_patch::persist::tests::persist_with_inserts_one_assistant_patch_message_and_part` passed. -- [x] AC14: pre-existing dirty change `A` excluded, Codex-authored change `B` included -> `hooks::codex::apply_patch::post::tests::finalize_with_excludes_a_pre_existing_dirty_change_from_the_observed_diff` passed. -- [x] AC15: `PostToolUse apply_patch` with no pending before-state fails open, no diff evidence -> `hooks::codex::apply_patch::post::tests::finalize_with_fails_open_when_no_pending_file_exists` and `..._fails_open_and_removes_a_malformed_pending_file` passed. -- [x] AC16: identical before/after states produce no diff trace, treated as successful no-op -> `hooks::codex::apply_patch::post::tests::finalize_with_treats_identical_before_and_after_as_a_no_op_and_still_consumes_pending_file` passed. -- [x] AC17: committed Codex `apply_patch` diff_trace attributed through the unmodified post-commit intersection pipeline -> `hooks::tests::codex_diff_trace_is_attributed_through_the_post_commit_pipeline` passed. -- [x] AC18: resulting Agent Trace identifies Codex as tool and preserves the Codex model ID -> same `hooks::tests::codex_diff_trace_is_attributed_through_the_post_commit_pipeline` test asserts `tool.name == Some("codex")` and `contributor.model_id == "openai/gpt-5.6-codex"`. -- [x] AC19: no Agent Trace repository schema migration added -> `git status --porcelain --untracked-files=all -- cli/migrations/agent-trace-repository/` produced no output (no changed or added files). -- [x] AC20: existing OpenCode/Claude/Pi setup, generated assets, tracing, policy, and Agent Trace tests continue to pass -> `nix flake check` passed in full (`cli-tests` includes all pre-existing OpenCode/Claude/Pi test modules, unmodified). +- [x] AC1: `sce setup --codex --non-interactive` installs both output roots and persists `integrations.target` -> verified directly in a scratch git repo (see Commands run) +- [x] AC2: `sce setup --all --non-interactive` installs Codex alongside the other three targets with no regression -> verified directly in a scratch git repo +- [x] AC3: core workflows under `.agents/skills/`, `brownfield` obeys `--workflow` selection -> verified via `pkl-generate` inspection and paired scratch-repo `setup` runs with/without `--workflow brownfield` +- [x] AC4: `.codex/hooks.json` registers exactly the four documented lifecycle entries -> verified via direct `pkl-generate` output inspection +- [x] AC5: `UserPromptSubmit` produces one message + one part under `cx_` -> verified via `hooks::codex::user_prompt_submit::tests` (63-test run) +- [x] AC6: `Stop` produces one message + one part -> verified via `hooks::codex::stop::tests` +- [x] AC7: reprocessing does not duplicate the parent message -> verified via `capture_with_does_not_duplicate_the_parent_message_on_reprocess` (both arms) +- [x] AC8: allowed Bash command is silent -> verified via `hooks::codex::bash_policy::tests` +- [x] AC9: denied Bash command uses Codex-native deny shape with policy reason -> verified via `hooks::codex::bash_policy::tests` +- [x] AC10: Bash mutations create no `diff_trace` -> verified via `codex_bash_pre_tool_use_path_creates_no_diff_trace_for_a_filesystem_mutation_command` +- [x] AC11: successful `apply_patch` (Add/Update) produces one valid `diff_traces` row -> verified via `apply_patch_persists_one_row_with_expected_field_values_for_add_and_update` +- [x] AC12: persisted row carries expected `session_id`/`model_id`/`tool_name`/`tool_version`/`payload_type` -> verified via the same test asserting field values +- [x] AC13: `Update File` + `Move to` normalizes `old_path`/`new_path`, no row for a changeless move -> verified via `apply_patch_move_with_edits_persists_row_with_expected_paths` plus the paired no-changed-lines case +- [x] AC14: Delete-only produces no row; mixed Update+Delete+Add persists only Update/Add evidence -> verified via `apply_patch_mixed_operations_persists_only_add_and_update_evidence` plus the delete-only case +- [x] AC15: synthetic-line `diff_trace` still attributes through the unmodified post-commit intersection pipeline -> verified via `apply_patch_diff_trace_attributes_through_agent_trace_pipeline_at_different_real_lines` +- [x] AC16: no Agent Trace schema migration added -> verified via empty `git diff`/`git status` on `cli/migrations/agent-trace-repository/` +- [x] AC17: existing OpenCode/Claude/Pi behavior and tests continue to pass -> verified via `nix flake check` passing in full (no regressions) ### Failed checks and follow-ups @@ -293,4 +299,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ### Residual risks -- The Codex hook JSON schema (event/field names, native `PreToolUse` deny shape) was confirmed against a real GitHub-hosted Codex payload example at implementation time (T06/T09), but Codex CLI is an external, evolving tool; a future schema change could require parser/response-shape adjustments. This is a pre-existing, plan-documented open question, not a validation gap — every AC here is an SCE-side observable outcome independent of the exact wire format. +- The Codex hook JSON schema (event/field names, `apply_patch` grammar, deny-response shape) is an external, evolving contract verified against `openai/codex` source at implementation time (T06/T09/T10); a future upstream change could silently desync the SCE-side parser from real Codex output. Already recorded as a plan assumption/open question, not a defect. +- None else identified. + + diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index bbefe995..c551733d 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -202,7 +202,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow `sce hooks session-model` is no longer a supported command route, generated Claude settings no longer produce `SessionStart` model-attribution events, and the Agent Trace DB adapter no longer exposes a `session_models` API or fresh-schema table. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). -`sce hooks codex`'s `UserPromptSubmit` and `Stop` arms are each a second, independent writer into `messages` and `parts`, reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup — not a new adapter. They store `cx_`-prefixed session IDs and a deterministic `cx::user`/`cx::assistant` message ID rather than a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md). +`sce hooks codex`'s `UserPromptSubmit` and `Stop` arms are each a second, independent writer into `messages` and `parts`, reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup — not a new adapter. They store `cx_`-prefixed session IDs and a deterministic `cx::user`/`cx::assistant` message ID rather than a generated UUID. Its `PostToolUse(apply_patch)` arm is likewise a second, independent writer into `diff_traces`, reusing `insert_diff_trace` with `tool_name = "codex"`, `tool_version = NULL`, and `payload_type = "patch"` — not a new adapter, and no schema migration. See [codex-integration-runtime.md](codex-integration-runtime.md). ## Recent patch reads diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 9e4d72d1..a98fa77c 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -113,7 +113,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 085f8f18..61277e4f 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -15,12 +15,11 @@ for how the other three tools intake conversation/diff evidence. `prompt`, `last_assistant_message`; only `hook_event_name` is required, matching the working contract in `context/plans/codex-cli-integration.md`). - `classify_codex_event` matches `(hook_event_name, tool_name)` into one of - three dispatch arms — `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)` — with - every other combination (`apply_patch` under `PreToolUse`/`PostToolUse`, + four dispatch arms — `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, + `PostToolUse(apply_patch)` — with every other combination (`apply_patch` + under `PreToolUse` — no such registration exists in `.codex/hooks.json` — unknown tool, `Bash` under `PostToolUse`, unrecognized `hook_event_name`) - falling through to a deterministic `NoOp` success. Codex `apply_patch` - tracing is not yet implemented — there is no `PreToolUse`/`PostToolUse` - `apply_patch` registration or dispatch arm. + falling through to a deterministic `NoOp` success. - Malformed/non-JSON STDIN is logged through `sce.hooks.codex.error` and the command still returns hook success (fails open), matching the other hook intakes' producer-facing failure posture. @@ -31,17 +30,17 @@ for how the other three tools intake conversation/diff evidence. (`cli/src/services/hooks/mod.rs`) carry a `"codex" -> cx_` arm alongside `oc_`/`cc_`/`pi_`, idempotent for an already-prefixed session ID. - `normalize_codex_model_id` idempotently prefixes a raw Codex model ID with - `openai/`, mirroring `normalize_claude_model_id`. It is not yet called from - any dispatch arm; its first consumer lands with a later Codex-integration - task. + `openai/`, mirroring `normalize_claude_model_id`. `PostToolUse(apply_patch)` + calls it to derive a `diff_traces.model_id` value when the event reports a + model. ## Implemented slices: `UserPromptSubmit` and `Stop` capture `cli/src/services/hooks/codex/user_prompt_submit.rs` and `cli/src/services/hooks/codex/stop.rs` implement the `UserPromptSubmit` and `Stop` arms — conversation-capture dispatch arms with real behavior (see -"`PreToolUse(Bash)` policy delegation" below for the third). Both follow the -same shape: +"`PreToolUse(Bash)` policy delegation" and "`PostToolUse(apply_patch)` diff +capture" below for the other two). Both follow the same shape: - `UserPromptSubmit` requires non-empty `session_id`, `turn_id`, and `prompt`; `Stop` requires non-empty `session_id`, `turn_id`, and @@ -92,12 +91,55 @@ pending-state file; Bash-triggered filesystem mutations remain untracked for Codex (see "Explicit non-goals" in [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). +## `PostToolUse(apply_patch)` diff capture + +`cli/src/services/hooks/codex/apply_patch/` implements the +`PostToolUse(apply_patch)` arm: `parser.rs` parses Codex's own `apply_patch` +text format (`*** Begin Patch` ... `*** End Patch`, with `Add File`/`Delete +File`/`Update File` operations and an optional `Update File` + `Move to`) +into a typed `CodexPatch`; `normalize.rs` normalizes it into SCE `Index:`-form +unified-diff text `crate::services::patch::parse_patch` already accepts; +`mod.rs`'s `handle` wires the two together and persists the result: + +- Reads the raw patch text from `tool_input.command` (a working assumption + mirroring `PreToolUse(Bash)`'s own `tool_input.command` shape); a missing or + non-string `command` fails open with no evidence. +- A parse failure is logged (`sce.hooks.codex.apply_patch.parse_failed`) and + fails open with no evidence — never a deny response, since `apply_patch` + tracing is `PostToolUse`-only. +- Normalization keeps only the touched (`+`/`-`) lines of each `Add`/`Update` + operation under deterministic, patch-local synthetic line numbers (starting + at 1 per file) — Codex's own unchanged context lines are dropped entirely, + never persisted or claimed as real filesystem positions. `Delete File` + operations, and an `Update File` + `Move to` with no changed lines, produce + no evidence; a wholly-empty normalized result (e.g. delete-only) is a + successful no-op with no `diff_traces` insert. +- A non-empty result is persisted as exactly one `diff_traces` row via the + existing `insert_diff_trace` — `session_id = cx_`, `model_id = + normalize_codex_model_id(event.model)` when a model is reported, `tool_name + = "codex"`, `tool_version = None`, `payload_type = "patch"` — no new + persistence adapter. +- The timestamp comes from `current_unix_time_ms()`; unlike every other + Codex arm (which falls back to epoch zero via `.unwrap_or(0)`), a + timestamp-acquisition failure here skips the insert entirely (fails open) + rather than substituting a fabricated epoch-zero value. +- Every path — success, empty-normalize no-op, and every fail-open branch — + returns empty stdout. + +Once committed, a Codex Update's synthetic patch-local line numbers still +attribute correctly through the existing, unmodified `intersect_patches` +historical `kind`+`content` fallback (`cli/src/services/patch.rs`) even when +the real committed lines land at different real line numbers — this module +does not touch that fallback, and no `diff_traces`/Agent Trace schema +migration was added to support it. + ## No remaining stub arms -All three currently-registered dispatch arms (`UserPromptSubmit`, `Stop`, -`PreToolUse(Bash)`) now have real behavior. Codex `apply_patch` tracing has -no dispatch arm yet — it is not documented here in detail and is deferred to -a later task. +All four registered dispatch arms (`UserPromptSubmit`, `Stop`, +`PreToolUse(Bash)`, `PostToolUse(apply_patch)`) now have real behavior. +`PreToolUse(apply_patch)` is deliberately never registered (see plan +`context/plans/codex-cli-integration.md`'s no-snapshot design) and falls +open as a `NoOp` like any other unsupported combination. ## Verification From cb49faa97a74a45eb605ef7e06686f8fef6afd05 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 01:24:51 +0200 Subject: [PATCH 11/20] runtime: Harden Codex hook integration and apply_patch attribution Codex hook handling needed to accept current upstream apply_patch input while preserving silent fail-open behavior and conservative attribution boundaries. - Resolve patch paths from the event cwd against the real Git root and normalize supported outer wrappers before parsing. - Validate sessions, preserve truthful model IDs, and allocate event-scoped synthetic evidence identities without changing generic intersection behavior. - Make generated hook invocation root-aware and add end-to-end, parser, path, contract, and regression coverage. - Record the durable contracts and the remaining repeated-content attribution ambiguity. Plan: codex-cli-integration (T14-T19) Co-authored-by: SCE --- .../services/hooks/codex/apply_patch/mod.rs | 209 ++++++- .../hooks/codex/apply_patch/normalize.rs | 531 +++++++++++++++--- .../hooks/codex/apply_patch/parser.rs | 138 ++++- .../services/hooks/codex/apply_patch/path.rs | 420 ++++++++++++++ cli/src/services/hooks/codex/mod.rs | 334 ++++++++++- cli/src/services/hooks/codex/stop.rs | 4 +- .../hooks/codex/user_prompt_submit.rs | 4 +- cli/src/services/hooks/mod.rs | 29 +- config/pkl/renderers/codex-content.pkl | 5 +- .../renderers/generation-contract-check.pkl | 22 + context/architecture.md | 12 +- context/cli/cli-command-surface.md | 2 +- context/cli/patch-service.md | 19 + context/context-map.md | 5 +- ...-scoped-apply-patch-evidence-identities.md | 97 ++++ ...-08-23-codex-root-aware-hook-invocation.md | 85 +++ ...6-08-23-codex-truthful-model-provenance.md | 85 +++ context/glossary.md | 6 +- context/overview.md | 8 +- context/patterns.md | 8 +- context/plans/codex-cli-integration.md | 183 ++++-- .../sce/agent-trace-hooks-command-routing.md | 3 +- context/sce/codex-integration-runtime.md | 113 +++- flake.nix | 10 + scripts/test-codex-hook-command.sh | 110 ++++ 25 files changed, 2242 insertions(+), 200 deletions(-) create mode 100644 cli/src/services/hooks/codex/apply_patch/path.rs create mode 100644 context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md create mode 100644 context/decisions/2026-08-23-codex-root-aware-hook-invocation.md create mode 100644 context/decisions/2026-08-23-codex-truthful-model-provenance.md create mode 100755 scripts/test-codex-hook-command.sh diff --git a/cli/src/services/hooks/codex/apply_patch/mod.rs b/cli/src/services/hooks/codex/apply_patch/mod.rs index ddfd5cbc..482bc0f5 100644 --- a/cli/src/services/hooks/codex/apply_patch/mod.rs +++ b/cli/src/services/hooks/codex/apply_patch/mod.rs @@ -5,6 +5,7 @@ mod normalize; mod parser; +mod path; use std::path::Path; @@ -12,14 +13,18 @@ use anyhow::{Context, Result}; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{DiffTraceInsert, PAYLOAD_TYPE_PATCH}; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_for_hook_runtime_at_state_root, AgentTraceStorageContext, +}; use crate::services::observability::traits::Logger; use normalize::normalize_codex_patch; #[allow(unused_imports)] use parser::{ - parse_codex_apply_patch, CodexFileOperation, CodexHunk, CodexHunkLine, CodexPatch, - CodexPatchParseError, + normalize_outer_apply_patch_input, parse_codex_apply_patch, CodexFileOperation, CodexHunk, + CodexHunkLine, CodexPatch, CodexPatchParseError, }; +use path::resolve_codex_patch_paths; use super::super::{ current_unix_time_ms, normalize_codex_model_id, open_agent_trace_db_for_hook_runtime, @@ -40,11 +45,39 @@ pub(super) fn handle( event: &CodexHookEvent, logger: Option<&dyn Logger>, ) -> Result { + handle_with_state_root(repository_root, event, None, logger) +} + +pub(super) fn handle_with_state_root( + repository_root: &Path, + event: &CodexHookEvent, + state_root: Option<&Path>, + logger: Option<&dyn Logger>, +) -> Result { + // Validate the session before parsing, path resolution, or DB access so + // invalid Codex events can never reach apply_patch persistence. + required_session_id(event.session_id.as_deref())?; + let Some(command) = apply_patch_command_from_event(event) else { return Ok(String::new()); }; - let patch = match parse_codex_apply_patch(command) { + let canonical_command = match normalize_outer_apply_patch_input(command) { + Ok(command) => command, + Err(parse_error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.codex.apply_patch.parse_failed", + &parse_error.to_string(), + &[], + event.session_id.as_deref(), + ); + } + return Ok(String::new()); + } + }; + + let patch = match parse_codex_apply_patch(&canonical_command) { Ok(patch) => patch, Err(parse_error) => { if let Some(log) = logger { @@ -59,7 +92,46 @@ pub(super) fn handle( } }; - let normalized_patch = normalize_codex_patch(&patch); + let mut patch = patch; + if let Some(event_cwd) = event.cwd.as_deref() { + if let Err(error) = resolve_codex_patch_paths(repository_root, event_cwd, &mut patch) { + if let Some(log) = logger { + log.error( + "sce.hooks.codex.apply_patch.path_resolution_failed", + &error.to_string(), + &[], + event.session_id.as_deref(), + ); + } + return Ok(String::new()); + } + } else { + if let Some(log) = logger { + log.error( + "sce.hooks.codex.apply_patch.path_resolution_failed", + "Codex hook event cwd is missing or malformed.", + &[], + event.session_id.as_deref(), + ); + } + return Ok(String::new()); + } + + let normalized_patch = + match normalize_codex_patch(&patch, event.tool_use_id.as_deref().unwrap_or_default()) { + Ok(normalized_patch) => normalized_patch, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.codex.apply_patch.normalize_failed", + &error.to_string(), + &[], + event.session_id.as_deref(), + ); + } + return Ok(String::new()); + } + }; if normalized_patch.is_empty() { return Ok(String::new()); } @@ -68,10 +140,22 @@ pub(super) fn handle( return Ok(String::new()); }; - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for Codex apply_patch persistence.", - )?; + let db = match state_root { + Some(state_root) => resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + state_root, + ) + .map(|storage| storage.db) + .context("Failed to open Agent Trace DB for Codex apply_patch persistence."), + None => open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Codex apply_patch persistence.", + ), + }?; persist_with(&db, event, &normalized_patch, time_ms) } @@ -87,6 +171,15 @@ fn apply_patch_command_from_event(event: &CodexHookEvent) -> Option<&str> { .and_then(|value| value.as_str()) } +fn required_session_id(value: Option<&str>) -> Result<&str> { + match value.map(str::trim) { + Some(value) if !value.is_empty() => Ok(value), + _ => anyhow::bail!( + "Invalid Codex apply_patch payload: field 'session_id' must be a trimmed, non-empty string." + ), + } +} + /// Injectable counterpart of `handle`'s persistence step, for deterministic /// testing against an already-open Agent Trace DB — mirrors the /// `user_prompt_submit`/`stop` sibling arms' `capture_with` pattern. @@ -98,7 +191,7 @@ fn persist_with( ) -> Result { let session_id = prefixed_diff_trace_session_id( CODEX_TOOL_NAME, - event.session_id.as_deref().unwrap_or_default(), + required_session_id(event.session_id.as_deref())?, ); let model_id = event.model.as_deref().and_then(normalize_codex_model_id); @@ -203,7 +296,11 @@ mod tests { } fn normalized(raw: &str) -> String { - normalize_codex_patch(&parse_codex_apply_patch(raw).expect("fixture patch should parse")) + normalize_codex_patch( + &parse_codex_apply_patch(raw).expect("fixture patch should parse"), + "tool-1", + ) + .expect("fixture tool identity should normalize") } fn unique_test_db_path(label: &str) -> PathBuf { @@ -284,6 +381,51 @@ mod tests { assert_eq!(output, ""); } + #[test] + fn persist_with_rejects_missing_empty_and_whitespace_session_ids_without_rows() { + let db_path = unique_test_db_path("invalid-session"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let normalized_patch = normalized(UPDATE_ONLY_PATCH); + + for session_id in [None, Some(""), Some(" ")] { + let mut invalid_event = event("session-1", None, UPDATE_ONLY_PATCH); + invalid_event.session_id = session_id.map(str::to_string); + assert!(persist_with(&db, &invalid_event, &normalized_patch, 1_000).is_err()); + } + + let recent = db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 0); + + remove_test_db(&db_path); + } + + #[test] + fn persist_with_trims_valid_session_ids_before_prefixing() { + let db_path = unique_test_db_path("trimmed-session"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let normalized_patch = normalized(UPDATE_ONLY_PATCH); + let mut trimmed_event = event(" session-1 ", None, UPDATE_ONLY_PATCH); + + persist_with(&db, &trimmed_event, &normalized_patch, 1_000) + .expect("trimmed session should persist"); + trimmed_event.session_id = Some("cx_session-2".to_string()); + persist_with(&db, &trimmed_event, &normalized_patch, 2_000) + .expect("already prefixed session should persist"); + + let rows = db + .query_map( + "SELECT session_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("diff trace query should succeed"); + assert_eq!(rows, vec!["cx_session-1", "cx_session-2"]); + + remove_test_db(&db_path); + } + #[test] fn handle_is_a_successful_no_op_for_a_pure_rename_with_no_changed_lines() { let output = handle( @@ -336,7 +478,50 @@ mod tests { .files .iter() .flat_map(|file| &file.hunks) - .all(|hunk| hunk.model_id.as_deref() == Some("openai/gpt-5-codex"))); + .all(|hunk| hunk.model_id.as_deref() == Some("gpt-5-codex"))); + + remove_test_db(&db_path); + } + + #[test] + fn apply_patch_persists_truthful_model_ids_without_fabricating_openai() { + let db_path = unique_test_db_path("model-provenance"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let normalized_patch = normalized(UPDATE_ONLY_PATCH); + let cases = [ + (Some("openai/gpt-x"), Some("openai/gpt-x")), + ( + Some("qualified/custom-provider/model"), + Some("qualified/custom-provider/model"), + ), + (Some("custom-model"), Some("custom-model")), + (Some(" "), None), + (None, None), + ]; + + for (index, (model, _expected)) in cases.iter().enumerate() { + persist_with( + &db, + &event(&format!("session-{index}"), *model, UPDATE_ONLY_PATCH), + &normalized_patch, + i64::try_from(index).expect("test index should fit") + 1_000, + ) + .expect("model provenance should persist"); + } + + let recent = db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + let model_ids: Vec> = recent + .patches + .iter() + .map(|row| row.patch.files[0].hunks[0].model_id.clone()) + .collect(); + let expected: Vec> = cases + .iter() + .map(|(_, expected)| expected.map(str::to_string)) + .collect(); + assert_eq!(model_ids, expected); remove_test_db(&db_path); } @@ -483,7 +668,7 @@ mod tests { assert_eq!(agent_trace_json["tool"]["name"], "codex"); assert_eq!( agent_trace_json["files"][0]["conversations"][0]["contributor"]["model_id"], - "openai/gpt-5-codex" + "gpt-5-codex" ); remove_test_db(&db_path); diff --git a/cli/src/services/hooks/codex/apply_patch/normalize.rs b/cli/src/services/hooks/codex/apply_patch/normalize.rs index a811cd25..93dff816 100644 --- a/cli/src/services/hooks/codex/apply_patch/normalize.rs +++ b/cli/src/services/hooks/codex/apply_patch/normalize.rs @@ -2,11 +2,13 @@ //! `Index:`-form unified-diff text that `crate::services::patch::parse_patch` //! already accepts. //! -//! Positions are deterministic and patch-local: each `Update File` operation -//! numbers only the touched (`+`/`-`) lines it actually emits, starting from -//! line 1, ignoring Codex's own unchanged context lines entirely (they are -//! dropped, not persisted as evidence, and contribute no positional weight). -//! These positions are never claimed to be real filesystem line numbers. The +//! Positions are deterministic and event-scoped: each `Update File` operation +//! numbers only the touched (`+`/`-`) lines it actually emits, from a bounded +//! range derived from the stable `tool_use_id`. Local offsets are allocated +//! across every emitted operation, hunk, and file. Codex's own unchanged +//! context lines are dropped, not persisted as evidence, and contribute no +//! positional weight. These positions are evidence identities, never real +//! filesystem line numbers. The //! existing, unmodified `intersect_patches` //! historical `kind`+`content` fallback is what lets this synthetic-line //! evidence still attribute correctly once a real commit lands at different @@ -20,53 +22,170 @@ use std::fmt::Write as _; +use sha2::{Digest, Sha256}; + use super::{CodexFileOperation, CodexHunk, CodexHunkLine, CodexPatch}; const PATCH_INDEX_SEPARATOR: &str = "==================================================================="; +const CODEX_SYNTHETIC_LINE_ID_DOMAIN: &[u8] = b"sce-codex-apply-patch-line-id-v1\0"; +const SYNTHETIC_EVENT_RANGE_SIZE: u64 = 1 << 31; +const SYNTHETIC_BASE_OFFSET: u64 = 2; + +/// Error produced when Codex apply-patch evidence cannot be assigned safe, +/// event-scoped synthetic line identities. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexPatchNormalizeError { + message: String, +} + +impl std::fmt::Display for CodexPatchNormalizeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "codex apply_patch normalization error: {}", self.message) + } +} + +impl std::error::Error for CodexPatchNormalizeError {} + +fn normalize_error(message: impl Into) -> CodexPatchNormalizeError { + CodexPatchNormalizeError { + message: message.into(), + } +} /// Normalizes every `Add`/`Update` file operation in `patch` into one /// combined SCE `Index:`-form unified-diff string, in operation order. +/// +/// The synthetic line identities are deterministic for `tool_use_id`, and +/// local offsets are allocated across the entire patch rather than restarting +/// for each file. They are evidence identities, not source line numbers. #[allow(dead_code)] -pub(crate) fn normalize_codex_patch(patch: &CodexPatch) -> String { +pub(crate) fn normalize_codex_patch( + patch: &CodexPatch, + tool_use_id: &str, +) -> Result { + let base = synthetic_base(tool_use_id)?; + normalize_codex_patch_with_base(patch, base) +} + +fn synthetic_base(tool_use_id: &str) -> Result { + let identity = tool_use_id.trim(); + if identity.is_empty() || identity != tool_use_id { + return Err(normalize_error( + "Codex apply_patch tool_use_id must be present, trimmed, and non-empty.", + )); + } + + let mut hasher = Sha256::new(); + hasher.update(CODEX_SYNTHETIC_LINE_ID_DOMAIN); + hasher.update(identity.as_bytes()); + let digest = hasher.finalize(); + let mut bucket_bytes = [0_u8; 4]; + bucket_bytes.copy_from_slice(&digest[..4]); + let bucket = u64::from(u32::from_be_bytes(bucket_bytes)); + + bucket + .checked_mul(SYNTHETIC_EVENT_RANGE_SIZE) + .and_then(|value| value.checked_add(SYNTHETIC_BASE_OFFSET)) + .ok_or_else(|| normalize_error("Codex apply_patch synthetic base overflowed.")) +} + +fn normalize_codex_patch_with_base( + patch: &CodexPatch, + base: u64, +) -> Result { + let mut allocator = SyntheticLineAllocator { + base, + next_offset: 0, + }; + patch .operations .iter() - .filter_map(normalize_operation) - .collect() + .try_fold(String::new(), |mut output, operation| { + if let Some(normalized) = normalize_operation(operation, &mut allocator)? { + output.push_str(&normalized); + } + Ok(output) + }) +} + +struct SyntheticLineAllocator { + base: u64, + next_offset: u64, +} + +impl SyntheticLineAllocator { + fn allocate(&mut self, count: u64) -> Result { + if count == 0 { + return Err(normalize_error( + "Codex apply_patch cannot allocate an empty synthetic range.", + )); + } + + let start = self.base.checked_add(self.next_offset).ok_or_else(|| { + normalize_error("Codex apply_patch synthetic line identity overflowed.") + })?; + let next_offset = self + .next_offset + .checked_add(count) + .ok_or_else(|| normalize_error("Codex apply_patch synthetic offset overflowed."))?; + if next_offset > SYNTHETIC_EVENT_RANGE_SIZE { + return Err(normalize_error( + "Codex apply_patch synthetic line range was exhausted.", + )); + } + self.base.checked_add(next_offset - 1).ok_or_else(|| { + normalize_error("Codex apply_patch synthetic line identity overflowed.") + })?; + self.next_offset = next_offset; + Ok(start) + } } -fn normalize_operation(operation: &CodexFileOperation) -> Option { +fn normalize_operation( + operation: &CodexFileOperation, + allocator: &mut SyntheticLineAllocator, +) -> Result, CodexPatchNormalizeError> { match operation { - CodexFileOperation::Add { path, lines } => Some(normalize_add(path, lines)), + CodexFileOperation::Add { path, lines } => normalize_add(path, lines, allocator), CodexFileOperation::Update { old_path, new_path, hunks, - } => normalize_update(old_path, new_path.as_deref(), hunks), - CodexFileOperation::Delete { .. } => None, + } => normalize_update(old_path, new_path.as_deref(), hunks, allocator), + CodexFileOperation::Delete { .. } => Ok(None), } } -fn normalize_add(path: &str, lines: &[String]) -> String { - let mut body = format!("@@ -0,0 +1,{} @@\n", lines.len()); +fn normalize_add( + path: &str, + lines: &[String], + allocator: &mut SyntheticLineAllocator, +) -> Result, CodexPatchNormalizeError> { + if lines.is_empty() { + return Ok(None); + } + let start = allocator.allocate(line_count(lines.len())?)?; + let mut body = format!("@@ -0,0 +{start},{} @@\n", lines.len()); for line in lines { body.push('+'); body.push_str(line); body.push('\n'); } - render_file_section(path, path, &body) + Ok(Some(render_file_section(path, path, &body))) } -fn normalize_update(old_path: &str, new_path: Option<&str>, hunks: &[CodexHunk]) -> Option { +fn normalize_update( + old_path: &str, + new_path: Option<&str>, + hunks: &[CodexHunk], + allocator: &mut SyntheticLineAllocator, +) -> Result, CodexPatchNormalizeError> { let mut body = String::new(); - let mut old_pos: u64 = 1; - let mut new_pos: u64 = 1; let mut has_changes = false; for hunk in hunks { - let hunk_old_start = old_pos; - let hunk_new_start = new_pos; let mut hunk_body = String::new(); let mut removed_count: u64 = 0; let mut added_count: u64 = 0; @@ -80,23 +199,27 @@ fn normalize_update(old_path: &str, new_path: Option<&str>, hunks: &[CodexHunk]) hunk_body.push('-'); hunk_body.push_str(content); hunk_body.push('\n'); - old_pos += 1; - removed_count += 1; + removed_count = removed_count.checked_add(1).ok_or_else(|| { + normalize_error("Codex apply_patch removed-line count overflowed.") + })?; } CodexHunkLine::Added(content) => { hunk_body.push('+'); hunk_body.push_str(content); hunk_body.push('\n'); - new_pos += 1; - added_count += 1; + added_count = added_count.checked_add(1).ok_or_else(|| { + normalize_error("Codex apply_patch added-line count overflowed.") + })?; } } } if removed_count > 0 || added_count > 0 { + let local_count = removed_count.max(added_count); + let start = allocator.allocate(local_count)?; let _ = writeln!( body, - "@@ -{hunk_old_start},{removed_count} +{hunk_new_start},{added_count} @@" + "@@ -{start},{removed_count} +{start},{added_count} @@" ); body.push_str(&hunk_body); has_changes = true; @@ -104,11 +227,16 @@ fn normalize_update(old_path: &str, new_path: Option<&str>, hunks: &[CodexHunk]) } if !has_changes { - return None; + return Ok(None); } let destination = new_path.unwrap_or(old_path); - Some(render_file_section(old_path, destination, &body)) + Ok(Some(render_file_section(old_path, destination, &body))) +} + +fn line_count(count: usize) -> Result { + u64::try_from(count) + .map_err(|_| normalize_error("Codex apply_patch line count does not fit in u64.")) } fn render_file_section(old_path: &str, new_path: &str, body: &str) -> String { @@ -120,14 +248,22 @@ mod tests { use super::super::parser::parse_codex_apply_patch; use super::*; use crate::services::patch::{ - intersect_patches, parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, - TouchedLine, TouchedLineKind, + combine_patches, intersect_patches, parse_patch, FileChangeKind, ParsedPatch, + PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, }; fn parse(raw: &str) -> CodexPatch { parse_codex_apply_patch(raw).expect("fixture patch should parse") } + fn normalized(raw: &str) -> String { + normalize_codex_patch(&parse(raw), "tool-1").expect("fixture should normalize") + } + + fn test_base() -> u64 { + synthetic_base("tool-1").expect("test identity should hash") + } + #[test] fn normalizes_add_file_into_a_parseable_added_hunk() { let patch = parse( @@ -138,17 +274,20 @@ mod tests { *** End Patch", ); - let normalized = normalize_codex_patch(&patch); + let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); + let base = test_base(); assert_eq!( normalized, - "Index: foo.txt\n\ - ===================================================================\n\ - --- foo.txt\n\ - +++ foo.txt\n\ - @@ -0,0 +1,2 @@\n\ - +line one\n\ - +line two\n" + format!( + "Index: foo.txt\n\ + ===================================================================\n\ + --- foo.txt\n\ + +++ foo.txt\n\ + @@ -0,0 +{base},2 @@\n\ + +line one\n\ + +line two\n" + ) ); let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); @@ -161,13 +300,13 @@ mod tests { vec![ TouchedLine { kind: TouchedLineKind::Added, - line_number: 1, + line_number: base, content: "line one".to_string(), session_id: Some("cx_test".to_string()), }, TouchedLine { kind: TouchedLineKind::Added, - line_number: 2, + line_number: base + 1, content: "line two".to_string(), session_id: Some("cx_test".to_string()), }, @@ -187,19 +326,22 @@ mod tests { *** End Patch", ); - let normalized = normalize_codex_patch(&patch); + let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); + let base = test_base(); // The context line (" unchanged") is dropped entirely and // contributes no positional weight. assert_eq!( normalized, - "Index: src/lib.rs\n\ - ===================================================================\n\ - --- src/lib.rs\n\ - +++ src/lib.rs\n\ - @@ -1,1 +1,1 @@\n\ - - old_line\n\ - + new_line\n" + format!( + "Index: src/lib.rs\n\ + ===================================================================\n\ + --- src/lib.rs\n\ + +++ src/lib.rs\n\ + @@ -{base},1 +{base},1 @@\n\ + - old_line\n\ + + new_line\n" + ) ); let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); @@ -211,13 +353,13 @@ mod tests { vec![ TouchedLine { kind: TouchedLineKind::Removed, - line_number: 1, + line_number: base, content: " old_line".to_string(), session_id: Some("cx_test".to_string()), }, TouchedLine { kind: TouchedLineKind::Added, - line_number: 1, + line_number: base, content: " new_line".to_string(), session_id: Some("cx_test".to_string()), }, @@ -237,17 +379,20 @@ mod tests { *** End Patch", ); - let normalized = normalize_codex_patch(&patch); + let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); + let base = test_base(); assert_eq!( normalized, - "Index: new_name.txt\n\ - ===================================================================\n\ - --- old_name.txt\n\ - +++ new_name.txt\n\ - @@ -1,1 +1,1 @@\n\ - -old\n\ - +new\n" + format!( + "Index: new_name.txt\n\ + ===================================================================\n\ + --- old_name.txt\n\ + +++ new_name.txt\n\ + @@ -{base},1 +{base},1 @@\n\ + -old\n\ + +new\n" + ) ); let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); @@ -267,7 +412,10 @@ mod tests { *** End Patch", ); - assert_eq!(normalize_codex_patch(&patch), ""); + assert_eq!( + normalize_codex_patch(&patch, "tool-1").expect("should normalize"), + "" + ); } #[test] @@ -278,12 +426,15 @@ mod tests { *** End Patch", ); - assert_eq!(normalize_codex_patch(&patch), ""); + assert_eq!( + normalize_codex_patch(&patch, "tool-1").expect("should normalize"), + "" + ); } #[test] fn mixed_patch_keeps_only_add_and_update_evidence() { - let patch = parse( + let normalized = normalized( "*** Begin Patch\n\ *** Add File: a.txt\n\ +hello\n\ @@ -294,8 +445,6 @@ mod tests { +new\n\ *** End Patch", ); - - let normalized = normalize_codex_patch(&patch); let parsed = parse_patch(&normalized, Some("cx_test")).expect("should parse"); assert_eq!(parsed.files.len(), 2); @@ -319,25 +468,257 @@ mod tests { *** End Patch", ); - let normalized = normalize_codex_patch(&patch); + let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); + let base = test_base(); assert_eq!( normalized, - "Index: d.txt\n\ - ===================================================================\n\ - --- d.txt\n\ - +++ d.txt\n\ - @@ -1,1 +1,1 @@\n\ - -a\n\ - +b\n\ - @@ -2,1 +2,1 @@\n\ - -c\n\ - +d\n" + format!( + "Index: d.txt\n\ + ===================================================================\n\ + --- d.txt\n\ + +++ d.txt\n\ + @@ -{base},1 +{base},1 @@\n\ + -a\n\ + +b\n\ + @@ -{next},1 +{next},1 @@\n\ + -c\n\ + +d\n", + next = base + 1 + ) ); parse_patch(&normalized, Some("cx_test")).expect("should parse"); } + #[test] + fn event_scoped_normalization_is_deterministic_and_allocates_across_files() { + let patch = parse( + "*** Begin Patch\n\ + *** Add File: first.txt\n\ + +first\n\ + +second\n\ + *** Update File: second.txt\n\ + @@\n\ + -old\n\ + +new\n\ + *** Add File: third.txt\n\ + +third\n\ + *** End Patch", + ); + + let first = normalize_codex_patch(&patch, "event-1").expect("should normalize"); + let repeated = normalize_codex_patch(&patch, "event-1").expect("should normalize"); + assert_eq!(first, repeated); + + let parsed = parse_patch(&first, None).expect("normalized patch should parse"); + let line_numbers: Vec = parsed + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.line_number) + .collect(); + assert_eq!(line_numbers.len(), 5); + assert!(line_numbers.iter().all(|line| *line > 1)); + assert_eq!( + line_numbers, + vec![ + test_base_for("event-1"), + test_base_for("event-1") + 1, + test_base_for("event-1") + 2, + test_base_for("event-1") + 2, + test_base_for("event-1") + 3, + ] + ); + } + + #[test] + fn different_event_ids_use_separate_synthetic_ranges() { + let patch = parse( + "*** Begin Patch\n\ + *** Add File: same.txt\n\ + +same content\n\ + *** End Patch", + ); + + let first = normalize_codex_patch(&patch, "event-1").expect("should normalize"); + let second = normalize_codex_patch(&patch, "event-2").expect("should normalize"); + assert_ne!(synthetic_base("event-1"), synthetic_base("event-2")); + assert_ne!(first, second); + + let first_line = parse_patch(&first, None) + .expect("first patch should parse") + .files[0] + .hunks[0] + .lines[0] + .line_number; + let second_line = parse_patch(&second, None) + .expect("second patch should parse") + .files[0] + .hunks[0] + .lines[0] + .line_number; + assert_ne!(first_line, second_line); + } + + #[test] + fn rejects_faulted_identity_inputs_and_checked_range_overflow() { + let patch = parse( + "*** Begin Patch\n\ + *** Add File: file.txt\n\ + +one\n\ + +two\n\ + *** End Patch", + ); + + assert!(normalize_codex_patch(&patch, "").is_err()); + assert!(normalize_codex_patch(&patch, " ").is_err()); + assert!(normalize_codex_patch(&patch, " event-1").is_err()); + assert!(normalize_codex_patch_with_base(&patch, u64::MAX).is_err()); + } + + #[test] + fn same_content_events_survive_combination_and_match_two_commit_additions() { + let patch = parse( + "*** Begin Patch\n\ + *** Add File: same.txt\n\ + +same content\n\ + *** End Patch", + ); + let first = parse_patch( + &normalize_codex_patch(&patch, "event-1").expect("first event should normalize"), + Some("cx_event-1"), + ) + .expect("first normalized patch should parse"); + let second = parse_patch( + &normalize_codex_patch(&patch, "event-2").expect("second event should normalize"), + Some("cx_event-2"), + ) + .expect("second normalized patch should parse"); + + let combined = combine_patches(&[first, second]); + let combined_lines: Vec<&TouchedLine> = combined.files[0] + .hunks + .iter() + .flat_map(|hunk| hunk.lines.iter()) + .collect(); + assert_eq!(combined_lines.len(), 2); + assert_ne!(combined_lines[0].line_number, combined_lines[1].line_number); + + let post_commit = ParsedPatch { + files: vec![PatchFileChange { + old_path: String::new(), + new_path: "same.txt".to_string(), + kind: FileChangeKind::Added, + hunks: vec![PatchHunk { + old_start: 0, + old_count: 0, + new_start: 40, + new_count: 2, + model_id: None, + lines: vec![ + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 40, + content: "same content".to_string(), + session_id: None, + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 41, + content: "same content".to_string(), + session_id: None, + }, + ], + }], + }], + }; + + let overlap = intersect_patches(&combined, &post_commit); + let overlap_lines: Vec<&TouchedLine> = overlap.files[0] + .hunks + .iter() + .flat_map(|hunk| hunk.lines.iter()) + .collect(); + assert_eq!(overlap_lines.len(), 2); + assert_eq!(overlap_lines[0].line_number, 40); + assert_eq!(overlap_lines[1].line_number, 41); + } + + #[test] + fn repeated_identical_content_remains_physically_ambiguous_without_line_ranges() { + let patch = parse( + "*** Begin Patch\n\ + *** Update File: repeated.txt\n\ + @@\n\ + -before\n\ + +same\n\ + @@\n\ + -before\n\ + +same\n\ + *** End Patch", + ); + let first = parse_patch( + &normalize_codex_patch(&patch, "event-1").expect("first event should normalize"), + Some("cx_event-1"), + ) + .expect("first normalized patch should parse"); + let second = parse_patch( + &normalize_codex_patch(&patch, "event-2").expect("second event should normalize"), + Some("cx_event-2"), + ) + .expect("second normalized patch should parse"); + + let combined = combine_patches(&[first, second]); + let post_commit = ParsedPatch { + files: vec![PatchFileChange { + old_path: "repeated.txt".to_string(), + new_path: "repeated.txt".to_string(), + kind: FileChangeKind::Modified, + hunks: vec![PatchHunk { + old_start: 20, + old_count: 2, + new_start: 20, + new_count: 2, + model_id: None, + lines: vec![ + TouchedLine { + kind: TouchedLineKind::Removed, + line_number: 20, + content: "before".to_string(), + session_id: None, + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: 20, + content: "same".to_string(), + session_id: None, + }, + ], + }], + }], + }; + + let overlap = intersect_patches(&combined, &post_commit); + let lines: Vec<&TouchedLine> = overlap.files[0] + .hunks + .iter() + .flat_map(|hunk| hunk.lines.iter()) + .collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].session_id.as_deref(), Some("cx_event-1")); + assert_eq!( + lines[1].session_id.as_deref(), + Some("cx_event-1"), + "without true line ranges, both matching physical lines are attributed to the first available event" + ); + } + + fn test_base_for(tool_use_id: &str) -> u64 { + synthetic_base(tool_use_id).expect("test identity should hash") + } + /// AC15: synthetic patch-local line numbers must still attribute /// correctly through the existing, unmodified `intersect_patches` /// historical `kind`+`content` fallback once the real commit lands the @@ -353,12 +734,12 @@ mod tests { +new_line\n\ *** End Patch", ); - let normalized = normalize_codex_patch(&codex_patch); + let normalized = normalize_codex_patch(&codex_patch, "tool-1").expect("should normalize"); let constructed_patch = parse_patch(&normalized, Some("cx_test")).expect("constructed patch should parse"); // A realistic post-commit patch where the same touched lines sit at - // different real line numbers than the synthetic ones above (1/1), + // different real line numbers than the event-scoped synthetic ones, // plus one unrelated line that should not intersect. let post_commit_patch = real_commit_patch(); diff --git a/cli/src/services/hooks/codex/apply_patch/parser.rs b/cli/src/services/hooks/codex/apply_patch/parser.rs index d0006f35..1d1db494 100644 --- a/cli/src/services/hooks/codex/apply_patch/parser.rs +++ b/cli/src/services/hooks/codex/apply_patch/parser.rs @@ -115,9 +115,55 @@ fn error(message: impl Into) -> CodexPatchParseError { } } -/// Parses raw Codex `apply_patch` `tool_input.command` text into a -/// [`CodexPatch`]. Performs no normalization to SCE unified-diff form and no -/// filesystem access; see the `apply_patch` module's own scope boundary. +/// Removes the optional shell-like heredoc boundary that current upstream +/// Codex may include around an `apply_patch` command. The canonical grammar +/// parser intentionally does not know about shell syntax; callers should pass +/// this result to [`parse_codex_apply_patch`]. +#[allow(dead_code)] +pub(crate) fn normalize_outer_apply_patch_input(raw: &str) -> Result { + let trimmed = raw.trim(); + let lines: Vec<&str> = trimmed.lines().collect(); + + if has_canonical_boundaries(&lines) { + // Preserve ordinary raw patch input byte-for-byte. The canonical + // parser performs the same boundary trimming it did before this + // outer-normalization seam was introduced. + return Ok(raw.to_string()); + } + + let Some(first_line) = lines.first().copied() else { + return Err(error("Codex apply_patch input cannot be empty.")); + }; + let Some(last_line) = lines.last().copied() else { + return Err(error("Codex apply_patch input cannot be empty.")); + }; + + let is_supported_heredoc = matches!(first_line, "< bool { + lines.first().map(|line| line.trim()) == Some(BEGIN_PATCH_MARKER) + && lines.last().map(|line| line.trim()) == Some(END_PATCH_MARKER) +} + +/// Parses canonical Codex `apply_patch` text into a [`CodexPatch`]. Performs +/// no outer shell normalization, normalization to SCE unified-diff form, or +/// filesystem access; callers handling hook `tool_input.command` should first +/// use [`normalize_outer_apply_patch_input`]. #[allow(dead_code)] pub(crate) fn parse_codex_apply_patch(raw: &str) -> Result { let trimmed = raw.trim(); @@ -336,6 +382,92 @@ fn validate_path(path: &str) -> Result { mod tests { use super::*; + const SIMPLE_CANONICAL_PATCH: &str = + "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch"; + + #[test] + fn normal_raw_patch_is_returned_unchanged() { + let raw = format!("\n{SIMPLE_CANONICAL_PATCH}\n"); + + assert_eq!( + normalize_outer_apply_patch_input(&raw).expect("raw patch should be accepted"), + raw + ); + } + + #[test] + fn normalizes_each_upstream_supported_heredoc_wrapper_exactly() { + for wrapper in ["< Result { + if event_cwd.trim().is_empty() || event_cwd.contains('\0') { + bail!("Codex hook event cwd is missing or malformed."); + } + + let cwd = Path::new(event_cwd); + if !cwd.is_absolute() { + bail!("Codex hook event cwd must be an absolute path."); + } + + let cwd = std::fs::canonicalize(cwd).with_context(|| { + format!( + "failed to resolve Codex hook event cwd '{}'.", + cwd.display() + ) + })?; + if !cwd.is_dir() { + bail!( + "Codex hook event cwd '{}' is not a directory.", + cwd.display() + ); + } + if !cwd.starts_with(git_root) { + bail!( + "Codex hook event cwd '{}' is outside Git repository '{}'.", + cwd.display(), + git_root.display() + ); + } + + Ok(cwd) +} + +fn resolve_path_from_cwd(git_root: &Path, event_cwd: &Path, codex_path: &str) -> Result { + let relative_path = normalize_codex_relative_path(codex_path)?; + let candidate = event_cwd.join(&relative_path); + ensure_existing_prefix_is_inside_repository(git_root, &candidate)?; + + let cwd_relative = event_cwd + .strip_prefix(git_root) + .map_err(|_| anyhow!("Codex hook event cwd cannot be represented relative to Git root."))?; + let repository_relative = cwd_relative.join(relative_path); + path_to_utf8_slash_path(&repository_relative) +} + +fn normalize_codex_relative_path(codex_path: &str) -> Result { + if codex_path.trim().is_empty() || codex_path.contains('\0') { + bail!("Codex apply_patch path is empty or malformed."); + } + + let path = Path::new(codex_path); + if path.is_absolute() { + bail!("Codex apply_patch path '{codex_path}' must not be absolute."); + } + + let mut normalized = PathBuf::new(); + let mut has_normal_component = false; + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => { + if value.to_str().is_none() { + bail!("Codex apply_patch path '{codex_path}' is not valid UTF-8."); + } + normalized.push(value); + has_normal_component = true; + } + Component::ParentDir => { + bail!("Codex apply_patch path '{codex_path}' must not escape the event cwd."); + } + Component::RootDir | Component::Prefix(_) => { + bail!("Codex apply_patch path '{codex_path}' is not relative."); + } + } + } + + if !has_normal_component { + bail!("Codex apply_patch path '{codex_path}' has no file component."); + } + + Ok(normalized) +} + +/// Check the nearest existing path prefix so a lexical path through a +/// symlink cannot silently map evidence outside the real repository. Missing +/// Add File targets are allowed; their existing parent prefix is checked. +fn ensure_existing_prefix_is_inside_repository(git_root: &Path, candidate: &Path) -> Result<()> { + let mut existing = candidate; + loop { + match std::fs::symlink_metadata(existing) { + Ok(_) => { + let resolved = std::fs::canonicalize(existing).with_context(|| { + format!( + "failed to resolve existing Codex apply_patch path prefix '{}'.", + existing.display() + ) + })?; + if !resolved.starts_with(git_root) { + bail!( + "Codex apply_patch path '{}' resolves outside Git repository '{}'.", + candidate.display(), + git_root.display() + ); + } + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + existing = existing.parent().ok_or_else(|| { + anyhow!( + "Codex apply_patch path '{}' has no existing repository prefix.", + candidate.display() + ) + })?; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect Codex apply_patch path prefix '{}'.", + existing.display() + ) + }); + } + } + } +} + +fn path_to_utf8_slash_path(path: &Path) -> Result { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => components.push( + value + .to_str() + .ok_or_else(|| anyhow!("repository-relative path is not valid UTF-8"))?, + ), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + bail!("repository-relative path is ambiguous or unsafe."); + } + } + } + + if components.is_empty() { + bail!("repository-relative path is empty."); + } + Ok(components.join("/")) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn temp_repo(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "sce-codex-path-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temporary repository should be created"); + let output = Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .output() + .expect("git init should run"); + assert!(output.status.success(), "git init failed: {output:?}"); + root + } + + fn remove_repo(root: &Path) { + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolves_a_root_cwd_path_to_repository_relative_form() { + let root = temp_repo("root"); + let src = root.join("src"); + fs::create_dir(&src).expect("src directory should be created"); + let result = resolve_codex_patch_path(&root, &root.to_string_lossy(), "src/lib.rs") + .expect("root cwd path should resolve"); + assert_eq!(result, "src/lib.rs"); + remove_repo(&root); + } + + #[test] + fn resolves_nested_cwd_and_dot_components() { + let root = temp_repo("nested"); + let cwd = root.join("src").join("lib"); + fs::create_dir_all(&cwd).expect("nested cwd should be created"); + let result = + resolve_codex_patch_path(&root, &cwd.join(".").to_string_lossy(), "./../lib.rs") + .expect_err("traversal must be rejected even when dot components are present"); + assert!(result.to_string().contains("must not escape")); + + let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "./nested/file.rs") + .expect("nested relative path should resolve"); + assert_eq!(result, "src/lib/nested/file.rs"); + remove_repo(&root); + } + + #[test] + fn resolves_move_source_and_destination_independently() { + let root = temp_repo("move"); + let cwd = root.join("src"); + fs::create_dir(&cwd).expect("src directory should be created"); + let source = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "old.rs") + .expect("move source should resolve"); + let destination = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "new.rs") + .expect("move destination should resolve"); + assert_eq!(source, "src/old.rs"); + assert_eq!(destination, "src/new.rs"); + remove_repo(&root); + } + + #[test] + fn rejects_repository_escape_absolute_and_malformed_paths() { + let root = temp_repo("invalid-path"); + let outside = root + .parent() + .expect("temporary root should have a parent") + .to_path_buf(); + + for path in ["../outside.txt", "/etc/passwd", "./..", ""] { + let error = resolve_codex_patch_path(&root, &root.to_string_lossy(), path) + .expect_err("unsafe path should be rejected"); + assert!(!error.to_string().is_empty()); + } + + let error = resolve_codex_patch_path(&root, &outside.to_string_lossy(), "file.rs") + .expect_err("outside cwd should be rejected"); + assert!(error.to_string().contains("outside Git repository")); + + let error = resolve_codex_patch_path(&root, "", "file.rs") + .expect_err("missing cwd should be rejected"); + assert!(error.to_string().contains("missing or malformed")); + + let error = resolve_codex_patch_path(&root, "relative/cwd", "file.rs") + .expect_err("relative cwd should be rejected"); + assert!(error.to_string().contains("absolute")); + remove_repo(&root); + } + + #[test] + fn preserves_spaces_in_repository_relative_paths() { + let root = temp_repo("spaces"); + let cwd = root.join("folder with spaces"); + fs::create_dir(&cwd).expect("spaced cwd should be created"); + let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "file with spaces.rs") + .expect("spaced paths should resolve"); + assert_eq!(result, "folder with spaces/file with spaces.rs"); + remove_repo(&root); + } + + #[test] + fn resolves_all_operation_paths_in_one_event() { + let root = temp_repo("operations"); + let cwd = root.join("src"); + fs::create_dir(&cwd).expect("src directory should be created"); + let mut patch = CodexPatch { + operations: vec![ + CodexFileOperation::Add { + path: "new.rs".to_string(), + lines: vec!["new".to_string()], + }, + CodexFileOperation::Update { + old_path: "old.rs".to_string(), + new_path: Some("moved.rs".to_string()), + hunks: Vec::new(), + }, + CodexFileOperation::Delete { + path: "gone.rs".to_string(), + }, + ], + }; + + resolve_codex_patch_paths(&root, &cwd.to_string_lossy(), &mut patch) + .expect("all operation paths should resolve"); + assert_eq!( + patch.operations[0], + CodexFileOperation::Add { + path: "src/new.rs".to_string(), + lines: vec!["new".to_string()], + } + ); + assert_eq!( + patch.operations[1], + CodexFileOperation::Update { + old_path: "src/old.rs".to_string(), + new_path: Some("src/moved.rs".to_string()), + hunks: Vec::new(), + } + ); + assert_eq!( + patch.operations[2], + CodexFileOperation::Delete { + path: "src/gone.rs".to_string(), + } + ); + remove_repo(&root); + } +} diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index 8dcc2a56..d471b539 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -99,23 +99,48 @@ fn run_codex_subcommand_from_payload( repository_root: &Path, stdin_payload: &str, logger: Option<&dyn Logger>, +) -> Result { + run_codex_subcommand_from_payload_with_state_root(repository_root, stdin_payload, logger, None) +} + +#[cfg(test)] +fn run_codex_subcommand_from_payload_at_state_root( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + state_root: &Path, +) -> Result { + run_codex_subcommand_from_payload_with_state_root( + repository_root, + stdin_payload, + logger, + Some(state_root), + ) +} + +fn run_codex_subcommand_from_payload_with_state_root( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + state_root: Option<&Path>, ) -> Result { let event: CodexHookEvent = serde_json::from_str(stdin_payload) .context("Invalid Codex hook payload from STDIN: expected valid JSON.")?; Ok(match classify_codex_event(&event) { - CodexDispatchArm::UserPromptSubmit => { - user_prompt_submit::handle(repository_root, &event)? - } + CodexDispatchArm::UserPromptSubmit => user_prompt_submit::handle(repository_root, &event)?, CodexDispatchArm::Stop => stop::handle(repository_root, &event)?, CodexDispatchArm::PreToolUseBash => bash_policy::handle(repository_root, &event)?, - CodexDispatchArm::PostToolUseApplyPatch => { - apply_patch::handle(repository_root, &event, logger)? - } - CodexDispatchArm::NoOp => format!( - "codex hooks: no-op for unsupported event/tool combination (hook_event_name='{}', tool_name={:?}).", - event.hook_event_name, event.tool_name - ), + CodexDispatchArm::PostToolUseApplyPatch => match state_root { + Some(state_root) => apply_patch::handle_with_state_root( + repository_root, + &event, + Some(state_root), + logger, + )?, + None => apply_patch::handle(repository_root, &event, logger)?, + }, + CodexDispatchArm::NoOp => String::new(), }) } @@ -124,12 +149,24 @@ fn log_codex_fail_open(error: &anyhow::Error, logger: Option<&dyn Logger>) -> St log.error("sce.hooks.codex.error", &error.to_string(), &[], None); } - String::from("codex hook intake failed open; error logged.") + String::new() } #[cfg(test)] mod tests { - use std::path::Path; + use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + use serde_json::json; + + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, + resolve_agent_trace_storage_for_hook_runtime_at_state_root, AgentTraceStorageContext, + }; use super::*; @@ -228,7 +265,7 @@ mod tests { let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload, None) .expect("no-op dispatch should succeed"); - assert!(output.contains("no-op")); + assert_eq!(output, ""); } #[test] @@ -238,7 +275,7 @@ mod tests { let output = run_codex_subcommand_from_payload(Path::new("/tmp"), payload, None) .expect("no-op dispatch should succeed"); - assert!(output.contains("no-op")); + assert_eq!(output, ""); } #[test] @@ -255,6 +292,273 @@ mod tests { let output = log_codex_fail_open(&error, None); - assert_eq!(output, "codex hook intake failed open; error logged."); + assert_eq!(output, ""); + } + + fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "sce-codex-pipeline-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("temporary directory should be created"); + path + } + + fn git(repository_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repository_root) + .output() + .unwrap_or_else(|error| panic!("git {args:?} failed to spawn: {error}")); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn initialize_repository(label: &str) -> (PathBuf, PathBuf) { + let repository_root = unique_temp_dir(&format!("{label}-repo")); + git(&repository_root, &["init", "-q"]); + git( + &repository_root, + &[ + "remote", + "add", + "origin", + "https://example.invalid/codex-t19.git", + ], + ); + let state_root = unique_temp_dir(&format!("{label}-state")); + let context = AgentTraceStorageContext { + repository_root: &repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }; + let storage = resolve_agent_trace_storage_at_state_root(&context, &state_root) + .expect("repository Agent Trace DB should initialize"); + drop(storage); + (repository_root, state_root) + } + + fn codex_apply_patch_payload( + cwd: &Path, + session_id: &str, + model: &str, + tool_use_id: &str, + command: &str, + ) -> String { + json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "turn_id": "turn-realistic", + "cwd": cwd, + "model": model, + "tool_name": "apply_patch", + "tool_use_id": tool_use_id, + "tool_input": {"command": command}, + "tool_response": {"success": true} + }) + .to_string() + } + + #[test] + #[allow(clippy::too_many_lines)] + fn realistic_post_tool_use_patch_flows_through_repository_db_and_post_commit_attribution() { + let (repository_root, state_root) = initialize_repository("end-to-end"); + let source_dir = repository_root.join("src"); + fs::create_dir_all(&source_dir).expect("source directory should be created"); + fs::write(source_dir.join("lib.rs"), "prefix\nold_line\nsuffix\n") + .expect("initial source should be written"); + git(&repository_root, &["add", "."]); + git( + &repository_root, + &[ + "-c", + "user.name=SCE Test", + "-c", + "user.email=sce@example.invalid", + "commit", + "-qm", + "initial", + ], + ); + + let command = "<<\"EOF\"\n*** Begin Patch\n*** Update File: lib.rs\n@@\n-old_line\n+new_line\n*** End Patch\nEOF"; + let payload = codex_apply_patch_payload( + &source_dir, + " session-realistic ", + "custom/codex-model", + "tool-realistic-1", + command, + ); + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("realistic Codex PostToolUse dispatch should succeed"); + assert_eq!(output, "", "successful apply_patch hooks are silent"); + + fs::write(source_dir.join("lib.rs"), "prefix\nnew_line\nsuffix\n") + .expect("updated source should be written"); + git(&repository_root, &["add", "."]); + git( + &repository_root, + &[ + "-c", + "user.name=SCE Test", + "-c", + "user.email=sce@example.invalid", + "commit", + "-qm", + "apply patch", + ], + ); + + let context = AgentTraceStorageContext { + repository_root: &repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }; + let storage = + resolve_agent_trace_storage_for_hook_runtime_at_state_root(&context, &state_root) + .expect("repository Agent Trace DB should reopen"); + let recent = storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("stored Codex patch should be queryable"); + assert_eq!(recent.loaded_count(), 1); + let stored_file = &recent.patches[0].patch.files[0]; + assert_eq!(recent.patches[0].session_id, "cx_session-realistic"); + assert_eq!(recent.patches[0].tool_name.as_deref(), Some("codex")); + assert_eq!(recent.patches[0].tool_version, None); + assert_eq!(recent.patches[0].payload_type, "patch"); + assert_eq!(recent.patches[0].patch.files.len(), 1); + assert_eq!(stored_file.old_path, "src/lib.rs"); + assert_eq!(stored_file.new_path, "src/lib.rs"); + assert_eq!( + stored_file.hunks[0].model_id.as_deref(), + Some("custom/codex-model") + ); + + let flow = super::super::run_post_commit_intersection_flow_with( + &repository_root, + super::super::capture_post_commit_patch_from_git, + super::super::current_unix_time_ms, + |cutoff_ms, end_ms| storage.db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| { + storage + .db + .insert_post_commit_patch_intersection(insert) + .map(|_| ()) + }, + ) + .expect("post-commit intersection should use the stored Codex evidence"); + let trace = super::super::run_post_commit_agent_trace_flow_with( + &flow, + None, + "https://example.invalid/codex-t19.git", + |value| { + crate::services::agent_trace::validate_agent_trace_value(value) + .map_err(|error| anyhow::anyhow!(error.to_string())) + }, + |insert| storage.db.insert_agent_trace(insert).map(|_| ()), + ) + .expect("post-commit Agent Trace should persist"); + assert_eq!( + trace.tool.as_ref().and_then(|tool| tool.name.as_deref()), + Some("codex") + ); + + let intersections = storage + .db + .query_map( + "SELECT intersection_patch FROM post_commit_patch_intersections ORDER BY id", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("intersection row should be queryable"); + assert_eq!(intersections.len(), 1); + let intersection: serde_json::Value = + serde_json::from_str(&intersections[0]).expect("intersection JSON should parse"); + assert_eq!( + intersection["files"][0]["hunks"][0]["lines"][1]["session_id"], + "cx_session-realistic" + ); + + let traces = storage + .db + .query_map( + "SELECT trace_json FROM agent_traces ORDER BY id", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("Agent Trace row should be queryable"); + assert_eq!(traces.len(), 1); + let trace_json: serde_json::Value = + serde_json::from_str(&traces[0]).expect("stored Agent Trace JSON should parse"); + assert_eq!(trace_json["tool"]["name"], "codex"); + assert_eq!( + trace_json["files"][0]["conversations"][0]["contributor"]["model_id"], + "custom/codex-model" + ); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + #[test] + fn delete_only_and_pure_rename_apply_patch_events_persist_no_rows() { + let (repository_root, state_root) = initialize_repository("no-row-boundaries"); + let delete_payload = codex_apply_patch_payload( + &repository_root, + "session-delete", + "custom/model", + "tool-delete", + "*** Begin Patch\n*** Delete File: obsolete.txt\n*** End Patch", + ); + let rename_payload = codex_apply_patch_payload( + &repository_root, + "session-rename", + "custom/model", + "tool-rename", + "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** End Patch", + ); + + for payload in [delete_payload, rename_payload] { + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("delete and pure-rename hooks should fail open successfully"); + assert_eq!(output, ""); + } + + let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root: &repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("repository Agent Trace DB should reopen"); + let recent = storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 0); + assert_eq!(recent.skipped_count(), 0); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); } } diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs index 68109d77..f9f902a7 100644 --- a/cli/src/services/hooks/codex/stop.rs +++ b/cli/src/services/hooks/codex/stop.rs @@ -63,7 +63,7 @@ where }]) .context("Failed to insert Codex Stop text part row.")?; - Ok("codex hooks: Stop captured into messages/parts.".to_string()) + Ok(String::new()) } fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { @@ -158,7 +158,7 @@ mod tests { let output = capture_with(&db, &event("session-1", "turn-1", "hello back"), || 1_000) .expect("capture should succeed"); - assert!(output.contains("Stop")); + assert_eq!(output, ""); assert_eq!( message_rows(&db), diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index d4e78b9d..6eaecfa2 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -60,7 +60,7 @@ where }]) .context("Failed to insert Codex UserPromptSubmit text part row.")?; - Ok("codex hooks: UserPromptSubmit captured into messages/parts.".to_string()) + Ok(String::new()) } fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { @@ -155,7 +155,7 @@ mod tests { let output = capture_with(&db, &event("session-1", "turn-1", "hello world"), || 1_000) .expect("capture should succeed"); - assert!(output.contains("UserPromptSubmit")); + assert_eq!(output, ""); assert_eq!( message_rows(&db), diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index e87ac7e7..76ac91a0 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -49,7 +49,6 @@ const OPENCODE_TOOL_NAME: &str = "opencode"; const CLAUDE_TOOL_NAME: &str = "claude"; const PI_TOOL_NAME: &str = "pi"; const CODEX_TOOL_NAME: &str = "codex"; -const OPENAI_MODEL_ID_PREFIX: &str = "openai/"; const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; type PayloadValidationError = fn(&str) -> String; @@ -1039,11 +1038,7 @@ fn normalize_codex_model_id(model: &str) -> Option { return None; } - if normalized.starts_with(OPENAI_MODEL_ID_PREFIX) { - Some(normalized.to_string()) - } else { - Some(format!("{OPENAI_MODEL_ID_PREFIX}{normalized}")) - } + Some(normalized.to_string()) } /// Extract a u64 timestamp from a Claude hook event payload, falling back to the @@ -2751,21 +2746,33 @@ mod tests { } #[test] - fn normalize_codex_model_id_prefixes_fresh_model_id() { + fn normalize_codex_model_id_preserves_fresh_model_id() { assert_eq!( normalize_codex_model_id("gpt-5.6-codex").as_deref(), - Some("openai/gpt-5.6-codex") + Some("gpt-5.6-codex") ); } #[test] - fn normalize_codex_model_id_keeps_already_prefixed_model_id() { + fn normalize_codex_model_id_preserves_qualified_model_ids() { + for model in ["openai/gpt-x", "qualified/custom-provider/model"] { + assert_eq!(normalize_codex_model_id(model).as_deref(), Some(model)); + } + } + + #[test] + fn normalize_codex_model_id_preserves_unqualified_model_ids() { assert_eq!( - normalize_codex_model_id("openai/gpt-5.6-codex").as_deref(), - Some("openai/gpt-5.6-codex") + normalize_codex_model_id("custom-codex-model").as_deref(), + Some("custom-codex-model") ); } + #[test] + fn normalize_codex_model_id_returns_none_for_blank_model_ids() { + assert_eq!(normalize_codex_model_id(" "), None); + } + #[test] fn pi_normalized_diff_trace_payload_persists_with_pi_prefixed_session_id() { let stdin_payload = serde_json::json!({ diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl index 2ba1c6a5..3f5bb841 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -17,7 +17,10 @@ local missingSceInstallMessage = "sce CLI not found. Install it from https://sce local codexSceHookScriptPath = ".codex/hooks/run-sce-or-show-install-guidance.sh" -local codexSceHookCommand = "bash \\\"\(codexSceHookScriptPath)\\\" sce hooks codex" +/// Codex invokes hooks with the event cwd, which may be nested below the +/// repository root. Resolve that root at invocation time and fail open when +/// Git cannot resolve it; the quoted expansion keeps spaces in the root safe. +local codexSceHookCommand = "root=\\\"$(git rev-parse --show-toplevel 2>/dev/null)\\\" || exit 0; exec bash \\\"$root/\(codexSceHookScriptPath)\\\" sce hooks codex" /// Every Codex lifecycle event Codex routes to the SCE hook is dispatched /// through a single command (`sce hooks codex`); the typed dispatcher inside diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index dce5ff60..1f787338 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -56,6 +56,27 @@ hidden generatedArtifacts = new Mapping { } } +local assertCodexHookInvocationContract = (artifacts: Mapping) -> + let (text = artifacts["config/.codex/hooks.json"]) + if ( + text.split("\"UserPromptSubmit\"").length == 2 + && text.split("\"Stop\"").length == 2 + && text.split("\"PreToolUse\"").length == 2 + && text.split("\"PostToolUse\"").length == 2 + && text.split("\"type\": \"command\"").length == 5 + && text.split("\"matcher\": \"Bash\"").length == 2 + && text.split("\"matcher\": \"apply_patch\"").length == 2 + && !text.contains("\"$schema\"") + && text.contains("git rev-parse --show-toplevel") + && text.contains("2>/dev/null") + && text.contains("|| exit 0") + && text.contains("exec bash") + && text.contains("$root/.codex/hooks/run-sce-or-show-install-guidance.sh") + && text.contains("sce hooks codex") + && !text.contains("eval") + ) "Codex hook invocation: four registrations use safe repository-root resolution" + else throw("Codex hook invocation must resolve the Git root safely and preserve the exact four registrations") + hidden workflowDocuments = new Mapping { for (path, document in opencode.skillDocuments) { ["config/.opencode/skills/\(path)"] = document.text @@ -790,6 +811,7 @@ hidden assertValidateExcludesDecisionAndPlanSync = (documents: Mapping) -> contractChecks { ["artifact-paths"] = assertExactArtifactPaths.apply(generatedArtifacts) + ["codex-hook-invocation"] = assertCodexHookInvocationContract.apply(generatedArtifacts) ["optional-workflow-manifest"] = assertOptionalWorkflowManifest.apply(generatedArtifacts) ["workflow-references"] = assertWorkflowReferences.apply(workflowDocuments) ["workflow-helper-composition"] = assertWorkflowHelperComposition.apply(compositeWorkflowDocuments) diff --git a/context/architecture.md b/context/architecture.md index 071d927e..47890155 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -38,7 +38,7 @@ Current target renderer helper modules: - `config/pkl/generator-inputs.txt` (machine-readable repository-relative declaration of canonical Pkl and referenced plugin/extension inputs) - `scripts/produce-cli-generated-input.sh` (canonical generated-input producer for input discovery, two-pass evaluation, determinism and input-mutation checks, exact payload/input inventories, atomic publication, and temporary-state cleanup; consumed by the repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation) - `config/pkl/check-generated.sh` (dev-shell integration check that delegates deterministic generation and inventories to the producer while retaining metadata/contract fixtures, required outputs, forbidden repository generated paths, and the stray repository-local `config/pkl/rendered` evaluation artifact) -- `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,pkl-generated,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format,workflow-actionlint}` plus Linux-only `flatpak-static-validation`, `cargo-sources-parity`, and `flatpak-manifest-parity` (root-flake checks for CLI behavior, ephemeral Pkl generation, JS validation, workflow linting, and lightweight Flatpak validation) +- `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,pkl-generated,codex-hook-command,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format,workflow-actionlint}` plus Linux-only `flatpak-static-validation`, `cargo-sources-parity`, and `flatpak-manifest-parity` (root-flake checks for CLI behavior, ephemeral Pkl generation, Codex hook invocation, JS validation, workflow linting, and lightweight Flatpak validation) - `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden fixture coverage (fully Rust-owned; the Claude TypeScript Bun test was removed in T07). The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, the Pi extension entrypoint, generated OpenCode package manifests, and generated Claude project settings). @@ -48,17 +48,17 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the hook script locates itself via a project-root-relative path rather than an env var, since no Codex analog to `$CLAUDE_PROJECT_DIR` is established. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers; `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, normalizes Add/Update evidence into an SCE unified diff under deterministic patch-local synthetic line numbers, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — Delete-File operations and a `Move to` with no changed lines produce no evidence (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open as a deterministic no-op. Bash-triggered filesystem mutations remain untracked for Codex. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers; `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). - Target renderers remain responsible for formatting target-supported metadata. OpenCode metadata owns thin-agent presentation and compatibility while deriving the ordered permission blocks — non-SCE wildcard allow, `sce-*` wildcard deny, then catalog-owned workflow allows — from catalog role assignments; OpenCode command routing derives the same role and skill identity from the catalog. Claude metadata derives command tools from catalog records. Pi has no metadata module because it adds no target-specific frontmatter. - `config/pkl/renderers/metadata-coverage-check.pkl` derives commands and exact package-relative workflow-document expectations from the typed catalog and the workflow-document inventories, adds the unchanged phase-free and decision-package expectations, verifies every command's one-to-one workflow-skill route for OpenCode/Claude/Pi, asserts the same exact skill-document inventory for Codex (no command-route check, since Codex has no commands), and forces every rendered document and target metadata lookup to evaluate. -- `config/pkl/renderers/generation-contract-check.pkl` independently derives the complete expected artifact paths from those target document inventories plus explicitly retained non-workflow assets, compares them with `generate.pkl`'s `output.files`, and requires the exact path count declared by the current generation contract — stated as a literal `expectedArtifactPathCount` inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts the generated `config/optional-workflows.json` against the catalog (`optional-workflow-manifest`): every optional workflow appears with its catalog title and both slugs, no core workflow id appears, and `schemaVersion` is present. It also verifies that every required phase reference exists and is cited by its owning `SKILL.md`, scans generated workflow entrypoint `SKILL.md` documents for stale phase-skill slugs and unresolved package-local reference tokens while allowing package-local reference prose to mention its own persisted-format history, asserts the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions on every generated workflow skill, asserts the exact cross-target `sce-decision` paths plus its required gate, status, immutability, handoff, and ADR-template content, permits `sce-decision` references only in `sce-next-task`, verifies the exact catalog-derived OpenCode skill permission order and Code-only OpenCode decision permission, asserts every explicit `sce-*` allow names an emitted OpenCode skill artifact, asserts the generated `sce-handover` `SKILL.md` covers both writer- and loader-mode content on all four targets, asserts the generated `sce-brownfield` `SKILL.md` still carries the bootstrap gate, documentation-discovery sweep, no-network rule, sub-`50` blocking threshold, always-disclosed contradiction contract, and additive-vs-`rebuild` write rule on all four targets, rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), and rejects any generated `SKILL.md` that reproduces one of its sibling `references/output.md` fenced layouts verbatim (`output-dedup`, matched fence markers included), plus nineteen semantic checks for layout-heading resolution, package-local path existence, forbidden validate/commit files, consolidated atomic-commit content, next-task report ownership, cross-target reference parity, stale synchronization wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording (`plan-review-sync-debt-recovery`, asserting the generated `sce-next-task/references/plan-review.md` states both the sync-debt recovery and legacy-migration-failure behavior), the compact completed-task record model (four checks replacing the removed `handoff-identity-fields` persisted-handoff check: `compact-plan-template-schema`, asserting the generated plan-template's new-task and completion examples use the compact `Scope`/`Done when`/`Verify`/`Result`/`Files changed`/`Context impact`/`Context synchronization` fields and name none of the removed `Goal`/`Boundaries (in/out of scope)`/`Verification notes`/`Implementation evidence`/`Verification evidence`/`Context synchronization handoff` fields; `next-task-compact-completion-writing`, asserting `task-execution.md` records execution facts directly on the completed task with no separate handoff/evidence construction; `plan-review-reads-completed-record`, asserting `plan-review.md`'s sync-debt recovery reads the completed task record directly by plan path and task ID rather than a persisted handoff; and `context-sync-validates-task-record`, asserting `context-sync.md` validates the completed task record rather than a persisted handoff), the `/next-task` sync-debt-recovery branch's reference-before-invocation ordering (`sync-debt-recovery-branch`, asserting its citation of `references/context-sync.md` precedes any instruction to run the Task context synchronization phase), the synchronization-debt scan's all-completed-task scope (`plan-review-all-tasks-scope`, asserting `plan-review.md` covers every completed task with no surviving position-relative wording), the sync-debt-recovery branch's blocked-outcome layout routing (`sync-debt-blocked-routing`, asserting its `blocked` branch cites the **Context synchronization blocked** layout rather than **Review blocked**), and the `sce-validate` decision/plan-sync exclusion (`validate-decision-sync-boundary`, asserting no generated `sce-validate` document contains a `sce-decision` reference or plan-context-sync wording). Checked-in negative fixtures prove the existing and nineteen semantic contract failures. +- `config/pkl/renderers/generation-contract-check.pkl` independently derives the complete expected artifact paths from those target document inventories plus explicitly retained non-workflow assets, compares them with `generate.pkl`'s `output.files`, and requires the exact path count declared by the current generation contract — stated as a literal `expectedArtifactPathCount` inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts the generated `config/optional-workflows.json` against the catalog (`optional-workflow-manifest`): every optional workflow appears with its catalog title and both slugs, no core workflow id appears, and `schemaVersion` is present. It also checks that Codex's four hook registrations share the root-aware, quoted, no-`eval`, fail-open invocation contract. It also verifies that every required phase reference exists and is cited by its owning `SKILL.md`, scans generated workflow entrypoint `SKILL.md` documents for stale phase-skill slugs and unresolved package-local reference tokens while allowing package-local reference prose to mention its own persisted-format history, asserts the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions on every generated workflow skill, asserts the exact cross-target `sce-decision` paths plus its required gate, status, immutability, handoff, and ADR-template content, permits `sce-decision` references only in `sce-next-task`, verifies the exact catalog-derived OpenCode skill permission order and Code-only OpenCode decision permission, asserts every explicit `sce-*` allow names an emitted OpenCode skill artifact, asserts the generated `sce-handover` `SKILL.md` covers both writer- and loader-mode content on all four targets, asserts the generated `sce-brownfield` `SKILL.md` still carries the bootstrap gate, documentation-discovery sweep, no-network rule, sub-`50` blocking threshold, always-disclosed contradiction contract, and additive-vs-`rebuild` write rule on all four targets, rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), and rejects any generated `SKILL.md` that reproduces one of its sibling `references/output.md` fenced layouts verbatim (`output-dedup`, matched fence markers included), plus twenty semantic checks for layout-heading resolution, package-local path existence, forbidden validate/commit files, consolidated atomic-commit content, next-task report ownership, cross-target reference parity, stale synchronization wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording (`plan-review-sync-debt-recovery`, asserting the generated `sce-next-task/references/plan-review.md` states both the sync-debt recovery and legacy-migration-failure behavior), the compact completed-task record model (four checks replacing the removed `handoff-identity-fields` persisted-handoff check: `compact-plan-template-schema`, asserting the generated plan-template's new-task and completion examples use the compact `Scope`/`Done when`/`Verify`/`Result`/`Files changed`/`Context impact`/`Context synchronization` fields and name none of the removed `Goal`/`Boundaries (in/out of scope)`/`Verification notes`/`Implementation evidence`/`Verification evidence`/`Context synchronization handoff` fields; `next-task-compact-completion-writing`, asserting `task-execution.md` records execution facts directly on the completed task with no separate handoff/evidence construction; `plan-review-reads-completed-record`, asserting `plan-review.md`'s sync-debt recovery reads the completed task record directly by plan path and task ID rather than a persisted handoff; and `context-sync-validates-task-record`, asserting `context-sync.md` validates the completed task record rather than a persisted handoff), the `/next-task` sync-debt-recovery branch's reference-before-invocation ordering (`sync-debt-recovery-branch`, asserting its citation of `references/context-sync.md` precedes any instruction to run the Task context synchronization phase), the synchronization-debt scan's all-completed-task scope (`plan-review-all-tasks-scope`, asserting `plan-review.md` covers every completed task with no surviving position-relative wording), the sync-debt-recovery branch's blocked-outcome layout routing (`sync-debt-blocked-routing`, asserting its `blocked` branch cites the **Context synchronization blocked** layout rather than **Review blocked**), and the `sce-validate` decision/plan-sync exclusion (`validate-decision-sync-boundary`, asserting no generated `sce-validate` document contains a `sce-decision` reference or plan-context-sync wording). Checked-in negative fixtures continue to prove the existing semantic contract failures, while the Codex invocation assertion is exercised by the dedicated generated command check. - OpenCode, Claude, Pi, and Codex renderers expose flattened `{skill slug}/{package-relative path}` skill documents consumed by `config/pkl/generate.pkl` (OpenCode, Claude, and Pi also expose command documents; Codex exposes none); every target's flattened skill-document inventory contains `SKILL.md` and `references/output.md` for each workflow slug plus `sce-decision/SKILL.md` and `sce-decision/references/adr-template.md`. - `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the Codex target tree under `config/.agents/skills/` (the same workflow-package inventories as Pi, byte-identical bodies, no commands, no agents, no settings/plugin manifest) plus its separate `.codex/hooks.json` and `.codex/hooks/run-sce-or-show-install-guidance.sh` hook-registration outputs; the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. - Generated-file warning markers are not injected by the generator: Markdown outputs render deterministic frontmatter + body, and shared library outputs are emitted without a leading generated warning header. -- `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, rejects the repository-local `config/pkl/rendered` evaluation artifact before generation, rejects committed target trees, the generated SCE schema, and `cli/assets/generated`, evaluates exact metadata and generation contracts, confirms the existing and nineteen semantic negative fixtures fail with their contract diagnostics, then delegates two-pass generation, input checks, and payload inventories to `scripts/produce-cli-generated-input.sh`. It projects the producer inventory only to preserve the established report digest path format; it does not rehash generated files. Required-path checks remain fast surface diagnostics, the Pkl contract owns exact complete-path coverage, and forbidden-output checks reject removed generator surfaces. +- `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, rejects the repository-local `config/pkl/rendered` evaluation artifact before generation, rejects committed target trees, the generated SCE schema, and `cli/assets/generated`, evaluates exact metadata and generation contracts, confirms the existing semantic negative fixtures fail with their contract diagnostics, then delegates two-pass generation, input checks, and payload inventories to `scripts/produce-cli-generated-input.sh`. It projects the producer inventory only to preserve the established report digest path format; it does not rehash generated files. Required-path checks remain fast surface diagnostics, the Pkl contract owns exact complete-path coverage, and forbidden-output checks reject removed generator surfaces. Generated authored classes: @@ -139,7 +139,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `sce sync [--format text|json]` is implemented: `cli/src/services/sync/sync.rs` resolves repository-scoped Agent Trace storage, authenticates against the control plane with stored WorkOS credentials, uses the config-resolved `control_plane_base_url` with baked default `https://sce.crocoderlab.dev`, calls the ingestion `/state` endpoint once, then starts the `messages`/`parts`/`diff_traces`/`agent_traces` capture-stream state machines concurrently via `AgentTraceExportReader` and a shared per-stream reconciliation engine. Batches and cursor refreshes remain sequential within each stream, while fixed stream order is retained for final and stream-completion reporting; `cli/src/services/sync/render_sync.rs` renders the converged `AgentTraceSyncReport` as concise per-stream text or `camelCase` JSON without a nested subcommand field (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization otherwise still flow through lifecycle providers aggregated by setup, while repository-scoped DB health/repair flows through the doctor surface. The former trace database inspection and nested sync surfaces are unavailable. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. - `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It derives Claude `PostToolUse` `Write` structured-update hunks, `Write` `tool_input.content` create fallback, and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free. It is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). -- `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, checkout identity, bash_policy, version, completion, help, patch, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/checkout/` owns checkout ID file infrastructure (the per-checkout Agent Trace DB opener/path helper was removed by the `retire-legacy-agent-trace-db` plan); active setup/hooks use `agent_trace_storage` to establish checkout identity as diagnostics and initialize/open the repository-scoped DB, while `sce doctor` surfaces checkout identity facts plus credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. +- `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, checkout identity, bash_policy, version, completion, help, patch, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/checkout/` owns checkout ID file infrastructure (the per-checkout Agent Trace DB opener/path helper was removed by the `retire-legacy-agent-trace-db` plan); active setup/hooks use `agent_trace_storage` to establish checkout identity as diagnostics and initialize/open the repository-scoped DB, while `sce doctor` surfaces checkout identity facts plus credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers; Codex delegates in-process to the same evaluator for its native `PreToolUse(Bash)` response. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.95.0.default` with `rustfmt` + `clippy`, and reads package/check version from repo-root `.version`. Its `cliGeneratedInput` derivation invokes the shared generated-input producer from a declarative source containing the producer and canonical inputs, then publishes the producer-validated handoff as its store output. Native, release, test, and Clippy Cargo derivations receive that same store path through `SCE_CLI_GENERATED_INPUT_DIR`; their Cargo environments exclude Pkl and assert that it is unavailable. Repository-mode `cli/build.rs` validates the handoff before copying it into `OUT_DIR`; published crates use the validated packaging-only fallback. The build script stages SQL under `OUT_DIR/static/migrations` and writes `OUT_DIR/generated_migrations.rs` with deterministic migration constants sorted by numeric filename prefix. - Crane dependency-only derivations and `cli-fmt` intentionally do not receive `SCE_CLI_GENERATED_INPUT_DIR`, so canonical generation changes invalidate the producer and compiling derivations while preserving host/musl dependency artifacts and formatting. The root flake runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed paths and exposes `cli-generated-input` as the focused payload/inventory integrity check. It also exposes directory-scoped JS validation derivations for `npm/` and `config/lib/`, while `pkl-generated` uses a narrow canonical-input source set plus maybe-missing forbidden paths so reintroduced generated repository artifacts fail the check. @@ -201,5 +201,5 @@ Shared Context Plan and Shared Context Code remain separate architectural roles. - Doctor follows that target capability boundary in its installed-asset inventory: Claude exposes only `Plugins`, `Commands`, and `Skills`, while OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; the shared `IntegrationArea::Agents` model remains for OpenCode. - The canonical `/change-to-plan` workflow sequences `sce-context-load` and `sce-plan-authoring`; `/next-task` sequences `sce-plan-review`, `sce-task-execution`, and `sce-task-context-sync`; `/validate` runs `sce-validation` only and reports its Validation Report; `/commit` sequences around `sce-atomic-commit`; `/handover` and `/brownfield` have no sibling phases at all — their single `sce-handover` and `sce-brownfield` skills own their whole routing directly. Those phase modules are canonical authoring source; no target generates them as packages. - Every target embeds those same phase boundaries inside `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`, so no generated command or prompt invokes a phase or sibling SCE package. Workflow skills may use relevant non-SCE helpers inside the active step, but the helper returns control to that step; the only SCE sibling invocation remains the successful task-synchronization decision gate's bounded `sce-decision` call. -- OpenCode, Claude, and Pi all generate `/handover` routed to exactly `sce-handover` (see [Handover workflow](sce/handover-workflow.md)) and `/brownfield` routed to exactly `sce-brownfield` (see [Brownfield workflow](sce/brownfield-workflow.md)); the automated OpenCode profile is removed. +- OpenCode, Claude, Pi, and Codex all generate `/handover` routed to exactly `sce-handover` (see [Handover workflow](sce/handover-workflow.md)) and `/brownfield` routed to exactly `sce-brownfield` (see [Brownfield workflow](sce/brownfield-workflow.md)); the automated OpenCode profile is removed. - `/brownfield` is the only workflow outside the task synchronization phase authorized to write durable `context/`, under its own additive-by-default boundary; see [Context workflow rules](sce/context-workflow-rules.md). diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index f44b4de4..63891f8c 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -62,7 +62,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m `auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `whoami` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. Authenticated `sce auth whoami` retrieves the authoritative profile from the Control Plane `GET /me` endpoint and renders flat text labels for `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name`; missing first/last names render empty and missing role, permissions, or workspace values render `none`. `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi/Codex targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and (via a build-time staging merge of `config/.agents/**` + `config/.codex/**`) `config/codex-target/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. -`setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. Codex is the one target whose embedded relative paths already carry their own output-root prefix (`.agents/...`, `.codex/...`), so its destination root is the repository root itself rather than a single `.codex/`-style subdirectory. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. +`setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. Codex is the one target whose embedded relative paths already carry their own output-root prefix (`.agents/...`, `.codex/...`), so its destination root is the repository root itself rather than a single `.codex/`-style subdirectory. Its generated hook command resolves that repository root at invocation time, so Codex events from nested cwd and repositories with spaces reach the installed helper safely; Git-root failure is a silent successful no-op, while the helper preserves missing-CLI stderr guidance and STDIN forwarding. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. `setup` now executes end-to-end and prints deterministic completion details including selected target(s) and per-target install count. `doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. The former Agent Trace database inspection routes are unavailable; doctor owns repository-scoped Agent Trace DB health and checkout-identity diagnostics. The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and post-commit Agent Trace auto-sync readiness derived from canonical managed-block currency plus resolved configuration. The readiness fact reports enabled/current, explicit disabled, not-ready, and not-applicable states in text and JSON without launching synchronization; Claude's inventory is only `Plugins`, `Commands`, and `Skills`, while OpenCode retains `Agents`. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. `sce sync [--format text|json]` is the implemented user-invocable synchronization command: it synchronizes the current repository's Agent Trace DB with the control-plane ingestion API; local DB and Agent Trace DB bootstrap continue to happen through `setup`, and DB health/repair continues to happen through `doctor`. See [agent-trace-sync-command.md](agent-trace-sync-command.md) and [sync-command.md](sync-command.md). diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index f40202f0..a5d6ff76 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -66,6 +66,25 @@ Both functions wrap `serde_json::from_str`/`serde_json::from_slice` and map serd - **Determinism**: the same inputs in the same order always produce the same output - **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches before intersecting (see `agent-trace-hooks-command-routing.md`). +### Codex apply_patch boundary + +Codex `PostToolUse(apply_patch)` evidence enters this service only after the +Codex-specific outer wrapper normalization, canonical parsing, and event-cwd +path resolution have produced safe repository-relative paths. Its normalizer +emits only provable Add/Update touched lines as `Index:`-form text, assigning +bounded deterministic synthetic line identities from `tool_use_id`; those +identities are evidence keys, not physical source line numbers. Delete File, +pure rename, and Bash filesystem mutations produce no line-level evidence. + +The existing `combine_patches` and `intersect_patches` operations remain +unchanged. Their historical `kind` + `content` fallback reconciles synthetic +Codex positions with real post-commit positions, while repeated identical +content can remain physically ambiguous because Codex supplies no true line +ranges and SCE takes no filesystem snapshot. The Codex handler persists through +the existing `diff_traces` row shape and the post-commit runtime consumes it +through the same combination/intersection path; no Codex-specific Agent Trace +builder, pending state, or schema migration is introduced. + ### Runtime wiring status | Operation | Wired into | Notes | diff --git a/context/context-map.md b/context/context-map.md index fa70694e..1f9da5a1 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -75,7 +75,7 @@ Feature/domain context: - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) -- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, unregistered), idempotent `cx_` session prefixing and `openai/` model-ID normalization, the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice parsing/normalizing/persisting a `diff_traces` row for provable Add/Update evidence under deterministic patch-local synthetic line numbers) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, unregistered), idempotent `cx_` session prefixing with required trimmed non-empty sessions and truthful reported model-ID preservation (blank models are absent), the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice outer-normalizing then parsing, resolving paths from event cwd against the real Git root, normalizing, and persisting a `diff_traces` row for provable Add/Update evidence under deterministic event-scoped synthetic line identities derived from `tool_use_id`; generated hook commands also resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; all non-policy success/fail-open paths are silent while Bash denial retains Codex's structured response) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) @@ -98,6 +98,9 @@ Supporting repo docs: Recent decision records: +- `context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md` (uses bounded, deterministic `tool_use_id`-derived synthetic line identities for Codex apply_patch evidence; positions are evidence identities rather than source line numbers, with existing patch combination/intersection semantics unchanged) +- `context/decisions/2026-08-23-codex-truthful-model-provenance.md` (preserves non-empty Codex model IDs unchanged, leaves blank/missing values nullable, and forbids inferred provider prefixes or a fabricated provider field) +- `context/decisions/2026-08-23-codex-root-aware-hook-invocation.md` (requires generated Codex hook commands to resolve the Git root at invocation time, quote the helper path, preserve STDIN, and fail open when root resolution fails) - `context/decisions/2026-08-14-compact-task-record-supersedes-handoff.md` (the completed task record — `Completed`/`Files changed`/`Result`/`Verify`/`Context impact`/`Context synchronization`, identified only by plan path and task ID — is the sole durable input for immediate and cross-session task synchronization, with no separate persisted `Context synchronization handoff` structure; supersedes only the handoff-shape portion of `2026-08-12-persist-workflow-sync-lifecycle-in-plans.md`, whose `pending`/`synced`/`blocked` lifecycle-state invariant remains in force) - `context/decisions/2026-08-12-decision-gate-semantics.md` (nonqualifying/skipped decision gates are non-blocking; ADRs are immutable, active-only reuse is allowed, changed decisions create new dated records, and `Deprecated`/`Superseded` are creation-time-only statuses) - `context/decisions/2026-08-12-observational-final-validation.md` diff --git a/context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md b/context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md new file mode 100644 index 00000000..900c86cb --- /dev/null +++ b/context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md @@ -0,0 +1,97 @@ +# Decision: Use event-scoped synthetic identities for Codex apply_patch evidence + +Date: 2026-08-23 +Status: Accepted +Plan: `context/plans/codex-cli-integration.md` +Task: `T16` + +## Context + +Codex `apply_patch` hook input identifies changed content but does not provide +reliable source line ranges. SCE must preserve the touched Add/Update lines so +the existing post-commit intersection can attribute a later commit, while +avoiding a filesystem snapshot, a pending mutation state, or changes to the +generic patch-combination/intersection contract. Multiple apply_patch events +may contain identical content, so restarting synthetic positions for every +file or event would allow the existing combination identity to collide. + +T16's implementation and verification established deterministic normalization +from the stable `tool_use_id`, checked allocation across all emitted files and +hunks, safe failure on invalid identities or exhausted ranges, and successful +matching through the unchanged historical `kind` + `content` intersection +fallback. See the task record and its focused normalization/intersection tests. + +## Decision + +Represent Codex apply_patch Add/Update touched lines with deterministic, +event-scoped synthetic line identities derived from a domain-separated SHA-256 +of the trimmed `tool_use_id`. Allocate checked local offsets across the entire +normalized event within a bounded range. These values are evidence identities, +not source line numbers, and are consumed through the existing +`combine_patches` and `intersect_patches` behavior. + +## Rationale + +Hashing the stable event identity gives repeated normalization of one event the +same result while separating independent events with overwhelming probability. +A bounded range and checked arithmetic make allocation deterministic and prevent +an oversized event or arithmetic failure from producing unsafe evidence. The +approach preserves exact touched-line content and order without claiming +unknown physical positions, and the existing content fallback can reconcile +synthetic positions with real committed line numbers. + +## Alternatives considered + +- **Use Codex line ranges as real positions** — Rejected because the hook payload +does not provide trustworthy ranges for this integration. +- **Restart positions at one for each file or event** — Rejected because +identical evidence would collide in the existing patch-combination identity. +- **Take a filesystem snapshot or add pending/snapshot state** — Rejected because +that would expand the runtime ownership and persistence model beyond the +approved no-snapshot pipeline. +- **Change generic `combine_patches` or `intersect_patches` semantics** — +Rejected; Codex evidence can use the existing historical content fallback. + +## Compatibility and risks + +- The synthetic positions are compatible with the existing SCE unified-diff +parser and downstream intersection, but they must never be rendered or +interpreted as physical source line numbers. +- Hash-range separation has a documented negligible collision risk. Invalid, +missing, untrimmed, or overflowed identities fail open without persistence. +- The content fallback can match repeated identical lines ambiguously because +Codex supplies no physical occurrence information; later context and tests must +state that limitation rather than claim occurrence-level certainty. + +## Guardrails + +- Derive identities only from the stable `tool_use_id`; do not use time, +randomness, filesystem paths, or mutable repository state. +- Keep allocation event-scoped, bounded, and checked across all emitted +operations, hunks, and files. +- Do not add database columns, migrations, snapshots, pending state, or a +Codex-specific intersection algorithm for this identity scheme. +- Keep Delete File and changeless Move evidence out of line-level persistence. + +## Consequences + +- Separate same-content Codex events survive existing patch combination and can +both be consumed by the current post-commit intersection pipeline. +- Codex evidence remains useful when committed line numbers differ, but repeated +identical content can remain physically ambiguous. +- Future Codex hook changes must preserve the distinction between evidence +identity and source location when adapting this path. + +## Follow-up + +None. + +## References + +- Plan: [`codex-cli-integration`](../plans/codex-cli-integration.md) +- Task: `T16` +- Current-state context: [`Codex hook runtime`](../sce/codex-integration-runtime.md) +- Current-state context: [`Agent Trace hooks command routing`](../sce/agent-trace-hooks-command-routing.md) +- Evidence: [`normalize.rs`](../../cli/src/services/hooks/codex/apply_patch/normalize.rs) +- Evidence: [`T16 completed task record`](../plans/codex-cli-integration.md) +- Related context: [`patch service`](../cli/patch-service.md) diff --git a/context/decisions/2026-08-23-codex-root-aware-hook-invocation.md b/context/decisions/2026-08-23-codex-root-aware-hook-invocation.md new file mode 100644 index 00000000..860dda8a --- /dev/null +++ b/context/decisions/2026-08-23-codex-root-aware-hook-invocation.md @@ -0,0 +1,85 @@ +# Decision: Resolve the Codex hook helper from the Git repository root at invocation time + +Date: 2026-08-23 +Status: Accepted +Plan: `context/plans/codex-cli-integration.md` +Task: `T18` + +## Context + +Codex runs project hooks with the event's current working directory, which can +be the repository root or an arbitrary nested directory. The generated hook +command must therefore locate the installed SCE helper without relying on the +process working directory or an install-time absolute path. Repository paths +may contain spaces, and hook failures must not block Codex when Git-root +resolution is unavailable. The command also forwards the hook's JSON STDIN to +the Rust dispatcher, so it must not consume, rewrite, or expose that payload. + +## Decision + +Generated Codex hook commands resolve `git rev-parse --show-toplevel` at +invocation time, invoke the repository-root `.codex/hooks` helper with quoted +shell expansions, and exit successfully without output when Git-root +resolution fails. + +## Rationale + +Runtime root resolution works from both root and nested Codex working + directories while avoiding a machine-specific absolute install path. Quoted +expansions preserve repository paths containing spaces. Capturing the Git +command's result keeps its diagnostics out of hook output, and the explicit +fail-open branch preserves Codex's non-blocking hook contract. Passing the +command through the existing helper keeps missing-CLI guidance and STDIN +forwarding in one SCE-owned boundary. + +## Alternatives considered + +- **Use the current working directory with a relative helper path** — fails for + nested Codex event directories. +- **Embed an absolute helper path during setup** — is not portable across + machines, checkouts, or repository moves. +- **Use `eval` or reconstruct the command from unquoted path text** — risks + shell interpretation and breaks paths containing spaces; it also adds no + capability beyond quoted parameter expansion. + +## Compatibility and risks + +- Existing Codex hook registrations and the `.codex/hooks` helper remain the + same; only command invocation becomes independent of the event cwd. +- A hook invoked outside a Git working tree becomes a silent successful no-op, + preserving fail-open behavior but producing no SCE evidence. +- The command depends on Git being available at hook runtime, as does the + repository-root-aware Codex path contract; generated tests cover root, + nested, spaced-path, and Git-failure cases. + +## Guardrails + +- Keep exactly the four existing registrations: `UserPromptSubmit`, `Stop`, + `PreToolUse` for `Bash`, and `PostToolUse` for `apply_patch`. +- Keep all root and helper expansions quoted and do not use `eval`. +- Preserve the helper's existing missing-`sce` stderr guidance and direct STDIN + forwarding. +- Do not add absolute install-time paths, a new registration system, or a + `PreToolUse apply_patch` registration. + +## Consequences + +- Generated Codex hooks work from arbitrary nested repository directories and + repositories whose paths contain spaces. +- Hook installation remains relocatable, and failure to resolve a Git root is + non-blocking and silent. +- The generated contract and flake check must continue to exercise invocation + behavior rather than only inspect the JSON shape. + +## Follow-up + +- `T19` must retain this invocation contract while proving and documenting the + complete hardened Codex pipeline. + +## References + +- Plan: [`codex-cli-integration`](../plans/codex-cli-integration.md) +- Task: `T18` +- Current-state context: [`codex-integration-runtime`](../sce/codex-integration-runtime.md) +- Evidence: [`test-codex-hook-command.sh`](../../scripts/test-codex-hook-command.sh) +- Evidence: [`codex-content.pkl`](../../config/pkl/renderers/codex-content.pkl) diff --git a/context/decisions/2026-08-23-codex-truthful-model-provenance.md b/context/decisions/2026-08-23-codex-truthful-model-provenance.md new file mode 100644 index 00000000..2f720e7b --- /dev/null +++ b/context/decisions/2026-08-23-codex-truthful-model-provenance.md @@ -0,0 +1,85 @@ +# Decision: Preserve Codex model IDs without inferring a provider + +Date: 2026-08-23 +Status: Accepted +Plan: `context/plans/codex-cli-integration.md` +Task: `T17` + +## Context + +The Codex hook payload exposes a `model` value but no separate trustworthy +provider field. Prefixing every unqualified value with `openai/` would turn an +unverified assumption into persisted Agent Trace provenance and could mislabel +custom or future Codex model identifiers. Blank or absent values also need to +remain distinguishable from reported attribution. + +T17's implementation and verification covered already-qualified IDs, +custom-qualified IDs, unqualified IDs, blank values, and missing values through +Codex diff-trace persistence and model-normalization tests. The resulting +values are consumed by the existing Agent Trace attribution pipeline without a +new schema field or provider-inference path. + +## Decision + +For Codex events, trim the reported `model` value, persist it unchanged when +non-empty, and persist `None` when it is absent or blank. Do not infer or add a +provider prefix, and do not invent a separate provider field. + +## Rationale + +Preserving the producer's value is the only truthful transformation available +when the payload does not identify a provider independently. It retains useful +custom and qualified identifiers, avoids false OpenAI attribution, and keeps +missing provenance explicit for downstream Agent Trace rendering. + +## Alternatives considered + +- **Prefix every unqualified value with `openai/`** — Rejected because the + payload does not establish that provider identity for every model string. +- **Infer a provider from model-name patterns** — Rejected because pattern + matching would be speculative and would create unstable provenance. +- **Add a provider field to Codex persistence** — Rejected because the upstream + payload exposes no trustworthy separate provider value and the existing + schema does not require a new field. + +## Compatibility and risks + +- Existing already-qualified IDs remain byte-for-byte unchanged; newly + persisted unqualified IDs no longer carry the previously fabricated + `openai/` prefix. +- Downstream consumers must treat an unqualified non-empty ID as producer- + reported but provider-unspecified. Blank and missing values remain nullable. +- A future upstream provider field may require a new decision and explicit + schema/consumer work; this record does not authorize provider inference. + +## Guardrails + +- Apply this normalization only to Codex model values; other producers retain + their existing model conventions. +- Trim only surrounding whitespace and never rewrite the model's remaining + content. +- Keep provider identity out of the Codex event model and Agent Trace schema + unless upstream supplies trustworthy data and a separate change approves it. + +## Consequences + +- Codex Agent Trace attribution is truthful but may be provider-unspecified for + unqualified custom model IDs. +- Existing downstream storage and intersection flows remain unchanged, with + `model_id` carrying the raw reported value or `NULL`. +- Tests and runtime documentation must preserve the distinction between a + model identifier and a provider-qualified identifier. + +## Follow-up + +None. + +## References + +- Plan: [`codex-cli-integration`](../plans/codex-cli-integration.md) +- Task: `T17` +- Current-state context: [`Codex hook runtime`](../sce/codex-integration-runtime.md) +- Current-state context: [`Agent Trace hooks command routing`](../sce/agent-trace-hooks-command-routing.md) +- Evidence: [`hooks/mod.rs`](../../cli/src/services/hooks/mod.rs) +- Evidence: [`apply_patch/mod.rs`](../../cli/src/services/hooks/codex/apply_patch/mod.rs) +- Related decision: [`Use event-scoped synthetic identities for Codex apply_patch evidence`](2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md) diff --git a/context/glossary.md b/context/glossary.md index decb11c1..c16317a6 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -1,5 +1,4 @@ # Glossary - - `pkl-check-generated`: Flake app exposed as `nix run .#pkl-check-generated`; canonical ephemeral-generation check that rejects committed target/schema/mirror outputs, evaluates exact workflow metadata, the generated artifact contract, semantic layout/path/inventory/content/parity/observational checks, and the optional-workflow manifest's content against the catalog, requires the shared helper-composition rule and SCE-scoped workflow prohibitions, enforces ordered catalog-derived OpenCode skill permissions plus explicit-permission artifact integrity, rejects stale sibling-package references or unresolved internalization tokens in workflow entrypoint `SKILL.md` documents, proves contract failures through checked-in negative fixtures, and delegates deterministic generation plus payload/input inventories to the generated-input producer while preserving its established inventory report. - `repo-level verification preference`: Current repository guidance that contributor-facing validation/check flows should prefer `nix flake check`; direct Cargo verification commands are secondary and used only when explicitly requested or for narrow targeted debugging, while `cargo fmt` remains the explicit autofix path. - lightweight post-task verification baseline: Required quick checks after each completed task in this repo: `nix run .#pkl-check-generated` and `nix flake check`. @@ -7,6 +6,7 @@ - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. - verify-only root context pass: Context-sync mode for localized tasks where root-level behavior, architecture, and terminology are unchanged; root shared files are checked against code truth but are not edited by default. - ephemeral generated payload: Files materialized by `config/pkl/generate.pkl` using payload-relative `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, `config/.agents/**`, `config/.codex/**`, and `config/schema/sce-config.schema.json` paths beneath Cargo `OUT_DIR`, temporary previews, or packaging fallbacks. These layouts are installed by `sce setup` but are never committed as repository target trees; `config/automated/.opencode/**` remains a forbidden generator surface. +- `Codex root-aware hook invocation`: Generated `.codex/hooks.json` command contract that resolves the Git repository root at hook runtime, invokes the installed helper through quoted path expansion from root or nested event cwd, preserves JSON STDIN, and exits silently successfully when Git-root resolution fails. The existing helper remains responsible for missing-`sce` stderr guidance; the contract forbids install-time absolute paths and `eval`. - `CLI generated-input handoff`: Repository-build contract rooted at the temporary directory named by `SCE_CLI_GENERATED_INPUT_DIR`. `config/pkl/generator-inputs.txt` declares the canonical `config/pkl` and referenced `config/lib` inputs; `scripts/produce-cli-generated-input.sh` discovers those files, generates Pkl twice, rejects nondeterminism and in-flight input mutation, and atomically places `pkl-generated/`, its exact `SHA256SUMS`, and `INPUTS.SHA256SUMS` there. `scripts/run-cli-cargo.sh` delegates production and removes its temporary handoff after Cargo exits. `cli/build.rs` verifies payload integrity and input freshness before copying `pkl-generated/` into Cargo `OUT_DIR`; missing, incomplete, modified, or stale handoffs fail rather than invoking Pkl or falling back to packaged assets. - `generated-input producer`: Repository-owned `scripts/produce-cli-generated-input.sh` contract driven by `config/pkl/generator-inputs.txt`. It is the canonical owner for expanding repository-relative generator inputs, snapshotting their inventory, two-pass Pkl evaluation, byte-tree determinism comparison, payload and canonical-input SHA-256 inventories, input-mutation rejection, atomic output publication, and private staging cleanup. The repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation all consume it. - `Pi workflow package`: Generated Pi workflow surface consisting of one thin prompt in `config/.pi/prompts/` plus the one workflow skill package under `config/.pi/skills/` that the prompt routes to. Phase-based workflows include `SKILL.md`, `references/output.md`, and named phase, persisted-document, or supporting references; phase-free `/brownfield` has the two core files, while `/handover` also has `references/handover-template.md`. Pi currently receives `/change-to-plan`, `/next-task`, `/validate`, `/commit`, `/handover`, and `/brownfield` this way and has no generated agent-role prompts. @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence (see `context/sce/codex-integration-runtime.md`). +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` outer-normalizes supported raw/heredoc input before parsing, resolves paths from event `cwd` against the real Git root into safe repository-relative paths, then parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence using event-scoped synthetic line identities derived from `tool_use_id` (see `context/sce/codex-integration-runtime.md`). Invalid cwd/path mappings or identity/range failures fail open before persistence. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into four supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row — with every other event/tool combination (including `PreToolUse(apply_patch)`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into four supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row using event-scoped synthetic line identities — with every other event/tool combination (including `PreToolUse(apply_patch)`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, Pi, plus Codex integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, `Pi skills`, `Pi extensions`, `Codex skills`, and `Codex hooks`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, Pi `prompts/**` and `skills/**` map to the Pi groups, and Codex's `.agents/skills/**` plus `.codex/hooks.json`/`.codex/hooks/**` map to the Codex groups (the latter also carrying a Codex hook trust/review reminder when unhealthy). Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. diff --git a/context/overview.md b/context/overview.md index f1b376ac..f8c2fee2 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, and Pi from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude and Pi; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, normalizes Add/Update evidence into an SCE unified diff under deterministic patch-local synthetic line numbers, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while any malformed STDIN payload also fails open as a no-op. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. +This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. @@ -27,7 +27,7 @@ The CLI now also enforces a shared output-format parser contract in `cli/src/ser Agent Trace lifecycle setup now resolves repository storage, creates/reuses checkout identity for diagnostics, and initializes `/sce/repos//agent-trace.db` via `RepositoryAgentTraceDb`; hook runtime lazy initialization uses the same repository storage resolver when setup has not prepared the DB or schema metadata is incomplete. The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/Pi/Codex/All with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--codex`, `--all`), standalone `--bootstrap-context` for additive durable-context baseline creation without integration installs, deterministic mutually-exclusive validation, and non-destructive cancellation exits; the former `--both` flag was removed in favor of `--all` (opencode+claude+pi+codex). Every normal successful setup path also ensures the same context baseline after the Git gate. Workflows the catalog marks optional are installed only when a repository opts in: the repeatable `sce setup --workflow ` flag names the selection for a run, an omitted flag reuses the selection persisted in `integrations.optional_workflows`, and the resolved selection filters the installed target assets and is written back to repo-local config. Interactive runs ask for the selection instead: a multi-select prompt follows target selection with every row unchecked on a first run and pre-checked from the persisted selection afterwards, cancelling it exits non-destructively like the target prompt, and the prompt is skipped when the catalog marks no workflow optional. `brownfield` is currently the only optional workflow, so a default run installs the five core workflows and no brownfield assets. `sce doctor` scopes its integration checks to that same recorded selection, so it never reports an unselected optional workflow's files as missing. -For repository generation consumers, `config/pkl/generator-inputs.txt` declares the canonical Pkl/plugin input set and `scripts/produce-cli-generated-input.sh` owns its discovery, two-pass `config/pkl/generate.pkl` evaluation, determinism comparison, payload/input inventories, in-flight input-mutation rejection, atomic handoff publication, and staging cleanup. `scripts/run-cli-cargo.sh` creates a fresh temporary destination, delegates generation to that producer, invokes the requested Cargo workflow with `SCE_CLI_GENERATED_INPUT_DIR`, and removes the handoff after Cargo success, failure, or handled signals. `config/pkl/check-generated.sh` delegates the same production mechanics while retaining contract and path assertions. `scripts/prepare-cli-generated-assets.sh` moves the producer-validated Pkl payload and checksums into the unchanged package fallback, adds hooks, migrations, and the Agent Trace schema, and appends only those static checksums to the combined inventory. The root flake's pre-Cargo `cliGeneratedInput` derivation invokes the same producer from a declarative source containing the producer plus its declared inputs. `cli/build.rs` rejects missing, incomplete, modified, or stale repository handoffs, copies the validated payload into Cargo `OUT_DIR/pkl-generated`, stages static inputs under `OUT_DIR/static`, and writes setup-asset, optional-workflow-catalog, and migration Rust manifests into `OUT_DIR`; it never invokes Pkl. Published crates carry the ignored packaging-only fallback, and unpacked downstream builds validate and copy it into their own `OUT_DIR` without requiring Pkl or parent repository paths. +For repository generation consumers, `config/pkl/generator-inputs.txt` declares the canonical Pkl/plugin input set and `scripts/produce-cli-generated-input.sh` owns its discovery, two-pass `config/pkl/generate.pkl` evaluation, determinism comparison, payload/input inventories, in-flight input-mutation rejection, atomic handoff publication, and staging cleanup. `scripts/run-cli-cargo.sh` creates a fresh temporary destination, delegates generation to that producer, invokes the requested Cargo workflow with `SCE_CLI_GENERATED_INPUT_DIR`, and removes the handoff after Cargo success, failure, or handled signals. `config/pkl/check-generated.sh` delegates the same production mechanics while retaining contract and path assertions. The root flake also runs `codex-hook-command`, which generates the Codex assets and verifies root, nested-cwd, spaced-path, stdin-forwarding, and fail-open invocation behavior against a stub `sce`. `scripts/prepare-cli-generated-assets.sh` moves the producer-validated Pkl payload and checksums into the unchanged package fallback, adds hooks, migrations, and the Agent Trace schema, and appends only those static checksums to the combined inventory. The root flake's pre-Cargo `cliGeneratedInput` derivation invokes the same producer from a declarative source containing the producer plus its declared inputs. `cli/build.rs` rejects missing, incomplete, modified, or stale repository handoffs, copies the validated payload into Cargo `OUT_DIR/pkl-generated`, stages static inputs under `OUT_DIR/static`, and writes setup-asset, optional-workflow-catalog, and migration Rust manifests into `OUT_DIR`; it never invokes Pkl. Published crates carry the ignored packaging-only fallback, and unpacked downstream builds validate and copy it into their own `OUT_DIR` without requiring Pkl or parent repository paths. The setup service also provides repository-root install orchestration: it resolves the repository root, ensures the additive durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup installs config assets (`.opencode`/`.claude`/`.pi`) per file: each embedded asset is staged and swapped into its own destination path, creating parent directories as needed, without removing or recreating the target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. Two assets are merge targets rather than verbatim writes: Claude's `.claude/settings.json` and OpenCode's `.opencode/opencode.json`. For each, setup JSON-merges the generated document into the user's existing file rather than overwriting it, and fails deterministically without writing if the existing file is not valid JSON; a missing file is still created from the generated document verbatim. Claude's merge replaces only SCE-owned hook entries (identified by a command containing `run-sce-or-show-install-guidance.sh`) and the `$schema` key while preserving every other key and hook entry untouched. OpenCode's merge replaces the `$schema` key and merges the `plugin` array as a set: any entry shaped like an SCE plugin path (`./plugins/sce-*`) is dropped, structurally, so a path an older or renamed catalog once installed is still recognized and pruned, and the generated document's canonical plugin entries are appended after the surviving user entries. Required-hook install uses the same per-file stage/atomic-swap choreography as config-asset install — the staging file is renamed directly over an existing hook without unlinking it first, so a rename failure leaves the prior hook untouched. Both flows return deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. After installing, config install prunes stale SCE-owned assets: it deletes every path the full embedded catalog for the target claims but the current selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then removes any parent directory left empty by that deletion, leaving a directory intact if a user file still lives inside it. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs`now separates install-flow logic from interactive prompt logic through focused support seams. The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction,`sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_dir`, `timeout_ms`, `workos_client_id`, and nested `policies.bash`, `policies.attribution_hooks.enabled`, plus `policies.database_retry` with per-DB `connection_open`/`query` retry policy specs), deterministic default discovery/merge of global+local config files (`${config*root}/sce/config.json`then`.sce/config.json`with local override, where`config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_dir=/sce/logs`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, custom-policy `satisfied_by`wrapper exemption (a policy does not fire when the matched command was unwrapped from a declared wrapper such as`nix shell nixpkgs#ripgrep`), and a canonical Pkl-authored `sce/config.json`JSON Schema generated beneath Cargo`OUT_DIR`and embedded by`cli/src/services/config/mod.rs`for both`sce config validate`and doctor-time config checks. Runtime startup config loading keeps parity with that schema by accepting its`$schema`declaration in repo-local and global config files, so startup commands such as`sce version`no longer fail before dispatch on that field; the canonical declaration is`"https://sce.crocoder.dev/config.json"`; this schema URL is separate from the `https://sce.crocoderlab.dev` baked default used by `sce sync` for control-plane ingestion. App-runtime observability now consumes flat logging keys through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for `log_dir`; positive-integer `log_file_retention_limit` uses config-file/default precedence, defaults to `10`, and controls creation-triggered cleanup for primary and v2 log files; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/base/bash-policy-presets.pkl` and `context/sce/bash-tool-policy-enforcement-contract.md`. @@ -47,7 +47,7 @@ The root flake splits native and release outputs:`packages.sce`and`packages.defa Git-commit embedding is **release-only**: `SCE_GIT_COMMIT`is injected via a`releaseCommitArgs` fragment applied only to the release derivations (`scePackageMusl`on Linux,`sceReleasePackageNative`on Darwin), not to`commonCargoArgs`. So native `.#sce`/`.#default`and every`nix flake check` derivation (`cli-tests`, `cli-clippy`, `cli-fmt`) build without the commit in their inputs and stay cache-reusable across commits (native `sce version`reports`unknown`), while `.#sce-release`still reports the real commit via`sce version`. `cli/build.rs` `emit_git_commit`emits`SCE_GIT_COMMIT`only when the env var is explicitly set — no`git rev-parse`fallback and no`.git/HEAD`/`.git/packed-refs`rerun watches. On Darwin the release now uses a distinct native-toolchain derivation (native toolchain + commit), so it diverges from`.#sce`to carry the commit while native stays commit-independent. The default development shell is slimmed for fast iteration:`devShells.default`no longer includes`scePackage`or`tursoPackage`, so `nix develop`compiles neither the CLI package nor the Turso CLI — it provides only the Rust toolchain and JS/pkl tooling for`cargo`/`biome`/`pkl`work. Turso stays available as`packages..turso`and through a new opt-in`devShells..database`shell (default tools +`tursoPackage`), entered via `nix develop .#database`. Both shells share `defaultDevShellPackages`/`defaultDevShellHook` `let`bindings so they cannot drift. The CLI Cargo package metadata now includes crates.io publication-ready fields with crate-local install guidance in`cli/README.md`; supported Cargo install paths are `cargo install shared-context-engineering --locked`and local checkout installation through`./scripts/run-cli-cargo.sh install --path cli --locked`. Direct `cargo install --git`is unsupported because it cannot run the repository pre-Cargo producer. The published crate installs the`sce`binary. The crate also keeps`cargo clippy --manifest-path cli/Cargo.toml`warnings-denied through`cli/Cargo.toml`lint configuration, so an extra`-- -D warnings`flag is redundant. -The repository-root flake is the single Nix entrypoint for repo tooling and CLI packaging/checks, so root-level`nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the ephemeral `pkl-generated`inventory check, Linux-only Flatpak checks,`workflow-actionlint`, and the split npm/config-lib JavaScript checks without nested-flake indirection. Repository Cargo builds copy a validated pre-Cargo generated payload into Cargo `OUT_DIR`; crates.io packaging prepares a self-contained Pkl-free fallback in a temporary clean workspace, and Flatpak helpers prepare the same payload beside generated manifests before the sandboxed source build. No general-purpose `cli/assets/generated/`mirror or committed generated target tree remains. +The repository-root flake is the single Nix entrypoint for repo tooling and CLI packaging/checks, so root-level`nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the ephemeral `pkl-generated`inventory check, the generated Codex `codex-hook-command` invocation check, Linux-only Flatpak checks,`workflow-actionlint`, and the split npm/config-lib JavaScript checks without nested-flake indirection. Repository Cargo builds copy a validated pre-Cargo generated payload into Cargo `OUT_DIR`; crates.io packaging prepares a self-contained Pkl-free fallback in a temporary clean workspace, and Flatpak helpers prepare the same payload beside generated manifests before the sandboxed source build. No general-purpose `cli/assets/generated/`mirror or committed generated target tree remains. Config-lib JS flake checks execute from`config/lib/`, but the copied Nix check source is repo-shaped when tests require shared repo fixtures; the current Claude agent-trace golden tests are fully Rust-owned in `cli/src/services/structured_patch/fixtures`(Claude TypeScript plugin test removed in T07). Local developer Nix tuning guidance now lives in`AGENTS.md`, including optional user-level `~/.config/nix/nix.conf`recommendations for`max-jobs`and`cores`plus an explicit system-level-only note for`auto-optimise-store`. The Pkl authoring layer owns generated OpenCode plugin registration for SCE-managed plugins: `config/pkl/base/opencode.pkl`defines the canonical plugin entries,`config/pkl/renderers/common.pkl`re-exports the shared plugin list for renderer use, and generated`config/.opencode/opencode.json`registers`./plugins/sce-bash-policy.ts`and`./plugins/sce-agent-trace.ts`through OpenCode's`plugin`field. Claude does not use an OpenCode-style plugin manifest; Claude bash-policy enforcement is registered through generated`.claude/settings.json`as a`PreToolUse` `Bash`command hook routed through`.claude/hooks/run-sce-or-show-install-guidance.sh`before running`sce policy bash`. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, and Pi are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence. +- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely reaches the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/patterns.md b/context/patterns.md index 11dfeb4f..c545b1cc 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -77,7 +77,7 @@ - Keep only actively consumed target metadata in dedicated modules (`opencode-metadata.pkl` and `claude-metadata.pkl`); Pi needs no metadata module because it adds no target-specific frontmatter. - Add OpenCode machine-readable orchestration metadata in `config/pkl/renderers/opencode-content.pkl`: catalog-derived `agent`, `entry-skill`, and a `skills` chain naming that command's single workflow skill. In `opencode-metadata.pkl`, derive ordered agent skill permissions from catalog role assignments: allow `*` for ordinary non-SCE skills, deny `sce-*`, then allow only the role's owned workflow skills; derive the additional `sce-decision` permission only for the Code agent. - Keep `config/pkl/renderers/metadata-coverage-check.pkl` as a fail-fast exact-inventory guard deriving command slugs, skill entrypoints, and package-local workflow paths from the typed catalog, while independently retaining the expected OpenCode agent inventory and per-target one-to-one command-to-workflow-skill route assertions; run it whenever workflow documents or target metadata change. -- Keep `config/pkl/renderers/generation-contract-check.pkl` independent of `generate.pkl` output assembly when deriving expected paths: build the exact target paths from renderer document inventories, name retained non-workflow assets explicitly, compare against all `output.files`, require every phase-based `SKILL.md` to cite each emitted phase reference, require the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions in every generated workflow skill, enforce the exact catalog-derived OpenCode skill permission order, and reject stale phase-skill slugs or unresolved package-local reference tokens in generated workflow entrypoint `SKILL.md` documents. Package-local reference prose is allowed to mention its own persisted-format history. It also rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), any `SKILL.md` that reproduces a sibling `references/output.md` fenced layout verbatim (`output-dedup`), and nineteen semantic violations covering layout headings, package-local paths, forbidden files, consolidated commit content, report ownership, target parity, stale sync-debt wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording, the compact completed-task record model (`compact-plan-template-schema`, `next-task-compact-completion-writing`, `plan-review-reads-completed-record`, and `context-sync-validates-task-record`, which together replaced the removed persisted-handoff `handoff-identity-fields` check), sync-debt-recovery branch reference-before-invocation ordering, the debt scan's all-completed-task scope, sync-debt blocked-outcome layout routing, and the `sce-validate` package excluding any `sce-decision` reference or plan-context-sync wording. Preserve controlled negative fixtures for each of these contracts. +- Keep `config/pkl/renderers/generation-contract-check.pkl` independent of `generate.pkl` output assembly when deriving expected paths: build the exact target paths from renderer document inventories, name retained non-workflow assets explicitly, compare against all `output.files`, require every phase-based `SKILL.md` to cite each emitted phase reference, require the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions in every generated workflow skill, enforce the exact catalog-derived OpenCode skill permission order, and reject stale phase-skill slugs or unresolved package-local reference tokens in generated workflow entrypoint `SKILL.md` documents. Package-local reference prose is allowed to mention its own persisted-format history. It also rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), any `SKILL.md` that reproduces a sibling `references/output.md` fenced layout verbatim (`output-dedup`), and the existing semantic violations covering layout headings, package-local paths, forbidden files, consolidated commit content, report ownership, target parity, stale sync-debt wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording, the compact completed-task record model (`compact-plan-template-schema`, `next-task-compact-completion-writing`, `plan-review-reads-completed-record`, and `context-sync-validates-task-record`, which together replaced the removed persisted-handoff `handoff-identity-fields` check), sync-debt-recovery branch reference-before-invocation ordering, the debt scan's all-completed-task scope, sync-debt blocked-outcome layout routing, and the `sce-validate` package excluding any `sce-decision` reference or plan-context-sync wording. Preserve controlled negative fixtures for these existing contracts; the Codex invocation contract has dedicated generated command execution coverage. - Workflow renderers may extend canonical frontmatter only with target-supported metadata, must preserve behavior, and append only the required final newline at the output mapping. Structured composition renders semantic package/composite differences at their source while preserving one owner for every canonical gate, branch, write, and continuation. Composite mode emits a document's body only: frontmatter is a package-mode concern, so an embedded command or phase contributes no `name:`, `description:`, or `argument-hint:` line to the composed `SKILL.md`. Suppression happens in the typed model, never by parsing or stripping Markdown markers. Every reference a rendered document states must resolve in the mode that states it: composite text may name a section embedded in the same `SKILL.md` or the sibling `references/output.md`, but a sentence whose only target is a package-mode file — a `references/*-contract.yaml`, a removed `.md`, or the composed workflow itself — is package-only and its composite spelling drops the sentence rather than pointing at nothing. A phase's terminal internal states are named by its own steps, so dropping such a pointer removes no instruction. Migrate one workflow at a time and compare its OpenCode, Claude, and Pi paths against a retained pre-task root. Byte-identical generated payload is the regression guard for refactors that must preserve output; when a change intentionally alters generated text, the guard becomes the reviewed diff against that retained root, showing only the intended additions and removals. - Every target's commands (Pi: prompts) must stay thin and invoke exactly one corresponding workflow skill (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, or `sce-commit`). They must not sequence phase skills. The workflow skill executes package-local phases directly, after reading the applicable reference, and keeps phase statuses as internal state. Relevant non-SCE helper skills may run inside the active step only as helpers that return control to that step; only the successful task-synchronization decision gate may invoke sibling SCE `sce-decision`, preserving that exception as exact rather than general SCE orchestration. - A phase-based workflow package contains `SKILL.md`, `references/output.md`, and named package-local references for its phase instructions and persisted-file templates. `SKILL.md` alone owns ordering, branching, waits, and same-session resume; it reads the applicable reference before phase side effects. Phase-free `/brownfield` contains exactly `SKILL.md` and `references/output.md`; `/handover` additionally emits `references/handover-template.md`. Put every and only human-visible gate, report, and terminal response layout in `output.md`. State each layout exactly once inside `output.md`: when a phase's return-value layout and a workflow branch's layout describe the same output, keep the fuller statement (the one carrying every status variant, field, and report rule) and let the other place point at that section by heading instead of restating it. An `output.md` section must not reference a document outside its own package; a mode-aware semantic reference is how composite text names the embedded section that replaced a package-mode file. A composed `SKILL.md` states no layout of its own: a branch that produces user-visible output names the `output.md` section it renders (`Render the **{Section}** layout from `references/output.md`.`) and keeps only what `output.md` does not carry — the branch condition, the field mapping that fills the layout, the wait, and every prohibition. Model this per layout as a mode-aware semantic reference whose package spelling is the inline block a standalone command file still owns and whose composite spelling is the citation; do not achieve it by deleting text the sibling `output.md` never states. Wait points such as bootstrap, clarification, revision, implementation approval, and failed-validation repair remain real same-session turn boundaries owned by the composite skill. @@ -98,7 +98,7 @@ - Use `config/pkl/README.md` as the contributor-facing runbook for prerequisites, ownership boundaries, regeneration steps, and troubleshooting. - Run multi-file generation only into an explicit temporary output root, for example `nix run .#pkl-generate -- "$(mktemp -d)"`; never evaluate with `-m .`. - Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 135-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. -- Keep this contract anchored to the root `nix flake check` `pkl-generated` derivation. Removed target paths are forbidden repository artifacts even though the same path names remain valid inside temporary payload roots. +- Keep this contract anchored to the root `nix flake check` `pkl-generated` derivation. The separate `codex-hook-command` check executes the generated Codex command from root and nested cwd, including spaced repository paths, and verifies unchanged STDIN plus silent fail-open behavior. Removed target paths are forbidden repository artifacts even though the same path names remain valid inside temporary payload roots. - Treat `nix run .#pkl-check-generated` and `nix flake check` as the lightweight post-task verification baseline and run both after each completed task. - Keep `output.files` limited to payload-relative paths (`config/.opencode/{agent,command,skills,lib,plugins,opencode.json}`, `config/.claude/{commands,skills,hooks,settings.json}` with no Claude agents, `config/.pi/{prompts,skills,extensions}`, and the generated schema). Do not emit `config/automated/.opencode`. - For OpenCode pre-execution bash-policy hooks, keep the generated plugin entrypoint thin (`plugins/sce-bash-policy.ts`) and delegate policy evaluation to the Rust `sce policy bash --input normalized --output json` command so OpenCode and Claude share one evaluator. @@ -158,10 +158,10 @@ - For cross-service CLI dependencies exposed through the borrowed `AppContext` view, prefer shared capability/accessor traits over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors, and keep command execution generic over the narrow accessors each command needs where practical. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence; `PreToolUse(apply_patch)` remains an unregistered no-op. `session-model` is no longer a supported hook intake path. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`), and `tool_version` (present and either `null` or non-empty string), same-tool-idempotent stored `diff_traces.session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion whose failure is logged and reflected in deterministic success text without creating a `context/tmp` artifact fallback; keep `conversation-trace` as the active message/part intake path; keep `codex` as Codex's own single dispatcher subcommand — its `UserPromptSubmit` and `Stop` arms capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` outer-normalizes, parses, resolves paths from event `cwd` against the real Git root, then normalizes/persists a `diff_traces` row for provable Add/Update evidence using event-scoped synthetic line identities derived from `tool_use_id`; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; reported model IDs remain raw and blank values remain absent; all non-policy Codex paths return empty stdout; `PreToolUse(apply_patch)` remains an unregistered no-op. `session-model` is no longer a supported hook intake path. - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. -- For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`PostToolUse(apply_patch)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `PreToolUse(apply_patch)`) to the same deterministic `NoOp` success text rather than an error. +- For generated Codex hook invocation, resolve the Git repository root at runtime and invoke the installed helper with quoted expansions; exit successfully and silently when Git-root resolution fails, and preserve the helper's existing missing-CLI stderr guidance and STDIN forwarding. For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`PostToolUse(apply_patch)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `PreToolUse(apply_patch)`) to the same deterministic silent `NoOp` success rather than an error. - For diff-trace attribution persistence, keep Claude model resolution event-local: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, normalize either source through the `claude/` convention, and store unresolved attribution as `NULL` in `diff_traces`. Persist `tool_version` directly. Do not restore the former `session_models` fallback or any session-level cache. - For recent structured diff-trace reconstruction, treat persisted row attribution as canonical: assign the row `model_id` to every reconstructed hunk and the tool-prefixed row `session_id` to every reconstructed touched line before combination/intersection. Never reuse the raw unprefixed Claude payload session as touched-line provenance. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate) plus the staged-diff AI-overlap evidence gate (`StagedDiffAiOverlapResult::Overlap` maps to `ai_contribution_present = true`; `NoOverlap` and `Error` both map to `false`), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 3d9e9a80..6f097bf9 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -4,7 +4,9 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode, Claude Code, and Pi. This extends existing behavior rather than replacing it: Codex reuses the same canonical Pkl workflow catalog, the same Rust Bash policy engine, the same conversation (`messages`/`parts`) persistence, and the same `diff_traces` → post-commit intersection → `agent_traces` pipeline every other integration already goes through. The only new runtime surface is a Codex-specific hook adapter (`sce hooks codex`) that produces normalized evidence for Codex's two output roots (`.agents/` for skills, `.codex/` for hooks). -`apply_patch` attribution was originally scoped around a transient before/after Git-index snapshot mechanism (T10–T12 below). That implementation was built, validated, and then removed from this branch before this revision; its task and acceptance-criteria text is replaced here with a no-snapshot design: `PostToolUse apply_patch` only (no `PreToolUse apply_patch` registration, no snapshots, no temporary Git indexes, no pending tool state) parses Codex's own `tool_input.command` apply_patch text, normalizes it into an SCE-supported unified diff with deterministic patch-local synthetic line numbers, and persists it as a `diff_traces` row. The existing, unmodified `intersect_patches` historical `kind`+`content` fallback is what lets that synthetic-line evidence still attribute correctly once the real commit lands at different line numbers — this plan does not touch that fallback. Bash-triggered filesystem mutations remain explicitly out of scope for attribution — Bash gets policy enforcement only, matching the current-state boundary already documented for Claude/Pi. +`apply_patch` attribution was originally scoped around a transient before/after Git-index snapshot mechanism (T10–T12 below). That implementation was built, validated, and then removed from this branch before this revision; its task and acceptance-criteria text is replaced here with a no-snapshot design: `PostToolUse apply_patch` only (no `PreToolUse apply_patch` registration, no snapshots, no temporary Git indexes, no pending tool state) parses Codex's own `tool_input.command` apply_patch text, normalizes it into an SCE-supported unified diff with deterministic event-scoped synthetic line identities, and persists it as a `diff_traces` row. + +This revision hardens that implementation rather than redesigning Agent Trace. It adds upstream-aligned outer patch normalization, resolves parsed paths against the real Git repository and Codex event `cwd`, rejects missing/invalid provenance inputs, makes all non-policy Codex hook paths silent, fixes nested-cwd generated hook invocation, prevents avoidable `combine_patches` collisions, and removes fabricated OpenAI model provenance. The current upstream `openai/codex` source inspected for this revision is commit `343074d4207d572809bd8cea15f4be1d09d98e0b`; its hook payload has `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, and `tool_response`, but no separate provider field, and its command runner executes hooks with `.current_dir(cwd)`. The existing, unmodified `intersect_patches` historical `kind`+`content` fallback is what lets that synthetic-line evidence still attribute correctly once the real commit lands at different line numbers — this plan does not touch that fallback. Bash-triggered filesystem mutations remain explicitly out of scope for attribution — Bash gets policy enforcement only, matching the current-state boundary already documented for Claude/Pi. ## Acceptance criteria @@ -30,7 +32,7 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode - Validate: regression test running `echo generated > generated.txt` through the Codex Bash hook path and asserting zero new `diff_traces` rows. - [x] AC11: A successful Codex `apply_patch` containing an Add File and/or Update File operation produces exactly one `diff_traces` row whose patch text is a valid SCE unified diff carrying only that file's added/removed lines (not unrelated Codex context), under deterministic patch-local synthetic hunk positions. - Validate: integration test driving `PostToolUse apply_patch` with Add/Update operations against a scratch repo and asserting one inserted `diff_traces` row whose stored patch parses via `parse_patch`. -- [x] AC12: The persisted `diff_traces` row for a successful `apply_patch` carries `session_id = cx_`, `model_id = openai/` when the event reports a model, `tool_name = codex`, `tool_version = NULL`, and `payload_type = patch`. +- [x] AC12: The persisted `diff_traces` row for a successful `apply_patch` carries `session_id = cx_`, preserves the reported Codex `model_id` according to AC22, `tool_name = codex`, `tool_version = NULL`, and `payload_type = patch`. - Validate: same integration test as AC11, asserting row field values. - [x] AC13: A Codex `apply_patch` `Update File` with a `Move to` destination normalizes with `old_path`/`new_path` matching the source/destination paths, and persists any changed lines as evidence; a move with no changed lines persists no `diff_traces` row. - Validate: integration tests covering a move-with-edits and a pure rename with no line changes. @@ -43,6 +45,23 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode - [x] AC17: Existing OpenCode, Claude, and Pi setup, generated assets, conversation tracing, diff tracing, policy behavior, and Agent Trace tests continue to pass. - Validate: `nix flake check`. +- [x] AC18: Codex apply_patch input accepted by the current upstream lenient parser is accepted by SCE's outer normalization and canonical parser, including raw text, `<`; missing, empty, and whitespace-only IDs create no `diff_traces` row. Every non-policy success, no-op, malformed-payload, malformed-apply_patch, and fail-open path returns exactly `""` on stdout, while Bash denial alone returns the exact existing Codex-native deny JSON. + - Validate: full Codex hook dispatch tests asserting exact strings and temporary Agent Trace DB row counts for all session variants and malformed paths. +- [x] AC21: Separate apply_patch events use deterministic, event-scoped synthetic `u64` line identities derived from `tool_use_id` plus bounded local offsets; the same event and patch normalize identically, different event IDs do not begin at the same synthetic range except for the documented negligible hash-collision risk, Add File evidence is event-scoped, and checked arithmetic prevents overflow. Combining two same-content events preserves both pieces of evidence, and the existing intersection can consume two corresponding commit additions. + - Validate: normalization determinism/overflow tests, two-event `combine_patches` tests, and a post-commit intersection test with two identical matching additions. +- [x] AC22: Codex model provenance is truthful: already-qualified IDs remain unchanged, unqualified custom-looking IDs are not prefixed with `openai/`, blank IDs become `None`, and no provider field is invented because current upstream Codex exposes no trustworthy provider identity separately from `model`. + - Validate: persistence and model-normalization tests for `openai/gpt-x`, `qualified/custom-provider/model`, an unqualified custom-looking ID, and blank/missing model values. +- [x] AC23: Generated Codex hook commands locate and invoke the SCE-owned helper through the Git repository root from both repository root and arbitrary nested cwd, including repository paths containing spaces; the command uses safe quoting/no `eval`, preserves stdin, fails open when Git root resolution fails, and keeps the four registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` Bash, `PostToolUse` apply_patch) without `PreToolUse` apply_patch or an invalid `$schema`. Doctor expectations remain structural and do not require root cwd. + - Validate: fresh generated asset inspection plus helper-command execution tests from root/nested cwd and a spaced-path temporary Git repository, with a stub `sce` proving the same helper receives unchanged JSON stdin; `nix run .#pkl-check-generated` and doctor tests. +- [x] AC24: Codex integration documentation and tests explicitly state that Add/Update evidence is exact for supplied touched content but physical occurrence attribution can remain ambiguous for repeated identical lines because Codex supplies no true line ranges and SCE takes no filesystem snapshot; Delete File, pure rename, and Bash-created mutations remain without line-level evidence, and the existing post-commit intersection remains the final filter without generic semantic changes. + - Validate: focused repeated-identical-content intersection test and documentation inspection of `context/sce/codex-integration-runtime.md`, directly relevant architecture/context references, and the revised plan. +- [x] AC25: The complete hardened pipeline remains `PostToolUse apply_patch` → `tool_input.command` parsing → cwd-aware path resolution → SCE `payload_type = "patch"` `diff_traces` → existing `recent_diff_trace_patches`/`combine_patches` → existing Git post-commit intersection → Agent Trace, with no new schema, snapshot, pending state, PreToolUse apply_patch registration, Bash mutation attribution, or Codex-specific Agent Trace builder. + - Validate: end-to-end temporary repository/Agent Trace DB test feeding realistic Codex PostToolUse JSON and a realistic post-commit patch, plus source/status inspection showing no migration or forbidden state artifacts. + ### Full validation - `nix run .#pkl-check-generated` @@ -54,9 +73,9 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode - `context/cli/cli-command-surface.md` — `sce setup --codex`, `sce hooks codex` command-surface additions. - `context/cli/config-precedence-contract.md` — `integrations.target` accepting `"codex"`. - `context/overview.md` — remove the "Codex `apply_patch` tracing is not yet implemented" sentence and describe the implemented `PostToolUse`-only pipeline instead. -- `context/sce/codex-integration-runtime.md` (modeled on `context/sce/pi-extension-runtime.md`) — `cx_` session prefix, `openai/` model normalization, UserPromptSubmit/Stop mapping, Bash policy delegation, the `PostToolUse apply_patch` parse/normalize/persist pipeline and its boundary (Add/Update produce line-level evidence, Update+Move preserves the destination path, Delete produces none, Bash mutation attribution remains unsupported, final attribution is always the existing post-commit intersection), replacing the "not yet implemented" framing. +- `context/sce/codex-integration-runtime.md` (modeled on `context/sce/pi-extension-runtime.md`) — `cx_` session prefix, truthful model provenance without fabricated `openai/` prefixes, UserPromptSubmit/Stop mapping, Bash policy delegation, the `PostToolUse apply_patch` outer-normalize/parse/resolve/normalize/persist pipeline and its boundary (Add/Update produce line-level evidence, paths resolve from Codex cwd, Update+Move preserves the destination path, Delete produces none, Bash mutation attribution remains unsupported, final attribution is always the existing post-commit intersection), silent fail-open behavior, event-scoped synthetic identities, and the repeated-content ambiguity limitation. - `context/sce/doctor-human-text-contract.md` — Codex integration group/area ordering. -- `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md` — note that `sce hooks codex` is a second writer into the same `diff_traces`/`messages`/`parts` tables via the existing insert helpers, with no new adapter. +- `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md` — retain the existing second-writer/no-new-adapter contract and clarify that Codex apply_patch remains on the existing `diff_traces`/post-commit intersection path with no snapshots or pending state. ## Task context synchronization lifecycle @@ -71,14 +90,16 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Constraints and non-goals -- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, a new Codex `apply_patch` parser/normalizer module under `cli/src/services/hooks/codex/`, and the durable context files listed under Context sync. +- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, the Codex `apply_patch` parser/outer-normalization/path-resolution/normalizer modules under `cli/src/services/hooks/codex/`, generated-hook command tests, and the durable context files listed under Context sync. - **Out of scope:** any change to `cli/migrations/agent-trace-repository/`; any change to OpenCode/Claude/Pi's own generated behavior beyond what is mechanically required to add a fourth target to shared enums/renderers; Codex App Server or `codex exec --json` integration; MCP-tool or subagent attribution; `AGENTS.md` generation/management; a Codex slash-command compatibility layer; any change to `intersect_patches`/`combine_patches` in `cli/src/services/patch.rs` unless a test demonstrates the normalized Codex evidence cannot flow through the existing contract. - **Constraints:** reuse `cli/src/services/bash_policy.rs` for Bash policy evaluation without reimplementing matching; reuse `DiffTraceInsert`/`insert_diff_trace` for persistence without a Codex-specific DB adapter; reuse `cli/src/services/patch.rs`'s existing, unmodified `parse_patch`/`intersect_patches`/`combine_patches` — the Codex apply_patch normalizer must produce text `parse_patch` already accepts, and `intersect_patches`' existing historical `kind`+`content` fallback is the sole mechanism for reconciling Codex's synthetic line numbers against real post-commit line numbers; no second diff engine. - **Non-goal:** Bash-created filesystem change attribution for Codex, Claude, or Pi (deferred — tracked as a known gap, not solved here); a generic cross-producer mutation tracker; any `diff_traces`/Agent Trace DB schema column for snapshot/pending state; filesystem snapshots, temporary Git indexes, or pending tool state for Codex `apply_patch` (the removed design, deliberately not reintroduced); Delete-File line-level attribution for Codex `apply_patch` (no before-state snapshot exists to prove removed content, and this plan does not add one). ## Assumptions -- The Codex hook lifecycle event names and field names given in the change request (`hook_event_name`, `session_id`, `turn_id`, `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`; events `UserPromptSubmit`, `Stop`, `PreToolUse`, `PostToolUse`; tool identifiers `Bash`, `apply_patch`) are taken as the working contract. T06/T09 already checked the `Bash`-related shape against Codex CLI reality; T10 opens by checking the `apply_patch` `tool_input.command` grammar the same way against current `openai/codex` source before finalizing the parser, adjusting field/marker details to match reality without changing the architecture, dispatcher shape, or any acceptance criterion above (all stated as SCE-side observable outcomes, not exact Codex wire-format assertions). +- Before this revision, `git fetch origin codex` was run on local branch `codex`; `HEAD` and `origin/codex` both resolved to `3ada88f04f5c8441b8f537bfb48478f14d8f819e` (`codex: Implement PostToolUse apply_patch tracing`). +- Current upstream `openai/codex` was inspected before planning against commit `343074d4207d572809bd8cea15f4be1d09d98e0b`: `codex-rs/apply-patch/src/parser.rs` accepts the exact lenient wrappers `< exit 0 (Ephemeral Pkl generation passed: 135 files, inventory sha256 8be0ee0f495048f048317d2bd9d8e0ebc11120e12eba16e472dc7ed0b929a033) -- `nix flake check` -> exit 0 (all checks passed!: cli-tests, cli-clippy, cli-fmt, cli-generated-input, pkl-generated, npm-bun-tests, npm-biome-check, npm-biome-format, config-lib-bun-tests, config-lib-biome-check, config-lib-biome-format, workflow-actionlint, native-portability-audit, flatpak-static-validation, cargo-sources-parity, flatpak-manifest-parity) -- `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex` -> exit 0 (63 passed, 0 failed, 0 ignored — covers all `hooks::codex::*` unit/integration tests including `apply_patch`, `bash_policy`, `stop`, `user_prompt_submit`) -- `nix run .#pkl-generate -- ` + manual inspection of `.agents/skills/` and `.codex/hooks.json` -> exit 0; `.agents/skills/` contains the five core skills plus `sce-brownfield`/`sce-decision`; `.codex/hooks.json` registers exactly `UserPromptSubmit`, `Stop`, `PreToolUse` (`Bash`), `PostToolUse` (`apply_patch`), no `PreToolUse apply_patch`, no `PostToolUse Bash`, no `$schema` -- `sce setup --codex --non-interactive` in a scratch git repo (fake `origin` remote) -> exit 0; installed `.agents/skills/**` (6 core skills, no `sce-brownfield`) and `.codex/hooks.json` + `.codex/hooks/run-sce-or-show-install-guidance.sh`; `.sce/config.json` recorded `{"integrations": {"optional_workflows": [], "target": ["codex"]}}` -- `sce setup --all --non-interactive` in a scratch git repo -> exit 0; reported "Selected target(s): OpenCode, Claude, Pi, Codex" and installed all four target trees (`.opencode` 35, `.claude` 31, `.pi` 30, `.agents` 24 + `.codex` 2) with no regression to the other three; `.sce/config.json` recorded `"target": ["opencode", "claude", "pi", "codex"]` -- `sce setup --codex --workflow brownfield --non-interactive` vs `sce setup --codex --non-interactive` in separate scratch repos -> exit 0 each; `sce-brownfield` present only in the `--workflow brownfield` run's `.agents/skills/` -- `git diff --stat -- cli/migrations/agent-trace-repository/` -> empty (no changes); `git status --short -- cli/migrations/agent-trace-repository/` -> empty; directory contains only the two pre-existing baseline files +- `nix run .#pkl-check-generated` -> exit 0 (ephemeral Pkl generation passed: 135 files) +- `nix flake check` -> exit 0 (all checks passed) +- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml'` -> exit 0 (475 passed, 0 failed) +- `nix develop -c sh -c './scripts/test-codex-hook-command.sh'` -> exit 0 (root, nested, spaced-path, stdin, and Git-failure cases passed) +- scratch `sce setup --codex --non-interactive`, `sce setup --all --non-interactive`, and paired `--workflow brownfield`/default runs -> exit 0 (setup, target, asset, config, and optional-workflow checks passed) +- `git diff -- cli/migrations/agent-trace-repository` and `git status --short -- cli/migrations/agent-trace-repository` -> exit 0 (no migration changes) +- Codex hardened pipeline source/status inspection -> exit 0 (no forbidden snapshot/pending artifacts; existing persistence/intersection paths present) ### Success-criteria verification -- [x] AC1: `sce setup --codex --non-interactive` installs both output roots and persists `integrations.target` -> verified directly in a scratch git repo (see Commands run) -- [x] AC2: `sce setup --all --non-interactive` installs Codex alongside the other three targets with no regression -> verified directly in a scratch git repo -- [x] AC3: core workflows under `.agents/skills/`, `brownfield` obeys `--workflow` selection -> verified via `pkl-generate` inspection and paired scratch-repo `setup` runs with/without `--workflow brownfield` -- [x] AC4: `.codex/hooks.json` registers exactly the four documented lifecycle entries -> verified via direct `pkl-generate` output inspection -- [x] AC5: `UserPromptSubmit` produces one message + one part under `cx_` -> verified via `hooks::codex::user_prompt_submit::tests` (63-test run) -- [x] AC6: `Stop` produces one message + one part -> verified via `hooks::codex::stop::tests` -- [x] AC7: reprocessing does not duplicate the parent message -> verified via `capture_with_does_not_duplicate_the_parent_message_on_reprocess` (both arms) -- [x] AC8: allowed Bash command is silent -> verified via `hooks::codex::bash_policy::tests` -- [x] AC9: denied Bash command uses Codex-native deny shape with policy reason -> verified via `hooks::codex::bash_policy::tests` -- [x] AC10: Bash mutations create no `diff_trace` -> verified via `codex_bash_pre_tool_use_path_creates_no_diff_trace_for_a_filesystem_mutation_command` -- [x] AC11: successful `apply_patch` (Add/Update) produces one valid `diff_traces` row -> verified via `apply_patch_persists_one_row_with_expected_field_values_for_add_and_update` -- [x] AC12: persisted row carries expected `session_id`/`model_id`/`tool_name`/`tool_version`/`payload_type` -> verified via the same test asserting field values -- [x] AC13: `Update File` + `Move to` normalizes `old_path`/`new_path`, no row for a changeless move -> verified via `apply_patch_move_with_edits_persists_row_with_expected_paths` plus the paired no-changed-lines case -- [x] AC14: Delete-only produces no row; mixed Update+Delete+Add persists only Update/Add evidence -> verified via `apply_patch_mixed_operations_persists_only_add_and_update_evidence` plus the delete-only case -- [x] AC15: synthetic-line `diff_trace` still attributes through the unmodified post-commit intersection pipeline -> verified via `apply_patch_diff_trace_attributes_through_agent_trace_pipeline_at_different_real_lines` -- [x] AC16: no Agent Trace schema migration added -> verified via empty `git diff`/`git status` on `cli/migrations/agent-trace-repository/` -- [x] AC17: existing OpenCode/Claude/Pi behavior and tests continue to pass -> verified via `nix flake check` passing in full (no regressions) +- [x] AC1: setup installs both Codex output roots and persists `integrations.target` -> scratch Git repository passed. +- [x] AC2: `setup --all` installs Codex alongside OpenCode, Claude, and Pi -> scratch Git repository passed. +- [x] AC3: core and optional workflow selection is correct -> generation inspection and paired scratch setup runs passed. +- [x] AC4: generated Codex hook registrations are exactly the four required entries -> generated inspection and hook-command check passed. +- [x] AC5: `UserPromptSubmit` produces one user message and text part -> full test suite passed the Codex persistence tests. +- [x] AC6: `Stop` produces one assistant message and text part -> full test suite passed the Codex persistence tests. +- [x] AC7: repeated conversation events do not duplicate parent messages -> full test suite passed both reprocessing tests. +- [x] AC8: allowed Bash is silent -> Codex Bash policy tests passed. +- [x] AC9: denied Bash uses the native deny response and policy reason -> Codex Bash policy tests passed. +- [x] AC10: Bash mutations create no diff trace -> regression test passed. +- [x] AC11: Add/Update apply_patch persists valid evidence -> full test suite passed the persistence and parser tests. +- [x] AC12: persisted model ID follows the truthful AC22 provenance contract -> persistence test passed with raw and qualified IDs. +- [x] AC13: move-with-edits preserves paths and pure rename creates no row -> full test suite passed. +- [x] AC14: delete-only and mixed-operation evidence boundaries hold -> full test suite passed. +- [x] AC15: synthetic evidence attributes through the existing intersection pipeline -> full test suite passed the Agent Trace attribution test. +- [x] AC16: no Agent Trace schema migration was added -> migration diff/status inspection passed. +- [x] AC17: existing integrations and repository checks continue to pass -> full test suite and `nix flake check` passed. +- [x] AC18: upstream-compatible outer wrappers and malformed-input behavior -> parser tests passed. +- [x] AC19: cwd-aware repository-relative path resolution -> path and realistic hook tests passed. +- [x] AC20: session validation and exact silent/non-policy output contracts -> Codex dispatcher and persistence tests passed. +- [x] AC21: deterministic event-scoped synthetic identities and collision handling -> normalization/combination/intersection tests passed. +- [x] AC22: truthful model provenance and no invented provider -> model normalization and persistence tests passed. +- [x] AC23: root-aware generated hook invocation and structural doctor expectations -> generated hook-command check, Pkl check, and doctor tests passed. +- [x] AC24: conservative attribution boundary and repeated-content ambiguity are documented and tested -> documentation inspection and repeated-content test passed. +- [x] AC25: complete hardened pipeline and forbidden-artifact boundaries -> realistic end-to-end test and source/status inspection passed. ### Failed checks and follow-ups @@ -299,7 +412,5 @@ Persist this field in every plan; this is durable plan state, not chat state: ### Residual risks -- The Codex hook JSON schema (event/field names, `apply_patch` grammar, deny-response shape) is an external, evolving contract verified against `openai/codex` source at implementation time (T06/T09/T10); a future upstream change could silently desync the SCE-side parser from real Codex output. Already recorded as a plan assumption/open question, not a defect. -- None else identified. - +- Codex's external hook schema and apply_patch grammar may evolve beyond the upstream commit used for these fixtures. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index a98fa77c..fd471265 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -13,6 +13,7 @@ - `sce hooks post-rewrite ` - `sce hooks diff-trace` - `sce hooks conversation-trace` +- `sce hooks codex` ## Parser and dispatch behavior @@ -113,7 +114,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths from the event `cwd` against the real Git root and rejects unsafe or non-repository-relative mappings. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 61277e4f..4124c028 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -7,6 +7,19 @@ See [Codex generated assets](../architecture.md) for the Pkl-authored [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md) for how the other three tools intake conversation/diff evidence. +## Generated hook invocation + +The generated `.codex/hooks.json` routes all four registrations through the +same command. That command resolves `git rev-parse --show-toplevel` at +invocation time, then invokes the repository-root +`.codex/hooks/run-sce-or-show-install-guidance.sh` helper with quoted +expansions. It therefore works from the repository root, arbitrary nested +Codex working directories, and repository paths containing spaces. Git-root +resolution failures exit successfully without stdout; the helper retains its +existing missing-`sce` stderr guidance and forwards the hook JSON STDIN +unchanged. The exact four-registration and invocation contract is covered by +the generated contract and `codex-hook-command` flake check. See [the ADR](../decisions/2026-08-23-codex-root-aware-hook-invocation.md). + ## Dispatch skeleton - STDIN carries one raw Codex hook-event JSON payload, deserialized into a @@ -19,20 +32,22 @@ for how the other three tools intake conversation/diff evidence. `PostToolUse(apply_patch)` — with every other combination (`apply_patch` under `PreToolUse` — no such registration exists in `.codex/hooks.json` — unknown tool, `Bash` under `PostToolUse`, unrecognized `hook_event_name`) - falling through to a deterministic `NoOp` success. + falling through to a deterministic `NoOp` success with empty stdout. - Malformed/non-JSON STDIN is logged through `sce.hooks.codex.error` and the - command still returns hook success (fails open), matching the other hook - intakes' producer-facing failure posture. + command still returns hook success with empty stdout (fails open), matching + the other hook intakes' producer-facing failure posture. ## Session and model identity - `prefixed_session_id`/`prefixed_diff_trace_session_id`/`prefixed_conversation_trace_session_id` (`cli/src/services/hooks/mod.rs`) carry a `"codex" -> cx_` arm alongside `oc_`/`cc_`/`pi_`, idempotent for an already-prefixed session ID. -- `normalize_codex_model_id` idempotently prefixes a raw Codex model ID with - `openai/`, mirroring `normalize_claude_model_id`. `PostToolUse(apply_patch)` - calls it to derive a `diff_traces.model_id` value when the event reports a - model. +- `normalize_codex_model_id` trims a Codex model ID, returns `None` for blank + values, and otherwise preserves the reported ID unchanged. It does not infer + or fabricate a provider prefix because Codex exposes no separate provider + field. `PostToolUse(apply_patch)` calls it to derive a + `diff_traces.model_id` value when the event reports a model. This + provider-preserving rule is an accepted durable decision; see [the ADR](../decisions/2026-08-23-codex-truthful-model-provenance.md). ## Implemented slices: `UserPromptSubmit` and `Stop` capture @@ -64,6 +79,9 @@ capture" below for the other two). Both follow the same shape: - The DB is opened per invocation through the same `open_agent_trace_db_for_hook_runtime` repository-storage resolution the other hook intakes use. +- Both successful conversation-capture arms return empty stdout; their + diagnostics and persistence failures remain logger-only through the outer + fail-open dispatcher. ## `PreToolUse(Bash)` policy delegation @@ -97,42 +115,87 @@ Codex (see "Explicit non-goals" in `PostToolUse(apply_patch)` arm: `parser.rs` parses Codex's own `apply_patch` text format (`*** Begin Patch` ... `*** End Patch`, with `Add File`/`Delete File`/`Update File` operations and an optional `Update File` + `Move to`) -into a typed `CodexPatch`; `normalize.rs` normalizes it into SCE `Index:`-form -unified-diff text `crate::services::patch::parse_patch` already accepts; -`mod.rs`'s `handle` wires the two together and persists the result: +into a typed `CodexPatch`; `path.rs` resolves its paths from the event cwd to +safe repository-relative paths; `normalize.rs` normalizes it into SCE +`Index:`-form unified-diff text `crate::services::patch::parse_patch` already +accepts; `mod.rs`'s `handle` wires the stages together and persists the result: - Reads the raw patch text from `tool_input.command` (a working assumption mirroring `PreToolUse(Bash)`'s own `tool_input.command` shape); a missing or non-string `command` fails open with no evidence. +- Before canonical parsing, outer intake preserves raw patch input and unwraps + exactly the upstream-compatible `<`, `model_id = - normalize_codex_model_id(event.model)` when a model is reported, `tool_name - = "codex"`, `tool_version = None`, `payload_type = "patch"` — no new - persistence adapter. + existing `insert_diff_trace` — `session_id = cx_` after required + trimmed non-empty validation, `model_id = normalize_codex_model_id(event.model)` + when a model is reported, `tool_name = "codex"`, `tool_version = None`, + `payload_type = "patch"` — no new persistence adapter. The event-scoped + synthetic identity scheme is an accepted durable decision; see [the ADR](../decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md). - The timestamp comes from `current_unix_time_ms()`; unlike every other Codex arm (which falls back to epoch zero via `.unwrap_or(0)`), a timestamp-acquisition failure here skips the insert entirely (fails open) rather than substituting a fabricated epoch-zero value. - Every path — success, empty-normalize no-op, and every fail-open branch — - returns empty stdout. - -Once committed, a Codex Update's synthetic patch-local line numbers still -attribute correctly through the existing, unmodified `intersect_patches` -historical `kind`+`content` fallback (`cli/src/services/patch.rs`) even when -the real committed lines land at different real line numbers — this module -does not touch that fallback, and no `diff_traces`/Agent Trace schema + returns exactly empty stdout; Bash denial is the only structured Codex + response. + +Once committed, Codex evidence still attributes correctly through the +existing, unmodified `intersect_patches` historical `kind`+`content` fallback +(`cli/src/services/patch.rs`) even when the real committed lines land at +different real line numbers. Multiple same-content events retain separate +synthetic identities through the existing `combine_patches` behavior and can +match corresponding committed additions. This module does not touch the +fallback or combination semantics, and no `diff_traces`/Agent Trace schema migration was added to support it. +## Conservative attribution boundary + +This pipeline proves supplied touched content, not the physical occurrence of +that content in the repository. Codex provides no true source line ranges, and +SCE intentionally takes no filesystem snapshot or maintains pending tool state. +When repeated identical lines occur, `combine_patches` preserves separate +event-scoped evidence identities, but the existing content-based intersection +can only match available occurrences deterministically; it cannot prove which +identical physical occurrence came from which event. The focused regression test +covers this ambiguity and deliberately does not claim that issue 8 is solved. + +The complete supported path is therefore `PostToolUse apply_patch` → +`tool_input.command` outer normalization and parsing → event-cwd/real-Git-root +path resolution → SCE `payload_type = "patch"` `diff_traces` persistence → +existing recent-row parsing, `combine_patches`, and post-commit intersection → +Agent Trace. Delete File, pure rename, and Bash-created filesystem mutations +remain without line-level evidence. There is no snapshot, pending-state, +Codex-specific Agent Trace builder, schema migration, or generic intersection +redesign in this path; malformed or unsafe inputs fail open silently. + ## No remaining stub arms All four registered dispatch arms (`UserPromptSubmit`, `Stop`, @@ -145,6 +208,10 @@ open as a `NoOp` like any other unsupported combination. - `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` (also runnable narrowed per-arm, e.g. `hooks::codex::user_prompt_submit`). + This includes the realistic repository-scoped PostToolUse/post-commit + regression and the repeated-identical-content ambiguity test. +- `nix run .#pkl-check-generated` verifies the four generated Codex hook + registrations and root-aware invocation contract. - `nix flake check` runs the same tests plus clippy/fmt/generated-asset checks. See also: [agent-trace-db.md](agent-trace-db.md), diff --git a/flake.nix b/flake.nix index ef47c62c..af4c735c 100644 --- a/flake.nix +++ b/flake.nix @@ -229,6 +229,7 @@ (pkgs.lib.fileset.maybeMissing ./config/schema/sce-config.schema.json) (pkgs.lib.fileset.maybeMissing ./cli/assets/generated) ./scripts/produce-cli-generated-input.sh + ./scripts/test-codex-hook-command.sh ]; }; @@ -1289,6 +1290,14 @@ checkCommand = "biome check --${mode}-enabled=false ."; }; + codexHookCommandCheck = mkCopiedSourceCheck { + name = "codex-hook-command-check"; + src = pklGeneratedCheckSrc; + workdir = "."; + nativeBuildInputs = [ pkgs.bash pkgs.coreutils pkgs.git pkgs.jq pkgs.pkl ]; + checkCommand = "bash ./scripts/test-codex-hook-command.sh"; + }; + configLibBunTests = mkBunCheck { name = "config-lib-bun-tests"; src = configLibBashPolicySrc; @@ -1527,6 +1536,7 @@ cli-generated-input = cliGeneratedInputCheck; pkl-generated = pklGeneratedCheck; + codex-hook-command = codexHookCommandCheck; npm-bun-tests = npmTests; npm-biome-check = npmBiomeCheck; diff --git a/scripts/test-codex-hook-command.sh b/scripts/test-codex-hook-command.sh new file mode 100755 index 00000000..513a9e4c --- /dev/null +++ b/scripts/test-codex-hook-command.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +tmp_root="$(mktemp -d)" +cleanup() { + rm -rf "${tmp_root}" +} +trap cleanup EXIT + +fail() { + printf 'Codex hook command test failed: %s\n' "$1" >&2 + exit 1 +} + +generated_root="${tmp_root}/generated" +pkl eval -m "${generated_root}" "${repo_root}/config/pkl/generate.pkl" >/dev/null +hooks_json="${generated_root}/config/.codex/hooks.json" +helper="${generated_root}/config/.codex/hooks/run-sce-or-show-install-guidance.sh" + +[ -f "${hooks_json}" ] || fail "generated hooks.json is missing" +[ -f "${helper}" ] || fail "generated hook helper is missing" + +expected_events='["PostToolUse","PreToolUse","Stop","UserPromptSubmit"]' +actual_events="$(jq -c '.hooks | keys | sort' "${hooks_json}")" +[ "${actual_events}" = "${expected_events}" ] || fail "unexpected Codex hook event registrations: ${actual_events}" + +jq -e ' + ((.hooks.UserPromptSubmit | length == 1) and (.hooks.UserPromptSubmit[0].hooks | length == 1)) + and ((.hooks.Stop | length == 1) and (.hooks.Stop[0].hooks | length == 1)) + and ((.hooks.PreToolUse | length == 1) and (.hooks.PreToolUse[0].matcher == "Bash") and (.hooks.PreToolUse[0].hooks | length == 1)) + and ((.hooks.PostToolUse | length == 1) and (.hooks.PostToolUse[0].matcher == "apply_patch") and (.hooks.PostToolUse[0].hooks | length == 1)) + and (has("$schema") | not) +' "${hooks_json}" >/dev/null || fail "Codex hook registrations are not the expected four-entry contract" + +hook_command="$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "${hooks_json}")" +for event in UserPromptSubmit Stop PreToolUse PostToolUse; do + event_command="$(jq -r --arg event "${event}" '.hooks[$event][0].hooks[0].command' "${hooks_json}")" + [ "${event_command}" = "${hook_command}" ] || fail "${event} does not use the shared Codex hook command" +done +case "${hook_command}" in + *'git rev-parse --show-toplevel'*'2>/dev/null'*'|| exit 0; exec bash '*'$root/.codex/hooks/run-sce-or-show-install-guidance.sh'*' sce hooks codex') ;; + *) fail "Codex hook command is not root-aware and fail-open: ${hook_command}" ;; +esac +case "${hook_command}" in + *eval*) fail "Codex hook command uses eval" ;; +esac + +repo="${tmp_root}/repo with spaces" +mkdir -p "${repo}/a/b/c" +git init -q "${repo}" +mkdir -p "${repo}/.codex/hooks" +cp "${helper}" "${repo}/.codex/hooks/run-sce-or-show-install-guidance.sh" + +fake_bin="${tmp_root}/bin" +mkdir -p "${fake_bin}" +{ + printf '#!%s\n' "$(command -v bash)" + cat <<'EOF' +set -euo pipefail +[ "$#" -eq 2 ] && [ "$1" = hooks ] && [ "$2" = codex ] || exit 2 +cat +EOF +} > "${fake_bin}/sce" +chmod +x "${fake_bin}/sce" + +sentinel='{"hook_event_name":"UserPromptSubmit","session_id":"sentinel"}' +printf '%s' "${sentinel}" > "${tmp_root}/expected" + +run_from() { + local working_directory="$1" + local output_path="$2" + printf '%s' "${sentinel}" | + ( + cd "${working_directory}" + PATH="${fake_bin}:${PATH}" bash -c "${hook_command}" + ) > "${output_path}" +} + +run_from "${repo}" "${tmp_root}/root-output" +run_from "${repo}/a/b/c" "${tmp_root}/nested-output" +cmp -s "${tmp_root}/expected" "${tmp_root}/root-output" || fail "root invocation did not preserve stdin" +cmp -s "${tmp_root}/expected" "${tmp_root}/nested-output" || fail "nested invocation did not preserve stdin" + +outside="${tmp_root}/outside" +mkdir -p "${outside}" +run_without_git() { + local output_path="$1" + printf '%s' "${sentinel}" | + ( + cd "${outside}" + PATH="${fake_bin}:${PATH}" bash -c "${hook_command}" + ) > "${output_path}" +} +run_without_git "${tmp_root}/outside-output" +[ ! -s "${tmp_root}/outside-output" ] || fail "Git-root failure was not silent" + +git_bin="$(command -v git)" +bash_bin="$(command -v bash)" +minimal_path="$(dirname "${git_bin}"):$(dirname "${bash_bin}")" +printf '%s' "${sentinel}" | + ( + cd "${repo}" + PATH="${minimal_path}" bash -c "${hook_command}" + ) > "${tmp_root}/missing-sce-output" 2> "${tmp_root}/missing-sce-error" +[ ! -s "${tmp_root}/missing-sce-output" ] || fail "missing-sce path emitted stdout" +grep -F 'sce CLI not found.' "${tmp_root}/missing-sce-error" >/dev/null || fail "missing-sce guidance was not emitted on stderr" + +printf 'Codex hook command tests passed.\n' From dd99dfb59a1820c1fb535085114202528914226d Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 01:58:00 +0200 Subject: [PATCH 12/20] hooks: Accept safe Codex apply_patch path traversal Allow valid parent traversal and absolute paths when canonical resolution remains inside the Git worktree, while preserving symlink-escape protection for existing and missing targets. Add coverage and durable context for nested cwd, Add File, move, malformed, and outside-path behavior. Plan: `codex-cli-integration` (T20) Co-authored-by: SCE --- .../services/hooks/codex/apply_patch/path.rs | 270 ++++++++++++++---- context/context-map.md | 1 + ...odex-canonical-worktree-path-resolution.md | 73 +++++ context/plans/codex-cli-integration.md | 74 +++++ .../sce/agent-trace-hooks-command-routing.md | 2 +- context/sce/codex-integration-runtime.md | 15 +- 6 files changed, 366 insertions(+), 69 deletions(-) create mode 100644 context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md diff --git a/cli/src/services/hooks/codex/apply_patch/path.rs b/cli/src/services/hooks/codex/apply_patch/path.rs index f9b27c09..8b44ec8d 100644 --- a/cli/src/services/hooks/codex/apply_patch/path.rs +++ b/cli/src/services/hooks/codex/apply_patch/path.rs @@ -118,19 +118,20 @@ fn resolve_event_cwd(git_root: &Path, event_cwd: &str) -> Result { bail!("Codex hook event cwd must be an absolute path."); } - let cwd = std::fs::canonicalize(cwd).with_context(|| { + let lexical_cwd = normalize_absolute_path(cwd)?; + let canonical_cwd = std::fs::canonicalize(&lexical_cwd).with_context(|| { format!( "failed to resolve Codex hook event cwd '{}'.", cwd.display() ) })?; - if !cwd.is_dir() { + if !canonical_cwd.is_dir() { bail!( "Codex hook event cwd '{}' is not a directory.", cwd.display() ); } - if !cwd.starts_with(git_root) { + if !canonical_cwd.starts_with(git_root) { bail!( "Codex hook event cwd '{}' is outside Git repository '{}'.", cwd.display(), @@ -138,87 +139,86 @@ fn resolve_event_cwd(git_root: &Path, event_cwd: &str) -> Result { ); } - Ok(cwd) + Ok(lexical_cwd) } fn resolve_path_from_cwd(git_root: &Path, event_cwd: &Path, codex_path: &str) -> Result { - let relative_path = normalize_codex_relative_path(codex_path)?; - let candidate = event_cwd.join(&relative_path); - ensure_existing_prefix_is_inside_repository(git_root, &candidate)?; - - let cwd_relative = event_cwd - .strip_prefix(git_root) - .map_err(|_| anyhow!("Codex hook event cwd cannot be represented relative to Git root."))?; - let repository_relative = cwd_relative.join(relative_path); - path_to_utf8_slash_path(&repository_relative) -} - -fn normalize_codex_relative_path(codex_path: &str) -> Result { if codex_path.trim().is_empty() || codex_path.contains('\0') { bail!("Codex apply_patch path is empty or malformed."); } - let path = Path::new(codex_path); - if path.is_absolute() { - bail!("Codex apply_patch path '{codex_path}' must not be absolute."); + let codex_path = Path::new(codex_path); + let candidate = if codex_path.is_absolute() { + codex_path.to_path_buf() + } else { + event_cwd.join(codex_path) + }; + let lexical_target = normalize_absolute_path(&candidate)?; + let resolved = resolve_candidate_inside_repository(git_root, &lexical_target)?; + path_to_utf8_slash_path( + resolved + .strip_prefix(git_root) + .map_err(|_| anyhow!("repository-relative path is outside the Git root."))?, + ) +} + +/// Resolve a lexically normalized absolute path while preserving filesystem +/// semantics for existing components. Lexical normalization must happen before +/// this function so a symlink component removed by `..` is never inspected. +fn resolve_candidate_inside_repository(git_root: &Path, candidate: &Path) -> Result { + let (existing, suffix) = nearest_existing_prefix(candidate)?; + let canonical_existing = canonicalize_inside_repository(git_root, &existing, candidate)?; + let resolved = append_path_lexically(&canonical_existing, &suffix)?; + if !resolved.starts_with(git_root) { + bail!( + "Codex apply_patch path '{}' resolves outside Git repository '{}'.", + candidate.display(), + git_root.display() + ); + } + Ok(resolved) +} + +/// Normalize an absolute path using Codex's lexical `PathUri::join` semantics: +/// `.` is removed, `..` removes the preceding lexical component, and parent +/// traversal at the filesystem root is clamped rather than treated as an error. +fn normalize_absolute_path(path: &Path) -> Result { + if !path.is_absolute() { + bail!("Codex path must be absolute after joining with the event cwd."); } let mut normalized = PathBuf::new(); - let mut has_normal_component = false; for component in path.components() { match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()), Component::CurDir => {} - Component::Normal(value) => { - if value.to_str().is_none() { - bail!("Codex apply_patch path '{codex_path}' is not valid UTF-8."); - } - normalized.push(value); - has_normal_component = true; - } + Component::Normal(value) => normalized.push(value), Component::ParentDir => { - bail!("Codex apply_patch path '{codex_path}' must not escape the event cwd."); - } - Component::RootDir | Component::Prefix(_) => { - bail!("Codex apply_patch path '{codex_path}' is not relative."); + let _ = normalized.pop(); } } } - if !has_normal_component { - bail!("Codex apply_patch path '{codex_path}' has no file component."); - } - Ok(normalized) } -/// Check the nearest existing path prefix so a lexical path through a -/// symlink cannot silently map evidence outside the real repository. Missing -/// Add File targets are allowed; their existing parent prefix is checked. -fn ensure_existing_prefix_is_inside_repository(git_root: &Path, candidate: &Path) -> Result<()> { - let mut existing = candidate; +fn nearest_existing_prefix(path: &Path) -> Result<(PathBuf, PathBuf)> { + let mut existing = path.to_path_buf(); loop { - match std::fs::symlink_metadata(existing) { + match std::fs::symlink_metadata(&existing) { Ok(_) => { - let resolved = std::fs::canonicalize(existing).with_context(|| { - format!( - "failed to resolve existing Codex apply_patch path prefix '{}'.", - existing.display() - ) - })?; - if !resolved.starts_with(git_root) { - bail!( - "Codex apply_patch path '{}' resolves outside Git repository '{}'.", - candidate.display(), - git_root.display() - ); - } - return Ok(()); + let suffix = path + .strip_prefix(&existing) + .map_err(|_| anyhow!("Codex apply_patch path has an invalid prefix."))? + .to_path_buf(); + return Ok((existing, suffix)); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - existing = existing.parent().ok_or_else(|| { + existing = existing.parent().map(Path::to_path_buf).ok_or_else(|| { anyhow!( "Codex apply_patch path '{}' has no existing repository prefix.", - candidate.display() + path.display() ) })?; } @@ -234,6 +234,48 @@ fn ensure_existing_prefix_is_inside_repository(git_root: &Path, candidate: &Path } } +fn canonicalize_inside_repository( + git_root: &Path, + existing: &Path, + candidate: &Path, +) -> Result { + let resolved = std::fs::canonicalize(existing).with_context(|| { + format!( + "failed to resolve existing Codex apply_patch path prefix '{}'.", + existing.display() + ) + })?; + if !resolved.starts_with(git_root) { + bail!( + "Codex apply_patch path '{}' resolves outside Git repository '{}'.", + candidate.display(), + git_root.display() + ); + } + Ok(resolved) +} + +fn append_path_lexically(base: &Path, suffix: &Path) -> Result { + let mut result = base.to_path_buf(); + for component in suffix.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => result.push(value), + Component::ParentDir => { + if !result.pop() { + bail!("Codex apply_patch path traverses above the filesystem root."); + } + } + Component::RootDir | Component::Prefix(_) => { + bail!("Codex apply_patch path has an invalid suffix."); + } + } + } + Ok(result) +} + +/// Convert a canonical or lexically resolved path into the slash-separated +/// UTF-8 form used by SCE patch text. fn path_to_utf8_slash_path(path: &Path) -> Result { let mut components = Vec::new(); for component in path.components() { @@ -302,14 +344,18 @@ mod tests { } #[test] - fn resolves_nested_cwd_and_dot_components() { + fn resolves_nested_cwd_parent_traversal_and_dot_components() { let root = temp_repo("nested"); let cwd = root.join("src").join("lib"); fs::create_dir_all(&cwd).expect("nested cwd should be created"); - let result = - resolve_codex_patch_path(&root, &cwd.join(".").to_string_lossy(), "./../lib.rs") - .expect_err("traversal must be rejected even when dot components are present"); - assert!(result.to_string().contains("must not escape")); + + let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "./../lib.rs") + .expect("valid parent traversal should resolve"); + assert_eq!(result, "src/lib.rs"); + + let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "../../root.rs") + .expect("multiple valid parent traversals should resolve"); + assert_eq!(result, "root.rs"); let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "./nested/file.rs") .expect("nested relative path should resolve"); @@ -317,6 +363,37 @@ mod tests { remove_repo(&root); } + #[test] + fn accepts_absolute_inside_worktree_paths() { + let root = temp_repo("absolute-inside"); + let cwd = root.join("src"); + fs::create_dir(&cwd).expect("src directory should be created"); + let target = cwd.join("../lib.rs"); + + let result = + resolve_codex_patch_path(&root, &cwd.to_string_lossy(), &target.to_string_lossy()) + .expect("absolute path inside the worktree should resolve"); + assert_eq!(result, "lib.rs"); + remove_repo(&root); + } + + #[test] + fn accepts_missing_add_targets_and_normalizes_their_parent_components() { + let root = temp_repo("missing-target"); + let cwd = root.join("src").join("lib"); + fs::create_dir_all(&cwd).expect("nested cwd should be created"); + + let result = + resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "../generated/new file.rs") + .expect("missing add target should resolve"); + assert_eq!(result, "src/generated/new file.rs"); + + let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), "../../new.rs") + .expect("missing target after parent traversal should resolve"); + assert_eq!(result, "new.rs"); + remove_repo(&root); + } + #[test] fn resolves_move_source_and_destination_independently() { let root = temp_repo("move"); @@ -339,7 +416,7 @@ mod tests { .expect("temporary root should have a parent") .to_path_buf(); - for path in ["../outside.txt", "/etc/passwd", "./..", ""] { + for path in ["../outside.txt", "/etc/passwd", "./..", "", "bad\0path"] { let error = resolve_codex_patch_path(&root, &root.to_string_lossy(), path) .expect_err("unsafe path should be rejected"); assert!(!error.to_string().is_empty()); @@ -359,6 +436,73 @@ mod tests { remove_repo(&root); } + #[cfg(unix)] + #[test] + fn rejects_existing_and_missing_paths_that_escape_through_symlinks() { + use std::os::unix::fs::symlink; + + let root = temp_repo("symlink-escape"); + let outside = root + .parent() + .expect("temporary root should have a parent") + .join(format!("sce-codex-path-outside-{}", std::process::id())); + fs::create_dir_all(&outside).expect("outside directory should be created"); + fs::write(outside.join("existing.rs"), "outside").expect("outside file should be created"); + + let alias = root.join("alias"); + let existing_link = root.join("existing-link"); + let missing_link = root.join("missing-link"); + symlink(&outside, &alias).expect("alias escape symlink should be created"); + symlink(&outside, &existing_link).expect("existing escape symlink should be created"); + symlink(&outside, &missing_link).expect("missing escape symlink should be created"); + + let eliminated = + resolve_codex_patch_path(&root, &root.to_string_lossy(), "alias/../foo.rs") + .expect("a symlink removed by lexical parent traversal must not be inspected"); + assert_eq!(eliminated, "foo.rs"); + assert!( + resolve_codex_patch_path(&root, &root.to_string_lossy(), "alias/foo.rs").is_err(), + "an actually traversed escape symlink must be rejected" + ); + assert!(resolve_codex_patch_path( + &root, + &root.to_string_lossy(), + "existing-link/existing.rs" + ) + .is_err()); + assert!( + resolve_codex_patch_path(&root, &root.to_string_lossy(), "missing-link/new.rs") + .is_err() + ); + + remove_repo(&root); + let _ = fs::remove_dir_all(outside); + } + + #[test] + fn clamps_excessive_parent_traversal_at_filesystem_root() { + let root = temp_repo("root-clamp"); + let cwd = root.join("src"); + fs::create_dir(&cwd).expect("src directory should be created"); + let parent_path = root + .parent() + .expect("temporary repository should have a parent") + .strip_prefix(Path::new("/")) + .expect("temporary repository parent should be absolute") + .to_string_lossy(); + let root_name = root + .file_name() + .expect("temporary repository should have a name") + .to_str() + .expect("temporary repository name should be UTF-8"); + let path = format!("../../../../{parent_path}/{root_name}/clamped.rs"); + + let result = resolve_codex_patch_path(&root, &cwd.to_string_lossy(), &path) + .expect("excessive parent traversal should clamp at filesystem root"); + assert_eq!(result, "clamped.rs"); + remove_repo(&root); + } + #[test] fn preserves_spaces_in_repository_relative_paths() { let root = temp_repo("spaces"); diff --git a/context/context-map.md b/context/context-map.md index 1f9da5a1..d295bd61 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -98,6 +98,7 @@ Supporting repo docs: Recent decision records: +- `context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md` (accepts upstream-compatible Codex apply_patch parent/absolute paths only when canonical resolution remains inside the Git worktree, validates nearest existing prefixes for missing targets, and rejects symlink escapes) - `context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md` (uses bounded, deterministic `tool_use_id`-derived synthetic line identities for Codex apply_patch evidence; positions are evidence identities rather than source line numbers, with existing patch combination/intersection semantics unchanged) - `context/decisions/2026-08-23-codex-truthful-model-provenance.md` (preserves non-empty Codex model IDs unchanged, leaves blank/missing values nullable, and forbids inferred provider prefixes or a fabricated provider field) - `context/decisions/2026-08-23-codex-root-aware-hook-invocation.md` (requires generated Codex hook commands to resolve the Git root at invocation time, quote the helper path, preserve STDIN, and fail open when root resolution fails) diff --git a/context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md b/context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md new file mode 100644 index 00000000..733aa9b1 --- /dev/null +++ b/context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md @@ -0,0 +1,73 @@ +# Decision: Resolve Codex apply_patch paths through canonical worktree containment + +Date: 2026-08-23 +Status: Accepted +Plan: `context/plans/codex-cli-integration.md` +Task: `T20` + +## Context + +Codex `apply_patch` supplies paths relative to its event cwd and can also supply +absolute paths or valid parent traversal. SCE must accept upstream-compatible +inputs without allowing evidence paths to escape the real Git worktree, +including through symlinks. Add File targets may not exist yet, so checking only +the final path is insufficient. + +## Decision + +Resolve each Codex apply_patch source and move-destination path independently +from the event cwd against the canonical Git root, accepting relative `..` and +absolute paths only when canonical resolution remains inside the worktree. For +missing targets, canonicalize and validate the nearest existing prefix, then +emit the resulting repository-relative UTF-8 slash path. Reject malformed, +outside, or symlink-escaping mappings before normalization or database access. + +## Rationale + +This preserves current upstream path compatibility while keeping the evidence +boundary tied to the real repository rather than the hook process cwd. Prefix +canonicalization protects both existing paths and not-yet-created Add File +paths without snapshots or filesystem-delta observation. + +## Alternatives considered + +- **Reject all absolute and parent-traversal paths** — safer lexically but + incompatible with valid current Codex inputs. +- **Use lexical normalization only** — accepts compatible syntax but cannot + detect symlink escapes. +- **Take a filesystem snapshot** — could provide stronger mutation evidence but + violates the Codex no-snapshot design and is outside this integration's scope. + +## Compatibility and risks + +- Valid upstream-accepted paths inside the worktree now resolve successfully; + unsafe or ambiguous mappings fail open with no evidence. +- Missing targets are represented from their validated existing prefix, so a + later filesystem change between the hook and commit can still affect physical + occurrence attribution; the existing post-commit intersection remains the + final filter. + +## Guardrails + +- The canonical Git root and event cwd must resolve to existing directories. +- Every source and move destination is resolved independently. +- No snapshot, pending state, schema migration, or generic intersection change + is introduced by this path contract. + +## Consequences + +- Codex evidence contains only repository-relative UTF-8 slash paths. +- Add File paths can be absent at hook time, while existing and missing symlink + escapes are rejected conservatively. + +## Follow-up + +None. + +## References + +- Plan: [`codex-cli-integration`](../plans/codex-cli-integration.md) +- Task: `T20` +- Current-state context: [`Codex hook runtime`](../sce/codex-integration-runtime.md) +- Evidence: [`path resolution implementation`](../../cli/src/services/hooks/codex/apply_patch/path.rs) +- Related decision: [`Codex root-aware hook invocation`](2026-08-23-codex-root-aware-hook-invocation.md) diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 6f097bf9..eb58cbdb 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -8,6 +8,8 @@ Adds Codex CLI as a fourth first-class SCE integration target alongside OpenCode This revision hardens that implementation rather than redesigning Agent Trace. It adds upstream-aligned outer patch normalization, resolves parsed paths against the real Git repository and Codex event `cwd`, rejects missing/invalid provenance inputs, makes all non-policy Codex hook paths silent, fixes nested-cwd generated hook invocation, prevents avoidable `combine_patches` collisions, and removes fabricated OpenAI model provenance. The current upstream `openai/codex` source inspected for this revision is commit `343074d4207d572809bd8cea15f4be1d09d98e0b`; its hook payload has `cwd`, `model`, `tool_name`, `tool_use_id`, `tool_input`, and `tool_response`, but no separate provider field, and its command runner executes hooks with `.current_dir(cwd)`. The existing, unmodified `intersect_patches` historical `kind`+`content` fallback is what lets that synthetic-line evidence still attribute correctly once the real commit lands at different line numbers — this plan does not touch that fallback. Bash-triggered filesystem mutations remain explicitly out of scope for attribution — Bash gets policy enforcement only, matching the current-state boundary already documented for Claude/Pi. +This revision extends the completed Codex rollout with six correctness hardening slices: repository-safe apply_patch path normalization, non-destructive Codex hook-config ownership and merging, upstream-compatible structural/trust diagnosis, skill-invocation-neutral generated Codex Markdown, nullable Stop and timestamp correctness, and one transactional conversation-event storage primitive. The existing evidence architecture remains unchanged: Codex produces evidence, while Git post-commit intersection remains the final attribution authority. + ## Acceptance criteria - [x] AC1: `sce setup --codex --non-interactive` succeeds in a Git repository, installs `.agents/skills/**` and `.codex/hooks.json` + `.codex/hooks/**`, and persists `{"integrations": {"target": ["codex"]}}` into `.sce/config.json` under existing merge semantics. @@ -62,6 +64,21 @@ This revision hardens that implementation rather than redesigning Agent Trace. I - [x] AC25: The complete hardened pipeline remains `PostToolUse apply_patch` → `tool_input.command` parsing → cwd-aware path resolution → SCE `payload_type = "patch"` `diff_traces` → existing `recent_diff_trace_patches`/`combine_patches` → existing Git post-commit intersection → Agent Trace, with no new schema, snapshot, pending state, PreToolUse apply_patch registration, Bash mutation attribution, or Codex-specific Agent Trace builder. - Validate: end-to-end temporary repository/Agent Trace DB test feeding realistic Codex PostToolUse JSON and a realistic post-commit patch, plus source/status inspection showing no migration or forbidden state artifacts. +- [ ] AC26: Codex apply_patch path resolution accepts valid `..` components and absolute paths when current Codex semantics accept them and the logical target remains inside the canonical Git worktree; accepts missing Add File targets and paths containing spaces; resolves nested cwd, Update source, and Move destination independently; rejects outside escapes, malformed/NUL paths, outside/empty cwd, and existing or missing targets that escape through symlinks. It emits only repository-relative UTF-8 slash paths. + - Validate: `hooks::codex::apply_patch::path` tests cover the complete path matrix, including valid parent traversal, absolute-inside paths, Add File missing targets, move paths, outside paths, and both symlink escape forms. +- [ ] AC27: `sce setup --codex` merges `.codex/hooks.json` through shared Codex ownership logic, preserving unrelated top-level properties, event groups, matcher groups, and user handlers; it replaces stale/duplicate SCE-owned handlers with exactly one current handler per required registration, adds missing registrations, is semantically idempotent, and leaves malformed/structurally invalid existing JSON byte-for-byte untouched while naming the file in the error. + - Validate: shared Codex hook-config unit tests and setup tests cover absent files, every required event/matcher, unrelated handlers/groups/properties, stale and duplicate SCE handlers, repeated merge, and malformed JSON/no-write behavior. +- [ ] AC28: `sce doctor` reports Codex-owned registrations structurally as `PresentAndCurrent`, `Missing`, `Stale`, or `Malformed`, ignores user-owned additions, and separately reports executable trust readiness. It does not claim health when the effective Codex state is disabled, untrusted, modified, or unknown; it reports healthy only for current SCE registrations that Codex will actually execute, and `--fix` repairs only the SCE-owned fragment without changing user hooks or trust state. + - Validate: doctor/shared-service tests cover current plus user hooks, missing/stale/malformed fragments, trusted/untrusted/modified/disabled/unknown state, current upstream key/hash/config-layer semantics, and trust-preserving fix behavior. +- [ ] AC29: No generated Codex `.agents/skills/**/*.md` file, including `SKILL.md` and `references/*.md`, contains literal `$ARGUMENTS`; canonical workflow content uses explicit skill-invocation input semantics while command-capable Claude/OpenCode/Pi entrypoints retain their existing argument-substitution behavior. + - Validate: generated contract coverage walks all Codex skill Markdown and asserts the token is absent, while cross-target generation tests assert command wrappers and non-Codex behavior remain unchanged. +- [ ] AC30: Codex Stop accepts upstream-valid `last_assistant_message: null` as a successful silent no-op before Agent Trace DB access, returning exact stdout `""` and inserting neither a message nor a part. Explicit empty-string behavior is tested separately according to the current upstream contract and is never conflated with null; malformed values still fail open without fake assistant text. + - Validate: Codex Stop dispatcher/handler tests cover normal text, null, explicit empty string, exact stdout, and no-write behavior. +- [ ] AC31: Codex conversation handlers trim and persist validated non-empty `session_id` and `turn_id` consistently, acquire timestamps with fallible propagation, and never persist epoch-0 fallback provenance. Timestamp acquisition failure is fail-open with no DB write for UserPromptSubmit, Stop, and all other Codex trace paths that could otherwise synthesize zero. + - Validate: Codex handler tests cover whitespace-padded identifiers, missing identifiers, timestamp failures, and source inspection/tests for `unwrap_or(0)`, zero timestamp literals, and equivalent default fallbacks. +- [ ] AC32: UserPromptSubmit and Stop persist one logical conversation text event through one transactional DB primitive: parent message plus text part are inserted together or neither is inserted; replay of one, ten, or concurrent duplicate deliveries is a successful no-op with exactly one message and one part; injected part failure rolls back the parent message; apply_patch persistence remains on its existing independent diff-trace API and no migration is added. + - Validate: Agent Trace DB atomic-event tests cover replay, transaction rollback via an injectable failure seam, and the SQLite write-serialization/concurrent duplicate contract; both Codex handlers use the primitive. + ### Full validation - `nix run .#pkl-check-generated` @@ -103,6 +120,8 @@ Persist this field in every plan; this is durable plan state, not chat state: - Codex's `PreToolUse` deny response shape (confirmed in T09) is unaffected by this revision; `apply_patch` tracing is `PostToolUse`-only and never returns a deny response, only empty stdout on success (including every fail-open branch). - `apply_patch` tracing departs from this codebase's existing `current_unix_time_ms().unwrap_or(0)` pattern (used elsewhere in `cli/src/services/hooks/mod.rs` and by the Codex `UserPromptSubmit`/`Stop` arms) by design: a time-acquisition failure skips the `diff_traces` insert entirely (fails open) rather than substituting a fabricated epoch-zero timestamp, per the change request's explicit instruction for this one code path. +- Current upstream behavior was refreshed at `openai/codex` commit `8e649e3afa5cdddfb09a1b85a090b94775045d9b` before this revision. Its apply-patch parser accepts relative and absolute paths, resolves them by joining relative paths to the hook cwd, and retains lexical `..` components for later filesystem resolution; its Stop schema declares `last_assistant_message` as `string | null`, while its UserPromptSubmit/PreToolUse/PostToolUse schemas require the corresponding fields and use untyped JSON for tool input/response. Its hook discovery keys state by source path + normalized event label (`pre_tool_use`, `post_tool_use`, etc.) + matcher-group index + handler index; hashes a normalized event/matcher-group/single-handler identity with the current `version_for_toml` algorithm; loads effective hook state from user and session-flag layers; treats `enabled = false` as disabled; and classifies non-managed hooks as Trusted when `trusted_hash` equals the current hash, Modified when it differs, and Untrusted when absent, while managed hooks are Managed. Project hooks are non-managed and therefore do not become executable merely because their JSON is current. The current command runner executes hooks with the event cwd and forwards stdin unchanged. Current Codex skills are loaded as Markdown prompt contents after metadata discovery and injected as model-visible skill instructions; the loading/invocation code does not provide Claude/OpenCode-style `$ARGUMENTS` substitution to skill Markdown. + ## Task stack - [x] T01: `Add AgentProducer identity, cx_ session prefixing, and openai/ model normalization` (status:done) @@ -359,9 +378,64 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — the hardened Codex runtime and patch attribution boundaries are cross-cutting integration contracts; `context/overview.md`, `context/architecture.md`, `context/sce/agent-trace-hooks-command-routing.md`, `context/sce/codex-integration-runtime.md`, and `context/cli/patch-service.md` now document cwd-aware resolution, event-scoped identities, silent fail-open behavior, truthful model provenance, existing-intersection attribution, and repeated-content ambiguity. - Context synchronization: synced +- [x] T20: `Resolve Codex apply_patch paths with canonical worktree semantics` (status:done) + - Task ID: T20 + - Scope: In — replace the current lexical-only path rejection in `cli/src/services/hooks/codex/apply_patch/path.rs` with canonical Git-root/event-cwd resolution that accepts safe parent traversal and upstream-accepted absolute paths, handles non-existent Add File targets by canonicalizing the nearest existing prefix, preserves symlink-escape protection, and adds the full path matrix for source/destination operations. Out — snapshots, filesystem-delta observation, pending apply_patch state, and generic patch intersection changes. + - Dependencies: T19 + - Done when: every resolved path is proven inside the canonical Git worktree and emitted as a repository-relative UTF-8 slash path; valid `..`, absolute-inside, missing Add File, nested cwd, move, spaces, and dot cases pass; outside, malformed/NUL, empty/outside cwd, and existing/missing symlink escapes fail open before DB access. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch::path'` + - Completed: 2026-08-23 + - Files changed: `cli/src/services/hooks/codex/apply_patch/path.rs` + - Result: Replaced lexical-only path rejection with canonical Git-root/event-cwd resolution. Relative parent traversal and absolute paths are accepted when the resolved target remains inside the canonical worktree; missing targets are resolved through the nearest existing prefix, while existing and missing symlink escapes are rejected. Resolved evidence is emitted as repository-relative UTF-8 slash paths, with source and move-destination paths handled independently. Added coverage for valid traversal, absolute-inside paths, missing Add targets, malformed inputs, outside cwd, spaces, moves, and symlink escapes. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch::path'` — passed: 9 tests. + - Verify: `nix flake check` — passed: all checks, including CLI tests, Clippy, formatting, generated-input, Pkl, and Codex hook-command checks. + - Context impact: root — Codex apply_patch path resolution now accepts canonical safe parent/absolute paths and protects canonical worktree boundaries, refining the durable Codex runtime and directly relevant architecture/hook-routing context contracts. + - Context synchronization: synced + +- [ ] T21: `Add shared non-destructive Codex hook ownership and setup merge` (status:todo) + - Task ID: T21 + - Scope: In — add a focused shared Codex hook-config module used by setup and doctor for parsing, structural validation, SCE ownership predicates, canonical required registrations, and order-preserving JSON merge; make `sce setup --codex` and `--all` merge `.codex/hooks.json` rather than overwrite it, including malformed/structural error handling and idempotent stale/duplicate replacement. Out — trust-state writes, auto-trust behavior, unrelated target merge logic, and whole-document replacement. + - Dependencies: T20 + - Done when: exactly one current SCE-owned handler exists for UserPromptSubmit, Stop, PreToolUse/Bash, and PostToolUse/apply_patch while unrelated fields/groups/handlers survive byte-for-byte or structurally unchanged where serialization requires; malformed existing files are not modified; repeated setup is idempotent; the shared ownership predicate requires the generated helper path and `sce hooks codex` command contract rather than a substring such as `sce`. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::setup'` + - Context synchronization: pending + +- [ ] T22: `Make Codex doctor structural and trust-aware` (status:todo) + - Task ID: T22 + - Scope: In — reuse the shared Codex hook-config service in doctor diagnosis and fix, report per-registration PresentAndCurrent/Missing/Stale/Malformed states, isolate upstream-compatible trust-key/hash/state compatibility code, and integrate disabled/untrusted/modified/unknown readiness into existing doctor severity/rendering conventions without auto-trusting. Out — changing Codex itself, writing trust state, and changing the core Agent Trace evidence model. + - Dependencies: T21 + - Done when: user-owned additions do not create SCE drift; current SCE fragments plus enabled/trusted effective state are healthy; missing, stale, malformed, disabled, untrusted, modified, unreadable, unresolvable, or unsupported state is not reported as executable healthy; doctor fix preserves user hooks and never changes trust consent. Comments identify the mirrored upstream source and tests cover current key/hash/config precedence semantics. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor'` + - Context synchronization: pending + +- [ ] T23: `Render Codex skills with explicit skill-invocation input semantics` (status:todo) + - Task ID: T23 + - Scope: In — parameterize the smallest canonical Pkl workflow-content/composite rendering seam so skill-mode prose names the user-invoked change request and does not mention literal `$ARGUMENTS`, while command-mode wrappers retain `$ARGUMENTS` where their harness substitutes it; update generated Codex skill contract tests for `SKILL.md` and all package references and preserve Claude/OpenCode/Pi output behavior. Out — global post-render text replacement, duplicated workflows, and changes to Codex skill loading itself. + - Dependencies: T22 + - Done when: every generated Codex `.agents/skills/**/*.md` document is free of `$ARGUMENTS`, generated command entrypoints that support substitution remain unchanged, and the target-neutral workflow behavior remains semantically equivalent across all targets. + - Verify: `nix run .#pkl-check-generated` + - Context synchronization: pending + +- [ ] T24: `Correct Codex Stop nullability, identifiers, and timestamps` (status:todo) + - Task ID: T24 + - Scope: In — update UserPromptSubmit and Stop validation/persistence to trim and persist IDs consistently, short-circuit nullable Stop messages before DB open, define and test distinct explicit-empty-string behavior, replace timestamp fallbacks with fallible acquisition through the existing outer fail-open boundary, and audit all Codex provenance timestamp paths for zero/default synthesis. Out — apply_patch evidence architecture and database schema changes. + - Dependencies: T23 + - Done when: null Stop is a silent successful no-op with no message/part and no DB open; valid padded IDs persist trimmed values; normal and explicit-empty cases follow separate tested semantics; timestamp failures never write and no Codex trace path can persist January 1, 1970 fallback provenance. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` + - Context synchronization: pending + +- [ ] T25: `Persist Codex conversation text events atomically and replay-safely` (status:todo) + - Task ID: T25 + - Scope: In — add one repository DB operation for exactly-once conversation text events that serializes the existence check and parent-plus-part insert in a transaction, expose a failure-injection seam for rollback tests, and migrate only UserPromptSubmit/Stop to it. Out — apply_patch/diff-trace persistence, schema migrations, new uniqueness columns, and per-handler dedupe implementations. + - Dependencies: T24 + - Done when: one transaction inserts both rows or neither, duplicate sequential and concurrent deliveries are successful no-ops with one message and one part, injected part failure leaves zero rows, and existing conversation-trace writers plus apply_patch persistence remain unchanged. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db hooks::codex'` + - Context synchronization: pending + ## Open questions - Current upstream behavior is verified at `openai/codex` commit `343074d4207d572809bd8cea15f4be1d09d98e0b`, but Codex is external and evolving; a future upstream hook-schema or parser change can require refreshing the compatibility fixtures. This is non-blocking because the plan records the source commit and makes the accepted forms/tests explicit. The current source exposes no provider identity separate from `model`, so this revision intentionally preserves incomplete model provenance rather than fabricating `openai/`. +- The current upstream trust/config contract is mirrored only for local diagnosis; if Codex changes the state-file location or hash serialization, doctor must report `Unknown` rather than infer executable trust until the compatibility tests are refreshed. ## Validation Report diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index fd471265..0ecfcb70 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -114,7 +114,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths from the event `cwd` against the real Git root and rejects unsafe or non-repository-relative mappings. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 4124c028..27ca08b1 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -133,11 +133,16 @@ accepts; `mod.rs`'s `handle` wires the stages together and persists the result: - After parsing, the handler discovers the real Git root rather than assuming the process cwd is the repository root. It requires `cwd` to be a valid absolute directory inside that root, resolves every source and move - destination independently from that cwd, normalizes `.` components, and - emits only lossless repository-relative UTF-8 paths. Traversal, absolute or - malformed paths, outside-repository cwd, and symlink-escaping existing path - prefixes are logged as `sce.hooks.codex.apply_patch.path_resolution_failed` - and fail open before normalization or database access. + destination independently from that cwd, and emits only lossless + repository-relative UTF-8 paths. Valid `..` components and absolute paths + are accepted when canonical resolution remains inside the worktree. Missing + targets are checked through their nearest existing prefix, so Add File paths + can remain absent while existing and missing symlink escapes are rejected. + Outside-repository cwd, outside paths, malformed/NUL paths, and ambiguous + mappings are logged as + `sce.hooks.codex.apply_patch.path_resolution_failed` and fail open before + normalization or database access. This canonical worktree containment rule + is an accepted compatibility and security decision; see [the ADR](../decisions/2026-08-23-codex-canonical-worktree-path-resolution.md). - A parse failure is logged (`sce.hooks.codex.apply_patch.parse_failed`) and fails open with no evidence — never a deny response, since `apply_patch` tracing is `PostToolUse`-only. From aa6259d5f34b9846f2ae4dd01773d2e38b6ce781 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 02:35:00 +0200 Subject: [PATCH 13/20] setup+doctor: Add non-destructive Codex hook configuration merging Codex's user-owned hook registry must retain unrelated configuration while SCE refreshes its registrations. Add a shared structural merge service that validates hook documents, recognizes ownership by the helper path and `sce hooks codex` contract, and replaces stale or duplicate handlers with one current registration for each required event. Use the service during Codex setup and doctor inspection so malformed files are rejected before writes, repeated setup is idempotent, and user-added handlers do not create false drift. Record the completed T21 implementation and its architectural decision. Plan: `context/plans/codex-cli-integration.md` (T21) Co-authored-by: SCE --- cli/src/services/codex_hook_config.rs | 791 ++++++++++++++++++ cli/src/services/doctor/inspect.rs | 11 +- cli/src/services/mod.rs | 1 + cli/src/services/setup/mod.rs | 100 +++ context/architecture.md | 4 +- context/context-map.md | 1 + ...-23-codex-nondestructive-hook-ownership.md | 86 ++ context/glossary.md | 4 +- context/overview.md | 2 +- context/patterns.md | 2 +- context/plans/codex-cli-integration.md | 15 +- .../sce/agent-trace-hooks-command-routing.md | 2 +- context/sce/codex-integration-runtime.md | 20 + context/sce/doctor-human-text-contract.md | 6 +- context/sce/setup-no-backup-policy-seam.md | 7 +- 15 files changed, 1034 insertions(+), 18 deletions(-) create mode 100644 cli/src/services/codex_hook_config.rs create mode 100644 context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md diff --git a/cli/src/services/codex_hook_config.rs b/cli/src/services/codex_hook_config.rs new file mode 100644 index 00000000..9f03a97c --- /dev/null +++ b/cli/src/services/codex_hook_config.rs @@ -0,0 +1,791 @@ +//! Shared structural ownership and merge logic for Codex's repository hook config. +//! +//! The accepted shape intentionally mirrors the relevant current upstream +//! `HooksFile`, `HookEventsToml`, `MatcherGroup`, and `HookHandlerConfig` JSON +//! deserialization rules. This keeps setup and doctor aligned without taking a +//! dependency on Codex's source or preserving JSON that Codex cannot load. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +const CODEX_HOOKS_ROOT: &str = "hooks"; +const CODEX_HELPER_PATH: &str = ".codex/hooks/run-sce-or-show-install-guidance.sh"; +const CODEX_ROOTED_HELPER_PATH: &str = "$root/.codex/hooks/run-sce-or-show-install-guidance.sh"; +const CODEX_COMMAND_WORDS: [&str; 3] = ["sce", "hooks", "codex"]; +const REQUIRED_EVENTS: [(&str, Option<&str>); 4] = [ + ("UserPromptSubmit", None), + ("Stop", None), + ("PreToolUse", Some("Bash")), + ("PostToolUse", Some("apply_patch")), +]; + +/// Merge the canonical generated Codex hooks into an existing file. +/// +/// A missing file is installed verbatim. An existing file is parsed and +/// structurally validated before any merged bytes are returned, allowing the +/// caller to preserve it unchanged when parsing or validation fails. +pub(crate) fn merge_or_create( + existing_bytes: Option<&[u8]>, + generated_bytes: &[u8], + source_path: &str, +) -> Result> { + let Some(existing_bytes) = existing_bytes else { + validate_generated_document(generated_bytes)?; + return Ok(generated_bytes.to_vec()); + }; + + let existing: Value = serde_json::from_slice(existing_bytes).with_context(|| { + format!("Existing Codex hook config '{source_path}' must contain valid JSON.") + })?; + validate_document(&existing, source_path)?; + + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated Codex hook config must contain valid JSON")?; + let registrations = validate_generated_document_value(&generated)?; + let merged = merge_document(existing, ®istrations, source_path)?; + + let mut serialized = serde_json::to_string_pretty(&merged) + .context("Failed to serialize merged Codex hook config")?; + serialized.push('\n'); + Ok(serialized.into_bytes()) +} + +/// Returns whether the existing file already contains exactly the current SCE +/// fragment. Unrelated valid Codex configuration is intentionally ignored. +pub(crate) fn fragment_is_current(existing_bytes: &[u8], generated_bytes: &[u8]) -> Result { + let existing: Value = serde_json::from_slice(existing_bytes) + .context("Existing Codex hook config must contain valid JSON")?; + validate_document(&existing, "existing Codex hook config")?; + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated Codex hook config must contain valid JSON")?; + let registrations = validate_generated_document_value(&generated)?; + Ok(merge_document( + existing.clone(), + ®istrations, + "existing Codex hook config", + )? == existing) +} + +#[derive(Clone)] +struct Registration { + event: &'static str, + matcher: Option<&'static str>, + group: Value, + handler: Value, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct CodexHooksFile { + #[serde(default)] + description: Option, + #[serde(default)] + hooks: CodexHookEvents, +} + +#[derive(Debug, Default, Deserialize)] +struct CodexHookEvents { + #[serde(rename = "PreToolUse", default)] + pre_tool_use: Vec, + #[serde(rename = "PermissionRequest", default)] + permission_request: Vec, + #[serde(rename = "PostToolUse", default)] + post_tool_use: Vec, + #[serde(rename = "PreCompact", default)] + pre_compact: Vec, + #[serde(rename = "PostCompact", default)] + post_compact: Vec, + #[serde(rename = "SessionStart", default)] + session_start: Vec, + #[serde(rename = "SessionEnd", default)] + session_end: Vec, + #[serde(rename = "UserPromptSubmit", default)] + user_prompt_submit: Vec, + #[serde(rename = "SubagentStart", default)] + subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + subagent_stop: Vec, + #[serde(rename = "Stop", default)] + stop: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct CodexMatcherGroup { + #[serde(default)] + matcher: Option, + #[serde(default)] + hooks: Vec, +} + +fn validate_generated_document(bytes: &[u8]) -> Result<()> { + let generated: Value = serde_json::from_slice(bytes) + .context("Generated Codex hook config must contain valid JSON")?; + validate_generated_document_value(&generated).map(|_| ()) +} + +fn validate_generated_document_value(generated: &Value) -> Result> { + validate_document(generated, "generated Codex hook config")?; + let object = generated + .as_object() + .context("Generated Codex hook config must contain a top-level JSON object")?; + let hooks = object + .get(CODEX_HOOKS_ROOT) + .context("Generated Codex hook config must contain a 'hooks' object")? + .as_object() + .context("Generated Codex hook config key 'hooks' must be a JSON object")?; + + let mut registrations = Vec::with_capacity(REQUIRED_EVENTS.len()); + for (event, matcher) in REQUIRED_EVENTS { + let groups = hooks + .get(event) + .with_context(|| format!("Generated Codex hook config is missing '{event}'"))? + .as_array() + .with_context(|| { + format!("Generated Codex hook config key 'hooks.{event}' must be a JSON array") + })?; + if groups.len() != 1 { + bail!( + "Generated Codex hook config key 'hooks.{event}' must contain exactly one matcher group" + ); + } + let group = groups[0].as_object().with_context(|| { + format!("Generated Codex hook config 'hooks.{event}[0]' must be a JSON object") + })?; + validate_matcher(group, event, matcher)?; + let handlers = group + .get("hooks") + .with_context(|| { + format!("Generated Codex hook config '{event}' group must contain 'hooks'") + })? + .as_array() + .with_context(|| { + format!("Generated Codex hook config '{event}' group 'hooks' must be a JSON array") + })?; + if handlers.len() != 1 { + bail!("Generated Codex hook config '{event}' must contain exactly one handler"); + } + validate_handler(&handlers[0], "generated Codex hook config", event, 0, 0)?; + let handler = handlers[0].as_object().expect("validated handler object"); + let command = handler + .get("command") + .and_then(Value::as_str) + .with_context(|| { + format!( + "Generated Codex hook config '{event}' handler must have a string 'command'" + ) + })?; + if !command_is_current_sce_contract(command) { + bail!("Generated Codex hook config '{event}' handler does not use the current SCE command contract"); + } + + registrations.push(Registration { + event, + matcher, + group: Value::Object(group.clone()), + handler: Value::Object(handler.clone()), + }); + } + + Ok(registrations) +} + +fn validate_document(document: &Value, source_path: &str) -> Result<()> { + let typed: CodexHooksFile = serde_json::from_value(document.clone()).with_context(|| { + format!("Existing Codex hook config '{source_path}' has an invalid Codex structure") + })?; + + let _ = typed.description; + let event_groups = [ + ("PreToolUse", typed.hooks.pre_tool_use), + ("PermissionRequest", typed.hooks.permission_request), + ("PostToolUse", typed.hooks.post_tool_use), + ("PreCompact", typed.hooks.pre_compact), + ("PostCompact", typed.hooks.post_compact), + ("SessionStart", typed.hooks.session_start), + ("SessionEnd", typed.hooks.session_end), + ("UserPromptSubmit", typed.hooks.user_prompt_submit), + ("SubagentStart", typed.hooks.subagent_start), + ("SubagentStop", typed.hooks.subagent_stop), + ("Stop", typed.hooks.stop), + ]; + for (event, groups) in event_groups { + for (group_index, group) in groups.iter().enumerate() { + let _ = &group.matcher; + for (handler_index, handler) in group.hooks.iter().enumerate() { + validate_handler(handler, source_path, event, group_index, handler_index)?; + } + } + } + Ok(()) +} + +fn validate_handler( + handler: &Value, + source_path: &str, + event: &str, + group_index: usize, + handler_index: usize, +) -> Result<()> { + let handler = handler.as_object().with_context(|| { + format!( + "Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] must be a JSON object" + ) + })?; + let handler_type = handler + .get("type") + .and_then(Value::as_str) + .with_context(|| format!("Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] must have a string 'type'"))?; + + match handler_type { + "command" => { + if handler.contains_key("commandWindows") && handler.contains_key("command_windows") { + bail!("Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] cannot contain both 'commandWindows' and 'command_windows'"); + } + required_string(handler, "command", source_path, event, group_index, handler_index)?; + optional_string(handler, "commandWindows", source_path, event, group_index, handler_index)?; + optional_string(handler, "command_windows", source_path, event, group_index, handler_index)?; + optional_u64(handler, "timeout", source_path, event, group_index, handler_index)?; + if let Some(value) = handler.get("async") { + if !value.is_boolean() { + bail!("Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] field 'async' must be a boolean"); + } + } + optional_string(handler, "statusMessage", source_path, event, group_index, handler_index)?; + optional_usize( + handler, + "additionalContextLimit", + source_path, + event, + group_index, + handler_index, + )?; + } + "mcp_tool" => { + required_string(handler, "server", source_path, event, group_index, handler_index)?; + required_string(handler, "tool", source_path, event, group_index, handler_index)?; + if let Some(input) = handler.get("input") { + let input = input.as_object().with_context(|| { + format!("Codex hook config '{source_path}' MCP handler input must be a JSON object") + })?; + for (key, value) in input { + if !toml_compatible_json(value) { + bail!("Codex hook config '{source_path}' MCP handler input '{key}' is not representable as TOML"); + } + } + } + optional_u64(handler, "timeout", source_path, event, group_index, handler_index)?; + optional_string(handler, "statusMessage", source_path, event, group_index, handler_index)?; + } + "prompt" | "agent" => {} + other => bail!( + "Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] has unsupported type '{other}'" + ), + } + Ok(()) +} + +fn required_string( + object: &Map, + field: &str, + source_path: &str, + event: &str, + group_index: usize, + handler_index: usize, +) -> Result<()> { + if object.get(field).and_then(Value::as_str).is_none() { + bail!( + "Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] field '{field}' must be a string" + ); + } + Ok(()) +} + +fn optional_string( + object: &Map, + field: &str, + source_path: &str, + event: &str, + group_index: usize, + handler_index: usize, +) -> Result<()> { + if let Some(value) = object.get(field) { + if !value.is_null() && !value.is_string() { + bail!( + "Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] field '{field}' must be a string or null" + ); + } + } + Ok(()) +} + +fn optional_u64( + object: &Map, + field: &str, + source_path: &str, + event: &str, + group_index: usize, + handler_index: usize, +) -> Result<()> { + if let Some(value) = object.get(field) { + if !value.is_null() && value.as_u64().is_none() { + bail!( + "Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] field '{field}' must be a non-negative integer or null" + ); + } + } + Ok(()) +} + +fn optional_usize( + object: &Map, + field: &str, + source_path: &str, + event: &str, + group_index: usize, + handler_index: usize, +) -> Result<()> { + if let Some(value) = object.get(field) { + if !value.is_null() + && value + .as_u64() + .is_none_or(|number| usize::try_from(number).is_err()) + { + bail!( + "Codex hook config '{source_path}' handler hooks.{event}[{group_index}].hooks[{handler_index}] field '{field}' must be a platform-sized non-negative integer or null" + ); + } + } + Ok(()) +} + +fn toml_compatible_json(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(_) | Value::String(_) => true, + Value::Number(number) => { + number.as_i64().is_some() + || number + .as_u64() + .is_some_and(|number| i64::try_from(number).is_ok()) + || (number.as_i64().is_none() + && number.as_u64().is_none() + && number.as_f64().is_some()) + } + Value::Array(values) => { + let Some(first) = values.first() else { + return true; + }; + let first_kind = toml_json_kind(first); + values + .iter() + .all(|value| toml_json_kind(value) == first_kind && toml_compatible_json(value)) + } + Value::Object(object) => object.values().all(toml_compatible_json), + } +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum TomlJsonKind { + Bool, + String, + Number, + Array, + Object, +} + +fn toml_json_kind(value: &Value) -> Option { + match value { + Value::Null => None, + Value::Bool(_) => Some(TomlJsonKind::Bool), + Value::String(_) => Some(TomlJsonKind::String), + Value::Number(_) => Some(TomlJsonKind::Number), + Value::Array(_) => Some(TomlJsonKind::Array), + Value::Object(_) => Some(TomlJsonKind::Object), + } +} + +fn validate_matcher(group: &Map, event: &str, expected: Option<&str>) -> Result<()> { + let actual = group.get("matcher").and_then(Value::as_str); + if actual != expected { + if expected.is_some() { + bail!("Generated Codex hook config '{event}' group must have matcher '{expected:?}'"); + } + bail!("Generated Codex hook config '{event}' group must not have a non-null matcher"); + } + Ok(()) +} + +fn merge_document( + mut existing: Value, + registrations: &[Registration], + source_path: &str, +) -> Result { + let object = existing.as_object_mut().with_context(|| { + format!("Existing Codex hook config '{source_path}' must contain a top-level JSON object.") + })?; + let mut hooks = object + .remove(CODEX_HOOKS_ROOT) + .map_or_else(Map::new, |value| { + value.as_object().cloned().unwrap_or_default() + }); + + for registration in registrations { + let existing_groups = hooks + .remove(registration.event) + .map_or_else(Vec::new, |value| { + value.as_array().cloned().unwrap_or_default() + }); + hooks.insert( + registration.event.to_string(), + Value::Array(merge_event_groups( + existing_groups, + registration.matcher, + ®istration.handler, + ®istration.group, + )), + ); + } + + object.insert(CODEX_HOOKS_ROOT.to_string(), Value::Object(hooks)); + Ok(existing) +} + +fn merge_event_groups( + groups: Vec, + matcher: Option<&str>, + current_handler: &Value, + canonical_group: &Value, +) -> Vec { + let mut inserted = false; + let mut merged_groups = Vec::with_capacity(groups.len().saturating_add(1)); + + for group in groups { + let Some(group_object) = group.as_object() else { + continue; + }; + let matcher_matches = group_matches(group_object, matcher); + let handlers = group_object + .get("hooks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let has_owned = handlers.iter().any(handler_is_sce_owned); + if !has_owned && !matcher_matches { + merged_groups.push(group); + continue; + } + + let mut group = group; + let group_object = group.as_object_mut().expect("validated group object"); + let mut handlers = group_object + .remove("hooks") + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + let first_owned = handlers.iter().position(handler_is_sce_owned); + handlers.retain(|handler| !handler_is_sce_owned(handler)); + + if matcher_matches && !inserted { + let insert_at = first_owned.unwrap_or(handlers.len()).min(handlers.len()); + handlers.insert(insert_at, current_handler.clone()); + inserted = true; + } + group_object.insert("hooks".to_string(), Value::Array(handlers)); + merged_groups.push(group); + } + + if !inserted { + merged_groups.push(canonical_group.clone()); + } + merged_groups +} + +fn group_matches(group: &Map, matcher: Option<&str>) -> bool { + group.get("matcher").and_then(Value::as_str) == matcher +} + +fn handler_is_sce_owned(handler: &Value) -> bool { + handler + .as_object() + .and_then(|handler| handler.get("command")) + .and_then(Value::as_str) + .is_some_and(command_is_current_sce_contract) +} + +fn command_is_current_sce_contract(command: &str) -> bool { + command.split(';').any(|segment| { + let tokens: Vec<&str> = segment.split_whitespace().collect(); + let offset = usize::from(tokens.first() == Some(&"exec")); + tokens.len() == offset + 5 + && tokens.get(offset) == Some(&"bash") + && helper_path_token_is_valid(tokens[offset + 1]) + && tokens[offset + 2..] == CODEX_COMMAND_WORDS + }) +} + +fn helper_path_token_is_valid(token: &str) -> bool { + let token = token.trim_matches(['"', '\'']); + token == CODEX_HELPER_PATH || token == CODEX_ROOTED_HELPER_PATH +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn generated() -> Vec { + let mut generated = serde_json::to_string_pretty(&json!({ + "hooks": { + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}], + "Stop": [{"hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}], + "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}], + "PostToolUse": [{"matcher": "apply_patch", "hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}] + } + })) + .unwrap(); + generated.push('\n'); + generated.into_bytes() + } + + #[test] + fn accepts_upstream_defaulted_groups_and_events() { + let existing = json!({ + "description": "user hooks", + "hooks": { + "Stop": [{}], + "PreToolUse": [{"matcher": null, "hooks": []}], + "SessionStart": [{}] + } + }); + merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json", + ) + .expect("upstream-valid defaulted groups should merge"); + } + + #[test] + fn preserves_valid_user_handlers_and_optional_fields() { + let existing = json!({ + "description": "user hooks", + "hooks": { + "PostToolUse": [{"matcher": "Write", "hooks": [ + {"type": "command", "command": "python3 /tmp/pre.py", "commandWindows": "powershell -File C:\\\\pre.ps1", "timeout": 10, "async": true, "statusMessage": "checking", "additionalContextLimit": 4096}, + {"type": "mcp_tool", "server": "security", "tool": "scan", "input": {"file_path": "${tool_input.file_path}", "include_ignored": false}, "timeout": 30, "statusMessage": "Scanning"}, + {"type": "prompt"}, + {"type": "agent"} + ]}] + } + }); + let merged = merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json", + ) + .expect("valid handlers should survive"); + let value: Value = serde_json::from_slice(&merged).unwrap(); + assert_eq!(value["description"], "user hooks"); + assert_eq!(value["hooks"]["PostToolUse"].as_array().unwrap().len(), 2); + assert_eq!( + value["hooks"]["PostToolUse"][0]["hooks"] + .as_array() + .unwrap() + .len(), + 4 + ); + } + + #[test] + fn rejects_unknown_top_level_fields() { + let existing = json!({"custom": true}); + assert!(merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json" + ) + .is_err()); + } + + // Upstream `HookEventsToml`, `MatcherGroup`, and `HookHandlerConfig` variant + // structs do not use `#[serde(deny_unknown_fields)]` (only `HooksFile` does; + // see openai/codex codex-rs/config/src/hook_config.rs), so Codex silently + // ignores unrecognized nested keys instead of rejecting the file. SCE must + // accept and preserve them rather than fail the merge. + #[test] + fn preserves_unknown_nested_events_groups_and_handler_fields_as_codex_does() { + let existing = json!({ + "description": "user hooks", + "hooks": { + "CustomEvent": [{"hooks": []}], + "Stop": [ + { + "customGroupField": "keep", + "hooks": [ + { + "type": "command", + "command": "echo user", + "customHandlerField": "keep" + } + ] + } + ] + } + }); + let merged = merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json", + ) + .expect("Codex-compatible unknown nested fields must not be rejected"); + let value: Value = serde_json::from_slice(&merged).unwrap(); + + assert_eq!( + value["hooks"]["CustomEvent"], + json!([{"hooks": []}]), + "unknown top-level event name must be preserved" + ); + assert_eq!( + value["hooks"]["Stop"][0]["customGroupField"], "keep", + "unknown matcher group field must be preserved" + ); + let user_handler = value["hooks"]["Stop"][0]["hooks"] + .as_array() + .unwrap() + .iter() + .find(|handler| handler["command"] == "echo user") + .expect("user handler must survive merge"); + assert_eq!(user_handler["customHandlerField"], "keep"); + } + + #[test] + fn rejects_invalid_matcher_and_handler_shapes() { + for existing in [ + json!({"hooks": {"Stop": [{"matcher": 42} ]}}), + json!({"hooks": {"Stop": [{"hooks": [{"nonsense": true}]}]}}), + json!({"hooks": {"Stop": [{"hooks": [{"type": "unknown"}]}]}}), + json!({"hooks": {"Stop": [{"hooks": [{"type": "command"}]}]}}), + json!({"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "echo ok", "timeout": "fast"}]}]}}), + json!({"hooks": {"Stop": [{"hooks": [{"type": "mcp_tool", "server": "s", "tool": "t", "input": {"x": null}}]}]}}), + ] { + assert!(merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json" + ) + .is_err()); + } + } + + #[test] + fn preserves_defaulted_groups_without_rewriting_optional_fields() { + let existing = json!({"hooks": {"Stop": [{}]}}); + let merged = merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json", + ) + .unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + assert!(!value["hooks"]["Stop"][0] + .as_object() + .unwrap() + .contains_key("matcher")); + assert_eq!( + value["hooks"]["Stop"][0]["hooks"].as_array().unwrap().len(), + 1 + ); + } + + #[test] + fn preserves_unrelated_fields_groups_and_handlers() { + let existing = json!({ + "description": "user hooks", + "hooks": { + "UserPromptSubmit": [{"hooks": [ + {"type": "command", "command": "echo user"}, + {"type": "command", "command": "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"} + ]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "echo session"}]}] + } + }); + let merged = merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + ".codex/hooks.json", + ) + .unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + assert_eq!(value["description"], "user hooks"); + assert_eq!( + value["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "echo session" + ); + assert_eq!( + value["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "echo user" + ); + } + + #[test] + fn stale_and_duplicate_owned_handlers_become_one_current_handler() { + let existing = json!({ + "hooks": { + "Stop": [{"hooks": [ + {"type": "command", "command": "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"}, + {"type": "command", "command": "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"} + ]}] + } + }); + let merged = merge_or_create( + Some(&serde_json::to_vec(&existing).unwrap()), + &generated(), + "hooks.json", + ) + .unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + let handlers = value["hooks"]["Stop"][0]["hooks"].as_array().unwrap(); + assert_eq!(handlers.len(), 1); + assert!(handlers[0]["command"] + .as_str() + .unwrap() + .contains("$root/.codex/hooks")); + } + + #[test] + fn repeated_merge_is_idempotent() { + let first = merge_or_create(None, &generated(), "hooks.json").unwrap(); + let second = merge_or_create(Some(&first), &generated(), "hooks.json").unwrap(); + assert_eq!(first, second); + assert!(fragment_is_current(&first, &generated()).unwrap()); + } + + #[test] + fn ownership_requires_a_bounded_helper_invocation_shape() { + assert!(command_is_current_sce_contract( + "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex" + )); + assert!(command_is_current_sce_contract( + r#"root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0; exec bash "$root/.codex/hooks/run-sce-or-show-install-guidance.sh" sce hooks codex"# + )); + for command in [ + "echo .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex", + "printf '%s' '.codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex'", + "foo='.codex/hooks/run-sce-or-show-install-guidance.sh'; echo sce hooks codex", + "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex && echo user", + ] { + assert!( + !command_is_current_sce_contract(command), + "claimed ownership for {command}" + ); + } + } + + #[test] + fn malformed_existing_json_is_rejected_without_a_replacement() { + let existing = br#"{\"hooks\":{\"Stop\":\"not-an-array\"}}"#; + let error = merge_or_create(Some(existing), &generated(), ".codex/hooks.json").unwrap_err(); + assert!(error.to_string().contains(".codex/hooks.json")); + assert_eq!(existing, br#"{\"hooks\":{\"Stop\":\"not-an-array\"}}"#); + } +} diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 9fc4b7d8..8ec01a8a 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -5,6 +5,7 @@ use sha2::{Digest, Sha256}; use crate::services::agent_trace_db::lifecycle::diagnose_agent_trace_db_health; use crate::services::checkout; +use crate::services::codex_hook_config; use crate::services::config::schema::parse_file_config; use crate::services::config::{self, ConfigPathSource, IntegrationTargetId}; use crate::services::default_paths::{ @@ -1687,7 +1688,9 @@ fn collect_codex_integration_groups( let mut hook_children = Vec::new(); for asset in embedded_assets { - let child = build_integration_child_from_asset(&codex_root, asset, None); + let merge_target = + (asset.relative_path == ".codex/hooks.json").then_some(&MergeTargetAsset::CodexHooks); + let child = build_integration_child_from_asset(&codex_root, asset, merge_target); if child .relative_path @@ -1730,6 +1733,7 @@ const OPENCODE_CONFIG_RELATIVE_PATH: &str = "opencode.json"; enum MergeTargetAsset { ClaudeSettings, OpenCodeConfig, + CodexHooks, } fn build_integration_child_from_asset( @@ -1749,6 +1753,11 @@ fn build_integration_child_from_asset( asset.bytes, config_merge::opencode_config_fragment_is_current, ), + Some(MergeTargetAsset::CodexHooks) => inspect_merge_target_asset_state( + &path, + asset.bytes, + codex_hook_config::fragment_is_current, + ), None => inspect_integration_asset_state(&path, &asset.sha256), }; IntegrationChildHealth { diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index e84ebdba..c007e1ae 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -14,6 +14,7 @@ pub mod auth_db; pub mod bash_policy; pub mod capabilities; pub mod checkout; +pub(crate) mod codex_hook_config; pub mod command_registry; pub mod completion; pub mod config; diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 41ce064e..76e0d625 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -845,6 +845,7 @@ mod install { time::{SystemTime, UNIX_EPOCH}, }; + use crate::services::codex_hook_config; use crate::services::default_paths::InstallTargetPaths; use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; @@ -1376,6 +1377,12 @@ mod install { && relative_path == default_paths::repo_file::OPENCODE_MANIFEST } + /// True for Codex's user-owned hook registry, which is merged rather than + /// overwritten so setup preserves unrelated Codex handlers and settings. + fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::Codex && relative_path == ".codex/hooks.json" + } + fn install_single_asset_with_rename( target: SetupTarget, destination_root: &Path, @@ -1438,6 +1445,22 @@ mod install { asset.bytes, &destination.display().to_string(), )? + } else if is_codex_hooks_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + codex_hook_config::merge_or_create( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? } else { asset.bytes.to_vec() }; @@ -2164,6 +2187,83 @@ mod tests { let _ = fs::remove_dir_all(&repo); } + #[test] + fn install_merges_codex_hooks_and_replaces_stale_owned_handlers_idempotently() { + let repo = init_git_repo("install-merges-codex-hooks"); + let hooks_path = repo.join(".codex/hooks.json"); + fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); + let stale_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "description": "user hooks", + "hooks": { + "UserPromptSubmit": [{"hooks": [ + {"type": "command", "command": "echo user"}, + {"type": "command", "command": stale_command} + ]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "echo session"}]}] + } + }); + fs::write(&hooks_path, serde_json::to_vec(&existing).unwrap()).expect("seed hooks config"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("first Codex install should succeed"); + let first = fs::read(&hooks_path).expect("read merged hooks config"); + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("second Codex install should succeed"); + let second = fs::read(&hooks_path).expect("read merged hooks config again"); + assert_eq!(first, second); + + let merged: serde_json::Value = serde_json::from_slice(&second).unwrap(); + assert_eq!(merged["description"], "user hooks"); + assert_eq!( + merged["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "echo session" + ); + assert_eq!( + merged["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "echo user" + ); + assert_eq!(merged["hooks"].as_object().unwrap().len(), 5); + + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn invalid_codex_hooks_are_not_modified() { + let invalid_documents = [ + br#"{\"hooks\":{"#.to_vec(), + serde_json::to_vec(&json!({"custom": true})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"matcher": 42}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": "invalid"}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"nonsense": true}]}]}})) + .unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"type": "unknown"}]}]}})) + .unwrap(), + ]; + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + for (index, original) in invalid_documents.iter().enumerate() { + let repo = init_git_repo(&format!("install-rejects-malformed-codex-hooks-{index}")); + let hooks_path = repo.join(".codex/hooks.json"); + fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); + fs::write(&hooks_path, original).expect("seed malformed hooks config"); + + let error = install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect_err("malformed Codex hooks should fail setup"); + assert!(error.to_string().contains(".codex/hooks.json")); + assert_eq!(fs::read(&hooks_path).unwrap(), original.as_slice()); + + let _ = fs::remove_dir_all(&repo); + } + } + #[test] fn install_preserves_user_owned_files_and_writes_sce_assets() { let repo = init_git_repo("install-preserves-user-files"); diff --git a/context/architecture.md b/context/architecture.md index 47890155..7690cf53 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -125,10 +125,10 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering; structured-row reconstruction applies the persisted row `model_id` to every hunk and the persisted canonical `session_id` to every touched line before downstream combination and intersection. Active hook runtime, setup/lifecycle storage, and `sce sync` resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the former `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. -- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the two assets that are merge targets — the Claude target's `settings.json`, detected by `is_claude_settings_merge_target`, and the OpenCode target's `opencode.json`, detected by `is_opencode_config_merge_target` — the content staged is not the embedded asset's bytes but the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. +- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the three assets that are merge targets — the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's user-owned `.codex/hooks.json` — the content staged is not always the embedded asset's bytes. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`, while Codex uses the shared `cli/src/services/codex_hook_config.rs` service for structural validation and canonical-registration merging. For the Claude and OpenCode targets, the content staged is the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. Codex's merge validates the document shape, recognizes ownership only when both the generated helper path and the `sce hooks codex` command contract are present, and replaces stale or duplicate SCE handlers with exactly one current handler for each `UserPromptSubmit`, `Stop`, `PreToolUse/Bash`, and `PostToolUse/apply_patch` registration while preserving unrelated valid Codex fields, groups, and handlers. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. -- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. +- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. Its `.codex/hooks.json` child uses the shared Codex hook-config fragment comparison, so unrelated user handlers do not create a false whole-document mismatch. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution event-locally: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`; either source is normalized once with the `claude/` prefix and lookup failures remain nullable. `session-model` is no longer a supported hook route. diff --git a/context/context-map.md b/context/context-map.md index d295bd61..f0a7baca 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -99,6 +99,7 @@ Supporting repo docs: Recent decision records: - `context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md` (accepts upstream-compatible Codex apply_patch parent/absolute paths only when canonical resolution remains inside the Git worktree, validates nearest existing prefixes for missing targets, and rejects symlink escapes) +- `context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md` (uses one shared structural ownership predicate and merge service for setup/doctor: Codex SCE handlers require the generated helper path plus the `sce hooks codex` contract, while unrelated hook configuration survives) - `context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md` (uses bounded, deterministic `tool_use_id`-derived synthetic line identities for Codex apply_patch evidence; positions are evidence identities rather than source line numbers, with existing patch combination/intersection semantics unchanged) - `context/decisions/2026-08-23-codex-truthful-model-provenance.md` (preserves non-empty Codex model IDs unchanged, leaves blank/missing values nullable, and forbids inferred provider prefixes or a fabricated provider field) - `context/decisions/2026-08-23-codex-root-aware-hook-invocation.md` (requires generated Codex hook commands to resolve the Git root at invocation time, quote the helper path, preserve STDIN, and fail open when root resolution fails) diff --git a/context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md b/context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md new file mode 100644 index 00000000..9fdec3b6 --- /dev/null +++ b/context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md @@ -0,0 +1,86 @@ +# Decision: Use structural ownership for non-destructive Codex hook configuration merges + +Date: 2026-08-23 +Status: Accepted +Plan: `context/plans/codex-cli-integration.md` +Task: T21 + +## Context + +Codex's `.codex/hooks.json` is a user-owned configuration document. SCE must +install and refresh its four registrations without deleting unrelated Codex +event groups, handlers, or top-level properties. A broad command substring is +not a safe ownership boundary because user commands may mention `sce` without +belonging to SCE. Setup and doctor also need one shared definition of the +fragment that SCE owns. + +T21 implementation and verification established a pure JSON merge service in +`cli/src/services/codex_hook_config.rs`, used by setup and Codex integration +inspection. It validates the existing structure, replaces stale or duplicate +SCE handlers, preserves unrelated JSON structure, rejects malformed input before +staging, and passes the focused setup tests plus `nix flake check`. + +## Decision + +SCE-owned Codex hook handlers are identified structurally by requiring both the +installed `.codex/hooks/run-sce-or-show-install-guidance.sh` helper path and the +`sce hooks codex` command-word contract; setup and doctor use this predicate to +merge exactly one current handler for each canonical registration while +preserving all unrelated document content. + +## Rationale + +This gives setup a bounded, non-destructive ownership boundary that recognizes +stale SCE registrations without treating arbitrary user handlers as SCE-owned. +Using one pure service for setup and doctor prevents installation and diagnosis +from disagreeing about whether a Codex hook fragment is current. Computing the +merged document before the existing per-file atomic swap preserves the prior +file when parsing or structural validation fails. + +## Alternatives considered + +- **Overwrite the whole `.codex/hooks.json` document** — rejected because it + destroys user-owned Codex configuration. +- **Match any command containing `sce`** — rejected because it can claim + unrelated user handlers and does not establish a reliable ownership boundary. +- **Maintain separate setup and doctor predicates** — rejected because the two + surfaces could disagree about current SCE registrations and stale handlers. + +## Compatibility and risks + +- Existing valid documents are reformatted when merged, but their unrelated + fields, event groups, and handlers remain structurally unchanged; missing + documents retain the canonical generated bytes. +- Structurally invalid existing hook documents fail before the staging swap and + remain untouched. Future Codex schema changes require refreshing the shared + structural validation and canonical-registration tests. + +## Guardrails + +- Only the four generated registrations are replaced; unrelated event groups, + handlers, and top-level properties are preserved. +- Ownership requires the helper path and the exact `sce hooks codex` command + words; a generic `sce` substring is insufficient. +- Trust state and auto-trust behavior remain outside this merge service. + +## Consequences + +- `sce setup --codex` and `sce setup --all` can safely refresh SCE's Codex + registrations without whole-document replacement. +- Doctor can compare the SCE fragment independently of user-added Codex hooks. +- Codex hook configuration is a shared cross-service compatibility contract, + not setup-local JSON logic. + +## Follow-up + +- T22 reuses this service for structural, trust-aware doctor status and fix + behavior. + +## References + +- Plan: [`codex-cli-integration`](../plans/codex-cli-integration.md) +- Task: T21 +- Current-state context: [`Codex hook runtime`](../sce/codex-integration-runtime.md) +- Current-state context: [`Setup non-destructive install policy`](../sce/setup-no-backup-policy-seam.md) +- Evidence: [`shared Codex hook-config service`](../../cli/src/services/codex_hook_config.rs) +- Related decision: [`Codex root-aware hook invocation`](2026-08-23-codex-root-aware-hook-invocation.md) diff --git a/context/glossary.md b/context/glossary.md index c16317a6..6371dcac 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -164,9 +164,9 @@ - `sce policy command adapter`: Hidden/internal `sce policy bash` command in `cli/src/services/bash_policy.rs` that exposes the Rust bash-policy evaluator to hook callers. It reads JSON from STDIN, resolves bash-policy config from the project root (git root with current-directory fallback), evaluates the command against active policies, and emits hook-safe output: Claude Code deny JSON (`hookSpecificOutput` with `permissionDecision: "deny"`) or empty string for allowed commands in `--output claude-hook` mode (default), and structured `{"status","decision","command","normalized_argv","reason","policy_id"}` JSON in `--output json` mode. Input modes are `--input claude-pre-tool-use` (default, parses Claude `PreToolUse` event JSON with `tool_name`/`tool_input.command`) and `--input normalized` (parses `{"command":...}` for OpenCode delegation). The command uses explicit `--input`/`--output` flags rather than auto-detection; Claude Code hooks invoke `sce policy bash` with defaults, while OpenCode plugin delegation passes `--input normalized --output json`. Invalid invocation/input returns deterministic validation diagnostics without executing target commands. - `bash policy redundancy warning`: Non-fatal config validation output emitted when `forbid-git-all` and `forbid-git-commit` are enabled together; the config remains valid, but `sce config show|validate` reports the overlap deterministically as a warning instead of an error. - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_01KZE4DDA8HM1JHZGF2QCF49RP`. -- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes each embedded setup asset into its own staged file next to its final destination under repository-root `.opencode/`/`.claude/`/`.pi/` (or, for Codex, the repository root itself, since its asset paths already carry their own `.agents/`/`.codex/` prefix), then swaps it into place via the `setup atomic-swap` policy (see `setup atomic-swap`) — renaming the staging file directly over the destination without unlinking it first. It never removes an integration target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. After the per-asset install loop, it prunes stale SCE-owned paths (see `setup catalog-derived pruning`). Two assets, the Claude target's `settings.json` and the OpenCode target's `opencode.json`, stage merged content instead of the embedded asset's bytes verbatim (see `setup config-merge seam`). +- `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes each embedded setup asset into its own staged file next to its final destination under repository-root `.opencode/`/`.claude/`/`.pi/` (or, for Codex, the repository root itself, since its asset paths already carry their own `.agents/`/`.codex/` prefix), then swaps it into place via the `setup atomic-swap` policy (see `setup atomic-swap`) — renaming the staging file directly over the destination without unlinking it first. It never removes an integration target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. After the per-asset install loop, it prunes stale SCE-owned paths (see `setup catalog-derived pruning`). Three assets, the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's `.codex/hooks.json`, stage merged content instead of the embedded asset's bytes verbatim (see `setup config-merge seam`). - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. -- `setup config-merge seam`: Pure module `cli/src/services/setup/config_merge.rs`, covering `.claude/settings.json` and `.opencode/opencode.json`. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The same module also exposes `claude_settings_fragment_is_current` / `opencode_config_fragment_is_current`, which merge `existing_bytes` into `generated_bytes` and compare the merged `Value` against the existing one: a no-op merge means the existing file already carries a current copy of the SCE-owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses these two functions (instead of byte-exact `sha256`) to inspect `.claude/settings.json` and `.opencode/opencode.json`, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair a drifted merge target by reinstalling just that one asset through the same merge-install path. +- `setup config-merge seam`: Pure JSON merge services covering `.claude/settings.json`, `.opencode/opencode.json`, and Codex's `.codex/hooks.json`; the latter is owned by shared `cli/src/services/codex_hook_config.rs`, which validates structure and requires both the generated helper path and the `sce hooks codex` command contract before replacing stale or duplicate registrations. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` / `is_codex_hooks_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The shared Codex service also exposes `fragment_is_current`, which validates and compares the merged SCE fragment against the existing document; a no-op merge means the existing file already carries the current owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses the Claude/OpenCode fragment functions and the Codex function (instead of byte-exact `sha256`) to inspect merge targets, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair the two existing repairable merge targets by reinstalling just that one asset through the same merge-install path. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` outer-normalizes supported raw/heredoc input before parsing, resolves paths from event `cwd` against the real Git root into safe repository-relative paths, then parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence using event-scoped synthetic line identities derived from `tool_use_id` (see `context/sce/codex-integration-runtime.md`). Invalid cwd/path mappings or identity/range failures fail open before persistence. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. diff --git a/context/overview.md b/context/overview.md index f8c2fee2..0420e3f5 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. +This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap, while doctor evaluates the SCE fragment rather than requiring whole-document equality. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. diff --git a/context/patterns.md b/context/patterns.md index c545b1cc..529020ce 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -140,7 +140,7 @@ - For repository setup-asset build prep, declare canonical generator inputs in `config/pkl/generator-inputs.txt` and route input discovery, two-pass Pkl evaluation, determinism comparison, payload/input inventory creation, in-flight input checks, atomic publication, and private staging cleanup through `scripts/produce-cli-generated-input.sh`. The Cargo wrapper, generated-output check, package-fallback helper, and Nix `cliGeneratedInput` derivation must consume that producer rather than implement those mechanics independently. Keep each consumer's domain checks separate: the generated-output check owns metadata/contract/negative/path assertions; packaging owns static hook/schema/migration staging and the combined Pkl-plus-static checksum inventory; Nix owns declarative producer/input source selection and pre-Cargo handoff wiring. Route build, run, targeted-test, Clippy, and local-install Cargo workflows through `scripts/run-cli-cargo.sh`, which passes the producer handoff through `SCE_CLI_GENERATED_INPUT_DIR` and owns cleanup around Cargo. Keep `cli/build.rs` free of Pkl subprocesses and source-tree generated mirrors. - For CLI database migration prep, keep SQL files under immediate `cli/migrations//` directories named `NNN_description.sql`; `cli/build.rs` stages those files under `OUT_DIR/static/migrations`, sorts by the numeric prefix before `_`, and writes deterministic `OUT_DIR/generated_migrations.rs` constants with `include_str!` references for service `DbSpec` consumers. - For setup install execution, write each selected embedded asset into its own staging file next to its final destination, then swap the staged content into place by renaming it directly over the destination — never unlink the destination first, since `fs::rename` already replaces an existing file atomically; never remove or recreate the integration target directory as a whole. On swap failure, clean the failing asset's staging path and return deterministic recovery guidance naming that asset's destination (recover from version control); the pre-existing destination content, if any, is untouched. No backup artifacts are created. After the install loop, prune stale SCE-owned paths by diffing the full embedded catalog for the target against the assets actually installed, deleting each catalog path not installed, then removing any parent directory left empty by that deletion (a directory still holding a user file fails to remove and survives). -- For a config asset a user may already own and extend (`.claude/settings.json`, `.opencode/opencode.json`), do not write the embedded asset's bytes verbatim: compute the bytes to stage with a pure `serde_json`-based merge (`cli/src/services/setup/config_merge.rs`) that copies SCE-owned keys/entries from the generated document — identified by a fixed ownership marker, such as a hook command substring for Claude hooks or a plugin path prefix for OpenCode plugins — over the existing file, and preserves every other key and entry untouched. A parse failure on the existing file is a hard, deterministic error naming the file's path with no write; a missing file still gets the generated document verbatim. Keep this pure and filesystem-free per "Unit testing in Nix sandbox" below; the install seam reads the existing file and calls the merge before staging. +- For a config asset a user may already own and extend (`.claude/settings.json`, `.opencode/opencode.json`, or Codex's `.codex/hooks.json`), do not write the embedded asset's bytes verbatim: compute the bytes to stage with a pure `serde_json`-based merge. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`; Codex uses the shared `cli/src/services/codex_hook_config.rs` service, which validates the document shape, identifies SCE handlers only when the generated helper path and `sce hooks codex` command contract are both present, and replaces stale/duplicate required registrations while preserving unrelated valid Codex fields, groups, and handlers. A parse or structural-validation failure on the existing file is a hard, deterministic error naming the file's path with no write; a missing file still gets the generated document verbatim. Keep these merges pure and filesystem-free per "Unit testing in Nix sandbox" below; the install seam reads the existing file and calls the merge before staging. - For required-hook setup execution, resolve repository root and effective hooks directory from git (`rev-parse --show-toplevel`, `rev-parse --git-path hooks`), then compute the bytes to stage with a pure merge (`cli/src/services/setup/hook_merge.rs::merge_or_create_hook`) — mirroring the config-asset merge-target pattern above — rather than the canonical asset's bytes verbatim: a foreign hook's content is kept as an exact byte prefix with the SCE managed block appended after it, an SCE-owned hook has only its block replaced or left unchanged, and a legacy pre-marker hook upgrades wholesale. Apply deterministic per-hook outcomes (`Installed`, `Updated`, `Skipped`) against the merged bytes plus executable bit, with staged writes, executable-bit enforcement, and the same atomic-swap behavior as config-asset install: the staged file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact. Surface a deterministic advisory naming the hook when the appended block would be unreachable (a foreign hook whose last effective line is a zero-indent `exec`/`exit`). - For hook setup CLI UX, allow `--hooks` as both hooks-only and composable target+hooks execution (optional `--repo `), enforce deterministic option compatibility (`--repo` requires `--hooks`; target flags stay mutually exclusive), and emit stable section-ordered setup/hook status lines for automation-friendly logs. - For setup command messaging, emit deterministic completion output that includes selected target(s) and per-target install counts. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index eb58cbdb..e277d83a 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -66,8 +66,8 @@ This revision extends the completed Codex rollout with six correctness hardening - [ ] AC26: Codex apply_patch path resolution accepts valid `..` components and absolute paths when current Codex semantics accept them and the logical target remains inside the canonical Git worktree; accepts missing Add File targets and paths containing spaces; resolves nested cwd, Update source, and Move destination independently; rejects outside escapes, malformed/NUL paths, outside/empty cwd, and existing or missing targets that escape through symlinks. It emits only repository-relative UTF-8 slash paths. - Validate: `hooks::codex::apply_patch::path` tests cover the complete path matrix, including valid parent traversal, absolute-inside paths, Add File missing targets, move paths, outside paths, and both symlink escape forms. -- [ ] AC27: `sce setup --codex` merges `.codex/hooks.json` through shared Codex ownership logic, preserving unrelated top-level properties, event groups, matcher groups, and user handlers; it replaces stale/duplicate SCE-owned handlers with exactly one current handler per required registration, adds missing registrations, is semantically idempotent, and leaves malformed/structurally invalid existing JSON byte-for-byte untouched while naming the file in the error. - - Validate: shared Codex hook-config unit tests and setup tests cover absent files, every required event/matcher, unrelated handlers/groups/properties, stale and duplicate SCE handlers, repeated merge, and malformed JSON/no-write behavior. +- [ ] AC27: `sce setup --codex` merges `.codex/hooks.json` through shared Codex ownership logic, preserving unrelated valid Codex fields, supported event groups, matcher groups, and handlers; it rejects top-level fields, event names, groups, and handlers that current Codex rejects; it replaces stale/duplicate SCE-owned handlers with exactly one current handler per required registration, adds missing registrations, is semantically idempotent, and leaves malformed/structurally invalid existing JSON byte-for-byte untouched while naming the file in the error. + - Validate: shared Codex hook-config unit tests and setup tests cover upstream-defaulted groups, the strict top-level/event/handler schema, valid command/MCP/prompt/agent handlers, valid user-content preservation, stale and duplicate SCE handlers, repeated merge, ownership negatives, and malformed JSON/no-write behavior. - [ ] AC28: `sce doctor` reports Codex-owned registrations structurally as `PresentAndCurrent`, `Missing`, `Stale`, or `Malformed`, ignores user-owned additions, and separately reports executable trust readiness. It does not claim health when the effective Codex state is disabled, untrusted, modified, or unknown; it reports healthy only for current SCE registrations that Codex will actually execute, and `--fix` repairs only the SCE-owned fragment without changing user hooks or trust state. - Validate: doctor/shared-service tests cover current plus user hooks, missing/stale/malformed fragments, trusted/untrusted/modified/disabled/unknown state, current upstream key/hash/config-layer semantics, and trust-preserving fix behavior. - [ ] AC29: No generated Codex `.agents/skills/**/*.md` file, including `SKILL.md` and `references/*.md`, contains literal `$ARGUMENTS`; canonical workflow content uses explicit skill-invocation input semantics while command-capable Claude/OpenCode/Pi entrypoints retain their existing argument-substitution behavior. @@ -392,13 +392,18 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — Codex apply_patch path resolution now accepts canonical safe parent/absolute paths and protects canonical worktree boundaries, refining the durable Codex runtime and directly relevant architecture/hook-routing context contracts. - Context synchronization: synced -- [ ] T21: `Add shared non-destructive Codex hook ownership and setup merge` (status:todo) +- [x] T21: `Add shared non-destructive Codex hook ownership and setup merge` (status:done) - Task ID: T21 - Scope: In — add a focused shared Codex hook-config module used by setup and doctor for parsing, structural validation, SCE ownership predicates, canonical required registrations, and order-preserving JSON merge; make `sce setup --codex` and `--all` merge `.codex/hooks.json` rather than overwrite it, including malformed/structural error handling and idempotent stale/duplicate replacement. Out — trust-state writes, auto-trust behavior, unrelated target merge logic, and whole-document replacement. - Dependencies: T20 - - Done when: exactly one current SCE-owned handler exists for UserPromptSubmit, Stop, PreToolUse/Bash, and PostToolUse/apply_patch while unrelated fields/groups/handlers survive byte-for-byte or structurally unchanged where serialization requires; malformed existing files are not modified; repeated setup is idempotent; the shared ownership predicate requires the generated helper path and `sce hooks codex` command contract rather than a substring such as `sce`. + - Done when: exactly one current SCE-owned handler exists for UserPromptSubmit, Stop, PreToolUse/Bash, and PostToolUse/apply_patch while unrelated valid Codex fields/groups/handlers survive byte-for-byte or structurally unchanged where serialization requires; Codex-invalid existing files are rejected and not modified; repeated setup is idempotent; the shared ownership predicate requires a bounded generated-helper invocation followed by `sce hooks codex`, not independent substrings such as `sce`. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::setup'` - - Context synchronization: pending + - Completed: 2026-08-23 + - Files changed: `cli/src/services/codex_hook_config.rs` (new), `cli/src/services/doctor/inspect.rs`, `cli/src/services/mod.rs`, `cli/src/services/setup/mod.rs` + - Result: Inspected current upstream `openai/codex` commit `a73485dc76e5b2d31d28109a57f6876f4e1dcc24` and aligned the shared service with its strict `HooksFile` top-level fields, eleven `HookEventsToml` event names, defaulted `MatcherGroup`, and command/MCP/prompt/agent `HookHandlerConfig` shapes and field aliases. The service preserves valid user configuration, rejects Codex-invalid structures before writing, recognizes only bounded SCE helper invocations, and merges exactly one current handler for each required registration. Codex setup stages merged `.codex/hooks.json` content for both `--codex` and `--all`, remains idempotent, and doctor continues evaluating the merged SCE fragment. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml codex_hook_config'` — passed: 10 focused tests covering upstream-defaulted groups, strict schema rejection, valid handler preservation, bounded ownership, malformed input, and idempotence. `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::setup'` — passed: 61 tests, including seven on-disk invalid-config no-write cases. `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor'` — passed: 12 tests; invalid Codex config remains unhealthy through fragment inspection. `nix flake check` — passed: all checks, including CLI tests, Clippy, and formatting. + - Context impact: root — Codex hook configuration is now a shared setup/doctor contract: setup merges user-owned `.codex/hooks.json` non-destructively, ownership is structural, and doctor compares the SCE fragment rather than the whole document. The durable Codex runtime, setup/doctor context, and directly relevant architecture context must be synchronized. + - Context synchronization: synced - [ ] T22: `Make Codex doctor structural and trust-aware` (status:todo) - Task ID: T22 diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 0ecfcb70..927f23e7 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -114,7 +114,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 27ca08b1..d429caa5 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -20,6 +20,26 @@ existing missing-`sce` stderr guidance and forwards the hook JSON STDIN unchanged. The exact four-registration and invocation contract is covered by the generated contract and `codex-hook-command` flake check. See [the ADR](../decisions/2026-08-23-codex-root-aware-hook-invocation.md). +## Non-destructive hook configuration ownership + +`.codex/hooks.json` is a user-owned document. `sce setup --codex` and +`--all` merge the generated SCE fragment instead of replacing the whole file. +The shared `cli/src/services/codex_hook_config.rs` service mirrors current +Codex deserialization: top-level `description`/`hooks` only, the eleven +supported event names, defaulted matcher groups, and `command`, `mcp_tool`, +`prompt`, or `agent` handlers with their typed fields. It preserves unrelated +valid Codex fields, event groups, matcher groups, and handlers, and replaces stale or duplicate SCE-owned handlers with +one current handler for each of the four required registrations. Ownership +requires both `.codex/hooks/run-sce-or-show-install-guidance.sh` and the +`sce hooks codex` command contract; a generic `sce` substring is not enough. +Malformed or structurally invalid existing documents fail before staging, so +the existing file remains untouched. Doctor uses the same fragment comparison, +so user-added valid Codex handlers do not appear as SCE drift; invalid Codex +configuration remains unhealthy. Trust state and +auto-trust behavior are separate concerns owned by later doctor work. See +[the ADR](../decisions/2026-08-23-codex-nondestructive-hook-ownership.md) and +[the setup install policy](setup-no-backup-policy-seam.md). + ## Dispatch skeleton - STDIN carries one raw Codex hook-event JSON payload, deserialized into a diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index bda77e0c..06424ab1 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -89,8 +89,10 @@ own the hierarchy. Areas render in deterministic order: - Codex: `Skills`, `Hooks` Codex's `Hooks` area covers `.codex/hooks.json` and -`.codex/hooks/run-sce-or-show-install-guidance.sh`. A missing or mismatched -Codex `Hooks` asset also carries a reminder that Codex requires reviewing and +`.codex/hooks/run-sce-or-show-install-guidance.sh`. Doctor evaluates the +SCE-owned `.codex/hooks.json` fragment through the shared structural merge +service, so unrelated user handlers do not make a valid file mismatched. A +missing or mismatched Codex `Hooks` asset also carries a reminder that Codex requires reviewing and trusting this project's hooks inside the Codex CLI before they take effect; doctor diagnoses and can reinstall the on-disk file but cannot grant that trust. diff --git a/context/sce/setup-no-backup-policy-seam.md b/context/sce/setup-no-backup-policy-seam.md index 301432da..0e796d9c 100644 --- a/context/sce/setup-no-backup-policy-seam.md +++ b/context/sce/setup-no-backup-policy-seam.md @@ -14,15 +14,16 @@ - After the per-asset install loop, config install prunes stale SCE-owned paths: `prune_stale_assets_for_concrete_target` diffs the full embedded-asset catalog for the concrete target against the assets this run actually installed, and deletes every catalog path present in the former but not the latter (deselected optional-workflow files, or an asset a newer catalog renamed or dropped). Each successful deletion is followed by `remove_empty_ancestor_directories`, which removes now-empty parent directories upward until it reaches the target root or hits a directory that still holds something (a directory holding a user file fails to remove and is left in place, so a user file nested inside an SCE-owned skill directory survives even though the SCE file next to it is pruned). Pruning is stateless and catalog-derived — no install manifest is persisted — so it only ever considers paths the compiled-in catalog still names. - No `.backup` artifacts are created during any setup write flow, and no backup-based rollback is attempted on swap failure. - Recovery guidance is generic (not git-specific wording): "Setup ... does not create backups. Recover '' from version control if needed." -- Two config assets are merge targets instead of verbatim-content assets: `.claude/settings.json` for the Claude target, and `.opencode/opencode.json` for the OpenCode target. `install_single_asset_with_rename` detects each (`is_claude_settings_merge_target`, `is_opencode_config_merge_target`) and, before staging, computes the bytes to stage from `cli/src/services/setup/config_merge.rs` rather than writing the embedded asset's bytes directly. Both merge functions return the generated document verbatim when no existing file is present; otherwise each parses the existing file as JSON (a parse failure is a hard error naming the file's path, and nothing is written) and merges the generated document into it, preserving every other top-level key untouched: +- Three config assets are merge targets instead of verbatim-content assets: `.claude/settings.json` for the Claude target, `.opencode/opencode.json` for the OpenCode target, and `.codex/hooks.json` for Codex. `install_single_asset_with_rename` detects each target and, before staging, computes the bytes to stage from the appropriate pure merge service rather than writing the embedded asset's bytes directly. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`; Codex uses the shared `cli/src/services/codex_hook_config.rs` service. The merge functions return the generated document verbatim when no existing file is present; otherwise they parse and structurally validate the existing file (a failure is a hard error naming the file's path, and nothing is written) and merge only the canonical SCE fragment while preserving unrelated content: - `merge_or_create_claude_settings`: `$schema` and, event-by-event, every hook entry whose command contains the marker `run-sce-or-show-install-guidance.sh` are SCE-owned and replaced from the generated document; every hook entry or event key the generated document does not declare is preserved untouched. - `merge_or_create_opencode_config`: `$schema` is SCE-owned and replaced from the generated document; the `plugin` array is merged as a set — any existing entry whose path starts with `./plugins/sce-` is dropped (structural ownership, so a plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. + - Codex hook merge: the shared service validates the hook registry structure, recognizes ownership only when a handler command contains both `.codex/hooks/run-sce-or-show-install-guidance.sh` and the `sce hooks codex` command words, and replaces stale or duplicate owned handlers with exactly one current handler for each required registration. Valid user-owned fields, event groups, matcher groups, and handlers remain structurally unchanged; malformed or Codex-invalid documents remain untouched. Doctor uses the same fragment comparison so user additions do not appear as Codex drift. The merged bytes then flow through the same stage/atomic-swap choreography as every other asset, so this is a content-computation seam layered on the shared install policy, not a different write path. -- `sce doctor --fix` reuses this same per-asset install path for the two merge targets rather than running its own repair logic: `crate::services::setup::repair_merge_target_asset` looks up the one embedded asset by relative path and reinstalls only it through `install_single_asset_with_rename`, so a drifted `.claude/settings.json` or `.opencode/opencode.json` is repaired by merge — every other installed asset and every user key is left untouched. `sce doctor` (diagnose or fix) tells a merge target's drift apart from a legitimately extended file by SCE-fragment equality (`config_merge::claude_settings_fragment_is_current`, `config_merge::opencode_config_fragment_is_current`) instead of the byte-exact `sha256` check every other integration asset uses (see [doctor human text contract](doctor-human-text-contract.md)). +- `sce doctor --fix` reuses this same per-asset install path for the two existing repairable merge targets rather than running its own repair logic: `crate::services::setup::repair_merge_target_asset` looks up the one embedded asset by relative path and reinstalls only it through `install_single_asset_with_rename`, so a drifted `.claude/settings.json` or `.opencode/opencode.json` is repaired by merge — every other installed asset and every user key is left untouched. Codex diagnosis already uses `codex_hook_config::fragment_is_current`; Codex trust-aware repair remains a later doctor task. `sce doctor` tells a merge target's drift apart from a legitimately extended file by SCE-fragment equality instead of the byte-exact `sha256` check every other integration asset uses (see [doctor human text contract](doctor-human-text-contract.md)). ## Scope boundary -- This file captures the non-destructive, per-file install policy shared by config-install and required-hook install flows, including the merge-target content-computation seam for `.claude/settings.json`. +- This file captures the non-destructive, per-file install policy shared by config-install and required-hook install flows, including the merge-target content-computation seam for Claude, OpenCode, and Codex hook configuration. - Future setup-managed write flows should follow the same per-file stage/atomic-swap pattern instead of introducing backup creation or whole-directory replacement. A future merge target computes its staged content the same way `.claude/settings.json` does, ahead of the shared stage/swap step. See also: [../overview.md](../overview.md), [../context-map.md](../context-map.md), [setup-githooks-install-flow.md](setup-githooks-install-flow.md) From fa80af710394f6e6a016bcdbb153667081b19f50 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 09:30:58 +0200 Subject: [PATCH 14/20] doctor: Add structural and trust-aware Codex hook diagnosis Reuse the shared Codex hook configuration service to classify each required registration independently, preserving unrelated user handlers and repairing only structural drift through the existing merge path. Add a read-only mirror of Codex's persisted hook-trust bookkeeping so doctor reports untrusted, modified, disabled, and unknown registrations without attempting to grant consent. Wire the new states through doctor health/problem rendering and document the per-registration contract, including the TOML dependency required to read Codex's config. Fix three correctness issues found in review (PR #229): - Structural diagnosis scanned only the first matcher group matching a registration's event, so an SCE-owned handler duplicated or misplaced in a second matcher group could be invisible to diagnosis while setup's merge (which scrubs owned handlers across every group for the event) would still rewrite the document, breaking the PresentAndCurrent-implies-no-op-merge invariant. Diagnosis now scans every matcher group for the event and requires exactly one owned handler, in the canonical group, matching the canonical handler, to report PresentAndCurrent. - Trust-state reading independently parsed `enabled`/`trusted_hash`, so a malformed `enabled` field next to a correct `trusted_hash` could still read Trusted. Upstream deserializes the whole state entry and discards it entirely on any error; the state entry type now derives Deserialize directly and is read the same way, so a malformed field drops the whole entry rather than being salvaged field-by-field. - The merge itself still always relocated the canonical handler into the first matcher-matching group, so a document already diagnosed PresentAndCurrent with its canonical handler in a non-first matching group would still be rewritten by merge_or_create, a second violation of the same no-op invariant. merge_event_groups now scans every group the same way diagnosis does and returns the document completely untouched whenever it is already canonical wherever that handler lives, repairing only when it genuinely isn't. Plan: `context/plans/codex-cli-integration.md` (T22) Co-authored-by: SCE --- cli/Cargo.lock | 60 +- cli/Cargo.toml | 1 + cli/src/services/codex_hook_config.rs | 747 +++++++++++++++++++-- cli/src/services/codex_hook_trust.rs | 622 +++++++++++++++++ cli/src/services/doctor/inspect.rs | 519 +++++++++++++- cli/src/services/doctor/mod.rs | 12 + cli/src/services/doctor/render.rs | 17 + cli/src/services/doctor/types.rs | 46 ++ cli/src/services/lifecycle.rs | 2 + cli/src/services/mod.rs | 1 + context/architecture.md | 4 +- context/glossary.md | 2 +- context/overview.md | 2 +- context/plans/codex-cli-integration.md | 18 +- context/sce/agent-trace-hook-doctor.md | 2 +- context/sce/codex-integration-runtime.md | 17 +- context/sce/doctor-human-text-contract.md | 35 +- context/sce/setup-no-backup-policy-seam.md | 2 +- 18 files changed, 1994 insertions(+), 115 deletions(-) create mode 100644 cli/src/services/codex_hook_trust.rs diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 698d1426..293d3978 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3389,6 +3389,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3461,6 +3470,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tokio", + "toml", "tracing", "turso", "uuid", @@ -3986,6 +3996,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4002,9 +4036,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4013,9 +4047,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -4926,6 +4966,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.3" @@ -5015,7 +5061,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -5054,7 +5100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow", + "winnow 1.0.3", "zvariant", ] @@ -5183,7 +5229,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] @@ -5211,5 +5257,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d80a3dd5..990f1179 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -44,6 +44,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" tokio = { version = "1", default-features = false, features = ["rt", "io-util", "sync", "time"] } +toml = "0.9" tracing = "0.1" uuid = { version = "1", features = ["v4", "v7"] } diff --git a/cli/src/services/codex_hook_config.rs b/cli/src/services/codex_hook_config.rs index 9f03a97c..9e5df7bf 100644 --- a/cli/src/services/codex_hook_config.rs +++ b/cli/src/services/codex_hook_config.rs @@ -20,6 +20,21 @@ const REQUIRED_EVENTS: [(&str, Option<&str>); 4] = [ ("PostToolUse", Some("apply_patch")), ]; +/// The persisted hook-state key label for one of SCE's four required Codex +/// event names, matching upstream `hooks::hook_event_key_label` +/// (`openai/codex` commit `8e649e3afa5cdddfb09a1b85a090b94775045d9b`, +/// `hooks/src/lib.rs`). Only covers the events SCE registers; any other input +/// is a programming error. +pub(crate) fn hook_event_key_label(event: &str) -> &'static str { + match event { + "UserPromptSubmit" => "user_prompt_submit", + "Stop" => "stop", + "PreToolUse" => "pre_tool_use", + "PostToolUse" => "post_tool_use", + other => unreachable!("unexpected Codex hook event name '{other}'"), + } +} + /// Merge the canonical generated Codex hooks into an existing file. /// /// A missing file is installed verbatim. An existing file is parsed and @@ -51,22 +66,6 @@ pub(crate) fn merge_or_create( Ok(serialized.into_bytes()) } -/// Returns whether the existing file already contains exactly the current SCE -/// fragment. Unrelated valid Codex configuration is intentionally ignored. -pub(crate) fn fragment_is_current(existing_bytes: &[u8], generated_bytes: &[u8]) -> Result { - let existing: Value = serde_json::from_slice(existing_bytes) - .context("Existing Codex hook config must contain valid JSON")?; - validate_document(&existing, "existing Codex hook config")?; - let generated: Value = serde_json::from_slice(generated_bytes) - .context("Generated Codex hook config must contain valid JSON")?; - let registrations = validate_generated_document_value(&generated)?; - Ok(merge_document( - existing.clone(), - ®istrations, - "existing Codex hook config", - )? == existing) -} - #[derive(Clone)] struct Registration { event: &'static str, @@ -75,6 +74,186 @@ struct Registration { handler: Value, } +/// The structural state of one required Codex hook registration, independent +/// of Codex's own separate hook-trust bookkeeping (see `codex_hook_trust`). +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RegistrationStructuralState { + /// Exactly one SCE-owned handler exists anywhere for this event, it sits + /// in the registration's canonical matcher group, and it matches the + /// canonical generated handler byte-for-byte. + PresentAndCurrent, + /// No SCE-owned handler exists in any matcher group for this event. + Missing, + /// An SCE-owned handler exists somewhere for this event, but the + /// registration is not `PresentAndCurrent`: more than one owned handler + /// (whether duplicated within one group or spread across groups), one + /// sitting in the wrong matcher group, or one whose content does not + /// match the canonical generated handler. + Stale, +} + +/// One required Codex hook registration's structural diagnosis, carrying the +/// existing owned handler JSON (when present) so callers can compute Codex's +/// own trust hash for it without re-parsing the document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RegistrationDiagnosis { + pub(crate) event: &'static str, + pub(crate) matcher: Option<&'static str>, + pub(crate) state: RegistrationStructuralState, + pub(crate) owned_handler: Option, + /// Position of the matching matcher group among `hooks.`, and of + /// the owned handler within that group's `hooks` array, exactly as + /// upstream's `hook_key` enumerates them. `None` when no owned handler + /// was found (state is `Missing`), since there is nothing to key. + pub(crate) position: Option<(usize, usize)>, +} + +/// Whole-document diagnosis backing `sce doctor`'s Codex hook-registration +/// reporting. `Malformed` covers both unparsable JSON and JSON that fails +/// Codex's own structural schema; either way no per-registration state can be +/// determined and the document cannot be safely merged. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum HooksDocumentDiagnosis { + Absent, + Malformed(String), + Registrations(Vec), +} + +/// Diagnose each required registration's structural state without writing +/// anything. Mirrors `merge_or_create`'s validation rules exactly so a +/// `PresentAndCurrent` result here always implies a no-op merge. +pub(crate) fn diagnose_document( + existing_bytes: Option<&[u8]>, + generated_bytes: &[u8], +) -> Result { + let Some(existing_bytes) = existing_bytes else { + return Ok(HooksDocumentDiagnosis::Absent); + }; + + let existing: Value = match serde_json::from_slice(existing_bytes) { + Ok(value) => value, + Err(error) => { + return Ok(HooksDocumentDiagnosis::Malformed(format!( + "Existing Codex hook config must contain valid JSON: {error}" + ))) + } + }; + if let Err(error) = validate_document(&existing, "existing Codex hook config") { + return Ok(HooksDocumentDiagnosis::Malformed(error.to_string())); + } + + let generated: Value = serde_json::from_slice(generated_bytes) + .context("Generated Codex hook config must contain valid JSON")?; + let registrations = validate_generated_document_value(&generated)?; + + let hooks = existing + .get(CODEX_HOOKS_ROOT) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + let diagnoses = registrations + .iter() + .map(|registration| diagnose_registration(&hooks, registration)) + .collect(); + + Ok(HooksDocumentDiagnosis::Registrations(diagnoses)) +} + +/// One SCE-owned handler found while scanning every matcher group for an +/// event, tagged with where it sits and whether that group is the +/// registration's canonical matcher group. +struct OwnedHandlerSighting { + group_index: usize, + handler_index: usize, + handler: Value, + in_canonical_group: bool, +} + +/// Diagnose one required registration by scanning **every** matcher group +/// under `hooks.`, not just the first one whose matcher matches. +/// Setup's merge (`merge_event_groups`) strips SCE-owned handlers from every +/// group for the event, so a duplicate or misplaced SCE handler sitting in a +/// second group is exactly as stale as one in the first; scoping discovery +/// to only the first matching group would let such a document read +/// `PresentAndCurrent` even though `merge_or_create` would still rewrite it. +fn diagnose_registration( + hooks: &Map, + registration: &Registration, +) -> RegistrationDiagnosis { + let missing = || RegistrationDiagnosis { + event: registration.event, + matcher: registration.matcher, + state: RegistrationStructuralState::Missing, + owned_handler: None, + position: None, + }; + + let groups = hooks + .get(registration.event) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut sightings = Vec::new(); + for (group_index, group) in groups.iter().enumerate() { + let Some(group_object) = group.as_object() else { + continue; + }; + let in_canonical_group = group_matches(group_object, registration.matcher); + let Some(handlers) = group_object.get("hooks").and_then(Value::as_array) else { + continue; + }; + for (handler_index, handler) in handlers.iter().enumerate() { + if !handler_is_sce_owned(handler) { + continue; + } + sightings.push(OwnedHandlerSighting { + group_index, + handler_index, + handler: handler.clone(), + in_canonical_group, + }); + } + } + + let Some((only, [])) = sightings.split_first() else { + return match sightings.first() { + None => missing(), + // More than one SCE-owned handler anywhere for this event: + // always stale, whatever their placement. Surface the first as + // diagnostic context; it is not necessarily "the" canonical one. + Some(first) => RegistrationDiagnosis { + event: registration.event, + matcher: registration.matcher, + state: RegistrationStructuralState::Stale, + owned_handler: Some(first.handler.clone()), + position: Some((first.group_index, first.handler_index)), + }, + }; + }; + + if only.in_canonical_group && only.handler == registration.handler { + RegistrationDiagnosis { + event: registration.event, + matcher: registration.matcher, + state: RegistrationStructuralState::PresentAndCurrent, + owned_handler: Some(only.handler.clone()), + position: Some((only.group_index, only.handler_index)), + } + } else { + // Exactly one owned handler, but either in the wrong matcher group + // or not byte-identical to the canonical generated handler. + RegistrationDiagnosis { + event: registration.event, + matcher: registration.matcher, + state: RegistrationStructuralState::Stale, + owned_handler: Some(only.handler.clone()), + position: Some((only.group_index, only.handler_index)), + } + } +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct CodexHooksFile { @@ -451,52 +630,119 @@ fn merge_document( Ok(existing) } +/// Merge one event's matcher groups so the result matches exactly what +/// `diagnose_registration` calls `PresentAndCurrent`: if the existing +/// document already has exactly one SCE-owned handler, it sits in a matcher +/// group that satisfies `matcher`, and it is byte-identical to +/// `current_handler`, the groups are returned completely untouched — +/// wherever that handler already lives, including a non-first matching +/// group. Relocating an already-canonical handler merely because an earlier +/// matcher group happens to exist would make `merge_or_create` rewrite a +/// document `diagnose_document` calls current, breaking the +/// `PresentAndCurrent` ⇒ no-op invariant those two functions must share. +/// +/// Otherwise every SCE-owned handler across every group is removed and +/// exactly one canonical handler is (re)inserted at a deterministic +/// position: preferring the first matcher-matching group that already held +/// an owned handler (replacing it in place), then the first +/// matcher-matching group at all (appending to it), then a freshly appended +/// `canonical_group` when no matcher-matching group exists. No group is +/// ever deleted, and non-owned handlers/groups are never touched. fn merge_event_groups( groups: Vec, matcher: Option<&str>, current_handler: &Value, canonical_group: &Value, ) -> Vec { - let mut inserted = false; - let mut merged_groups = Vec::with_capacity(groups.len().saturating_add(1)); + let mut owned_sightings: Vec<(usize, usize)> = Vec::new(); + let mut canonical_group_sightings: Vec<(usize, usize)> = Vec::new(); + let mut first_matching_group_index: Option = None; - for group in groups { + for (group_index, group) in groups.iter().enumerate() { let Some(group_object) = group.as_object() else { continue; }; - let matcher_matches = group_matches(group_object, matcher); - let handlers = group_object - .get("hooks") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let has_owned = handlers.iter().any(handler_is_sce_owned); - if !has_owned && !matcher_matches { - merged_groups.push(group); + let is_canonical_group = group_matches(group_object, matcher); + if is_canonical_group && first_matching_group_index.is_none() { + first_matching_group_index = Some(group_index); + } + let Some(handlers) = group_object.get("hooks").and_then(Value::as_array) else { continue; + }; + for (handler_index, handler) in handlers.iter().enumerate() { + if !handler_is_sce_owned(handler) { + continue; + } + owned_sightings.push((group_index, handler_index)); + if is_canonical_group { + canonical_group_sightings.push((group_index, handler_index)); + } + } + } + + if let [(group_index, handler_index)] = owned_sightings.as_slice() { + let (group_index, handler_index) = (*group_index, *handler_index); + if canonical_group_sightings.len() == 1 { + let existing_handler = groups + .get(group_index) + .and_then(|group| group.get("hooks")) + .and_then(Value::as_array) + .and_then(|handlers| handlers.get(handler_index)); + if existing_handler == Some(current_handler) { + return groups; + } } + } + + // Repair. Prefer the (first, by document order) group that already held + // a canonical-matcher owned handler, so collapsing duplicates keeps the + // earliest one in place; otherwise the first group whose matcher + // already matches, even if it never held an owned handler; otherwise + // fall back to appending a fresh canonical group below. + let target_group_index = canonical_group_sightings + .first() + .map(|(group_index, _)| *group_index) + .or(first_matching_group_index); + + let mut merged_groups = groups; + let mut insert_at_in_target: Option = None; - let mut group = group; - let group_object = group.as_object_mut().expect("validated group object"); - let mut handlers = group_object - .remove("hooks") - .and_then(|value| value.as_array().cloned()) - .unwrap_or_default(); - let first_owned = handlers.iter().position(handler_is_sce_owned); + for (group_index, group) in merged_groups.iter_mut().enumerate() { + let Some(group_object) = group.as_object_mut() else { + continue; + }; + let Some(handlers) = group_object.get_mut("hooks").and_then(Value::as_array_mut) else { + continue; + }; + if target_group_index == Some(group_index) { + insert_at_in_target = handlers.iter().position(handler_is_sce_owned); + } handlers.retain(|handler| !handler_is_sce_owned(handler)); + } - if matcher_matches && !inserted { - let insert_at = first_owned.unwrap_or(handlers.len()).min(handlers.len()); + match target_group_index { + Some(group_index) => { + let group_object = merged_groups[group_index] + .as_object_mut() + .expect("validated group object"); + // A defaulted group (upstream's `#[serde(default)] hooks: Vec<...>`) + // may carry no "hooks" key at all; create an empty array so there + // is somewhere to insert the canonical handler. + let handlers = group_object + .entry("hooks".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("validated group's hooks field is a JSON array"); + let insert_at = insert_at_in_target + .unwrap_or(handlers.len()) + .min(handlers.len()); handlers.insert(insert_at, current_handler.clone()); - inserted = true; } - group_object.insert("hooks".to_string(), Value::Array(handlers)); - merged_groups.push(group); + None => { + merged_groups.push(canonical_group.clone()); + } } - if !inserted { - merged_groups.push(canonical_group.clone()); - } merged_groups } @@ -757,7 +1003,13 @@ mod tests { let first = merge_or_create(None, &generated(), "hooks.json").unwrap(); let second = merge_or_create(Some(&first), &generated(), "hooks.json").unwrap(); assert_eq!(first, second); - assert!(fragment_is_current(&first, &generated()).unwrap()); + let document_diagnosis = diagnose_document(Some(&first), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert!(diagnoses + .iter() + .all(|diagnosis| diagnosis.state == RegistrationStructuralState::PresentAndCurrent)); } #[test] @@ -788,4 +1040,411 @@ mod tests { assert!(error.to_string().contains(".codex/hooks.json")); assert_eq!(existing, br#"{\"hooks\":{\"Stop\":\"not-an-array\"}}"#); } + + fn registration<'a>( + diagnoses: &'a [RegistrationDiagnosis], + event: &str, + ) -> &'a RegistrationDiagnosis { + diagnoses + .iter() + .find(|diagnosis| diagnosis.event == event) + .unwrap_or_else(|| panic!("no diagnosis for event '{event}'")) + } + + #[test] + fn diagnose_document_reports_absent_for_a_missing_file() { + assert_eq!( + diagnose_document(None, &generated()).unwrap(), + HooksDocumentDiagnosis::Absent + ); + } + + #[test] + fn diagnose_document_reports_malformed_for_invalid_json() { + let document_diagnosis = diagnose_document( + Some(br#"{\"hooks\":{\"Stop\":\"not-an-array\"}}"#), + &generated(), + ) + .unwrap(); + assert!(matches!( + document_diagnosis, + HooksDocumentDiagnosis::Malformed(_) + )); + } + + #[test] + fn diagnose_document_reports_malformed_for_codex_invalid_structure() { + let existing = serde_json::to_vec(&json!({"custom": true})).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing), &generated()).unwrap(); + assert!(matches!( + document_diagnosis, + HooksDocumentDiagnosis::Malformed(_) + )); + } + + #[test] + fn diagnose_document_reports_missing_registrations_for_an_empty_valid_document() { + let existing = serde_json::to_vec(&json!({})).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected a validated document with per-registration diagnoses"); + }; + assert_eq!(diagnoses.len(), 4); + for diagnosis in &diagnoses { + assert_eq!(diagnosis.state, RegistrationStructuralState::Missing); + assert!(diagnosis.owned_handler.is_none()); + assert!(diagnosis.position.is_none()); + } + } + + #[test] + fn diagnose_document_reports_present_and_current_after_a_fresh_merge() { + let installed = merge_or_create(None, &generated(), "hooks.json").unwrap(); + let document_diagnosis = diagnose_document(Some(&installed), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + for diagnosis in &diagnoses { + assert_eq!( + diagnosis.state, + RegistrationStructuralState::PresentAndCurrent + ); + assert!(diagnosis.owned_handler.is_some()); + assert_eq!(diagnosis.position, Some((0, 0))); + } + } + + #[test] + fn diagnose_document_reports_stale_for_a_legacy_owned_handler() { + let existing = json!({ + "hooks": { + "Stop": [{"hooks": [ + {"type": "command", "command": "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex", "timeout": 30} + ]}] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + let stop = registration(&diagnoses, "Stop"); + assert_eq!(stop.state, RegistrationStructuralState::Stale); + assert_eq!(stop.position, Some((0, 0))); + assert_eq!( + registration(&diagnoses, "UserPromptSubmit").state, + RegistrationStructuralState::Missing + ); + } + + #[test] + fn diagnose_document_reports_stale_for_duplicate_owned_handlers() { + let owned_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "hooks": { + "Stop": [{"hooks": [ + {"type": "command", "command": owned_command}, + {"type": "command", "command": owned_command} + ]}] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert_eq!( + registration(&diagnoses, "Stop").state, + RegistrationStructuralState::Stale + ); + } + + #[test] + fn diagnose_document_treats_an_owned_handler_in_the_wrong_matcher_group_as_stale() { + // Codex still discovers this handler (it just never dispatches for a + // Bash PreToolUse call, since the matcher does not match); doctor + // must not report "nothing is here" when something structurally + // wrong is actually present. + let owned_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "hooks": { + "PreToolUse": [ + {"matcher": "Write", "hooks": [{"type": "command", "command": owned_command}]} + ] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert_eq!( + registration(&diagnoses, "PreToolUse").state, + RegistrationStructuralState::Stale + ); + } + + const CANONICAL_COMMAND: &str = "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"; + + #[test] + fn diagnose_document_reports_stale_for_a_canonical_handler_duplicated_in_a_second_matcher_group( + ) { + let existing = json!({ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]}, + {"matcher": "Bash", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]} + ] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert_eq!( + registration(&diagnoses, "PreToolUse").state, + RegistrationStructuralState::Stale, + "an owned handler duplicated across two matcher groups must not read PresentAndCurrent, \ + since merge_or_create would still collapse it to one handler" + ); + } + + #[test] + fn diagnose_document_finds_the_canonical_handler_in_a_non_first_matcher_group() { + let existing = json!({ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo user only"}]}, + {"matcher": "Bash", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]} + ] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + let pre_tool_use = registration(&diagnoses, "PreToolUse"); + assert_eq!( + pre_tool_use.state, + RegistrationStructuralState::PresentAndCurrent + ); + assert_eq!( + pre_tool_use.position, + Some((1, 0)), + "position must name the second group, not wrongly default to the first" + ); + } + + #[test] + fn diagnose_document_reports_stale_for_a_canonical_handler_plus_a_wrong_matcher_duplicate() { + let owned_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]}, + {"matcher": "Write", "hooks": [{"type": "command", "command": owned_command}]} + ] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert_eq!( + registration(&diagnoses, "PreToolUse").state, + RegistrationStructuralState::Stale, + "a canonical placement plus any other owned handler anywhere must still read Stale" + ); + } + + #[test] + fn diagnose_document_reports_missing_when_every_group_holds_only_non_owned_handlers() { + let existing = json!({ + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "echo one"}]}, + {"hooks": [{"type": "command", "command": "echo two"}]} + ] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert_eq!( + registration(&diagnoses, "Stop").state, + RegistrationStructuralState::Missing + ); + } + + #[test] + fn diagnose_document_finds_the_canonical_handler_mixed_with_arbitrary_user_handlers() { + let existing = json!({ + "hooks": { + "Stop": [{"hooks": [ + {"type": "command", "command": "echo user one"}, + {"type": "command", "command": CANONICAL_COMMAND}, + {"type": "command", "command": "echo user two"} + ]}] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + let stop = registration(&diagnoses, "Stop"); + assert_eq!(stop.state, RegistrationStructuralState::PresentAndCurrent); + assert_eq!(stop.position, Some((0, 1))); + } + + #[test] + fn present_and_current_implies_merge_or_create_is_a_semantic_no_op() { + let canonical = merge_or_create(None, &generated(), "hooks.json").unwrap(); + let document_diagnosis = diagnose_document(Some(&canonical), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert!(diagnoses + .iter() + .all(|diagnosis| diagnosis.state == RegistrationStructuralState::PresentAndCurrent)); + + let merged_again = merge_or_create(Some(&canonical), &generated(), "hooks.json").unwrap(); + let canonical_value: Value = serde_json::from_slice(&canonical).unwrap(); + let merged_again_value: Value = serde_json::from_slice(&merged_again).unwrap(); + assert_eq!( + canonical_value, merged_again_value, + "PresentAndCurrent for every registration must imply merge_or_create is a no-op" + ); + } + + /// A matrix proving `merge_or_create` never rewrites a document every + /// registration is diagnosed `PresentAndCurrent` for, including the + /// specific relocation bug: a canonical handler already sitting in a + /// *non-first* matcher group must stay exactly where it is, not be + /// moved into an earlier matcher group merely because one exists. + #[test] + fn merge_or_create_is_a_no_op_for_every_present_and_current_placement() { + let user_prompt_submit = + json!({"hooks": [{"type": "command", "command": CANONICAL_COMMAND}]}); + let stop = json!({"hooks": [{"type": "command", "command": CANONICAL_COMMAND}]}); + let post_tool_use = json!({"matcher": "apply_patch", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]}); + + let cases: Vec<(&str, Value)> = vec![ + ( + "canonical handler in the only (first) matching group", + json!({ + "hooks": { + "UserPromptSubmit": [user_prompt_submit.clone()], + "Stop": [stop.clone()], + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]} + ], + "PostToolUse": [post_tool_use.clone()] + } + }), + ), + ( + "canonical handler in a second matching group, behind a user-only first group", + json!({ + "hooks": { + "UserPromptSubmit": [user_prompt_submit.clone()], + "Stop": [stop.clone()], + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo user only"}]}, + {"matcher": "Bash", "hooks": [{"type": "command", "command": CANONICAL_COMMAND}]} + ], + "PostToolUse": [post_tool_use.clone()] + } + }), + ), + ( + "canonical handler mixed with arbitrary user handlers in the same group", + json!({ + "hooks": { + "UserPromptSubmit": [user_prompt_submit.clone()], + "Stop": [stop.clone()], + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [ + {"type": "command", "command": "echo user one"}, + {"type": "command", "command": CANONICAL_COMMAND}, + {"type": "command", "command": "echo user two"} + ] + }], + "PostToolUse": [post_tool_use.clone()] + } + }), + ), + ]; + + for (label, existing) in cases { + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let document_diagnosis = + diagnose_document(Some(&existing_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("case '{label}': expected per-registration diagnoses"); + }; + assert!( + diagnoses + .iter() + .all(|diagnosis| diagnosis.state + == RegistrationStructuralState::PresentAndCurrent), + "case '{label}': every registration must diagnose PresentAndCurrent, got {diagnoses:?}" + ); + + let merged_bytes = + merge_or_create(Some(&existing_bytes), &generated(), "hooks.json").unwrap(); + let existing_value: Value = serde_json::from_slice(&existing_bytes).unwrap(); + let merged_value: Value = serde_json::from_slice(&merged_bytes).unwrap(); + assert_eq!( + existing_value, merged_value, + "case '{label}': merge_or_create must be a semantic no-op when every \ + registration is already PresentAndCurrent" + ); + } + } + + #[test] + fn merge_relocates_a_wrong_matcher_owned_handler_into_the_correct_matcher_group() { + let owned_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo user only"}]}, + {"matcher": "Write", "hooks": [{"type": "command", "command": owned_command}]} + ] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + let merged_bytes = + merge_or_create(Some(&existing_bytes), &generated(), "hooks.json").unwrap(); + let merged: Value = serde_json::from_slice(&merged_bytes).unwrap(); + + let bash_group = &merged["hooks"]["PreToolUse"][0]; + assert_eq!(bash_group["matcher"], "Bash"); + assert_eq!(bash_group["hooks"][0]["command"], "echo user only"); + assert_eq!(bash_group["hooks"][1]["command"], CANONICAL_COMMAND); + + let write_group = &merged["hooks"]["PreToolUse"][1]; + assert_eq!(write_group["matcher"], "Write"); + assert_eq!( + write_group["hooks"].as_array().unwrap().len(), + 0, + "the misplaced handler must be removed from the Write group, not left duplicated" + ); + + let document_diagnosis = diagnose_document(Some(&merged_bytes), &generated()).unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { + panic!("expected per-registration diagnoses"); + }; + assert_eq!( + registration(&diagnoses, "PreToolUse").state, + RegistrationStructuralState::PresentAndCurrent + ); + } } diff --git a/cli/src/services/codex_hook_trust.rs b/cli/src/services/codex_hook_trust.rs new file mode 100644 index 00000000..6d232538 --- /dev/null +++ b/cli/src/services/codex_hook_trust.rs @@ -0,0 +1,622 @@ +//! Read-only diagnosis of Codex's own hook-trust bookkeeping for SCE-owned +//! `.codex/hooks.json` registrations. +//! +//! Mirrors current upstream `openai/codex` (commit +//! `8e649e3afa5cdddfb09a1b85a090b94775045d9b`): +//! `hooks/src/engine/discovery.rs` (`hook_hash`, `hook_trust_status`, +//! `hook_enabled`, `hook_trusted_hash`, `NormalizedHookIdentity`), +//! `config/src/fingerprint.rs` (`version_for_toml`), and `hooks/src/lib.rs` +//! (`hook_key`, `hook_event_key_label`). SCE never writes this state; see +//! `codex_hook_config` for the SCE-owned merge/diagnosis boundary this module +//! deliberately stays out of (no auto-trust, no state.toml writes). +//! +//! Scope limitation: Codex's effective hook state is layered from its user +//! config (`$CODEX_HOME/config.toml`) and ephemeral, process-local session +//! flags (`hooks/src/config_rules.rs` `hook_states_from_stack`). Doctor is a +//! static, out-of-process inspection, so it can only ever read the durable +//! user-config layer; a live Codex session with session-flag overrides can +//! diverge from what doctor reports here. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// Default output-context token budget Codex applies when +/// `additionalContextLimit` is unset (`hooks/src/output_spill.rs` +/// `DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT`). An explicit value equal to this +/// default is normalized away before hashing, exactly as upstream does. +const DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT: u64 = 2_500; + +/// Events whose hooks may carry `additionalContext` +/// (`hooks/src/engine/discovery.rs`); `Stop` cannot, so an +/// `additionalContextLimit` set on a Stop handler is dropped before hashing, +/// matching upstream's own normalization. +const EVENTS_SUPPORTING_ADDITIONAL_CONTEXT: [&str; 4] = [ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "SessionStart", +]; + +/// Effective trust readiness for one Codex hook registration's current +/// on-disk handler. `Managed` never applies here: SCE only ever registers +/// project-owned (non-managed) handlers in `.codex/hooks.json`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TrustReadiness { + /// Enabled and `trusted_hash` matches the handler's current hash: Codex + /// will execute this handler. + Trusted, + /// Enabled but no `trusted_hash` is recorded for this handler yet. + Untrusted, + /// Enabled but the recorded `trusted_hash` does not match the handler's + /// current hash (the handler content changed since it was trusted). + Modified, + /// The user's Codex config explicitly disabled this handler + /// (`hooks.state."".enabled = false`). + Disabled, + /// Trust state could not be determined; carries a human-readable reason. + Unknown(String), +} + +/// Where doctor reads Codex's durable, user-scoped hook-trust state from. +/// Injectable so tests never touch the real `$CODEX_HOME`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TrustContext { + pub(crate) codex_home: Option, +} + +/// Resolve `$CODEX_HOME`, falling back to `~/.codex` (Codex's own default; +/// see `openai/codex` `config/src/loader/local.rs`). +pub(crate) fn default_trust_context() -> TrustContext { + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".codex"))); + TrustContext { codex_home } +} + +/// Diagnose whether Codex will actually execute the given SCE-owned handler +/// for one required registration. `hooks_json_path` must be the on-disk path +/// to the `.codex/hooks.json` file the handler was read from, and `position` +/// the `(group_index, handler_index)` it occupies there (from +/// `codex_hook_config::RegistrationDiagnosis::position`). +pub(crate) fn trust_readiness( + context: &TrustContext, + hooks_json_path: &Path, + event: &str, + matcher: Option<&str>, + handler: &Value, + position: (usize, usize), +) -> TrustReadiness { + let Some(codex_home) = context.codex_home.as_ref() else { + return TrustReadiness::Unknown( + "Unable to resolve a Codex home directory (CODEX_HOME is unset and no home \ + directory could be determined)." + .to_string(), + ); + }; + + let current_hash = match hash_command_handler(event, matcher, handler) { + Ok(hash) => hash, + Err(error) => return TrustReadiness::Unknown(error), + }; + + let key = match state_key(hooks_json_path, event, position) { + Ok(key) => key, + Err(error) => return TrustReadiness::Unknown(error), + }; + + let config_path = codex_home.join("config.toml"); + let state = match read_hook_state(&config_path, &key) { + Ok(state) => state, + Err(error) => return TrustReadiness::Unknown(error), + }; + + if state.enabled == Some(false) { + return TrustReadiness::Disabled; + } + match state.trusted_hash { + Some(trusted_hash) if trusted_hash == current_hash => TrustReadiness::Trusted, + Some(_) => TrustReadiness::Modified, + None => TrustReadiness::Untrusted, + } +} + +/// Mirrors upstream `HookStateToml` (`config/src/hook_config.rs`) exactly: +/// both fields optional, no `deny_unknown_fields` (an unrecognized extra key +/// is ignored, matching upstream). Deriving `Deserialize` from this shape +/// (rather than reading `enabled`/`trusted_hash` independently) is what lets +/// `read_hook_state` reject the whole entry, not just one bad field, exactly +/// as upstream's `hook_states_from_stack` does. +#[derive(Debug, Default, Clone, serde::Deserialize)] +struct HookStateEntry { + #[serde(default)] + enabled: Option, + #[serde(default)] + trusted_hash: Option, +} + +/// Read `hooks.state.""` from the user's Codex config, treating a +/// missing config file as "no state recorded" (a normal, common state) rather +/// than an error. A config file that exists but cannot be read or parsed is +/// an error, since doctor cannot tell whether trust was actually granted. +/// +/// A present entry that fails to deserialize as a whole (e.g. `enabled` set +/// to a non-boolean) is treated as absent, matching upstream +/// `hook_states_from_stack`'s `Err(_) => continue`: Codex never salvages +/// individual fields from a malformed state entry, so neither does doctor — +/// a malformed entry must never read as `Trusted` just because its +/// `trusted_hash` string happens to be well-formed. +fn read_hook_state(config_path: &Path, key: &str) -> Result { + let contents = match std::fs::read_to_string(config_path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(HookStateEntry::default()) + } + Err(error) => { + return Err(format!( + "Unable to read Codex user config '{}': {error}", + config_path.display() + )) + } + }; + // Parsed as a document-level `Table` (supports `[section]` headers), not + // as a bare `Value` (whose `FromStr` parses a single TOML value and would + // misread a leading `[` as an array literal). + let document: toml::Table = contents.parse().map_err(|error| { + format!( + "Unable to parse Codex user config '{}' as TOML: {error}", + config_path.display() + ) + })?; + + let entry = document + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(|state| state.get(key)); + let Some(entry) = entry else { + return Ok(HookStateEntry::default()); + }; + + Ok(HookStateEntry::deserialize(entry.clone()).unwrap_or_default()) +} + +/// Build Codex's persisted hook-state key for one registration, matching +/// `hooks::hook_key` (`"{key_source}:{event_label}:{group_index}:{handler_index}"` +/// where `key_source` is the hooks file's absolute display path). +fn state_key( + hooks_json_path: &Path, + event: &str, + (group_index, handler_index): (usize, usize), +) -> Result { + let absolute = std::fs::canonicalize(hooks_json_path).map_err(|error| { + format!( + "Unable to resolve the absolute path of '{}': {error}", + hooks_json_path.display() + ) + })?; + Ok(format!( + "{}:{}:{group_index}:{handler_index}", + absolute.display(), + super::codex_hook_config::hook_event_key_label(event) + )) +} + +/// Hash one existing `command` handler's normalized identity exactly as +/// upstream `hook_hash` does: build the same `{event_name, matcher?, hooks: +/// []}` shape, canonicalize (recursively sort object +/// keys, matching `fingerprint::canonical_json`), and SHA-256 the compact +/// JSON encoding. +fn hash_command_handler( + event: &str, + matcher: Option<&str>, + handler: &Value, +) -> Result { + let object = handler + .as_object() + .ok_or_else(|| "Codex hook handler must be a JSON object".to_string())?; + let handler_type = object + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| "Codex hook handler must have a string 'type'".to_string())?; + if handler_type != "command" { + return Err(format!( + "Codex hook trust hashing only supports 'command' handlers, found '{handler_type}'" + )); + } + let command = object + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| "Codex 'command' hook handler must have a string 'command'".to_string())?; + + let mut handler_fields = serde_json::Map::new(); + handler_fields.insert("type".to_string(), Value::String("command".to_string())); + handler_fields.insert("command".to_string(), Value::String(command.to_string())); + let timeout = object + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(600) + .max(1); + handler_fields.insert("timeout".to_string(), Value::from(timeout)); + let is_async = object + .get("async") + .and_then(Value::as_bool) + .unwrap_or(false); + handler_fields.insert("async".to_string(), Value::Bool(is_async)); + if let Some(status_message) = object.get("statusMessage").and_then(Value::as_str) { + handler_fields.insert( + "statusMessage".to_string(), + Value::String(status_message.to_string()), + ); + } + if EVENTS_SUPPORTING_ADDITIONAL_CONTEXT.contains(&event) { + if let Some(limit) = object.get("additionalContextLimit").and_then(Value::as_u64) { + if limit != DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT { + handler_fields.insert("additionalContextLimit".to_string(), Value::from(limit)); + } + } + } + + let mut identity = serde_json::Map::new(); + identity.insert( + "event_name".to_string(), + Value::String(super::codex_hook_config::hook_event_key_label(event).to_string()), + ); + if let Some(matcher) = matcher { + identity.insert("matcher".to_string(), Value::String(matcher.to_string())); + } + identity.insert( + "hooks".to_string(), + Value::Array(vec![Value::Object(handler_fields)]), + ); + + Ok(version_for_canonical_json(&Value::Object(identity))) +} + +fn version_for_canonical_json(value: &Value) -> String { + use std::fmt::Write as _; + + let canonical = canonical_json(value); + let serialized = serde_json::to_vec(&canonical).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(&serialized); + let hash = hasher.finalize(); + let hex = hash + .iter() + .fold(String::with_capacity(hash.len() * 2), |mut hex, byte| { + let _ = write!(hex, "{byte:02x}"); + hex + }); + format!("sha256:{hex}") +} + +/// Recursively sort object keys, matching `fingerprint::canonical_json` +/// exactly (arrays keep their order; scalars pass through unchanged). +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + let mut sorted = serde_json::Map::new(); + for key in keys { + if let Some(inner) = map.get(&key) { + sorted.insert(key, canonical_json(inner)); + } + } + Value::Object(sorted) + } + Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "sce-codex-hook-trust-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn bare_command_handler() -> Value { + json!({ + "type": "command", + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex" + }) + } + + #[test] + fn hashing_a_bare_handler_is_deterministic_and_matcher_sensitive() { + let handler = bare_command_handler(); + let a = hash_command_handler("Stop", None, &handler).unwrap(); + let b = hash_command_handler("Stop", None, &handler).unwrap(); + assert_eq!(a, b); + assert!(a.starts_with("sha256:")); + + let c = hash_command_handler("PreToolUse", Some("Bash"), &handler).unwrap(); + assert_ne!(a, c, "different event/matcher must hash differently"); + } + + #[test] + fn hashing_ignores_a_default_valued_additional_context_limit() { + let handler = bare_command_handler(); + let mut with_default_limit = handler.as_object().unwrap().clone(); + with_default_limit.insert("additionalContextLimit".to_string(), json!(2500)); + let with_default_limit = Value::Object(with_default_limit); + + let without = hash_command_handler("UserPromptSubmit", None, &handler).unwrap(); + let with_default = + hash_command_handler("UserPromptSubmit", None, &with_default_limit).unwrap(); + assert_eq!(without, with_default); + } + + #[test] + fn hashing_a_non_default_additional_context_limit_changes_the_hash() { + let handler = bare_command_handler(); + let mut with_limit = handler.as_object().unwrap().clone(); + with_limit.insert("additionalContextLimit".to_string(), json!(1000)); + let with_limit = Value::Object(with_limit); + + let without = hash_command_handler("UserPromptSubmit", None, &handler).unwrap(); + let with_limit = hash_command_handler("UserPromptSubmit", None, &with_limit).unwrap(); + assert_ne!(without, with_limit); + } + + #[test] + fn hashing_drops_additional_context_limit_on_stop_since_it_is_unsupported() { + let handler = bare_command_handler(); + let mut with_limit = handler.as_object().unwrap().clone(); + with_limit.insert("additionalContextLimit".to_string(), json!(1000)); + let with_limit = Value::Object(with_limit); + + let without = hash_command_handler("Stop", None, &handler).unwrap(); + let with_limit = hash_command_handler("Stop", None, &with_limit).unwrap(); + assert_eq!( + without, with_limit, + "Stop cannot carry additionalContext, so the field must not affect its hash" + ); + } + + #[test] + fn trust_readiness_is_untrusted_when_no_user_config_exists() { + let dir = temp_dir("no-config"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let context = TrustContext { + codex_home: Some(dir.join("codex-home-does-not-exist")), + }; + let handler = bare_command_handler(); + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!(readiness, TrustReadiness::Untrusted); + } + + #[test] + fn trust_readiness_is_trusted_when_the_recorded_hash_matches() { + let dir = temp_dir("trusted"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let absolute = fs::canonicalize(&hooks_json).unwrap(); + let handler = bare_command_handler(); + let hash = hash_command_handler("Stop", None, &handler).unwrap(); + let key = format!("{}:stop:0:0", absolute.display()); + + let codex_home = dir.join("codex-home"); + fs::create_dir_all(&codex_home).unwrap(); + let escaped_key = key.replace('\\', "\\\\").replace('"', "\\\""); + fs::write( + codex_home.join("config.toml"), + format!("[hooks.state.\"{escaped_key}\"]\ntrusted_hash = \"{hash}\"\n"), + ) + .unwrap(); + + let context = TrustContext { + codex_home: Some(codex_home), + }; + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!(readiness, TrustReadiness::Trusted); + } + + #[test] + fn trust_readiness_is_modified_when_the_recorded_hash_differs() { + let dir = temp_dir("modified"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let absolute = fs::canonicalize(&hooks_json).unwrap(); + let handler = bare_command_handler(); + let key = format!("{}:stop:0:0", absolute.display()); + + let codex_home = dir.join("codex-home"); + fs::create_dir_all(&codex_home).unwrap(); + let escaped_key = key.replace('\\', "\\\\").replace('"', "\\\""); + fs::write( + codex_home.join("config.toml"), + format!("[hooks.state.\"{escaped_key}\"]\ntrusted_hash = \"sha256:stale\"\n"), + ) + .unwrap(); + + let context = TrustContext { + codex_home: Some(codex_home), + }; + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!(readiness, TrustReadiness::Modified); + } + + #[test] + fn trust_readiness_is_disabled_when_the_state_disables_it_even_if_trusted() { + let dir = temp_dir("disabled"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let absolute = fs::canonicalize(&hooks_json).unwrap(); + let handler = bare_command_handler(); + let hash = hash_command_handler("Stop", None, &handler).unwrap(); + let key = format!("{}:stop:0:0", absolute.display()); + + let codex_home = dir.join("codex-home"); + fs::create_dir_all(&codex_home).unwrap(); + let escaped_key = key.replace('\\', "\\\\").replace('"', "\\\""); + fs::write( + codex_home.join("config.toml"), + format!( + "[hooks.state.\"{escaped_key}\"]\ntrusted_hash = \"{hash}\"\nenabled = false\n" + ), + ) + .unwrap(); + + let context = TrustContext { + codex_home: Some(codex_home), + }; + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!(readiness, TrustReadiness::Disabled); + } + + #[test] + fn trust_readiness_is_unknown_when_the_user_config_cannot_be_parsed() { + let dir = temp_dir("malformed-config"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let codex_home = dir.join("codex-home"); + fs::create_dir_all(&codex_home).unwrap(); + fs::write(codex_home.join("config.toml"), "not = [valid").unwrap(); + + let context = TrustContext { + codex_home: Some(codex_home), + }; + let handler = bare_command_handler(); + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert!(matches!(readiness, TrustReadiness::Unknown(_))); + } + + #[test] + fn trust_readiness_is_unknown_when_codex_home_cannot_be_resolved() { + let dir = temp_dir("no-home"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let context = TrustContext { codex_home: None }; + let handler = bare_command_handler(); + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert!(matches!(readiness, TrustReadiness::Unknown(_))); + } + + /// Writes `[hooks.state.""]` plus + /// `body` verbatim into a fresh `$CODEX_HOME/config.toml`, returning the + /// `TrustContext` pointed at it. + fn write_state_toml( + dir: &std::path::Path, + label: &str, + hooks_json: &std::path::Path, + event_label: &str, + body: &str, + ) -> TrustContext { + let absolute = fs::canonicalize(hooks_json).unwrap(); + let key = format!("{}:{event_label}:0:0", absolute.display()); + let escaped_key = key.replace('\\', "\\\\").replace('"', "\\\""); + + let codex_home = dir.join(format!("codex-home-{label}")); + fs::create_dir_all(&codex_home).unwrap(); + fs::write( + codex_home.join("config.toml"), + format!("[hooks.state.\"{escaped_key}\"]\n{body}\n"), + ) + .unwrap(); + + TrustContext { + codex_home: Some(codex_home), + } + } + + #[test] + fn trust_readiness_ignores_the_whole_entry_when_enabled_has_the_wrong_type() { + // Upstream deserializes the complete `HookStateToml` entry; a + // present field with the wrong type fails the whole entry, so a + // syntactically-correct `trusted_hash` next to it must never be + // salvaged into a false `Trusted` result. + let dir = temp_dir("malformed-enabled"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let handler = bare_command_handler(); + let hash = hash_command_handler("Stop", None, &handler).unwrap(); + + let context = write_state_toml( + &dir, + "malformed-enabled", + &hooks_json, + "stop", + &format!("enabled = \"not-a-bool\"\ntrusted_hash = \"{hash}\""), + ); + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!( + readiness, + TrustReadiness::Untrusted, + "a malformed 'enabled' field must drop the whole entry, never read Trusted" + ); + } + + #[test] + fn trust_readiness_ignores_the_whole_entry_when_trusted_hash_has_the_wrong_type() { + let dir = temp_dir("malformed-hash-type"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let handler = bare_command_handler(); + + // `enabled = false` here is the distinguishing signal: if only the + // malformed `trusted_hash` field were dropped in isolation, + // `enabled = false` would still be honored and this would read + // `Disabled`. The correct whole-entry-drop behavior discards + // `enabled` too, so the result must be `Untrusted` (the same as no + // entry at all). + let context = write_state_toml( + &dir, + "malformed-hash-type", + &hooks_json, + "stop", + "enabled = false\ntrusted_hash = 12345", + ); + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!( + readiness, + TrustReadiness::Untrusted, + "a malformed 'trusted_hash' field must drop the whole entry (including 'enabled'), \ + not just be ignored on its own" + ); + } + + #[test] + fn trust_readiness_ignores_a_completely_malformed_state_entry() { + let dir = temp_dir("malformed-entry"); + let hooks_json = dir.join("hooks.json"); + fs::write(&hooks_json, "{}").unwrap(); + let handler = bare_command_handler(); + let absolute = fs::canonicalize(&hooks_json).unwrap(); + let key = format!("{}:stop:0:0", absolute.display()); + let escaped_key = key.replace('\\', "\\\\").replace('"', "\\\""); + + let codex_home = dir.join("codex-home-malformed-entry"); + fs::create_dir_all(&codex_home).unwrap(); + // The entry itself is a plain string, not a table: cannot + // deserialize as `HookStateToml` at all, so it must not panic and + // must fall back to "no state recorded" like a missing entry. + fs::write( + codex_home.join("config.toml"), + format!("[hooks.state]\n\"{escaped_key}\" = \"not a table\"\n"), + ) + .unwrap(); + + let context = TrustContext { + codex_home: Some(codex_home), + }; + let readiness = trust_readiness(&context, &hooks_json, "Stop", None, &handler, (0, 0)); + assert_eq!(readiness, TrustReadiness::Untrusted); + } +} diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 8ec01a8a..cc47c2da 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -6,6 +6,7 @@ use sha2::{Digest, Sha256}; use crate::services::agent_trace_db::lifecycle::diagnose_agent_trace_db_health; use crate::services::checkout; use crate::services::codex_hook_config; +use crate::services::codex_hook_trust; use crate::services::config::schema::parse_file_config; use crate::services::config::{self, ConfigPathSource, IntegrationTargetId}; use crate::services::default_paths::{ @@ -587,8 +588,11 @@ fn inspect_repository_integrations( integration_groups.extend(pi_groups); } IntegrationTargetId::Codex => { - let codex_groups = - collect_codex_integration_groups(resolved_root, &selected_optional_workflows); + let codex_groups = collect_codex_integration_groups( + resolved_root, + &selected_optional_workflows, + &codex_hook_trust::default_trust_context(), + ); inspect_codex_integration_health(&codex_groups, problems); integration_groups.extend(codex_groups); } @@ -635,9 +639,78 @@ pub(super) fn repair_merge_target_configs(repository_root: &Path) -> Vec Option { + let is_structurally_unhealthy = groups + .iter() + .flat_map(|group| &group.children) + .filter(|child| { + child + .relative_path + .starts_with(&format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#")) + }) + .any(|child| { + matches!( + child.content_state, + IntegrationContentState::Missing + | IntegrationContentState::Stale + | IntegrationContentState::Malformed(_) + ) + }); + if !is_structurally_unhealthy { + return None; + } + + Some( + match repair_merge_target_asset( + repository_root, + SetupTarget::Codex, + CODEX_HOOKS_JSON_RELATIVE_PATH, + ) { + Ok(()) => DoctorFixResultRecord { + category: ProblemCategory::RepoAssets, + outcome: FixResult::Fixed, + detail: format!( + "Merged canonical SCE hook registrations into '{CODEX_HOOKS_JSON_RELATIVE_PATH}'." + ), + }, + Err(error) => DoctorFixResultRecord { + category: ProblemCategory::RepoAssets, + outcome: FixResult::Failed, + detail: format!( + "Failed to merge canonical SCE hook registrations into \ + '{CODEX_HOOKS_JSON_RELATIVE_PATH}': {error}" + ), + }, + }, + ) +} + fn repair_merge_target_if_mismatched( repository_root: &Path, target: SetupTarget, @@ -924,6 +997,83 @@ fn inspect_codex_integration_health( push_codex_integration_missing_problems(integration_groups, problems); push_codex_integration_mismatch_problems(integration_groups, problems); push_codex_integration_read_fail_problems(integration_groups, problems); + push_codex_hook_malformed_problems(integration_groups, problems); + push_codex_hook_trust_problems(integration_groups, problems); +} + +fn push_codex_hook_malformed_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let Some(error) = group + .children + .iter() + .find_map(|child| match &child.content_state { + IntegrationContentState::Malformed(error) => Some(error.clone()), + _ => None, + }) + else { + continue; + }; + + problems.push(DoctorProblem { + kind: ProblemKind::CodexHookRegistrationMalformed, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "'.codex/hooks.json' cannot be structurally validated, so its required \ + registrations cannot be verified: {error}" + ), + remediation: "Fix or remove the invalid '.codex/hooks.json' by hand, then rerun \ + 'sce setup --codex' or 'sce doctor' to reinstall it; SCE will not \ + overwrite content it cannot safely merge." + .to_string(), + next_action: "manual_steps", + scope: Some(group.key), + }); + } +} + +fn push_codex_hook_trust_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let not_trusted_children = group + .children + .iter() + .filter_map(|child| match &child.content_state { + IntegrationContentState::NotTrusted(reason) => { + Some((child.relative_path.as_str(), reason.as_str())) + } + _ => None, + }) + .collect::>(); + if not_trusted_children.is_empty() { + continue; + } + + let details = not_trusted_children + .iter() + .map(|(relative_path, reason)| format!("'{relative_path}' ({reason})")) + .collect::>() + .join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::CodexHookRegistrationNotTrusted, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Warning, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Codex has not yet marked these current SCE hook registrations as trusted and \ + will not execute them: {details}." + ), + remediation: CODEX_HOOK_TRUST_GUIDANCE.to_string(), + next_action: "manual_steps", + scope: Some(group.key), + }); + } } fn push_opencode_integration_missing_problems( @@ -1265,7 +1415,7 @@ fn push_codex_integration_missing_problems( let missing_paths = missing_children .iter() - .map(|child| format!("'{}'", child.path.display())) + .map(|child| format!("'{}'", child.relative_path)) .collect::>() .join(", "); let mut remediation = format!( @@ -1301,7 +1451,12 @@ fn push_codex_integration_mismatch_problems( let mismatched_children = group .children .iter() - .filter(|child| matches!(&child.content_state, IntegrationContentState::Mismatch)) + .filter(|child| { + matches!( + &child.content_state, + IntegrationContentState::Mismatch | IntegrationContentState::Stale + ) + }) .collect::>(); if mismatched_children.is_empty() { continue; @@ -1309,7 +1464,7 @@ fn push_codex_integration_mismatch_problems( let mismatched_paths = mismatched_children .iter() - .map(|child| format!("'{}'", child.path.display())) + .map(|child| format!("'{}'", child.relative_path)) .collect::>() .join(", "); let mut remediation = format!( @@ -1677,6 +1832,7 @@ fn collect_pi_integration_groups( fn collect_codex_integration_groups( repository_root: &Path, selected_optional_workflows: &[String], + trust_context: &codex_hook_trust::TrustContext, ) -> Vec { let codex_root = InstallTargetPaths::new(repository_root).codex_target_dir(); let embedded_assets = iter_embedded_assets_for_setup_target_with_selection( @@ -1686,11 +1842,18 @@ fn collect_codex_integration_groups( .collect::>(); let mut skill_children = Vec::new(); let mut hook_children = Vec::new(); + let mut hooks_json_generated_bytes: Option<&'static [u8]> = None; for asset in embedded_assets { - let merge_target = - (asset.relative_path == ".codex/hooks.json").then_some(&MergeTargetAsset::CodexHooks); - let child = build_integration_child_from_asset(&codex_root, asset, merge_target); + if asset.relative_path == CODEX_HOOKS_JSON_RELATIVE_PATH { + // `.codex/hooks.json` is diagnosed per required registration + // (structural state plus Codex's own hook-trust readiness) + // instead of as a single whole-file child; see + // `codex_hooks_json_registration_children`. + hooks_json_generated_bytes = Some(asset.bytes); + continue; + } + let child = build_integration_child_from_asset(&codex_root, asset, None); if child .relative_path @@ -1705,6 +1868,15 @@ fn collect_codex_integration_groups( } } + if let Some(generated_bytes) = hooks_json_generated_bytes { + let hooks_json_path = codex_root.join(CODEX_HOOKS_JSON_RELATIVE_PATH); + hook_children.extend(codex_hooks_json_registration_children( + &hooks_json_path, + generated_bytes, + trust_context, + )); + } + sort_integration_children(&mut skill_children); sort_integration_children(&mut hook_children); @@ -1720,6 +1892,144 @@ fn collect_codex_integration_groups( ] } +/// `.codex/hooks.json`'s relative path within Codex's embedded-asset set. +const CODEX_HOOKS_JSON_RELATIVE_PATH: &str = ".codex/hooks.json"; + +/// Build one `IntegrationChildHealth` per required Codex hook registration, +/// combining `codex_hook_config`'s structural diagnosis with Codex's own +/// hook-trust readiness (`codex_hook_trust`) for registrations that are +/// structurally present. A registration only needs a trust check once it is +/// structurally current or stale; a missing registration has no on-disk +/// handler to hash. +fn codex_hooks_json_registration_children( + hooks_json_path: &Path, + generated_bytes: &[u8], + trust_context: &codex_hook_trust::TrustContext, +) -> Vec { + let existing_bytes = match fs::read(hooks_json_path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return codex_hook_registration_paths() + .into_iter() + .map(|(suffix, _event, _matcher)| IntegrationChildHealth { + relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), + path: hooks_json_path.to_path_buf(), + content_state: IntegrationContentState::ReadFailed(error.to_string()), + }) + .collect(); + } + }; + + let document_diagnosis = + match codex_hook_config::diagnose_document(existing_bytes.as_deref(), generated_bytes) { + Ok(document_diagnosis) => document_diagnosis, + Err(error) => codex_hook_config::HooksDocumentDiagnosis::Malformed(error.to_string()), + }; + + match document_diagnosis { + codex_hook_config::HooksDocumentDiagnosis::Absent => codex_hook_registration_paths() + .into_iter() + .map(|(suffix, _event, _matcher)| IntegrationChildHealth { + relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), + path: hooks_json_path.to_path_buf(), + content_state: IntegrationContentState::Missing, + }) + .collect(), + codex_hook_config::HooksDocumentDiagnosis::Malformed(error) => { + codex_hook_registration_paths() + .into_iter() + .map(|(suffix, _event, _matcher)| IntegrationChildHealth { + relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), + path: hooks_json_path.to_path_buf(), + content_state: IntegrationContentState::Malformed(error.clone()), + }) + .collect() + } + codex_hook_config::HooksDocumentDiagnosis::Registrations(diagnoses) => diagnoses + .iter() + .map(|registration_diagnosis| { + codex_hook_registration_child( + hooks_json_path, + registration_diagnosis, + trust_context, + ) + }) + .collect(), + } +} + +/// The four required registrations' display suffixes, in canonical order. +fn codex_hook_registration_paths() -> [(&'static str, &'static str, Option<&'static str>); 4] { + [ + ("UserPromptSubmit", "UserPromptSubmit", None), + ("Stop", "Stop", None), + ("PreToolUse(Bash)", "PreToolUse", Some("Bash")), + ( + "PostToolUse(apply_patch)", + "PostToolUse", + Some("apply_patch"), + ), + ] +} + +fn codex_hook_registration_child( + hooks_json_path: &Path, + diagnosis: &codex_hook_config::RegistrationDiagnosis, + trust_context: &codex_hook_trust::TrustContext, +) -> IntegrationChildHealth { + let suffix = codex_hook_registration_paths() + .into_iter() + .find(|(_, event, matcher)| *event == diagnosis.event && *matcher == diagnosis.matcher) + .map_or(diagnosis.event, |(suffix, _, _)| suffix); + let relative_path = format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"); + + let content_state = match &diagnosis.state { + codex_hook_config::RegistrationStructuralState::Missing => IntegrationContentState::Missing, + codex_hook_config::RegistrationStructuralState::Stale => IntegrationContentState::Stale, + codex_hook_config::RegistrationStructuralState::PresentAndCurrent => { + let (Some(handler), Some(position)) = (&diagnosis.owned_handler, diagnosis.position) + else { + // Structurally impossible: `PresentAndCurrent` always carries + // both. Treat defensively as stale rather than panicking. + return IntegrationChildHealth { + relative_path, + path: hooks_json_path.to_path_buf(), + content_state: IntegrationContentState::Stale, + }; + }; + match codex_hook_trust::trust_readiness( + trust_context, + hooks_json_path, + diagnosis.event, + diagnosis.matcher, + handler, + position, + ) { + codex_hook_trust::TrustReadiness::Trusted => IntegrationContentState::Match, + codex_hook_trust::TrustReadiness::Untrusted => { + IntegrationContentState::NotTrusted("untrusted".to_string()) + } + codex_hook_trust::TrustReadiness::Modified => { + IntegrationContentState::NotTrusted("modified".to_string()) + } + codex_hook_trust::TrustReadiness::Disabled => { + IntegrationContentState::NotTrusted("disabled".to_string()) + } + codex_hook_trust::TrustReadiness::Unknown(_) => { + IntegrationContentState::NotTrusted("unknown".to_string()) + } + } + } + }; + + IntegrationChildHealth { + relative_path, + path: hooks_json_path.to_path_buf(), + content_state, + } +} + fn sort_integration_children(children: &mut [IntegrationChildHealth]) { children.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); } @@ -1733,7 +2043,6 @@ const OPENCODE_CONFIG_RELATIVE_PATH: &str = "opencode.json"; enum MergeTargetAsset { ClaudeSettings, OpenCodeConfig, - CodexHooks, } fn build_integration_child_from_asset( @@ -1753,11 +2062,6 @@ fn build_integration_child_from_asset( asset.bytes, config_merge::opencode_config_fragment_is_current, ), - Some(MergeTargetAsset::CodexHooks) => inspect_merge_target_asset_state( - &path, - asset.bytes, - codex_hook_config::fragment_is_current, - ), None => inspect_integration_asset_state(&path, &asset.sha256), }; IntegrationChildHealth { @@ -1882,11 +2186,12 @@ mod tests { use std::path::PathBuf; use super::{ - collect_claude_integration_groups, collect_codex_integration_groups, + codex_hook_trust, collect_claude_integration_groups, collect_codex_integration_groups, collect_hook_file_health, collect_opencode_integration_groups, collect_pi_integration_groups, inspect_claude_integration_health, inspect_codex_integration_health, resolve_doctor_integration_targets, HookContentState, - IntegrationArea, IntegrationContentState, IntegrationGroupHealth, IntegrationTarget, + IntegrationArea, IntegrationContentState, IntegrationGroupHealth, IntegrationGroupKey, + IntegrationTarget, ProblemKind, }; use crate::services::config::IntegrationTargetId; use crate::services::setup::OPTIONAL_WORKFLOWS; @@ -2019,6 +2324,18 @@ mod tests { ); } + /// A `TrustContext` pointed at a codex-home directory with no + /// `config.toml`, so tests never depend on the real `$CODEX_HOME` or + /// `~/.codex` of the machine running them: every registration diagnosed + /// as structurally current resolves deterministically to `Untrusted`. + fn deterministic_untrusted_context(label: &str) -> codex_hook_trust::TrustContext { + codex_hook_trust::TrustContext { + codex_home: Some(unique_temp_repository_root(&format!( + "{label}-codex-home-absent" + ))), + } + } + fn unique_temp_repository_root(label: &str) -> PathBuf { let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -2067,7 +2384,11 @@ mod tests { #[test] fn codex_integration_groups_split_into_skills_and_hooks_areas() { let root = absent_repository_root(); - let groups = collect_codex_integration_groups(&root, &[]); + let groups = collect_codex_integration_groups( + &root, + &[], + &deterministic_untrusted_context("split-areas"), + ); let skills_group = groups .iter() @@ -2087,13 +2408,20 @@ mod tests { .iter() .find(|group| group.key.area == IntegrationArea::Hooks) .expect("Codex hooks group present"); - assert!( - hooks_group - .children - .iter() - .any(|child| child.relative_path == ".codex/hooks.json"), - "Codex hooks group should include .codex/hooks.json" - ); + for suffix in [ + "UserPromptSubmit", + "Stop", + "PreToolUse(Bash)", + "PostToolUse(apply_patch)", + ] { + assert!( + hooks_group + .children + .iter() + .any(|child| child.relative_path == format!(".codex/hooks.json#{suffix}")), + "Codex hooks group should include a .codex/hooks.json#{suffix} registration" + ); + } assert!( hooks_group .children @@ -2113,7 +2441,7 @@ mod tests { } #[test] - fn codex_hooks_json_reports_match_then_missing_problem_includes_trust_guidance() { + fn codex_hooks_json_reports_present_and_current_but_untrusted_then_missing() { let root = unique_temp_repository_root("codex-hooks"); let codex_hooks_dir = root.join(".codex"); std::fs::create_dir_all(&codex_hooks_dir).unwrap(); @@ -2123,27 +2451,51 @@ mod tests { ) .unwrap(); - let groups = collect_codex_integration_groups(&root, &[]); - let hooks_child = groups + let trust_context = deterministic_untrusted_context("codex-hooks-match"); + let groups = collect_codex_integration_groups(&root, &[], &trust_context); + let registration_children = groups .iter() .flat_map(|group| &group.children) - .find(|child| child.relative_path == ".codex/hooks.json") - .expect(".codex/hooks.json child present"); - assert!(matches!( - hooks_child.content_state, - IntegrationContentState::Match - )); + .filter(|child| child.relative_path.starts_with(".codex/hooks.json#")) + .collect::>(); + assert_eq!(registration_children.len(), 4); + for child in ®istration_children { + assert_eq!( + child.content_state, + IntegrationContentState::NotTrusted("untrusted".to_string()), + "a current-but-never-trusted registration ('{}') should report not-trusted, \ + not a bare content mismatch", + child.relative_path + ); + } + + let mut trust_problems = Vec::new(); + inspect_codex_integration_health(&groups, &mut trust_problems); + let trust_problem = trust_problems + .iter() + .find(|problem| problem.kind == ProblemKind::CodexHookRegistrationNotTrusted) + .expect("a not-trusted problem was reported for current registrations"); + assert!( + trust_problem.remediation.contains("trust"), + "not-trusted remediation should mention the project hook trust/review requirement: {}", + trust_problem.remediation + ); std::fs::remove_file(codex_hooks_dir.join("hooks.json")).unwrap(); - let groups_after_delete = collect_codex_integration_groups(&root, &[]); + let groups_after_delete = collect_codex_integration_groups(&root, &[], &trust_context); let mut problems = Vec::new(); inspect_codex_integration_health(&groups_after_delete, &mut problems); + let hooks_scope = + IntegrationGroupKey::new(IntegrationTarget::Codex, IntegrationArea::Hooks); let hooks_problem = problems .iter() - .find(|problem| problem.summary.contains(".codex/hooks.json")) - .expect("a missing .codex/hooks.json problem was reported"); + .find(|problem| { + problem.kind == ProblemKind::CodexIntegrationFilesMissing + && problem.scope == Some(hooks_scope) + }) + .expect("a missing Codex hook registration problem was reported"); assert!( hooks_problem.remediation.contains("trust"), "Codex hooks remediation should mention the project hook trust/review requirement: {}", @@ -2153,6 +2505,101 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn codex_hooks_json_stale_registration_is_repaired_preserving_unrelated_user_content() { + let root = unique_temp_repository_root("codex-hooks-fix"); + let codex_hooks_dir = root.join(".codex"); + std::fs::create_dir_all(&codex_hooks_dir).unwrap(); + + let existing = serde_json::json!({ + "description": "user hooks", + "hooks": { + "Stop": [{"hooks": [ + { + "type": "command", + "command": "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex", + "timeout": 30 + } + ]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "echo user session hook"}]}] + } + }); + let hooks_json_path = codex_hooks_dir.join("hooks.json"); + std::fs::write( + &hooks_json_path, + serde_json::to_vec_pretty(&existing).unwrap(), + ) + .unwrap(); + + let trust_context = deterministic_untrusted_context("codex-hooks-fix"); + let groups = collect_codex_integration_groups(&root, &[], &trust_context); + let stop_child = groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == ".codex/hooks.json#Stop") + .expect(".codex/hooks.json#Stop child present"); + assert_eq!(stop_child.content_state, IntegrationContentState::Stale); + + let fix_results = super::repair_merge_target_configs(&root); + assert!( + fix_results + .iter() + .any(|result| matches!(result.outcome, super::FixResult::Fixed)), + "expected the stale Codex Stop registration to be repaired: {fix_results:?}" + ); + + let repaired: serde_json::Value = + serde_json::from_slice(&std::fs::read(&hooks_json_path).unwrap()).unwrap(); + assert_eq!(repaired["description"], "user hooks"); + assert_eq!( + repaired["hooks"]["SessionStart"][0]["hooks"][0]["command"], "echo user session hook", + "unrelated user hooks must survive the repair" + ); + + let groups_after_fix = collect_codex_integration_groups(&root, &[], &trust_context); + for suffix in [ + "UserPromptSubmit", + "Stop", + "PreToolUse(Bash)", + "PostToolUse(apply_patch)", + ] { + let child = groups_after_fix + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == format!(".codex/hooks.json#{suffix}")) + .unwrap_or_else(|| panic!("expected a .codex/hooks.json#{suffix} child")); + assert_eq!( + child.content_state, + IntegrationContentState::NotTrusted("untrusted".to_string()), + "repair only fixes structure; a never-trusted registration stays not-trusted \ + rather than becoming falsely healthy" + ); + } + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn codex_hooks_json_repair_never_runs_for_not_trusted_only_drift() { + let root = unique_temp_repository_root("codex-hooks-no-fix"); + let codex_hooks_dir = root.join(".codex"); + std::fs::create_dir_all(&codex_hooks_dir).unwrap(); + std::fs::write( + codex_hooks_dir.join("hooks.json"), + embedded_codex_asset_bytes(".codex/hooks.json"), + ) + .unwrap(); + + let fix_results = super::repair_merge_target_configs(&root); + assert!( + fix_results.is_empty(), + "a structurally current but never-trusted Codex hooks.json must never trigger a \ + repair attempt: {fix_results:?}" + ); + + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn resolve_doctor_integration_targets_detects_codex_directory() { let root = unique_temp_repository_root("codex-detect"); diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index dd2a4ba9..f830d4e4 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -329,6 +329,12 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::ClaudeAssetReadFailed => ProblemKind::ClaudeAssetReadFailed, HealthProblemKind::PiAssetReadFailed => ProblemKind::PiAssetReadFailed, HealthProblemKind::CodexAssetReadFailed => ProblemKind::CodexAssetReadFailed, + HealthProblemKind::CodexHookRegistrationMalformed => { + ProblemKind::CodexHookRegistrationMalformed + } + HealthProblemKind::CodexHookRegistrationNotTrusted => { + ProblemKind::CodexHookRegistrationNotTrusted + } HealthProblemKind::AgentTraceDbConnectionFailed => { ProblemKind::AgentTraceDbConnectionFailed } @@ -391,6 +397,12 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::ClaudeAssetReadFailed => HealthProblemKind::ClaudeAssetReadFailed, ProblemKind::PiAssetReadFailed => HealthProblemKind::PiAssetReadFailed, ProblemKind::CodexAssetReadFailed => HealthProblemKind::CodexAssetReadFailed, + ProblemKind::CodexHookRegistrationMalformed => { + HealthProblemKind::CodexHookRegistrationMalformed + } + ProblemKind::CodexHookRegistrationNotTrusted => { + HealthProblemKind::CodexHookRegistrationNotTrusted + } ProblemKind::AgentTraceDbConnectionFailed => { HealthProblemKind::AgentTraceDbConnectionFailed } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index 41a5788f..7db24ce7 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -434,7 +434,10 @@ fn integration_group_status( IntegrationContentState::Match => DoctorDisplayStatus::Pass, IntegrationContentState::Missing | IntegrationContentState::Mismatch + | IntegrationContentState::Stale + | IntegrationContentState::Malformed(_) | IntegrationContentState::ReadFailed(_) => DoctorDisplayStatus::Fail, + IntegrationContentState::NotTrusted(_) => DoctorDisplayStatus::Warn, }) }); let problem_status = report @@ -589,6 +592,20 @@ fn render_display_detail(lines: &mut Vec, detail: &DoctorDisplayDetail, lines.push(format!("{prefix}Path: {}", path.display())); lines.push(format!("{prefix}Read error: {error}")); } + DoctorDisplayDetail::Stale { path } => { + lines.push(format!("{prefix}Path: {}", path.display())); + lines.push(format!( + "{prefix}Stale: this registration does not match the canonical handler." + )); + } + DoctorDisplayDetail::Malformed { path, error } => { + lines.push(format!("{prefix}Path: {}", path.display())); + lines.push(format!("{prefix}Malformed: {error}")); + } + DoctorDisplayDetail::NotTrusted { path, reason } => { + lines.push(format!("{prefix}Path: {}", path.display())); + lines.push(format!("{prefix}Not yet executable by Codex: {reason}")); + } DoctorDisplayDetail::Problem { summary, remediation, diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index bb250d5b..3ed93a3b 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -213,6 +213,19 @@ pub(super) enum IntegrationContentState { Missing, Mismatch, ReadFailed(String), + /// A Codex hook registration is present but not canonical (an SCE-owned + /// handler exists but differs from the generated one, or duplicates + /// exist). Distinct from `Mismatch`, which describes a whole-file + /// byte/fragment comparison rather than one registration. + Stale, + /// The whole `.codex/hooks.json` document could not be structurally + /// validated, so no per-registration state could be determined. + Malformed(String), + /// The registration is structurally current, but Codex will not execute + /// it yet: disabled, never trusted, or trusted against stale content. + /// Carries a short machine-readable reason (`"untrusted"`, `"modified"`, + /// `"disabled"`, or `"unknown"`). + NotTrusted(String), } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -247,6 +260,17 @@ pub(super) enum DoctorDisplayDetail { path: PathBuf, error: String, }, + Stale { + path: PathBuf, + }, + Malformed { + path: PathBuf, + error: String, + }, + NotTrusted { + path: PathBuf, + reason: String, + }, Problem { summary: String, remediation: String, @@ -354,6 +378,26 @@ impl IntegrationChildHealth { error: error.clone(), }), ), + IntegrationContentState::Stale => ( + DoctorDisplayStatus::Fail, + Some(DoctorDisplayDetail::Stale { + path: self.path.clone(), + }), + ), + IntegrationContentState::Malformed(error) => ( + DoctorDisplayStatus::Fail, + Some(DoctorDisplayDetail::Malformed { + path: self.path.clone(), + error: error.clone(), + }), + ), + IntegrationContentState::NotTrusted(reason) => ( + DoctorDisplayStatus::Warn, + Some(DoctorDisplayDetail::NotTrusted { + path: self.path.clone(), + reason: reason.clone(), + }), + ), }; DoctorDisplayNode::asset(self.relative_path.clone(), status, detail) } @@ -411,6 +455,8 @@ pub(crate) enum ProblemKind { ClaudeAssetReadFailed, PiAssetReadFailed, CodexAssetReadFailed, + CodexHookRegistrationMalformed, + CodexHookRegistrationNotTrusted, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } diff --git a/cli/src/services/lifecycle.rs b/cli/src/services/lifecycle.rs index bc0b6b90..23570374 100644 --- a/cli/src/services/lifecycle.rs +++ b/cli/src/services/lifecycle.rs @@ -65,6 +65,8 @@ pub enum HealthProblemKind { ClaudeAssetReadFailed, PiAssetReadFailed, CodexAssetReadFailed, + CodexHookRegistrationMalformed, + CodexHookRegistrationNotTrusted, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index c007e1ae..f94129dd 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -15,6 +15,7 @@ pub mod bash_policy; pub mod capabilities; pub mod checkout; pub(crate) mod codex_hook_config; +pub(crate) mod codex_hook_trust; pub mod command_registry; pub mod completion; pub mod config; diff --git a/context/architecture.md b/context/architecture.md index 7690cf53..b69499bc 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -128,7 +128,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the three assets that are merge targets — the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's user-owned `.codex/hooks.json` — the content staged is not always the embedded asset's bytes. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`, while Codex uses the shared `cli/src/services/codex_hook_config.rs` service for structural validation and canonical-registration merging. For the Claude and OpenCode targets, the content staged is the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. Codex's merge validates the document shape, recognizes ownership only when both the generated helper path and the `sce hooks codex` command contract are present, and replaces stale or duplicate SCE handlers with exactly one current handler for each `UserPromptSubmit`, `Stop`, `PreToolUse/Bash`, and `PostToolUse/apply_patch` registration while preserving unrelated valid Codex fields, groups, and handlers. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. -- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. Its `.codex/hooks.json` child uses the shared Codex hook-config fragment comparison, so unrelated user handlers do not create a false whole-document mismatch. +- `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. Its `.codex/hooks.json` reporting is per-registration rather than one whole-file child: `codex_hook_config::diagnose_document` classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it cannot be structurally validated) without writing anything, so unrelated user handlers never create a false whole-document mismatch. For a structurally current registration, `codex_hook_trust` separately reads (never writes) Codex's own durable `$CODEX_HOME/config.toml` hook-trust state — reproducing upstream's `hook_hash`/`hook_key`/`hook_trust_status` exactly — and reports `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown`; only `Trusted` renders healthy. `sce doctor --fix` repairs a structurally unhealthy `.codex/hooks.json` through the existing merge-install path, but a registration that is current yet not-yet-trusted is never "fixed", since SCE cannot grant Codex hook trust. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution event-locally: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`; either source is normalized once with the `claude/` prefix and lookup failures remain nullable. `session-model` is no longer a supported hook route. @@ -187,7 +187,7 @@ Investigations T08 (`turso default-features = false`) and T09 (isolating the with rationale in the benchmark doc. Final after-change numbers and remaining bottlenecks are captured in T11. -This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends); no CLI dev-dependencies are currently declared. Per-user local Turso DB and Agent Trace DB bootstrap/health coverage now exist through setup/doctor flows; the user-invocable `sce sync` command is now fully implemented including rendering (see above), and broader runtime integrations remain deferred. +This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `toml`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends); no CLI dev-dependencies are currently declared. Per-user local Turso DB and Agent Trace DB bootstrap/health coverage now exist through setup/doctor flows; the user-invocable `sce sync` command is now fully implemented including rendering (see above), and broader runtime integrations remain deferred. ## SCE plan/code role boundary diff --git a/context/glossary.md b/context/glossary.md index 6371dcac..4a2b7406 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -166,7 +166,7 @@ - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_01KZE4DDA8HM1JHZGF2QCF49RP`. - `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes each embedded setup asset into its own staged file next to its final destination under repository-root `.opencode/`/`.claude/`/`.pi/` (or, for Codex, the repository root itself, since its asset paths already carry their own `.agents/`/`.codex/` prefix), then swaps it into place via the `setup atomic-swap` policy (see `setup atomic-swap`) — renaming the staging file directly over the destination without unlinking it first. It never removes an integration target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. After the per-asset install loop, it prunes stale SCE-owned paths (see `setup catalog-derived pruning`). Three assets, the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's `.codex/hooks.json`, stage merged content instead of the embedded asset's bytes verbatim (see `setup config-merge seam`). - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. -- `setup config-merge seam`: Pure JSON merge services covering `.claude/settings.json`, `.opencode/opencode.json`, and Codex's `.codex/hooks.json`; the latter is owned by shared `cli/src/services/codex_hook_config.rs`, which validates structure and requires both the generated helper path and the `sce hooks codex` command contract before replacing stale or duplicate registrations. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` / `is_codex_hooks_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The shared Codex service also exposes `fragment_is_current`, which validates and compares the merged SCE fragment against the existing document; a no-op merge means the existing file already carries the current owned fragment, whatever else it holds. `cli/src/services/doctor/inspect.rs` uses the Claude/OpenCode fragment functions and the Codex function (instead of byte-exact `sha256`) to inspect merge targets, and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair the two existing repairable merge targets by reinstalling just that one asset through the same merge-install path. +- `setup config-merge seam`: Pure JSON merge services covering `.claude/settings.json`, `.opencode/opencode.json`, and Codex's `.codex/hooks.json`; the latter is owned by shared `cli/src/services/codex_hook_config.rs`, which validates structure and requires both the generated helper path and the `sce hooks codex` command contract before replacing stale or duplicate registrations. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` / `is_codex_hooks_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The shared Codex service also exposes `diagnose_document`, which classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it fails structural validation) without writing anything; a `PresentAndCurrent` result always implies a no-op merge. `cli/src/services/doctor/inspect.rs` uses the Claude/OpenCode fragment functions to inspect those merge targets and the Codex diagnosis (instead of byte-exact `sha256` or whole-document comparison) to inspect `.codex/hooks.json` per registration, further gating a structurally current registration on `codex_hook_trust::trust_readiness` (reads Codex's own `$CODEX_HOME`/`~/.codex/config.toml` hook-trust state read-only; see `context/architecture.md`), and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair all three repairable merge targets, including Codex's, by reinstalling just that one asset through the same merge-install path — never to grant trust. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. - `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` outer-normalizes supported raw/heredoc input before parsing, resolves paths from event `cwd` against the real Git root into safe repository-relative paths, then parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence using event-scoped synthetic line identities derived from `tool_use_id` (see `context/sce/codex-integration-runtime.md`). Invalid cwd/path mappings or identity/range failures fail open before persistence. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. diff --git a/context/overview.md b/context/overview.md index 0420e3f5..5c589a4e 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap, while doctor evaluates the SCE fragment rather than requiring whole-document equality. `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. +This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated SCE config schema are not committed. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 135-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer), plus a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, and separately reports whether Codex has actually marked each structurally current registration trusted by reading (never writing) Codex's own `$CODEX_HOME/config.toml` hook-trust state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index e277d83a..bd54e137 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -64,11 +64,12 @@ This revision extends the completed Codex rollout with six correctness hardening - [x] AC25: The complete hardened pipeline remains `PostToolUse apply_patch` → `tool_input.command` parsing → cwd-aware path resolution → SCE `payload_type = "patch"` `diff_traces` → existing `recent_diff_trace_patches`/`combine_patches` → existing Git post-commit intersection → Agent Trace, with no new schema, snapshot, pending state, PreToolUse apply_patch registration, Bash mutation attribution, or Codex-specific Agent Trace builder. - Validate: end-to-end temporary repository/Agent Trace DB test feeding realistic Codex PostToolUse JSON and a realistic post-commit patch, plus source/status inspection showing no migration or forbidden state artifacts. -- [ ] AC26: Codex apply_patch path resolution accepts valid `..` components and absolute paths when current Codex semantics accept them and the logical target remains inside the canonical Git worktree; accepts missing Add File targets and paths containing spaces; resolves nested cwd, Update source, and Move destination independently; rejects outside escapes, malformed/NUL paths, outside/empty cwd, and existing or missing targets that escape through symlinks. It emits only repository-relative UTF-8 slash paths. +- [x] AC26: Codex apply_patch path resolution accepts valid `..` components and absolute paths when current Codex semantics accept them and the logical target remains inside the canonical Git worktree; accepts missing Add File targets and paths containing spaces; resolves nested cwd, Update source, and Move destination independently; rejects outside escapes, malformed/NUL paths, outside/empty cwd, and existing or missing targets that escape through symlinks. It emits only repository-relative UTF-8 slash paths. - Validate: `hooks::codex::apply_patch::path` tests cover the complete path matrix, including valid parent traversal, absolute-inside paths, Add File missing targets, move paths, outside paths, and both symlink escape forms. -- [ ] AC27: `sce setup --codex` merges `.codex/hooks.json` through shared Codex ownership logic, preserving unrelated valid Codex fields, supported event groups, matcher groups, and handlers; it rejects top-level fields, event names, groups, and handlers that current Codex rejects; it replaces stale/duplicate SCE-owned handlers with exactly one current handler per required registration, adds missing registrations, is semantically idempotent, and leaves malformed/structurally invalid existing JSON byte-for-byte untouched while naming the file in the error. +- [x] AC27: `sce setup --codex` merges `.codex/hooks.json` through shared Codex ownership logic, preserving unrelated valid Codex fields, supported event groups, matcher groups, and handlers; it rejects top-level fields, event names, groups, and handlers that current Codex rejects; it replaces stale/duplicate SCE-owned handlers with exactly one current handler per required registration, adds missing registrations, is semantically idempotent, and leaves malformed/structurally invalid existing JSON byte-for-byte untouched while naming the file in the error. - Validate: shared Codex hook-config unit tests and setup tests cover upstream-defaulted groups, the strict top-level/event/handler schema, valid command/MCP/prompt/agent handlers, valid user-content preservation, stale and duplicate SCE handlers, repeated merge, ownership negatives, and malformed JSON/no-write behavior. -- [ ] AC28: `sce doctor` reports Codex-owned registrations structurally as `PresentAndCurrent`, `Missing`, `Stale`, or `Malformed`, ignores user-owned additions, and separately reports executable trust readiness. It does not claim health when the effective Codex state is disabled, untrusted, modified, or unknown; it reports healthy only for current SCE registrations that Codex will actually execute, and `--fix` repairs only the SCE-owned fragment without changing user hooks or trust state. +- [x] AC28: `sce doctor` reports Codex-owned registrations structurally as `PresentAndCurrent`, `Missing`, `Stale`, or `Malformed`, ignores user-owned additions, and separately reports executable trust readiness. It does not claim health when the effective Codex state is disabled, untrusted, modified, or unknown; it reports healthy only for current SCE registrations that Codex will actually execute, and `--fix` repairs only the SCE-owned fragment without changing user hooks or trust state. + - Validate: `codex_hook_config`/`codex_hook_trust`/`services::doctor` test suites (52 tests) — structural diagnosis scans every matcher group per event (not just the first matching one), trust-state deserialization discards a malformed state entry as a whole rather than salvaging individual fields, and `PresentAndCurrent` for every registration is proven equivalent to a no-op `merge_or_create`. - Validate: doctor/shared-service tests cover current plus user hooks, missing/stale/malformed fragments, trusted/untrusted/modified/disabled/unknown state, current upstream key/hash/config-layer semantics, and trust-preserving fix behavior. - [ ] AC29: No generated Codex `.agents/skills/**/*.md` file, including `SKILL.md` and `references/*.md`, contains literal `$ARGUMENTS`; canonical workflow content uses explicit skill-invocation input semantics while command-capable Claude/OpenCode/Pi entrypoints retain their existing argument-substitution behavior. - Validate: generated contract coverage walks all Codex skill Markdown and asserts the token is absent, while cross-target generation tests assert command wrappers and non-Codex behavior remain unchanged. @@ -405,13 +406,20 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — Codex hook configuration is now a shared setup/doctor contract: setup merges user-owned `.codex/hooks.json` non-destructively, ownership is structural, and doctor compares the SCE fragment rather than the whole document. The durable Codex runtime, setup/doctor context, and directly relevant architecture context must be synchronized. - Context synchronization: synced -- [ ] T22: `Make Codex doctor structural and trust-aware` (status:todo) +- [x] T22: `Make Codex doctor structural and trust-aware` (status:done) - Task ID: T22 - Scope: In — reuse the shared Codex hook-config service in doctor diagnosis and fix, report per-registration PresentAndCurrent/Missing/Stale/Malformed states, isolate upstream-compatible trust-key/hash/state compatibility code, and integrate disabled/untrusted/modified/unknown readiness into existing doctor severity/rendering conventions without auto-trusting. Out — changing Codex itself, writing trust state, and changing the core Agent Trace evidence model. - Dependencies: T21 - Done when: user-owned additions do not create SCE drift; current SCE fragments plus enabled/trusted effective state are healthy; missing, stale, malformed, disabled, untrusted, modified, unreadable, unresolvable, or unsupported state is not reported as executable healthy; doctor fix preserves user hooks and never changes trust consent. Comments identify the mirrored upstream source and tests cover current key/hash/config precedence semantics. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor'` - - Context synchronization: pending + - Completed: 2026-08-23 + - Files changed: `cli/src/services/codex_hook_config.rs`, `cli/src/services/codex_hook_trust.rs` (new), `cli/src/services/doctor/inspect.rs`, `cli/src/services/doctor/mod.rs`, `cli/src/services/doctor/render.rs`, `cli/src/services/doctor/types.rs`, `cli/src/services/lifecycle.rs`, `cli/src/services/mod.rs`, `cli/Cargo.toml`, `cli/Cargo.lock` + - Result: Extended `codex_hook_config.rs` with `diagnose_document`/`diagnose_registration`, returning a `HooksDocumentDiagnosis` (`Absent` | `Malformed(reason)` | `Registrations(Vec)`) that classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` without writing anything, mirroring `merge_or_create`'s own validation so a `PresentAndCurrent` result always implies a no-op merge; removed the now-superseded whole-document `fragment_is_current` (doctor no longer needs it). Added a new `codex_hook_trust` module that mirrors current upstream `openai/codex` (commit `8e649e3afa5cdddfb09a1b85a090b94775045d9b`) hook-trust bookkeeping read-only: `hash_command_handler`/`version_for_canonical_json` reproduce `hooks/src/engine/discovery.rs`'s `hook_hash` and `config/src/fingerprint.rs`'s `version_for_toml` (canonical-JSON SHA-256, `additionalContextLimit` normalization including the Stop-cannot-carry-context rule) without depending on Codex's own crates; `state_key` reproduces `hooks::hook_key`'s persisted-state key format; `trust_readiness` reads only the durable `$CODEX_HOME/config.toml` `[hooks.state.""]` layer (the ephemeral session-flags layer upstream also consults cannot be observed by a static, out-of-process `sce doctor`, and this scope limitation is documented in the module) and classifies `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown(reason)`, never writing trust state. Added the `toml` crate (0.9, matching upstream's own pin) as a new dependency, needed to parse Codex's own TOML config; discovered mid-implementation that `toml::Value::from_str` parses the bare-value grammar (misreading a leading `[hooks.state.…]` table header as an array literal) and switched to `toml::Table::from_str` (document grammar) to fix it. Doctor's `.codex/hooks.json` reporting now diagnoses per registration instead of as one whole-file child: `collect_codex_integration_groups` reports four synthetic children (`.codex/hooks.json#UserPromptSubmit`, `#Stop`, `#PreToolUse(Bash)`, `#PostToolUse(apply_patch)`) via new `IntegrationContentState::{Stale, Malformed(String), NotTrusted(String)}` variants (extending the existing `Match`/`Missing`/`Mismatch`/`ReadFailed` vocabulary, reusing all existing tree/JSON/problem-scoping machinery) and two new `ProblemKind`s (`CodexHookRegistrationMalformed`, `CodexHookRegistrationNotTrusted`; `Missing`/`Stale` reuse the existing generic Codex missing/mismatch problem kinds with a broadened filter). `sce doctor --fix` now also repairs `.codex/hooks.json` (new `repair_codex_hooks_json_if_structurally_unhealthy`, reusing the existing `repair_merge_target_asset`/`codex_hook_config::merge_or_create` write path) whenever any registration is structurally Missing/Stale/Malformed — never when the only drift is trust readiness, since SCE cannot and must not grant Codex hook trust. `HealthProblemKind` (`cli/src/services/lifecycle.rs`) and both `doctor_problem_kind`/`health_problem_kind` exhaustive mappings in `doctor/mod.rs` gained the two new kinds. Trust-context resolution (`$CODEX_HOME` or `~/.codex`) is injected as an explicit parameter through `collect_codex_integration_groups` so tests never depend on the real host's `~/.codex/config.toml`; production call sites use `codex_hook_trust::default_trust_context()`. + - Follow-up correctness fix (2026-08-23, PR #229 review): `diagnose_registration` previously used `.find(...)` to inspect only the first matcher group matching a registration's event, so an SCE-owned handler duplicated or misplaced in a *second* matcher group for the same event could be invisible to diagnosis while `merge_or_create` (which scrubs SCE-owned handlers across every group for the event) would still rewrite the document — breaking the `PresentAndCurrent => merge is a no-op` invariant. Rewrote it to scan every matcher group under `hooks.`, collecting every SCE-owned handler with its `(group_index, handler_index, in_canonical_group)`: `PresentAndCurrent` now requires exactly one owned handler anywhere for the event, in the canonical matcher group, byte-identical to the canonical handler; zero owned handlers anywhere is `Missing`; every other case (duplicates within or across groups, or an owned handler in the wrong matcher group) is `Stale`. This also changes the wrong-matcher case from `Missing` to `Stale`, since Codex does discover such a handler — reporting "nothing is here" was misleading. Separately, `codex_hook_trust::read_hook_state` previously read `enabled`/`trusted_hash` independently via `.as_bool()`/`.as_str()`, so a state entry with a malformed `enabled` (e.g. a string) but a syntactically valid, hash-matching `trusted_hash` could read `Trusted` even though upstream's `hook_states_from_stack` deserializes the whole `HookStateToml` entry and discards it entirely on any error (`Err(_) => continue`). `HookStateEntry` now derives `serde::Deserialize` directly (mirroring `HookStateToml`'s shape, no `deny_unknown_fields`, matching upstream) and `read_hook_state` deserializes the whole entry via `HookStateEntry::deserialize(entry.clone()).unwrap_or_default()`, so a malformed field drops the entire entry — including any other otherwise-valid field — falling back to the same default (`Untrusted`) as no entry at all. + - Follow-up correctness fix #2 (2026-08-23, PR #229 review): the first fix above made diagnosis correctly scan every matcher group, but `merge_event_groups` still always inserted the canonical handler into the *first* matcher-matching group regardless of where an already-canonical handler actually lived. So a document diagnosed `PresentAndCurrent` with its canonical handler sitting in a non-first matching group (e.g. a user-only `Bash` group before the one holding the canonical handler) was reported healthy, yet `merge_or_create` would still relocate the handler into the earlier group — a second, narrower violation of the same `PresentAndCurrent => merge is a no-op` invariant. Rewrote `merge_event_groups` to scan every group first (mirroring `diagnose_registration`'s own scan) and return the input `groups` completely untouched whenever exactly one owned handler exists, it is in a matcher-matching group, and it is byte-identical to the canonical handler — wherever that group sits. Only when that fast path does not apply does it repair: strip every owned handler from every group, then reinsert exactly one canonical handler at a deterministic position (prefer the matcher-matching group that already held an owned handler, replacing in place; else the first matcher-matching group, even one that never held an owned handler; else append a fresh canonical group). No group is ever deleted, and a "defaulted" existing group with no `hooks` key at all is handled by creating one rather than panicking. + - Verify: `nix flake check` — passed: "all checks passed!", covering `cli-tests` (523 tests; one unrelated pre-existing flaky test, `agent_trace_db::repository::tests::baseline_only_fixture_migrates_and_gets_a_stable_source_instance_id`, failed once under full-suite parallelism and passed both in isolation and on a clean rerun of the full suite — not touched by this change), `cli-clippy` (`--all-targets --all-features`, `-D clippy::pedantic`), `cli-fmt`, `cli-generated-input`, `pkl-generated`, `codex-hook-command`, and the rest of the flake's checks. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml codex_hook_config` — passed: 27 tests, including the new `merge_or_create_is_a_no_op_for_every_present_and_current_placement` matrix (covers the critical case: canonical handler in a second `Bash` matcher group behind a user-only first group) and `merge_relocates_a_wrong_matcher_owned_handler_into_the_correct_matcher_group`. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml codex_hook_trust` — passed: 13 tests, unaffected. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor services::setup` — passed: 75 tests, unaffected. + - Context impact: root — `context/sce/doctor-human-text-contract.md` (already named under this plan's Context sync list for "Codex integration group/area ordering") now also needs the new per-registration `.codex/hooks.json` reporting vocabulary and the trust-readiness dimension documented; the durable Codex runtime context should record that `sce doctor`/`sce doctor --fix` now diagnose and repair per-registration structural state and read (never write) Codex's own hook-trust bookkeeping. + - Context synchronization: synced - [ ] T23: `Render Codex skills with explicit skill-invocation input semantics` (status:todo) - Task ID: T23 diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 67288d29..9ef3a903 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -45,7 +45,7 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - integration target resolution that reads `integrations.target` from repo-local `.sce/config.json` when present, or falls back to detecting repo-root `.opencode/`, `.claude/`, `.pi/`, and `.codex/` directories when config has no `integrations` or `integrations.target`; only the resolved targets are inspected - repo-root installed OpenCode integration inventory for typed `Plugins`, `Agents`, `Commands`, and `Skills` areas, Claude inventory for generated `Plugins`, `Commands`, and `Skills` areas with no `Agents` expectation, Pi inventory for `Extensions`, `Prompts`, and `Skills`, plus Codex inventory for `Skills` and `Hooks`, all scoped to the resolved targets - integration groups are rendered beneath typed, target-scoped `Claude Code`, `OpenCode`, `Pi`, and `Codex` nodes in deterministic target-specific area order; healthy groups render one concise status row without listing installed files -- OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); Codex groups are derived from the embedded Codex catalog (`.agents/skills/**` under `Codex skills`, `.codex/hooks.json` and `.codex/hooks/**` under `Codex hooks`, the latter also carrying a Codex hook trust/review reminder when files are missing or mismatched — see `context/sce/doctor-human-text-contract.md`); generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/.agents/**`/`config/.codex/**` trees are not inspected by doctor +- OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); Codex groups are derived from the embedded Codex catalog (`.agents/skills/**` under `Codex skills`, one row per required `.codex/hooks.json` registration plus `.codex/hooks/**` under `Codex hooks`, the former also gated on Codex's own read-only hook-trust state — see `context/sce/doctor-human-text-contract.md`); generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/.agents/**`/`config/.codex/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks, so repair restores the canonical all-hook non-blocking missing-`sce` guidance, available-CLI argument/failure propagation, and post-commit-only remote forwarding contract; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories ## Approved human text-mode contract diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index d429caa5..303ac90e 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -33,12 +33,17 @@ one current handler for each of the four required registrations. Ownership requires both `.codex/hooks/run-sce-or-show-install-guidance.sh` and the `sce hooks codex` command contract; a generic `sce` substring is not enough. Malformed or structurally invalid existing documents fail before staging, so -the existing file remains untouched. Doctor uses the same fragment comparison, -so user-added valid Codex handlers do not appear as SCE drift; invalid Codex -configuration remains unhealthy. Trust state and -auto-trust behavior are separate concerns owned by later doctor work. See -[the ADR](../decisions/2026-08-23-codex-nondestructive-hook-ownership.md) and -[the setup install policy](setup-no-backup-policy-seam.md). +the existing file remains untouched. Doctor diagnoses each required +registration structurally (present-and-current, missing, or stale, with a +malformed whole document reported separately), so user-added valid Codex +handlers do not appear as SCE drift and invalid Codex configuration remains +unhealthy; `sce doctor --fix` repairs a structurally unhealthy document +through the same merge service. Codex's own hook-trust state — whether it has +actually marked a structurally current registration trusted, in its durable +`$CODEX_HOME/config.toml` — is read-only for doctor and separate from this +structural check; SCE never writes trust or auto-trust state. See [the +ADR](../decisions/2026-08-23-codex-nondestructive-hook-ownership.md) and [the +setup install policy](setup-no-backup-policy-seam.md). ## Dispatch skeleton diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index 06424ab1..251aad9b 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -88,14 +88,25 @@ own the hierarchy. Areas render in deterministic order: - Pi: `Extensions`, `Prompts`, `Skills` - Codex: `Skills`, `Hooks` -Codex's `Hooks` area covers `.codex/hooks.json` and -`.codex/hooks/run-sce-or-show-install-guidance.sh`. Doctor evaluates the -SCE-owned `.codex/hooks.json` fragment through the shared structural merge -service, so unrelated user handlers do not make a valid file mismatched. A -missing or mismatched Codex `Hooks` asset also carries a reminder that Codex requires reviewing and -trusting this project's hooks inside the Codex CLI before they take effect; -doctor diagnoses and can reinstall the on-disk file but cannot grant that -trust. +Codex's `Hooks` area covers `.codex/hooks/run-sce-or-show-install-guidance.sh` +plus one row per required `.codex/hooks.json` registration +(`UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`) +instead of one whole-file row. Doctor classifies each registration +structurally — `[PASS]` when present and canonical, `[MISS]` when absent, +`[FAIL]` when stale (an SCE-owned handler exists but does not match the +canonical one) or when the whole document cannot be structurally validated — +so unrelated user handlers never make a structurally valid document look +mismatched. `sce doctor --fix` repairs a structurally unhealthy +`.codex/hooks.json` through the same merge service used by `sce setup`. + +A structurally current registration is further gated on whether Codex has +actually marked it trusted, by reading (never writing) Codex's own durable +`$CODEX_HOME/config.toml` hook-trust state: `[PASS]` only when trusted; +`[WARN]` when enabled but never yet trusted, trusted against different +content, or explicitly disabled by the user's Codex config, or when trust +state could not be determined. This is the first `Integrations`-hierarchy use +of `[WARN]`, since Codex hook trust is a manual step in the Codex CLI that +`sce doctor --fix` cannot perform and never attempts. Healthy areas render one concise `[PASS]` row and never list installed files. The report and JSON payload still retain the complete inspected asset facts for @@ -113,9 +124,11 @@ child fact or missing-file problem. The compact text layout is intentionally a human-facing contract change. JSON field names, identity/path/problem detail, readiness classification, exit-code -semantics, stream ownership, diagnosis read-only behavior, and fix behavior -remain unchanged. Scripts should use `--format json` rather than parse compact -text. +semantics, and stream ownership remain unchanged by the text-layout redesign. +Diagnosis stays read-only, and fix behavior only ever repairs SCE-owned +structural content it can safely reinstall — it never writes trust or consent +state, on Codex or any other target. Scripts should use `--format json` rather +than parse compact text. See also [doctor operator contract](agent-trace-hook-doctor.md) and [CLI command surface](../cli/cli-command-surface.md). diff --git a/context/sce/setup-no-backup-policy-seam.md b/context/sce/setup-no-backup-policy-seam.md index 0e796d9c..8b3eba0b 100644 --- a/context/sce/setup-no-backup-policy-seam.md +++ b/context/sce/setup-no-backup-policy-seam.md @@ -19,7 +19,7 @@ - `merge_or_create_opencode_config`: `$schema` is SCE-owned and replaced from the generated document; the `plugin` array is merged as a set — any existing entry whose path starts with `./plugins/sce-` is dropped (structural ownership, so a plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. - Codex hook merge: the shared service validates the hook registry structure, recognizes ownership only when a handler command contains both `.codex/hooks/run-sce-or-show-install-guidance.sh` and the `sce hooks codex` command words, and replaces stale or duplicate owned handlers with exactly one current handler for each required registration. Valid user-owned fields, event groups, matcher groups, and handlers remain structurally unchanged; malformed or Codex-invalid documents remain untouched. Doctor uses the same fragment comparison so user additions do not appear as Codex drift. The merged bytes then flow through the same stage/atomic-swap choreography as every other asset, so this is a content-computation seam layered on the shared install policy, not a different write path. -- `sce doctor --fix` reuses this same per-asset install path for the two existing repairable merge targets rather than running its own repair logic: `crate::services::setup::repair_merge_target_asset` looks up the one embedded asset by relative path and reinstalls only it through `install_single_asset_with_rename`, so a drifted `.claude/settings.json` or `.opencode/opencode.json` is repaired by merge — every other installed asset and every user key is left untouched. Codex diagnosis already uses `codex_hook_config::fragment_is_current`; Codex trust-aware repair remains a later doctor task. `sce doctor` tells a merge target's drift apart from a legitimately extended file by SCE-fragment equality instead of the byte-exact `sha256` check every other integration asset uses (see [doctor human text contract](doctor-human-text-contract.md)). +- `sce doctor --fix` reuses this same per-asset install path for all three repairable merge targets rather than running its own repair logic: `crate::services::setup::repair_merge_target_asset` looks up the one embedded asset by relative path and reinstalls only it through `install_single_asset_with_rename`, so a drifted `.claude/settings.json`, `.opencode/opencode.json`, or structurally unhealthy `.codex/hooks.json` is repaired by merge — every other installed asset and every user key is left untouched. Codex diagnosis uses `codex_hook_config::diagnose_document`, which classifies each required registration structurally (`PresentAndCurrent`/`Missing`/`Stale`, or the whole document `Malformed`) instead of one whole-document equality check; a structurally current registration is separately, read-only gated on Codex's own hook-trust state (`codex_hook_trust::trust_readiness`), which `--fix` never touches since SCE cannot grant that trust (see [doctor human text contract](doctor-human-text-contract.md)). `sce doctor` tells a Claude/OpenCode merge target's drift apart from a legitimately extended file by SCE-fragment equality instead of the byte-exact `sha256` check every other integration asset uses. ## Scope boundary From 39f48e86daf2e1e9562ff8783ad809bf128b68ad Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 11:20:14 +0200 Subject: [PATCH 15/20] config: Parameterize generated skill input references for Codex Keep the shared workflow renderer target-neutral while replacing literal `$ARGUMENTS` in Codex skill prose with an explicit invocation-input phrase. Thread the target-specific reference through skill bodies and output documents, preserve command-mode substitution, and update architecture and plan records to describe the intentional cross-target divergence. Ref: context/plans/codex-cli-integration.md T23 Co-authored-by: SCE --- config/pkl/base/workflow-brownfield.pkl | 18 ++++++----- config/pkl/base/workflow-change-to-plan.pkl | 6 ++-- config/pkl/base/workflow-commit.pkl | 6 ++-- config/pkl/base/workflow-content.pkl | 30 ++++++++++++++----- config/pkl/base/workflow-handover.pkl | 19 ++++++------ config/pkl/renderers/claude-content.pkl | 2 +- config/pkl/renderers/codex-content.pkl | 7 ++++- .../renderers/generation-contract-check.pkl | 29 +++++++++++++++++- config/pkl/renderers/opencode-content.pkl | 2 +- config/pkl/renderers/pi-content.pkl | 2 +- config/pkl/renderers/workflow-composite.pkl | 26 ++++++++++------ context/architecture.md | 10 +++---- context/overview.md | 4 +-- context/plans/codex-cli-integration.md | 9 ++++-- 14 files changed, 117 insertions(+), 53 deletions(-) diff --git a/config/pkl/base/workflow-brownfield.pkl b/config/pkl/base/workflow-brownfield.pkl index 06b587b6..c7acedac 100644 --- a/config/pkl/base/workflow-brownfield.pkl +++ b/config/pkl/base/workflow-brownfield.pkl @@ -64,10 +64,10 @@ local titleAndPurpose = model.semanticReference.apply( scopeStatement + "\n\n" ) -local renderSkillBody = (mode: model.WorkflowRenderMode) -> """ +local renderSkillBody = (mode: model.WorkflowRenderMode, argumentsReference: String) -> """ \(titleAndPurpose.render.apply(mode))## Input - `$ARGUMENTS` is `[rebuild] [path ...]`. Parse it into two parts before any + `\(argumentsReference)` is `[rebuild] [path ...]`. Parse it into two parts before any investigation: - An optional leading literal token `rebuild`. Its presence, and only its @@ -77,7 +77,7 @@ local renderSkillBody = (mode: model.WorkflowRenderMode) -> """ Parsing rules: - - Empty `$ARGUMENTS` is valid and selects additive mode with no extra paths. + - Empty `\(argumentsReference)` is valid and selects additive mode with no extra paths. - `rebuild` is recognized only as the first token. In any later position it is a path. - Never infer `rebuild` from conversation content, repository state, or the @@ -374,7 +374,7 @@ local structuredCommand = new model.StructuredWorkflowDocument { } } body = new model.WorkflowBody { - render = (mode: model.WorkflowRenderMode) -> renderSkillBody.apply(mode) + render = (mode: model.WorkflowRenderMode) -> renderSkillBody.apply(mode, "$ARGUMENTS") } } @@ -383,7 +383,7 @@ local SKILL = structuredCommand.render.apply("package", "").text /// Custom string delimiters: the fact-ledger table row escapes its column /// separators as `\|`, which a default Pkl string would reject as an unknown /// escape sequence. -local OUTPUT_MD = #""" +local OUTPUT_MD = (argumentsReference: String) -> #""" # Brownfield output layouts Use only the applicable layout. Values come from internal workflow state. @@ -400,7 +400,7 @@ local OUTPUT_MD = #""" Problem: {unrecognized token, misplaced `rebuild`, or unreadable path} - Received: `{$ARGUMENTS}` + Received: `{\#(argumentsReference)}` Nothing was investigated and nothing was written. ``` @@ -525,16 +525,18 @@ local brownfieldPackage = new model.SkillPackage { title = "SCE Brownfield" documents = model.packageDocuments.apply(new Listing { model.makeDocument.apply("SKILL.md", SKILL) - model.makeDocument.apply("references/output.md", OUTPUT_MD) + model.makeDocument.apply("references/output.md", OUTPUT_MD.apply("$ARGUMENTS")) }) } structuredComposite = new model.StructuredCompositeSource { command = structuredCommand + argumentDependentCommandBody = (argumentsReference: String) -> renderSkillBody.apply("composite", argumentsReference) + argumentReferenceOutputDocument = (argumentsReference: String) -> model.makeDocument.apply("references/output.md", OUTPUT_MD.apply(argumentsReference)) phases = new Listing {} internalDocuments = new Listing {} outputDocuments = new Listing { - model.makeDocument.apply("Brownfield output layouts", OUTPUT_MD) + model.makeDocument.apply("Brownfield output layouts", OUTPUT_MD.apply("$ARGUMENTS")) } } diff --git a/config/pkl/base/workflow-change-to-plan.pkl b/config/pkl/base/workflow-change-to-plan.pkl index 8f576898..efa2507d 100644 --- a/config/pkl/base/workflow-change-to-plan.pkl +++ b/config/pkl/base/workflow-change-to-plan.pkl @@ -1,6 +1,6 @@ import "workflow-content.pkl" as model -changeToPlanSkillBody = """ +changeToPlanSkillBody = (argumentsReference: String) -> """ # SCE Change to Plan ## Purpose @@ -49,12 +49,12 @@ as the workflow's final response. ## Input -`$ARGUMENTS` is the change request, in free-form prose. +`\(argumentsReference)` is the change request, in free-form prose. - The change request is required. - It may describe a new plan or a change to an existing plan. Do not resolve which one applies; step 2 owns that decision. -When `$ARGUMENTS` is empty, report that a change request is required, state the expected argument, and stop. Do not infer a change request from the repository state or the conversation. +When `\(argumentsReference)` is empty, report that a change request is required, state the expected argument, and stop. Do not infer a change request from the repository state or the conversation. Pass the change request to step 2 unmodified. Do not restate, summarize, or pre-scope it. diff --git a/config/pkl/base/workflow-commit.pkl b/config/pkl/base/workflow-commit.pkl index bfa6822e..bd15c935 100644 --- a/config/pkl/base/workflow-commit.pkl +++ b/config/pkl/base/workflow-commit.pkl @@ -476,7 +476,7 @@ local renderCommitMessageStyle = (mode: model.WorkflowRenderMode) -> """ - Overly playful tone in serious bug-fix or architectural change. """ -local commitSkillBody = """ +local commitSkillBody = (argumentsReference: String) -> """ # SCE Commit ## Purpose @@ -517,7 +517,7 @@ as the workflow's final response. ## Input -`$ARGUMENTS` is optional. Split it into two parts before invoking the skill: +`\(argumentsReference)` is optional. Split it into two parts before invoking the skill: `[mode-token] [commit context]` @@ -531,7 +531,7 @@ A `mode-token` selects the bypass path. Its absence selects the regular path. Do not infer the bypass path from anything else — not from the commit context, not from repository state, and not from the conversation. -Empty `$ARGUMENTS` is valid. It selects the regular path with no commit +Empty `\(argumentsReference)` is valid. It selects the regular path with no commit context, and commit intent is inferred from the staged changes alone. Pass `commit context` to the **Atomic commit phase** unmodified. Do not restate, diff --git a/config/pkl/base/workflow-content.pkl b/config/pkl/base/workflow-content.pkl index c247f757..97623577 100644 --- a/config/pkl/base/workflow-content.pkl +++ b/config/pkl/base/workflow-content.pkl @@ -96,13 +96,29 @@ class StructuredCompositeSource { /// Complete workflow body for a phase-reference package. When present, the /// composite renderer uses this body instead of inlining phase instructions. - compositeSkillBody: String? = null + /// Parameterized by the target's arguments-reference token so skill-mode + /// prose can name the invocation input without a literal `$ARGUMENTS` for a + /// target whose harness does not substitute it. + compositeSkillBody: ((String) -> String)? = null + + /// The composite command body's own `## Input`/`## Workflow` text, for a + /// phase-free workflow (no `compositeSkillBody`) whose command body names the + /// invocation input directly, parameterized the same way. When present, the + /// generic composite renderer uses this instead of `command`'s mode-only + /// render, keeping that renderer's own shared preamble/appendix wrapper. + argumentDependentCommandBody: ((String) -> String)? = null /// Package-local documents read only when their owning workflow step runs. /// Phase-based workflows include output.md here; phase-free workflows keep /// using outputDocuments for their sole reference. referenceDocuments: Listing = new Listing {} + /// The workflow's own `references/output.md`, for a workflow whose output + /// layouts quote the received invocation input back to the user. Present + /// only where that quoting occurs; other workflows' output.md carries no + /// argument-dependent content and stays a plain `referenceDocuments` entry. + argumentReferenceOutputDocument: ((String) -> WorkflowDocument)? = null + /// Phase documents still rendered as a trailing appendix. A module that /// states every phase inside the step that runs it lists none, and composite /// rendering then emits no appendix at all. @@ -182,7 +198,7 @@ validation, stops, and terminal user-visible output. /// four phase-based workflows. Target renderers add only supported entrypoint /// frontmatter; all operational and persisted-document content remains /// target-neutral. -nextTaskSkillBody = """ +nextTaskSkillBody = (argumentsReference: String) -> """ # SCE Next Task ## Purpose @@ -231,7 +247,7 @@ Never expose an internal phase result as the workflow's final response. ## Input -Parse `$ARGUMENTS` into three positional parts before invoking any phase: +Parse `\(argumentsReference)` into three positional parts before invoking any phase: [task-id] [auto-approve] @@ -243,7 +259,7 @@ Resolve `auto-approve` even when `task-id` is absent. A token matching neither a task ID nor `approved` is an error. Report the unrecognized token and the expected arguments, and stop. Do not guess its meaning. -Pass each part only to the phase that owns it. Do not forward the raw `$ARGUMENTS` string to a phase. +Pass each part only to the phase that owns it. Do not forward the raw `\(argumentsReference)` string to a phase. Every `{plan-path}` and `{candidate-path}` emitted anywhere in this workflow is the path resolved in step 1 (`plan.path`, or an entry of `candidates`), so every emitted command is directly runnable. @@ -369,7 +385,7 @@ Stop. - Preserve completed work and evidence when a later phase fails. """ -validateSkillBody = """ +validateSkillBody = (argumentsReference: String) -> """ # SCE Validate ## Purpose @@ -414,13 +430,13 @@ Never expose an internal phase result as the workflow's final response. ## Input -`$ARGUMENTS` is the plan name or plan path. +`\(argumentsReference)` is the plan name or plan path. - The plan name or path is required. - Resolve exactly one plan. Do not invent a plan from the conversation or from incomplete nearby work. -When `$ARGUMENTS` is empty, report that a plan name or path is required, state +When `\(argumentsReference)` is empty, report that a plan name or path is required, state the expected argument, and stop. Do not infer the plan from repository state or the conversation. diff --git a/config/pkl/base/workflow-handover.pkl b/config/pkl/base/workflow-handover.pkl index 132adf6a..481db1c1 100644 --- a/config/pkl/base/workflow-handover.pkl +++ b/config/pkl/base/workflow-handover.pkl @@ -92,12 +92,12 @@ local renderPersistedFormatBody = """ one. """ -local renderSkillBody = (mode: model.WorkflowRenderMode) -> """ +local renderSkillBody = (mode: model.WorkflowRenderMode, argumentsReference: String) -> """ \(titleAndPurpose.render.apply(mode))## Input - `$ARGUMENTS` is optional and selects the mode: + `\(argumentsReference)` is optional and selects the mode: - - Empty `$ARGUMENTS` selects **writer mode**. + - Empty `\(argumentsReference)` selects **writer mode**. - Exactly one whitespace-trimmed path argument selects **loader mode**. - Anything else — more than one token, or a token that is clearly not a path — is invalid input: state the expected usage (`/handover` or @@ -241,13 +241,13 @@ local structuredCommand = new model.StructuredWorkflowDocument { } } body = new model.WorkflowBody { - render = (mode: model.WorkflowRenderMode) -> renderSkillBody.apply(mode) + render = (mode: model.WorkflowRenderMode) -> renderSkillBody.apply(mode, "$ARGUMENTS") } } local SKILL = structuredCommand.render.apply("package", "").text -local OUTPUT_MD = """ +local OUTPUT_MD = (argumentsReference: String) -> """ # Handover output layouts Use only the applicable layout. Values come from the resolved mode and @@ -261,7 +261,7 @@ local OUTPUT_MD = """ `/handover` takes no arguments (writer mode) or exactly one handover path (loader mode): `/handover context/handovers/.md`. - Received: `{$ARGUMENTS}` + Received: `{\(argumentsReference)}` ``` ## Writer blocked @@ -352,20 +352,21 @@ local handoverPackage = new model.SkillPackage { documents = model.packageDocuments.apply(new Listing { model.makeDocument.apply("SKILL.md", SKILL) model.makeDocument.apply("references/handover-template.md", renderPersistedFormatBody) - model.makeDocument.apply("references/output.md", OUTPUT_MD) + model.makeDocument.apply("references/output.md", OUTPUT_MD.apply("$ARGUMENTS")) }) } structuredComposite = new model.StructuredCompositeSource { command = structuredCommand + argumentDependentCommandBody = (argumentsReference: String) -> renderSkillBody.apply("composite", argumentsReference) referenceDocuments = new Listing { model.makeDocument.apply("references/handover-template.md", renderPersistedFormatBody) - model.makeDocument.apply("references/output.md", OUTPUT_MD) } + argumentReferenceOutputDocument = (argumentsReference: String) -> model.makeDocument.apply("references/output.md", OUTPUT_MD.apply(argumentsReference)) phases = new Listing {} internalDocuments = new Listing {} outputDocuments = new Listing { - model.makeDocument.apply("Handover output layouts", OUTPUT_MD) + model.makeDocument.apply("Handover output layouts", OUTPUT_MD.apply("$ARGUMENTS")) } } diff --git a/config/pkl/renderers/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index 9972c29a..a69bcd03 100644 --- a/config/pkl/renderers/claude-content.pkl +++ b/config/pkl/renderers/claude-content.pkl @@ -96,7 +96,7 @@ commands { /// Claude has six command-routed workflow packages plus the standalone /// decision-writing package used internally during synchronization. skillDocuments { - for (path, document in workflowResults.skillDocuments.apply("compatibility: claude\n")) { + for (path, document in workflowResults.skillDocuments.apply("compatibility: claude\n", "$ARGUMENTS")) { [path] = document } for (path, document in decision.skillDocuments.apply("compatibility: claude\n")) { diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl index 3f5bb841..23488e90 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -2,10 +2,15 @@ import "../base/decision-skill.pkl" as decision import "common.pkl" as common import "workflow-composite.pkl" as workflowResults +/// Current upstream Codex skill loading does not provide Claude/OpenCode-style +/// `$ARGUMENTS` substitution into skill Markdown, so Codex skill bodies name the +/// invocation input in prose instead of the literal, unsubstituted token. +local codexArgumentsReference = "invocation input" + /// Codex has no command-routed entrypoints — it discovers skills directly, with /// no per-target frontmatter beyond the shared description, matching Pi. skillDocuments { - for (path, document in workflowResults.skillDocuments.apply("")) { + for (path, document in workflowResults.skillDocuments.apply("", codexArgumentsReference)) { [path] = document } for (path, document in decision.skillDocuments.apply("")) { diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index 1f787338..235fae73 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -77,6 +77,19 @@ local assertCodexHookInvocationContract = (artifacts: Mapping) -> ) "Codex hook invocation: four registrations use safe repository-root resolution" else throw("Codex hook invocation must resolve the Git root safely and preserve the exact four registrations") +/// Current upstream Codex skill loading provides no Claude/OpenCode-style +/// `$ARGUMENTS` substitution, so a literal, unsubstituted token in generated +/// Codex skill Markdown would be model-visible noise rather than the invoked +/// input. +local assertCodexSkillsExcludeArguments = (artifacts: Mapping) -> + if ( + artifacts.every((path, text) -> + !path.startsWith("config/.agents/skills/") + || !text.contains("$ARGUMENTS") + ) + ) "Codex skills: no literal $ARGUMENTS in generated Markdown" + else throw("generated Codex skill Markdown must not contain the literal token $ARGUMENTS") + hidden workflowDocuments = new Mapping { for (path, document in opencode.skillDocuments) { ["config/.opencode/skills/\(path)"] = document.text @@ -603,6 +616,15 @@ hidden assertNextTaskReportOwnership = (documents: Mapping) -> ) "sce-next-task report ownership: sync report is not duplicated in output.md" else throw("sce-next-task output.md must not duplicate the context-sync report contract") +/// Codex names the invocation input in prose instead of a literal `$ARGUMENTS` +/// (its harness does not substitute it), so exactly these two output-layout +/// references — the only ones quoting the received input back to the user — +/// legitimately diverge from Pi/Claude/OpenCode's text there. +local codexArgumentDependentReferencePaths = new Listing { + "sce-handover/references/output.md" + "sce-brownfield/references/output.md" +} + hidden assertTargetNeutralReferences = (documents: Mapping) -> let (opencodeReferences = new Mapping { for (path, text in documents) { @@ -615,7 +637,11 @@ hidden assertTargetNeutralReferences = (documents: Mapping) -> opencodeReferences.every((relativePath, text) -> documents["config/.claude/skills/" + relativePath] == text && documents["config/.pi/skills/" + relativePath] == text - && (!documents.containsKey("config/.agents/skills/" + relativePath) || documents["config/.agents/skills/" + relativePath] == text) + && ( + !documents.containsKey("config/.agents/skills/" + relativePath) + || documents["config/.agents/skills/" + relativePath] == text + || codexArgumentDependentReferencePaths.contains(relativePath) + ) ) ) "target-neutral references: Pi, Claude, and OpenCode bodies match" else throw("target-neutral package references differ between Pi, Claude, and OpenCode") @@ -812,6 +838,7 @@ hidden assertValidateExcludesDecisionAndPlanSync = (documents: Mapping) -> contractChecks { ["artifact-paths"] = assertExactArtifactPaths.apply(generatedArtifacts) ["codex-hook-invocation"] = assertCodexHookInvocationContract.apply(generatedArtifacts) + ["codex-skills-exclude-arguments"] = assertCodexSkillsExcludeArguments.apply(generatedArtifacts) ["optional-workflow-manifest"] = assertOptionalWorkflowManifest.apply(generatedArtifacts) ["workflow-references"] = assertWorkflowReferences.apply(workflowDocuments) ["workflow-helper-composition"] = assertWorkflowHelperComposition.apply(compositeWorkflowDocuments) diff --git a/config/pkl/renderers/opencode-content.pkl b/config/pkl/renderers/opencode-content.pkl index 7b313e1b..3461fec3 100644 --- a/config/pkl/renderers/opencode-content.pkl +++ b/config/pkl/renderers/opencode-content.pkl @@ -67,7 +67,7 @@ commands { /// relative paths stay flattened for deterministic generation, and only skill /// entrypoints carry OpenCode-supported metadata. skillDocuments { - for (path, document in workflowResults.skillDocuments.apply("compatibility: \(metadata.skillCompatibility)\n")) { + for (path, document in workflowResults.skillDocuments.apply("compatibility: \(metadata.skillCompatibility)\n", "$ARGUMENTS")) { [path] = document } for (path, document in decision.skillDocuments.apply("compatibility: \(metadata.skillCompatibility)\n")) { diff --git a/config/pkl/renderers/pi-content.pkl b/config/pkl/renderers/pi-content.pkl index 7152aec5..db1ee501 100644 --- a/config/pkl/renderers/pi-content.pkl +++ b/config/pkl/renderers/pi-content.pkl @@ -15,7 +15,7 @@ commands { /// writing package used internally during synchronization, with no target- /// specific skill frontmatter. skillDocuments { - for (path, document in workflowResults.skillDocuments.apply("")) { + for (path, document in workflowResults.skillDocuments.apply("", "$ARGUMENTS")) { [path] = document } for (path, document in decision.skillDocuments.apply("")) { diff --git a/config/pkl/renderers/workflow-composite.pkl b/config/pkl/renderers/workflow-composite.pkl index 02d1fbf8..97ebaf76 100644 --- a/config/pkl/renderers/workflow-composite.pkl +++ b/config/pkl/renderers/workflow-composite.pkl @@ -40,8 +40,11 @@ local renderStructuredPhase = (document: model.StructuredWorkflowDocument) -> local renderStructuredInternalDocument = (document: model.WorkflowDocument) -> "## Internal persisted-document format: \(document.path)\n\n" + document.text -local renderCanonicalWorkflow = (workflow: CompositeWorkflow) -> - workflow.structuredSource.command.render.apply("composite", "").text +local renderCanonicalWorkflow = (workflow: CompositeWorkflow, argumentsReference: String) -> + if (workflow.structuredSource.argumentDependentCommandBody != null) + workflow.structuredSource.argumentDependentCommandBody.apply(argumentsReference) + else + workflow.structuredSource.command.render.apply("composite", "").text local siblingSceWorkflowRule = (workflow: CompositeWorkflow) -> if (workflow.slug == "next-task" || workflow.slug == "validate") @@ -78,7 +81,7 @@ local renderInternalDocuments = (workflow: CompositeWorkflow) -> /// section between the preamble and the first instruction. A phase appendix is /// emitted only for phases a module still lists; once every phase is stated at /// the step that runs it, the listing is empty and no appendix heading renders. -local renderSkill = (workflow: CompositeWorkflow, extraFrontmatterLines: String) -> new model.WorkflowDocument { +local renderSkill = (workflow: CompositeWorkflow, extraFrontmatterLines: String, argumentsReference: String) -> new model.WorkflowDocument { path = "SKILL.md" text = if (workflow.structuredSource.compositeSkillBody != null) """ @@ -88,7 +91,7 @@ local renderSkill = (workflow: CompositeWorkflow, extraFrontmatterLines: String) \(workflow.description) \(extraFrontmatterLines)--- - \(workflow.structuredSource.compositeSkillBody) + \(workflow.structuredSource.compositeSkillBody.apply(argumentsReference)) """ else new Listing { @@ -125,7 +128,7 @@ local renderSkill = (workflow: CompositeWorkflow, extraFrontmatterLines: String) \(model.helperSkillCompositionRule) """ - renderCanonicalWorkflow.apply(workflow) + renderCanonicalWorkflow.apply(workflow, argumentsReference) when (workflow.structuredSource.phases.length > 0) { "## Embedded phase behavior\n\n" + renderPhases.apply(workflow) } @@ -222,11 +225,16 @@ workflows = new Mapping { } /// Each target renders the same package-relative workflow documents and differs -/// only in the frontmatter its skill entrypoint carries. -hidden skillDocuments = (extraSkillFrontmatterLines: String) -> new Mapping { +/// only in the frontmatter its skill entrypoint carries and, for a target whose +/// harness does not substitute `$ARGUMENTS` into skill Markdown, the prose that +/// names the skill's invocation input. +hidden skillDocuments = (extraSkillFrontmatterLines: String, argumentsReference: String) -> new Mapping { for (_, workflow in workflows) { - ["\(workflow.skillSlug)/SKILL.md"] = renderSkill.apply(workflow, extraSkillFrontmatterLines) - when (workflow.structuredSource.referenceDocuments.length == 0) { + ["\(workflow.skillSlug)/SKILL.md"] = renderSkill.apply(workflow, extraSkillFrontmatterLines, argumentsReference) + when (workflow.structuredSource.argumentReferenceOutputDocument != null) { + ["\(workflow.skillSlug)/references/output.md"] = workflow.structuredSource.argumentReferenceOutputDocument.apply(argumentsReference) + } + when (workflow.structuredSource.referenceDocuments.length == 0 && workflow.structuredSource.argumentReferenceOutputDocument == null) { ["\(workflow.skillSlug)/references/output.md"] = new model.WorkflowDocument { path = "references/output.md" text = workflow.outputText diff --git a/context/architecture.md b/context/architecture.md index b69499bc..ebc5cf4f 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -8,7 +8,7 @@ Authored config content is standardized around one canonical Pkl source model wi Current location for canonical workflow content primitives: -- `config/pkl/base/workflow-content.pkl` (shared workflow command and self-contained skill-package document model, including structured composite sources with optional canonical `compositeSkillBody` plus deterministic `referenceDocuments`, alongside the typed package/composite rendering primitives; workflow-specific bodies and package-local documents remain in the canonical workflow modules rather than being catalogued here) +- `config/pkl/base/workflow-content.pkl` (shared workflow command and self-contained skill-package document model, including structured composite sources with optional canonical `compositeSkillBody` and `argumentDependentCommandBody` — both functions of the target's arguments-reference token, letting skill-mode prose name the invocation input instead of a literal `$ARGUMENTS` for a target whose harness does not substitute it — plus deterministic `referenceDocuments` and an optional, similarly parameterized `argumentReferenceOutputDocument` for a workflow whose output layouts quote that same input back to the user, alongside the typed package/composite rendering primitives; workflow-specific bodies and package-local documents remain in the canonical workflow modules rather than being catalogued here) - `config/pkl/base/workflow-catalog.pkl` (typed six-workflow catalog owning command and skill slugs, titles, descriptions, argument hints, OpenCode routing roles, Claude allowed-tool metadata, and the per-workflow `optional` flag that defaults to `false` and is `true` only for `brownfield`) - `config/pkl/base/optional-workflow-manifest.pkl` (install-time projection of the catalog's optional records into the generated `config/optional-workflows.json` manifest — `schemaVersion` plus one entry per optional workflow carrying `id`, `title`, `description`, `commandSlug`, and `skillSlug`. Optionality never affects generation: all six workflows are still generated for all four targets, so the manifest exists solely to carry optional-workflow identity out of Pkl for install-time and doctor-time consumers) - `config/pkl/base/decision-skill.pkl` (canonical standalone `sce-decision` package outside the workflow catalog; renders its decision gate, one-record and immutable-ADR rules, active-only reuse and creation-time status semantics, deterministic written/not-qualified/skipped/blocked handoff, and `references/adr-template.md` for all four targets without creating a command or prompt) @@ -46,17 +46,17 @@ The scaffold provides stable canonical content-unit identifiers and reusable tar Renderer modules apply target-specific metadata/frontmatter rules while reusing canonical content bodies: - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. -- Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. +- Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. Codex also differs from all three other targets in the arguments-reference token it passes to `skillDocuments`: OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input` (its skill loading provides no such substitution), so Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by that token alone. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers; `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. -- Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`). It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers; `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. +- Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` and `skillDocuments` additionally take an `argumentsReference` string naming the invocation input in skill-mode prose — `$ARGUMENTS` for OpenCode, Claude, and Pi, whose harnesses substitute it, or a plain-prose token for a target whose skill loading does not (Codex passes `invocation input`); `renderCommand`'s thin wrapper text is unaffected and always states the literal `$ARGUMENTS` its harness substitutes. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). - Target renderers remain responsible for formatting target-supported metadata. OpenCode metadata owns thin-agent presentation and compatibility while deriving the ordered permission blocks — non-SCE wildcard allow, `sce-*` wildcard deny, then catalog-owned workflow allows — from catalog role assignments; OpenCode command routing derives the same role and skill identity from the catalog. Claude metadata derives command tools from catalog records. Pi has no metadata module because it adds no target-specific frontmatter. - `config/pkl/renderers/metadata-coverage-check.pkl` derives commands and exact package-relative workflow-document expectations from the typed catalog and the workflow-document inventories, adds the unchanged phase-free and decision-package expectations, verifies every command's one-to-one workflow-skill route for OpenCode/Claude/Pi, asserts the same exact skill-document inventory for Codex (no command-route check, since Codex has no commands), and forces every rendered document and target metadata lookup to evaluate. - `config/pkl/renderers/generation-contract-check.pkl` independently derives the complete expected artifact paths from those target document inventories plus explicitly retained non-workflow assets, compares them with `generate.pkl`'s `output.files`, and requires the exact path count declared by the current generation contract — stated as a literal `expectedArtifactPathCount` inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts the generated `config/optional-workflows.json` against the catalog (`optional-workflow-manifest`): every optional workflow appears with its catalog title and both slugs, no core workflow id appears, and `schemaVersion` is present. It also checks that Codex's four hook registrations share the root-aware, quoted, no-`eval`, fail-open invocation contract. It also verifies that every required phase reference exists and is cited by its owning `SKILL.md`, scans generated workflow entrypoint `SKILL.md` documents for stale phase-skill slugs and unresolved package-local reference tokens while allowing package-local reference prose to mention its own persisted-format history, asserts the shared non-SCE helper-composition rule and SCE-scoped workflow prohibitions on every generated workflow skill, asserts the exact cross-target `sce-decision` paths plus its required gate, status, immutability, handoff, and ADR-template content, permits `sce-decision` references only in `sce-next-task`, verifies the exact catalog-derived OpenCode skill permission order and Code-only OpenCode decision permission, asserts every explicit `sce-*` allow names an emitted OpenCode skill artifact, asserts the generated `sce-handover` `SKILL.md` covers both writer- and loader-mode content on all four targets, asserts the generated `sce-brownfield` `SKILL.md` still carries the bootstrap gate, documentation-discovery sweep, no-network rule, sub-`50` blocking threshold, always-disclosed contradiction contract, and additive-vs-`rebuild` write rule on all four targets, rejects two or more consecutive blank lines in generated workflow entrypoint `SKILL.md` documents (`no-blank-line-runs`), and rejects any generated `SKILL.md` that reproduces one of its sibling `references/output.md` fenced layouts verbatim (`output-dedup`, matched fence markers included), plus twenty semantic checks for layout-heading resolution, package-local path existence, forbidden validate/commit files, consolidated atomic-commit content, next-task report ownership, cross-target reference parity, stale synchronization wording, observational validation, OpenCode permission-artifact integrity, plan-review sync-debt-recovery reference wording (`plan-review-sync-debt-recovery`, asserting the generated `sce-next-task/references/plan-review.md` states both the sync-debt recovery and legacy-migration-failure behavior), the compact completed-task record model (four checks replacing the removed `handoff-identity-fields` persisted-handoff check: `compact-plan-template-schema`, asserting the generated plan-template's new-task and completion examples use the compact `Scope`/`Done when`/`Verify`/`Result`/`Files changed`/`Context impact`/`Context synchronization` fields and name none of the removed `Goal`/`Boundaries (in/out of scope)`/`Verification notes`/`Implementation evidence`/`Verification evidence`/`Context synchronization handoff` fields; `next-task-compact-completion-writing`, asserting `task-execution.md` records execution facts directly on the completed task with no separate handoff/evidence construction; `plan-review-reads-completed-record`, asserting `plan-review.md`'s sync-debt recovery reads the completed task record directly by plan path and task ID rather than a persisted handoff; and `context-sync-validates-task-record`, asserting `context-sync.md` validates the completed task record rather than a persisted handoff), the `/next-task` sync-debt-recovery branch's reference-before-invocation ordering (`sync-debt-recovery-branch`, asserting its citation of `references/context-sync.md` precedes any instruction to run the Task context synchronization phase), the synchronization-debt scan's all-completed-task scope (`plan-review-all-tasks-scope`, asserting `plan-review.md` covers every completed task with no surviving position-relative wording), the sync-debt-recovery branch's blocked-outcome layout routing (`sync-debt-blocked-routing`, asserting its `blocked` branch cites the **Context synchronization blocked** layout rather than **Review blocked**), and the `sce-validate` decision/plan-sync exclusion (`validate-decision-sync-boundary`, asserting no generated `sce-validate` document contains a `sce-decision` reference or plan-context-sync wording). Checked-in negative fixtures continue to prove the existing semantic contract failures, while the Codex invocation assertion is exercised by the dedicated generated command check. - OpenCode, Claude, Pi, and Codex renderers expose flattened `{skill slug}/{package-relative path}` skill documents consumed by `config/pkl/generate.pkl` (OpenCode, Claude, and Pi also expose command documents; Codex exposes none); every target's flattened skill-document inventory contains `SKILL.md` and `references/output.md` for each workflow slug plus `sce-decision/SKILL.md` and `sce-decision/references/adr-template.md`. -- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the Codex target tree under `config/.agents/skills/` (the same workflow-package inventories as Pi, byte-identical bodies, no commands, no agents, no settings/plugin manifest) plus its separate `.codex/hooks.json` and `.codex/hooks/run-sce-or-show-install-guidance.sh` hook-registration outputs; the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. +- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode's six workflow commands, four phase-based workflow packages with package-local phase and supporting references, and two phase-free workflow packages (handover also has its persisted-format template), standalone two-file decision package, and two thin routing agents; Claude's six thin commands, the same workflow-package inventories, and standalone decision package with no agents; Claude project settings and hook helper; shared bash-policy preset assets; OpenCode plugin entrypoints (`sce-bash-policy.ts` and `sce-agent-trace.ts`); generated OpenCode `opencode.json`; the Pi target tree (six thin workflow prompts, the same four phase-based packages with package-local references and two phase-free workflow packages, with handover's persisted-format template, the standalone two-file decision package, and the extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts`); the Codex target tree under `config/.agents/skills/` (the same workflow-package inventories as Pi, bodies matching Pi's byte-for-byte except for the arguments-reference token, no commands, no agents, no settings/plugin manifest) plus its separate `.codex/hooks.json` and `.codex/hooks/run-sce-or-show-install-guidance.sh` hook-registration outputs; the generated `sce/config.json` schema artifact; and the optional-workflow manifest at `config/optional-workflows.json`. The removed `config/automated/.opencode` profile has no generator ownership or output mappings. - Generated-file warning markers are not injected by the generator: Markdown outputs render deterministic frontmatter + body, and shared library outputs are emitted without a leading generated warning header. - `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, rejects the repository-local `config/pkl/rendered` evaluation artifact before generation, rejects committed target trees, the generated SCE schema, and `cli/assets/generated`, evaluates exact metadata and generation contracts, confirms the existing semantic negative fixtures fail with their contract diagnostics, then delegates two-pass generation, input checks, and payload inventories to `scripts/produce-cli-generated-input.sh`. It projects the producer inventory only to preserve the established report digest path format; it does not rehash generated files. Required-path checks remain fast surface diagnostics, the Pkl contract owns exact complete-path coverage, and forbidden-output checks reject removed generator surfaces. diff --git a/context/overview.md b/context/overview.md index 5c589a4e..22cc03d6 100644 --- a/context/overview.md +++ b/context/overview.md @@ -76,7 +76,7 @@ The setup command parser/dispatch now also supports composable setup+hooks runs ## Repository model -- Author the six SCE workflows in `config/pkl/base/workflow-{change-to-plan,next-task,validate,commit,handover,brownfield}.pkl` using the self-contained package and structured-rendering model in `workflow-content.pkl`; all six render package and composite forms directly from typed semantic values. `workflow-context-sync.pkl` renders task and plan context-sync skills from explicit role data ordered as named frontmatter, purpose, input, workflow, boundaries, and completion sections, renders their reports through named introduction, status-variant, and rules sections driven by a typed synchronization-report role, and exposes both roles as mode-aware structured phases for their owning workflow compositions. Keep command slug, skill slug, title, description, argument hint, OpenCode role, and Claude allowed tools in the typed `workflow-catalog.pkl`. OpenCode, Claude, Pi, and Codex all compose each full workflow into one skill and one `references/output.md` through the shared `workflow-composite.pkl` renderer, which is parameterized only by the extra frontmatter each target supports. +- Author the six SCE workflows in `config/pkl/base/workflow-{change-to-plan,next-task,validate,commit,handover,brownfield}.pkl` using the self-contained package and structured-rendering model in `workflow-content.pkl`; all six render package and composite forms directly from typed semantic values. `workflow-context-sync.pkl` renders task and plan context-sync skills from explicit role data ordered as named frontmatter, purpose, input, workflow, boundaries, and completion sections, renders their reports through named introduction, status-variant, and rules sections driven by a typed synchronization-report role, and exposes both roles as mode-aware structured phases for their owning workflow compositions. Keep command slug, skill slug, title, description, argument hint, OpenCode role, and Claude allowed tools in the typed `workflow-catalog.pkl`. OpenCode, Claude, Pi, and Codex all compose each full workflow into one skill and one `references/output.md` through the shared `workflow-composite.pkl` renderer, which is parameterized by the extra frontmatter each target supports and by an arguments-reference token: OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex — whose skill loading provides no such substitution — passes a plain-prose token (`invocation input`) so its generated skill Markdown never contains the literal, unsubstituted `$ARGUMENTS`. - Apply target-specific metadata/rendering in `config/pkl/renderers/`. - Use `config/pkl/generate.pkl` to emit the logical `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, `config/.agents/**`, and SCE schema layouts only under temporary generation roots, Cargo `OUT_DIR`, or packaging fallbacks. - Treat generated outputs as ephemeral build/package artifacts, never repository editing surfaces. @@ -109,7 +109,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages (byte-identical bodies to Pi's) plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely reaches the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. +- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter; its bodies match Pi's except where prose names the skill's invocation input (`invocation input` in place of Pi's substituted `$ARGUMENTS`), and its `sce-handover`/`sce-brownfield` `references/output.md` quote that same token back to the user in their invalid-usage example. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely reaches the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index bd54e137..25320ac0 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -421,13 +421,18 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/sce/doctor-human-text-contract.md` (already named under this plan's Context sync list for "Codex integration group/area ordering") now also needs the new per-registration `.codex/hooks.json` reporting vocabulary and the trust-readiness dimension documented; the durable Codex runtime context should record that `sce doctor`/`sce doctor --fix` now diagnose and repair per-registration structural state and read (never write) Codex's own hook-trust bookkeeping. - Context synchronization: synced -- [ ] T23: `Render Codex skills with explicit skill-invocation input semantics` (status:todo) +- [x] T23: `Render Codex skills with explicit skill-invocation input semantics` (status:done) - Task ID: T23 - Scope: In — parameterize the smallest canonical Pkl workflow-content/composite rendering seam so skill-mode prose names the user-invoked change request and does not mention literal `$ARGUMENTS`, while command-mode wrappers retain `$ARGUMENTS` where their harness substitutes it; update generated Codex skill contract tests for `SKILL.md` and all package references and preserve Claude/OpenCode/Pi output behavior. Out — global post-render text replacement, duplicated workflows, and changes to Codex skill loading itself. - Dependencies: T22 - Done when: every generated Codex `.agents/skills/**/*.md` document is free of `$ARGUMENTS`, generated command entrypoints that support substitution remain unchanged, and the target-neutral workflow behavior remains semantically equivalent across all targets. - Verify: `nix run .#pkl-check-generated` - - Context synchronization: pending + - Completed: 2026-08-23 + - Files changed: `config/pkl/base/workflow-content.pkl`, `config/pkl/base/workflow-change-to-plan.pkl`, `config/pkl/base/workflow-commit.pkl`, `config/pkl/base/workflow-handover.pkl`, `config/pkl/base/workflow-brownfield.pkl`, `config/pkl/renderers/workflow-composite.pkl`, `config/pkl/renderers/codex-content.pkl`, `config/pkl/renderers/claude-content.pkl`, `config/pkl/renderers/opencode-content.pkl`, `config/pkl/renderers/pi-content.pkl`, `config/pkl/renderers/generation-contract-check.pkl` + - Result: Every live `$ARGUMENTS`-bearing skill-body source (`nextTaskSkillBody`, `validateSkillBody`, `changeToPlanSkillBody`, `commitSkillBody`, handover's and brownfield's `renderSkillBody`/`OUTPUT_MD`) became a function of a new `argumentsReference: String` parameter, substituting `\(argumentsReference)` (or `\#(argumentsReference)` inside brownfield's `#"""` raw-string `OUTPUT_MD`) for the bare `$ARGUMENTS` token everywhere it was already backtick-wrapped in the template, so Claude/OpenCode/Pi (passing the literal `"$ARGUMENTS"`) render byte-identical output while Codex (passing `"invocation input"`) never emits the literal token. `StructuredCompositeSource` gained three new optional function-typed fields: `compositeSkillBody` changed from `String?` to `((String) -> String)?`; a new `argumentDependentCommandBody` lets the generic composite renderer (used by handover/brownfield, which have no `compositeSkillBody`) substitute a parameterized body while keeping its own shared preamble/appendix wrapper intact — an initial attempt to instead give handover/brownfield a `compositeSkillBody` was reverted after it was found to silently drop their shared title/purpose/user-visible-output/composite-control-flow preamble (that generic wrapper text lives only in `renderSkill`'s fallback branch, not in `titleAndPurpose`'s composite-mode output, which is deliberately empty per `packageOnlyBlock`); a new `argumentReferenceOutputDocument` lets handover's and brownfield's `references/output.md` (which quotes the received input back to the user in an example transcript) vary per target instead of being baked once into the shared `referenceDocuments`/`outputDocuments` listings. `workflow-composite.pkl`'s `renderSkill`, `renderCanonicalWorkflow`, and `skillDocuments` all gained the threaded `argumentsReference` parameter; all four target-content Pkl files updated their `workflowResults.skillDocuments.apply(...)` call site accordingly (three pass `"$ARGUMENTS"`, Codex passes a new local `codexArgumentsReference = "invocation input"`). `generation-contract-check.pkl` gained a new `codex-skills-exclude-arguments` check asserting no `config/.agents/skills/**` document contains the literal `$ARGUMENTS`, and `assertTargetNeutralReferences` gained an explicit two-path allowlist (`sce-handover/references/output.md`, `sce-brownfield/references/output.md`) for the one legitimate Codex divergence that check's existing `containsKey` guard did not anticipate — discovered only by writing a standalone Pkl debug script that reconstructed the check's exact comparison logic per-path, since the check's thrown message ("differ between Pi, Claude, and OpenCode") is misleading for a failure that was actually in its Codex-tolerance clause. `decision-skill.pkl`'s own `skillDocuments` needed no change since it contains no `$ARGUMENTS` occurrences. Verified via direct diff against a detached-worktree baseline generation that Claude/OpenCode/Pi's entire generated skill trees (`SKILL.md` plus every `references/*.md`) are byte-for-byte unchanged, and that no file under generated `.agents/skills/**` contains `$ARGUMENTS` anywhere. + - Verify: `nix run .#pkl-check-generated` — passed: "Ephemeral Pkl generation passed: 135 files" (all fixture/contract checks, including the new `codex-skills-exclude-arguments` check, evaluated successfully). `nix flake check` — passed: "all checks passed!", covering `cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `pkl-generated`, `codex-hook-command`. + - Context impact: root — `context/architecture.md:51` states as fact that "Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's," which this task makes false (Codex's `references/output.md` for `sce-handover` and `sce-brownfield`, and every skill body's `## Input` prose, now legitimately diverges from Pi's). `context/architecture.md:11`'s description of `StructuredCompositeSource`'s "optional canonical `compositeSkillBody`" is also now incomplete given the two new sibling fields (`argumentDependentCommandBody`, `argumentReferenceOutputDocument`). + - Context synchronization: synced - [ ] T24: `Correct Codex Stop nullability, identifiers, and timestamps` (status:todo) - Task ID: T24 From 4c8623225123c3ab4ccb39e93621fe7ffd7a33cb Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 11:49:46 +0200 Subject: [PATCH 16/20] hooks: Handle nullable Codex stop events and identifier validation Codex hook persistence must distinguish a legitimate null assistant message from malformed identifiers and avoid writing fabricated epoch timestamps. Trim session and turn IDs before persistence, short-circuit null Stop events before opening the trace database, preserve explicit empty messages, and fail open when timestamp acquisition fails. Plan: codex-cli-integration (T24) Co-authored-by: SCE --- .../services/hooks/codex/apply_patch/mod.rs | 2 +- cli/src/services/hooks/codex/bash_policy.rs | 3 +- cli/src/services/hooks/codex/mod.rs | 254 +++++++++++++++- cli/src/services/hooks/codex/stop.rs | 278 ++++++++++++++++-- .../hooks/codex/user_prompt_submit.rs | 109 +++++-- context/plans/codex-cli-integration.md | 24 +- context/sce/codex-integration-runtime.md | 46 +-- 7 files changed, 630 insertions(+), 86 deletions(-) diff --git a/cli/src/services/hooks/codex/apply_patch/mod.rs b/cli/src/services/hooks/codex/apply_patch/mod.rs index 482bc0f5..a4b9a00c 100644 --- a/cli/src/services/hooks/codex/apply_patch/mod.rs +++ b/cli/src/services/hooks/codex/apply_patch/mod.rs @@ -291,7 +291,7 @@ mod tests { tool_input, tool_response: None, prompt: None, - last_assistant_message: None, + last_assistant_message: super::super::NullableField::Missing, } } diff --git a/cli/src/services/hooks/codex/bash_policy.rs b/cli/src/services/hooks/codex/bash_policy.rs index e5d656c6..e282f924 100644 --- a/cli/src/services/hooks/codex/bash_policy.rs +++ b/cli/src/services/hooks/codex/bash_policy.rs @@ -81,6 +81,7 @@ mod tests { use serde_json::json; + use super::super::NullableField; use super::*; use crate::services::agent_trace_storage::{ resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, @@ -99,7 +100,7 @@ mod tests { tool_input, tool_response: None, prompt: None, - last_assistant_message: None, + last_assistant_message: NullableField::Missing, } } diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index d471b539..e34bba70 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -1,7 +1,7 @@ use std::path::Path; use anyhow::{Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use serde_json::Value; use crate::services::observability::traits::Logger; @@ -20,6 +20,57 @@ const CODEX_HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; const CODEX_HOOK_TOOL_BASH: &str = "Bash"; const CODEX_HOOK_TOOL_APPLY_PATCH: &str = "apply_patch"; +/// Distinguishes a JSON field that is absent from the payload entirely +/// (`Missing`) from one that is present with an explicit `null` (`Null`) +/// from one that is present with a value (`Value`). A plain +/// `#[serde(default)] Option` cannot make this distinction: Serde's +/// `Option` deserializer maps JSON `null` to `None` at the *same* layer +/// it uses for "value absent", so both missing-field and explicit-null +/// collapse to `None`. `#[serde(default, deserialize_with = "...")]` on a +/// field of this type keeps `Default` (→ `Missing`) for the no-field case +/// and routes every present field (including `null`) through +/// [`deserialize_nullable_field`], which is the only path that can produce +/// `Null` or `Value`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum NullableField { + #[default] + Missing, + Null, + Value(T), +} + +impl NullableField { + #[cfg(test)] + pub(crate) fn is_missing(&self) -> bool { + matches!(self, NullableField::Missing) + } + + #[cfg(test)] + pub(crate) fn is_null(&self) -> bool { + matches!(self, NullableField::Null) + } + + pub(crate) fn as_value(&self) -> Option<&T> { + match self { + NullableField::Value(value) => Some(value), + NullableField::Missing | NullableField::Null => None, + } + } +} + +fn deserialize_nullable_field<'de, T, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Ok(match Option::::deserialize(deserializer)? { + Some(value) => NullableField::Value(value), + None => NullableField::Null, + }) +} + /// A single Codex hook lifecycle event, deserialized from the raw STDIN JSON /// payload `sce hooks codex` receives via /// `.codex/hooks/run-sce-or-show-install-guidance.sh`. @@ -30,8 +81,11 @@ const CODEX_HOOK_TOOL_APPLY_PATCH: &str = "apply_patch"; /// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`; /// `prompt` is present only on `UserPromptSubmit`, matching Claude's own /// `UserPromptSubmit` payload shape (see `transform_claude_user_prompt_submit_with`); -/// `last_assistant_message` is present only on `Stop`, matching Claude's own -/// `Stop` payload shape (see `transform_claude_stop_with`). +/// `last_assistant_message` is present (per current upstream Codex `Stop` +/// schema, required and typed `string | null`) only on `Stop`, matching +/// Claude's own `Stop` payload shape (see `transform_claude_stop_with`) +/// except that Codex allows an explicit `null` where Claude does not — see +/// [`NullableField`]. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct CodexHookEvent { @@ -54,8 +108,8 @@ pub(crate) struct CodexHookEvent { pub(crate) tool_response: Option, #[serde(default)] pub(crate) prompt: Option, - #[serde(default)] - pub(crate) last_assistant_message: Option, + #[serde(default, deserialize_with = "deserialize_nullable_field")] + pub(crate) last_assistant_message: NullableField, } /// The set of Codex hook-event/tool combinations `sce hooks codex` gives @@ -182,7 +236,7 @@ mod tests { tool_input: None, tool_response: None, prompt: None, - last_assistant_message: None, + last_assistant_message: NullableField::Missing, } } @@ -295,6 +349,194 @@ mod tests { assert_eq!(output, ""); } + #[derive(Clone, Default)] + struct RecordingLogger { + errors: std::sync::Arc>>, + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn warn(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn error(&self, _event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.errors + .lock() + .expect("recording logger mutex must not be poisoned") + .push(message.to_string()); + } + + fn log_cli_error(&self, _error: &crate::services::error::CliError, _: Option<&str>) {} + } + + #[test] + fn log_codex_fail_open_logs_a_propagated_timestamp_failure_and_returns_empty_stdout() { + let logger = RecordingLogger::default(); + let error = anyhow::anyhow!("clock failed"); + + let output = log_codex_fail_open(&error, Some(&logger)); + + assert_eq!(output, ""); + let errors = logger.errors.lock().expect("mutex must not be poisoned"); + assert_eq!(errors.as_slice(), ["clock failed"]); + } + + #[derive(Debug, Clone, Copy)] + struct StopRowCounts { + messages: i64, + parts: i64, + } + + fn stop_row_counts( + storage: &crate::services::agent_trace_storage::ResolvedAgentTraceStorage, + ) -> StopRowCounts { + let messages = storage + .db + .query_map("SELECT COUNT(*) FROM messages", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("messages count query should succeed")[0]; + let parts = storage + .db + .query_map("SELECT COUNT(*) FROM parts", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("parts count query should succeed")[0]; + StopRowCounts { messages, parts } + } + + fn reopen_storage_for_counts( + repository_root: &Path, + state_root: &Path, + ) -> crate::services::agent_trace_storage::ResolvedAgentTraceStorage { + resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + state_root, + ) + .expect("repository Agent Trace DB should reopen") + } + + #[test] + fn stop_dispatch_propagates_a_missing_last_assistant_message_field_for_the_outer_fail_open_boundary( + ) { + let (repository_root, state_root) = initialize_repository("stop-dispatch-missing-field"); + let payload = json!({ + "hook_event_name": "Stop", + "session_id": "s1", + "turn_id": "t1" + }) + .to_string(); + + let error = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect_err( + "a Stop payload missing last_assistant_message must error so the outer boundary can fail open", + ); + assert!(error.to_string().contains("last_assistant_message")); + assert_eq!(log_codex_fail_open(&error, None), ""); + + let storage = reopen_storage_for_counts(&repository_root, &state_root); + let counts = stop_row_counts(&storage); + assert_eq!(counts.messages, 0); + assert_eq!(counts.parts, 0); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + #[test] + fn stop_dispatch_is_a_silent_no_op_for_an_explicit_null_last_assistant_message() { + let (repository_root, state_root) = initialize_repository("stop-dispatch-null"); + let payload = json!({ + "hook_event_name": "Stop", + "session_id": "s1", + "turn_id": "t1", + "last_assistant_message": null + }) + .to_string(); + + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("explicit null last_assistant_message should be a successful no-op"); + assert_eq!(output, ""); + + let storage = reopen_storage_for_counts(&repository_root, &state_root); + let counts = stop_row_counts(&storage); + assert_eq!(counts.messages, 0); + assert_eq!(counts.parts, 0); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + // Explicit-empty-string and normal-text persistence *through raw JSON + // deserialization* are covered in `stop::tests` (e.g. + // `capture_with_persists_deserialized_raw_json_with_empty_string_last_assistant_message`), + // not here: `open_agent_trace_db_for_hook_runtime` (used by `stop::handle` + // for every persisting case) resolves the real default Agent Trace + // storage path and has no `state_root` injection seam — unlike + // `apply_patch`, which added one specifically for its own dispatcher + // tests. Missing/null above need no DB at all (they short-circuit before + // DB open), so they remain safe to exercise through the full + // `run_codex_subcommand_from_payload_at_state_root` dispatcher path. + + #[test] + fn codex_hook_event_deserializes_missing_last_assistant_message_as_missing() { + let event: CodexHookEvent = + serde_json::from_str(r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1"}"#) + .expect("payload without last_assistant_message should still deserialize"); + + assert!(event.last_assistant_message.is_missing()); + } + + #[test] + fn codex_hook_event_deserializes_explicit_null_last_assistant_message_as_null() { + let event: CodexHookEvent = serde_json::from_str( + r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1","last_assistant_message":null}"#, + ) + .expect("payload with explicit null last_assistant_message should deserialize"); + + assert!(event.last_assistant_message.is_null()); + } + + #[test] + fn codex_hook_event_deserializes_empty_string_last_assistant_message_as_value() { + let event: CodexHookEvent = serde_json::from_str( + r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1","last_assistant_message":""}"#, + ) + .expect("payload with empty string last_assistant_message should deserialize"); + + assert_eq!( + event.last_assistant_message.as_value().map(String::as_str), + Some("") + ); + } + + #[test] + fn codex_hook_event_deserializes_present_text_last_assistant_message_as_value() { + let event: CodexHookEvent = serde_json::from_str( + r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1","last_assistant_message":"hello"}"#, + ) + .expect("payload with text last_assistant_message should deserialize"); + + assert_eq!( + event.last_assistant_message.as_value().map(String::as_str), + Some("hello") + ); + } + fn unique_temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs index f9f902a7..fb096e02 100644 --- a/cli/src/services/hooks/codex/stop.rs +++ b/cli/src/services/hooks/codex/stop.rs @@ -11,40 +11,71 @@ use super::super::{ current_unix_time_ms, open_agent_trace_db_for_hook_runtime, prefixed_conversation_trace_session_id, CODEX_TOOL_NAME, }; -use super::CodexHookEvent; +use super::{CodexHookEvent, NullableField}; /// Captures a Codex `Stop` event as one `messages` row (`role = "assistant"`) /// and one `parts` row (`part_type = "text"`, `text = last_assistant_message`) /// under session `cx_`, message `cx::assistant`. +/// +/// Upstream Codex's `Stop` schema requires `last_assistant_message` and +/// types it `string | null`. This handler therefore distinguishes three +/// cases via [`NullableField`]: a missing field is a malformed payload that +/// errors so the outer Codex dispatcher fail-open boundary +/// (`run_codex_subcommand` → `log_codex_fail_open`) logs it and emits exact +/// empty stdout with no DB access; an explicit `null` is a valid, +/// upstream-legitimate "no assistant text this turn" signal that +/// short-circuits as a silent successful no-op *before* timestamp +/// acquisition or the Agent Trace DB is ever opened; a present value +/// (including an explicit empty string, persisted like any other text) is +/// captured normally. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for Codex Stop persistence.", - )?; + handle_with_clock(repository_root, event, current_unix_time_ms) +} - capture_with(&db, event, || current_unix_time_ms().unwrap_or(0)) +/// Injectable-clock counterpart of `handle`. Timestamp acquisition is +/// fallible and its failure is propagated as `Err` rather than swallowed +/// internally, so the existing outer Codex fail-open boundary owns logging +/// and the empty-stdout contract for a failed clock exactly as it does for +/// any other handler error. +fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result +where + F: FnOnce() -> Result, +{ + match &event.last_assistant_message { + NullableField::Missing => Err(anyhow::anyhow!( + "Invalid Codex Stop payload: field 'last_assistant_message' must be present." + )), + NullableField::Null => Ok(String::new()), + NullableField::Value(_) => { + let generated_at_unix_ms = now()?; + + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Codex Stop persistence.", + )?; + + capture_with(&db, event, generated_at_unix_ms) + } + } } /// Injectable counterpart of `handle` for deterministic testing against an /// already-open Agent Trace DB. -fn capture_with( +fn capture_with( db: &RepositoryAgentTraceDb, event: &CodexHookEvent, - generate_timestamp_ms: T, -) -> Result -where - T: FnOnce() -> i64, -{ - let session_id = required_field(event.session_id.as_deref(), "session_id")?; - let turn_id = required_field(event.turn_id.as_deref(), "turn_id")?; - let last_assistant_message = required_field( - event.last_assistant_message.as_deref(), - "last_assistant_message", - )?; + generated_at_unix_ms: i64, +) -> Result { + let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; + let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; + let last_assistant_message = event.last_assistant_message.as_value().ok_or_else(|| { + anyhow::anyhow!( + "Invalid Codex Stop payload: field 'last_assistant_message' must be present for persistence." + ) + })?; let prefixed_session_id = prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, session_id); let message_id = format!("cx:{turn_id}:assistant"); - let generated_at_unix_ms = generate_timestamp_ms(); db.insert_messages(vec![InsertMessageInsert { session_id: prefixed_session_id.clone(), @@ -56,7 +87,7 @@ where db.insert_parts(vec![InsertPartInsert { part_type: PartType::Text, - text: last_assistant_message.to_string(), + text: last_assistant_message.clone(), session_id: prefixed_session_id, message_id, generated_at_unix_ms, @@ -66,9 +97,12 @@ where Ok(String::new()) } -fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { - match value { - Some(value) if !value.trim().is_empty() => Ok(value), +/// Validates an identifier field (`session_id`/`turn_id`) is present and +/// non-blank, returning it trimmed so downstream prefixing/formatting never +/// persists incidental leading/trailing whitespace. +fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { + match value.map(str::trim) { + Some(value) if !value.is_empty() => Ok(value), _ => Err(anyhow::anyhow!( "Invalid Codex Stop payload: field '{field_name}' must be a non-empty string." )), @@ -116,7 +150,7 @@ mod tests { tool_input: None, tool_response: None, prompt: None, - last_assistant_message: Some(last_assistant_message.to_string()), + last_assistant_message: NullableField::Value(last_assistant_message.to_string()), } } @@ -156,7 +190,7 @@ mod tests { let db_path = unique_test_db_path("basic"); let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); - let output = capture_with(&db, &event("session-1", "turn-1", "hello back"), || 1_000) + let output = capture_with(&db, &event("session-1", "turn-1", "hello back"), 1_000) .expect("capture should succeed"); assert_eq!(output, ""); @@ -186,7 +220,7 @@ mod tests { let db_path = unique_test_db_path("prefixed"); let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); - capture_with(&db, &event("cx_session-1", "turn-1", "hi"), || 1_000) + capture_with(&db, &event("cx_session-1", "turn-1", "hi"), 1_000) .expect("capture should succeed"); assert_eq!(message_rows(&db)[0].0, "cx_session-1"); @@ -200,8 +234,8 @@ mod tests { let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); let payload = event("session-1", "turn-1", "hello back"); - capture_with(&db, &payload, || 1_000).expect("first capture should succeed"); - capture_with(&db, &payload, || 2_000).expect("reprocessed capture should succeed"); + capture_with(&db, &payload, 1_000).expect("first capture should succeed"); + capture_with(&db, &payload, 2_000).expect("reprocessed capture should succeed"); assert_eq!( message_rows(&db).len(), @@ -217,15 +251,29 @@ mod tests { let db_path = unique_test_db_path("missing-last-assistant-message"); let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); let mut payload = event("session-1", "turn-1", "hello back"); - payload.last_assistant_message = None; + payload.last_assistant_message = NullableField::Missing; - let error = capture_with(&db, &payload, || 1_000) + let error = capture_with(&db, &payload, 1_000) .expect_err("missing last_assistant_message should error"); assert!(error.to_string().contains("'last_assistant_message'")); remove_test_db(&db_path); } + #[test] + fn capture_with_rejects_a_null_last_assistant_message() { + let db_path = unique_test_db_path("null-last-assistant-message"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello back"); + payload.last_assistant_message = NullableField::Null; + + let error = capture_with(&db, &payload, 1_000) + .expect_err("null last_assistant_message should error inside capture_with"); + assert!(error.to_string().contains("'last_assistant_message'")); + + remove_test_db(&db_path); + } + #[test] fn capture_with_rejects_a_missing_turn_id() { let db_path = unique_test_db_path("missing-turn-id"); @@ -233,10 +281,176 @@ mod tests { let mut payload = event("session-1", "turn-1", "hello back"); payload.turn_id = None; - let error = - capture_with(&db, &payload, || 1_000).expect_err("missing turn_id should error"); + let error = capture_with(&db, &payload, 1_000).expect_err("missing turn_id should error"); assert!(error.to_string().contains("'turn_id'")); remove_test_db(&db_path); } + + #[test] + fn capture_with_trims_padded_session_and_turn_ids_before_persisting() { + let db_path = unique_test_db_path("trimmed-ids"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event(" session-1 ", " turn-1 ", "hello back"); + payload.session_id = Some(" session-1 ".to_string()); + payload.turn_id = Some(" turn-1 ".to_string()); + + capture_with(&db, &payload, 1_000).expect("padded ids should persist trimmed"); + + assert_eq!( + message_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:assistant".to_string(), + "assistant".to_string() + )] + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_rejects_a_whitespace_only_session_id() { + let db_path = unique_test_db_path("blank-session-id"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello back"); + payload.session_id = Some(" ".to_string()); + + let error = capture_with(&db, &payload, 1_000).expect_err("blank session_id should error"); + assert!(error.to_string().contains("'session_id'")); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_persists_an_explicit_empty_last_assistant_message() { + let db_path = unique_test_db_path("explicit-empty"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let payload = event("session-1", "turn-1", ""); + + let output = + capture_with(&db, &payload, 1_000).expect("explicit empty text should persist"); + assert_eq!(output, ""); + + assert_eq!( + part_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:assistant".to_string(), + "text".to_string(), + String::new() + )], + "an explicit empty string is a present value, unlike null, and persists like any other text" + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_persists_deserialized_raw_json_with_an_explicit_empty_string() { + let db_path = unique_test_db_path("raw-json-empty-string"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let payload: CodexHookEvent = serde_json::from_str( + r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1","last_assistant_message":""}"#, + ) + .expect("raw JSON with an explicit empty string should deserialize"); + + let output = capture_with(&db, &payload, 1_000) + .expect("deserialized explicit empty string should persist"); + assert_eq!(output, ""); + assert_eq!(message_rows(&db).len(), 1); + assert_eq!( + part_rows(&db), + vec![( + "cx_s1".to_string(), + "cx:t1:assistant".to_string(), + "text".to_string(), + String::new() + )] + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_persists_deserialized_raw_json_with_normal_text() { + let db_path = unique_test_db_path("raw-json-normal-text"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let payload: CodexHookEvent = serde_json::from_str( + r#"{"hook_event_name":"Stop","session_id":"s1","turn_id":"t1","last_assistant_message":"hello"}"#, + ) + .expect("raw JSON with normal text should deserialize"); + + let output = + capture_with(&db, &payload, 1_000).expect("deserialized normal text should persist"); + assert_eq!(output, ""); + assert_eq!(message_rows(&db).len(), 1); + assert_eq!( + part_rows(&db), + vec![( + "cx_s1".to_string(), + "cx:t1:assistant".to_string(), + "text".to_string(), + "hello".to_string() + )] + ); + + remove_test_db(&db_path); + } + + #[test] + fn handle_is_a_silent_no_op_for_a_null_last_assistant_message_without_opening_the_db() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.last_assistant_message = NullableField::Null; + + // A nonexistent repository root proves `handle` never reaches Agent + // Trace DB resolution for a null `last_assistant_message`: DB opening + // against a nonexistent repository would otherwise fail loudly. + let output = handle(Path::new("/nonexistent-repository-root"), &payload) + .expect("null last_assistant_message should be a silent successful no-op"); + assert_eq!(output, ""); + } + + #[test] + fn handle_with_clock_errors_for_a_missing_last_assistant_message_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.last_assistant_message = NullableField::Missing; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a missing last_assistant_message") + }) + .expect_err("missing last_assistant_message should error"); + assert!(error.to_string().contains("'last_assistant_message'")); + } + + #[test] + fn handle_with_clock_is_a_silent_no_op_for_null_without_calling_the_clock_or_opening_the_db() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.last_assistant_message = NullableField::Null; + + // A failing/panicking clock closure and a nonexistent repository + // root together prove `handle_with_clock` short-circuits before + // timestamp acquisition and before Agent Trace DB resolution for an + // explicit null. + let output = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for an explicit null last_assistant_message") + }) + .expect("null last_assistant_message should be a silent successful no-op"); + assert_eq!(output, ""); + } + + #[test] + fn handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence() { + let payload = event("session-1", "turn-1", "hello back"); + + // A nonexistent repository root additionally proves the failed + // clock is consulted (and propagated) before Agent Trace DB + // resolution is ever attempted: a subsequent DB-open attempt + // against this path would fail loudly instead. + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + Err(anyhow::anyhow!("clock failed")) + }) + .expect_err("a failed clock must propagate as an error for the outer fail-open boundary"); + assert!(error.to_string().contains("clock failed")); + } } diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index 6eaecfa2..ec9dc492 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -17,31 +17,42 @@ use super::CodexHookEvent; /// (`role = "user"`) and one `parts` row (`part_type = "text"`, `text = prompt`) /// under session `cx_`, message `cx::user`. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { + handle_with_clock(repository_root, event, current_unix_time_ms) +} + +/// Injectable-clock counterpart of `handle`. Timestamp acquisition is +/// fallible and its failure is propagated as `Err` rather than swallowed +/// internally, so the existing outer Codex fail-open boundary +/// (`run_codex_subcommand` → `log_codex_fail_open`) owns logging and the +/// empty-stdout contract for a failed clock exactly as it does for any +/// other handler error. +fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result +where + F: FnOnce() -> Result, +{ + let generated_at_unix_ms = now()?; + let db = open_agent_trace_db_for_hook_runtime( repository_root, "Failed to open Agent Trace DB for Codex UserPromptSubmit persistence.", )?; - capture_with(&db, event, || current_unix_time_ms().unwrap_or(0)) + capture_with(&db, event, generated_at_unix_ms) } /// Injectable counterpart of `handle` for deterministic testing against an /// already-open Agent Trace DB. -fn capture_with( +fn capture_with( db: &RepositoryAgentTraceDb, event: &CodexHookEvent, - generate_timestamp_ms: T, -) -> Result -where - T: FnOnce() -> i64, -{ - let session_id = required_field(event.session_id.as_deref(), "session_id")?; - let turn_id = required_field(event.turn_id.as_deref(), "turn_id")?; + generated_at_unix_ms: i64, +) -> Result { + let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; + let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; let prompt = required_field(event.prompt.as_deref(), "prompt")?; let prefixed_session_id = prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, session_id); let message_id = format!("cx:{turn_id}:user"); - let generated_at_unix_ms = generate_timestamp_ms(); db.insert_messages(vec![InsertMessageInsert { session_id: prefixed_session_id.clone(), @@ -72,6 +83,18 @@ fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a st } } +/// Validates an identifier field (`session_id`/`turn_id`) is present and +/// non-blank, returning it trimmed so downstream prefixing/formatting never +/// persists incidental leading/trailing whitespace. +fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { + match value.map(str::trim) { + Some(value) if !value.is_empty() => Ok(value), + _ => Err(anyhow::anyhow!( + "Invalid Codex UserPromptSubmit payload: field '{field_name}' must be a non-empty string." + )), + } +} + #[cfg(test)] mod tests { use std::{ @@ -80,6 +103,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + use super::super::NullableField; use super::*; fn unique_test_db_path(label: &str) -> PathBuf { @@ -113,7 +137,7 @@ mod tests { tool_input: None, tool_response: None, prompt: Some(prompt.to_string()), - last_assistant_message: None, + last_assistant_message: NullableField::Missing, } } @@ -153,7 +177,7 @@ mod tests { let db_path = unique_test_db_path("basic"); let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); - let output = capture_with(&db, &event("session-1", "turn-1", "hello world"), || 1_000) + let output = capture_with(&db, &event("session-1", "turn-1", "hello world"), 1_000) .expect("capture should succeed"); assert_eq!(output, ""); @@ -183,7 +207,7 @@ mod tests { let db_path = unique_test_db_path("prefixed"); let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); - capture_with(&db, &event("cx_session-1", "turn-1", "hi"), || 1_000) + capture_with(&db, &event("cx_session-1", "turn-1", "hi"), 1_000) .expect("capture should succeed"); assert_eq!(message_rows(&db)[0].0, "cx_session-1"); @@ -197,8 +221,8 @@ mod tests { let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); let payload = event("session-1", "turn-1", "hello world"); - capture_with(&db, &payload, || 1_000).expect("first capture should succeed"); - capture_with(&db, &payload, || 2_000).expect("reprocessed capture should succeed"); + capture_with(&db, &payload, 1_000).expect("first capture should succeed"); + capture_with(&db, &payload, 2_000).expect("reprocessed capture should succeed"); assert_eq!( message_rows(&db).len(), @@ -216,7 +240,7 @@ mod tests { let mut payload = event("session-1", "turn-1", "hello world"); payload.prompt = None; - let error = capture_with(&db, &payload, || 1_000).expect_err("missing prompt should error"); + let error = capture_with(&db, &payload, 1_000).expect_err("missing prompt should error"); assert!(error.to_string().contains("'prompt'")); remove_test_db(&db_path); @@ -229,10 +253,59 @@ mod tests { let mut payload = event("session-1", "turn-1", "hello world"); payload.turn_id = None; - let error = - capture_with(&db, &payload, || 1_000).expect_err("missing turn_id should error"); + let error = capture_with(&db, &payload, 1_000).expect_err("missing turn_id should error"); + assert!(error.to_string().contains("'turn_id'")); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_trims_padded_session_and_turn_ids_before_persisting() { + let db_path = unique_test_db_path("trimmed-ids"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello world"); + payload.session_id = Some(" session-1 ".to_string()); + payload.turn_id = Some(" turn-1 ".to_string()); + + capture_with(&db, &payload, 1_000).expect("padded ids should persist trimmed"); + + assert_eq!( + message_rows(&db), + vec![( + "cx_session-1".to_string(), + "cx:turn-1:user".to_string(), + "user".to_string() + )] + ); + + remove_test_db(&db_path); + } + + #[test] + fn capture_with_rejects_a_whitespace_only_turn_id() { + let db_path = unique_test_db_path("blank-turn-id"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let mut payload = event("session-1", "turn-1", "hello world"); + payload.turn_id = Some(" ".to_string()); + + let error = capture_with(&db, &payload, 1_000).expect_err("blank turn_id should error"); assert!(error.to_string().contains("'turn_id'")); remove_test_db(&db_path); } + + #[test] + fn handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence() { + let payload = event("session-1", "turn-1", "hello world"); + + // A nonexistent repository root additionally proves the failed + // clock is consulted (and propagated) before Agent Trace DB + // resolution is ever attempted: a subsequent DB-open attempt + // against this path would fail loudly instead. + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + Err(anyhow::anyhow!("clock failed")) + }) + .expect_err("a failed clock must propagate as an error for the outer fail-open boundary"); + assert!(error.to_string().contains("clock failed")); + } } diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 25320ac0..9d4eefb2 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -71,11 +71,11 @@ This revision extends the completed Codex rollout with six correctness hardening - [x] AC28: `sce doctor` reports Codex-owned registrations structurally as `PresentAndCurrent`, `Missing`, `Stale`, or `Malformed`, ignores user-owned additions, and separately reports executable trust readiness. It does not claim health when the effective Codex state is disabled, untrusted, modified, or unknown; it reports healthy only for current SCE registrations that Codex will actually execute, and `--fix` repairs only the SCE-owned fragment without changing user hooks or trust state. - Validate: `codex_hook_config`/`codex_hook_trust`/`services::doctor` test suites (52 tests) — structural diagnosis scans every matcher group per event (not just the first matching one), trust-state deserialization discards a malformed state entry as a whole rather than salvaging individual fields, and `PresentAndCurrent` for every registration is proven equivalent to a no-op `merge_or_create`. - Validate: doctor/shared-service tests cover current plus user hooks, missing/stale/malformed fragments, trusted/untrusted/modified/disabled/unknown state, current upstream key/hash/config-layer semantics, and trust-preserving fix behavior. -- [ ] AC29: No generated Codex `.agents/skills/**/*.md` file, including `SKILL.md` and `references/*.md`, contains literal `$ARGUMENTS`; canonical workflow content uses explicit skill-invocation input semantics while command-capable Claude/OpenCode/Pi entrypoints retain their existing argument-substitution behavior. +- [x] AC29: No generated Codex `.agents/skills/**/*.md` file, including `SKILL.md` and `references/*.md`, contains literal `$ARGUMENTS`; canonical workflow content uses explicit skill-invocation input semantics while command-capable Claude/OpenCode/Pi entrypoints retain their existing argument-substitution behavior. - Validate: generated contract coverage walks all Codex skill Markdown and asserts the token is absent, while cross-target generation tests assert command wrappers and non-Codex behavior remain unchanged. -- [ ] AC30: Codex Stop accepts upstream-valid `last_assistant_message: null` as a successful silent no-op before Agent Trace DB access, returning exact stdout `""` and inserting neither a message nor a part. Explicit empty-string behavior is tested separately according to the current upstream contract and is never conflated with null; malformed values still fail open without fake assistant text. +- [x] AC30: Codex Stop accepts upstream-valid `last_assistant_message: null` as a successful silent no-op before Agent Trace DB access, returning exact stdout `""` and inserting neither a message nor a part. Explicit empty-string behavior is tested separately according to the current upstream contract and is never conflated with null; malformed values still fail open without fake assistant text. - Validate: Codex Stop dispatcher/handler tests cover normal text, null, explicit empty string, exact stdout, and no-write behavior. -- [ ] AC31: Codex conversation handlers trim and persist validated non-empty `session_id` and `turn_id` consistently, acquire timestamps with fallible propagation, and never persist epoch-0 fallback provenance. Timestamp acquisition failure is fail-open with no DB write for UserPromptSubmit, Stop, and all other Codex trace paths that could otherwise synthesize zero. +- [x] AC31: Codex conversation handlers trim and persist validated non-empty `session_id` and `turn_id` consistently, acquire timestamps with fallible propagation, and never persist epoch-0 fallback provenance. Timestamp acquisition failure is fail-open with no DB write for UserPromptSubmit, Stop, and all other Codex trace paths that could otherwise synthesize zero. - Validate: Codex handler tests cover whitespace-padded identifiers, missing identifiers, timestamp failures, and source inspection/tests for `unwrap_or(0)`, zero timestamp literals, and equivalent default fallbacks. - [ ] AC32: UserPromptSubmit and Stop persist one logical conversation text event through one transactional DB primitive: parent message plus text part are inserted together or neither is inserted; replay of one, ten, or concurrent duplicate deliveries is a successful no-op with exactly one message and one part; injected part failure rolls back the parent message; apply_patch persistence remains on its existing independent diff-trace API and no migration is added. - Validate: Agent Trace DB atomic-event tests cover replay, transaction rollback via an injectable failure seam, and the SQLite write-serialization/concurrent duplicate contract; both Codex handlers use the primitive. @@ -434,13 +434,27 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: root — `context/architecture.md:51` states as fact that "Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi, so its `skillDocuments` output is byte-identical to Pi's," which this task makes false (Codex's `references/output.md` for `sce-handover` and `sce-brownfield`, and every skill body's `## Input` prose, now legitimately diverges from Pi's). `context/architecture.md:11`'s description of `StructuredCompositeSource`'s "optional canonical `compositeSkillBody`" is also now incomplete given the two new sibling fields (`argumentDependentCommandBody`, `argumentReferenceOutputDocument`). - Context synchronization: synced -- [ ] T24: `Correct Codex Stop nullability, identifiers, and timestamps` (status:todo) +- [x] T24: `Correct Codex Stop nullability, identifiers, and timestamps` (status:done) - Task ID: T24 - Scope: In — update UserPromptSubmit and Stop validation/persistence to trim and persist IDs consistently, short-circuit nullable Stop messages before DB open, define and test distinct explicit-empty-string behavior, replace timestamp fallbacks with fallible acquisition through the existing outer fail-open boundary, and audit all Codex provenance timestamp paths for zero/default synthesis. Out — apply_patch evidence architecture and database schema changes. - Dependencies: T23 - Done when: null Stop is a silent successful no-op with no message/part and no DB open; valid padded IDs persist trimmed values; normal and explicit-empty cases follow separate tested semantics; timestamp failures never write and no Codex trace path can persist January 1, 1970 fallback provenance. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` - - Context synchronization: pending + - Completed: 2026-08-23 + - Files changed: `cli/src/services/hooks/codex/stop.rs`, `cli/src/services/hooks/codex/user_prompt_submit.rs` + - Result: `stop::handle` now checks `event.last_assistant_message.is_none()` before calling `open_agent_trace_db_for_hook_runtime`, returning `Ok(String::new())` immediately — upstream Codex's `Stop` schema types `last_assistant_message` as `string | null`, so `null` (deserializing to `None`) is a legitimate "no assistant text this turn" signal, distinct from a missing/malformed `session_id`/`turn_id` (still rejected, fails open, no fake text) and from an explicit empty string (a present value: `capture_with` still persists a `parts` row with empty `text` for `Some("")`, exercised by a dedicated test). Both `user_prompt_submit::handle` and `stop::handle` replaced `capture_with(&db, event, || current_unix_time_ms().unwrap_or(0))` with `let Ok(generated_at_unix_ms) = current_unix_time_ms() else { return Ok(String::new()); };` before calling `capture_with`, mirroring `apply_patch::handle`'s existing (T12) fail-open pattern exactly — `capture_with` itself now takes a plain `i64` timestamp instead of an injectable closure, since the fail-open branch already lives in `handle`. `required_field` (kept as-is, used only for `prompt` in `user_prompt_submit.rs`, unused/removed for `last_assistant_message` in `stop.rs` since that field's presence is now checked directly against `None` before the closure-free capture path) is joined by a new `required_trimmed_field` in both files, mirroring `apply_patch::required_session_id`'s existing `.map(str::trim)` pattern: `session_id` and `turn_id` are now trimmed before use in prefixing/`message_id` formatting, so `" session-1 "`/`" turn-1 "` persist as `cx_session-1`/`cx:turn-1:...` instead of carrying incidental whitespace into stored identifiers. Audited every Codex trace path for `unwrap_or(0)`/zero-timestamp fallback: `user_prompt_submit.rs:25` and `stop.rs:25` were the only two remaining sites (confirmed via `grep -rn "unwrap_or(0)" cli/src/services/hooks/codex/`); `apply_patch::handle` already used the fail-open pattern since T12 and needed no change; the three `unwrap_or(0)` sites in `cli/src/services/hooks/mod.rs` (`transform_claude_user_prompt_submit`/`transform_claude_stop`/`transform_claude_post_tool_use`) are Claude-producer code, explicitly out of this task's Codex-only scope. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 95 passed, 0 failed (85 pre-existing plus 10 new: `stop::tests::handle_is_a_silent_no_op_for_a_null_last_assistant_message_without_opening_the_db`, `stop::tests::capture_with_trims_padded_session_and_turn_ids_before_persisting`, `stop::tests::capture_with_rejects_a_whitespace_only_session_id`, `stop::tests::capture_with_persists_an_explicit_empty_last_assistant_message`, `user_prompt_submit::tests::capture_with_trims_padded_session_and_turn_ids_before_persisting`, `user_prompt_submit::tests::capture_with_rejects_a_whitespace_only_turn_id`, plus signature-updated existing tests). + - Verify: `nix flake check` — passed: "all checks passed!" (`cli-tests`, `cli-clippy`, `cli-fmt` after one `cargo fmt` pass, `cli-generated-input`, `pkl-generated`, `codex-hook-command` all green; no Codex asset/generation surface was touched by this task, so the non-Rust checks were unaffected and served from cache). + - Context impact: root — `context/sce/codex-integration-runtime.md` (lines 85-87) states as fact that "Stop requires non-empty session_id, turn_id, and last_assistant_message. A missing or blank required field is a [fail-open]" — now incomplete/stale since `null` `last_assistant_message` is a distinct, documented no-op path (not a validation failure) and IDs are now trimmed before persistence; this file is already named under this plan's "Context sync" list (item 5, `cx_` session prefix / UserPromptSubmit/Stop mapping) for exactly this kind of update. + - Context synchronization: synced + - Follow-up repair (2026-08-23): The initial T24 pass above still swallowed timestamp failures inside each handler (`let Ok(...) = current_unix_time_ms() else { return Ok(String::new()); }`) instead of propagating them to the existing outer `run_codex_subcommand` → `log_codex_fail_open` boundary, and `CodexHookEvent.last_assistant_message: Option` (`#[serde(default)]`) collapsed a genuinely missing field and an explicit upstream `null` into the same `None`, so a malformed Stop payload (field absent) was silently treated identically to a valid null no-op. Re-verified upstream at `openai/codex` commit `343074d4207d572809bd8cea15f4be1d09d98e0b` (schema files `codex-rs/hooks/src/schema.rs` `StopCommandInput`, `codex-rs/hooks/schema/generated/stop.command.input.schema.json`; cross-checked byte-identical against current `main` `c9b19deb09c1841ce7acc33ddb96276030936a29`): `last_assistant_message` is a required property typed `["string","null"]` (Rust `NullableString(Option)`, `#[serde(transparent)]`), with a real production code path (`codex-rs/core/src/compact.rs:350`, `.unwrap_or_default()`) producing an explicit `""` distinct from `null`; `session_id`/`turn_id` are required plain, non-nullable strings. Fixed by: (1) adding `pub(crate) enum NullableField { Missing, Null, Value(T) }` (`mod.rs`) with `#[serde(default, deserialize_with = "deserialize_nullable_field")]`, where `deserialize_nullable_field` only runs when the field is present (missing falls back to `Default` → `Missing`) and maps `Option::::deserialize`'s `None`/`Some` to `Null`/`Value` — the standard "double option" trick, since a bare `Option>` cannot make this distinction (JSON `null` and a missing field both collapse to the outer `None` without it); `last_assistant_message` changed from `Option` to `NullableField`. (2) Both `stop::handle` and `user_prompt_submit::handle` now delegate to a private `handle_with_clock Result>(repository_root, event, now)`, with production `handle` passing `current_unix_time_ms` and tests passing failing closures; a failed clock now returns `Err` (propagated by `?`) instead of `Ok(String::new())`, letting the existing `run_codex_subcommand` → `log_codex_fail_open` boundary own logging and the empty-stdout contract exactly as it already does for every other handler error (dispatch parse failures, malformed identifiers, etc.) — no duplicated fail-open logging was added inside either handler. `stop::handle_with_clock` matches on `&event.last_assistant_message`: `Missing` returns `Err` (malformed payload) before calling `now` or opening the DB; `Null` returns `Ok(String::new())` before calling `now` or opening the DB (proven by tests passing a panicking clock closure); `Value(_)` calls `now()?` and only then opens the DB, i.e. timestamp acquisition now happens *before* DB open for both handlers (previously DB opened first). `capture_with` in both files is unchanged in persistence behavior other than reading `NullableField::as_value()`/pattern-matching instead of `Option::as_deref()`. Audited every Codex trace path again post-fix: `grep -rn "unwrap_or(0)" cli/src/services/hooks/codex/` and `grep -rn "unwrap_or_default" cli/src/services/hooks/codex/` — zero `unwrap_or(0)` hits; the one `unwrap_or_default` hit (`apply_patch/mod.rs:121`, `event.tool_use_id.as_deref().unwrap_or_default()`) defaults an identity string used in patch normalization, not a timestamp, and is unrelated. `apply_patch::handle`'s own `let Ok(time_ms) = current_unix_time_ms() else { return Ok(String::new()); };` was deliberately left unchanged: it already fails open with no epoch-0 synthesis, and unlike Stop/UserPromptSubmit it has its own internal `logger`-threaded fail-open convention (every other failure branch in that function — parse, path-resolution, normalize — already logs via the injected `Logger` and returns `Ok(String::new())` directly rather than propagating through the outer boundary), so routing it through `log_codex_fail_open` instead would be an apply_patch architecture change, which is explicitly out of scope/a non-goal for this repair. Files changed (this repair): `cli/src/services/hooks/codex/mod.rs` (new `NullableField`/`deserialize_nullable_field`, `last_assistant_message` field type, `RecordingLogger` test double, new dispatcher/deserialization tests), `cli/src/services/hooks/codex/stop.rs` (`handle_with_clock`, `NullableField`-based branching, new tests), `cli/src/services/hooks/codex/user_prompt_submit.rs` (`handle_with_clock`, new test), `cli/src/services/hooks/codex/bash_policy.rs` and `cli/src/services/hooks/codex/apply_patch/mod.rs` (test-only `CodexHookEvent` literals updated to `NullableField::Missing`; no behavior change). + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 109 passed, 0 failed (full `cli/Cargo.toml` suite also re-run: 543 passed, 0 failed). + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings'` — passed clean after removing two clippy findings introduced by this repair (`NullableField`'s manual `Default` impl replaced with `#[derive(Default)] #[default] Missing`; test-only `is_missing`/`is_null` gated `#[cfg(test)]`; `last_assistant_message.to_string()` on a `&String` changed to `.clone()`). + - Verify: `cargo fmt --manifest-path cli/Cargo.toml` then `-- --check` — clean. + - Verify: `nix flake check` — passed: "all checks passed!" (`cli-tests`, `cli-clippy`, `cli-fmt` all green; no Codex asset/generation surface touched, so `cli-generated-input`/`pkl-generated`/`codex-hook-command` were unaffected). + - Verify: `grep -Rn "unwrap_or(0)" cli/src/services/hooks/codex/` and `grep -Rn "unwrap_or_default" cli/src/services/hooks/codex/` — see audit above; no epoch-0 synthesis on any Codex path. + - New/changed tests: `mod.rs` — `codex_hook_event_deserializes_{missing,explicit_null,empty_string,present_text}_last_assistant_message_as_{missing,null,value}` (raw-JSON deserialization through `CodexHookEvent`), `stop_dispatch_propagates_a_missing_last_assistant_message_field_for_the_outer_fail_open_boundary`, `stop_dispatch_is_a_silent_no_op_for_an_explicit_null_last_assistant_message` (both through the real `run_codex_subcommand_from_payload_at_state_root` dispatcher path), `log_codex_fail_open_logs_a_propagated_timestamp_failure_and_returns_empty_stdout` (`RecordingLogger` test double proves the boundary logs a propagated error and still returns exact `""`). `stop.rs` — `handle_with_clock_errors_for_a_missing_last_assistant_message_without_calling_the_clock`, `handle_with_clock_is_a_silent_no_op_for_null_without_calling_the_clock_or_opening_the_db` (panicking clock closure + nonexistent repository root proves both no-clock-call and no-DB-open for null), `handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence`, `capture_with_rejects_a_null_last_assistant_message`, `capture_with_persists_deserialized_raw_json_with_an_explicit_empty_string`/`..._with_normal_text` (raw JSON → `capture_with`, sidesteps the fact that `open_agent_trace_db_for_hook_runtime` — used by every persisting Stop/UserPromptSubmit call, unlike `apply_patch`'s dispatcher-injectable `state_root` seam — has no test-injectable storage root). `user_prompt_submit.rs` — `handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence`. + - AC29/AC30/AC31/AC32 reconciled: AC29 marked `[x]` (T23 already satisfied it; the checkbox was stale). AC30 and AC31 marked `[x]` per this repair. AC32 remains `[ ]` — T25's scope (atomic/replay-safe transactional persistence) was not started. - [ ] T25: `Persist Codex conversation text events atomically and replay-safely` (status:todo) - Task ID: T25 diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 303ac90e..cae6eacf 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -38,20 +38,17 @@ registration structurally (present-and-current, missing, or stale, with a malformed whole document reported separately), so user-added valid Codex handlers do not appear as SCE drift and invalid Codex configuration remains unhealthy; `sce doctor --fix` repairs a structurally unhealthy document -through the same merge service. Codex's own hook-trust state — whether it has -actually marked a structurally current registration trusted, in its durable -`$CODEX_HOME/config.toml` — is read-only for doctor and separate from this +through the same merge service. Codex's own hook-trust state in its durable +`$CODEX_HOME/config.toml` is read-only for doctor, separate from this structural check; SCE never writes trust or auto-trust state. See [the ADR](../decisions/2026-08-23-codex-nondestructive-hook-ownership.md) and [the setup install policy](setup-no-backup-policy-seam.md). ## Dispatch skeleton -- STDIN carries one raw Codex hook-event JSON payload, deserialized into a - typed `CodexHookEvent` (`hook_event_name`, `session_id`, `turn_id`, `cwd`, - `model`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, - `prompt`, `last_assistant_message`; only `hook_event_name` is required, - matching the working contract in `context/plans/codex-cli-integration.md`). +- STDIN carries one raw Codex hook-event JSON payload into a typed + `CodexHookEvent` (nine documented fields; only `hook_event_name` is + required). - `classify_codex_event` matches `(hook_event_name, tool_name)` into one of four dispatch arms — `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)` — with every other combination (`apply_patch` @@ -68,11 +65,11 @@ setup install policy](setup-no-backup-policy-seam.md). (`cli/src/services/hooks/mod.rs`) carry a `"codex" -> cx_` arm alongside `oc_`/`cc_`/`pi_`, idempotent for an already-prefixed session ID. - `normalize_codex_model_id` trims a Codex model ID, returns `None` for blank - values, and otherwise preserves the reported ID unchanged. It does not infer - or fabricate a provider prefix because Codex exposes no separate provider - field. `PostToolUse(apply_patch)` calls it to derive a - `diff_traces.model_id` value when the event reports a model. This - provider-preserving rule is an accepted durable decision; see [the ADR](../decisions/2026-08-23-codex-truthful-model-provenance.md). + values, and otherwise preserves the reported ID unchanged — no inferred or + fabricated provider prefix, since Codex exposes no separate provider field. + `PostToolUse(apply_patch)` calls it to derive `diff_traces.model_id` when + the event reports a model. This provider-preserving rule is an accepted + durable decision; see [the ADR](../decisions/2026-08-23-codex-truthful-model-provenance.md). ## Implemented slices: `UserPromptSubmit` and `Stop` capture @@ -83,9 +80,13 @@ setup install policy](setup-no-backup-policy-seam.md). capture" below for the other two). Both follow the same shape: - `UserPromptSubmit` requires non-empty `session_id`, `turn_id`, and - `prompt`; `Stop` requires non-empty `session_id`, `turn_id`, and - `last_assistant_message`. A missing or blank required field is a - validation error (logged and failed open by the outer dispatcher). + `prompt`. `Stop` requires non-empty `session_id`/`turn_id`; a `null` + `last_assistant_message` (upstream types the field `string | null`) is a + legitimate no-op — `stop::handle` returns silently before the Agent Trace + DB opens, writing no message or part. An explicit empty string is a + present value and still persists (unlike `null`). `session_id`/`turn_id` + are trimmed before use, and a timestamp-acquisition failure fails open + with no write for both arms, matching `PostToolUse(apply_patch)` below. - `session_id` is stored as `cx_` (idempotent) for both arms. `message_id` is deterministic rather than a generated UUID — `cx::user` for `UserPromptSubmit`, `cx::assistant` for `Stop` — so that @@ -124,9 +125,8 @@ no reimplemented matching and no Codex-specific DB adapter: - Blocked: returns Codex's own native `PreToolUse` deny response — `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": ""}}` — confirmed - against Codex's real hook contract (`openai/codex` issue #28437) to be - identical in shape to Claude's own deny response - (`render_claude_hook_result` in `bash_policy.rs`), though built directly + (via `openai/codex` issue #28437) identical in shape to Claude's own deny + response (`render_claude_hook_result` in `bash_policy.rs`), built directly rather than by calling that Claude-specific function. Neither branch reads or writes `diff_traces`, a snapshot, or any @@ -189,10 +189,10 @@ accepts; `mod.rs`'s `handle` wires the stages together and persists the result: when a model is reported, `tool_name = "codex"`, `tool_version = None`, `payload_type = "patch"` — no new persistence adapter. The event-scoped synthetic identity scheme is an accepted durable decision; see [the ADR](../decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md). -- The timestamp comes from `current_unix_time_ms()`; unlike every other - Codex arm (which falls back to epoch zero via `.unwrap_or(0)`), a - timestamp-acquisition failure here skips the insert entirely (fails open) - rather than substituting a fabricated epoch-zero value. +- The timestamp comes from `current_unix_time_ms()`; a timestamp-acquisition + failure here skips the insert entirely (fails open) rather than + substituting a fabricated epoch-zero value, matching `UserPromptSubmit` + and `Stop`'s own fail-open timestamp behavior above. - Every path — success, empty-normalize no-op, and every fail-open branch — returns exactly empty stdout; Bash denial is the only structured Codex response. From 4e0b2469db4edf8b2fdcdfd49adb369758be757e Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 13:34:40 +0200 Subject: [PATCH 17/20] hooks: Validate Codex Stop/UserPromptSubmit identifiers before side effects T24 correctly distinguished missing/null/empty/present last_assistant_message and propagated timestamp failures through the outer fail-open boundary, but Stop still branched on last_assistant_message presence before validating session_id/turn_id, so a null Stop with a missing or blank session_id/turn_id incorrectly short-circuited to a successful no-op instead of being rejected as malformed. Both handlers now extract a single validated representation (ValidatedStop/ValidatedUserPromptSubmit) before any side effect: session_id and turn_id are validated/trimmed first, then (for Stop) last_assistant_message presence is classified, and only then does timestamp acquisition or DB access occur. An explicit null Stop is a successful no-op only once its identifiers are confirmed valid. Re-verified upstream openai/codex at commit 343074d4207d572809bd8cea15f4be1d09d98e0b (byte-identical to current main c9b19deb09c1841ce7acc33ddb96276030936a29): Stop's session_id/turn_id are required non-nullable strings (nullability applies only to last_assistant_message), and UserPromptSubmit's session_id/turn_id/prompt are likewise all required non-nullable strings. Co-authored-by: SCE --- cli/src/services/hooks/codex/mod.rs | 1 + cli/src/services/hooks/codex/stop.rs | 270 +++++++++++++++--- .../hooks/codex/user_prompt_submit.rs | 143 +++++++++- context/plans/codex-cli-integration.md | 9 + 4 files changed, 367 insertions(+), 56 deletions(-) diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index e34bba70..fc23db3f 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -50,6 +50,7 @@ impl NullableField { matches!(self, NullableField::Null) } + #[cfg(test)] pub(crate) fn as_value(&self) -> Option<&T> { match self { NullableField::Value(value) => Some(value), diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs index fb096e02..d5ec865e 100644 --- a/cli/src/services/hooks/codex/stop.rs +++ b/cli/src/services/hooks/codex/stop.rs @@ -17,17 +17,21 @@ use super::{CodexHookEvent, NullableField}; /// and one `parts` row (`part_type = "text"`, `text = last_assistant_message`) /// under session `cx_`, message `cx::assistant`. /// -/// Upstream Codex's `Stop` schema requires `last_assistant_message` and -/// types it `string | null`. This handler therefore distinguishes three -/// cases via [`NullableField`]: a missing field is a malformed payload that -/// errors so the outer Codex dispatcher fail-open boundary -/// (`run_codex_subcommand` → `log_codex_fail_open`) logs it and emits exact -/// empty stdout with no DB access; an explicit `null` is a valid, -/// upstream-legitimate "no assistant text this turn" signal that -/// short-circuits as a silent successful no-op *before* timestamp -/// acquisition or the Agent Trace DB is ever opened; a present value -/// (including an explicit empty string, persisted like any other text) is -/// captured normally. +/// Upstream Codex's `Stop` schema requires `session_id`, `turn_id`, and +/// `last_assistant_message` (typed `string | null`) on every Stop payload. +/// This handler validates all three *before* any side effect — timestamp +/// acquisition, Agent Trace DB access, or persistence — via +/// [`validate_stop_event`]. A missing/blank `session_id` or `turn_id`, or a +/// missing `last_assistant_message`, is a malformed payload that errors so +/// the outer Codex dispatcher fail-open boundary (`run_codex_subcommand` → +/// `log_codex_fail_open`) logs it and emits exact empty stdout with no DB +/// access — this is true even for an otherwise-valid explicit `null`: a +/// null Stop with a blank/missing identifier is still malformed and must +/// not reach the null no-op path. Only once identifiers and presence are +/// confirmed valid does an explicit `null` short-circuit as a silent +/// successful no-op *before* timestamp acquisition or the Agent Trace DB is +/// ever opened; a present value (including an explicit empty string, +/// persisted like any other text) is captured normally. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { handle_with_clock(repository_root, event, current_unix_time_ms) } @@ -41,41 +45,76 @@ fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) where F: FnOnce() -> Result, { - match &event.last_assistant_message { - NullableField::Missing => Err(anyhow::anyhow!( - "Invalid Codex Stop payload: field 'last_assistant_message' must be present." - )), - NullableField::Null => Ok(String::new()), - NullableField::Value(_) => { - let generated_at_unix_ms = now()?; + let validated = validate_stop_event(event)?; + + let Some(last_assistant_message) = validated.last_assistant_message else { + return Ok(String::new()); + }; + + let generated_at_unix_ms = now()?; - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for Codex Stop persistence.", - )?; + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Codex Stop persistence.", + )?; - capture_with(&db, event, generated_at_unix_ms) + persist_with( + &db, + &validated, + last_assistant_message, + generated_at_unix_ms, + ) +} + +/// A Codex `Stop` event whose `session_id`/`turn_id` are confirmed +/// non-blank and trimmed, and whose `last_assistant_message` presence has +/// already been confirmed (a missing field cannot produce a `ValidatedStop` +/// at all). `None` here means an explicit upstream `null` — the valid +/// "no assistant text this turn" no-op signal; `Some` carries a present +/// value (including an explicit empty string). +#[derive(Debug)] +struct ValidatedStop<'a> { + session_id: &'a str, + turn_id: &'a str, + last_assistant_message: Option<&'a str>, +} + +/// The single validation layer for `Stop` events: every required-field +/// check (`session_id`, `turn_id`, `last_assistant_message` presence) lives +/// here so no other function re-validates the same fields with subtly +/// different semantics. Runs before any timestamp acquisition or DB access. +fn validate_stop_event(event: &CodexHookEvent) -> Result> { + let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; + let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; + let last_assistant_message = match &event.last_assistant_message { + NullableField::Missing => { + return Err(anyhow::anyhow!( + "Invalid Codex Stop payload: field 'last_assistant_message' must be present." + )) } - } + NullableField::Null => None, + NullableField::Value(text) => Some(text.as_str()), + }; + + Ok(ValidatedStop { + session_id, + turn_id, + last_assistant_message, + }) } -/// Injectable counterpart of `handle` for deterministic testing against an -/// already-open Agent Trace DB. -fn capture_with( +/// Persists an already-validated `Stop` event with a known-present +/// assistant message against an already-open Agent Trace DB. Performs no +/// validation of its own. +fn persist_with( db: &RepositoryAgentTraceDb, - event: &CodexHookEvent, + validated: &ValidatedStop<'_>, + last_assistant_message: &str, generated_at_unix_ms: i64, ) -> Result { - let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; - let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; - let last_assistant_message = event.last_assistant_message.as_value().ok_or_else(|| { - anyhow::anyhow!( - "Invalid Codex Stop payload: field 'last_assistant_message' must be present for persistence." - ) - })?; - - let prefixed_session_id = prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, session_id); - let message_id = format!("cx:{turn_id}:assistant"); + let prefixed_session_id = + prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, validated.session_id); + let message_id = format!("cx:{}:assistant", validated.turn_id); db.insert_messages(vec![InsertMessageInsert { session_id: prefixed_session_id.clone(), @@ -87,7 +126,7 @@ fn capture_with( db.insert_parts(vec![InsertPartInsert { part_type: PartType::Text, - text: last_assistant_message.clone(), + text: last_assistant_message.to_string(), session_id: prefixed_session_id, message_id, generated_at_unix_ms, @@ -109,6 +148,27 @@ fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Resul } } +/// Test-only convenience wrapper preserving the pre-refactor `capture_with` +/// call shape (`event` + timestamp, against an already-open DB) for tests +/// that build a full `CodexHookEvent`. Routes through the same single +/// validation layer (`validate_stop_event`) as production `handle`, so it +/// exercises identical semantics — including the null no-op — rather than +/// re-implementing validation. +#[cfg(test)] +fn capture_with( + db: &RepositoryAgentTraceDb, + event: &CodexHookEvent, + generated_at_unix_ms: i64, +) -> Result { + let validated = validate_stop_event(event)?; + match validated.last_assistant_message { + Some(last_assistant_message) => { + persist_with(db, &validated, last_assistant_message, generated_at_unix_ms) + } + None => Ok(String::new()), + } +} + #[cfg(test)] mod tests { use std::{ @@ -261,15 +321,17 @@ mod tests { } #[test] - fn capture_with_rejects_a_null_last_assistant_message() { + fn capture_with_is_a_no_op_for_a_null_last_assistant_message() { let db_path = unique_test_db_path("null-last-assistant-message"); let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); let mut payload = event("session-1", "turn-1", "hello back"); payload.last_assistant_message = NullableField::Null; - let error = capture_with(&db, &payload, 1_000) - .expect_err("null last_assistant_message should error inside capture_with"); - assert!(error.to_string().contains("'last_assistant_message'")); + let output = capture_with(&db, &payload, 1_000) + .expect("null last_assistant_message is a valid no-op, not an error"); + assert_eq!(output, ""); + assert_eq!(message_rows(&db).len(), 0); + assert_eq!(part_rows(&db).len(), 0); remove_test_db(&db_path); } @@ -453,4 +515,126 @@ mod tests { .expect_err("a failed clock must propagate as an error for the outer fail-open boundary"); assert!(error.to_string().contains("clock failed")); } + + #[test] + fn handle_with_clock_rejects_a_null_stop_with_a_missing_session_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.session_id = None; + payload.last_assistant_message = NullableField::Null; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed Stop payload") + }) + .expect_err("a null Stop with a missing session_id must still be rejected as malformed"); + assert!(error.to_string().contains("'session_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_null_stop_with_an_empty_session_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.session_id = Some(String::new()); + payload.last_assistant_message = NullableField::Null; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed Stop payload") + }) + .expect_err("a null Stop with an empty session_id must still be rejected as malformed"); + assert!(error.to_string().contains("'session_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_null_stop_with_a_whitespace_only_session_id_without_calling_the_clock( + ) { + let mut payload = event("session-1", "turn-1", "unused"); + payload.session_id = Some(" ".to_string()); + payload.last_assistant_message = NullableField::Null; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed Stop payload") + }) + .expect_err( + "a null Stop with a whitespace-only session_id must still be rejected as malformed", + ); + assert!(error.to_string().contains("'session_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_null_stop_with_a_missing_turn_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.turn_id = None; + payload.last_assistant_message = NullableField::Null; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed Stop payload") + }) + .expect_err("a null Stop with a missing turn_id must still be rejected as malformed"); + assert!(error.to_string().contains("'turn_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_null_stop_with_an_empty_turn_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.turn_id = Some(String::new()); + payload.last_assistant_message = NullableField::Null; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed Stop payload") + }) + .expect_err("a null Stop with an empty turn_id must still be rejected as malformed"); + assert!(error.to_string().contains("'turn_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_null_stop_with_a_whitespace_only_turn_id_without_calling_the_clock( + ) { + let mut payload = event("session-1", "turn-1", "unused"); + payload.turn_id = Some(" ".to_string()); + payload.last_assistant_message = NullableField::Null; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed Stop payload") + }) + .expect_err( + "a null Stop with a whitespace-only turn_id must still be rejected as malformed", + ); + assert!(error.to_string().contains("'turn_id'")); + } + + #[test] + fn handle_with_clock_is_a_silent_no_op_for_null_with_padded_ids_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.session_id = Some(" session-1 ".to_string()); + payload.turn_id = Some(" turn-1 ".to_string()); + payload.last_assistant_message = NullableField::Null; + + // Padded-but-otherwise-valid identifiers must validate under their + // trimmed representation even though a null Stop persists nothing. + let output = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for an explicit null last_assistant_message") + }) + .expect("a null Stop with padded-but-valid identifiers should still be a successful no-op"); + assert_eq!(output, ""); + } + + #[test] + fn validate_stop_event_rejects_a_missing_last_assistant_message_with_valid_ids() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.last_assistant_message = NullableField::Missing; + + let error = validate_stop_event(&payload) + .expect_err("missing last_assistant_message should be rejected"); + assert!(error.to_string().contains("'last_assistant_message'")); + } + + #[test] + fn validate_stop_event_returns_none_for_an_explicit_null_with_valid_ids() { + let mut payload = event("session-1", "turn-1", "unused"); + payload.last_assistant_message = NullableField::Null; + + let validated = + validate_stop_event(&payload).expect("valid ids with a null message should validate"); + assert_eq!(validated.session_id, "session-1"); + assert_eq!(validated.turn_id, "turn-1"); + assert_eq!(validated.last_assistant_message, None); + } } diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index ec9dc492..b7a5723a 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -20,16 +20,21 @@ pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result where F: FnOnce() -> Result, { + let validated = validate_user_prompt_submit_event(event)?; + let generated_at_unix_ms = now()?; let db = open_agent_trace_db_for_hook_runtime( @@ -37,22 +42,47 @@ where "Failed to open Agent Trace DB for Codex UserPromptSubmit persistence.", )?; - capture_with(&db, event, generated_at_unix_ms) + persist_with(&db, &validated, generated_at_unix_ms) } -/// Injectable counterpart of `handle` for deterministic testing against an -/// already-open Agent Trace DB. -fn capture_with( - db: &RepositoryAgentTraceDb, +/// A Codex `UserPromptSubmit` event whose `session_id`/`turn_id` are +/// confirmed non-blank and trimmed, and whose `prompt` is confirmed +/// present and non-blank (but left untrimmed — prompt text is not +/// whitespace-normalized). +struct ValidatedUserPromptSubmit<'a> { + session_id: &'a str, + turn_id: &'a str, + prompt: &'a str, +} + +/// The single validation layer for `UserPromptSubmit` events: every +/// required-field check (`session_id`, `turn_id`, `prompt`) lives here so +/// no other function re-validates the same fields with subtly different +/// semantics. Runs before any timestamp acquisition or DB access. +fn validate_user_prompt_submit_event( event: &CodexHookEvent, - generated_at_unix_ms: i64, -) -> Result { +) -> Result> { let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; let prompt = required_field(event.prompt.as_deref(), "prompt")?; - let prefixed_session_id = prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, session_id); - let message_id = format!("cx:{turn_id}:user"); + Ok(ValidatedUserPromptSubmit { + session_id, + turn_id, + prompt, + }) +} + +/// Persists an already-validated `UserPromptSubmit` event against an +/// already-open Agent Trace DB. Performs no validation of its own. +fn persist_with( + db: &RepositoryAgentTraceDb, + validated: &ValidatedUserPromptSubmit<'_>, + generated_at_unix_ms: i64, +) -> Result { + let prefixed_session_id = + prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, validated.session_id); + let message_id = format!("cx:{}:user", validated.turn_id); db.insert_messages(vec![InsertMessageInsert { session_id: prefixed_session_id.clone(), @@ -64,7 +94,7 @@ fn capture_with( db.insert_parts(vec![InsertPartInsert { part_type: PartType::Text, - text: prompt.to_string(), + text: validated.prompt.to_string(), session_id: prefixed_session_id, message_id, generated_at_unix_ms, @@ -95,6 +125,21 @@ fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Resul } } +/// Test-only convenience wrapper preserving the pre-refactor `capture_with` +/// call shape (`event` + timestamp, against an already-open DB) for tests +/// that build a full `CodexHookEvent`. Routes through the same single +/// validation layer (`validate_user_prompt_submit_event`) as production +/// `handle`. +#[cfg(test)] +fn capture_with( + db: &RepositoryAgentTraceDb, + event: &CodexHookEvent, + generated_at_unix_ms: i64, +) -> Result { + let validated = validate_user_prompt_submit_event(event)?; + persist_with(db, &validated, generated_at_unix_ms) +} + #[cfg(test)] mod tests { use std::{ @@ -308,4 +353,76 @@ mod tests { .expect_err("a failed clock must propagate as an error for the outer fail-open boundary"); assert!(error.to_string().contains("clock failed")); } + + #[test] + fn handle_with_clock_rejects_a_missing_session_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "hello world"); + payload.session_id = None; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed UserPromptSubmit payload") + }) + .expect_err("missing session_id should be rejected before the clock is consulted"); + assert!(error.to_string().contains("'session_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_whitespace_only_session_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "hello world"); + payload.session_id = Some(" ".to_string()); + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed UserPromptSubmit payload") + }) + .expect_err("whitespace-only session_id should be rejected before the clock is consulted"); + assert!(error.to_string().contains("'session_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_missing_turn_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "hello world"); + payload.turn_id = None; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed UserPromptSubmit payload") + }) + .expect_err("missing turn_id should be rejected before the clock is consulted"); + assert!(error.to_string().contains("'turn_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_whitespace_only_turn_id_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "hello world"); + payload.turn_id = Some(" ".to_string()); + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed UserPromptSubmit payload") + }) + .expect_err("whitespace-only turn_id should be rejected before the clock is consulted"); + assert!(error.to_string().contains("'turn_id'")); + } + + #[test] + fn handle_with_clock_rejects_a_missing_prompt_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "hello world"); + payload.prompt = None; + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed UserPromptSubmit payload") + }) + .expect_err("missing prompt should be rejected before the clock is consulted"); + assert!(error.to_string().contains("'prompt'")); + } + + #[test] + fn handle_with_clock_rejects_a_whitespace_only_prompt_without_calling_the_clock() { + let mut payload = event("session-1", "turn-1", "hello world"); + payload.prompt = Some(" ".to_string()); + + let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { + panic!("clock must not be called for a malformed UserPromptSubmit payload") + }) + .expect_err("whitespace-only prompt should be rejected before the clock is consulted"); + assert!(error.to_string().contains("'prompt'")); + } } diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 9d4eefb2..bff1f511 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -455,6 +455,15 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify: `grep -Rn "unwrap_or(0)" cli/src/services/hooks/codex/` and `grep -Rn "unwrap_or_default" cli/src/services/hooks/codex/` — see audit above; no epoch-0 synthesis on any Codex path. - New/changed tests: `mod.rs` — `codex_hook_event_deserializes_{missing,explicit_null,empty_string,present_text}_last_assistant_message_as_{missing,null,value}` (raw-JSON deserialization through `CodexHookEvent`), `stop_dispatch_propagates_a_missing_last_assistant_message_field_for_the_outer_fail_open_boundary`, `stop_dispatch_is_a_silent_no_op_for_an_explicit_null_last_assistant_message` (both through the real `run_codex_subcommand_from_payload_at_state_root` dispatcher path), `log_codex_fail_open_logs_a_propagated_timestamp_failure_and_returns_empty_stdout` (`RecordingLogger` test double proves the boundary logs a propagated error and still returns exact `""`). `stop.rs` — `handle_with_clock_errors_for_a_missing_last_assistant_message_without_calling_the_clock`, `handle_with_clock_is_a_silent_no_op_for_null_without_calling_the_clock_or_opening_the_db` (panicking clock closure + nonexistent repository root proves both no-clock-call and no-DB-open for null), `handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence`, `capture_with_rejects_a_null_last_assistant_message`, `capture_with_persists_deserialized_raw_json_with_an_explicit_empty_string`/`..._with_normal_text` (raw JSON → `capture_with`, sidesteps the fact that `open_agent_trace_db_for_hook_runtime` — used by every persisting Stop/UserPromptSubmit call, unlike `apply_patch`'s dispatcher-injectable `state_root` seam — has no test-injectable storage root). `user_prompt_submit.rs` — `handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence`. - AC29/AC30/AC31/AC32 reconciled: AC29 marked `[x]` (T23 already satisfied it; the checkbox was stale). AC30 and AC31 marked `[x]` per this repair. AC32 remains `[ ]` — T25's scope (atomic/replay-safe transactional persistence) was not started. + - Follow-up repair 2 (2026-08-23): The first follow-up repair (above) still validated `session_id`/`turn_id` *after* branching on `last_assistant_message` presence — `stop::handle_with_clock` matched on `last_assistant_message` first (`Missing` → `Err`, `Null` → `Ok("")`, `Value` → validate ids inside `capture_with`), so an explicit-null Stop with a missing/blank `session_id`/`turn_id` incorrectly short-circuited to a successful no-op *before* identifier validation ever ran — e.g. `{"hook_event_name":"Stop","last_assistant_message":null}` (no `session_id`/`turn_id` at all) previously returned `Ok("")` instead of being rejected as malformed. Re-verified upstream again at the same commit `343074d4207d572809bd8cea15f4be1d09d98e0b` (byte-identical to current `main`): Stop's `session_id`/`turn_id` are required, non-nullable `String` — nullability applies only to `last_assistant_message` — and UserPromptSubmit's `session_id`/`turn_id`/`prompt` are likewise all required, non-nullable `String`. Fixed by extracting a single validation layer per handler that runs before every side effect: `stop.rs` gained `struct ValidatedStop<'a> { session_id: &'a str, turn_id: &'a str, last_assistant_message: Option<&'a str> }` and `fn validate_stop_event(event) -> Result>`, which validates/trims `session_id` then `turn_id` then classifies `last_assistant_message` (`Missing` → `Err`, `Null` → `Ok(None)`, `Value(v)` → `Ok(Some(v))`) — `None` here can only mean a validated explicit null, since a missing field can no longer produce a `ValidatedStop` at all. `handle_with_clock` now calls `validate_stop_event(event)?` first; only for `Some(message)` does it call `now()?` then open the DB then `persist_with(...)`; for `None` it returns `Ok(String::new())` immediately, after validation but before any side effect. `user_prompt_submit.rs` got the symmetric `struct ValidatedUserPromptSubmit<'a> { session_id, turn_id, prompt }` / `fn validate_user_prompt_submit_event(event) -> Result>`, called before `now()`/DB open in `handle_with_clock`. Both files' old `capture_with(db, event, timestamp)` — previously the single function that both validated fields *and* persisted — was split into validation (`validate_stop_event`/`validate_user_prompt_submit_event`) and a validation-free `persist_with(db, &validated, ..., timestamp)`; a `#[cfg(test)]`-only `capture_with` wrapper (`validate` then `persist_with`) was kept so existing event-shaped tests needed no call-site changes. `prompt` remains untrimmed per its existing semantic contract (only checked for blankness via `.trim().is_empty()`, not rewritten); an explicit empty assistant string (`Value("")`) still persists as `text == ""`, unchanged. `stop::tests::capture_with_rejects_a_null_last_assistant_message` was renamed to `capture_with_is_a_no_op_for_a_null_last_assistant_message` and now asserts `Ok("")` with zero message/part rows, since under the unified validation layer a validated null is never an error at any layer (previously `capture_with` treated `Null` as an error itself, a second, subtly different semantics for the same field the task explicitly asked to eliminate). No apply_patch change. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 124 passed, 0 failed (full `cli/Cargo.toml` suite also re-run: 558 passed, 0 failed). + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings'` — passed clean after gating two more test-only items (`ValidatedStop` needed `#[derive(Debug)]` for `.expect_err()`; `NullableField::as_value` — now only used from `#[cfg(test)]` deserialization tests in `mod.rs` since `stop.rs`/`user_prompt_submit.rs` no longer call it in production code — gated `#[cfg(test)]`). + - Verify: `cargo fmt --manifest-path cli/Cargo.toml` then `-- --check` — clean. + - Verify: `nix flake check` — passed: "all checks passed!". + - Verify: `grep -Rn "unwrap_or(0)" cli/src/services/hooks/codex/` (zero hits) and `grep -Rn "unwrap_or_default" cli/src/services/hooks/codex/` (one unrelated hit, `apply_patch/mod.rs:121` `tool_use_id`) — unchanged from the prior repair; still no epoch-zero synthesis on any Codex path. + - New Stop tests (validation-order): `handle_with_clock_rejects_a_null_stop_with_a_missing_session_id_without_calling_the_clock`, `..._an_empty_session_id...`, `..._a_whitespace_only_session_id...`, `..._a_missing_turn_id...`, `..._an_empty_turn_id...`, `..._a_whitespace_only_turn_id...` (all panicking-clock + nonexistent-repository-root, asserting `Err` mentioning the right field name), `handle_with_clock_is_a_silent_no_op_for_null_with_padded_ids_without_calling_the_clock` (padded-but-valid ids + null still validates and still short-circuits to `Ok("")`), `validate_stop_event_rejects_a_missing_last_assistant_message_with_valid_ids`, `validate_stop_event_returns_none_for_an_explicit_null_with_valid_ids` (direct unit tests of the new validation function). + - New UserPromptSubmit tests (validation-order): `handle_with_clock_rejects_a_missing_session_id_without_calling_the_clock`, `..._a_whitespace_only_session_id...`, `..._a_missing_turn_id...`, `..._a_whitespace_only_turn_id...`, `..._a_missing_prompt...`, `..._a_whitespace_only_prompt...` (all panicking-clock + nonexistent-repository-root). + - AC29/AC30/AC31/AC32 re-confirmed unchanged: AC29 `[x]`, AC30 `[x]`, AC31 `[x]` (now additionally covering the corrected validation order), AC32 remains `[ ]` (T25 not started). - [ ] T25: `Persist Codex conversation text events atomically and replay-safely` (status:todo) - Task ID: T25 From 31c371d1ce5decc13a0aa3e3888483faa4688990 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 14:03:43 +0200 Subject: [PATCH 18/20] agent-trace: Persist Codex conversation events atomically Prevent replayed or concurrent Codex UserPromptSubmit and Stop deliveries from duplicating message/part rows by using one transactional existence check and pair insert with rollback on failure. Update the related runtime documentation and complete plan task T25 with verification evidence. Plan: codex-cli-integration (T25) Co-authored-by: SCE --- cli/src/services/agent_trace_db/mod.rs | 49 +++++ cli/src/services/agent_trace_db/repository.rs | 190 +++++++++++++++++- cli/src/services/db/mod.rs | 127 ++++++++++++ cli/src/services/hooks/codex/stop.rs | 32 +-- .../hooks/codex/user_prompt_submit.rs | 32 +-- context/architecture.md | 2 +- context/context-map.md | 2 +- context/plans/codex-cli-integration.md | 96 +++++---- context/sce/agent-trace-db.md | 7 +- .../sce/agent-trace-hooks-command-routing.md | 2 +- context/sce/codex-integration-runtime.md | 23 ++- 11 files changed, 473 insertions(+), 89 deletions(-) diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 258afec3..1c5ae8b7 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -60,6 +60,15 @@ pub const INSERT_PART_SQL: &str = "INSERT INTO parts (type, text, message_id, session_id, generated_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5)"; +/// Parameterized SQL for checking whether a message row already exists, +/// used as the existence guard for +/// [`insert_conversation_text_event_with`]. +const SELECT_MESSAGE_EXISTS_SQL: &str = + "SELECT 1 FROM messages WHERE session_id = ?1 AND message_id = ?2 LIMIT 1"; + +const CONVERSATION_TEXT_EVENT_OPERATION_NAME: &str = "insert conversation text event"; +const CONVERSATION_TEXT_EVENT_RETRY_HINT: &str = "retry after the database lock clears; if the issue persists, stop other SCE processes using this database and rerun the command"; + /// Diff trace payload to persist in the agent trace database. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DiffTraceInsert<'a> { @@ -330,6 +339,46 @@ fn insert_parts_with(db: &TursoDb, inputs: Vec) db.execute(&sql, params) } +/// Atomically insert one conversation `messages` row and its one `parts` +/// row: if `(message.session_id, message.message_id)` already exists, this +/// is a no-op (`Ok(false)`); otherwise both rows insert together in one +/// transaction (`Ok(true)`). `fail_before_part_insert` is a test-only hook +/// forcing the transaction to fail after the message insert and before the +/// part insert, to prove both roll back together. +fn insert_conversation_text_event_with( + db: &TursoDb, + message: InsertMessageInsert, + part: InsertPartInsert, + fail_before_part_insert: bool, +) -> Result { + let exists_params = (message.session_id.clone(), message.message_id.clone()); + let message_params = ( + message.session_id, + message.message_id, + message.role.to_string(), + message.generated_at_unix_ms, + ); + let part_params = ( + part.part_type.to_string(), + part.text, + part.message_id, + part.session_id, + part.generated_at_unix_ms, + ); + + db.execute_transactional_insert_pair_if_absent( + CONVERSATION_TEXT_EVENT_OPERATION_NAME, + CONVERSATION_TEXT_EVENT_RETRY_HINT, + SELECT_MESSAGE_EXISTS_SQL, + exists_params, + INSERT_MESSAGE_SQL, + message_params, + INSERT_PART_SQL, + part_params, + fail_before_part_insert, + ) +} + fn numbered_placeholders(start: usize, count: usize) -> String { let placeholders = (start..start + count) .map(|index| format!("?{index}")) diff --git a/cli/src/services/agent_trace_db/repository.rs b/cli/src/services/agent_trace_db/repository.rs index 1fc90b9c..9cd63d2e 100644 --- a/cli/src/services/agent_trace_db/repository.rs +++ b/cli/src/services/agent_trace_db/repository.rs @@ -23,10 +23,11 @@ use crate::{ }; use super::{ - insert_agent_trace_with, insert_diff_trace_with, insert_message_with, insert_messages_with, - insert_part_with, insert_parts_with, insert_post_commit_patch_intersection_with, - recent_diff_trace_patches_with, AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, - InsertPartInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, + insert_agent_trace_with, insert_conversation_text_event_with, insert_diff_trace_with, + insert_message_with, insert_messages_with, insert_part_with, insert_parts_with, + insert_post_commit_patch_intersection_with, recent_diff_trace_patches_with, AgentTraceInsert, + DiffTraceInsert, InsertMessageInsert, InsertPartInsert, PostCommitPatchIntersectionInsert, + RecentDiffTracePatches, }; const REPOSITORY_AGENT_TRACE_SCHEMA_SETUP_GUIDANCE: &str = "Run 'sce setup'."; @@ -277,6 +278,33 @@ impl RepositoryAgentTraceDb { pub fn insert_parts(&self, inputs: Vec) -> Result { insert_parts_with(self, inputs) } + + /// Atomically insert one conversation `messages` row and its one + /// `parts` row: if `(message.session_id, message.message_id)` already + /// exists, this is a no-op (`Ok(false)`); otherwise both rows insert + /// together in one transaction (`Ok(true)`). Used by conversation + /// text-event handlers (e.g. Codex `UserPromptSubmit`/`Stop`) in place + /// of separate `insert_messages`/`insert_parts` calls, so a replayed or + /// concurrent duplicate delivery never produces an orphaned `parts` row. + pub fn insert_conversation_text_event( + &self, + message: InsertMessageInsert, + part: InsertPartInsert, + ) -> Result { + insert_conversation_text_event_with(self, message, part, false) + } + + /// Test-only counterpart of [`insert_conversation_text_event`] that + /// forces the transaction to fail after the message insert and before + /// the part insert, proving both statements roll back together. + #[cfg(test)] + pub(crate) fn insert_conversation_text_event_with_injected_failure( + &self, + message: InsertMessageInsert, + part: InsertPartInsert, + ) -> Result { + insert_conversation_text_event_with(self, message, part, true) + } } #[cfg(test)] @@ -326,6 +354,16 @@ mod tests { !rows.is_empty() } + fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(Into::into) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + fn table_sql(db: &RepositoryAgentTraceDb, name: &str) -> String { db.query_map( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -726,6 +764,150 @@ mod tests { remove_test_db(&db_path); } + fn conversation_text_event_fixture() -> (InsertMessageInsert, InsertPartInsert) { + ( + InsertMessageInsert { + session_id: "cx_session-1".to_string(), + message_id: "cx:turn-1:user".to_string(), + role: MessageRole::User, + generated_at_unix_ms: 1_000, + }, + InsertPartInsert { + part_type: PartType::Text, + text: "hello world".to_string(), + session_id: "cx_session-1".to_string(), + message_id: "cx:turn-1:user".to_string(), + generated_at_unix_ms: 1_000, + }, + ) + } + + #[test] + fn insert_conversation_text_event_inserts_message_and_part_together() { + let db_path = unique_test_db_path("conversation-event-insert"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let (message, part) = conversation_text_event_fixture(); + + let inserted = db + .insert_conversation_text_event(message, part) + .expect("conversation text event insert should succeed"); + + assert!(inserted, "first delivery should insert both rows"); + assert_eq!(row_count(&db, "messages"), 1); + assert_eq!(row_count(&db, "parts"), 1); + + remove_test_db(&db_path); + } + + #[test] + fn insert_conversation_text_event_is_a_no_op_on_sequential_replay() { + let db_path = unique_test_db_path("conversation-event-replay"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let (message, part) = conversation_text_event_fixture(); + let first = db + .insert_conversation_text_event(message, part) + .expect("first delivery should succeed"); + + let (message, part) = conversation_text_event_fixture(); + let second = db + .insert_conversation_text_event(message, part) + .expect("replayed delivery should succeed"); + + assert!(first); + assert!(!second, "a replayed delivery must be a no-op"); + assert_eq!(row_count(&db, "messages"), 1); + assert_eq!(row_count(&db, "parts"), 1); + + remove_test_db(&db_path); + } + + #[test] + fn insert_conversation_text_event_ten_sequential_replays_still_leave_one_row_pair() { + let db_path = unique_test_db_path("conversation-event-replay-ten"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + for _ in 0..10 { + let (message, part) = conversation_text_event_fixture(); + db.insert_conversation_text_event(message, part) + .expect("every replayed delivery should succeed"); + } + + assert_eq!(row_count(&db, "messages"), 1); + assert_eq!(row_count(&db, "parts"), 1); + + remove_test_db(&db_path); + } + + #[test] + fn insert_conversation_text_event_injected_failure_rolls_back_both_rows() { + let db_path = unique_test_db_path("conversation-event-rollback"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let (message, part) = conversation_text_event_fixture(); + + let error = db + .insert_conversation_text_event_with_injected_failure(message, part) + .expect_err("an injected failure before the part insert should propagate as an error"); + assert!(error.to_string().contains("injected failure")); + + assert_eq!( + row_count(&db, "messages"), + 0, + "the message row must roll back along with the failed part insert" + ); + assert_eq!(row_count(&db, "parts"), 0); + + remove_test_db(&db_path); + } + + #[test] + fn insert_conversation_text_event_concurrent_duplicate_delivery_leaves_one_row_pair() { + use std::sync::Arc; + + let db_path = unique_test_db_path("conversation-event-concurrent"); + + // Create the schema up front so every thread races only on the + // conversation text event insert, not schema creation, mirroring + // `concurrent_initialization_converges_on_one_source_instance_id`. + RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let db_path = Arc::new(db_path); + let handles: Vec<_> = (0..4) + .map(|_| { + let db_path = Arc::clone(&db_path); + std::thread::spawn(move || { + let db = RepositoryAgentTraceDb::open_without_migrations_at(&*db_path) + .expect("repository DB should reopen for concurrent delivery"); + let (message, part) = conversation_text_event_fixture(); + db.insert_conversation_text_event(message, part) + }) + }) + .collect(); + + let results: Vec = handles + .into_iter() + .map(|handle| { + handle + .join() + .expect("worker thread should not panic") + .expect("every concurrent delivery attempt should succeed") + }) + .collect(); + + assert_eq!( + results.iter().filter(|inserted| **inserted).count(), + 1, + "exactly one concurrent delivery should have performed the insert" + ); + + let db = RepositoryAgentTraceDb::open_without_migrations_at(&*db_path) + .expect("repository DB should reopen for verification"); + assert_eq!(row_count(&db, "messages"), 1); + assert_eq!(row_count(&db, "parts"), 1); + + remove_test_db(&db_path); + } + #[test] fn recent_diff_trace_reads_all_repository_rows_without_checkout_filter() { let db_path = unique_test_db_path("recent-repository-level"); diff --git a/cli/src/services/db/mod.rs b/cli/src/services/db/mod.rs index 9f08cdee..ffeb3250 100644 --- a/cli/src/services/db/mod.rs +++ b/cli/src/services/db/mod.rs @@ -277,6 +277,50 @@ fn apply_migration( }) } +/// Body of [`TursoDb::execute_transactional_insert_pair_if_absent`], run +/// against an already-open transaction. Kept as a standalone `async fn` so +/// the caller can uniformly commit on `Ok` and roll back on `Err`. +#[allow(clippy::too_many_arguments)] +async fn execute_insert_pair_if_absent_body( + tx: &turso::transaction::Transaction<'_>, + db_name: &str, + exists_sql: &str, + exists_params: turso::params::Params, + first_sql: &str, + first_params: turso::params::Params, + second_sql: &str, + second_params: turso::params::Params, + fail_before_second: bool, +) -> Result { + let mut rows = tx + .query(exists_sql, exists_params) + .await + .map_err(|e| anyhow::anyhow!("{db_name} existence check failed: {exists_sql}: {e}"))?; + let already_exists = rows + .next() + .await + .map_err(|e| anyhow::anyhow!("{db_name} existence row fetch failed: {exists_sql}: {e}"))? + .is_some(); + + if already_exists { + return Ok(false); + } + + tx.execute(first_sql, first_params) + .await + .map_err(|e| anyhow::anyhow!("{db_name} execute failed: {first_sql}: {e}"))?; + + if fail_before_second { + anyhow::bail!("{db_name} injected failure before second statement (test-only)"); + } + + tx.execute(second_sql, second_params) + .await + .map_err(|e| anyhow::anyhow!("{db_name} execute failed: {second_sql}: {e}"))?; + + Ok(true) +} + struct TursoConnectionCore { conn: turso::Connection, runtime: tokio::runtime::Runtime, @@ -557,6 +601,89 @@ impl TursoDb { ) } + /// Run an "insert row pair if absent" write transaction. + /// + /// If `exists_sql` (bound to `exists_params`) finds a matching row, the + /// transaction is rolled back as a no-op and this returns `false`. + /// Otherwise `first_sql` then `second_sql` execute in order inside one + /// `BEGIN IMMEDIATE` transaction and commit together, returning `true`. + /// `BEGIN IMMEDIATE` serializes concurrent callers against the same + /// database file, so the existence check and both inserts are never + /// interleaved with another writer's attempt. The whole attempt is + /// retried as one unit on transient failure. + /// + /// `fail_before_second` is a test-only hook: when `true`, an error is + /// forced immediately after `first_sql` succeeds and before `second_sql` + /// runs or the transaction commits, so callers can prove the whole + /// transaction — including the already-executed `first_sql` — rolls + /// back together. + #[allow(clippy::too_many_arguments)] + pub fn execute_transactional_insert_pair_if_absent( + &self, + operation_name: &str, + retry_hint: &str, + exists_sql: &str, + exists_params: impl turso::params::IntoParams, + first_sql: &str, + first_params: impl turso::params::IntoParams, + second_sql: &str, + second_params: impl turso::params::IntoParams, + fail_before_second: bool, + ) -> Result { + let db_name = M::db_name(); + let exists_params = turso::params::IntoParams::into_params(exists_params).map_err(|e| { + anyhow::anyhow!("{db_name} parameter conversion failed: {exists_sql}: {e}") + })?; + let first_params = turso::params::IntoParams::into_params(first_params).map_err(|e| { + anyhow::anyhow!("{db_name} parameter conversion failed: {first_sql}: {e}") + })?; + let second_params = turso::params::IntoParams::into_params(second_params).map_err(|e| { + anyhow::anyhow!("{db_name} parameter conversion failed: {second_sql}: {e}") + })?; + + run_with_retry_sync( + resolve_query_retry_policy::(), + operation_name, + retry_hint, + |_| { + block_on_isolated(&self.core.runtime, async { + let tx = turso::transaction::Transaction::new_unchecked( + &self.core.conn, + turso::transaction::TransactionBehavior::Immediate, + ) + .await + .map_err(|e| anyhow::anyhow!("{db_name} failed to begin transaction: {e}"))?; + + let outcome = execute_insert_pair_if_absent_body( + &tx, + db_name, + exists_sql, + exists_params.clone(), + first_sql, + first_params.clone(), + second_sql, + second_params.clone(), + fail_before_second, + ) + .await; + + match outcome { + Ok(inserted) => { + tx.commit().await.map_err(|e| { + anyhow::anyhow!("{db_name} failed to commit transaction: {e}") + })?; + Ok(inserted) + } + Err(err) => { + let _ = tx.rollback().await; + Err(err) + } + } + }) + }, + ) + } + /// Execute a SQL query and synchronously map all returned rows. pub fn query_map( &self, diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs index d5ec865e..c19a9856 100644 --- a/cli/src/services/hooks/codex/stop.rs +++ b/cli/src/services/hooks/codex/stop.rs @@ -116,22 +116,22 @@ fn persist_with( prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, validated.session_id); let message_id = format!("cx:{}:assistant", validated.turn_id); - db.insert_messages(vec![InsertMessageInsert { - session_id: prefixed_session_id.clone(), - message_id: message_id.clone(), - role: MessageRole::Assistant, - generated_at_unix_ms, - }]) - .context("Failed to insert Codex Stop message row.")?; - - db.insert_parts(vec![InsertPartInsert { - part_type: PartType::Text, - text: last_assistant_message.to_string(), - session_id: prefixed_session_id, - message_id, - generated_at_unix_ms, - }]) - .context("Failed to insert Codex Stop text part row.")?; + db.insert_conversation_text_event( + InsertMessageInsert { + session_id: prefixed_session_id.clone(), + message_id: message_id.clone(), + role: MessageRole::Assistant, + generated_at_unix_ms, + }, + InsertPartInsert { + part_type: PartType::Text, + text: last_assistant_message.to_string(), + session_id: prefixed_session_id, + message_id, + generated_at_unix_ms, + }, + ) + .context("Failed to insert Codex Stop message/text-part event.")?; Ok(String::new()) } diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index b7a5723a..fc044cd4 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -84,22 +84,22 @@ fn persist_with( prefixed_conversation_trace_session_id(CODEX_TOOL_NAME, validated.session_id); let message_id = format!("cx:{}:user", validated.turn_id); - db.insert_messages(vec![InsertMessageInsert { - session_id: prefixed_session_id.clone(), - message_id: message_id.clone(), - role: MessageRole::User, - generated_at_unix_ms, - }]) - .context("Failed to insert Codex UserPromptSubmit message row.")?; - - db.insert_parts(vec![InsertPartInsert { - part_type: PartType::Text, - text: validated.prompt.to_string(), - session_id: prefixed_session_id, - message_id, - generated_at_unix_ms, - }]) - .context("Failed to insert Codex UserPromptSubmit text part row.")?; + db.insert_conversation_text_event( + InsertMessageInsert { + session_id: prefixed_session_id.clone(), + message_id: message_id.clone(), + role: MessageRole::User, + generated_at_unix_ms, + }, + InsertPartInsert { + part_type: PartType::Text, + text: validated.prompt.to_string(), + session_id: prefixed_session_id, + message_id, + generated_at_unix_ms, + }, + ) + .context("Failed to insert Codex UserPromptSubmit message/text-part event.")?; Ok(String::new()) } diff --git a/context/architecture.md b/context/architecture.md index ebc5cf4f..fdac31fb 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -48,7 +48,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. Codex also differs from all three other targets in the arguments-reference token it passes to `skillDocuments`: OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input` (its skill loading provides no such substitution), so Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by that token alone. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`). It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers; `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`). It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive (a replayed or concurrent duplicate delivery leaves exactly one row pair); `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` and `skillDocuments` additionally take an `argumentsReference` string naming the invocation input in skill-mode prose — `$ARGUMENTS` for OpenCode, Claude, and Pi, whose harnesses substitute it, or a plain-prose token for a target whose skill loading does not (Codex passes `invocation input`); `renderCommand`'s thin wrapper text is unaffected and always states the literal `$ARGUMENTS` its harness substitutes. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). diff --git a/context/context-map.md b/context/context-map.md index f0a7baca..16c324ef 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -75,7 +75,7 @@ Feature/domain context: - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) -- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, unregistered), idempotent `cx_` session prefixing with required trimmed non-empty sessions and truthful reported model-ID preservation (blank models are absent), the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row through the existing `insert_messages`/`insert_parts` helpers with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice outer-normalizing then parsing, resolving paths from event cwd against the real Git root, normalizing, and persisting a `diff_traces` row for provable Add/Update evidence under deterministic event-scoped synthetic line identities derived from `tool_use_id`; generated hook commands also resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; all non-policy success/fail-open paths are silent while Bash denial retains Codex's structured response) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, unregistered), idempotent `cx_` session prefixing with required trimmed non-empty sessions and truthful reported model-ID preservation (blank models are absent), the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice outer-normalizing then parsing, resolving paths from event cwd against the real Git root, normalizing, and persisting a `diff_traces` row for provable Add/Update evidence under deterministic event-scoped synthetic line identities derived from `tool_use_id`; generated hook commands also resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; all non-policy success/fail-open paths are silent while Bash denial retains Codex's structured response) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index bff1f511..9f2d8a20 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -77,7 +77,7 @@ This revision extends the completed Codex rollout with six correctness hardening - Validate: Codex Stop dispatcher/handler tests cover normal text, null, explicit empty string, exact stdout, and no-write behavior. - [x] AC31: Codex conversation handlers trim and persist validated non-empty `session_id` and `turn_id` consistently, acquire timestamps with fallible propagation, and never persist epoch-0 fallback provenance. Timestamp acquisition failure is fail-open with no DB write for UserPromptSubmit, Stop, and all other Codex trace paths that could otherwise synthesize zero. - Validate: Codex handler tests cover whitespace-padded identifiers, missing identifiers, timestamp failures, and source inspection/tests for `unwrap_or(0)`, zero timestamp literals, and equivalent default fallbacks. -- [ ] AC32: UserPromptSubmit and Stop persist one logical conversation text event through one transactional DB primitive: parent message plus text part are inserted together or neither is inserted; replay of one, ten, or concurrent duplicate deliveries is a successful no-op with exactly one message and one part; injected part failure rolls back the parent message; apply_patch persistence remains on its existing independent diff-trace API and no migration is added. +- [x] AC32: UserPromptSubmit and Stop persist one logical conversation text event through one transactional DB primitive: parent message plus text part are inserted together or neither is inserted; replay of one, ten, or concurrent duplicate deliveries is a successful no-op with exactly one message and one part; injected part failure rolls back the parent message; apply_patch persistence remains on its existing independent diff-trace API and no migration is added. - Validate: Agent Trace DB atomic-event tests cover replay, transaction rollback via an injectable failure seam, and the SQLite write-serialization/concurrent duplicate contract; both Codex handlers use the primitive. ### Full validation @@ -465,13 +465,27 @@ Persist this field in every plan; this is durable plan state, not chat state: - New UserPromptSubmit tests (validation-order): `handle_with_clock_rejects_a_missing_session_id_without_calling_the_clock`, `..._a_whitespace_only_session_id...`, `..._a_missing_turn_id...`, `..._a_whitespace_only_turn_id...`, `..._a_missing_prompt...`, `..._a_whitespace_only_prompt...` (all panicking-clock + nonexistent-repository-root). - AC29/AC30/AC31/AC32 re-confirmed unchanged: AC29 `[x]`, AC30 `[x]`, AC31 `[x]` (now additionally covering the corrected validation order), AC32 remains `[ ]` (T25 not started). -- [ ] T25: `Persist Codex conversation text events atomically and replay-safely` (status:todo) +- [x] T25: `Persist Codex conversation text events atomically and replay-safely` (status:done) - Task ID: T25 - Scope: In — add one repository DB operation for exactly-once conversation text events that serializes the existence check and parent-plus-part insert in a transaction, expose a failure-injection seam for rollback tests, and migrate only UserPromptSubmit/Stop to it. Out — apply_patch/diff-trace persistence, schema migrations, new uniqueness columns, and per-handler dedupe implementations. - Dependencies: T24 - Done when: one transaction inserts both rows or neither, duplicate sequential and concurrent deliveries are successful no-ops with one message and one part, injected part failure leaves zero rows, and existing conversation-trace writers plus apply_patch persistence remain unchanged. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db hooks::codex'` - - Context synchronization: pending + - Completed: 2026-08-23 + - Files changed: `cli/src/services/db/mod.rs`, `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/agent_trace_db/repository.rs`, `cli/src/services/hooks/codex/user_prompt_submit.rs`, `cli/src/services/hooks/codex/stop.rs` + - Result: Added a generic write-transaction primitive `TursoDb::execute_transactional_insert_pair_if_absent` (`cli/src/services/db/mod.rs`) using the vendored `turso` crate's `Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)` (available on `&self`, so it needed no change to `TursoDb`'s existing non-`mut` API): it runs an existence-check `SELECT`, then — only if no row matches — `first_sql` then `second_sql`, committing once; a match rolls back as a no-op (`Ok(false)`); `BEGIN IMMEDIATE` serializes concurrent callers writing to the same database file so the existence check and both inserts are never interleaved with another writer's attempt; the whole attempt is retried as one unit by the existing `run_with_retry_sync` on transient failure. Its `fail_before_second: bool` parameter is the required test-only failure-injection seam: when set, an error is forced immediately after `first_sql` succeeds and before `second_sql` runs or the transaction commits. This primitive is schema-agnostic (raw SQL + params, matching `execute`/`query`'s existing shape), preserving the existing `db` → `agent_trace_db` layering rather than importing message/part schema knowledge into `db/mod.rs`. On top of it, `cli/src/services/agent_trace_db/mod.rs` adds `insert_conversation_text_event_with` (a new `SELECT_MESSAGE_EXISTS_SQL` existence guard plus the existing `INSERT_MESSAGE_SQL`/`INSERT_PART_SQL` as the pair), and `RepositoryAgentTraceDb` (`repository.rs`) exposes it as `pub fn insert_conversation_text_event(message, part) -> Result` plus a `#[cfg(test)] pub(crate) fn insert_conversation_text_event_with_injected_failure` counterpart (`true` for the seam) — mirroring this codebase's existing precedent of `#[cfg(test)]`-gated test seams (e.g. `stop.rs`'s `capture_with`) rather than a runtime feature flag. `user_prompt_submit.rs`'s and `stop.rs`'s `persist_with` were switched from two independent `insert_messages`/`insert_parts` calls to this one atomic call; both handlers' existing single validation layer, timestamp handling, and `cx_`/`cx::` ID formatting were left untouched. The pre-existing multi-row `insert_messages`/`insert_parts` (and their single-row counterparts) remain unchanged and still serve OpenCode/Claude/Pi conversation-trace writers (`cli/src/services/sync/sync.rs`, `cli/src/services/hooks/mod.rs`, `cli/src/services/agent_trace_export/mod.rs`) and this plan's own `apply_patch` diff-trace persistence, none of which were touched — confirmed by `grep` showing their continued call sites. No Agent Trace DB schema migration and no new uniqueness column were added, per this task's own out-of-scope boundary; the existence check is a plain `SELECT`, and `messages`' existing `ON CONFLICT (session_id, message_id) DO NOTHING` constraint is retained as defense-in-depth but is no longer relied on for correctness under the new transaction. Added five new tests in `agent_trace_db/repository.rs`'s test module: a basic insert-both-rows case; a sequential-replay no-op case; a ten-times-sequential-replay case (still one row pair); an injected-failure rollback case (asserts zero message and zero part rows survive); and a four-thread concurrent-duplicate-delivery case (mirroring this file's existing `concurrent_initialization_converges_on_one_source_instance_id` precedent of creating the schema once via `new_at` then racing separate `open_without_migrations_at` connections) asserting exactly one thread's attempt actually inserted and exactly one row pair exists afterward — verified stable across 5 repeated local runs. An initial 8-thread version of the concurrent test exceeded the default `QUERY_RETRY_POLICY`'s retry budget (5 attempts, 200ms timeout, 25–100ms backoff) under contention and was reduced to 4 threads to match this codebase's own established concurrency-test scale and stay reliably within that budget. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db'` — passed: 21 passed, 0 failed, including the 5 new `insert_conversation_text_event_*` tests. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 124 passed, 0 failed, including `user_prompt_submit`'s and `stop`'s `capture_with_does_not_duplicate_the_parent_message_on_reprocess` now exercising the atomic path. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml'` (full suite) — passed: 563 passed, 0 failed (558 prior + 5 new). + - Verify: `nix flake check` — passed: "all checks passed!" (`cli-tests`, `cli-clippy`, `cli-fmt`; no Codex asset/generation surface touched, so `cli-generated-input`/`pkl-generated`/`codex-hook-command` were unaffected and served from cache). + - Verify: `cargo fmt --manifest-path cli/Cargo.toml -- --check` — clean. + - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings'` — clean, no findings. + - Context impact: root — this plan's own "Context sync" list already names `context/sce/codex-integration-runtime.md` for exactly this update. Two documented facts in this file and in `context/sce/agent-trace-db.md` are now stale: (1) `codex-integration-runtime.md`'s "Implemented slices" section states Codex's `UserPromptSubmit`/`Stop` "go through `RepositoryAgentTraceDb::insert_messages`/`insert_parts` — the same insert helpers ... there is no Codex-specific DB adapter" and that "only the parent message row's non-duplication is guaranteed on reprocess, not the part row's" — both now false: both arms call the new `insert_conversation_text_event`, and both the message and part rows are now guaranteed non-duplicated together. (2) `agent-trace-db.md`'s "Codex `sce hooks codex`" section states Codex's `UserPromptSubmit`/`Stop` arms are "reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup" — also now stale for the same reason. Neither file documents a schema change (there is none) or a change to OpenCode/Claude/Pi/`apply_patch` behavior (unchanged). + - Context synchronization: synced + - Root pass: all five root files read and confirmed. `context/architecture.md` and `context/context-map.md` each carried the same stale "`insert_messages`/`insert_parts`" claim about Codex's `UserPromptSubmit`/`Stop` arms in their Codex-dispatcher prose and were corrected in place to describe the shared `insert_conversation_text_event` atomic primitive. `context/overview.md`, `context/glossary.md` (its `messages table`/`parts table` entries document schema-level facts unaffected by this application-level change), and `context/patterns.md` were verified with no contradiction and left unedited. + - Domain files updated: `context/sce/agent-trace-db.md` (new `insert_conversation_text_event` entry in "Shared insert/query payloads", added to the repository-level write-helper list and the message/part API-surface Non-goals bullet, and its own stale Codex-specific claim corrected), `context/sce/codex-integration-runtime.md` ("Implemented slices" section corrected; file held at exactly 250 lines), `context/sce/agent-trace-hooks-command-routing.md` (its `sce hooks codex` paragraph corrected; OpenCode/Claude/Pi's own `conversation-trace` paragraph, which genuinely still uses `insert_messages`/`insert_parts`, was left unchanged). + - No qualifying architecture decision: this is an internal correctness primitive behind an existing write path, not a new system boundary, public/cross-domain interface, data model/schema change, compatibility contract, security posture, deployment change, or major dependency. `sce-decision` was not invoked. + - No new glossary term: "conversation text event" describes existing `messages`/`parts` concepts already covered by the glossary's `messages table (Agent Trace DB)`/`parts table (Agent Trace DB)` entries; it is not new domain language. ## Open questions @@ -485,41 +499,51 @@ Persist this field in every plan; this is durable plan state, not chat state: ### Commands run -- `nix run .#pkl-check-generated` -> exit 0 (ephemeral Pkl generation passed: 135 files) -- `nix flake check` -> exit 0 (all checks passed) -- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml'` -> exit 0 (475 passed, 0 failed) -- `nix develop -c sh -c './scripts/test-codex-hook-command.sh'` -> exit 0 (root, nested, spaced-path, stdin, and Git-failure cases passed) -- scratch `sce setup --codex --non-interactive`, `sce setup --all --non-interactive`, and paired `--workflow brownfield`/default runs -> exit 0 (setup, target, asset, config, and optional-workflow checks passed) -- `git diff -- cli/migrations/agent-trace-repository` and `git status --short -- cli/migrations/agent-trace-repository` -> exit 0 (no migration changes) -- Codex hardened pipeline source/status inspection -> exit 0 (no forbidden snapshot/pending artifacts; existing persistence/intersection paths present) +- `nix run .#pkl-check-generated` -> exit 0 (ephemeral Pkl generation passed: 135 files, inventory sha256 7064aa074a1bf94f6e525df85ff1843d479be96e82b4044482980d31446e20db — unchanged from the prior validation pass) +- `nix flake check` -> exit 0 (all checks passed: cli-tests, cli-clippy, cli-fmt, cli-generated-input, pkl-generated, codex-hook-command, plus the full non-Rust check set) +- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db::'` -> exit 0 (21 passed, 0 failed, including the five `insert_conversation_text_event_*` atomic-primitive tests: basic insert, sequential no-op, 10x sequential no-op, injected-failure rollback, concurrent duplicate delivery) +- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::'` -> exit 0 (124 passed, 0 failed) +- `git status --short -- cli/migrations/agent-trace-repository` and `git diff --stat -- cli/migrations/agent-trace-repository` -> exit 0 (no migration changes) +- `git status --short` (repository root) -> exit 0 (only `context/plans/codex-cli-integration.md` modified — this plan's own task-completion/validation-report edits; no leftover debug artifacts, temp files, or scaffolding) + +This re-validation run was executed fresh in this session (not a re-print of the prior report): both full-validation commands and the two most relevant targeted Rust test suites were re-run directly against the current working tree and produced identical pass counts and the identical Pkl inventory hash as the prior 2026-08-23 pass, confirming no regression since that report was written. + +Prior task-level evidence for AC1-AC25 (setup/target installs, generated-hook inspection, Codex persistence/policy/apply_patch/attribution test suites, doctor tests, and the realistic end-to-end pipeline test) is recorded per-task in the Task stack above and was re-covered by this run's `nix flake check`; it was not independently re-run command-by-command in this session since no implementation changed since the prior validation pass and the full suite (`cli-tests`) re-executes those same tests. ### Success-criteria verification -- [x] AC1: setup installs both Codex output roots and persists `integrations.target` -> scratch Git repository passed. -- [x] AC2: `setup --all` installs Codex alongside OpenCode, Claude, and Pi -> scratch Git repository passed. -- [x] AC3: core and optional workflow selection is correct -> generation inspection and paired scratch setup runs passed. -- [x] AC4: generated Codex hook registrations are exactly the four required entries -> generated inspection and hook-command check passed. -- [x] AC5: `UserPromptSubmit` produces one user message and text part -> full test suite passed the Codex persistence tests. -- [x] AC6: `Stop` produces one assistant message and text part -> full test suite passed the Codex persistence tests. -- [x] AC7: repeated conversation events do not duplicate parent messages -> full test suite passed both reprocessing tests. -- [x] AC8: allowed Bash is silent -> Codex Bash policy tests passed. -- [x] AC9: denied Bash uses the native deny response and policy reason -> Codex Bash policy tests passed. -- [x] AC10: Bash mutations create no diff trace -> regression test passed. -- [x] AC11: Add/Update apply_patch persists valid evidence -> full test suite passed the persistence and parser tests. -- [x] AC12: persisted model ID follows the truthful AC22 provenance contract -> persistence test passed with raw and qualified IDs. -- [x] AC13: move-with-edits preserves paths and pure rename creates no row -> full test suite passed. -- [x] AC14: delete-only and mixed-operation evidence boundaries hold -> full test suite passed. -- [x] AC15: synthetic evidence attributes through the existing intersection pipeline -> full test suite passed the Agent Trace attribution test. -- [x] AC16: no Agent Trace schema migration was added -> migration diff/status inspection passed. -- [x] AC17: existing integrations and repository checks continue to pass -> full test suite and `nix flake check` passed. -- [x] AC18: upstream-compatible outer wrappers and malformed-input behavior -> parser tests passed. -- [x] AC19: cwd-aware repository-relative path resolution -> path and realistic hook tests passed. -- [x] AC20: session validation and exact silent/non-policy output contracts -> Codex dispatcher and persistence tests passed. -- [x] AC21: deterministic event-scoped synthetic identities and collision handling -> normalization/combination/intersection tests passed. -- [x] AC22: truthful model provenance and no invented provider -> model normalization and persistence tests passed. -- [x] AC23: root-aware generated hook invocation and structural doctor expectations -> generated hook-command check, Pkl check, and doctor tests passed. -- [x] AC24: conservative attribution boundary and repeated-content ambiguity are documented and tested -> documentation inspection and repeated-content test passed. -- [x] AC25: complete hardened pipeline and forbidden-artifact boundaries -> realistic end-to-end test and source/status inspection passed. +- [x] AC1: setup installs both Codex output roots and persists `integrations.target` -> T05 scratch Git repository run recorded in the task stack; re-covered by this run's `nix flake check` (`cli-tests`). +- [x] AC2: `setup --all` installs Codex alongside OpenCode, Claude, and Pi -> T05 scratch Git repository run; re-covered by `nix flake check`. +- [x] AC3: core and optional workflow selection is correct -> T02/T03 generation inspection and paired scratch setup runs; re-covered by `nix run .#pkl-check-generated` (this run: 135 files). +- [x] AC4: generated Codex hook registrations are exactly the four required entries -> T03/T18 generated inspection and hook-command check; re-covered by `nix run .#pkl-check-generated` and `nix flake check` (`codex-hook-command`). +- [x] AC5: `UserPromptSubmit` produces one user message and text part -> `hooks::codex::user_prompt_submit` tests, this run: 124 passed under `hooks::codex::`. +- [x] AC6: `Stop` produces one assistant message and text part -> `hooks::codex::stop` tests, this run: 124 passed under `hooks::codex::`. +- [x] AC7: repeated conversation events do not duplicate parent messages -> `capture_with_does_not_duplicate_the_parent_message_on_reprocess` (both handlers), this run: passed under `hooks::codex::`. +- [x] AC8: allowed Bash is silent -> `hooks::codex::bash_policy` tests, this run: passed under `hooks::codex::`. +- [x] AC9: denied Bash uses the native deny response and policy reason -> `hooks::codex::bash_policy` tests, this run: passed under `hooks::codex::`. +- [x] AC10: Bash mutations create no diff trace -> T09 end-to-end regression test, this run: passed under `hooks::codex::`. +- [x] AC11: Add/Update apply_patch persists valid evidence -> `hooks::codex::apply_patch` persistence/parser tests, this run: passed under `hooks::codex::`. +- [x] AC12: persisted model ID follows the truthful AC22 provenance contract -> `apply_patch_persists_truthful_model_ids_without_fabricating_openai`, this run: passed under `hooks::codex::`. +- [x] AC13: move-with-edits preserves paths and pure rename creates no row -> T11/T16 tests, this run: passed under `hooks::codex::`. +- [x] AC14: delete-only and mixed-operation evidence boundaries hold -> `delete_only_and_pure_rename_apply_patch_events_persist_no_rows` and mixed-operation tests, this run: passed under `hooks::codex::`. +- [x] AC15: synthetic evidence attributes through the existing intersection pipeline -> T16/T19 Agent Trace attribution test, this run: `realistic_post_tool_use_patch_flows_through_repository_db_and_post_commit_attribution` passed under `hooks::codex::`. +- [x] AC16: no Agent Trace schema migration was added -> `git diff`/`git status --short` against `cli/migrations/agent-trace-repository`, this run: no changes. +- [x] AC17: existing integrations and repository checks continue to pass -> this run's `nix flake check`: all checks passed. +- [x] AC18: upstream-compatible outer wrappers and malformed-input behavior -> T14 parser/outer-normalization tests, this run: passed under `hooks::codex::`. +- [x] AC19: cwd-aware repository-relative path resolution -> T15/T20 path and realistic hook tests, this run: passed under `hooks::codex::`. +- [x] AC20: session validation and exact silent/non-policy output contracts -> T17 dispatcher and persistence tests, this run: passed under `hooks::codex::`. +- [x] AC21: deterministic event-scoped synthetic identities and collision handling -> T16 normalization/combination/intersection tests, this run: passed under `hooks::codex::`. +- [x] AC22: truthful model provenance and no invented provider -> T01/T17 model normalization and persistence tests, this run: passed under `hooks::codex::`. +- [x] AC23: root-aware generated hook invocation and structural doctor expectations -> T18 hook-command check, this run: `nix flake check` (`codex-hook-command`) and doctor tests (`cli-tests`). +- [x] AC24: conservative attribution boundary and repeated-content ambiguity are documented and tested -> T19 repeated-content test and `context/sce/codex-integration-runtime.md` inspection, this run: test passed under `hooks::codex::`. +- [x] AC25: complete hardened pipeline and forbidden-artifact boundaries -> T19 realistic end-to-end test, this run: passed under `hooks::codex::`; source/status inspection confirms no snapshot/pending-state artifacts. +- [x] AC26: path-resolution matrix (`..`, absolute-inside, missing Add targets, spaced paths, nested cwd, Update/Move independence, escapes, symlink escapes) -> T20 `hooks::codex::apply_patch::path` tests, this run: passed under `hooks::codex::`. +- [x] AC27: shared Codex hook-config ownership/merge (preservation, strict schema rejection, stale/duplicate replacement, idempotence, malformed-JSON no-write) -> T21 shared hook-config and setup tests, this run: `cli-tests`/`nix flake check`. +- [x] AC28: doctor structural + trust-aware reporting (`PresentAndCurrent`/`Missing`/`Stale`/`Malformed`, trust states, `--fix` scope) -> T22 doctor/shared-service suites, this run: `cli-tests`/`nix flake check`. +- [x] AC29: no literal `$ARGUMENTS` in generated Codex skill Markdown; command-capable targets unaffected -> T23 generated-contract coverage, this run: `nix run .#pkl-check-generated`. +- [x] AC30: Stop accepts `last_assistant_message: null` as a silent no-op pre-DB-open; explicit empty string tested distinctly -> T24 (plus both follow-up repairs) Stop dispatcher/handler tests, this run: passed under `hooks::codex::`. +- [x] AC31: trimmed/validated `session_id`/`turn_id`, fallible timestamp acquisition, no epoch-0 fallback -> T24 handler tests plus `grep -Rn "unwrap_or(0)"`/`unwrap_or_default` audits, this run: passed under `hooks::codex::`. +- [x] AC32: one transactional primitive for UserPromptSubmit/Stop conversation text events (atomic pair insert, replay no-op at 1/10/concurrent, injected-failure rollback, apply_patch untouched, no migration) -> T25; this run: `insert_conversation_text_event_inserts_message_and_part_together`, `..._is_a_no_op_on_sequential_replay`, `..._ten_sequential_replays_still_leave_one_row_pair`, `..._injected_failure_rolls_back_both_rows`, `..._concurrent_duplicate_delivery_leaves_one_row_pair` all passed (21 passed under `services::agent_trace_db::`); `grep` confirms both `user_prompt_submit.rs`/`stop.rs` call `db.insert_conversation_text_event`; `apply_patch` persistence and the migrations directory are unchanged. ### Failed checks and follow-ups @@ -527,5 +551,5 @@ Persist this field in every plan; this is durable plan state, not chat state: ### Residual risks -- Codex's external hook schema and apply_patch grammar may evolve beyond the upstream commit used for these fixtures. +- Codex's external hook schema and apply_patch grammar may evolve beyond the upstream commit (`343074d4207d572809bd8cea15f4be1d09d98e0b`, refreshed against `8e649e3afa5cdddfb09a1b85a090b94775045d9b`) used for these fixtures. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index c551733d..de407139 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -26,6 +26,7 @@ - `INSERT_PART_SQL`: parameterized single-row append-only INSERT into `parts` (no upsert; multiple rows per `(session_id, message_id)` allowed). - `insert_part(input)`: typed single-row helper that inserts a part row without requiring a matching `messages` row (supports out-of-order writes); retained as part of the adapter surface. - `insert_parts(inputs)`: typed batch helper that generates and executes one parameterized multi-row append-only `parts` insert for valid conversation-trace `message.part` batches. +- `insert_conversation_text_event(message, part)`: atomic one-message-plus-one-part write for exactly-once conversation text events (currently used only by `sce hooks codex`'s `UserPromptSubmit`/`Stop` arms; see [codex-integration-runtime.md](codex-integration-runtime.md)). Delegates to `TursoDb::execute_transactional_insert_pair_if_absent` (`cli/src/services/db/mod.rs`), a generic, schema-agnostic primitive: inside one `BEGIN IMMEDIATE` transaction it checks whether `(session_id, message_id)` already exists and, only if absent, inserts the message row then the part row and commits; an existing row rolls back as a no-op. `BEGIN IMMEDIATE` serializes concurrent callers against the same database file, so a replayed or concurrent duplicate delivery leaves exactly one message row and one part row — stronger than `insert_messages`/`insert_parts`' own guarantee, which dedups only the parent message row via `ON CONFLICT DO NOTHING` and leaves `parts` unguarded. `insert_messages`/`insert_parts` remain unchanged and are still what OpenCode/Claude/Pi conversation-trace intake uses. - `lifecycle.rs`: service lifecycle provider for setup/doctor integration. ## Repository-scoped adapter seam @@ -38,13 +39,13 @@ pub type RepositoryAgentTraceDb = TursoDb; This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`: the fresh multi-statement baseline `cli/migrations/agent-trace-repository/001_repository_schema.sql` plus the additive `002_repository_source_instance_id.sql` (adds `repository_metadata.source_instance_id`). The baseline schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id) -> Result` inserts the singleton metadata row on first initialization, errors if an existing DB stores a different repository ID, and atomically claims `source_instance_id` for this physical database via `UPDATE ... WHERE source_instance_id = ''` (a losing racer's generated candidate is discarded and an already-valid stored value is never overwritten), returning the typed `RepositoryMetadata { repository_id, source_instance_id }`. `source_instance_id` is generated by application code (`generate_source_instance_id()`, UUID v4) and validated with `is_valid_source_instance_id()` (non-empty once trimmed); it is never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity, and stays stable across reopen and repeated `sce setup` runs. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. -`RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_message`, `insert_messages`, `insert_part`, and `insert_parts`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper, so repository-scoped attribution reads use the same chronological inclusive window semantics without a checkout filter. These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. +`RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_message`, `insert_messages`, `insert_part`, `insert_parts`, and `insert_conversation_text_event`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper, so repository-scoped attribution reads use the same chronological inclusive window semantics without a checkout filter. These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. The repository-scoped adapter is consumed by `agent_trace_storage`, active hook runtime opening, Agent Trace setup/doctor lifecycle, and `sce sync`. Hook writers/readers resolve the current repository storage context before using `RepositoryAgentTraceDb`. The migration-running `new_at(path)` constructor is used by setup and hook-runtime fallback initialization. There is no longer a checkout-scoped adapter or trace database inspection service. ## Non-goals -- No read/query helper for loading messages with their joined parts exists in the current runtime; the typed write helpers (`insert_message`, `insert_messages`, `insert_part`, `insert_parts`) are the only exposed message/part API surface. Message/part query helpers are deferred to a future task. +- No read/query helper for loading messages with their joined parts exists in the current runtime; the typed write helpers (`insert_message`, `insert_messages`, `insert_part`, `insert_parts`, `insert_conversation_text_event`) are the only exposed message/part API surface. Message/part query helpers are deferred to a future task. - No part upsert/deduplication; `parts` uses only the internal integer `id` for row identity (append-only per the `INSERT_PART_SQL` contract). ## Database path @@ -202,7 +203,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow `sce hooks session-model` is no longer a supported command route, generated Claude settings no longer produce `SessionStart` model-attribution events, and the Agent Trace DB adapter no longer exposes a `session_models` API or fresh-schema table. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). -`sce hooks codex`'s `UserPromptSubmit` and `Stop` arms are each a second, independent writer into `messages` and `parts`, reusing `insert_messages`/`insert_parts` and the same `ON CONFLICT (session_id, message_id) DO NOTHING` parent-message dedup — not a new adapter. They store `cx_`-prefixed session IDs and a deterministic `cx::user`/`cx::assistant` message ID rather than a generated UUID. Its `PostToolUse(apply_patch)` arm is likewise a second, independent writer into `diff_traces`, reusing `insert_diff_trace` with `tool_name = "codex"`, `tool_version = NULL`, and `payload_type = "patch"` — not a new adapter, and no schema migration. See [codex-integration-runtime.md](codex-integration-runtime.md). +`sce hooks codex`'s `UserPromptSubmit` and `Stop` arms are each a second, independent writer into `messages` and `parts`, calling the shared `insert_conversation_text_event` atomic primitive (see above) rather than the plain `insert_messages`/`insert_parts` calls — not a Codex-specific adapter, since the primitive itself is schema-agnostic and reusable. They store `cx_`-prefixed session IDs and a deterministic `cx::user`/`cx::assistant` message ID rather than a generated UUID. Its `PostToolUse(apply_patch)` arm is likewise a second, independent writer into `diff_traces`, reusing `insert_diff_trace` with `tool_name = "codex"`, `tool_version = NULL`, and `payload_type = "patch"` — not a new adapter, and no schema migration. See [codex-integration-runtime.md](codex-integration-runtime.md). ## Recent patch reads diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 927f23e7..0e4baa7f 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -114,7 +114,7 @@ - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables via the same `insert_messages`/`insert_parts` helpers described above, with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. +- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above (so a replayed or concurrent duplicate delivery leaves exactly one message and one part row, not only the parent message row), with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index cae6eacf..f714525f 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -89,19 +89,20 @@ capture" below for the other two). Both follow the same shape: with no write for both arms, matching `PostToolUse(apply_patch)` below. - `session_id` is stored as `cx_` (idempotent) for both arms. `message_id` is deterministic rather than a generated UUID — `cx::user` - for `UserPromptSubmit`, `cx::assistant` for `Stop` — so that - reprocessing the same turn's event is a no-op for the parent message row - via the existing `messages` table's `ON CONFLICT (session_id, message_id) - DO NOTHING` semantics. + for `UserPromptSubmit`, `cx::assistant` for `Stop`. - `UserPromptSubmit` persists one `role = "user"` row with a `part_type = "text"` part (`text = prompt`); `Stop` persists one `role = "assistant"` row with a - `part_type = "text"` part (`text = last_assistant_message`). Both go through - `RepositoryAgentTraceDb::insert_messages`/`insert_parts` — the same insert - helpers and `messages`/`parts` tables `conversation-trace` already writes; - there is no Codex-specific DB adapter. -- The `parts` table has no uniqueness constraint (append-only, like every - other producer's part rows), so only the parent message row's - non-duplication is guaranteed on reprocess, not the part row's. + `part_type = "text"` part (`text = last_assistant_message`). Both call + `RepositoryAgentTraceDb::insert_conversation_text_event`, which runs the + existence check plus both inserts inside one `BEGIN IMMEDIATE` transaction + (`TursoDb::execute_transactional_insert_pair_if_absent` in + `cli/src/services/db/mod.rs`): a replayed or concurrent duplicate delivery is + a no-op leaving exactly one message row and one part row, not only the + parent message row that the plain `messages` table's own `ON CONFLICT + (session_id, message_id) DO NOTHING` constraint alone would guarantee. This + is one shared transactional primitive for both arms, not a Codex-specific DB + adapter; OpenCode/Claude/Pi's conversation-trace writers still use the + separate `insert_messages`/`insert_parts` calls unchanged. - The DB is opened per invocation through the same `open_agent_trace_db_for_hook_runtime` repository-storage resolution the other hook intakes use. From 5b93350a9b59a5fe07b25a2a68408a7c57022bc8 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 19:13:37 +0200 Subject: [PATCH 19/20] db: Clarify duplicate insert transaction semantics Correct the transaction documentation to distinguish duplicate no-write commits returning `Ok(false)` from genuine failures that roll back. Update the Agent Trace DB context and Codex integration plan with the corrected contract and T26 validation evidence. Plan: codex-cli-integration (T26) Co-authored-by: SCE --- cli/src/services/db/mod.rs | 6 ++-- context/plans/codex-cli-integration.md | 38 ++++++++++++++++++++------ context/sce/agent-trace-db.md | 2 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/cli/src/services/db/mod.rs b/cli/src/services/db/mod.rs index ffeb3250..21c641cb 100644 --- a/cli/src/services/db/mod.rs +++ b/cli/src/services/db/mod.rs @@ -603,9 +603,9 @@ impl TursoDb { /// Run an "insert row pair if absent" write transaction. /// - /// If `exists_sql` (bound to `exists_params`) finds a matching row, the - /// transaction is rolled back as a no-op and this returns `false`. - /// Otherwise `first_sql` then `second_sql` execute in order inside one + /// If `exists_sql` (bound to `exists_params`) finds a matching row, no + /// insert statements run, the no-write transaction commits, and this + /// returns `false`. Otherwise `first_sql` then `second_sql` execute in order inside one /// `BEGIN IMMEDIATE` transaction and commit together, returning `true`. /// `BEGIN IMMEDIATE` serializes concurrent callers against the same /// database file, so the existence check and both inserts are never diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 9f2d8a20..20ea12b8 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -79,6 +79,8 @@ This revision extends the completed Codex rollout with six correctness hardening - Validate: Codex handler tests cover whitespace-padded identifiers, missing identifiers, timestamp failures, and source inspection/tests for `unwrap_or(0)`, zero timestamp literals, and equivalent default fallbacks. - [x] AC32: UserPromptSubmit and Stop persist one logical conversation text event through one transactional DB primitive: parent message plus text part are inserted together or neither is inserted; replay of one, ten, or concurrent duplicate deliveries is a successful no-op with exactly one message and one part; injected part failure rolls back the parent message; apply_patch persistence remains on its existing independent diff-trace API and no migration is added. - Validate: Agent Trace DB atomic-event tests cover replay, transaction rollback via an injectable failure seam, and the SQLite write-serialization/concurrent duplicate contract; both Codex handlers use the primitive. +- [x] AC33: `TursoDb::execute_transactional_insert_pair_if_absent`'s doc comment, and every other statement in this plan or `context/sce/agent-trace-db.md` describing the same function, accurately state that a matching `exists_sql` row causes no inserts to run and the no-write transaction to commit, returning `Ok(false)`, rather than rolling back — only a genuine failure (the `Err` arm) rolls back. AC32's implemented behavior (`Ok(inserted) => commit` / `Err => rollback`), T25's `[x]` status, and AC29–AC31's `[x]` status are unchanged. + - Validate: `grep -Rni "rolled back as a no-op" cli context` and `grep -Rni "rollback.*no-op" cli context` return no matches; `git diff` shows no change to executable Rust logic; `cargo fmt --manifest-path cli/Cargo.toml -- --check` and `nix flake check` pass. ### Full validation @@ -94,6 +96,7 @@ This revision extends the completed Codex rollout with six correctness hardening - `context/sce/codex-integration-runtime.md` (modeled on `context/sce/pi-extension-runtime.md`) — `cx_` session prefix, truthful model provenance without fabricated `openai/` prefixes, UserPromptSubmit/Stop mapping, Bash policy delegation, the `PostToolUse apply_patch` outer-normalize/parse/resolve/normalize/persist pipeline and its boundary (Add/Update produce line-level evidence, paths resolve from Codex cwd, Update+Move preserves the destination path, Delete produces none, Bash mutation attribution remains unsupported, final attribution is always the existing post-commit intersection), silent fail-open behavior, event-scoped synthetic identities, and the repeated-content ambiguity limitation. - `context/sce/doctor-human-text-contract.md` — Codex integration group/area ordering. - `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md` — retain the existing second-writer/no-new-adapter contract and clarify that Codex apply_patch remains on the existing `diff_traces`/post-commit intersection path with no snapshots or pending state. +- `context/sce/agent-trace-db.md` — correct the `insert_conversation_text_event` entry's "an existing row rolls back as a no-op" sentence to describe the existing row committing an empty transaction and returning `Ok(false)`. ## Task context synchronization lifecycle @@ -108,10 +111,10 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Constraints and non-goals -- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, the Codex `apply_patch` parser/outer-normalization/path-resolution/normalizer modules under `cli/src/services/hooks/codex/`, generated-hook command tests, and the durable context files listed under Context sync. +- **In scope:** `cli/src/services/setup/`, `cli/src/services/config/`, `cli/src/services/hooks/`, `cli/src/services/doctor/`, `cli/src/services/default_paths.rs`, `cli/build.rs`, `config/pkl/base/`, `config/pkl/renderers/`, a new `config/codex-target/` build-time asset source, the Codex `apply_patch` parser/outer-normalization/path-resolution/normalizer modules under `cli/src/services/hooks/codex/`, generated-hook command tests, the durable context files listed under Context sync, and — comment/documentation text only — `cli/src/services/db/mod.rs`'s `execute_transactional_insert_pair_if_absent` doc comment. - **Out of scope:** any change to `cli/migrations/agent-trace-repository/`; any change to OpenCode/Claude/Pi's own generated behavior beyond what is mechanically required to add a fourth target to shared enums/renderers; Codex App Server or `codex exec --json` integration; MCP-tool or subagent attribution; `AGENTS.md` generation/management; a Codex slash-command compatibility layer; any change to `intersect_patches`/`combine_patches` in `cli/src/services/patch.rs` unless a test demonstrates the normalized Codex evidence cannot flow through the existing contract. - **Constraints:** reuse `cli/src/services/bash_policy.rs` for Bash policy evaluation without reimplementing matching; reuse `DiffTraceInsert`/`insert_diff_trace` for persistence without a Codex-specific DB adapter; reuse `cli/src/services/patch.rs`'s existing, unmodified `parse_patch`/`intersect_patches`/`combine_patches` — the Codex apply_patch normalizer must produce text `parse_patch` already accepts, and `intersect_patches`' existing historical `kind`+`content` fallback is the sole mechanism for reconciling Codex's synthetic line numbers against real post-commit line numbers; no second diff engine. -- **Non-goal:** Bash-created filesystem change attribution for Codex, Claude, or Pi (deferred — tracked as a known gap, not solved here); a generic cross-producer mutation tracker; any `diff_traces`/Agent Trace DB schema column for snapshot/pending state; filesystem snapshots, temporary Git indexes, or pending tool state for Codex `apply_patch` (the removed design, deliberately not reintroduced); Delete-File line-level attribution for Codex `apply_patch` (no before-state snapshot exists to prove removed content, and this plan does not add one). +- **Non-goal:** Bash-created filesystem change attribution for Codex, Claude, or Pi (deferred — tracked as a known gap, not solved here); a generic cross-producer mutation tracker; any `diff_traces`/Agent Trace DB schema column for snapshot/pending state; filesystem snapshots, temporary Git indexes, or pending tool state for Codex `apply_patch` (the removed design, deliberately not reintroduced); Delete-File line-level attribution for Codex `apply_patch` (no before-state snapshot exists to prove removed content, and this plan does not add one); any change to `execute_transactional_insert_pair_if_absent`'s transaction/commit/rollback implementation, `BEGIN IMMEDIATE` usage, existence-check behavior, `Ok(false)` semantics, retry behavior, or AC32's tested behavior — AC33 is documentation wording only. ## Assumptions @@ -473,7 +476,7 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db hooks::codex'` - Completed: 2026-08-23 - Files changed: `cli/src/services/db/mod.rs`, `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/agent_trace_db/repository.rs`, `cli/src/services/hooks/codex/user_prompt_submit.rs`, `cli/src/services/hooks/codex/stop.rs` - - Result: Added a generic write-transaction primitive `TursoDb::execute_transactional_insert_pair_if_absent` (`cli/src/services/db/mod.rs`) using the vendored `turso` crate's `Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)` (available on `&self`, so it needed no change to `TursoDb`'s existing non-`mut` API): it runs an existence-check `SELECT`, then — only if no row matches — `first_sql` then `second_sql`, committing once; a match rolls back as a no-op (`Ok(false)`); `BEGIN IMMEDIATE` serializes concurrent callers writing to the same database file so the existence check and both inserts are never interleaved with another writer's attempt; the whole attempt is retried as one unit by the existing `run_with_retry_sync` on transient failure. Its `fail_before_second: bool` parameter is the required test-only failure-injection seam: when set, an error is forced immediately after `first_sql` succeeds and before `second_sql` runs or the transaction commits. This primitive is schema-agnostic (raw SQL + params, matching `execute`/`query`'s existing shape), preserving the existing `db` → `agent_trace_db` layering rather than importing message/part schema knowledge into `db/mod.rs`. On top of it, `cli/src/services/agent_trace_db/mod.rs` adds `insert_conversation_text_event_with` (a new `SELECT_MESSAGE_EXISTS_SQL` existence guard plus the existing `INSERT_MESSAGE_SQL`/`INSERT_PART_SQL` as the pair), and `RepositoryAgentTraceDb` (`repository.rs`) exposes it as `pub fn insert_conversation_text_event(message, part) -> Result` plus a `#[cfg(test)] pub(crate) fn insert_conversation_text_event_with_injected_failure` counterpart (`true` for the seam) — mirroring this codebase's existing precedent of `#[cfg(test)]`-gated test seams (e.g. `stop.rs`'s `capture_with`) rather than a runtime feature flag. `user_prompt_submit.rs`'s and `stop.rs`'s `persist_with` were switched from two independent `insert_messages`/`insert_parts` calls to this one atomic call; both handlers' existing single validation layer, timestamp handling, and `cx_`/`cx::` ID formatting were left untouched. The pre-existing multi-row `insert_messages`/`insert_parts` (and their single-row counterparts) remain unchanged and still serve OpenCode/Claude/Pi conversation-trace writers (`cli/src/services/sync/sync.rs`, `cli/src/services/hooks/mod.rs`, `cli/src/services/agent_trace_export/mod.rs`) and this plan's own `apply_patch` diff-trace persistence, none of which were touched — confirmed by `grep` showing their continued call sites. No Agent Trace DB schema migration and no new uniqueness column were added, per this task's own out-of-scope boundary; the existence check is a plain `SELECT`, and `messages`' existing `ON CONFLICT (session_id, message_id) DO NOTHING` constraint is retained as defense-in-depth but is no longer relied on for correctness under the new transaction. Added five new tests in `agent_trace_db/repository.rs`'s test module: a basic insert-both-rows case; a sequential-replay no-op case; a ten-times-sequential-replay case (still one row pair); an injected-failure rollback case (asserts zero message and zero part rows survive); and a four-thread concurrent-duplicate-delivery case (mirroring this file's existing `concurrent_initialization_converges_on_one_source_instance_id` precedent of creating the schema once via `new_at` then racing separate `open_without_migrations_at` connections) asserting exactly one thread's attempt actually inserted and exactly one row pair exists afterward — verified stable across 5 repeated local runs. An initial 8-thread version of the concurrent test exceeded the default `QUERY_RETRY_POLICY`'s retry budget (5 attempts, 200ms timeout, 25–100ms backoff) under contention and was reduced to 4 threads to match this codebase's own established concurrency-test scale and stay reliably within that budget. + - Result: Added a generic write-transaction primitive `TursoDb::execute_transactional_insert_pair_if_absent` (`cli/src/services/db/mod.rs`) using the vendored `turso` crate's `Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)` (available on `&self`, so it needed no change to `TursoDb`'s existing non-`mut` API): it runs an existence-check `SELECT`, then — only if no row matches — `first_sql` then `second_sql`, committing once; a match causes no insert statements to run, and the no-write transaction commits, returning `Ok(false)`; `BEGIN IMMEDIATE` serializes concurrent callers writing to the same database file so the existence check and both inserts are never interleaved with another writer's attempt; the whole attempt is retried as one unit by the existing `run_with_retry_sync` on transient failure. Its `fail_before_second: bool` parameter is the required test-only failure-injection seam: when set, an error is forced immediately after `first_sql` succeeds and before `second_sql` runs or the transaction commits. This primitive is schema-agnostic (raw SQL + params, matching `execute`/`query`'s existing shape), preserving the existing `db` → `agent_trace_db` layering rather than importing message/part schema knowledge into `db/mod.rs`. On top of it, `cli/src/services/agent_trace_db/mod.rs` adds `insert_conversation_text_event_with` (a new `SELECT_MESSAGE_EXISTS_SQL` existence guard plus the existing `INSERT_MESSAGE_SQL`/`INSERT_PART_SQL` as the pair), and `RepositoryAgentTraceDb` (`repository.rs`) exposes it as `pub fn insert_conversation_text_event(message, part) -> Result` plus a `#[cfg(test)] pub(crate) fn insert_conversation_text_event_with_injected_failure` counterpart (`true` for the seam) — mirroring this codebase's existing precedent of `#[cfg(test)]`-gated test seams (e.g. `stop.rs`'s `capture_with`) rather than a runtime feature flag. `user_prompt_submit.rs`'s and `stop.rs`'s `persist_with` were switched from two independent `insert_messages`/`insert_parts` calls to this one atomic call; both handlers' existing single validation layer, timestamp handling, and `cx_`/`cx::` ID formatting were left untouched. The pre-existing multi-row `insert_messages`/`insert_parts` (and their single-row counterparts) remain unchanged and still serve OpenCode/Claude/Pi conversation-trace writers (`cli/src/services/sync/sync.rs`, `cli/src/services/hooks/mod.rs`, `cli/src/services/agent_trace_export/mod.rs`) and this plan's own `apply_patch` diff-trace persistence, none of which were touched — confirmed by `grep` showing their continued call sites. No Agent Trace DB schema migration and no new uniqueness column were added, per this task's own out-of-scope boundary; the existence check is a plain `SELECT`, and `messages`' existing `ON CONFLICT (session_id, message_id) DO NOTHING` constraint is retained as defense-in-depth but is no longer relied on for correctness under the new transaction. Added five new tests in `agent_trace_db/repository.rs`'s test module: a basic insert-both-rows case; a sequential-replay no-op case; a ten-times-sequential-replay case (still one row pair); an injected-failure rollback case (asserts zero message and zero part rows survive); and a four-thread concurrent-duplicate-delivery case (mirroring this file's existing `concurrent_initialization_converges_on_one_source_instance_id` precedent of creating the schema once via `new_at` then racing separate `open_without_migrations_at` connections) asserting exactly one thread's attempt actually inserted and exactly one row pair exists afterward — verified stable across 5 repeated local runs. An initial 8-thread version of the concurrent test exceeded the default `QUERY_RETRY_POLICY`'s retry budget (5 attempts, 200ms timeout, 25–100ms backoff) under contention and was reduced to 4 threads to match this codebase's own established concurrency-test scale and stay reliably within that budget. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db'` — passed: 21 passed, 0 failed, including the 5 new `insert_conversation_text_event_*` tests. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex'` — passed: 124 passed, 0 failed, including `user_prompt_submit`'s and `stop`'s `capture_with_does_not_duplicate_the_parent_message_on_reprocess` now exercising the atomic path. - Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml'` (full suite) — passed: 563 passed, 0 failed (558 prior + 5 new). @@ -487,6 +490,23 @@ Persist this field in every plan; this is durable plan state, not chat state: - No qualifying architecture decision: this is an internal correctness primitive behind an existing write path, not a new system boundary, public/cross-domain interface, data model/schema change, compatibility contract, security posture, deployment change, or major dependency. `sce-decision` was not invoked. - No new glossary term: "conversation text event" describes existing `messages`/`parts` concepts already covered by the glossary's `messages table (Agent Trace DB)`/`parts table (Agent Trace DB)` entries; it is not new domain language. +- [x] T26: `Correct stale "rolls back as a no-op" wording for the duplicate-row path` (status:done) + - Task ID: T26 + - Scope: In — the doc comment on `TursoDb::execute_transactional_insert_pair_if_absent` in `cli/src/services/db/mod.rs`; the "an existing row rolls back as a no-op" sentence in `context/sce/agent-trace-db.md`'s `insert_conversation_text_event` entry; the "a match rolls back as a no-op (`Ok(false)`)" phrase in this plan's own T25 `Result` text above. Out — any change to `execute_transactional_insert_pair_if_absent`'s transaction/commit/rollback implementation, `BEGIN IMMEDIATE` usage, existence-check behavior, `Ok(false)` semantics, retry behavior, tests, schema, migrations, or any Codex handler code; T25 stays `[x]` and is not reopened; AC29–AC32 stay `[x]`. + - Dependencies: T25 + - Done when: all three locations describe the duplicate/existing-row path as "no insert statements execute; the no-write transaction commits; returns `Ok(false)`", never as a rollback, while a genuine failure is still described as rolling back and returning `Err`; `grep -Rni "rolled back as a no-op" cli context` and `grep -Rni "rollback.*no-op" cli context` return no matches; `git diff` contains no change to executable Rust logic (comment/doc/plan-text only). + - Verify: `grep -Rni "rolled back as a no-op" cli context`; `grep -Rni "rollback.*no-op" cli context`; `cargo fmt --manifest-path cli/Cargo.toml -- --check`; `nix flake check`. + - Completed: 2026-08-23 + - Files changed: `cli/src/services/db/mod.rs`, `context/sce/agent-trace-db.md`, `context/plans/codex-cli-integration.md` + - Result: Reworded all three targeted locations to describe the duplicate/existing-row path as committing a no-write transaction and returning `Ok(false)`, never as a rollback, reserving rollback language for the genuine-failure `Err` arm only: (1) `TursoDb::execute_transactional_insert_pair_if_absent`'s doc comment (`cli/src/services/db/mod.rs:606-607`) now reads "no insert statements run, the no-write transaction commits, and this returns `false`"; (2) `context/sce/agent-trace-db.md`'s `insert_conversation_text_event` entry now reads "an existing row causes no insert statements to run, and the no-write transaction commits, returning `Ok(false)`"; (3) this plan's own T25 `Result` text now reads "a match causes no insert statements to run, and the no-write transaction commits, returning `Ok(false)`". No change was made to `execute_transactional_insert_pair_if_absent`'s implementation, `BEGIN IMMEDIATE` usage, existence-check behavior, `Ok(false)` semantics, retry behavior, tests, schema, or any Codex handler code; T25 remains `[x]` and was not reopened; AC29–AC32 remain `[x]`. AC33 is satisfied by this task's completion. + - Verify: `grep -Rni "rolled back as a no-op" cli context` — no matches outside this plan's own AC33/T26 text, which quotes the check strings themselves (the verification-command text), not the offending prose; the three targeted locations are clean. + - Verify: `grep -Rni "rollback.*no-op" cli context` — same result: no matches outside AC33/T26's own quoted verification-command text. + - Verify: `cargo fmt --manifest-path cli/Cargo.toml -- --check` — clean, no diff. + - Verify: `nix flake check` — passed: "all checks passed!" (`cli-tests`, `cli-clippy`, `cli-fmt`). + - Verify: `git diff -- cli/src/services/db/mod.rs` — confirms the only change is the doc comment; no executable Rust logic changed. + - Context impact: root — `context/sce/agent-trace-db.md` is one of the five root-adjacent domain context files this plan tracks under "Context sync", and it carried the exact stale sentence this task corrects; already fixed directly as part of this task's own explicit in-scope target (not deferred to a separate synchronization pass). + - Context synchronization: synced + ## Open questions - Current upstream behavior is verified at `openai/codex` commit `343074d4207d572809bd8cea15f4be1d09d98e0b`, but Codex is external and evolving; a future upstream hook-schema or parser change can require refreshing the compatibility fixtures. This is non-blocking because the plan records the source commit and makes the accepted forms/tests explicit. The current source exposes no provider identity separate from `model`, so this revision intentionally preserves incomplete model provenance rather than fabricating `openai/`. @@ -499,14 +519,15 @@ Persist this field in every plan; this is durable plan state, not chat state: ### Commands run +- `grep -Rni "rolled back as a no-op" cli context` -> exit 0 (matches found only inside this plan's own AC33/T26 prose, which quotes the check strings themselves as verification-command text; the three targeted locations — `cli/src/services/db/mod.rs`'s doc comment, `context/sce/agent-trace-db.md`'s `insert_conversation_text_event` entry, and T25's own Result text — are clean) +- `grep -Rni "rollback.*no-op" cli context` -> exit 0 (same result: no matches outside this plan's own quoted verification-command text) +- `git diff -- cli/src/services/db/mod.rs` -> exit 0 (only the doc comment on `execute_transactional_insert_pair_if_absent` changed — "the transaction is rolled back as a no-op" -> "no insert statements run, the no-write transaction commits" — no executable Rust logic changed) +- `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (clean, no diff) - `nix run .#pkl-check-generated` -> exit 0 (ephemeral Pkl generation passed: 135 files, inventory sha256 7064aa074a1bf94f6e525df85ff1843d479be96e82b4044482980d31446e20db — unchanged from the prior validation pass) - `nix flake check` -> exit 0 (all checks passed: cli-tests, cli-clippy, cli-fmt, cli-generated-input, pkl-generated, codex-hook-command, plus the full non-Rust check set) -- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db::'` -> exit 0 (21 passed, 0 failed, including the five `insert_conversation_text_event_*` atomic-primitive tests: basic insert, sequential no-op, 10x sequential no-op, injected-failure rollback, concurrent duplicate delivery) -- `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::'` -> exit 0 (124 passed, 0 failed) -- `git status --short -- cli/migrations/agent-trace-repository` and `git diff --stat -- cli/migrations/agent-trace-repository` -> exit 0 (no migration changes) -- `git status --short` (repository root) -> exit 0 (only `context/plans/codex-cli-integration.md` modified — this plan's own task-completion/validation-report edits; no leftover debug artifacts, temp files, or scaffolding) +- `git status --short` (repository root) -> exit 0 (only `cli/src/services/db/mod.rs`, `context/sce/agent-trace-db.md`, and `context/plans/codex-cli-integration.md` modified — T26's doc/context/plan-text-only changes plus this plan's own task-completion/validation-report edits; no leftover debug artifacts, temp files, or scaffolding) -This re-validation run was executed fresh in this session (not a re-print of the prior report): both full-validation commands and the two most relevant targeted Rust test suites were re-run directly against the current working tree and produced identical pass counts and the identical Pkl inventory hash as the prior 2026-08-23 pass, confirming no regression since that report was written. +This re-validation run was executed fresh in this session (not a re-print of the prior report): the full-validation commands were re-run directly against the current working tree and produced the identical Pkl inventory hash and an identical `nix flake check` pass as the prior 2026-08-23 pass, confirming no regression since that report was written. This pass additionally closes the one outstanding gap from the prior report: AC33's own checkbox and validation evidence had not yet been recorded — verified directly above and marked accordingly. Prior task-level evidence for AC1-AC25 (setup/target installs, generated-hook inspection, Codex persistence/policy/apply_patch/attribution test suites, doctor tests, and the realistic end-to-end pipeline test) is recorded per-task in the Task stack above and was re-covered by this run's `nix flake check`; it was not independently re-run command-by-command in this session since no implementation changed since the prior validation pass and the full suite (`cli-tests`) re-executes those same tests. @@ -544,6 +565,7 @@ Prior task-level evidence for AC1-AC25 (setup/target installs, generated-hook in - [x] AC30: Stop accepts `last_assistant_message: null` as a silent no-op pre-DB-open; explicit empty string tested distinctly -> T24 (plus both follow-up repairs) Stop dispatcher/handler tests, this run: passed under `hooks::codex::`. - [x] AC31: trimmed/validated `session_id`/`turn_id`, fallible timestamp acquisition, no epoch-0 fallback -> T24 handler tests plus `grep -Rn "unwrap_or(0)"`/`unwrap_or_default` audits, this run: passed under `hooks::codex::`. - [x] AC32: one transactional primitive for UserPromptSubmit/Stop conversation text events (atomic pair insert, replay no-op at 1/10/concurrent, injected-failure rollback, apply_patch untouched, no migration) -> T25; this run: `insert_conversation_text_event_inserts_message_and_part_together`, `..._is_a_no_op_on_sequential_replay`, `..._ten_sequential_replays_still_leave_one_row_pair`, `..._injected_failure_rolls_back_both_rows`, `..._concurrent_duplicate_delivery_leaves_one_row_pair` all passed (21 passed under `services::agent_trace_db::`); `grep` confirms both `user_prompt_submit.rs`/`stop.rs` call `db.insert_conversation_text_event`; `apply_patch` persistence and the migrations directory are unchanged. +- [x] AC33: doc comment and every plan/context statement about `execute_transactional_insert_pair_if_absent` describe a matching `exists_sql` row as committing a no-write transaction and returning `Ok(false)`, never as a rollback -> T26; this run: `grep -Rni "rolled back as a no-op" cli context` and `grep -Rni "rollback.*no-op" cli context` show no matches outside this plan's own quoted verification text; direct inspection of `cli/src/services/db/mod.rs`'s doc comment and `context/sce/agent-trace-db.md`'s `insert_conversation_text_event` entry confirms the corrected wording; `git diff -- cli/src/services/db/mod.rs` shows a doc-comment-only change; `cargo fmt --manifest-path cli/Cargo.toml -- --check` and `nix flake check` passed. ### Failed checks and follow-ups diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index de407139..5ef76c8a 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -26,7 +26,7 @@ - `INSERT_PART_SQL`: parameterized single-row append-only INSERT into `parts` (no upsert; multiple rows per `(session_id, message_id)` allowed). - `insert_part(input)`: typed single-row helper that inserts a part row without requiring a matching `messages` row (supports out-of-order writes); retained as part of the adapter surface. - `insert_parts(inputs)`: typed batch helper that generates and executes one parameterized multi-row append-only `parts` insert for valid conversation-trace `message.part` batches. -- `insert_conversation_text_event(message, part)`: atomic one-message-plus-one-part write for exactly-once conversation text events (currently used only by `sce hooks codex`'s `UserPromptSubmit`/`Stop` arms; see [codex-integration-runtime.md](codex-integration-runtime.md)). Delegates to `TursoDb::execute_transactional_insert_pair_if_absent` (`cli/src/services/db/mod.rs`), a generic, schema-agnostic primitive: inside one `BEGIN IMMEDIATE` transaction it checks whether `(session_id, message_id)` already exists and, only if absent, inserts the message row then the part row and commits; an existing row rolls back as a no-op. `BEGIN IMMEDIATE` serializes concurrent callers against the same database file, so a replayed or concurrent duplicate delivery leaves exactly one message row and one part row — stronger than `insert_messages`/`insert_parts`' own guarantee, which dedups only the parent message row via `ON CONFLICT DO NOTHING` and leaves `parts` unguarded. `insert_messages`/`insert_parts` remain unchanged and are still what OpenCode/Claude/Pi conversation-trace intake uses. +- `insert_conversation_text_event(message, part)`: atomic one-message-plus-one-part write for exactly-once conversation text events (currently used only by `sce hooks codex`'s `UserPromptSubmit`/`Stop` arms; see [codex-integration-runtime.md](codex-integration-runtime.md)). Delegates to `TursoDb::execute_transactional_insert_pair_if_absent` (`cli/src/services/db/mod.rs`), a generic, schema-agnostic primitive: inside one `BEGIN IMMEDIATE` transaction it checks whether `(session_id, message_id)` already exists and, only if absent, inserts the message row then the part row and commits; an existing row causes no insert statements to run, and the no-write transaction commits, returning `Ok(false)`. `BEGIN IMMEDIATE` serializes concurrent callers against the same database file, so a replayed or concurrent duplicate delivery leaves exactly one message row and one part row — stronger than `insert_messages`/`insert_parts`' own guarantee, which dedups only the parent message row via `ON CONFLICT DO NOTHING` and leaves `parts` unguarded. `insert_messages`/`insert_parts` remain unchanged and are still what OpenCode/Claude/Pi conversation-trace intake uses. - `lifecycle.rs`: service lifecycle provider for setup/doctor integration. ## Repository-scoped adapter seam From 33224d3eb6e8d66d37521eab574cb9726b0c25c1 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 23 Aug 2026 22:07:32 +0200 Subject: [PATCH 20/20] hooks: Defer Codex apply_patch path safety to resolution Allow the parser to preserve absolute and `..` paths so cwd-aware resolution can validate containment against the canonical Git worktree. Add dispatcher-level coverage for safe and escaping paths, and record corrected T20/AC26 verification evidence. Plan: codex-cli-integration (T20, AC26) Co-authored-by: SCE --- .../hooks/codex/apply_patch/parser.rs | 76 +++--- cli/src/services/hooks/codex/mod.rs | 222 ++++++++++++++++++ context/plans/codex-cli-integration.md | 3 +- 3 files changed, 262 insertions(+), 39 deletions(-) diff --git a/cli/src/services/hooks/codex/apply_patch/parser.rs b/cli/src/services/hooks/codex/apply_patch/parser.rs index 1d1db494..ba9b6cfb 100644 --- a/cli/src/services/hooks/codex/apply_patch/parser.rs +++ b/cli/src/services/hooks/codex/apply_patch/parser.rs @@ -25,14 +25,13 @@ //! eof_line: "*** End of File" LF //! ``` //! -//! Upstream Codex itself accepts absolute hunk paths (resolving them against -//! the tool's own `cwd` later). This parser is deliberately more -//! conservative than upstream, per this task's own scope: it rejects -//! absolute paths and `..` traversal segments outright, since SCE has no -//! equivalent downstream resolution step and normalized evidence must stay -//! anchored inside the repository working tree. - -use std::path::{Component, Path}; +//! Upstream Codex itself accepts absolute hunk paths and `..` traversal +//! segments, resolving them against the tool's own `cwd` later. This parser +//! preserves that model: it validates only the syntactic `apply_patch` grammar +//! and basic path representability (e.g. a non-empty path), and leaves the +//! decision of whether a parsed path is safe and stays inside the canonical +//! Git worktree to `resolve_codex_patch_paths` in this module's sibling +//! `path.rs`, which runs after parsing. const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; const END_PATCH_MARKER: &str = "*** End Patch"; @@ -93,8 +92,7 @@ pub(crate) enum CodexHunkLine { } /// Error produced when raw `apply_patch` text does not conform to the -/// grammar above, or violates this parser's own conservative path -/// validation. +/// grammar above, or contains an unrepresentable path (e.g. empty). #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatchParseError { @@ -349,32 +347,16 @@ fn parse_update_hunks( Ok((hunks, consumed)) } -/// Conservative path validation: rejects absolute paths and any `..` -/// traversal segment. Deliberately stricter than upstream Codex, which -/// accepts and resolves absolute hunk paths itself (see this module's own -/// doc comment). +/// Validates only that a parsed path is representable at all (non-empty). +/// Absolute paths and `..` traversal segments are syntactically valid Codex +/// `apply_patch` paths and are passed through unchanged; whether a given path +/// is safe is decided later, against the event cwd and canonical Git +/// worktree, by `resolve_codex_patch_paths` in `path.rs`. fn validate_path(path: &str) -> Result { if path.is_empty() { return Err(error("Codex apply_patch path cannot be empty.")); } - let candidate = Path::new(path); - - if candidate.is_absolute() { - return Err(error(format!( - "Codex apply_patch path '{path}' must not be absolute." - ))); - } - - if candidate - .components() - .any(|component| matches!(component, Component::ParentDir)) - { - return Err(error(format!( - "Codex apply_patch path '{path}' must not contain '..' traversal segments." - ))); - } - Ok(path.to_string()) } @@ -772,26 +754,44 @@ mod tests { } #[test] - fn rejects_absolute_path() { + fn accepts_absolute_and_parent_traversal_path_syntax_unresolved() { + // The parser owns only the apply_patch grammar: absolute paths and + // `..` traversal segments are syntactically valid here and are + // passed through unresolved. Whether they are actually safe is + // `resolve_codex_patch_paths` (path.rs)'s decision, made later + // against the event cwd and canonical Git worktree. let patch = "*** Begin Patch\n\ *** Add File: /etc/passwd\n\ +x\n\ + *** Delete File: ../../etc/shadow\n\ *** End Patch"; - let error = parse_codex_apply_patch(patch).expect_err("absolute path is rejected"); + let parsed = parse_codex_apply_patch(patch) + .expect("absolute and parent-traversal path syntax should parse"); - assert!(error.message.contains("must not be absolute")); + assert_eq!( + parsed.operations, + vec![ + CodexFileOperation::Add { + path: "/etc/passwd".to_string(), + lines: vec!["x".to_string()], + }, + CodexFileOperation::Delete { + path: "../../etc/shadow".to_string(), + }, + ] + ); } #[test] - fn rejects_traversal_path() { + fn rejects_empty_path() { let patch = "*** Begin Patch\n\ - *** Add File: ../../etc/passwd\n\ + *** Add File: \n\ +x\n\ *** End Patch"; - let error = parse_codex_apply_patch(patch).expect_err("traversal path is rejected"); + let error = parse_codex_apply_patch(patch).expect_err("empty path is rejected"); - assert!(error.message.contains("traversal")); + assert!(error.message.contains("path cannot be empty")); } } diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index fc23db3f..d72de6f6 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -222,6 +222,7 @@ mod tests { resolve_agent_trace_storage_at_state_root, resolve_agent_trace_storage_for_hook_runtime_at_state_root, AgentTraceStorageContext, }; + use crate::services::patch::FileChangeKind; use super::*; @@ -804,4 +805,225 @@ mod tests { fs::remove_dir_all(&repository_root).ok(); fs::remove_dir_all(&state_root).ok(); } + + // --- T20/AC26 ownership boundary: the parser accepts absolute and `..` + // path syntax unresolved (see apply_patch/parser.rs), and + // `resolve_codex_patch_paths` (apply_patch/path.rs) is the sole + // authority deciding whether a parsed path is safe and stays inside the + // canonical Git worktree. These end-to-end tests exercise the real + // `PostToolUse apply_patch -> parse -> cwd-aware path resolution -> + // normalize -> diff_traces` pipeline, not `path.rs` in isolation. --- + + fn diff_trace_count(repository_root: &Path, state_root: &Path) -> usize { + let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + state_root, + ) + .expect("repository Agent Trace DB should reopen"); + storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed") + .loaded_count() + } + + #[test] + fn nested_cwd_parent_traversal_path_is_accepted_and_persisted_repo_relative() { + let (repository_root, state_root) = initialize_repository("nested-cwd-traversal"); + let cwd = repository_root.join("src").join("lib"); + fs::create_dir_all(&cwd).expect("nested cwd should be created"); + + let payload = codex_apply_patch_payload( + &cwd, + "session-nested-traversal", + "custom/model", + "tool-nested-traversal", + "*** Begin Patch\n*** Add File: ../inside.rs\n+content\n*** End Patch", + ); + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("a `..` path that stays inside the repo should be accepted"); + assert_eq!(output, ""); + + let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root: &repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("repository Agent Trace DB should reopen"); + let recent = storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + let file = &recent.patches[0].patch.files[0]; + assert_eq!(file.kind, FileChangeKind::Added); + assert_eq!(file.new_path, "src/inside.rs"); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + #[test] + fn absolute_path_inside_worktree_is_accepted_and_persisted_repo_relative() { + let (repository_root, state_root) = initialize_repository("absolute-inside"); + let absolute_target = repository_root.join("lib.rs"); + + let payload = codex_apply_patch_payload( + &repository_root, + "session-absolute-inside", + "custom/model", + "tool-absolute-inside", + &format!( + "*** Begin Patch\n*** Add File: {}\n+content\n*** End Patch", + absolute_target.display() + ), + ); + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("an absolute path inside the worktree should be accepted"); + assert_eq!(output, ""); + + let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root: &repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("repository Agent Trace DB should reopen"); + let recent = storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + let file = &recent.patches[0].patch.files[0]; + assert_eq!(file.kind, FileChangeKind::Added); + assert_eq!(file.new_path, "lib.rs"); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + #[test] + fn parent_traversal_path_escaping_repository_is_rejected_with_no_diff_trace() { + let (repository_root, state_root) = initialize_repository("traversal-escape"); + + let payload = codex_apply_patch_payload( + &repository_root, + "session-traversal-escape", + "custom/model", + "tool-traversal-escape", + "*** Begin Patch\n*** Add File: ../outside.rs\n+content\n*** End Patch", + ); + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("a `..` path escaping the repo should fail open, not error"); + assert_eq!(output, ""); + assert_eq!(diff_trace_count(&repository_root, &state_root), 0); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + #[test] + fn absolute_path_outside_repository_is_rejected_with_no_diff_trace() { + let (repository_root, state_root) = initialize_repository("absolute-outside"); + let outside_target = std::env::temp_dir().join(format!( + "sce-codex-outside-target-{}-{}.rs", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos() + )); + + let payload = codex_apply_patch_payload( + &repository_root, + "session-absolute-outside", + "custom/model", + "tool-absolute-outside", + &format!( + "*** Begin Patch\n*** Add File: {}\n+content\n*** End Patch", + outside_target.display() + ), + ); + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("an absolute path outside the repo should fail open, not error"); + assert_eq!(output, ""); + assert_eq!(diff_trace_count(&repository_root, &state_root), 0); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } + + #[test] + fn move_to_destination_with_valid_parent_traversal_resolves_source_and_destination_independently( + ) { + let (repository_root, state_root) = initialize_repository("move-traversal"); + let cwd = repository_root.join("src"); + fs::create_dir_all(&cwd).expect("src directory should be created"); + + let payload = codex_apply_patch_payload( + &cwd, + "session-move-traversal", + "custom/model", + "tool-move-traversal", + "*** Begin Patch\n*** Update File: old.rs\n*** Move to: ../moved.rs\n@@\n-old\n+new\n*** End Patch", + ); + let output = run_codex_subcommand_from_payload_at_state_root( + &repository_root, + &payload, + None, + &state_root, + ) + .expect("a move whose destination traverses `..` inside the repo should be accepted"); + assert_eq!(output, ""); + + let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( + &AgentTraceStorageContext { + repository_root: &repository_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("repository Agent Trace DB should reopen"); + let recent = storage + .db + .recent_diff_trace_patches(0, i64::MAX) + .expect("diff trace query should succeed"); + assert_eq!(recent.loaded_count(), 1); + let file = &recent.patches[0].patch.files[0]; + assert_eq!(file.old_path, "src/old.rs"); + assert_eq!(file.new_path, "moved.rs"); + + fs::remove_dir_all(&repository_root).ok(); + fs::remove_dir_all(&state_root).ok(); + } } diff --git a/context/plans/codex-cli-integration.md b/context/plans/codex-cli-integration.md index 20ea12b8..bb448307 100644 --- a/context/plans/codex-cli-integration.md +++ b/context/plans/codex-cli-integration.md @@ -395,6 +395,7 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify: `nix flake check` — passed: all checks, including CLI tests, Clippy, formatting, generated-input, Pkl, and Codex hook-command checks. - Context impact: root — Codex apply_patch path resolution now accepts canonical safe parent/absolute paths and protects canonical worktree boundaries, refining the durable Codex runtime and directly relevant architecture/hook-routing context contracts. - Context synchronization: synced + - Correction (2026-08-23, PR #229): T20's own verification tested only `path.rs` in isolation (`hooks::codex::apply_patch::path`), never the full `PostToolUse apply_patch -> parse -> path resolution` pipeline. T10's parser-level `validate_path` (see T10's own noted divergence) still rejected absolute paths and `..` components syntactically, before `resolve_codex_patch_paths` ever ran, so this task's stated "done when" — accepting valid `..` and absolute-inside paths end-to-end — was never actually true in the wired pipeline despite `path.rs`'s own tests passing. Fixed by narrowing `cli/src/services/hooks/codex/apply_patch/parser.rs`'s `validate_path` to representability only (non-empty), leaving `resolve_codex_patch_paths` (`path.rs`, unchanged) as sole authority over path safety/containment. Added end-to-end coverage in `cli/src/services/hooks/codex/mod.rs` (`nested_cwd_parent_traversal_path_is_accepted_and_persisted_repo_relative`, `absolute_path_inside_worktree_is_accepted_and_persisted_repo_relative`, `parent_traversal_path_escaping_repository_is_rejected_with_no_diff_trace`, `absolute_path_outside_repository_is_rejected_with_no_diff_trace`, `move_to_destination_with_valid_parent_traversal_resolves_source_and_destination_independently`) driving the real dispatcher against a real Git worktree, plus a parser-level test proving the grammar layer no longer rejects this syntax. Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::apply_patch'` — passed: 58 tests. Verify: `nix develop -c sh -c './scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml hooks::codex::'` — passed: 129 tests. Verify: `nix flake check` — passed: all checks. - [x] T21: `Add shared non-destructive Codex hook ownership and setup merge` (status:done) - Task ID: T21 @@ -558,7 +559,7 @@ Prior task-level evidence for AC1-AC25 (setup/target installs, generated-hook in - [x] AC23: root-aware generated hook invocation and structural doctor expectations -> T18 hook-command check, this run: `nix flake check` (`codex-hook-command`) and doctor tests (`cli-tests`). - [x] AC24: conservative attribution boundary and repeated-content ambiguity are documented and tested -> T19 repeated-content test and `context/sce/codex-integration-runtime.md` inspection, this run: test passed under `hooks::codex::`. - [x] AC25: complete hardened pipeline and forbidden-artifact boundaries -> T19 realistic end-to-end test, this run: passed under `hooks::codex::`; source/status inspection confirms no snapshot/pending-state artifacts. -- [x] AC26: path-resolution matrix (`..`, absolute-inside, missing Add targets, spaced paths, nested cwd, Update/Move independence, escapes, symlink escapes) -> T20 `hooks::codex::apply_patch::path` tests, this run: passed under `hooks::codex::`. +- [x] AC26: path-resolution matrix (`..`, absolute-inside, missing Add targets, spaced paths, nested cwd, Update/Move independence, escapes, symlink escapes) -> T20 `hooks::codex::apply_patch::path` tests, this run: passed under `hooks::codex::`. Correction (2026-08-23, PR #229): that verification covered `path.rs` in isolation only; the wired pipeline still rejected these paths at the parser stage (see T20's own correction note). Re-verified end-to-end via `hooks::codex::` dispatcher-level tests exercising a real Git worktree, this run: passed. - [x] AC27: shared Codex hook-config ownership/merge (preservation, strict schema rejection, stale/duplicate replacement, idempotence, malformed-JSON no-write) -> T21 shared hook-config and setup tests, this run: `cli-tests`/`nix flake check`. - [x] AC28: doctor structural + trust-aware reporting (`PresentAndCurrent`/`Missing`/`Stale`/`Malformed`, trust states, `--fix` scope) -> T22 doctor/shared-service suites, this run: `cli-tests`/`nix flake check`. - [x] AC29: no literal `$ARGUMENTS` in generated Codex skill Markdown; command-capable targets unaffected -> T23 generated-contract coverage, this run: `nix run .#pkl-check-generated`.