diff --git a/.agents/skills/sce-change-to-plan/SKILL.md b/.agents/skills/sce-change-to-plan/SKILL.md new file mode 100644 index 000000000..9d33cd036 --- /dev/null +++ b/.agents/skills/sce-change-to-plan/SKILL.md @@ -0,0 +1,177 @@ +--- +name: sce-change-to-plan +description: > + Turn one change request into a scoped SCE plan in one self-contained workflow +--- + +# SCE Change to Plan + +## Purpose + +Own this workflow from input parsing through its terminal user-visible response. +Execute the phases below directly and in order. Phase statuses are internal state, +not inter-SCE workflow handoffs. Do not invoke another SCE skill, sibling SCE +package, or SCE workflow command. Follow the canonical workflow's steps, gates, +and stops exactly as written: never invent, skip, reorder, or merge a step. + +## Phase references + +Each numbered step below dispatches to a phase whose steps and boundaries live in +a reference file. This document holds the control flow — which phase runs, what it +receives, and how its result branches — and each reference holds the phase itself. + +| Step | Read before running the phase | +|---|---| +| 1 | `references/context-load.md` | +| 2 and 4 | `references/plan-authoring.md` | + +`references/plan-template.md` defines the plan file written to disk. The plan +authoring phase points to it at the moment a plan is actually written, which never +happens on a `needs_clarification` or `blocked` result. + +Read a step's reference before taking any action for that step, not after. Read +only the reference for the step you have reached: a run that stops at the +bootstrap gate never authors a plan, which is why they are separate files. + +## User-visible output + +Use `references/output.md` for every gate and terminal response. Render no raw +internal state. The reference contains only human-visible Markdown layouts. +User-visible output is limited to those layouts: never invent a layout, and never +wrap one in an added preamble, commentary, summary, or extra section. + +## Composite control flow + +Keep phase results as internal state and continue immediately whenever the +canonical workflow says to continue. Stop only at a user wait or terminal branch. +Any workflow-defined user wait resumes this same skill in the same session. +Never expose an internal phase result +as the workflow's final response. + +Relevant non-SCE skills may be used as helper capabilities during the active step. +They are not workflow handoffs: when a helper returns, control returns to the active +step. Helper use must preserve the canonical phase order, gates, waits, writes, +validation, stops, and terminal user-visible output. + +## Input + +`invocation input` 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 `invocation input` 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. + +Every `{plan-path}` and `{candidate-path}` emitted anywhere in this workflow is the path resolved in step 2 (`plan.path`, or an entry of `candidates`), so every emitted command is directly runnable. + +For example: `$sce-change-to-plan "add dark mode to settings"`. + +## Workflow + +### 1. Load durable context + +Read `references/context-load.md`, then run the **Context load phase** with the +change request as the focus. + +`context/` is durable AI-first memory describing current state. Load it before planning so the plan starts from recorded truth. Where context and code disagree, the code is the source of truth. + +Branch on `status`: + +`bootstrap_required` -> `context/` does not exist. Do not create it, and do not plan without it. Render the **Missing context bootstrap gate** layout from `references/output.md`. + +Wait for the user. When they report the command ran, run the **Context load phase** again and continue in this session. Do not restart planning, and do not ask for the change request again. + +`loaded` -> Continue to the next step. + +Do not read `context/` yourself. Do not repair drift or stale context; the brief reports it and the plan may schedule the repair. + +### 2. Author the plan + +Read `references/plan-authoring.md`, then run the **Plan authoring phase** with +the change request and the complete `loaded` brief from the **Context load +phase**. + +Pass the brief verbatim. Do not restate, summarize, or reinterpret it. + +This phase challenges whether the change is worth building before planning how to +build it, and it decides on its own whether to stop at the clarification gate. +Both shape what reaches the user, so reach them through the reference rather than +acting from this summary. + +Do not write or edit the plan file yourself. + +Branch on `status`: + +`needs_clarification` -> No plan was written. Present the result as prose. Do not print the raw result. Render the **Clarification gate** layout from `references/output.md`. + +Render one `##` block per entry in `questions`, in result order. Use the question's `id`, `category`, `question`, and `why_blocking` fields exactly as returned. + +Do not answer the questions. Do not assume answers. Do not write a plan. Stop and wait. + +`blocked` -> No plan was written. Render the **Blocked** layout from `references/output.md`, drawing its issues from `issues` and, when `candidates` is present, its candidate paths from `candidates`. Do not print the raw result. Stop. + +`plan_ready` -> Continue to the next step. + +### 3. Determine the continuation + +Render the `plan_ready` result as the summary defined by the **Plan authoring phase** in `references/output.md`. Follow that layout exactly. Do not print the raw result. + +Take the next task from `next_task`. A `plan_ready` result always names one. Do not evaluate its dependencies; the **Plan review phase** checks them when the emitted command runs and returns `blocked` if they are unmet. + +The workflow carries one of two explicit continuation shapes across a same-session wait: + +- **Initial-clarification continuation:** `original_request`, `clarification_answers`, and `loaded_context_brief`. `original_request` is the unchanged request from step 1; preserve it with the answers and never ask the user to provide it again. +- **Existing-plan revision continuation:** `plan_path`, `correction`, and `loaded_context_brief`. `plan_path` identifies the plan already written, and `correction` contains the user's requested revision. + +The plan was written from one prose request, so its assumptions are guesses about what the user meant, its scope is one reading of the request, and its task boundaries are the author's judgement. The user has seen none of it until now, and every one of those is cheaper to correct here than after a task has been built on it. A user who does not know revision is on the table will implement a plan they would have changed. + +Write `task` rather than `tasks` when `total_tasks` is 1. + +Offer revision, but do not gate the handoff on it, do not manufacture concerns, and do not ask the user to confirm the plan. When the summary lists open questions, leave them in the summary only — do not restate them in the continuation, do not answer them, and do not block the handoff on them. Blocking questions belong in `needs_clarification` (step 2), not here. + +Render the **Ready continuation** layout from `references/output.md`. + +Then stop and wait. Do not implement, and do not run the handoff yourself. + +### 4. Revise the plan on request + +When the user answers clarification questions from step 2, resume the **Initial-clarification continuation** with `original_request`, `clarification_answers`, and the same `loaded_context_brief` from step 1. Preserve `original_request` unchanged and never ask the user for the original change request again. When the user answers open questions listed in the summary or requests changes to an already-written plan, resume the **Existing-plan revision continuation** with `plan_path`, `correction`, and the same `loaded_context_brief`. Do not ask them to rerun `/change-to-plan`. + +Run the **Plan authoring phase** with the applicable continuation fields. The brief still holds; durable context did not change because the user disagreed with a task boundary. Do not reload it. + +An answer that resolves a doubt removes that open question. An answer that does not resolve it leaves the question standing; do not drop it because the user replied to it. If the reply raises a new doubt, the revised plan carries a new open question. + +Pass `clarification_answers` or `correction` as written. Do not restate, soften, or pre-scope it. The **Plan authoring phase** owns resolving it against the existing plan, and owns preserving completed tasks and their evidence. + +Branch on `status` exactly as in step 2. A revision may legitimately return `needs_clarification` or `blocked`. + +On `plan_ready`, render the summary again and the continuation exactly as in step 3, replacing `is ready` with `revised` in the heading. + +Revise as many times as the user asks. Each revision is one invocation of the **Plan authoring phase** against the same plan. + +When the user signals the plan is good, or asks to begin, return the handoff without re-authoring the plan. Say so plainly if questions are still open: the user may proceed over an unresolved doubt, and that is their call, but do not record it as resolved. + +Stop. + +## Rules + +- Plan at most one change request per invocation. Revisions to the plan that request produced are part of the same invocation, not a second request. +- Read each phase's reference before running that phase. +- Always tell the user the plan can be revised, and always name its assumptions as the first thing worth checking. +- Do not gate the handoff on open questions listed in the plan summary. Blocking questions return `needs_clarification` before any plan is written. Offering revision is not the same as demanding it, and inventing doubts to justify a review gate is not allowed. +- Do not suppress, soften, or answer an open question or clarification question on the user's behalf. +- Do not defer the user's revision to a rerun of `/change-to-plan`, and do not defer it to the implementation phase. Revise the plan here. +- Do not narrow, expand, or reinterpret a revision the user asked for. Pass it to the **Plan authoring phase** as written. +- Do not duplicate the internal instructions of embedded phases. +- Do not plan before durable context is loaded. +- Do not bootstrap `context/` yourself. `sce setup --bootstrap-context` owns that. +- Do not modify any file under `context/` outside `context/plans/`. +- Do not implement any part of the plan. +- Do not ask for implementation confirmation. +- Do not run task execution, context synchronization, or full-plan validation. +- Do not emit a `/validate` command. This workflow always hands off to `/next-task`. +- Do not answer the skill's clarification questions on the user's behalf. +- Do not execute the continuation returned at the end. +- Do not infer success when the **Plan authoring phase** returns a non-`plan_ready` status. diff --git a/.agents/skills/sce-change-to-plan/agents/openai.yaml b/.agents/skills/sce-change-to-plan/agents/openai.yaml new file mode 100644 index 000000000..c97f25138 --- /dev/null +++ b/.agents/skills/sce-change-to-plan/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "SCE Change to Plan" + short_description: "Turn one change request into a scoped SCE plan in one self-contained workflow" + default_prompt: "Turn this change request into an SCE plan." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/sce-change-to-plan/references/context-load.md b/.agents/skills/sce-change-to-plan/references/context-load.md new file mode 100644 index 000000000..197a28c2d --- /dev/null +++ b/.agents/skills/sce-change-to-plan/references/context-load.md @@ -0,0 +1,89 @@ +# Context load phase + +Run this phase for step 1 of the workflow, with the change request as the focus. + +`context/` is durable AI-first memory describing current state. Load it before +planning so the plan starts from recorded truth. Where context and code disagree, +the code is the source of truth. + +This phase reads and reports; it never writes. + +## 1.1 Confirm the context root + +When `context/` does not exist, set internal status `bootstrap_required` +immediately. Read nothing further. + +Bootstrapping is the workflow's decision, not this phase's. + +## 1.2 Read the entry points + +Read, when present: + +- `context/context-map.md` +- `context/overview.md` +- `context/glossary.md` + +Read `context/architecture.md` when the focus touches structure, boundaries, or +data flow. Read `context/patterns.md` when it touches conventions the change must +follow. + +A missing entry point is a gap, not a failure. Record it and continue. + +## 1.3 Select the relevant domain context + +Consult `context/context-map.md` before any broad exploration. The map's +annotations name what each domain file owns; use them to select files, rather +than globbing or searching `context/`. + +Select only files whose subject overlaps the focus. Follow at most one level of +links out of a selected file, and only when the link is needed to understand the +focus. + +Do not read every domain file. A brief that includes everything has selected +nothing. + +Record focus areas with no matching context file under `gaps`. + +## 1.4 Check recorded context against the code + +For each selected file, spot-check its central claims against the code it +describes. + +When context and code diverge, the code is the source of truth. Record the +divergence under `drift` with what context says, what the code shows, and the +repair the context needs. + +Do not repair it here. Later phases decide whether repair belongs in the current +work. + +Keep this proportional: check the claims the focus depends on, not every +sentence. + +## 1.5 Return the brief + +Set exactly one internal state: + +- `loaded` +- `bootstrap_required` + +Report facts the workflow can act on. A brief that only lists file paths has +moved no knowledge. + +Record only the internal state. Do not add explanatory prose before or after it. + +Step 2 consumes a `loaded` brief verbatim and treats its `key_facts` as recorded +current state, its `gaps` as areas with no durable context, and its `drift` as +context the code has already outrun. + +## Context load boundaries + +Do not: + +- Create, update, move, or delete any file under `context/`. +- Bootstrap `context/`. +- Repair drift or stale context. +- Modify application code or tests. +- Read the entire `context/` tree by default. +- Explore the repository beyond what the focus and the selected context require. +- Ask the user questions. Report gaps and drift, and let the workflow decide. +- Author a plan, select a task, or implement anything. diff --git a/.agents/skills/sce-change-to-plan/references/output.md b/.agents/skills/sce-change-to-plan/references/output.md new file mode 100644 index 000000000..170f39491 --- /dev/null +++ b/.agents/skills/sce-change-to-plan/references/output.md @@ -0,0 +1,152 @@ +# Change-to-plan output layouts + +Use only the applicable layout. Values come from internal workflow state. + +## Missing context bootstrap gate + +```markdown +------------------------------------- + +# This repository has no durable context. + +Bootstrap it, then continue in this session: + +`sce setup --bootstrap-context` +``` + +## Clarification gate + +```markdown +------------------------------------- + +# Clarification needed. + +No plan was written. + +Answer each question below. + +## {question-id} · {category} + +{question} + +Why this blocks planning: {why_blocking} +``` + +## Blocked + +Present each issue's problem, impact, and required decision. For ambiguity, list +candidate plan paths and explain that naming one candidate resolves it. + +## Ready continuation + +```markdown +------------------------------------- + +# Plan {plan-name} is ready. + +{total-tasks} {task|tasks} planned. + +This plan is a draft. State a correction and it will be updated. + +Next up: + +{next-task-id} — {next-task-title} + +`/next-task {plan-path} {next-task-id}` +``` + +For revisions, replace `is ready` with `revised`. + +# SCE Plan Summary + +The user-facing summary shown after a plan is written. It is rendered from +the `plan_ready` result, immediately before the continuation block. + +This is chat output, not a file. Nothing here is written to the plan. + +## Layout + +``` +# Plan: {plan.name} + +Path: {plan.path} + +## Summary: +{plan summary} + +## Tasks: +1. {task.id} — {task.title} +2. {task.id} — {task.title} + +## Assumptions: +- {assumption} + +## Open questions: +- {open question} +``` + +## Field mapping + +Every value comes from the `plan_ready` result. Render nothing the result does +not carry. + +- `Plan:` — `plan.name`. Append ` (updated)` when `plan.action` is `updated`. + Render nothing extra when it is `created`. +- `Path:` — `plan.path`, exactly as returned, so it stays runnable. +- `Summary:` — `summary`, as prose. This is the only place the reader learns + what the plan actually does, so never omit it and never replace it with a + restatement of the task titles. +- `Tasks:` — one numbered line per entry in `tasks`, in plan order. Append + ` (done)` to any task whose `status` is `done`. +- `Assumptions:` — one line per entry in `assumptions`. +- `Open questions:` — one line per entry in `open_questions`. + +## Empty sections + +Never drop a section heading. An absent section reads as an oversight; an +explicit `None.` confirms nothing is pending. + +When `assumptions` is empty: + +``` +## Assumptions: +- None. +``` + +When `open_questions` is absent: + +``` +## Open questions: +- None. +``` + +## Rules + +- Render the sections in the order above. +- Keep task titles as authored. Do not reword, expand, or re-scope them. +- Do not restate goals, boundaries, done checks, or verification notes. The plan + file owns task detail; this summary orients the reader. +- Do not print the raw result, and do not wrap the summary in a code fence. +- Do not add commentary, recommendations, or a next step. The continuation block + that follows owns the handoff. + +## Example + +``` +# Plan: red-sce-banner + +Path: context/plans/red-sce-banner.md + +## Summary: +Renders the ASCII-art SCE banner at the top of `sce` help in red instead of the current gradient. Colour-disabled output is unchanged, and no other help surface is affected. + +## Tasks: +1. T01 — Render the SCE banner in red + +## Assumptions: +- "SCE letters" refers to the ASCII-art banner in top-level help. +- Red is uniform terminal red when colors are enabled; plain ASCII remains unchanged otherwise. + +## Open questions: +- None. +``` diff --git a/.agents/skills/sce-change-to-plan/references/plan-authoring.md b/.agents/skills/sce-change-to-plan/references/plan-authoring.md new file mode 100644 index 000000000..17e054e2d --- /dev/null +++ b/.agents/skills/sce-change-to-plan/references/plan-authoring.md @@ -0,0 +1,256 @@ +# Plan authoring phase + +Run this phase for step 2 of the workflow, and again for each revision in step 4. + +Input: the change request, and the complete `loaded` brief from the context load +phase. Pass the brief verbatim; do not restate, summarize, or reinterpret it. + +This phase exclusively owns: + +- Resolving whether the request targets a new or an existing plan. +- The clarification gate. +- Normalizing the change summary, acceptance criteria, constraints, and non-goals. +- Slicing the task stack into one-task/one-atomic-commit units. +- Writing `context/plans/{plan_name}.md`. + +Do not duplicate any of it elsewhere in the workflow. + +Use the document format in `references/plan-template.md`. Read it before writing +the plan file. + +The workflow renders this phase's result as the summary defined in +`references/output.md`. + +The change request may name a plan, describe a change to an existing plan, or +describe entirely new work. Resolving which applies is this phase's +responsibility. + +The context brief is the durable memory this plan starts from. Treat its +`key_facts` as recorded current state, its `gaps` as areas with no durable +context, and its `drift` as context the code has already outrun. + +When no brief is supplied, load the context named by the change request before +authoring, and follow the selection discipline in *Inspect relevant context*. + +Answers the user gave to earlier clarification questions arrive as part of the +change request. Incorporate them into the plan. + +A revision of a plan authored earlier in the session also arrives as the change +request, and it is usually terse: a task boundary the user disagrees with, an +ordering they want changed, work they want added or dropped. Read it against the +existing plan, which supplies the scope, criteria, and terminology it omits. +Terseness is not ambiguity. Do not set internal status `needs_clarification` for +detail the plan already carries; ask only when the revision itself is genuinely +undecidable. + +## 2.1 Resolve the plan target + +Determine whether the request targets a new plan or an existing plan in +`context/plans/`. + +When it targets an existing plan, read that plan before authoring. Preserve its +completed tasks, their recorded evidence, its structure, and its terminology. + +When multiple existing plans match and none can be selected safely, return +`blocked` with the matching candidates. + +When the request targets a new plan, derive `plan_name` as a short kebab-case +slug of the change, and confirm it does not collide with an existing plan. + +Resolve exactly one plan target per invocation. + +## 2.2 Challenge the change + +Before planning how to build the change, work out whether it is worth building. A +plan is a commitment of someone's time; authoring one for work that should not +happen is worse than authoring none. + +Interrogate the request: + +- What breaks, or stays broken, if this is never built? If the answer is nothing + concrete, say so. +- What problem is it actually solving, as opposed to what it proposes to do? A + request that names only a solution has not stated a problem. +- Does the repository already do this, or most of it? The brief's `key_facts` are + the first place to check. +- Is there a materially smaller version that gets most of the value? Name it. +- What does this cost beyond the tasks: new dependency, new concept in the + glossary, a boundary crossed, a surface that now needs maintaining forever? +- Does the stated justification survive contact with the code, or does the code + show the premise is already false? + +Doubt that survives this is not an implementation detail to be tidied away. It +belongs in the plan's `Open questions` and in `open_questions`, in the plain +words you would use to a colleague. "Is this worth doing at all, given X?" is a +legitimate open question. So is "this looks like it duplicates Y". + +Weigh honestly in both directions. A request that is obviously worth building +gets no manufactured doubt: inventing questions to look rigorous is its own +failure, and it teaches the user to ignore the section. Most changes are fine. +Say nothing when there is nothing to say. + +Keep going regardless. Skepticism shapes the plan and the open questions; it does +not withhold the plan. The only value judgment that stops authoring is +`no_actionable_work`, when the change is already implemented. + +## 2.3 Run the clarification gate + +Before writing or updating any plan file, check the request for critical +unresolved detail: + +- Scope boundaries and out-of-scope items. +- Acceptance criteria and the checks that prove them. +- Constraints and non-goals. +- Dependency choices, including new libraries or services, versions, and the + integration approach. +- Domain ambiguity, including unclear business rules, terminology, or ownership. +- Architecture concerns, including patterns, interfaces, data flow, migration + strategy, and risk tradeoffs. +- Task ordering assumptions and prerequisite sequencing. + +Set internal status `needs_clarification` with one to three targeted questions +when any of these would materially change the plan. Write no plan file in that +case. + +Use repository conventions for ordinary local choices. Do not block on: + +- Naming inferable from surrounding code. +- Established formatting or style. +- Reversible local implementation details. +- Details that do not change scope, acceptance criteria, or task ordering. + +Record those choices under `assumptions`. + +Do not silently invent missing requirements. When the user has explicitly allowed +assumptions, record them in the plan's `Assumptions` section instead of asking. + +A justification that does not survive inspection is itself a critical unresolved +detail. "For consistency", "to make it cleaner", "we will need it later" name no +outcome and prove nothing; ask what the change is actually for before planning +around it. Do not treat confident phrasing as evidence. + +## 2.4 Inspect relevant context + +Start from the context brief. Read code only where the brief leaves the change +underspecified: + +- Existing behavior the change affects. +- Applicable repository conventions. +- Architectural boundaries. +- Relevant tests and available verification commands. +- Decisions or specifications connected to the change. + +Where the brief reports `drift`, the code is the source of truth. Plan against +the code, and schedule the context repair as part of the change when it falls +inside scope. + +Where the brief reports `gaps`, the plan may need to establish durable context +the repository does not yet have. + +Do not explore the entire repository by default. + +## 2.5 Author the acceptance criteria + +State how the finished plan is proven, before slicing tasks. + +Each criterion describes observable behavior of the finished system and names the +check that proves it. Record repository-wide checks once under `Full validation`, +and the durable context the change must be reflected in under `Context sync`. + +`/validate` runs this section after the last task completes. It is the only place +a plan says how it is validated. + +## 2.6 Author the task stack + +Slice the work into sequential tasks `T01..T0N` using the task format and the +atomic slicing contract in `references/plan-template.md`. + +Every executable task must be completable and landable as one coherent commit. +Split any task that would require multiple independent commits. Convert broad +wrappers such as `polish` or `finalize` into specific outcomes with concrete +acceptance checks. + +Order tasks so each one's declared dependencies precede it. + +The last task is an ordinary implementation task. Do not author a trailing +validation-and-cleanup task, or any task whose only purpose is running the full +check suite, verifying durable context, or removing scaffolding. + +Confirm every acceptance criterion is satisfied by at least one task. When one is +not, the task stack is incomplete. + +A finished stack always leaves at least one incomplete task, so the workflow can +always hand off to `/next-task`. When the request resolves to a plan but produces +no incomplete task, because the change is already implemented or already covered +by completed tasks, set internal status `blocked` with category +`no_actionable_work` instead of writing the plan. + +## 2.7 Write the plan + +Write `context/plans/{plan_name}.md` using `references/plan-template.md`. + +When updating an existing plan, keep completed tasks and their evidence intact, +and append or renumber new tasks without disturbing recorded history. + +## 2.8 Return the result + +Set exactly one internal state: + +- `plan_ready` +- `needs_clarification` +- `blocked` + +Record only the internal state. Do not add explanatory prose before or after it. + +A `plan_ready` result always names the next task in `next_task`, and carries the +`total_tasks` count and any open questions the summary needs. Step 3 renders those +without recomputing them. + +## Plan authoring tone + +Every question and open question this phase writes is read by the user. Write +them the way a senior engineer talks in review: direct, specific, and unbothered +by the possibility of being unwelcome. + +- Ask about the thing that actually worries you, not a safer neighbouring thing. + A question you would not bother asking a colleague is not worth the user's + attention either. +- State a doubt as a doubt. "I do not think this is worth the two tasks it + costs, because X" is useful. "It may be worth considering whether this aligns + with broader goals" is noise. +- Name the alternative you have in mind. A challenge with no proposal behind it + is just friction. +- Do not open with praise, do not close with reassurance, and do not apologize + for asking. Do not pad a doubt with hedges to make it land more gently. +- Be persistent, not repetitive. Ask once, plainly, and let it stand; do not + restate the same doubt in three shapes to give it more weight. +- Being disagreeable is not the goal. Being easy to agree with is the failure + mode. A plan the user waves through without reading has cost them nothing and + bought them nothing. + +When the user overrules a doubt, record it and move on. Do not relitigate a +decision the user has made, and do not smuggle the objection back in as a +constraint, a non-goal, or a task. + +## Plan authoring boundaries + +Do not: + +- Ask the user questions directly. Set internal status `needs_clarification` and let the + workflow present the questions. +- Answer your own clarification questions. +- Write a plan file when returning `needs_clarification` or `blocked`. +- Implement any task in the plan. +- Modify application code or tests. +- Modify any file under `context/` outside `context/plans/`. Plan the context + repair instead of performing it. +- Mark any task complete. +- Request implementation confirmation. +- Run task execution. +- Synchronize context. +- Run final validation. +- Author a validation, cleanup, or context-verification task. `/validate` owns + that phase. +- Set internal status `plan_ready` for a plan with no incomplete task. +- Create a Git commit. +- Author more than one plan. diff --git a/.agents/skills/sce-change-to-plan/references/plan-template.md b/.agents/skills/sce-change-to-plan/references/plan-template.md new file mode 100644 index 000000000..19e8a12a0 --- /dev/null +++ b/.agents/skills/sce-change-to-plan/references/plan-template.md @@ -0,0 +1,184 @@ +# Internal persisted-document format: Plan template + +The document format for `context/plans/{plan_name}.md`. This is the plan file +written to disk, not the result returned to the workflow. + +Copy the template below and fill every `{placeholder}`. Omit optional sections +entirely rather than writing them empty. + +--- + +## Template + +```markdown +# Plan: {plan-name} + +## Change summary + +{One or two paragraphs: what changes, where, and why. State whether this +extends existing behavior, replaces it, or preserves work already in progress.} + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [ ] AC1: {observable outcome, stated as behavior rather than as work done} + - Validate: `{command, assertion, or inspection that proves AC1}` +- [ ] AC2: {observable outcome} + - Validate: `{command, assertion, or inspection that proves AC2}` + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. + +- `{full check suite command}` +- `{generated-output or parity check command, when applicable}` + +### Context sync + +- {Durable context files that must describe the change once implemented.} + +## 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:** {files, modules, and surfaces this plan may touch} +- **Out of scope:** {adjacent work explicitly excluded} +- **Constraints:** {dependencies, conventions, compatibility, or policy limits} +- **Non-goal:** {tempting generalization this plan deliberately avoids} + +## Assumptions + +{Include only when the user allowed assumptions, or ordinary local choices were +recorded. Remove the section otherwise.} + +- {Assumption, and the convention or decision record it rests on.} + +## Task stack + +- [ ] T01: `{single intent title}` (status:todo) + - Task ID: T01 + - Scope: In — {tight scope}. Out — {excluded work}. + - Dependencies: {task IDs, or none} + - Done when: {clear acceptance for one coherent change} + - Verify: {targeted checks for this change} + - Context synchronization: pending + +- [ ] T02: `{single intent title}` (status:todo) + - Task ID: T02 + - Scope: In — {tight scope}. Out — {excluded work}. + - Dependencies: T01 + - Done when: {clear acceptance for one coherent change} + - Verify: {targeted checks for this change} + - Context synchronization: pending + +## Open questions + +{Non-blocking questions only. A question that would change scope, success +criteria, or task ordering blocks authoring instead. Write `None.` with a short +justification when nothing remains.} + +{Unresolved doubt about the change's value belongs here — whether it is worth +building, whether it duplicates behavior the repository already has, whether a +smaller version would do. State it plainly and name the alternative. Do not +invent one: `None.` is the expected answer for a well-specified change.} +``` + +--- + +## Filled-in task example + +```markdown +- [ ] T02: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T02 + - Scope: In — route handler, token validation logic, response schema. Out — refresh token rotation policy (covered in T03), client-side storage changes. + - Dependencies: T01 + - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired or invalid token; targeted tests pass; OpenAPI spec updated. + - Verify: `pnpm test src/auth/refresh.test.ts`; `curl -X POST localhost:3000/auth/refresh -d '{"token":"..."}' -w "%{http_code}"`. + - Context synchronization: pending +``` + +## Acceptance criteria rules + +- Acceptance criteria describe the finished system, not the work. Prefer "the + endpoint returns 401 on an expired token" over "add expiry handling". +- Every criterion carries a `Validate:` line. A criterion nobody can check is + not an acceptance criterion. +- Prefer a runnable command. Fall back to a named inspection only when no + automated check exists, and say exactly what to look at. +- List repository-wide checks once under `Full validation` instead of repeating + them per criterion. +- Task-level `Verify` proves one task. Acceptance criteria prove the + plan. Keep them distinct: a task's checks are narrow and local, a criterion's + check is end-to-end. +- The union of the acceptance criteria must cover every success signal in the + change request. If a criterion has no task that could satisfy it, the task + stack is incomplete. + +## Task rules + +- Every task is a checkbox line so progress stays machine-readable: + `- [ ] T01: {title} (status:todo)`. +- Author each executable task as one atomic commit unit by default. +- Scope every task so one contributor can complete it and land it as one + coherent commit without bundling unrelated changes. +- Split any candidate task that would require multiple independent commits, for + example a refactor plus a behavior change plus documentation. +- Keep broad wrappers such as `polish`, `finalize`, or `misc updates` out of + executable tasks. Convert them into specific outcomes with concrete + acceptance checks. +- Order tasks so each one's declared dependencies precede it. + +## No validation task + +- The last task in the stack is an ordinary implementation task. Do not author a + trailing "validation and cleanup" task. +- Final validation, cleanup, and success-criteria verification are run by + `/validate` from the `Acceptance criteria` section after the last task + completes. +- Do not author a task whose only purpose is running the full check suite, + verifying durable context, or removing scaffolding. +- A task may still create or update durable context when that context is part of + the change itself. + +## Completion records + +When a task completes, the **Task execution phase** appends its evidence and flips the +checkbox and status: + +```markdown +- [x] T01: `{title}` (status:done) + - {authored fields, unchanged: Task ID, Scope, Dependencies, Done when} + - Verify: {each planned check, updated with its actual outcome} + - Completed: {YYYY-MM-DD} + - Files changed: {paths} + - Result: {concise factual outcome, not a prose diff} + - Context impact: {durable context this change affects, or none} + - Context synchronization: pending | synced | blocked + - Context synchronization blocker: {present only when status is blocked} Blocker: {problem}; Required action: {action}; Retry condition: {condition} +``` + +`/validate` appends a `## Validation Report` section at the end of the plan. +Do not author either while planning. + +## Updating an existing plan + +- Preserve completed tasks, their `(status:done)` markers, and their recorded + evidence verbatim. +- Preserve the plan's existing structure and terminology. +- Append new tasks after the existing stack. Renumber only when added work must + run earlier, and never renumber a completed task. +- Add acceptance criteria for newly planned outcomes rather than rewriting + criteria already satisfied. diff --git a/.agents/skills/sce-commit/SKILL.md b/.agents/skills/sce-commit/SKILL.md new file mode 100644 index 000000000..a5539c8c7 --- /dev/null +++ b/.agents/skills/sce-commit/SKILL.md @@ -0,0 +1,174 @@ +--- +name: sce-commit +description: > + Analyze staged changes and run the regular or explicit bypass commit workflow +--- + +# SCE Commit + +## Purpose + +Own this workflow from input parsing through its terminal user-visible response. +Execute the phases below directly and in order. Phase statuses are internal state, +not inter-SCE workflow handoffs. Do not invoke another SCE skill, sibling SCE +package, or SCE workflow command. Follow the canonical workflow's steps, gates, +and stops exactly as written: never invent, skip, reorder, or merge a step. + +## Phase reference + +Both paths below dispatch to the same phase, whose steps and boundaries live in +`references/atomic-commit.md`. This document holds the control flow — which path +runs, what the phase receives, and how its result branches — and the reference +holds the phase itself. + +Read `references/atomic-commit.md` before running the phase, not after. A regular +run that stops at the staging gate, and a bypass run that finds nothing staged, +both end without ever needing it. + +## User-visible output + +Use `references/output.md` for every gate and terminal response. Render no raw +internal state. The reference contains only human-visible Markdown layouts. +User-visible output is limited to those layouts: never invent a layout, and never +wrap one in an added preamble, commentary, summary, or extra section. + +## Composite control flow + +Keep phase results as internal state and continue immediately whenever the +canonical workflow says to continue. Stop only at a user wait or terminal branch. +Any workflow-defined user wait resumes this same skill in the same session. +Never expose an internal phase result +as the workflow's final response. + +Relevant non-SCE skills may be used as helper capabilities during the active step. +They are not workflow handoffs: when a helper returns, control returns to the active +step. Helper use must preserve the canonical phase order, gates, waits, writes, +validation, stops, and terminal user-visible output. + +## Input + +`invocation input` is optional. Split it into two parts before invoking the skill: + +`[mode-token] [commit context]` + +- `mode-token` is present only when the first whitespace-separated token is + exactly `oneshot` or `skip`, compared case-insensitively. Any other first + token is not a mode token. +- `commit context` is everything else: free-form prose that refines message + wording only. + +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 `invocation input` 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, +summarize, or pre-scope it. Never pass the `mode-token` as commit context. + +Staged changes are the source of truth for what is being committed. This +command never stages, unstages, or modifies files. + +For example: `$sce-commit oneshot`. + +## Workflow + +Follow exactly one path. + +### Regular path (no mode token) + +#### 1. Confirm staging + +Before running the phase, stop and prompt the user with the **Regular-mode +staging gate** layout from `references/output.md`. + +Wait for the user's confirmation. Do not stage files on their behalf, and do +not skip this prompt because the working tree looks ready. + +#### 2. Propose commits + +After confirmation, read `references/atomic-commit.md`, then run the **Atomic +commit phase** with `mode: regular` and the commit context. + +Do not write commit messages yourself. + +Branch on `status`: + +`blocked` -> Render the **Blocked** layout from `references/output.md`. Stop. + +`proposal` -> Render the **Regular proposal** layout from `references/output.md`, +which covers each proposed commit's message and files, and the split rationale +when more than one commit is proposed. + +Then stop. The regular path is proposal-only. + +Do not run `git commit`. Do not offer to commit on the user's behalf. The user +runs the commits they accept. + +### Bypass path (`oneshot` or `skip`) + +#### 1. Validate that staged content exists + +Run `git diff --cached --quiet`. A zero exit status means nothing is staged. + +When nothing is staged, stop with the **No staged changes** layout from +`references/output.md`. + +Do not stage anything. Do not proceed to the skill. + +#### 2. Request one commit message + +Read `references/atomic-commit.md`, then run the **Atomic commit phase** with +`mode: bypass` and the commit context. + +Bypass mode is the skill's contract for producing exactly one message. Do not +restate its overrides here; the **Atomic commit phase** owns them. + +Branch on `status`: + +`blocked` -> Render the **Blocked** layout from `references/output.md` and stop. Do not commit. + +`bypass_message` -> Continue to the next step. + +The skill never returns `proposal` in bypass mode. Treat a `proposal` result as +a contract violation: report it and stop without committing. + +#### 3. Execute exactly one commit + +Follow the **Bypass execution handoff** in `references/atomic-commit.md`: + +1. Create the commit-message temp file outside the repository working tree, and + write the returned `message` verbatim to it using a file-writing operation. Do + not interpolate the multiline message into shell source or a shell command. +2. Run `git commit -F ` exactly once. +3. Only after that command succeeds, retrieve the commit hash explicitly with + `git rev-parse --verify HEAD^{commit}`. Do not parse Git's human-readable + output. +4. Delete the temp file after the commit attempt, including on failure, where + practical. + +On success, render the **Bypass success** layout from `references/output.md` and +stop. + +On failure, render the **Bypass Git failure** layout from the same file and stop. + +Do not retry, do not amend, do not stage additional files, and do not fabricate a +commit hash. + +## Rules + +- Produce at most one commit per invocation, and only on the bypass path. +- Never commit on the regular path. +- Recognize `oneshot` and `skip` only as an exact case-insensitive first token. + They are behaviorally identical. +- Read `references/atomic-commit.md` before running the phase. +- Do not duplicate the internal instructions of the **Atomic commit phase**. +- Do not stage, unstage, restore, or otherwise modify repository or worktree + files. The bypass commit-message temp file is the sole exception: it must live + outside the working tree, so it is not a repository or worktree file. +- Do not amend, reset, revert, rebase, or push. +- Do not read unstaged or untracked changes as commit input. +- Do not infer success when the **Atomic commit phase** returns a non-success status. +- Do not proceed past a failed `git commit`. +- Do not run plan, task, or validation workflows from this command. diff --git a/.agents/skills/sce-commit/agents/openai.yaml b/.agents/skills/sce-commit/agents/openai.yaml new file mode 100644 index 000000000..dcd07f30b --- /dev/null +++ b/.agents/skills/sce-commit/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "SCE Commit" + short_description: "Analyze staged changes and run the regular or explicit bypass commit workflow" + default_prompt: "Analyze staged changes and run the SCE commit workflow." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/sce-commit/references/atomic-commit.md b/.agents/skills/sce-commit/references/atomic-commit.md new file mode 100644 index 000000000..b41d362ad --- /dev/null +++ b/.agents/skills/sce-commit/references/atomic-commit.md @@ -0,0 +1,154 @@ +# SCE Atomic Commit + +## Purpose + +Turn the current staged changes into atomic repository-style commit messages. + +Write messages matching: + +`references/commit-message-style.md` + +Committing is not this skill's job. The invoking `/commit` workflow decides +whether a returned message is committed, and it is the only thing that runs +`git commit`. + +## Input + +A mode (`regular` or `bypass`) and optional commit context, in free-form prose. + +The mode is supplied by the workflow from an explicit user-supplied token. +Never infer it, and never switch modes mid-analysis. + +Commit context refines wording only. The staged diff decides what the change +is; context never overrides staged truth, and never adds a claim the diff does +not support. + +Do not accept an unstaged diff, a working-tree summary, or a conversational +description as a substitute for the staged diff. + +## Workflow + +### 1. Read the staged diff + +Read the staged changes with `git diff --cached`, and the staged file list with +`git diff --cached --name-status`. + +Read staged file contents only when the diff alone does not explain the change. + +Return `blocked` when nothing is staged. + +### 2. Identify coherent units + +Infer the main reason for the staged change from the diff first. + +A coherent unit is one goal a reviewer would accept as a single commit. Group +staged files by that goal, not by directory. + +In `bypass` mode, stop grouping here: the result is exactly one message +covering all staged files, whether or not the diff is coherent. Do not propose +splits, and do not report split guidance. + +### 3. Choose a scope for each unit + +Use the smallest stable subsystem or module name recognizable in the repository. + +When no such name applies, use the primary directory or package of the unit's +changes. + +### 4. Write each message + +Follow `references/commit-message-style.md` for the subject pattern, the body +rules, issue references, the plan-citation rule, and the anti-patterns. + +### 5. Apply the plan-citation rule + +When the unit's staged files include `context/plans/*.md`, cite the affected +plan slug and updated task IDs in the body. + +When the staged plan diff does not expose the slug or task ID clearly enough to +cite faithfully: + +- In `regular` mode, return `blocked` and ask for the reference to be stated or + staged explicitly. +- In `bypass` mode, infer the citation when the diff supports it, and otherwise + omit it. Never stop, and never invent a slug or task ID. + +### 6. Apply context-file guidance gating + +This step applies in `regular` mode only. Skip it entirely in `bypass` mode; do +not classify staged scope there. + +Classify the staged diff: + +- Context-only (`context/**`): context-file-focused guidance is allowed. +- Mixed (`context/**` plus non-`context/**`): suppress default context-file + commit reminders and give guidance that reflects the full staged scope. + +### 7. Propose split guidance + +This step applies in `regular` mode only. + +When the units found in step 2 pursue unrelated goals, return one message per +unit, and state why the split is recommended and which staged files belong to +each. + +When the staged changes form one unit, return one message and no split +guidance. Do not split coherent work to appear thorough. + +### 8. Validate the result + +Confirm before returning that: + +- Every message describes its unit faithfully and covers only that unit's files. +- Every staged file belongs to exactly one returned message. +- No plan slug or task ID appears that the staged diff does not support. +- The mode's own constraints hold. + +## Bypass execution handoff + +This phase returns the message; the invoking `/commit` workflow performs the +bypass commit. When the mode is `bypass`, the invoking workflow must: + +1. Create the commit-message temp file outside the repository working tree, + and write the returned `message` verbatim to it using a file-writing + operation. Never interpolate a multiline message into shell source or a + shell command. +2. Run `git commit -F ` exactly once. +3. After and only after a successful commit, run + `git rev-parse --verify HEAD^{commit}` and use that explicit `HEAD` value as + the reported hash. Never parse Git's human-readable commit output. +4. On any commit failure, report Git's failure and stop. Never retry, amend, + stage more files, or fabricate a hash. +5. Delete the temp file after the commit attempt, including on failure, where + practical. + +`oneshot` and `skip` select this same bypass behavior; they differ only in the +trigger token. + +## Atomic commit boundaries + +Do not: + +- Run `git commit`, or any command that writes to the repository or its index. +- Stage, unstage, restore, or otherwise modify repository or worktree files. + The bypass commit-message temp file is the sole exception: it must live + outside the working tree, so it is not a repository or worktree file. +- Ask the user to stage or confirm staging. +- Analyze unstaged or untracked changes. +- Return more than one message in `bypass` mode. +- Return split guidance in `bypass` mode. +- Stop for plan-citation ambiguity in `bypass` mode. +- Invent plan slugs, task IDs, or issue references. +- Mention `context/` synchronization activity in a commit message. +- Claim a message was committed. +- Run plan, task, or validation workflows. + + +## Completion + +The skill is complete after: + +- The staged diff was read, or reading it failed and was reported. +- Messages were written for every staged file, or a blocker prevented it. + + diff --git a/.agents/skills/sce-commit/references/commit-message-style.md b/.agents/skills/sce-commit/references/commit-message-style.md new file mode 100644 index 000000000..056382ff6 --- /dev/null +++ b/.agents/skills/sce-commit/references/commit-message-style.md @@ -0,0 +1,44 @@ +# Commit Message Guide + +Use this repository style when writing new commits. + +## Core rules + +- Start with `scope: Subject` for most code changes. + - Common scopes: `runtime`, `language`, `objects`, `tests`, `CI`, `README`. + - Combined scopes are fine when needed (for example `language+runtime`). +- Use an imperative verb in the subject: `Fix`, `Add`, `Refactor`, `Remove`, `Implement`, `Update`, `Rewrite`, `Use`, `Allow`. +- Keep the subject specific and technical (name the subsystem and actual change). +- Keep the subject to one line and do not end it with a period. +- Add a body when the change is non-trivial. + - Explain why the change is needed. + - Explain how it works at a high level. + - Include impact/tradeoffs/follow-ups when relevant. +- For performance-related commits, include concrete measurements and benchmark context. + - Include regressions as well as improvements. +- Add issue references when relevant on their own lines. + - Example: `Fixes #123` + - Example: `Ref: https://...` + +## Practical template + +```text +: + + + + +Fixes # (optional) +``` + +## Size-based defaults + +1. Small fix: subject + 1 short reason line. +2. Medium refactor: subject + short why + short what changed. +3. Large architectural change: subject + context + bullets for major changes + impact/tradeoffs. + +## Anti-patterns to avoid + +- Vague subjects like "misc updates" or "cleanup". +- Bodies that only repeat the subject without explaining why or impact. +- Overly playful tone in serious bug-fix or architectural change. diff --git a/.agents/skills/sce-commit/references/output.md b/.agents/skills/sce-commit/references/output.md new file mode 100644 index 000000000..9fdbaddca --- /dev/null +++ b/.agents/skills/sce-commit/references/output.md @@ -0,0 +1,38 @@ +# Commit output layouts + +Use only the applicable layout. Values come from staged truth and internal +workflow state. + +## Regular-mode staging gate + +```markdown +Please run `git add ` for all changes you want included in this commit. +Atomic commits should only include intentionally staged changes. +Confirm once staging is complete. +``` + +## No staged changes + +```markdown +No staged changes. Stage changes before commit. +``` + +## Regular proposal + +For each proposal, present the complete commit message and covered files. When +more than one commit is proposed, also present the split rationale. Do not claim a +commit was created. + +## Blocked + +Present every issue's problem, impact, and required decision. Do not commit. + +## Bypass success + +```markdown +Committed {commit-hash} +``` + +## Bypass Git failure + +Present Git's failure unchanged and stop without retrying. diff --git a/.agents/skills/sce-decision/SKILL.md b/.agents/skills/sce-decision/SKILL.md new file mode 100644 index 000000000..543044c4d --- /dev/null +++ b/.agents/skills/sce-decision/SKILL.md @@ -0,0 +1,149 @@ +--- +name: sce-decision +description: > + Write one immutable ADR for one qualifying system-wide decision +--- + +# SCE Decision + +## Purpose + +Write exactly one architecture decision record for one qualifying system-wide +important decision during successful task context synchronization. Return +a deterministic internal handoff to the invoking synchronization phase. Do not +render an independent user-visible response. + +## Input + +Accept one structured decision request from `sce-next-task` task context +synchronization. It must identify: + +- One decision stated as a single durable choice. +- Why it qualifies under the decision gate. +- The implementation / task-verification evidence establishing the decision. +- The resolved plan path and relevant task IDs, when applicable. +- Related current-state context and existing ADR paths. +- An optional requested status. + +Do not accept raw workflow arguments, ordinary phase state, multiple decisions, +or direct user invocation. Do not reconstruct missing material facts. + +## Decision gate + +A decision qualifies only when it establishes or changes a system-wide important +constraint involving at least one of: + +- System boundaries or ownership. +- Public or cross-domain interfaces. +- Data models or persistence. +- Compatibility contracts. +- Security posture. +- Deployment or distribution strategy. +- A major dependency. +- A similarly durable constraint that is costly or risky to reverse. + +Routine implementation details, local refactors, naming and formatting choices, +temporary experiments, and easily reversible choices do not qualify. When the +request does not demonstrate the threshold, return `not_qualified` (or +`skipped` when the caller deliberately skips the gate); do not create an ADR +merely because context synchronization occurred. A nonqualifying or skipped +result is non-blocking, so the invoking synchronization phase continues +normally. Reserve `blocked` for missing, contradictory, or otherwise unsafe +decision input or history. + +## Workflow + +### 1. Validate one decision + +Confirm the request contains exactly one decision, qualifying evidence, a plan +path, references sufficient to make the record traceable, and no unresolved +material contradiction. If it contains several decisions, require the caller to +submit one request per decision. + +Allowed statuses for a newly written ADR are exactly `Proposed`, `Accepted`, +`Rejected`, `Deprecated`, and `Superseded`. Use the explicitly requested allowed +status; otherwise default to `Accepted`. `Deprecated` and `Superseded` remain +distinct creation-time-only statuses: use them to describe the record when it is +created, but never mutate an existing ADR into or out of either status. Reject any +other status rather than guessing. + +### 2. Inspect existing decision history + +Read `context/decisions/` and the supplied related ADR paths before writing. + +- Reuse an existing ADR only when it records an equivalent decision and has an + active status: `Proposed` or `Accepted`. Return its path without creating a + duplicate. Never reuse a `Rejected`, `Deprecated`, or `Superseded` ADR. +- Existing ADRs are immutable regardless of status. Never edit an ADR whose status is `Accepted`; do not edit, overwrite, or silently change the status of any existing record. +- A correction, reversal, or any changed decision always creates a new dated ADR; + it references and supersedes the prior record when applicable, rather than + modifying that record. + +If `context/` or `context/decisions/` is absent, or history cannot be interpreted +without inventing facts, return `blocked` without creating directories. + +### 3. Resolve the path + +Use exactly `context/decisions/YYYY-MM-DD-.md`, where the date is +the record creation date and `` is a concise lowercase kebab-case +summary of the one decision. Resolve collisions by choosing a more specific +deterministic slug; never add an arbitrary counter and never overwrite a record. + +### 4. Write the ADR + +Create exactly one file using `references/adr-template.md` and these rules: + +- **Context** states the forces and constraint that made a decision necessary. +- **Decision** states one resulting choice, not a list of unrelated choices. +- **Rationale** explains why this path best satisfies the constraints. +- **Alternatives considered** names credible alternatives and why they were not + selected. +- **Compatibility and risks** states compatibility effects, migration concerns, + and material risks with mitigations. +- **Guardrails** records durable limits that keep the decision narrow. +- **Consequences** records positive and negative resulting constraints. +- **Follow-up** lists only established work or conditions; use `None.` when no + follow-up is established. +- **References** links the plan, relevant tasks, evidence, current-state context, + related ADRs, and any superseded ADR. + +Use repository-relative Markdown links where practical. Describe durable truth, +not the implementation session. Do not edit current-state context; the invoking +synchronization phase owns linking the new ADR from authoritative context. + +### 5. Verify the record + +Confirm that exactly one ADR was created or one existing matching ADR was reused; +the filename, status, sections, and references satisfy this contract; every +referenced repository path exists when practical to check; and no accepted ADR +was modified. + +### 6. Return internal state + +Return exactly one internal handoff: + +- `written`: include `status`, `adr_path`, `decision`, `decision_status`, + `created` (`true` for a new ADR and `false` for reuse), `supersedes`, and + concise verification evidence. +- `not_qualified` or `skipped`: include `status`, the reason the decision gate + did not produce an ADR, and concise supporting evidence. These results are + non-blocking; the invoking synchronization phase continues normally. +- `blocked`: include `status`, the specific `problem`, its `impact`, and the + `required_action`. Use this only when decision writing cannot proceed safely. + +Use stable field names and repository-relative paths. Return no prose before or +after the handoff. The invoking synchronization phase owns all user-visible +reporting. + +## Boundaries + +Do not: + +- Write more than one ADR per request. +- Run outside successful task context synchronization. +- Create a command, prompt, context root, or decisions directory. +- Modify application code, tests, plans, current-state context, or existing + accepted ADRs. +- Treat every context update as an architecture decision. +- Choose among unresolved material alternatives on the user's behalf. +- Create a Git commit or push changes. diff --git a/.agents/skills/sce-decision/references/adr-template.md b/.agents/skills/sce-decision/references/adr-template.md new file mode 100644 index 000000000..31891d448 --- /dev/null +++ b/.agents/skills/sce-decision/references/adr-template.md @@ -0,0 +1,49 @@ +# Decision: {concise decision title} + +Date: {YYYY-MM-DD} +Status: {Proposed|Accepted|Rejected|Deprecated|Superseded} +Plan: `{context/plans/plan-name.md}` +Task: `{task-id or comma-separated task IDs}` +Supersedes: `{context/decisions/YYYY-MM-DD-prior-decision.md}` + +Omit `Task` or `Supersedes` only when it does not apply. Do not omit `Plan`. + +## Context + +{Forces, constraints, and evidence that require this decision.} + +## Decision + +{Exactly one durable system-wide choice.} + +## Rationale + +{Why this choice best satisfies the constraints.} + +## Alternatives considered + +- **{Alternative}** — {Why it was not selected.} + +## Compatibility and risks + +- {Compatibility effect, migration concern, or material risk and mitigation.} + +## Guardrails + +- {Durable limit that keeps the decision narrow.} + +## Consequences + +- {Positive or negative resulting constraint.} + +## Follow-up + +- {Established follow-up work or condition, or `None.`} + +## References + +- Plan: [`{plan name}`]({relative path}) +- Task: `{task ID}` +- Current-state context: [`{context file}`]({relative path}) +- Evidence: [`{file or report}`]({relative path}) +- Related decision: [`{decision title}`]({relative path}) diff --git a/.agents/skills/sce-handover/SKILL.md b/.agents/skills/sce-handover/SKILL.md new file mode 100644 index 000000000..75d0cf72a --- /dev/null +++ b/.agents/skills/sce-handover/SKILL.md @@ -0,0 +1,174 @@ +--- +name: sce-handover +description: > + Write a session handover document, or load one for continuation +--- + +# SCE Handover + +## Purpose + +Own this workflow from input parsing through its terminal user-visible response. +Execute the phases below directly and in order. Phase statuses are internal state, +not inter-SCE workflow handoffs. Do not invoke another SCE skill, sibling SCE +package, or SCE workflow command. Follow the canonical workflow's steps, gates, +and stops exactly as written: never invent, skip, reorder, or merge a step. + +## User-visible output + +Use `references/output.md` for every gate and terminal response. Render no raw +internal state. The reference contains only human-visible Markdown layouts. +User-visible output is limited to those layouts: never invent a layout, and never +wrap one in an added preamble, commentary, summary, or extra section. + +## Composite control flow + +Keep phase results as internal state and continue immediately whenever the +canonical workflow says to continue. Stop only at a user wait or terminal branch. +Any workflow-defined user wait resumes this same skill in the same session. +Never expose an internal phase result +as the workflow's final response. + +Relevant non-SCE skills may be used as helper capabilities during the active step. +They are not workflow handoffs: when a helper returns, control returns to the active +step. Helper use must preserve the canonical phase order, gates, waits, writes, +validation, stops, and terminal user-visible output. + +## Input + +`invocation input` is optional and selects the mode: + +- Empty `invocation input` 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 + `/handover context/handovers/.md`) and stop without guessing a mode. + +Never infer the mode from conversation content or repository state. Only the +presence or absence of a path argument decides it. + +For example: `$sce-handover context/handovers/2026-08-24-session.md`. + +## Workflow + +Follow exactly one path. + +### Writer path (no arguments) + +#### 1. Gather session and repository facts + +Inspect the current conversation for task-relevant progress: the goal being +pursued, decisions made, work completed or in flight, and open questions or +blockers. + +Ground those facts against repository state: + +- `git status`, `git diff`, and `git diff --cached` for uncommitted work, + including both unstaged and staged changes. +- `context/plans/*.md` for the active plan and task, when one is being worked. +- Recent commits, when they clarify what just landed. + +Label any detail not directly evidenced by the conversation or repository state +as an assumption. Do not present an inferred detail as confirmed fact. + +#### 2. Determine the file name + +- When exactly one plan task is unambiguously active — one plan with one + in-progress or next-actionable task identifiable from the conversation and + repository state — use `context/handovers/{plan_name}-{task_id}.md`, where + `plan_name` is the plan's file stem and `task_id` is its task ID (for + example `T01`). +- Otherwise use the collision-safe timestamped fallback + `context/handovers/handover-{YYYY-MM-DD-HHMMSS}.md`. + +Never overwrite an existing file. If the resolved path already exists, use the +current timestamp for the fallback name, or append a further distinguishing +timestamp segment, rather than overwriting it. + +#### 3. Compose the handover document + +Read `references/handover-template.md` before composing. It defines the +persisted-document format and is the only template authority. Populate all +four required sections: + +- `Current Task State` +- `Decisions Made` +- `Open Questions / Blockers` +- `Next Recommended Step` + +Every section must contain real content. Write `None identified.` (or a +section-appropriate equivalent) when nothing applies — never omit a required +section and never leave template placeholders in the written file. + +Label inferred or assumed details inline as assumptions; do not blend them with +confirmed facts. + +#### 4. Confirm the context root + +When `context/` does not exist, there is no durable location to write to. +Render the **Writer blocked** layout with `sce setup --bootstrap-context` as +the required action, and stop without writing a file. + +#### 5. Write exactly one file + +Write the composed document to the path resolved in step 2. Before reporting +success, confirm the written file contains all four required sections +populated with real content. + +#### 6. Report + +Render the **Writer success** layout from `references/output.md` with the +written path. Stop. + +### Loader path (one path argument) + +#### 1. Validate the path + +The argument must resolve to an existing file under `context/handovers/` with +a `.md` extension. Reject: + +- A path outside `context/handovers/`. +- A path with a different extension. +- A path that does not exist. + +Do not guess an alternate file, and do not treat an arbitrary repository file +as a handover. When the path is rejected, render the **Loader blocked** layout +and stop. + +#### 2. Validate handover completeness + +Read the file and confirm it contains all four required sections: +`Current Task State`, `Decisions Made`, `Open Questions / Blockers`, and +`Next Recommended Step`. For each section, inspect the content up to the +next required heading (or the end of the file): it must contain non-whitespace +content, and it must not consist only of an empty list marker, a template +placeholder such as `{What is being worked on...}`, or other unreplaced +`{...}` scaffolding. Explicit statements such as `None identified.` are real +content and are valid. + +When any required section is missing, empty, or placeholder-only, render the +**Loader blocked** layout (invalid handover) and stop. + +#### 3. Present for continuation + +Render the **Loader success** layout from `references/output.md`, surfacing +the handover's task state, decisions, open questions, and next recommended +step for continuation in the current session. + +Loading is read-only: do not edit any file, mark a plan task complete, change +repository state, or begin the recommended next step. Presenting the loaded +guidance is the entire loader contract. + +## Rules + +- Handle at most one handover per invocation, in exactly one mode. +- Writer mode never overwrites an existing handover file. +- Writer mode never marks a plan task complete or edits any file outside the + one handover document it writes. +- Loader mode never edits a file, writes a new file, or changes plan or task + state. +- Never invoke another SCE skill, sibling SCE package, or SCE workflow command. +- Never treat a file outside `context/handovers/`, or a non-Markdown file, as a + loadable handover. +- Never create the `context/` root; `sce setup --bootstrap-context` owns that. +- Do not begin, plan, or automate the loaded handover's recommended next step. diff --git a/.agents/skills/sce-handover/agents/openai.yaml b/.agents/skills/sce-handover/agents/openai.yaml new file mode 100644 index 000000000..7aebe8972 --- /dev/null +++ b/.agents/skills/sce-handover/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "SCE Handover" + short_description: "Write a session handover document, or load one for continuation" + default_prompt: "Write an SCE session handover document, or load one for continuation." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/sce-handover/references/handover-template.md b/.agents/skills/sce-handover/references/handover-template.md new file mode 100644 index 000000000..51edfefbc --- /dev/null +++ b/.agents/skills/sce-handover/references/handover-template.md @@ -0,0 +1,47 @@ +The Markdown document writer mode creates under +`context/handovers/{name}.md`. This is the persisted file's content, distinct +from the terminal response defined in `references/output.md`. + +### Layout + +```markdown +# Handover: {plan name or short session topic} + +Date: {YYYY-MM-DD} +Plan: `{context/plans/plan-name.md}` (omit when no plan applies) +Task: `{task-id}` (omit when no single task applies) + +## Current Task State + +{What is being worked on, what is complete, what is in progress. Cite files, +commands, or plan/task references where they ground the statement.} + +## Decisions Made + +- {Decision and its rationale, or `None made this session.`} + +## Open Questions / Blockers + +- {Unresolved question or blocker, or `None identified.`} + +## Next Recommended Step + +{The single most useful next action for the following session, concrete +enough to act on directly.} + +## Assumptions + +- {Any detail above that was inferred rather than directly evidenced, or + `None.`} +``` + +### Rules + +- Include `Plan` and `Task` only when the session was working one identifiable + plan task; omit them rather than guessing. +- Every one of the four required sections must appear, in this order, even + when its content is `None identified.` or an equivalent. +- Keep `Assumptions` scoped to details actually labeled as inferred elsewhere + in the document; do not duplicate confirmed facts here. +- Describe durable state useful to a future session, not a transcript of this + one. diff --git a/.agents/skills/sce-handover/references/output.md b/.agents/skills/sce-handover/references/output.md new file mode 100644 index 000000000..3d1e63891 --- /dev/null +++ b/.agents/skills/sce-handover/references/output.md @@ -0,0 +1,96 @@ +# Handover output layouts + +Use only the applicable layout. Values come from the resolved mode and +internal workflow state. + +## Invalid usage + +```markdown +# Handover: invalid arguments + +`/handover` takes no arguments (writer mode) or exactly one handover path +(loader mode): `/handover context/handovers/.md`. + +Received: `{invocation input}` +``` + +## Writer blocked + +```markdown +# Handover not written + +**Problem:** {specific blocker, e.g. missing context root} +**Required action:** `{command or decision}` + +No file was written. +``` + +## Writer success + +```markdown +# Handover written + +**Path:** `{written path}` + +To continue from this handover in another session: + +`/handover {written path}` +``` + +## Loader blocked + +```markdown +# Handover not loaded + +**Problem:** {path outside context/handovers/, non-Markdown, missing file, or +missing required section} +**Path:** `{argument as given}` + +Loading stopped. No file was read past the point of failure, and nothing was +changed. +``` + +## Loader success + +```markdown +# Handover loaded + +**Path:** `{loaded path}` + +## Current Task State + +{content read from the file} + +## Decisions Made + +- {content read from the file} + +## Open Questions / Blockers + +- {content read from the file} + +## Next Recommended Step + +{content read from the file} + +## Assumptions + +- {content read from the file, or `None.`} + +--- + +This handover has been presented for continuation only. No file was edited, no +plan task was marked complete, and the recommended next step was not started. +``` + +# Report rules + +- Writer success must report the exact written path so + `/handover {written path}` is directly runnable. +- Never claim a handover was written or loaded unless the corresponding file + operation actually completed. +- Loader success must not include any statement implying repository state + changed; loading is read-only. +- Omit `Plan` and `Task` framing in the loaded output only when the source + document itself omits them. +- Never fabricate section content; render exactly what was written or read. diff --git a/.agents/skills/sce-next-task/SKILL.md b/.agents/skills/sce-next-task/SKILL.md new file mode 100644 index 000000000..f3bb55663 --- /dev/null +++ b/.agents/skills/sce-next-task/SKILL.md @@ -0,0 +1,195 @@ +--- +name: sce-next-task +description: > + Review, approve, implement, verify, and synchronize one SCE plan task +--- + +# SCE Next Task + +## Purpose + +Own this workflow from input parsing through its terminal user-visible response. +Execute the phases below directly and in order. Phase statuses are internal state, +not inter-SCE workflow handoffs. Do not invoke another SCE skill, sibling SCE +package, or SCE workflow command except `sce-decision`, and invoke `sce-decision` +only from the successful context-synchronization decision gate. Follow the canonical workflow's steps, gates, +and stops exactly as written: never invent, skip, reorder, or merge a step. + +## Phase references + +Each numbered step below dispatches to a phase whose steps, gates, and boundaries +live in a reference file. This document holds the control flow — which phase runs, +what it receives, and how its result branches — and each reference holds the phase +itself. + +| Step | Read before running the phase | +|---|---| +| 1 | `references/plan-review.md` | +| 2 | `references/task-execution.md` | +| 3 | `references/context-sync.md` | + +Read a step's reference before taking any action for that step, not after. The +references carry gates that must fire before their phase's first side effect, so a +phase begun from this summary alone will already have skipped them. Read only the +reference for the step you have reached: a run that stops at step 1 never needs the +other two, which is why they are separate files. + +## User-visible output + +Use `references/output.md` for every gate and terminal response. Render no raw +internal state. The reference contains only human-visible Markdown layouts. +User-visible output is limited to those layouts: never invent a layout, and never +wrap one in an added preamble, commentary, summary, or extra section. + +## Composite control flow + +Keep phase results as internal state and continue immediately whenever the +canonical workflow says to continue. Stop only at a user wait or terminal branch. +Any workflow-defined user wait resumes this same skill in the same session. +Never expose an internal phase result as the workflow's final response. + +Relevant non-SCE skills may be used as helper capabilities during the active step. +They are not workflow handoffs: when a helper returns, control returns to the active +step. Helper use must preserve the canonical phase order, gates, waits, writes, +validation, stops, and terminal user-visible output. + +## Input + +Parse `invocation input` into three positional parts before invoking any phase: + + [task-id] [auto-approve] + +- `plan-name-or-path` is required. +- `task-id` is optional. It is present only when the token matches a task ID (`T01`, `T02`, ...). +- `auto-approve` is optional. It is present only when the token is exactly `approved`. + +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 `invocation input` 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. + +For example: `$sce-next-task my-plan T03 approved`. + +## Workflow + +### 1. Review the task + +Read `references/plan-review.md`, then run the **Plan review phase** with the +parsed `plan-name-or-path` and, when present, the parsed `task-id`. + +Do not pass the `auto-approve` token to the **Plan review phase**. + +Branch on `status`: + +`blocked` -> Do not run implementation. Render the **Review blocked** layout from `references/output.md`. When `candidates` is present the plan could not be resolved, and each entry is a candidate path for `/next-task {candidate-path}`. `executable_tasks_remaining` true means another task remains executable and `/next-task {plan-path} {task-id}` selects one; false means no task in the plan can proceed until the plan is updated. Do not print the raw result. Stop. + +`sync_debt` -> Read `references/context-sync.md`, then run the **Task context synchronization phase** using the debt task's persisted `Context synchronization handoff` — and, when present, its persisted `Context synchronization blocker` — named by the **Plan review phase**. Do not reconstruct a missing handoff from conversation history. + +Write the debt task's lifecycle to the plan: `synced`, clearing its blocker, required action, and retry condition, for `synced` or `no_context_change`; a refreshed `blocked` state with the report's blocker, required action, and retry condition for `blocked`. If that lifecycle write fails, treat the outcome as `blocked`. + +Branch on the outcome: + +`blocked` -> Render the **Context synchronization blocked** layout from `references/output.md`, distinct from the **Review blocked** layout above. The plan's task lifecycle record contains the blocker, required action, and retry condition. Do not select or start a new task. Stop. + +`synced` | `no_context_change` -> Re-invoke the **Plan review phase** with the same `plan-name-or-path` and, when present, `task-id` to resume normal task selection. + +`plan_complete` -> Render the **Plan already complete** layout from `references/output.md`. Stop. + +`ready` -> Pass the complete readiness result to the **Task execution phase**. + +Do not reconstruct, summarize, or reinterpret the reviewed task before passing it. + +The review inspects every completed task's `Context synchronization` field in +the plan, in plan order, regardless of its position relative to the task being +selected or resumed, before allowing a new implementation task to start. A +missing field, or any value other than `synced`, is unresolved synchronization +debt. Never infer `synced` from conversation history. When the debt-carrying +task has no durable `Context synchronization handoff` subsection, the **Plan +review phase** returns `blocked` directly with a legacy-migration required +action; otherwise it returns `sync_debt`, resolved by the branch above. + +### 2. Execute the task + +Read `references/task-execution.md`, then run the **Task execution phase** with +the complete `ready` result from the **Plan review phase**. + +This phase always shows an implementation gate before it modifies any file, and it +is the only phase permitted to ask the user for confirmation. Both properties are +load-bearing, so reach them through the reference rather than acting from this +summary. + +Branch on `auto-approve`: + +`approved` -> Also pass the `approve` flag. The **Task execution phase** then shows its implementation gate as a summary and proceeds without asking. + +else -> Do not pass the `approve` flag. The **Task execution phase** shows its implementation gate and waits for the user's decision. + +Do not present an additional implementation confirmation. + +Branch on the execution result. + +`declined` -> Render the **Declined** layout from `references/output.md`. Do not run context synchronization. Stop. + +`blocked` -> Render the **Execution blocked or incomplete** layout from `references/output.md`. Do not run context synchronization. Stop. + +`incomplete` -> Render the same **Execution blocked or incomplete** layout. Do not run context synchronization. Do not select another task. Stop. + +`complete` -> continue to the next step. + +### 3. Synchronize context + +Read `references/context-sync.md`, then run the **Task context synchronization +phase** with the complete `complete` result returned by the **Task execution +phase**. + +Pass that result verbatim. It is the authoritative handoff, and the **Task context synchronization phase** owns reading the plan, task, changed files, verification evidence, and reported context impact out of it. + +Do not restate, summarize, or reconstruct any part of the execution result. + +This phase verifies the five root context files on every invocation, whatever the +change's reported impact, so it is never correct to skip it as unnecessary. + +Before branching on the synchronization result, write the completed task's +lifecycle to the plan file: `synced` for `synced` or `no_context_change`, and +`blocked` with the report's blocker, required action, and retry condition for +`blocked`. If that lifecycle write fails, treat synchronization as `blocked`. + +Branch on the synchronization result. + +`blocked` -> The task itself succeeded and is already marked complete in the plan. Render the **Context synchronization blocked** layout from `references/output.md`. The plan's task lifecycle record contains the blocker, required action, and retry condition. + +Do not select another task. Stop. + +`synced` | `no_context_change` -> Print out the report the **Task context synchronization phase** returned. Continue to the next step. + +### 4. Determine the continuation + +Use `plan.completed_tasks` and `plan.total_tasks` from the execution result to determine which continuation applies. + +Do not execute another task. Return exactly one continuation. + +If incomplete tasks remain, read the plan and name the first unchecked task in plan order. Do not evaluate its dependencies; the **Plan review phase** checks them when the emitted command runs and returns `blocked` if they are unmet. + +Render the **More tasks remain** layout from `references/output.md`. + +If all tasks are completed, render the **All tasks complete** layout instead. + +Stop. + +## Rules + +- Execute at most one plan task per invocation. +- Review at most one task. +- Read each phase's reference before running that phase. +- Do not duplicate the internal instructions of embedded phases. +- The only permitted sibling-skill invocation is `sce-decision`, and only the + successful context-synchronization decision gate may invoke it. +- Do not ask for implementation confirmation outside "Task execution phase". +- Do not run full-plan validation. +- Do not mark the plan complete. +- Do not execute the continuation returned at the end. +- Do not infer success when an embedded phase returns a non-success status. +- Preserve completed work and evidence when a later phase fails. diff --git a/.agents/skills/sce-next-task/agents/openai.yaml b/.agents/skills/sce-next-task/agents/openai.yaml new file mode 100644 index 000000000..e2ada4f24 --- /dev/null +++ b/.agents/skills/sce-next-task/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "SCE Next Task" + short_description: "Review, approve, implement, verify, and synchronize one SCE plan task" + default_prompt: "Review, approve, implement, verify, and synchronize the next SCE plan task." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/sce-next-task/references/context-sync.md b/.agents/skills/sce-next-task/references/context-sync.md new file mode 100644 index 000000000..30ec3eaee --- /dev/null +++ b/.agents/skills/sce-next-task/references/context-sync.md @@ -0,0 +1,348 @@ +# Task context synchronization phase + +Run this phase for step 3 of the workflow, and only when task execution returned +`complete`. It updates durable repository knowledge in `context/` so the next +session inherits what this task established. It never touches code, tests, or +plan state. + +Input: either the complete `complete` result from the task execution phase +(same-session), passed verbatim, or the plan path and task ID a plan-review +recovery step resolved for a `blocked` task, together with that task's own +completed record — read directly from the plan — and its persisted `Context +synchronization blocker` when present (cross-session retry). Whichever was +supplied is the authoritative source, and this phase owns reading the plan, +task, changed files, verification evidence, and reported context impact out +of it. + +Do not restate, summarize, or reconstruct any part of it. Do not reconstruct a +missing execution result or completed task record from conversation history. + +A live execution result must have: + +```text +status: complete +``` + +A cross-session retry has no separate `status` field to check; the completed +task record's presence in the plan, identified by plan path and task ID, is +itself the authoritative signal. + +Use the report format in: + +`references/sync-report.md` + +Treat whichever source was supplied — the live execution result, or the +completed task record read directly from the plan — as the authoritative +source for: + +- The resolved plan and completed task. +- `changes.files_changed`, or the completed task record's own `Files changed` + field on retry, already attributed relative to the pre-edit Git baseline. +- Files changed by implementation. +- The task's `Result` (or implementation summary, for a live result). +- `Verify` outcomes (or verification evidence, for a live result). +- Done-check evidence. +- Reported context impact. + +This phase must not be run for `declined`, `blocked`, or `incomplete` execution +results. + +## 3.1 Validate the handoff + +Confirm that: + +- A live execution result has `status` exactly `complete`; a cross-session + retry has no `status` field to check and is authoritative by the completed + task record's presence in the plan. +- A resolved plan path and task ID are present; a live execution result + carries them in its `plan` and `task` objects, and a cross-session retry + receives them directly from the caller that resolved the debt task. +- Exactly one completed task is identified, and — on retry — its record is + read directly from the plan by that plan path and task ID rather than + reconstructed in-band. +- Changed files and a `Result` (an implementation summary, for a live result) + are present. +- `Verify` outcomes (verification evidence, for a live result) are present. +- Done-check evidence is present. +- A context-impact classification is present. + +If the required information is missing, the completed task record cannot be +read from the plan, or either is internally contradictory, do not modify +context. Return a `blocked` Markdown report. + +## 3.2 Confirm the context root + +When `context/` does not exist, there is no durable memory to synchronize. Do not +create it, and do not write context files outside it. + +Return a `blocked` report whose required action is: + +`sce setup --bootstrap-context` + +State that the task itself is complete and recorded in the plan, and that +synchronization should run again once the context root exists. + +Bootstrapping is the user's action, not this phase's. + +## 3.3 Discover applicable context + +Start with the execution result: + +- `context_impact.classification` +- `context_impact.affected_areas` +- Changed files. +- Implementation summary. +- Done-check evidence. + +Then inspect existing repository context in this order when present: + +1. `context/context-map.md` +2. Context files for the affected domain or subsystem +3. `context/overview.md` +4. `context/architecture.md` +5. `context/glossary.md` +6. `context/patterns.md` +7. Operational, product, or decision records directly related to the change + +Use the context map and existing links to locate authoritative files. + +Do not scan or rewrite the entire `context/` tree by default. + +Do not create a new context file when an existing authoritative file can be +updated coherently. + +### The mandatory root pass + +Every invocation verifies these five files against code truth, whatever the +reported classification is: + +- `context/overview.md` +- `context/architecture.md` +- `context/glossary.md` +- `context/patterns.md` +- `context/context-map.md` + +Verifying is not editing. A classification that warrants no root edit still +requires reading each of these and confirming it is not contradicted by the +completed implementation. A file that is absent is a gap; record it in the report +rather than creating it to satisfy the pass. + +Report each of the five as verified or edited. Never declare synchronization done +while one of them is unchecked. + +## 3.4 Determine whether durable context changed + +Use the reported context impact as a strong hint, then verify it against the +implementation and existing context. + +Durable context includes non-obvious repository knowledge such as: + +- User-visible or externally observable behavior. +- Architecture, boundaries, ownership, and dependency direction. +- Public interfaces, data contracts, and persistence behavior. +- Operational procedures and important failure modes. +- Security or privacy behavior. +- Shared terminology. +- Intentional limitations and meaningful design decisions. + +Do not document: + +- Details already obvious from the implementation. +- Temporary debugging information. +- A file-by-file narration of the change. +- Test output that belongs only in task evidence. +- Speculation or future work not established by the completed implementation. +- Generic engineering practices. + +Interpret impact classifications as follows. Each governs which files are +*edited*; none of them waives the mandatory root pass. + +- `none`: Make no edits beyond any correction the root pass turns up. +- `local`: Update the nearest existing authoritative context only when the new + behavior is not reliably discoverable from code. +- `domain`: Update affected domain context and the context map when its links or + summaries changed. +- `root`: Update the relevant root context and any affected domain context. + +A change is `root` when it introduces cross-cutting behavior, repository-wide +policy or contracts, an architecture or ownership boundary, or a change to +canonical terminology. A change confined to one feature or domain, with no +repository-wide behavior, architecture, or terminology impact, is `domain` or +`local`: capture its detail in domain files and leave the root files unedited. + +If the reported classification is inconsistent with the actual change, use the +verified classification and explain the difference in the report. + +## 3.5 Record qualifying architecture decisions + +During this successful synchronization, determine whether the completed change +establishes or changes a system-wide important constraint involving one or more +of: + +- System boundaries or ownership. +- Public or cross-domain interfaces. +- Data models or persistence. +- Compatibility contracts. +- Security posture. +- Deployment or distribution strategy. +- A major dependency. +- A similarly durable constraint that is costly or risky to reverse. + +Routine implementation details, local refactors, naming and formatting choices, +temporary experiments, and easily reversible choices do not qualify. Do not +invoke a decision skill for them. + +Use the discovered context, existing decision records, and this evidence: + +- execution and done-check evidence. + +Identify each qualifying decision, then handle qualifying decisions in +deterministic order: + +1. Reuse a written ADR path already returned during this plan when it records the + same decision. +2. Otherwise invoke `sce-decision` once with exactly one structured decision + request containing the decision, qualifying evidence, plan and task + references, related context and ADR paths, and any user-requested status. +3. On `written`, retain the returned `adr_path` as synchronization evidence and + make it available for current-state context links before synchronization + completes. Reuse is valid evidence; do not create a duplicate ADR. +4. On `blocked`, stop before current-state context edits and return a `blocked` + synchronization report carrying the decision-writing problem, impact, required + action, and retry condition. + +Invoke `sce-decision` only here, after a successful execution or validation +handoff and during context synchronization. Do not invoke it from a non-success +branch or for any non-decision purpose. When no decision qualifies, continue +without invoking it and record that outcome in synchronization evidence. + +## 3.6 Synchronize context + +Make the smallest coherent documentation change that preserves repository truth. + +When editing context: + +- Describe the resulting behavior, not the implementation session. +- Preserve repository terminology and document structure. +- Remove or correct statements contradicted by the completed implementation. +- Update cross-references when files are added, moved, renamed, or superseded. +- Keep one authoritative statement for each durable fact. +- Avoid copying the execution result verbatim into context files. +- Do not change application code, tests, or plan state. + +Create a new context file only when: + +- The knowledge is durable and non-obvious. +- No existing file owns it coherently. +- The new file has a clear place in the context map. + +### Feature existence + +Every feature the completed task implemented must have at least one durable +canonical description discoverable from `context/`, in a domain file under +`context/{domain}/` or in `context/overview.md` for a cross-cutting feature. + +When the task implemented a feature no context file describes, add that +description. A feature that fits no existing domain file gets a new focused file; +do not defer it to a later task. Prefer a small, precise domain file over +overloading `overview.md` with detail. + +This is the one case where documentation is warranted by the change itself rather +than by a gap in durable knowledge. It is not license to narrate the diff: +describe what the feature is and how it behaves, not what was edited. + +### Glossary + +Add a `context/glossary.md` entry for any domain language the task introduced. +New terminology is durable knowledge whatever the classification is: a `domain` +change that names a new concept still earns its glossary entry. + +### File hygiene + +Every context file this phase writes must satisfy: + +- One topic per file. +- At most 250 lines. When an edit would push a file past 250 lines, split it into + focused files and link them rather than letting it grow. +- Relative paths in every link to another context file. +- A Mermaid diagram where structure, boundaries, or flows are complex enough that + prose alone would not carry them. +- Concrete code examples only where they clarify non-trivial behavior. + +When detail outgrows a shared file, migrate it into `context/{domain}/`, leave a +concise pointer behind, and link the new file from `context/context-map.md`. + +## 3.7 Verify synchronization + +After edits, verify: + +- Every changed context file accurately reflects the completed implementation. +- No edited statement contradicts the code, plan, or execution evidence. +- Every qualifying decision has one written or reused ADR path in the report, and + the report states when no decision qualified. +- Every file in the mandatory root pass was read and confirmed against code + truth, whether or not it was edited. +- Each feature implemented by the task has a durable canonical description + reachable from `context/`. +- Every changed file is at or below 250 lines, covers one topic, and links other + context files by relative path. +- Diagrams are present where structure, boundaries, or flows are complex. +- Links and referenced paths resolve when practical to check. +- New context files are reachable from the context map or another authoritative + index. +- Root context remains concise and delegates details to domain files. +- Unrelated context was not changed. + +Use focused documentation, link, or formatting checks when available. + +Do not run full application or plan validation. + +If synchronization cannot be completed without inventing facts or resolving a +material contradiction, preserve safe edits when appropriate and return a +`blocked` report. + +## 3.8 Return the Markdown report + +Set exactly one report status: + +- `synced` +- `no_context_change` +- `blocked` + +`synced` means context files were updated and verified. `no_context_change` means +existing context was checked and no edit was warranted. `blocked` means context +could not be synchronized safely. + +A `blocked` report always writes the plan path and task ID/title as identity, +plus a `Context synchronization blocker` section (blocker, required action, +retry condition), using the same field names the plan's completion record +uses, so the plan-review recovery step can persist the blocker verbatim and a +future retry can read the completed task record directly from the plan by +plan path and task ID. + +Record only the Markdown report. Do not add explanatory prose before or after it. + +Do not determine whether the plan is complete. The `/next-task` workflow owns +that decision after context synchronization. + +## Task context synchronization boundaries + +Do not: + +- Accept an execution result whose status is not `complete`. +- Implement or modify application code. +- Modify tests. +- Change task completion status or plan evidence. +- Determine whether the plan is complete. +- Select or execute another task. +- Run full-plan validation. +- Mark the plan validated, closed, or archived. +- Create a Git commit or push changes. +- Create the context root. `sce setup --bootstrap-context` owns that. +- Narrate changed files as documentation. Feature existence is the only reason to + document a change that introduced no other durable knowledge. +- Invoke any sibling SCE skill, sibling SCE package, or SCE workflow command + except `sce-decision`, or invoke `sce-decision` outside the decision gate in + successful context synchronization. +- Delete a context file that has uncommitted changes. +- Return an execution-style internal state. diff --git a/.agents/skills/sce-next-task/references/output.md b/.agents/skills/sce-next-task/references/output.md new file mode 100644 index 000000000..b6e772bde --- /dev/null +++ b/.agents/skills/sce-next-task/references/output.md @@ -0,0 +1,140 @@ +# Next-task output layouts + +Use only the applicable layout. Values come from internal workflow state. + +## Review blocked + +Present the selected task, then each issue's problem, impact, and required +decision. If plan resolution is ambiguous, list candidate paths and +`/next-task {candidate-path}`. State whether another task remains executable. + +## Plan already complete + +```markdown +------------------------------------- + +# Implementation tasks are complete. + +Run the final validation: + +`/validate {plan-path}` +``` + +## Declined + +```markdown +You have declined to proceed with this task +``` + +## Execution blocked or incomplete + +For `blocked`, present the blocker, work completed before it, and the required +decision or action. For `incomplete`, present completed work, verification +evidence, remaining work, and the reason it remains incomplete. + +## Context synchronization blocked + +State that task `{completed-task-id}` was implemented, verified, and recorded; +report the contradiction or synchronization failure, preserved edits, required +action, and retry condition. State that durable context is out of date and must +be synchronized before continuing. + +## More tasks remain + +```markdown +------------------------------------- + +# Task {completed-task-id} completed. + +{completed-tasks} of {total-tasks} tasks complete. + +Next up: + +{next-task-id} — {next-task-title} + +`/next-task {plan-path} {next-task-id}` +``` + +## All tasks complete + +```markdown +------------------------------------- + +# Task {completed-task-id} completed. + +All tasks are complete. + +Run the final validation: + +`/validate {plan-path}` +``` + +# Implementation gate + +Always show this gate at the start of the **Task execution phase**, before editing any +file. + +The gate is user-facing prose. It is never serialized into a YAML result. This +file is the only authority for the gate's content and order. + +## Format + +# `{task.id} - {task.title} - {plan.name}` + +## Goal + +{task.goal} + +## In scope + +- {task.in_scope} + +## Out of scope + +- {task.out_of_scope} + +## Done when + +- {task.done_checks} + +## Expected changes + +- List confirmed files or areas expected to change. +- Label uncertain entries as likely rather than confirmed. + +## Approach + +Describe the smallest coherent implementation approach in 2–5 steps. + +## Assumptions + +- Include material assumptions returned by plan review. +- Omit this section when there are no assumptions. + +## Risks or trade-offs + +- Include only risks relevant to approving this task. +- Omit this section when there are no meaningful risks. + +## Verification + +- {task.verification} + +When the `approve` flag is absent, end with exactly: + +`Continue with implementation now? (yes/no)` + +When the `approve` flag is supplied, omit the question and end after +**Verification**. + +## Rules + +- Show the gate exactly once for an unchanged task. +- Do not modify files before approval. +- Do not add requirements absent from the reviewed task. +- Do not present multiple competing approaches unless a material decision is + required. +- Do not emit YAML while waiting for the user's answer. Stop after the gate and + wait. +- If the handoff is stale or incomplete, show the known task information and + identify the problem under **Risks or trade-offs**. diff --git a/.agents/skills/sce-next-task/references/plan-review.md b/.agents/skills/sce-next-task/references/plan-review.md new file mode 100644 index 000000000..89e378f36 --- /dev/null +++ b/.agents/skills/sce-next-task/references/plan-review.md @@ -0,0 +1,151 @@ +# Plan review phase + +Run this phase for step 1 of the workflow. It resolves one plan, selects one +task, and decides whether that task can be implemented right now. It reads; +it never writes. + +Inputs: the parsed `plan-name-or-path`, and `task-id` when present. The +`auto-approve` token is not passed here and has no meaning in this phase. + +## 1.1 Resolve the plan + +Resolve the supplied plan name or path to exactly one existing plan. + +When no plan can be found, set internal status `blocked`. + +When multiple plans match and none can be selected safely, set internal status +`blocked` with the matching candidates. + +Read the selected plan before exploring the repository. + +## 1.2 Resolve one task + +Before selecting or starting a task, inspect every completed task's +`Context synchronization` field in the plan, in plan order, regardless of its +position relative to the task being selected or resumed. A missing field, or +any value other than `synced`, is unresolved synchronization debt. Never infer +`synced` from chat history. + +For the first task carrying debt: + +- When the task has no durable completed-task record (no `Files changed`, + `Result`, `Verify`, or `Context impact` recorded — a legacy plan predating + that structure, or an incomplete write), do not attempt a reconstructed retry. + Set internal status `blocked` with a required action to migrate the plan + (backfill the completion record, or resolve the debt manually) and a retry + condition of the plan carrying that structure. Stop. +- Otherwise, set internal status `sync_debt`, naming the debt task (its ID and + title) and its own completed record — read directly from the plan by plan + path and task ID — including, when its field is `blocked`, its persisted + `Context synchronization blocker`. Do not run or cite the Task context + synchronization phase. Stop. Do not select or start a new task. + +Only after every completed task is `synced` does task selection proceed. + +When a task ID is supplied, select that task only after the same synchronization- +debt check passes. + +Otherwise, select the first incomplete task in plan order whose declared +dependencies are complete. + +Set internal status `plan_complete` when no incomplete tasks remain. + +Set internal status `blocked` when incomplete tasks remain but none can currently +be executed. + +Review at most one task per invocation. + +## 1.3 Inspect relevant context + +Start with the task and the files it directly references. + +Inspect only what is needed to understand: + +- Existing behavior. +- Applicable repository conventions. +- Architectural boundaries. +- Relevant tests. +- Available verification commands. +- Decisions or specifications connected to the task. + +Load root context only when the task affects repository-wide behavior, +architecture, shared terminology, or cross-domain interfaces. + +Do not explore the entire repository by default. + +## 1.4 Determine readiness + +A task is `ready` when: + +- Its goal is clear. +- Its scope is sufficiently bounded. +- Its dependencies are complete. +- Its done checks are observable. +- A credible verification method exists. +- No unresolved decision would materially change the implementation. + +Use repository conventions for ordinary local choices. + +Do not block on: + +- Naming inferable from surrounding code. +- Established formatting or style. +- Reversible local implementation details. +- Details that do not change observable behavior or scope. + +Record these choices under `assumptions`. + +Set internal status `blocked` when a missing decision materially affects: + +- User-visible behavior. +- Public interfaces. +- Architecture or ownership boundaries. +- Data shape or persistence. +- Security or privacy. +- External dependencies. +- Destructive or difficult-to-reverse behavior. +- The evidence needed to prove completion. + +## 1.5 Return the result + +Set exactly one internal state: + +- `ready` +- `blocked` +- `plan_complete` +- `sync_debt` + +Record only the internal state. Do not add explanatory prose before or after it. + +A `ready` result must identify: + +- One resolved plan. +- Exactly one incomplete task. +- The task goal and scope boundaries. +- Done checks. +- Verification expectations. +- Relevant files and context. +- Review assumptions. + +A `sync_debt` result must identify: + +- The debt-carrying task's ID and title. +- Its own completed record, read directly from the plan by plan path and task ID. +- Its persisted `Context synchronization blocker`, when present. + +Step 2 consumes a `ready` result verbatim, so anything the execution phase +needs has to be present here. + +## Plan review boundaries + +Do not: + +- Modify application code. +- Modify tests. +- Update the plan. +- Mark a task complete. +- Request implementation confirmation. +- Run task execution. +- Synchronize context. +- Run final validation. +- Review more than one task. diff --git a/.agents/skills/sce-next-task/references/sync-report.md b/.agents/skills/sce-next-task/references/sync-report.md new file mode 100644 index 000000000..0211740c5 --- /dev/null +++ b/.agents/skills/sce-next-task/references/sync-report.md @@ -0,0 +1,137 @@ +# Context Sync Report + +Return only one completed Markdown report using the applicable variant below. +Do not include unused sections, placeholders, YAML, or a fenced code block. + +The `Status` value must be exactly one of: + +- `synced` +- `no_context_change` +- `blocked` + +The input execution status is always `complete` and does not need to be repeated +as a separate workflow state. + +## Synced variant + +# Context Sync Report + +**Status:** synced +**Plan:** `{plan path}` +**Task:** `{task id} — {task title}` + +## Updated files + +- {List each changed file from the execution handoff except paths under + `context/`; state `None.` when no files remain.} + +## Updated context + +- `{context file}` — {concise description of the durable truth updated} + +## Architecture decisions + +- `{written or reused ADR path}` — {decision and status} +- None qualified. + +## Feature existence + +- `{feature}` — `{context file that canonically describes it}` + +## Verification + +- {How the edited context was checked against implementation and execution evidence.} +- {File hygiene: line counts, relative links, diagrams where structure is complex.} +- {Documentation, link, or formatting checks that were run, when applicable.} + +## Notes + +{Include only non-blocking information worth retaining. +Omit this section when unnecessary.} + +--- + +## No-context-change variant + +# Context Sync Report + +**Status:** no_context_change +**Plan:** `{plan path}` +**Task:** `{task id} — {task title}` + +## Updated files + +- {List each changed file from the execution handoff except paths under + `context/`; state `None.` when no files remain.} + +## Synchronization result + +{Explain why the completed implementation did not introduce durable, +non-obvious repository knowledge requiring an update.} + +## Context reviewed + +- `{context file or area}` — {what was checked and why it remains accurate} + +## Architecture decisions + +- `{reused ADR path}` — {decision and status} +- None qualified. + +## Feature existence + +- `{feature}` — `{context file that canonically describes it}`, already present. + +## Verification + +- {How existing context was compared with implementation and execution evidence.} + +--- + +## Blocked variant + +# Context Sync Report + +**Status:** blocked +**Plan:** `{plan path}` +**Task:** `{task id} — {task title}` + +## Context synchronization blocker + +- Blocker: {specific synchronization blocker} +- Required action: {decision or correction required} +- Retry condition: {concrete condition under which context synchronization + should run again} + +## Context changes + +- {List safe context edits preserved, or state `No context files were changed.`} + +## Architecture decisions + +- `{ADR path written or reused before the blocker}` — {decision and status} +- None written or reused before the blocker. + +## Report rules + +- Name exact context files when they were changed or reviewed. +- Under **Architecture decisions**, list every ADR path written or reused during + the decision gate. In a successful report, state `None qualified.` when the + gate skipped invocation. In a blocked report, state + `None written or reused before the blocker.` when applicable. +- Under **Updated files** (synced and no-context-change reports), list every + changed file from the execution handoff except paths under `context/`. A + blocked report does not repeat that list — it is already on the plan's + completed task record. +- Report the missing context root as `blocked`, with `sce setup + --bootstrap-context` as the required action and the existence of `context/` as + the retry condition. +- In a blocked report, write the `Context synchronization blocker` + subsection using the same field names the plan's completion record + uses, so plan review can persist it verbatim. +- Omit **Feature existence** only when the task implemented no feature. +- Describe durable truth, not implementation-session chronology. +- Keep evidence concise and factual. +- Do not claim final validation passed. +- Do not determine whether the plan is complete. +- Do not recommend a next implementation task. diff --git a/.agents/skills/sce-next-task/references/task-execution.md b/.agents/skills/sce-next-task/references/task-execution.md new file mode 100644 index 000000000..fe01b5304 --- /dev/null +++ b/.agents/skills/sce-next-task/references/task-execution.md @@ -0,0 +1,246 @@ +# Task execution phase + +Run this phase for step 2 of the workflow. It is the only phase that writes +application code, and the only one that asks the user for anything. + +Input: the complete `ready` result from the plan review phase, plus the `approve` +flag when the user pre-approved this invocation. + +This phase exclusively owns: + +- Presenting the implementation summary. +- Requesting implementation confirmation. +- Implementing the task. +- Running task-level verification. +- Updating the task status and evidence. + +Do not present an additional implementation confirmation anywhere else. + +The `approve` flag means the user pre-approved this task when invoking the +workflow. It suppresses the approval question and the wait. It never suppresses +the gate. Only the workflow entrypoint may set it, and only from an explicit +user-supplied approval token. Never infer it. + +If required handoff information is absent, stale, or contradictory, still show the +gate using what is known, clearly identify the handoff problem, and do not edit +files. With the `approve` flag supplied, do not treat pre-approval as permission +to repair or reinterpret the handoff: after showing the gate, set internal status +`blocked` deterministically. Without the flag, wait for the user's response and +then set internal status `blocked`; do not retry the handoff in the same phase. + +A successful `complete` handoff must explicitly contain all of these fields: + +- The resolved `plan` object, including its path and completion counts. +- The selected `task` identity, including its ID and title. +- `changes.files_changed`, the implementation's baseline-relative changed-file list. +- `changes.summary`, a concise implementation summary. +- `verification`, with every reported outcome marked `passed` and its evidence. +- `done_checks`, pairing every done check with evidence. +- `plan_update`, proving the selected task was marked complete and evidence recorded. +- `context_impact`, including classification, affected areas, and reason. + +Do not omit, invent, or reconstruct any of these fields when handing off to context +synchronization. + +## 2.1 Validate the handoff without editing + +Confirm that: + +- The readiness status is `ready`. +- Exactly one task is present. +- The plan file exists. +- The selected task is still incomplete. +- The task has not materially changed since review. +- Declared dependencies remain complete. + +Do not reconstruct missing material requirements. + +## 2.2 Always show the implementation gate + +At the start of the phase, before any file modification, present the task using +`references/output.md`. + +The gate must be shown even when: + +- The task appears straightforward. +- The workflow believes approval was already implied. +- The handoff is stale or incomplete. +- The user is likely to approve. + +When the `approve` flag is absent, end the gate with exactly one approval +question: + +`Continue with implementation now? (yes/no)` + +Stop and wait for the user's answer. Do not return internal state, and make no +file modifications, until the user has answered. + +When the `approve` flag is supplied, show the gate as a summary, omit the +approval question, do not wait, and continue at step 2.4. + +## 2.3 Handle the user's decision + +Skip this step when the `approve` flag was supplied. + +When the user rejects or cancels, do not modify files and set internal status +`declined`. + +When the user does not clearly approve, do not modify files. Ask the same +approval question once more only when the response is genuinely ambiguous. +Otherwise set internal status `blocked`. + +When the user approves, continue with implementation. + +Treat constraints supplied with approval as part of the approved task boundary. +If those constraints materially contradict the reviewed task, set internal status +`blocked` before editing. + +## 2.4 Prepare the implementation + +Before editing, capture a Git baseline. Record the current `HEAD` commit, the +staged and unstaged patch/content state, and every untracked path/content state +using equivalent `git status`, `git diff`, and `git diff --cached` views. If the +baseline cannot be captured reliably, stop before editing and set internal status +`blocked`. + +After implementation, capture the same views again. Compute +`changes.files_changed` by comparing the post-edit snapshot with the pre-edit +baseline, not by listing the whole working tree or by diffing only against +`HEAD`. Include each path whose state or content changed during this task once; +exclude paths unchanged from the baseline, including unrelated pre-existing +staged, unstaged, and untracked changes. A path already dirty at baseline is +included only when this task changed its state or content. + +Then: + +- Read the relevant files supplied by plan review. +- Inspect nearby code and tests when needed. +- Identify the smallest coherent change satisfying the task. +- Follow surrounding naming, structure, error handling, and test style. +- Preserve unrelated behavior. + +Do not create a second plan. + +Do not broaden the reviewed task. + +## 2.5 Implement one task + +Make the minimum coherent changes required to satisfy the task goal and done +checks. + +Use judgment for ordinary, reversible local implementation choices. + +Stop when implementation requires: + +- Material scope expansion. +- A new external dependency not authorized by the task. +- A public-interface decision not established by the plan. +- A destructive or difficult-to-reverse operation. +- An unresolved security, privacy, or data decision. +- Contradicting the reviewed task or repository architecture. + +When stopped, preserve completed in-scope work unless retaining it would leave +the repository unsafe or invalid. + +## 2.6 Verify the task + +Run the narrowest authoritative checks that demonstrate the done checks. + +Start with verification supplied by the readiness result. Add nearby or directly +relevant checks only when needed. + +Verification may include: + +- Targeted tests. +- Type checking for affected code. +- Linting affected files. +- Formatting checks. +- A focused build or compile step. +- Direct behavioral inspection when no automated check exists. + +Do not run final plan validation unless the task itself explicitly requires it. + +When a check fails: + +- Determine whether the task caused the failure. +- Fix it when the correction remains in scope. +- Rerun the relevant check. +- Set internal status `incomplete` when a done check remains unsatisfied, or + `blocked` when completing it requires an unapproved decision or scope + expansion. + +Never report a check as passed unless it ran successfully. + +## 2.7 Update the plan + +Only after successful implementation and task-level verification: + +- Mark only the selected task complete. +- Record directly on the completed task: `Completed` (the date), the + baseline-relative `Files changed` list, a concise factual `Result`, the + actual outcome of every planned `Verify` check, and `Context impact`. +- Set that task's `Context synchronization` field to `pending` in the plan file + before returning `complete`; this write must happen after the execution + facts above and before the synchronization phase is invoked. +- Record material deviations or approved assumptions. +- Preserve the plan's existing structure and terminology. + +Do not mark the task complete when returning `declined`, `blocked`, or +`incomplete`. + +## 2.8 Determine the terminal status + +Set internal status `complete` when the task was implemented, verified, and +marked complete in the plan with evidence. + +Set internal status `incomplete` when in-scope work was completed but one or more +done checks remain unsatisfied. + +Set internal status `declined` when the user rejected implementation. + +Set internal status `blocked` for every other non-successful outcome, including: + +- Missing approval. +- Stale or invalid handoff. +- Material blocker. +- A verification failure that cannot be resolved in scope. + +Do not determine whether the plan is complete. The `/next-task` workflow owns +that decision after context synchronization. + +Before determining terminal status for a `complete` result, verify that the +handoff contains the resolved plan, task identity, baseline-relative changed +files, implementation summary, verification evidence, done-check evidence, plan +update, and context-impact classification listed above. The mandatory five-root- +file context pass remains required for every completed task, regardless of the +reported context-impact classification, because it is cheap, deterministic, and +load-bearing for context accuracy; `context_impact` must not be used to waive it. + +## 2.9 Return internal state + +After the phase reaches a terminal state, set exactly one internal state. + +Record only the internal state. Do not add explanatory prose before or after it. + +A `complete` result is the authoritative handoff into step 3, which reads the +plan, completed task, changed files, implementation summary, verification +evidence, done-check evidence, and context-impact classification out of it. Step +3 is forbidden from reconstructing any of that, so it has to be present here. + +## Task execution boundaries + +Do not: + +- Edit before approval, whether explicit or pre-supplied. +- Execute more than one task. +- Select or execute the next task. +- Skip the implementation gate. +- Ask for multiple approval gates for the same unchanged task. +- Expand scope without authorization. +- Synchronize durable context. +- Run final plan validation. +- Determine whether the plan is complete. +- Create a Git commit. +- Push changes. +- Modify unrelated files. +- Claim verification that was not performed. diff --git a/.agents/skills/sce-validate/SKILL.md b/.agents/skills/sce-validate/SKILL.md new file mode 100644 index 000000000..df04f10df --- /dev/null +++ b/.agents/skills/sce-validate/SKILL.md @@ -0,0 +1,125 @@ +--- +name: sce-validate +description: > + Validate one completed SCE plan and record final validation evidence +--- + +# SCE Validate + +## Purpose + +Own this workflow from input parsing through its terminal user-visible response. +Execute the phases below directly and in order. Phase statuses are internal state, +not inter-SCE workflow handoffs. Do not invoke another SCE skill, sibling SCE +package, or SCE workflow command. Follow the canonical workflow's steps, gates, +and stops exactly as written: never invent, skip, reorder, or merge a step. + +## Phase references + +Each numbered step below dispatches to a phase whose steps and boundaries live in +a reference file. This document holds the control flow — which phase runs, what it +receives, and how its result branches — and each reference holds the phase itself. + +| Step | Read before running the phase | +|---|---| +| 1 | `references/validation.md` | + +`references/validation-report.md` defines the `## Validation Report` section +written into the plan file. Step 1 points to it at the moment it is needed, on a +`validated` or `failed` outcome only. + +Read the reference before taking any action for step 1, not after. + +## User-visible output + +Use `references/output.md` for every gate and terminal response. Render no raw +internal state. The reference contains only human-visible Markdown layouts. +User-visible output is limited to those layouts: never invent a layout, and never +wrap one in an added preamble, commentary, summary, or extra section. + +## Composite control flow + +Keep phase results as internal state and continue immediately whenever the +canonical workflow says to continue. Stop only at a user wait or terminal branch. +Any workflow-defined user wait resumes this same skill in the same session. +Never expose an internal phase result as the workflow's final response. + +Relevant non-SCE skills may be used as helper capabilities during the active step. +They are not workflow handoffs: when a helper returns, control returns to the active +step. Helper use must preserve the canonical phase order, gates, waits, writes, +validation, stops, and terminal user-visible output. + +## Input + +`invocation input` 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 `invocation input` 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. + +Pass the plan name or path to the **Validation phase** unmodified. Do not restate, +summarize, or pre-scope it. + +Every `{plan-path}` and `{candidate-path}` emitted anywhere in this workflow is +the path carried by the **Validation phase** in its Markdown result (`Plan:`, or a +candidate path), so every emitted command is directly runnable. + +For example: `$sce-validate my-plan`. + +## Workflow + +### 1. Validate the plan + +Read `references/validation.md`, then run the **Validation phase** with the plan +name or path. + +This phase measures finished work and never repairs it: it does not modify tests, +application code, or configuration to make a failing check pass. That property is +load-bearing, so reach it through the reference rather than acting from this +summary. + +Do not write the Validation Report yourself. + +Branch on the report's `Status:`. + +`blocked` -> Print the blocked Markdown report as returned. Do not rephrase it +into a different layout. Stop. + +`failed` -> Print the failed Markdown report as returned. It is already a session +handoff: self-contained, actionable, and ending with `/validate {plan-path}` after +repairs. + +Do not rewrite it into a shorter summary. Do not drop the retry command. Do not +add an alternate continuation that replaces `/validate`. Stop. + +`validated` -> Print the complete validated Markdown result as returned. +Continue to the next step. + +### 2. Report completion + +Return exactly one completion block. Do not start another workflow. + +Render the **Completion** layout from `references/output.md`. + +Stop. + +## Rules + +- Validate at most one plan per invocation. +- Read each phase's reference before running that phase. +- Do not duplicate the internal instructions of embedded phases. +- Do not run final validation when implementation tasks remain; the **Validation phase** + returns `blocked`, and this workflow stops. +- On `failed`, print the handoff Markdown as returned and stop. Preserve the + retry `/validate {plan-path}` instruction. +- Do not implement remaining plan tasks from this workflow unless the user + explicitly continues in-session after a failed handoff. +- Do not create a Git commit or push changes. +- Do not mark the plan archived or delete the plan. +- Do not execute a follow-up `/next-task`, `/change-to-plan`, or `/validate` + yourself. +- Do not infer success when an embedded phase returns a non-success status. diff --git a/.agents/skills/sce-validate/agents/openai.yaml b/.agents/skills/sce-validate/agents/openai.yaml new file mode 100644 index 000000000..bb775ed20 --- /dev/null +++ b/.agents/skills/sce-validate/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "SCE Validate" + short_description: "Validate one completed SCE plan and record final validation evidence" + default_prompt: "Validate this completed SCE plan and record final validation evidence." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/sce-validate/references/output.md b/.agents/skills/sce-validate/references/output.md new file mode 100644 index 000000000..8b811d796 --- /dev/null +++ b/.agents/skills/sce-validate/references/output.md @@ -0,0 +1,16 @@ +# Validate output layouts + +Use only the applicable layout. Values come from internal workflow state. + +## Completion + +```markdown +------------------------------------- + +# Plan {plan-name} validated. + +All implementation tasks were already complete. +Final validation passed. + +Validation report: {plan-path} +``` diff --git a/.agents/skills/sce-validate/references/validation-report.md b/.agents/skills/sce-validate/references/validation-report.md new file mode 100644 index 000000000..3121e1a48 --- /dev/null +++ b/.agents/skills/sce-validate/references/validation-report.md @@ -0,0 +1,76 @@ +# Plan-file Validation Report + +The Markdown section `sce-validation` appends to the plan file when returning +`validated` or `failed`. Write it at the end of `context/plans/{plan_name}.md` +under exactly one `## Validation Report` heading. + +This is plan-file content. The result returned to the workflow is defined +separately in `references/validation.md`. + +Do not author this section while planning. Only `/validate` through `sce-validation` +writes it. + +## Layout + +```markdown +## Validation Report + +**Status:** {validated | failed} +**Date:** {YYYY-MM-DD} + +### Commands run + +- `{command}` -> exit {code} ({concise outcome summary}) +- `{command}` -> exit {code} ({concise outcome summary}) + +### Success-criteria verification + +- [x] AC1: {criterion statement} -> {evidence} +- [ ] AC2: {criterion statement} -> {evidence of failure or not checked} + +### Failed checks and follow-ups + +- {check}: {problem}; evidence: {command output or inspection}; required: {decision or next action} +- None. + +### Residual risks + +- {risk} +- None identified. + +### Retry + +{Only when Status is failed:} + +After repairs, rerun: + +`/validate {plan path}` +``` + +## Rules + +- Use **Status:** `validated` only when every acceptance criterion is met and + every required full-validation command passed. +- Use **Status:** `failed` when evidence was captured but required checks or + criteria remain unsatisfied. +- List every command that ran under **Commands run**, including ones that + failed. Do not invent exit codes or outcomes. +- Prefer the plan's `Full validation` commands and each criterion's `Validate:` + line over rediscovering project defaults. Fall back to repository conventions + only when the plan omits them. +- Mark each acceptance criterion checkbox in the plan's `## Acceptance criteria` + section to match the evidence. Do not mark a criterion met unless the check + ran successfully or the inspection named by `Validate:` confirms it. +- Under **Failed checks and follow-ups**, record every failing check and its + evidence, including leftover debug-only flags, temporary artifacts, or local + scaffolding. Do not describe code or test edits made during validation; + validation does not modify tests or product code to clear failures. Write + `None.` when status is `validated`. +- When status is `failed`, always include **Retry** with the exact + `/validate {plan path}` command. Omit **Retry** when status is `validated`. +- Keep evidence concise and factual. Do not narrate the whole implementation + history. +- Do not claim durable context synchronization as part of validation. +- Do not rewrite task evidence or reopen completed tasks. +- When a previous `## Validation Report` already exists, replace it with the new + one rather than stacking duplicates. diff --git a/.agents/skills/sce-validate/references/validation.md b/.agents/skills/sce-validate/references/validation.md new file mode 100644 index 000000000..1816eacaa --- /dev/null +++ b/.agents/skills/sce-validate/references/validation.md @@ -0,0 +1,315 @@ +# SCE Validation + +## Purpose + +Prove that one finished SCE plan meets its acceptance criteria and repository +validation bar, then record the evidence on the plan and return one Markdown +result. + +This phase owns: + +- Resolving one plan. +- Confirming every implementation task is complete. +- Running the plan's full validation commands and each acceptance criterion + check. +- Writing the Validation Report into the plan. +- Marking acceptance criteria against the evidence. +- Returning one Markdown validation result. + +Return a result matching: + +the **Validation Result** section below in this file + +Write plan-file evidence matching: + +`references/validation-report.md` + +## Input + +A plan name or path. + +## Workflow + +### 1. Resolve the plan + +Resolve the supplied plan name or path to exactly one existing plan under +`context/plans/`. + +When no plan can be found, return `blocked`. + +When multiple plans match and none can be selected safely, return `blocked` +with the matching candidates. + +Read the selected plan before exploring the repository. + +### 2. Confirm implementation is finished + +Return `blocked` with incomplete tasks listed when any implementation task +remains incomplete. + +Final validation measures finished work. Do not run the full suite against a +partial stack, and do not complete remaining tasks here. + +### 3. Read the validation contract from the plan + +From the plan, collect: + +- Every acceptance criterion and its `Validate:` check. +- The `Full validation` command list. + +Return `blocked` when the plan has no usable acceptance criteria, or when no +validation commands can be determined from the plan or repository conventions. + +Prefer the plan's authored checks. Fall back to repository-primary test, lint, +and format commands only when `Full validation` is absent, and record that +fallback under notes on a `validated` or `failed` result. + +### 4. Run full validation and acceptance checks + +Run the plan's `Full validation` commands. + +Then verify each acceptance criterion using its `Validate:` line. Prefer a +runnable command. Use a named inspection only when the criterion authorizes it, +and say exactly what was inspected. + +Treat leftover debug-only flags, temporary files, intermediate artifacts, or +local scaffolding as a failed validation check. Record the path and evidence +under **Failed checks and follow-ups**; never delete or repair it during +validation. + +When a check fails, record the failure and continue gathering evidence. Do not +modify tests, application code, or configuration to make a check pass. Final +validation measures the finished work; repair belongs to a later work session, +not this skill. + +Never report a check as passed unless it ran successfully or the authorized +inspection confirmed the criterion. + +Do not run task-by-task implementation work for incomplete tasks. That belongs +to `/next-task`. + +### 5. Update the plan + +For `validated` and `failed` outcomes: + +- Mark each acceptance criterion checkbox to match the evidence. +- Append or replace the plan's `## Validation Report` section using + `references/validation-report.md`. +- When status is `failed`, the plan-file report must include the retry command + `/validate {plan path}`. + +Do not reopen completed tasks, rewrite task evidence, or change the task stack. + +For `blocked`, leave the plan file unchanged. + +### 6. Return the Markdown result + +Return exactly one Markdown result: + +- `validated` when every acceptance criterion is met, required full validation + passed, and the Validation Report was written. +- `failed` when evidence was captured but required checks or criteria remain + unsatisfied. Shape it as a session handoff per + the **Validation Result** section below in this file, ending recommended work with + `/validate {plan path}`. +- `blocked` when validation cannot proceed safely. + +Return only the Markdown report. Do not add explanatory prose before or after +it. Do not return YAML. + +## Validation boundaries + +Do not: + +- Validate more than one plan. +- Complete remaining implementation tasks. +- Modify tests, application code, or configuration to make a failing check pass. +- Apply lint or format auto-fixes that change product or test files as part of + making validation green. +- Synchronize durable context under `context/`. +- Create the context root. +- Mark the plan archived or delete the plan. +- Create a Git commit or push changes. +- Invent acceptance criteria the plan does not state. +- Claim verification that was not performed. +- Return a YAML result. + +## Completion + +The phase is complete after: + +- One plan was resolved, or resolution failed and was reported. +- Implementation completeness was checked. +- Validation ran to a terminal state, or a blocker prevented it. +- One valid Markdown result matching the **Validation Result** section below in this file was + returned. + + + +# Validation Result + +Return only one completed Markdown report using the applicable variant below. +Do not include unused sections, placeholders, YAML, or a fenced code block. + +The `Status` value must be exactly one of: + +- `validated` +- `failed` +- `blocked` + +The plan-file `## Validation Report` section is written separately using +`references/validation-report.md`. This file is the skill's return value to the +invoking workflow. + +## Validated variant + +# Validation Report + +**Status:** validated +**Plan:** `{plan path}` +**Name:** `{plan name}` +**Tasks:** `{completed}/{total} complete` +**Date:** `{YYYY-MM-DD}` + +## Commands run + +- `{command}` -> {passed} — {concise outcome summary} + +## Acceptance criteria + +- [x] AC1: {criterion statement} — {evidence} +- [x] AC2: {criterion statement} — {evidence} + +## Residual risks + +- {risk} +- None identified. + +## Notes + +{Include only non-blocking information worth retaining. +Omit this section when unnecessary.} + +--- + +## Failed variant + +This variant is a session handoff. Another agent or a later session must be +able to act from it alone. Write it as a prompt the user can paste forward, not +as a summary of the validation run. + +# Validation failed — handoff + +**Status:** failed +**Plan:** `{plan path}` +**Name:** `{plan name}` +**Tasks:** `{completed}/{total} complete` +**Date:** `{YYYY-MM-DD}` +**Validation report:** written to `{plan path}` + +## Goal for the next session + +Repair the unfinished validation so every acceptance criterion and full +validation command passes. Do not modify tests or product code inside a +`/validate` run to force green results; fix the implementation (or the plan) in +a normal work session, then rerun validation. + +## What failed + +- `{check or AC id}`: {problem} + - Evidence: {command output, exit summary, or inspection finding} + - Required action: {concrete repair or decision} + +## Acceptance criteria + +- [x] AC1: {criterion} — {evidence} +- [ ] AC2: {criterion} — {why unmet} + +## Commands run + +- `{command}` -> {passed | failed | not_run} — {concise outcome summary} + +## Constraints + +- All implementation tasks were already complete when validation ran. +- Validation did not modify tests, application code, or configuration to clear + failures. +- Validation does not synchronize durable context. +- Prefer the plan at `{plan path}` and its Validation Report as the source of + recorded evidence. + +## Residual risks + +- {risk} +- None identified. + +## Recommended work + +1. {First concrete fix, with files or areas when known} +2. {Second concrete fix, or decision the user must make} +3. Rerun final validation after the fixes land: + +`/validate {plan path}` + +Do not stop after the repair. The plan is not finished until `/validate` +returns `validated`. + +--- + +## Blocked variant + +# Validation blocked + +**Status:** blocked +**Plan:** `{plan path when resolved}` +**Name:** `{plan name when resolved}` + +## Issues + +- **{issue id}** ({category}): {problem} + - Impact: {impact} + - Required: {decision or action} + +## Incomplete tasks + +- `{task id}` — {title} +- Omit this section when no incomplete tasks apply. + +## Candidates + +- `{candidate plan path}` +- Omit this section when plan resolution was not ambiguous. + +## Next step + +{Exactly one continuation, matching the blocker:} + +- Incomplete tasks: + +`/next-task {plan path}` + +- Ambiguous plan: + +`/validate {candidate path}` + +- Missing plan content or other blocker: state the decision required. Do not + invent a command. + +--- + +## Report rules + +- Name the exact `Plan:` path so every emitted command is runnable. +- Use **Status:** exactly `validated`, `failed`, or `blocked`. +- Never claim a check passed unless it ran successfully or the authorized + inspection confirmed it. +- Do not modify tests or product code to clear a failure; record it under + **What failed**. +- The failed variant must always end its **Recommended work** with + `/validate {plan path}` as the final step after repairs. +- The failed variant must be self-contained enough to hand to another session + without the original chat. +- Do not include durable context synchronization results in this report. +- Do not select or describe an unrelated next implementation task when status is + `validated`. +- Omit empty optional sections rather than writing placeholders. diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..b318bd095 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,46 @@ +{ + "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" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.codex/hooks/run-sce-or-show-install-guidance.sh b/.codex/hooks/run-sce-or-show-install-guidance.sh new file mode 100644 index 000000000..a01dc8979 --- /dev/null +++ b/.codex/hooks/run-sce-or-show-install-guidance.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v sce >/dev/null 2>&1; then + echo "sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli" >&2 + exit 0 +fi + +exec "$@" \ No newline at end of file diff --git a/.sce/config.json b/.sce/config.json index d4cdb1711..0cea5dab1 100644 --- a/.sce/config.json +++ b/.sce/config.json @@ -1,16 +1,17 @@ { "$schema": "https://sce.crocoder.dev/config.json", + "agent_trace": { + "auto_sync": true + }, "integrations": { "optional_workflows": [], "target": [ "claude", "opencode", - "pi" + "pi", + "codex" ] }, - "agent_trace": { - "auto_sync": true - }, "log_dir": "context/tmp", "log_level": "error", "policies": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 698d1426c..293d39787 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 d80a3dd5a..990f11794 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/build.rs b/cli/build.rs index 782487110..08228085c 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 67c0d0502..a7dc33bc1 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)] @@ -317,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/command_surface.rs b/cli/src/command_surface.rs index ff4434d31..85d0a3601 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/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 258afec39..1c5ae8b7f 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 1fc90b9cd..9cd63d2ef 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/codex_hook_config.rs b/cli/src/services/codex_hook_config.rs new file mode 100644 index 000000000..9e5df7bf8 --- /dev/null +++ b/cli/src/services/codex_hook_config.rs @@ -0,0 +1,1450 @@ +//! 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")), +]; + +/// 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 +/// 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()) +} + +#[derive(Clone)] +struct Registration { + event: &'static str, + matcher: Option<&'static str>, + group: Value, + 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 { + #[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) +} + +/// 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 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_index, group) in groups.iter().enumerate() { + let Some(group_object) = group.as_object() else { + continue; + }; + 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; + + 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)); + } + + 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()); + } + None => { + 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); + 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] + 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\"}}"#); + } + + 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_policy.rs b/cli/src/services/codex_hook_policy.rs new file mode 100644 index 000000000..b595bc3d5 --- /dev/null +++ b/cli/src/services/codex_hook_policy.rs @@ -0,0 +1,614 @@ +//! Effective Codex hook-discovery *policy* readiness for SCE's project-owned +//! `.codex/hooks.json` registrations, obtained by asking the installed Codex +//! binary for its own composed effective configuration requirements rather +//! than reproducing Codex's multi-layer requirements composition in SCE. +//! +//! This is deliberately a separate dimension from `codex_hook_trust`: policy +//! answers "will Codex consider this hook *source* eligible at all?", while +//! trust answers "given an eligible source, has this handler been enabled and +//! durably trusted?". A registration is only executable when both are +//! satisfied. +//! +//! Upstream reference (`openai/codex` commit +//! `a8468330bb5f45e9f4d2ec630b01ea8c52908be3`): +//! - `hooks/src/engine/discovery.rs` `HookDiscoveryPolicy::allows`: +//! `!allow_managed_hooks_only || source.is_managed`, applied per config +//! layer before that layer's `hooks.json`/TOML hooks are even loaded. +//! Project `.codex/hooks.json` hooks are `HookSource::Project`, +//! `is_managed = false` (`hook_metadata_for_config_layer_source`), so they +//! are entirely excluded from discovery when the policy is active, +//! regardless of structural or trust state. +//! - `config/src/config_requirements.rs` `ConfigRequirements::allow_managed_hooks_only` +//! (`Option`) is populated only from `requirements.toml`/managed +//! layers; `docs/config.md` documents that putting it in `config.toml` does +//! not enable the policy, confirmed by +//! `hooks/src/engine/mod_tests.rs::allow_managed_hooks_only_in_config_toml_does_not_enable_policy`. +//! - Requirements are composed from multiple sources +//! (`config/src/config_requirements.rs` `RequirementSource`: system +//! `requirements.toml`, legacy managed `config.toml`/MDM, MDM managed +//! preferences, backend-delivered enterprise-managed layers, and composites +//! of these), so SCE cannot safely re-derive the effective value by reading +//! any single file; it must ask the installed Codex binary for its own +//! composed answer. +//! - `codex app-server` exposes that composed answer read-only via the +//! `configRequirements/read` method +//! (`app-server-protocol/src/protocol/common.rs` +//! `ConfigRequirementsRead => "configRequirements/read"`, no params), +//! returning `v2::ConfigRequirementsReadResponse { requirements: Option }` +//! (`app-server-protocol/src/protocol/v2/config.rs`), where +//! `ConfigRequirements::allow_managed_hooks_only: Option` serializes +//! as camelCase `allowManagedHooksOnly`. `requirements` itself is `null` +//! when no requirements are configured at all. +//! - Transport: `codex app-server --stdio` speaks newline-delimited JSON-RPC +//! 2.0 with the `"jsonrpc"` field omitted (`app-server/README.md` +//! "Protocol"). A connection must send `initialize` +//! (`app-server-protocol/src/protocol/v1.rs` `InitializeParams`/`ClientInfo`) +//! and then an `initialized` notification before any other request is +//! accepted ("Lifecycle Overview"/"Initialization"). + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +/// Effective Codex hook-discovery policy readiness for SCE's project-owned +/// `.codex/hooks.json` registrations. Independent of `codex_hook_trust`'s +/// per-handler enabled/trust bookkeeping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CodexHookPolicyReadiness { + /// Effective `allow_managed_hooks_only` is absent, `false`, or no + /// requirements are configured at all: Codex's discovery policy does not + /// exclude project `.codex/hooks.json` handlers. + ProjectHooksAllowed, + /// Effective `allow_managed_hooks_only = true`: Codex's discovery policy + /// discards every non-managed hook source, including SCE's project + /// `.codex/hooks.json` registrations, regardless of their structural or + /// trust state. + PolicyBlocked, + /// The effective policy could not be determined (no Codex executable, + /// spawn/initialization failure, malformed response, timeout, etc.); + /// carries a human-readable reason. Doctor must never treat this the same + /// as `ProjectHooksAllowed`. + Unknown(String), +} + +/// Default command used to probe Codex's effective policy in production: +/// `codex app-server --stdio`, invoked directly (no shell). +const DEFAULT_CODEX_COMMAND: &str = "codex"; + +/// Upper bound on the whole probe's wall-clock lifetime (spawn, initialize, +/// `configRequirements/read`, and teardown). `sce doctor` must never hang +/// because Codex is broken, slow, or unavailable. +pub(crate) const DEFAULT_POLICY_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Production entry point: probe the installed `codex` binary on `PATH`. +pub(crate) fn probe_default() -> CodexHookPolicyReadiness { + probe_effective_policy(DEFAULT_CODEX_COMMAND.as_ref(), DEFAULT_POLICY_PROBE_TIMEOUT) +} + +/// Probe one Codex executable's effective hook-discovery policy over +/// `codex app-server --stdio`, bounded by `timeout`. Never panics on +/// malformed output; always terminates and reaps the child process before +/// returning, on every exit path (success, protocol error, or timeout). +pub(crate) fn probe_effective_policy( + codex_command: &std::ffi::OsStr, + timeout: Duration, +) -> CodexHookPolicyReadiness { + let deadline = Instant::now() + timeout; + + let mut command = Command::new(codex_command); + command + .arg("app-server") + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return CodexHookPolicyReadiness::Unknown(format!( + "Codex executable '{}' was not found while probing the effective hook-discovery \ + policy: {error}", + codex_command.to_string_lossy() + )); + } + Err(error) => { + return CodexHookPolicyReadiness::Unknown(format!( + "Unable to start 'codex app-server --stdio' to probe the effective hook-discovery \ + policy: {error}" + )); + } + }; + + let Some(stdin) = child.stdin.take() else { + return terminate_and_reap_with_unknown( + child, + "Unable to open stdin for the Codex app-server probe process".to_string(), + ); + }; + let Some(stdout) = child.stdout.take() else { + return terminate_and_reap_with_unknown( + child, + "Unable to open stdout for the Codex app-server probe process".to_string(), + ); + }; + let mut guard = ChildGuard(child); + let mut stdin = stdin; + + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(line) => { + if tx.send(line).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let readiness = run_policy_probe_session(&mut stdin, &rx, deadline); + guard.terminate_and_reap(); + readiness +} + +/// Drives the initialize/initialized/`configRequirements/read` exchange over +/// an already-spawned child's stdin/stdout channel. Split out from +/// `probe_effective_policy` so the child-process teardown in the caller is +/// unconditional (this function only ever returns a readiness value, never +/// panics or leaks the process). +fn run_policy_probe_session( + stdin: &mut ChildStdin, + rx: &mpsc::Receiver, + deadline: Instant, +) -> CodexHookPolicyReadiness { + let initialize_request = json!({ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "sce-doctor", + "version": env!("CARGO_PKG_VERSION"), + } + } + }); + if let Err(error) = write_jsonl(stdin, &initialize_request) { + return CodexHookPolicyReadiness::Unknown(format!( + "Unable to send 'initialize' to the Codex app-server probe: {error}" + )); + } + + match read_response_for_id(rx, 1, deadline) { + Ok(ResponseOutcome::Result(_)) => {} + Ok(ResponseOutcome::Error(message)) => { + return CodexHookPolicyReadiness::Unknown(format!( + "Codex app-server rejected 'initialize' while probing the effective \ + hook-discovery policy: {message}" + )); + } + Err(reason) => return CodexHookPolicyReadiness::Unknown(reason), + } + + let initialized_notification = json!({ "method": "initialized" }); + if let Err(error) = write_jsonl(stdin, &initialized_notification) { + return CodexHookPolicyReadiness::Unknown(format!( + "Unable to send 'initialized' to the Codex app-server probe: {error}" + )); + } + + let config_requirements_request = json!({ + "method": "configRequirements/read", + "id": 2, + }); + if let Err(error) = write_jsonl(stdin, &config_requirements_request) { + return CodexHookPolicyReadiness::Unknown(format!( + "Unable to send 'configRequirements/read' to the Codex app-server probe: {error}" + )); + } + + match read_response_for_id(rx, 2, deadline) { + Ok(ResponseOutcome::Result(result)) => parse_config_requirements_result(&result), + Ok(ResponseOutcome::Error(message)) => CodexHookPolicyReadiness::Unknown(format!( + "Codex app-server rejected 'configRequirements/read': {message}" + )), + Err(reason) => CodexHookPolicyReadiness::Unknown(reason), + } +} + +fn terminate_and_reap_with_unknown(child: Child, reason: String) -> CodexHookPolicyReadiness { + let mut guard = ChildGuard(child); + guard.terminate_and_reap(); + CodexHookPolicyReadiness::Unknown(reason) +} + +/// Ensures the probed Codex process is always terminated and reaped, +/// regardless of which path through `probe_effective_policy` returns. +struct ChildGuard(Child); + +impl ChildGuard { + fn terminate_and_reap(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + self.terminate_and_reap(); + } +} + +fn write_jsonl(stdin: &mut ChildStdin, value: &Value) -> std::io::Result<()> { + let mut line = serde_json::to_vec(value)?; + line.push(b'\n'); + stdin.write_all(&line)?; + stdin.flush() +} + +enum ResponseOutcome { + Result(Value), + Error(String), +} + +/// Reads JSONL lines from `rx` until one whose `id` matches `expected_id` is +/// found, skipping unrelated notifications/responses (bounded, so a chatty or +/// malicious process cannot spin this loop forever), or until `deadline` +/// elapses. +fn read_response_for_id( + rx: &mpsc::Receiver, + expected_id: i64, + deadline: Instant, +) -> Result { + const MAX_UNRELATED_LINES: usize = 200; + let mut unrelated = 0usize; + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("Timed out waiting for a response from 'codex app-server'".to_string()); + } + let line = match rx.recv_timeout(remaining) { + Ok(line) => line, + Err(RecvTimeoutError::Timeout) => { + return Err("Timed out waiting for a response from 'codex app-server'".to_string()); + } + Err(RecvTimeoutError::Disconnected) => { + return Err("'codex app-server' exited before responding".to_string()); + } + }; + + let value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(error) => { + return Err(format!( + "Received malformed JSON from 'codex app-server': {error}" + )); + } + }; + + let matches_expected_id = value + .get("id") + .and_then(Value::as_i64) + .is_some_and(|id| id == expected_id); + if !matches_expected_id { + unrelated += 1; + if unrelated > MAX_UNRELATED_LINES { + return Err( + "Too many unrelated messages from 'codex app-server' while waiting for a \ + response" + .to_string(), + ); + } + continue; + } + + if let Some(error) = value.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_string(); + return Ok(ResponseOutcome::Error(message)); + } + let Some(result) = value.get("result") else { + return Err("'codex app-server' response had neither 'result' nor 'error'".to_string()); + }; + return Ok(ResponseOutcome::Result(result.clone())); + } +} + +/// Parses a `configRequirements/read` response's `result` value into policy +/// readiness. Pure and independent of process I/O so it can be tested with +/// captured fixtures. +fn parse_config_requirements_result(result: &Value) -> CodexHookPolicyReadiness { + let Some(requirements) = result.get("requirements") else { + return CodexHookPolicyReadiness::Unknown( + "'configRequirements/read' response was missing the 'requirements' field".to_string(), + ); + }; + if requirements.is_null() { + return CodexHookPolicyReadiness::ProjectHooksAllowed; + } + let Some(requirements) = requirements.as_object() else { + return CodexHookPolicyReadiness::Unknown( + "'configRequirements/read' response 'requirements' was not an object or null" + .to_string(), + ); + }; + match requirements.get("allowManagedHooksOnly") { + None | Some(Value::Null | Value::Bool(false)) => { + CodexHookPolicyReadiness::ProjectHooksAllowed + } + Some(Value::Bool(true)) => CodexHookPolicyReadiness::PolicyBlocked, + Some(other) => CodexHookPolicyReadiness::Unknown(format!( + "'configRequirements/read' response 'allowManagedHooksOnly' was not a boolean: {other}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- parse_config_requirements_result: pure-parser fixtures -------------- + + #[test] + fn null_requirements_allows_project_hooks() { + let result = json!({ "requirements": null }); + assert_eq!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::ProjectHooksAllowed + ); + } + + #[test] + fn missing_allow_managed_hooks_only_allows_project_hooks() { + let result = json!({ "requirements": { "allowAppshots": true } }); + assert_eq!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::ProjectHooksAllowed + ); + } + + #[test] + fn null_allow_managed_hooks_only_allows_project_hooks() { + let result = json!({ "requirements": { "allowManagedHooksOnly": null } }); + assert_eq!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::ProjectHooksAllowed + ); + } + + #[test] + fn false_allow_managed_hooks_only_allows_project_hooks() { + let result = json!({ "requirements": { "allowManagedHooksOnly": false } }); + assert_eq!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::ProjectHooksAllowed + ); + } + + #[test] + fn true_allow_managed_hooks_only_blocks_project_hooks() { + let result = json!({ "requirements": { "allowManagedHooksOnly": true } }); + assert_eq!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::PolicyBlocked + ); + } + + #[test] + fn non_boolean_allow_managed_hooks_only_is_unknown() { + let result = json!({ "requirements": { "allowManagedHooksOnly": "true" } }); + assert!(matches!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::Unknown(_) + )); + } + + #[test] + fn non_object_requirements_is_unknown() { + let result = json!({ "requirements": "not-an-object" }); + assert!(matches!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::Unknown(_) + )); + } + + #[test] + fn missing_requirements_field_is_unknown() { + let result = json!({}); + assert!(matches!( + parse_config_requirements_result(&result), + CodexHookPolicyReadiness::Unknown(_) + )); + } + + // -- probe_effective_policy: real subprocess spawn, using fake `codex` -- + // scripts (executed directly, never through `sh -c`/`bash -c`/`eval`) so + // these tests never depend on a real installed Codex binary. + + #[cfg(unix)] + fn temp_script(name: &str, contents: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicU64, Ordering}; + // A coarse-resolution clock in some sandboxes can make + // `SystemTime::now()` collide across concurrently-running test + // threads that write to the same OS temp directory; an atomic + // counter guarantees uniqueness regardless of clock resolution + // (`Command::spawn` on a path another thread's child process is + // still executing otherwise fails with `ETXTBSY`). + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nonce = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "sce-codex-hook-policy-{name}-{}-{}-{nonce}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, contents).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + /// Some sandboxed/overlay filesystems spuriously return `ETXTBSY` + /// ("Text file busy") when `exec`ing a script that was written and + /// `chmod`ed moments earlier by a concurrently-running test thread, even + /// with a guaranteed-unique path. Retrying a couple of times is the + /// standard mitigation for this known transient OS race and keeps these + /// tests meaningful (they still fail on a genuine, persistent spawn + /// failure). + #[cfg(unix)] + fn probe_effective_policy_retrying_on_transient_busy_text( + codex_command: &std::ffi::OsStr, + timeout: Duration, + ) -> CodexHookPolicyReadiness { + for attempt in 0..3 { + let readiness = probe_effective_policy(codex_command, timeout); + let is_transient_busy_text = matches!( + &readiness, + CodexHookPolicyReadiness::Unknown(reason) if reason.contains("Text file busy") + ); + if !is_transient_busy_text || attempt == 2 { + return readiness; + } + std::thread::sleep(Duration::from_millis(20)); + } + unreachable!() + } + + #[cfg(unix)] + #[test] + fn missing_codex_executable_is_unknown() { + let readiness = probe_effective_policy( + std::ffi::OsStr::new("/nonexistent/sce-doctor-fixture/codex"), + Duration::from_secs(2), + ); + assert!(matches!(readiness, CodexHookPolicyReadiness::Unknown(_))); + } + + #[cfg(unix)] + #[test] + fn full_handshake_with_allow_managed_hooks_only_true_is_policy_blocked() { + let script = temp_script( + "blocked", + r#"#!/bin/sh +read -r init_line +printf '{"id":1,"result":{}}\n' +read -r initialized_line +read -r config_line +printf '{"id":2,"result":{"requirements":{"allowManagedHooksOnly":true}}}\n' +# Keep running briefly so the probe's kill/reap path is exercised too. +sleep 5 +"#, + ); + + let readiness = probe_effective_policy_retrying_on_transient_busy_text( + script.as_os_str(), + Duration::from_secs(3), + ); + assert_eq!(readiness, CodexHookPolicyReadiness::PolicyBlocked); + std::fs::remove_file(&script).ok(); + } + + #[cfg(unix)] + #[test] + fn full_handshake_with_no_requirements_is_project_hooks_allowed() { + let script = temp_script( + "allowed", + r#"#!/bin/sh +read -r init_line +printf '{"id":1,"result":{}}\n' +read -r initialized_line +read -r config_line +printf '{"id":2,"result":{"requirements":null}}\n' +"#, + ); + + let readiness = probe_effective_policy_retrying_on_transient_busy_text( + script.as_os_str(), + Duration::from_secs(3), + ); + assert_eq!(readiness, CodexHookPolicyReadiness::ProjectHooksAllowed); + std::fs::remove_file(&script).ok(); + } + + #[cfg(unix)] + #[test] + fn process_exiting_before_responding_is_unknown() { + let script = temp_script( + "exits-early", + r"#!/bin/sh +exit 1 +", + ); + + let readiness = probe_effective_policy(script.as_os_str(), Duration::from_secs(3)); + assert!(matches!(readiness, CodexHookPolicyReadiness::Unknown(_))); + std::fs::remove_file(&script).ok(); + } + + #[cfg(unix)] + #[test] + fn malformed_json_response_is_unknown() { + let script = temp_script( + "malformed", + r"#!/bin/sh +read -r init_line +printf 'not json at all\n' +", + ); + + let readiness = probe_effective_policy(script.as_os_str(), Duration::from_secs(3)); + assert!(matches!(readiness, CodexHookPolicyReadiness::Unknown(_))); + std::fs::remove_file(&script).ok(); + } + + #[cfg(unix)] + #[test] + fn timeout_is_unknown_and_child_is_terminated() { + let script = temp_script( + "hangs", + r"#!/bin/sh +# Never reads or writes anything; the probe must time out and kill this. +sleep 30 +", + ); + + let started = Instant::now(); + let readiness = probe_effective_policy(script.as_os_str(), Duration::from_millis(500)); + assert!(matches!(readiness, CodexHookPolicyReadiness::Unknown(_))); + assert!( + started.elapsed() < Duration::from_secs(5), + "the probe must not wait anywhere near the child's own 30s sleep" + ); + std::fs::remove_file(&script).ok(); + } + + #[cfg(unix)] + #[test] + fn config_requirements_error_response_is_unknown() { + let script = temp_script( + "error-response", + r#"#!/bin/sh +read -r init_line +printf '{"id":1,"result":{}}\n' +read -r initialized_line +read -r config_line +printf '{"id":2,"error":{"code":-32601,"message":"method not found"}}\n' +"#, + ); + + let readiness = probe_effective_policy(script.as_os_str(), Duration::from_secs(3)); + assert!(matches!(readiness, CodexHookPolicyReadiness::Unknown(_))); + std::fs::remove_file(&script).ok(); + } +} diff --git a/cli/src/services/codex_hook_trust.rs b/cli/src/services/codex_hook_trust.rs new file mode 100644 index 000000000..2e35627a8 --- /dev/null +++ b/cli/src/services/codex_hook_trust.rs @@ -0,0 +1,641 @@ +//! Read-only diagnosis of Codex's own per-handler hook-*trust* bookkeeping +//! (enabled / `trusted_hash`) for SCE-owned `.codex/hooks.json` +//! registrations. +//! +//! This is deliberately only one of two independent dimensions Codex +//! requires before it will actually execute a project hook handler. This +//! module answers "given an eligible hook *source*, has this handler been +//! enabled and durably trusted?" — it says nothing about whether Codex +//! considers the *source* (SCE's project `.codex/hooks.json`) eligible at +//! all. That second dimension is effective hook-discovery *policy* +//! (`allow_managed_hooks_only`), owned entirely by `codex_hook_policy`. A +//! project registration is only executable when both are satisfied: +//! structurally current, policy-eligible, *and* trusted. See +//! `codex_hook_policy`'s module documentation for why policy cannot be +//! determined by reading any file this module could read, and for the +//! upstream discovery-policy source references. +//! +//! 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: this + /// handler is durably trusted. This alone does **not** mean Codex will + /// execute it — `Trusted` says nothing about whether Codex's + /// hook-discovery policy considers this project handler's *source* + /// eligible at all (see `codex_hook_policy`). The accurate reading is + /// "the current non-managed handler is enabled and durably trusted, + /// assuming Codex policy permits this hook source." + 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. +pub(crate) 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/config/types.rs b/cli/src/services/config/types.rs index 9ef5e1aa0..ef64f39c6 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/db/mod.rs b/cli/src/services/db/mod.rs index 9f08cdee2..21c641cb1 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, 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 + /// 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/default_paths.rs b/cli/src/services/default_paths.rs index 0cf470825..7155b947c 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"; } @@ -392,6 +393,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"; @@ -450,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) } @@ -536,6 +548,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 2c5c98c75..c6067a293 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -5,11 +5,14 @@ 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_policy::CodexHookPolicyReadiness; +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::{ - 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, @@ -35,12 +38,14 @@ pub(super) fn build_report_with_lifecycle_problems( repository_root: &Path, dependencies: &DoctorDependencies<'_>, lifecycle_problems: Vec, + codex_policy_readiness: &CodexHookPolicyReadiness, ) -> HookDoctorReport { let mut report = build_report_without_service_owned_problem_checks( mode, repository_root, dependencies, lifecycle_problems, + codex_policy_readiness, ); report.checkout_identity = collect_checkout_identity_health(repository_root); report.agent_trace_db = collect_agent_trace_db_health(repository_root, &mut report.problems); @@ -61,6 +66,7 @@ fn build_report_without_service_owned_problem_checks( repository_root: &Path, dependencies: &DoctorDependencies<'_>, mut problems: Vec, + codex_policy_readiness: &CodexHookPolicyReadiness, ) -> HookDoctorReport { let global_state = collect_global_state_locations(repository_root, dependencies); let checkout_identity = collect_checkout_identity_health(repository_root); @@ -144,6 +150,7 @@ fn build_report_without_service_owned_problem_checks( bare_repository, detected_repository_root.as_deref(), &mut problems, + codex_policy_readiness, ); HookDoctorReport { @@ -519,6 +526,9 @@ fn resolve_doctor_integration_targets(repository_root: &Path) -> Vec, problems: &mut Vec, + codex_policy_readiness: &CodexHookPolicyReadiness, ) -> Vec { if !git_available || bare_repository { return Vec::new(); @@ -544,10 +555,10 @@ fn inspect_repository_integrations( severity: ProblemSeverity::Error, fixability: ProblemFixability::ManualOnly, summary: String::from( - "No integrations are installed. Run 'sce setup' to install OpenCode, Claude, and/or Pi integration assets.", + "No integrations are installed. Run 'sce setup' to install OpenCode, Claude, Pi, and/or Codex integration assets.", ), remediation: String::from( - "Run 'sce setup --opencode', 'sce setup --claude', 'sce setup --pi', or 'sce setup --all' to install integration assets.", + "Run 'sce setup --opencode', 'sce setup --claude', 'sce setup --pi', 'sce setup --codex', or 'sce setup --all' to install integration assets.", ), next_action: "manual_steps", scope: None, @@ -582,19 +593,39 @@ fn inspect_repository_integrations( inspect_pi_integration_health(&pi_groups, problems); integration_groups.extend(pi_groups); } + IntegrationTargetId::Codex => { + let codex_groups = collect_codex_integration_groups( + resolved_root, + &selected_optional_workflows, + &codex_hook_trust::default_trust_context(), + codex_policy_readiness, + ); + inspect_codex_integration_health(&codex_groups, problems); + integration_groups.extend(codex_groups); + } } } integration_groups } +/// `codex_policy_readiness` is accepted only to build `collect_codex_integration_groups`' +/// per-registration content state; it never influences which files get +/// repaired here (see `repair_codex_hooks_json_if_structurally_unhealthy`, +/// which looks only at structural state). `sce doctor --fix` never writes +/// Codex policy or trust state — both are Codex-owned and read-only from +/// SCE's perspective. +/// /// Repairs each merge-target asset (`.claude/settings.json`, /// `.opencode/opencode.json`) whose SCE-owned fragment is currently missing or /// stale, by reinstalling just that asset through the same merge-install path /// `sce setup` uses. Assets whose fragment is already current are left /// untouched, and a fully missing integration is left to the existing /// "reinstall assets" guidance rather than being created here. -pub(super) fn repair_merge_target_configs(repository_root: &Path) -> Vec { +pub(super) fn repair_merge_target_configs( + repository_root: &Path, + codex_policy_readiness: &CodexHookPolicyReadiness, +) -> Vec { let targets = resolve_doctor_integration_targets(repository_root); let selected_optional_workflows = persisted_optional_workflows(repository_root); let mut results = Vec::new(); @@ -625,9 +656,79 @@ 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, @@ -907,6 +1008,183 @@ 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); + push_codex_hook_malformed_problems(integration_groups, problems); + push_codex_hook_policy_blocked_problems(integration_groups, problems); + push_codex_hook_policy_unknown_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), + }); + } +} + +/// Remediation for `PolicyBlocked` is deliberately administrative: SCE cannot +/// change Codex's enterprise/managed policy, so it must never suggest +/// re-trusting or reinstalling the hook (that would not fix anything). +const CODEX_HOOK_POLICY_BLOCKED_REMEDIATION: &str = + "Ask the Codex administrator to allow project hooks or provide the SCE hook through an \ + allowed managed-hook mechanism. 'sce doctor --fix' cannot repair this: it is a Codex \ + enterprise/managed policy setting, not an SCE-owned file."; + +fn push_codex_hook_policy_blocked_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let blocked_children = group + .children + .iter() + .filter(|child| { + matches!( + child.content_state, + IntegrationContentState::PolicyBlocked(_) + ) + }) + .map(|child| child.relative_path.as_str()) + .collect::>(); + if blocked_children.is_empty() { + continue; + } + + let details = blocked_children.join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::CodexHookRegistrationPolicyBlocked, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Codex project hooks are disabled by the effective allow_managed_hooks_only \ + policy. SCE registrations in '.codex/hooks.json' will not be loaded by Codex: \ + {details}." + ), + remediation: CODEX_HOOK_POLICY_BLOCKED_REMEDIATION.to_string(), + next_action: "manual_steps", + scope: Some(group.key), + }); + } +} + +fn push_codex_hook_policy_unknown_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let unknown_children = group + .children + .iter() + .filter_map(|child| match &child.content_state { + IntegrationContentState::PolicyUnknown(reason) => { + Some((child.relative_path.as_str(), reason.as_str())) + } + _ => None, + }) + .collect::>(); + let Some((_, reason)) = unknown_children.first().copied() else { + continue; + }; + + let details = unknown_children + .iter() + .map(|(relative_path, _)| format!("'{relative_path}'")) + .collect::>() + .join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::CodexHookRegistrationPolicyUnknown, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Warning, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Unable to determine whether Codex policy permits project hooks: {reason}. \ + These current SCE registrations cannot be confirmed healthy: {details}." + ), + remediation: "Ensure the 'codex' CLI is installed and reachable on PATH, then rerun \ + 'sce doctor' so it can re-probe Codex's effective hook-discovery \ + policy; 'sce doctor --fix' cannot repair this." + .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( integration_groups: &[IntegrationGroupHealth], problems: &mut Vec, @@ -1225,6 +1503,134 @@ 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.relative_path)) + .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 | IntegrationContentState::Stale + ) + }) + .collect::>(); + if mismatched_children.is_empty() { + continue; + } + + let mismatched_paths = mismatched_children + .iter() + .map(|child| format!("'{}'", child.relative_path)) + .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, @@ -1529,16 +1935,253 @@ fn collect_pi_integration_groups( ] } -fn sort_integration_children(children: &mut [IntegrationChildHealth]) { - children.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); -} +/// 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], + trust_context: &codex_hook_trust::TrustContext, + policy_readiness: &CodexHookPolicyReadiness, +) -> 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(); + let mut hooks_json_generated_bytes: Option<&'static [u8]> = None; -/// The relative path of the `OpenCode` merge-target asset within `.opencode/`. -const OPENCODE_CONFIG_RELATIVE_PATH: &str = "opencode.json"; + for asset in embedded_assets { + 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); -/// Identifies the two setup assets that are installed by JSON merge -/// (`config_merge`) rather than whole-file replacement, and therefore need -/// SCE-fragment-based content inspection instead of byte-exact `sha256`. + 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); + } + } + + 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, + policy_readiness, + )); + } + + 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, + ), + ] +} + +/// `.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 +/// effective hook-discovery policy readiness (`codex_hook_policy`) and its +/// own hook-trust readiness (`codex_hook_trust`) for registrations that are +/// structurally present. A registration only needs a policy/trust check once +/// it is structurally current; a missing or stale registration has no +/// on-disk canonical handler for Codex to ever load, so policy/trust do not +/// apply. `policy_readiness` is probed once per doctor invocation by the +/// caller and reused here for all four registrations. +fn codex_hooks_json_registration_children( + hooks_json_path: &Path, + generated_bytes: &[u8], + trust_context: &codex_hook_trust::TrustContext, + policy_readiness: &CodexHookPolicyReadiness, +) -> 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, + policy_readiness, + ) + }) + .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"), + ), + ] +} + +/// Human-readable explanation for `IntegrationContentState::PolicyBlocked`, +/// shared by both the per-registration content state and the aggregate +/// problem summary so their wording stays in sync. +const CODEX_HOOK_POLICY_BLOCKED_REASON: &str = + "Codex's effective 'allow_managed_hooks_only' policy is enabled, so Codex will not load \ + this project-owned (non-managed) '.codex/hooks.json' registration."; + +fn codex_hook_registration_child( + hooks_json_path: &Path, + diagnosis: &codex_hook_config::RegistrationDiagnosis, + trust_context: &codex_hook_trust::TrustContext, + policy_readiness: &CodexHookPolicyReadiness, +) -> 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}"); + + // Decision order (AC28): structural state wins first (a missing or stale + // registration has no canonical on-disk handler for Codex to ever load, + // so policy/trust cannot apply); only a structurally current registration + // is further gated on Codex's effective hook-discovery *policy*, and only + // once policy allows project hooks at all is per-handler *trust* + // consulted. Policy and trust are independent dimensions — see + // `codex_hook_policy` and `codex_hook_trust`'s module documentation. + 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 policy_readiness { + CodexHookPolicyReadiness::PolicyBlocked => IntegrationContentState::PolicyBlocked( + CODEX_HOOK_POLICY_BLOCKED_REASON.to_string(), + ), + CodexHookPolicyReadiness::Unknown(reason) => { + IntegrationContentState::PolicyUnknown(reason.clone()) + } + CodexHookPolicyReadiness::ProjectHooksAllowed => { + 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)); +} + +/// The relative path of the `OpenCode` merge-target asset within `.opencode/`. +const OPENCODE_CONFIG_RELATIVE_PATH: &str = "opencode.json"; + +/// Identifies the two setup assets that are installed by JSON merge +/// (`config_merge`) rather than whole-file replacement, and therefore need +/// SCE-fragment-based content inspection instead of byte-exact `sha256`. enum MergeTargetAsset { ClaudeSettings, OpenCodeConfig, @@ -1685,11 +2328,16 @@ 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, + codex_hook_config, codex_hook_registration_child, 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, + CodexHookPolicyReadiness, HookContentState, IntegrationArea, IntegrationContentState, + IntegrationGroupHealth, IntegrationGroupKey, IntegrationTarget, ProblemKind, + ProblemSeverity, }; + 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 @@ -1820,6 +2468,24 @@ 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" + ))), + } + } + + /// Deterministic "policy allows project hooks" reading, so existing + /// trust-focused tests keep exercising only the trust dimension. + fn allowed_policy() -> CodexHookPolicyReadiness { + CodexHookPolicyReadiness::ProjectHooksAllowed + } + fn unique_temp_repository_root(label: &str) -> PathBuf { let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1855,6 +2521,242 @@ 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, + &[], + &deterministic_untrusted_context("split-areas"), + &allowed_policy(), + ); + + 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"); + 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 + .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_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(); + std::fs::write( + codex_hooks_dir.join("hooks.json"), + embedded_codex_asset_bytes(".codex/hooks.json"), + ) + .unwrap(); + + let trust_context = deterministic_untrusted_context("codex-hooks-match"); + let groups = + collect_codex_integration_groups(&root, &[], &trust_context, &allowed_policy()); + let registration_children = groups + .iter() + .flat_map(|group| &group.children) + .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, &[], &trust_context, &allowed_policy()); + 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.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: {}", + hooks_problem.remediation + ); + + 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 = stale_stop_registration_fixture(); + 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, &allowed_policy()); + 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, &allowed_policy()); + 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, &allowed_policy()); + 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, &allowed_policy()); + 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"); + 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"); @@ -1925,7 +2827,7 @@ mod tests { IntegrationContentState::Mismatch )); - let fix_results = super::repair_merge_target_configs(&root); + let fix_results = super::repair_merge_target_configs(&root, &allowed_policy()); assert!( fix_results .iter() @@ -2008,7 +2910,7 @@ mod tests { IntegrationContentState::Mismatch )); - let fix_results = super::repair_merge_target_configs(&root); + let fix_results = super::repair_merge_target_configs(&root, &allowed_policy()); assert!( fix_results .iter() @@ -2172,4 +3074,407 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); } + + // -- Codex hook-discovery *policy* readiness (T22/AC28 repair) ---------- + + fn bare_command_handler_json() -> serde_json::Value { + serde_json::json!({ + "type": "command", + "command": "true" + }) + } + + /// A `Stop` registration whose command identifies it as SCE-owned but + /// whose JSON shape does not match the canonical generated handler + /// (extra `timeout`, no `async`), so structural diagnosis reports + /// `Stale`. Reused everywhere a deterministic stale fixture is needed. + fn stale_stop_registration_fixture() -> serde_json::Value { + 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"}]}] + } + }) + } + + /// Writes `[hooks.state.""]` plus + /// `body` verbatim into a fresh `$CODEX_HOME/config.toml`, mirroring + /// `codex_hook_trust::tests::write_state_toml`, so doctor-level tests can + /// drive `codex_hook_registration_child`'s trust branch deterministically + /// without depending on the real `$CODEX_HOME`. + fn write_trust_state( + dir: &std::path::Path, + label: &str, + hooks_json_path: &std::path::Path, + event: &str, + position: (usize, usize), + body: &str, + ) -> codex_hook_trust::TrustContext { + let absolute = std::fs::canonicalize(hooks_json_path).unwrap(); + let key = format!( + "{}:{}:{}:{}", + absolute.display(), + codex_hook_config::hook_event_key_label(event), + position.0, + position.1 + ); + let escaped_key = key.replace('\\', "\\\\").replace('"', "\\\""); + let codex_home = dir.join(format!("codex-home-{label}")); + std::fs::create_dir_all(&codex_home).unwrap(); + std::fs::write( + codex_home.join("config.toml"), + format!("[hooks.state.\"{escaped_key}\"]\n{body}\n"), + ) + .unwrap(); + codex_hook_trust::TrustContext { + codex_home: Some(codex_home), + } + } + + fn present_and_current_diagnosis( + event: &'static str, + matcher: Option<&'static str>, + handler: serde_json::Value, + ) -> codex_hook_config::RegistrationDiagnosis { + codex_hook_config::RegistrationDiagnosis { + event, + matcher, + state: codex_hook_config::RegistrationStructuralState::PresentAndCurrent, + owned_handler: Some(handler), + position: Some((0, 0)), + } + } + + #[test] + fn registration_child_is_match_when_trusted_and_policy_allows_project_hooks() { + let root = unique_temp_repository_root("policy-trusted-match"); + let hooks_json_path = root.join("hooks.json"); + std::fs::write(&hooks_json_path, "{}").unwrap(); + let handler = bare_command_handler_json(); + let hash = codex_hook_trust::hash_command_handler("Stop", None, &handler).unwrap(); + let trust_context = write_trust_state( + &root, + "trusted", + &hooks_json_path, + "Stop", + (0, 0), + &format!("trusted_hash = \"{hash}\""), + ); + let diagnosis = present_and_current_diagnosis("Stop", None, handler); + + let child = codex_hook_registration_child( + &hooks_json_path, + &diagnosis, + &trust_context, + &CodexHookPolicyReadiness::ProjectHooksAllowed, + ); + assert_eq!(child.content_state, IntegrationContentState::Match); + + std::fs::remove_dir_all(&root).ok(); + } + + /// The main regression test for this repair: a structurally current, + /// fully trusted registration must still report `PolicyBlocked` (not + /// `Match`) when Codex's effective `allow_managed_hooks_only` policy + /// excludes project hooks, and the resulting problem must name policy, + /// never trust, as the cause. + #[test] + fn registration_child_is_policy_blocked_even_when_structurally_current_and_trusted() { + let root = unique_temp_repository_root("policy-blocked-trusted"); + let hooks_json_path = root.join("hooks.json"); + std::fs::write(&hooks_json_path, "{}").unwrap(); + let handler = bare_command_handler_json(); + let hash = codex_hook_trust::hash_command_handler("Stop", None, &handler).unwrap(); + let trust_context = write_trust_state( + &root, + "blocked-but-trusted", + &hooks_json_path, + "Stop", + (0, 0), + &format!("trusted_hash = \"{hash}\""), + ); + let diagnosis = present_and_current_diagnosis("Stop", None, handler); + + let child = codex_hook_registration_child( + &hooks_json_path, + &diagnosis, + &trust_context, + &CodexHookPolicyReadiness::PolicyBlocked, + ); + assert!( + matches!( + child.content_state, + IntegrationContentState::PolicyBlocked(_) + ), + "a trusted handler must still report PolicyBlocked when Codex's effective policy \ + excludes project hooks: {:?}", + child.content_state + ); + assert_ne!( + child.content_state, + IntegrationContentState::Match, + "trust alone must never make a policy-blocked registration report healthy" + ); + + let groups = vec![IntegrationGroupHealth::new( + IntegrationGroupKey::new(IntegrationTarget::Codex, IntegrationArea::Hooks), + vec![child], + )]; + let mut problems = Vec::new(); + inspect_codex_integration_health(&groups, &mut problems); + let policy_problem = problems + .iter() + .find(|problem| problem.kind == ProblemKind::CodexHookRegistrationPolicyBlocked) + .expect("a policy-blocked problem was reported"); + assert_eq!(policy_problem.severity, ProblemSeverity::Error); + assert!( + !problems + .iter() + .any(|problem| problem.kind == ProblemKind::CodexHookRegistrationNotTrusted), + "policy blocking must short-circuit before trust is ever reported as the cause" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn registration_child_modified_trust_is_unaffected_by_allowed_policy() { + let root = unique_temp_repository_root("policy-allowed-modified"); + let hooks_json_path = root.join("hooks.json"); + std::fs::write(&hooks_json_path, "{}").unwrap(); + let handler = bare_command_handler_json(); + let trust_context = write_trust_state( + &root, + "modified", + &hooks_json_path, + "Stop", + (0, 0), + "trusted_hash = \"sha256:stale\"", + ); + let diagnosis = present_and_current_diagnosis("Stop", None, handler); + + let child = codex_hook_registration_child( + &hooks_json_path, + &diagnosis, + &trust_context, + &CodexHookPolicyReadiness::ProjectHooksAllowed, + ); + assert_eq!( + child.content_state, + IntegrationContentState::NotTrusted("modified".to_string()) + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn registration_child_disabled_trust_is_unaffected_by_allowed_policy() { + let root = unique_temp_repository_root("policy-allowed-disabled"); + let hooks_json_path = root.join("hooks.json"); + std::fs::write(&hooks_json_path, "{}").unwrap(); + let handler = bare_command_handler_json(); + let hash = codex_hook_trust::hash_command_handler("Stop", None, &handler).unwrap(); + let trust_context = write_trust_state( + &root, + "disabled", + &hooks_json_path, + "Stop", + (0, 0), + &format!("trusted_hash = \"{hash}\"\nenabled = false"), + ); + let diagnosis = present_and_current_diagnosis("Stop", None, handler); + + let child = codex_hook_registration_child( + &hooks_json_path, + &diagnosis, + &trust_context, + &CodexHookPolicyReadiness::ProjectHooksAllowed, + ); + assert_eq!( + child.content_state, + IntegrationContentState::NotTrusted("disabled".to_string()) + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn registration_child_is_policy_unknown_when_the_probe_could_not_determine_policy() { + let root = unique_temp_repository_root("policy-unknown"); + let hooks_json_path = root.join("hooks.json"); + std::fs::write(&hooks_json_path, "{}").unwrap(); + let handler = bare_command_handler_json(); + let hash = codex_hook_trust::hash_command_handler("Stop", None, &handler).unwrap(); + let trust_context = write_trust_state( + &root, + "unknown-policy", + &hooks_json_path, + "Stop", + (0, 0), + &format!("trusted_hash = \"{hash}\""), + ); + let diagnosis = present_and_current_diagnosis("Stop", None, handler); + + let child = codex_hook_registration_child( + &hooks_json_path, + &diagnosis, + &trust_context, + &CodexHookPolicyReadiness::Unknown("codex executable not found".to_string()), + ); + match &child.content_state { + IntegrationContentState::PolicyUnknown(reason) => { + assert!(reason.contains("codex executable not found")); + } + other => panic!("expected PolicyUnknown, got {other:?}"), + } + assert_ne!(child.content_state, IntegrationContentState::Match); + + let groups = vec![IntegrationGroupHealth::new( + IntegrationGroupKey::new(IntegrationTarget::Codex, IntegrationArea::Hooks), + vec![child], + )]; + let mut problems = Vec::new(); + inspect_codex_integration_health(&groups, &mut problems); + let unknown_problem = problems + .iter() + .find(|problem| problem.kind == ProblemKind::CodexHookRegistrationPolicyUnknown) + .expect("a policy-unknown problem was reported"); + assert_eq!(unknown_problem.severity, ProblemSeverity::Warning); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn structural_missing_state_wins_over_a_blocked_or_unknown_policy() { + let root = absent_repository_root(); + for policy in [ + CodexHookPolicyReadiness::PolicyBlocked, + CodexHookPolicyReadiness::Unknown("probe failed".to_string()), + ] { + let groups = collect_codex_integration_groups( + &root, + &[], + &deterministic_untrusted_context("structural-missing-wins"), + &policy, + ); + let registration_children = groups + .iter() + .flat_map(|group| &group.children) + .filter(|child| child.relative_path.starts_with(".codex/hooks.json#")) + .collect::>(); + assert_eq!(registration_children.len(), 4); + for child in registration_children { + assert_eq!( + child.content_state, + IntegrationContentState::Missing, + "a missing registration must stay Missing regardless of policy: {policy:?}" + ); + } + } + } + + #[test] + fn structural_stale_state_wins_over_a_blocked_policy() { + let root = unique_temp_repository_root("codex-hooks-stale-policy"); + 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"), + serde_json::to_vec_pretty(&stale_stop_registration_fixture()).unwrap(), + ) + .unwrap(); + + let trust_context = deterministic_untrusted_context("codex-hooks-stale-policy"); + let groups = collect_codex_integration_groups( + &root, + &[], + &trust_context, + &CodexHookPolicyReadiness::PolicyBlocked, + ); + 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, + "a stale registration must not be reclassified as PolicyBlocked" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn policy_readiness_is_reused_unchanged_across_all_four_registrations() { + let root = unique_temp_repository_root("codex-hooks-policy-reused"); + 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 trust_context = deterministic_untrusted_context("codex-hooks-policy-reused"); + let groups = collect_codex_integration_groups( + &root, + &[], + &trust_context, + &CodexHookPolicyReadiness::PolicyBlocked, + ); + let registration_children = groups + .iter() + .flat_map(|group| &group.children) + .filter(|child| child.relative_path.starts_with(".codex/hooks.json#")) + .collect::>(); + assert_eq!(registration_children.len(), 4); + for child in registration_children { + assert!( + matches!( + child.content_state, + IntegrationContentState::PolicyBlocked(_) + ), + "the single probed policy value must apply identically to every one of the \ + four registrations, not be re-probed per registration: '{}' was {:?}", + child.relative_path, + child.content_state + ); + } + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn fix_never_modifies_policy_or_trust_state_for_a_structurally_current_document() { + let root = unique_temp_repository_root("codex-hooks-fix-policy-noop"); + 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(); + + for policy in [ + CodexHookPolicyReadiness::PolicyBlocked, + CodexHookPolicyReadiness::Unknown("probe failed".to_string()), + ] { + let fix_results = super::repair_merge_target_configs(&root, &policy); + assert!( + fix_results.is_empty(), + "'--fix' must never attempt to repair a structurally current document merely \ + because policy is blocked/unknown ({policy:?}): {fix_results:?}" + ); + } + + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index fd3ed083d..6815a08ae 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -5,6 +5,7 @@ use std::process::Command; use anyhow::{Context, Result}; use crate::app::{ContextWithRepoRoot, HasRepoRoot}; +use crate::services::codex_hook_policy; use crate::services::default_paths::{resolve_sce_default_locations, resolve_state_data_root}; use crate::services::lifecycle::{ lifecycle_providers, FixOutcome, HealthCategory, HealthFixability, HealthProblem, @@ -52,6 +53,13 @@ struct DoctorDependencies<'a> { resolve_state_root: &'a dyn Fn() -> Result, resolve_global_config_path: &'a dyn Fn() -> Result, validate_config_file: &'a dyn Fn(&Path) -> Result<()>, + /// Probes Codex's effective hook-discovery policy + /// (`allow_managed_hooks_only`). Invoked exactly once per doctor + /// invocation (see `execute_doctor_with_lifecycle_providers`) and reused + /// for every Codex integration inspection within that invocation — + /// initial report, `--fix`, and final report alike — never once per + /// registration. + probe_codex_hook_policy: &'a dyn Fn() -> codex_hook_policy::CodexHookPolicyReadiness, } struct DoctorExecution { @@ -98,6 +106,7 @@ fn execute_doctor_with_context( Ok(resolve_sce_default_locations()?.global_config_file()) }, validate_config_file: &crate::services::config::validate_config_file, + probe_codex_hook_policy: &codex_hook_policy::probe_default, }, ) } @@ -108,6 +117,11 @@ fn execute_doctor_with_lifecycle_providers( context: &impl HasRepoRoot, dependencies: &DoctorDependencies<'_>, ) -> DoctorExecution { + // Probed exactly once per doctor invocation, then reused for every + // Codex integration inspection below (initial report, `--fix`, and final + // report alike) instead of once per registration or once per report. + let policy_readiness = (dependencies.probe_codex_hook_policy)(); + let providers = lifecycle_providers(true); let initial_problems = diagnose_lifecycle_providers(context, &providers); let initial_doctor_problems = initial_problems @@ -119,6 +133,7 @@ fn execute_doctor_with_lifecycle_providers( repository_root, dependencies, initial_doctor_problems, + &policy_readiness, ); if request.mode != DoctorMode::Fix { @@ -129,7 +144,10 @@ fn execute_doctor_with_lifecycle_providers( } let mut fix_results = fix_lifecycle_providers(context, &providers, &initial_problems); - fix_results.extend(repair_merge_target_configs(repository_root)); + fix_results.extend(repair_merge_target_configs( + repository_root, + &policy_readiness, + )); let final_problems = diagnose_lifecycle_providers(context, &providers); let final_doctor_problems = final_problems .into_iter() @@ -140,6 +158,7 @@ fn execute_doctor_with_lifecycle_providers( repository_root, dependencies, final_doctor_problems, + &policy_readiness, ); fix_results.extend(build_manual_fix_results(&final_report)); @@ -312,6 +331,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 +347,19 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::OpenCodeAssetReadFailed => ProblemKind::OpenCodeAssetReadFailed, HealthProblemKind::ClaudeAssetReadFailed => ProblemKind::ClaudeAssetReadFailed, HealthProblemKind::PiAssetReadFailed => ProblemKind::PiAssetReadFailed, + HealthProblemKind::CodexAssetReadFailed => ProblemKind::CodexAssetReadFailed, + HealthProblemKind::CodexHookRegistrationMalformed => { + ProblemKind::CodexHookRegistrationMalformed + } + HealthProblemKind::CodexHookRegistrationNotTrusted => { + ProblemKind::CodexHookRegistrationNotTrusted + } + HealthProblemKind::CodexHookRegistrationPolicyBlocked => { + ProblemKind::CodexHookRegistrationPolicyBlocked + } + HealthProblemKind::CodexHookRegistrationPolicyUnknown => { + ProblemKind::CodexHookRegistrationPolicyUnknown + } HealthProblemKind::AgentTraceDbConnectionFailed => { ProblemKind::AgentTraceDbConnectionFailed } @@ -367,6 +405,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 +421,19 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::OpenCodeAssetReadFailed => HealthProblemKind::OpenCodeAssetReadFailed, ProblemKind::ClaudeAssetReadFailed => HealthProblemKind::ClaudeAssetReadFailed, ProblemKind::PiAssetReadFailed => HealthProblemKind::PiAssetReadFailed, + ProblemKind::CodexAssetReadFailed => HealthProblemKind::CodexAssetReadFailed, + ProblemKind::CodexHookRegistrationMalformed => { + HealthProblemKind::CodexHookRegistrationMalformed + } + ProblemKind::CodexHookRegistrationNotTrusted => { + HealthProblemKind::CodexHookRegistrationNotTrusted + } + ProblemKind::CodexHookRegistrationPolicyBlocked => { + HealthProblemKind::CodexHookRegistrationPolicyBlocked + } + ProblemKind::CodexHookRegistrationPolicyUnknown => { + HealthProblemKind::CodexHookRegistrationPolicyUnknown + } ProblemKind::AgentTraceDbConnectionFailed => { HealthProblemKind::AgentTraceDbConnectionFailed } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index 2cb5d7c9b..e875e2786 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -434,7 +434,12 @@ fn integration_group_status( IntegrationContentState::Match => DoctorDisplayStatus::Pass, IntegrationContentState::Missing | IntegrationContentState::Mismatch - | IntegrationContentState::ReadFailed(_) => DoctorDisplayStatus::Fail, + | IntegrationContentState::Stale + | IntegrationContentState::Malformed(_) + | IntegrationContentState::ReadFailed(_) + | IntegrationContentState::PolicyBlocked(_) => DoctorDisplayStatus::Fail, + IntegrationContentState::NotTrusted(_) + | IntegrationContentState::PolicyUnknown(_) => DoctorDisplayStatus::Warn, }) }); let problem_status = report @@ -487,6 +492,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 +509,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)) { @@ -578,6 +594,30 @@ 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::PolicyBlocked { path, reason } => { + lines.push(format!("{prefix}Path: {}", path.display())); + lines.push(format!("{prefix}Blocked by Codex policy: {reason}")); + } + DoctorDisplayDetail::PolicyUnknown { path, reason } => { + lines.push(format!("{prefix}Path: {}", path.display())); + lines.push(format!( + "{prefix}Codex hook-discovery policy could not be determined: {reason}" + )); + } DoctorDisplayDetail::Problem { summary, remediation, @@ -593,6 +633,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 +676,7 @@ fn integration_area_label(area: IntegrationArea) -> &'static str { IntegrationArea::Skills => "Skills", IntegrationArea::Prompts => "Prompts", IntegrationArea::Extensions => "Extensions", + IntegrationArea::Hooks => "Hooks", } } @@ -646,6 +689,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 +698,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 +707,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 c17ae4c4c..dc832b303 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", } } } @@ -201,6 +213,31 @@ 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), + /// The registration is structurally current, but Codex's effective + /// hook-discovery policy (`allow_managed_hooks_only = true`) discards + /// every project-owned hook source, so Codex will never even consider + /// this handler — independent of, and checked before, trust readiness. + /// Carries a human-readable explanation. + PolicyBlocked(String), + /// Whether Codex's effective hook-discovery policy allows project hooks + /// could not be determined (no Codex executable, probe failure, timeout, + /// malformed response, etc.). Never treated as healthy: AC28 requires + /// proof Codex will actually execute the registration, and an unknown + /// policy is not proof. Carries a human-readable reason. + PolicyUnknown(String), } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -235,6 +272,25 @@ pub(super) enum DoctorDisplayDetail { path: PathBuf, error: String, }, + Stale { + path: PathBuf, + }, + Malformed { + path: PathBuf, + error: String, + }, + NotTrusted { + path: PathBuf, + reason: String, + }, + PolicyBlocked { + path: PathBuf, + reason: String, + }, + PolicyUnknown { + path: PathBuf, + reason: String, + }, Problem { summary: String, remediation: String, @@ -342,6 +398,40 @@ 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(), + }), + ), + IntegrationContentState::PolicyBlocked(reason) => ( + DoctorDisplayStatus::Fail, + Some(DoctorDisplayDetail::PolicyBlocked { + path: self.path.clone(), + reason: reason.clone(), + }), + ), + IntegrationContentState::PolicyUnknown(reason) => ( + DoctorDisplayStatus::Warn, + Some(DoctorDisplayDetail::PolicyUnknown { + path: self.path.clone(), + reason: reason.clone(), + }), + ), }; DoctorDisplayNode::asset(self.relative_path.clone(), status, detail) } @@ -390,12 +480,19 @@ pub(crate) enum ProblemKind { ClaudeIntegrationContentMismatch, PiIntegrationFilesMissing, PiIntegrationContentMismatch, + CodexIntegrationFilesMissing, + CodexIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, OpenCodeAssetReadFailed, ClaudeAssetReadFailed, PiAssetReadFailed, + CodexAssetReadFailed, + CodexHookRegistrationMalformed, + CodexHookRegistrationNotTrusted, + CodexHookRegistrationPolicyBlocked, + CodexHookRegistrationPolicyUnknown, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } 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 000000000..a4b9a00c2 --- /dev/null +++ b/cli/src/services/hooks/codex/apply_patch/mod.rs @@ -0,0 +1,676 @@ +//! 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; +mod path; + +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::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::{ + 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, + 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 { + 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 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 { + log.error( + "sce.hooks.codex.apply_patch.parse_failed", + &parse_error.to_string(), + &[], + event.session_id.as_deref(), + ); + } + return Ok(String::new()); + } + }; + + 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()); + } + + let Ok(time_ms) = current_unix_time_ms() else { + return Ok(String::new()); + }; + + 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) +} + +/// 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()) +} + +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. +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, + required_session_id(event.session_id.as_deref())?, + ); + 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: super::super::NullableField::Missing, + } + } + + fn normalized(raw: &str) -> String { + 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 { + 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 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( + 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("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); + } + + #[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"], + "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 000000000..93dff816e --- /dev/null +++ b/cli/src/services/hooks/codex/apply_patch/normalize.rs @@ -0,0 +1,807 @@ +//! 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 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 +//! 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 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, + 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() + .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, + allocator: &mut SyntheticLineAllocator, +) -> Result, CodexPatchNormalizeError> { + match operation { + 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, allocator), + CodexFileOperation::Delete { .. } => Ok(None), + } +} + +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'); + } + Ok(Some(render_file_section(path, path, &body))) +} + +fn normalize_update( + old_path: &str, + new_path: Option<&str>, + hunks: &[CodexHunk], + allocator: &mut SyntheticLineAllocator, +) -> Result, CodexPatchNormalizeError> { + let mut body = String::new(); + let mut has_changes = false; + + for hunk in hunks { + 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'); + 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'); + 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, + "@@ -{start},{removed_count} +{start},{added_count} @@" + ); + body.push_str(&hunk_body); + has_changes = true; + } + } + + if !has_changes { + return Ok(None); + } + + let destination = new_path.unwrap_or(old_path); + 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 { + 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::{ + 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( + "*** Begin Patch\n\ + *** Add File: foo.txt\n\ + +line one\n\ + +line two\n\ + *** End Patch", + ); + + let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); + let base = test_base(); + + assert_eq!( + normalized, + 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"); + 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: base, + content: "line one".to_string(), + session_id: Some("cx_test".to_string()), + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: base + 1, + 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, "tool-1").expect("should normalize"); + let base = test_base(); + + // The context line (" unchanged") is dropped entirely and + // contributes no positional weight. + assert_eq!( + normalized, + 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"); + 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: base, + content: " old_line".to_string(), + session_id: Some("cx_test".to_string()), + }, + TouchedLine { + kind: TouchedLineKind::Added, + line_number: base, + 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, "tool-1").expect("should normalize"); + let base = test_base(); + + assert_eq!( + normalized, + 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"); + 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, "tool-1").expect("should normalize"), + "" + ); + } + + #[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, "tool-1").expect("should normalize"), + "" + ); + } + + #[test] + fn mixed_patch_keeps_only_add_and_update_evidence() { + let normalized = normalized( + "*** 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_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, "tool-1").expect("should normalize"); + let base = test_base(); + + assert_eq!( + normalized, + 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 + /// 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, "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 event-scoped synthetic ones, + // 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 000000000..ba9b6cfb9 --- /dev/null +++ b/cli/src/services/hooks/codex/apply_patch/parser.rs @@ -0,0 +1,797 @@ +//! 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 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"; +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 contains an unrepresentable path (e.g. empty). +#[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(), + } +} + +/// 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(); + 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)) +} + +/// 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.")); + } + + Ok(path.to_string()) +} + +#[cfg(test)] +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<()> { + let git_root = resolve_git_root(repository_root)?; + let event_cwd = resolve_event_cwd(&git_root, event_cwd)?; + + for operation in &mut patch.operations { + match operation { + CodexFileOperation::Add { path, .. } | CodexFileOperation::Delete { path } => { + *path = resolve_path_from_cwd(&git_root, &event_cwd, path)?; + } + CodexFileOperation::Update { + old_path, new_path, .. + } => { + *old_path = resolve_path_from_cwd(&git_root, &event_cwd, old_path)?; + if let Some(new_path) = new_path { + *new_path = resolve_path_from_cwd(&git_root, &event_cwd, new_path)?; + } + } + } + } + + Ok(()) +} + +/// Resolves one Codex path to a repository-relative path. +/// +/// This public seam intentionally performs the same Git-root and cwd checks +/// as the event-level resolver, making the path contract independently +/// testable without invoking the hook dispatcher or opening the Agent Trace +/// database. +#[allow(dead_code)] +pub(crate) fn resolve_codex_patch_path( + repository_root: &Path, + event_cwd: &str, + codex_path: &str, +) -> Result { + let git_root = resolve_git_root(repository_root)?; + let event_cwd = resolve_event_cwd(&git_root, event_cwd)?; + resolve_path_from_cwd(&git_root, &event_cwd, codex_path) +} + +fn resolve_git_root(repository_root: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(repository_root) + .output() + .with_context(|| { + format!( + "failed to discover Git root from '{}'.", + repository_root.display() + ) + })?; + + if !output.status.success() { + bail!( + "git rev-parse --show-toplevel failed from '{}': {}", + repository_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let reported_root = String::from_utf8(output.stdout) + .context("git rev-parse --show-toplevel emitted invalid UTF-8")? + .trim() + .to_string(); + if reported_root.is_empty() || reported_root.contains('\0') { + bail!("git rev-parse --show-toplevel returned an invalid root."); + } + + let reported_root = PathBuf::from(reported_root); + let root_path = if reported_root.is_absolute() { + reported_root + } else { + repository_root.join(reported_root) + }; + let root = std::fs::canonicalize(&root_path).with_context(|| { + format!( + "failed to canonicalize the Git root '{}'.", + root_path.display() + ) + })?; + if !root.is_dir() { + bail!("resolved Git root '{}' is not a directory.", root.display()); + } + + Ok(root) +} + +fn resolve_event_cwd(git_root: &Path, event_cwd: &str) -> 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 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 !canonical_cwd.is_dir() { + bail!( + "Codex hook event cwd '{}' is not a directory.", + cwd.display() + ); + } + if !canonical_cwd.starts_with(git_root) { + bail!( + "Codex hook event cwd '{}' is outside Git repository '{}'.", + cwd.display(), + git_root.display() + ); + } + + Ok(lexical_cwd) +} + +fn resolve_path_from_cwd(git_root: &Path, event_cwd: &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 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(); + 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) => normalized.push(value), + Component::ParentDir => { + let _ = normalized.pop(); + } + } + } + + Ok(normalized) +} + +fn nearest_existing_prefix(path: &Path) -> Result<(PathBuf, PathBuf)> { + let mut existing = path.to_path_buf(); + loop { + match std::fs::symlink_metadata(&existing) { + 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().map(Path::to_path_buf).ok_or_else(|| { + anyhow!( + "Codex apply_patch path '{}' has no existing repository prefix.", + path.display() + ) + })?; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect Codex apply_patch path prefix '{}'.", + existing.display() + ) + }); + } + } + } +} + +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() { + 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_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.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"); + assert_eq!(result, "src/lib/nested/file.rs"); + 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"); + 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", "./..", "", "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()); + } + + 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); + } + + #[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"); + // Overshoot past the filesystem root by a wide margin: `TMPDIR` depth + // varies by platform (e.g. macOS's `/var/folders/xx/yyyy/T/` nests + // deeper than Linux's `/tmp/`), so a fixed `..` count that clamps on + // one platform can undershoot the root on another. + let cwd_depth = cwd.components().count(); + let excess_traversal = "../".repeat(cwd_depth + 8); + let path = format!("{excess_traversal}{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"); + 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/bash_policy.rs b/cli/src/services/hooks/codex/bash_policy.rs new file mode 100644 index 000000000..e282f9246 --- /dev/null +++ b/cli/src/services/hooks/codex/bash_policy.rs @@ -0,0 +1,246 @@ +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::super::NullableField; + 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: NullableField::Missing, + } + } + + 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, None) + .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 new file mode 100644 index 000000000..d72de6f6e --- /dev/null +++ b/cli/src/services/hooks/codex/mod.rs @@ -0,0 +1,1029 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; + +use crate::services::observability::traits::Logger; + +use super::read_hook_stdin; + +mod apply_patch; +mod bash_policy; +mod stop; +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"; + +/// 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) + } + + #[cfg(test)] + 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`. +/// +/// 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`; +/// `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 (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 { + 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, + #[serde(default)] + pub(crate) prompt: 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 +/// distinct behavior. Every other combination classifies as `NoOp`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CodexDispatchArm { + UserPromptSubmit, + Stop, + PreToolUseBash, + PostToolUseApplyPatch, + 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 + } + (CODEX_HOOK_EVENT_POST_TOOL_USE, Some(CODEX_HOOK_TOOL_APPLY_PATCH)) => { + CodexDispatchArm::PostToolUseApplyPatch + } + _ => 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, logger) { + Ok(output) => output, + Err(error) => log_codex_fail_open(&error, logger), + } +} + +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::Stop => stop::handle(repository_root, &event)?, + CodexDispatchArm::PreToolUseBash => bash_policy::handle(repository_root, &event)?, + 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(), + }) +} + +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::new() +} + +#[cfg(test)] +mod tests { + 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 crate::services::patch::FileChangeKind; + + 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, + prompt: None, + last_assistant_message: NullableField::Missing, + } + } + + #[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() { + assert_eq!( + classify_codex_event(&event("PostToolUse", Some("apply_patch"))), + CodexDispatchArm::PostToolUseApplyPatch + ); + } + + #[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_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, None) + .expect("no-op dispatch should succeed"); + + assert_eq!(output, ""); + } + + #[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, None) + .expect("no-op dispatch should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn run_codex_subcommand_from_payload_rejects_non_json_stdin() { + 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")); + } + + #[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, ""); + } + + #[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) + .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(); + } + + // --- 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/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs new file mode 100644 index 000000000..c19a9856b --- /dev/null +++ b/cli/src/services/hooks/codex/stop.rs @@ -0,0 +1,640 @@ +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, 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 `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) +} + +/// 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, +{ + 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.", + )?; + + 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, + }) +} + +/// 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, + validated: &ValidatedStop<'_>, + last_assistant_message: &str, + 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:{}:assistant", validated.turn_id); + + 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()) +} + +/// 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." + )), + } +} + +/// 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::{ + 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: NullableField::Value(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_eq!(output, ""); + + 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 = NullableField::Missing; + + 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_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 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); + } + + #[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); + } + + #[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")); + } + + #[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 new file mode 100644 index 000000000..fc044cd42 --- /dev/null +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -0,0 +1,428 @@ +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 { + handle_with_clock(repository_root, event, current_unix_time_ms) +} + +/// Injectable-clock counterpart of `handle`. Validates `session_id`, +/// `turn_id`, and `prompt` (via [`validate_user_prompt_submit_event`]) +/// *before* any side effect — a malformed payload never reaches timestamp +/// acquisition or Agent Trace DB access. Timestamp acquisition is itself +/// 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 both a malformed payload and 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 validated = validate_user_prompt_submit_event(event)?; + + 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.", + )?; + + persist_with(&db, &validated, generated_at_unix_ms) +} + +/// 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, +) -> 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")?; + + 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_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()) +} + +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." + )), + } +} + +/// 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." + )), + } +} + +/// 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::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::super::NullableField; + 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()), + last_assistant_message: NullableField::Missing, + } + } + + 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_eq!(output, ""); + + 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); + } + + #[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")); + } + + #[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/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 21aee3550..76ac91a04 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; @@ -43,9 +44,11 @@ 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"; const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; type PayloadValidationError = fn(&str) -> String; @@ -62,6 +65,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(), }; @@ -87,6 +91,7 @@ pub enum HookSubcommand { }, DiffTrace, ConversationTrace, + Codex, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -219,6 +224,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)), } } @@ -1026,6 +1032,15 @@ fn normalize_claude_model_id(model: &str) -> Option { } } +fn normalize_codex_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + /// 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 { @@ -1795,6 +1810,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", } } @@ -2697,6 +2713,66 @@ 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_preserves_fresh_model_id() { + assert_eq!( + normalize_codex_model_id("gpt-5.6-codex").as_deref(), + Some("gpt-5.6-codex") + ); + } + + #[test] + 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("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/cli/src/services/lifecycle.rs b/cli/src/services/lifecycle.rs index bcb4ef0e1..5edc33c55 100644 --- a/cli/src/services/lifecycle.rs +++ b/cli/src/services/lifecycle.rs @@ -56,12 +56,19 @@ pub enum HealthProblemKind { ClaudeIntegrationContentMismatch, PiIntegrationFilesMissing, PiIntegrationContentMismatch, + CodexIntegrationFilesMissing, + CodexIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, OpenCodeAssetReadFailed, ClaudeAssetReadFailed, PiAssetReadFailed, + CodexAssetReadFailed, + CodexHookRegistrationMalformed, + CodexHookRegistrationNotTrusted, + CodexHookRegistrationPolicyBlocked, + CodexHookRegistrationPolicyUnknown, AgentTraceDbConnectionFailed, AgentTraceDbSchemaNotReady, } diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index e84ebdba9..76d3c43ef 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -14,6 +14,9 @@ pub mod auth_db; pub mod bash_policy; pub mod capabilities; pub mod checkout; +pub(crate) mod codex_hook_config; +pub(crate) mod codex_hook_policy; +pub(crate) mod codex_hook_trust; pub mod command_registry; pub mod completion; pub mod config; diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index b21d135a3..31d564734 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 { Ok(services::hooks::HookSubcommand::ConversationTrace) } + cli_schema::HooksSubcommand::Codex => Ok(services::hooks::HookSubcommand::Codex), } } diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 54520b022..76e0d625d 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -28,6 +28,7 @@ pub enum SetupTarget { OpenCode, Claude, Pi, + Codex, All, } @@ -83,6 +84,7 @@ fn embedded_assets_for_concrete_target(target: SetupTarget) -> &'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") } @@ -822,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}; @@ -893,6 +917,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 +1292,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") } @@ -1351,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, @@ -1413,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() }; @@ -1522,6 +1570,7 @@ enum SetupPromptTarget { OpenCode, Claude, Pi, + Codex, All, } @@ -1570,6 +1619,7 @@ mod prompt { SetupPromptTarget::OpenCode, SetupPromptTarget::Claude, SetupPromptTarget::Pi, + SetupPromptTarget::Codex, SetupPromptTarget::All, ]; @@ -1579,12 +1629,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 +1664,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 +1763,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 +1897,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 +2083,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 +2100,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 +2120,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 +2149,121 @@ 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_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/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index da454f533..7b902e522 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/config/pkl/base/workflow-brownfield.pkl b/config/pkl/base/workflow-brownfield.pkl index 06b587b6a..c6b29cde4 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, invocationExample: 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 @@ -89,7 +89,7 @@ local renderSkillBody = (mode: model.WorkflowRenderMode) -> """ `references/output.md` naming the offending token, and stop. Do not guess the token's meaning, and do not investigate or write anything. - ## Workflow + \(model.invocationExampleParagraph.apply(invocationExample))## Workflow ### 1. Confirm the context root @@ -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, invocationExample: String) -> renderSkillBody.apply("composite", argumentsReference, invocationExample) + 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 8f5768989..501af0b64 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, invocationExample: String) -> """ # SCE Change to Plan ## Purpose @@ -49,18 +49,18 @@ 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. Every `{plan-path}` and `{candidate-path}` emitted anywhere in this workflow is the path resolved in step 2 (`plan.path`, or an entry of `candidates`), so every emitted command is directly runnable. -## Workflow +\(model.invocationExampleParagraph.apply(invocationExample))## Workflow ### 1. Load durable context diff --git a/config/pkl/base/workflow-commit.pkl b/config/pkl/base/workflow-commit.pkl index bfa6822e2..8240845da 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, invocationExample: 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, @@ -540,7 +540,7 @@ summarize, or pre-scope it. Never pass the `mode-token` as commit context. Staged changes are the source of truth for what is being committed. This command never stages, unstages, or modifies files. -## Workflow +\(model.invocationExampleParagraph.apply(invocationExample))## Workflow Follow exactly one path. diff --git a/config/pkl/base/workflow-content.pkl b/config/pkl/base/workflow-content.pkl index c247f7576..ec2970a70 100644 --- a/config/pkl/base/workflow-content.pkl +++ b/config/pkl/base/workflow-content.pkl @@ -96,13 +96,31 @@ 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, and by a target-specific + /// concrete `$sce-{slug}` invocation example appended to `## Input` (empty + /// for targets that need none). + compositeSkillBody: ((String, 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) -> 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. @@ -169,6 +187,14 @@ hidden commandBanner = (workflowName: String) -> packageOnlyBlock.apply("\(workf hidden inlinePhaseBody = (phaseBody: WorkflowBody) -> compositeOnlyBlock.apply(phaseBody.render.apply("composite")) +/// A concrete `$sce-{slug}` invocation example appended to a skill's `## Input` +/// section. Only Codex needs this: its skill loading has no `$ARGUMENTS`-style +/// substitution, so its Input section otherwise names the invocation input only +/// in prose. Empty for every other target, so their rendered Markdown is +/// byte-identical to before this paragraph existed. +hidden invocationExampleParagraph = (invocationExample: String) -> + if (invocationExample.isEmpty) "" else "For example: `\(invocationExample)`.\n\n" + /// Non-SCE skills may help with work inside the active workflow step without /// becoming an alternate owner of the workflow's control flow. helperSkillCompositionRule = """ @@ -182,7 +208,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, invocationExample: String) -> """ # SCE Next Task ## Purpose @@ -231,7 +257,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,11 +269,11 @@ 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. -## Workflow +\(invocationExampleParagraph.apply(invocationExample))## Workflow ### 1. Review the task @@ -369,7 +395,7 @@ Stop. - Preserve completed work and evidence when a later phase fails. """ -validateSkillBody = """ +validateSkillBody = (argumentsReference: String, invocationExample: String) -> """ # SCE Validate ## Purpose @@ -414,13 +440,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. @@ -431,7 +457,7 @@ Every `{plan-path}` and `{candidate-path}` emitted anywhere in this workflow is the path carried by the **Validation phase** in its Markdown result (`Plan:`, or a candidate path), so every emitted command is directly runnable. -## Workflow +\(invocationExampleParagraph.apply(invocationExample))## Workflow ### 1. Validate the plan diff --git a/config/pkl/base/workflow-handover.pkl b/config/pkl/base/workflow-handover.pkl index 132adf6a2..c6fc18fa1 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, invocationExample: 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 @@ -106,7 +106,7 @@ local renderSkillBody = (mode: model.WorkflowRenderMode) -> """ Never infer the mode from conversation content or repository state. Only the presence or absence of a path argument decides it. - ## Workflow + \(model.invocationExampleParagraph.apply(invocationExample))## Workflow Follow exactly one path. @@ -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, invocationExample: String) -> renderSkillBody.apply("composite", argumentsReference, invocationExample) 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/generate.pkl b/config/pkl/generate.pkl index 204676a07..f6d69a30f 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,17 @@ output { ["config/.pi/extensions/sce/index.ts"] { text = piExtensionSource } + for (documentPath, document in codex.skillDocuments) { + ["config/.agents/skills/\(documentPath)"] { + 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/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index 9972c29a8..2abbce93a 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 new file mode 100644 index 000000000..48661b3df --- /dev/null +++ b/config/pkl/renderers/codex-content.pkl @@ -0,0 +1,132 @@ +import "../base/decision-skill.pkl" as decision +import "../base/workflow-catalog.pkl" as catalog +import "../base/workflow-content.pkl" as model +import "common.pkl" as common +import "codex-metadata.pkl" as metadata +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" + +/// A concrete, runnable `$sce-{slug}` invocation example per catalog workflow, +/// appended to Codex's rendered `## Input` section alongside the prose above. +/// Codex's `/skills` UI and explicit `$sce-` invocation are the only ways +/// to reach these skills once `agents/openai.yaml` disables implicit invocation, +/// so the Input section states one concretely rather than only in the abstract. +/// Authored once here, directly against each workflow's own argument shape; +/// there is no cross-target canonical source for it, the same as the +/// `default_prompt` text in `codex-metadata.pkl`. +local invocationExamplesBySkillSlug = new Mapping { + ["sce-change-to-plan"] = "$sce-change-to-plan \"add dark mode to settings\"" + ["sce-next-task"] = "$sce-next-task my-plan T03 approved" + ["sce-validate"] = "$sce-validate my-plan" + ["sce-commit"] = "$sce-commit oneshot" + ["sce-handover"] = "$sce-handover context/handovers/2026-08-24-session.md" + ["sce-brownfield"] = "$sce-brownfield rebuild docs/" +} + +local invocationExampleForSkillSlug = (skillSlug: String) -> + if (invocationExamplesBySkillSlug.containsKey(skillSlug)) invocationExamplesBySkillSlug[skillSlug] else "" + +/// Codex has no command-routed entrypoints — it discovers skills directly, with +/// no per-target frontmatter beyond the shared description, matching Pi. Each +/// of the six catalog workflow skills additionally carries `agents/openai.yaml`, +/// disabling implicit invocation so these stateful lifecycle workflows are +/// reachable only via explicit `$sce-` invocation or `/skills` discovery. +/// `sce-decision` has no user-facing entrypoint on any target and is excluded. +skillDocuments { + for (path, document in workflowResults.skillDocuments.apply("", codexArgumentsReference, invocationExampleForSkillSlug)) { + [path] = document + } + for (slug, rendered in metadata.renderedByCommandSlug) { + ["\(catalog.workflows[slug].skillSlug)/agents/openai.yaml"] = new model.WorkflowDocument { + path = "agents/openai.yaml" + text = rendered + } + } + for (path, document in decision.skillDocuments.apply("")) { + [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" + +/// 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 +/// 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)" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "apply_patch", + "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/codex-metadata.pkl b/config/pkl/renderers/codex-metadata.pkl new file mode 100644 index 000000000..3d3a51c34 --- /dev/null +++ b/config/pkl/renderers/codex-metadata.pkl @@ -0,0 +1,69 @@ +// Typed Codex skill-interface metadata and the pure `agents/openai.yaml` +// renderer for the six catalog workflow skills, wired into +// `codex-content.pkl`'s exposed skill-document map. This module defines the +// model, the per-workflow authored values, and the render function, and also +// evaluates standalone. +// +// Confirmed upstream `agents/openai.yaml` schema +// (developers.openai.com/codex/skills, redirects to +// learn.chatgpt.com/docs/build-skills; confirmed 2026-08-24): three +// top-level sections — `interface` (`display_name`, `short_description`, +// `icon_small`, `icon_large`, `brand_color`, `default_prompt`, all +// optional), `policy.allow_implicit_invocation` (boolean, default `true`), +// and `dependencies.tools`. This module authors only +// `interface.{display_name,short_description,default_prompt}` and +// `policy.allow_implicit_invocation = false` — every catalog workflow is a +// stateful SCE lifecycle transition that must not auto-trigger from +// conversational relevance alone. +import "../base/workflow-catalog.pkl" as catalog + +class CodexSkillMetadata { + displayName: String + shortDescription: String + defaultPrompt: String +} + +/// `default_prompt` has no cross-target catalog counterpart (unlike +/// `display_name`/`short_description`, which reuse the catalog's +/// `title`/`description` verbatim); each value is authored once here as a +/// short imperative sentence describing what invoking the workflow does. +local defaultPrompts: Mapping = new Mapping { + ["change-to-plan"] = "Turn this change request into an SCE plan." + ["next-task"] = "Review, approve, implement, verify, and synchronize the next SCE plan task." + ["validate"] = "Validate this completed SCE plan and record final validation evidence." + ["commit"] = "Analyze staged changes and run the SCE commit workflow." + ["handover"] = "Write an SCE session handover document, or load one for continuation." + ["brownfield"] = "Reconstruct durable SCE context from this repository's own evidence." +} + +/// Keyed by `commandSlug`, matching `catalog.workflows`. +metadataByCommandSlug: Mapping = new Mapping { + for (slug, workflow in catalog.workflows) { + [slug] = new CodexSkillMetadata { + displayName = workflow.title + shortDescription = workflow.description + defaultPrompt = defaultPrompts[slug] + } + } +} + +/// Pure render: `interface` plus `policy.allow_implicit_invocation: false`, +/// no other top-level keys. Every value here is plain authored catalog text +/// with no YAML-special characters, so a double-quoted scalar needs no +/// general escaping helper. +local render = (metadata: CodexSkillMetadata) -> """ +interface: + display_name: "\(metadata.displayName)" + short_description: "\(metadata.shortDescription)" + default_prompt: "\(metadata.defaultPrompt)" +policy: + allow_implicit_invocation: false +""" + +/// Rendered `agents/openai.yaml` text per catalog workflow command slug. +/// Evaluating this module standalone exercises every workflow's output. +renderedByCommandSlug: Mapping = new Mapping { + for (slug, metadata in metadataByCommandSlug) { + [slug] = render.apply(metadata) + } +} diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index 2413bcd9d..bb8dc1038 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -3,6 +3,8 @@ 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 +import "codex-metadata.pkl" as codexMetadata /// Build the expected inventory from each target's independently checked /// document inventory plus the retained non-workflow assets. This deliberately @@ -39,6 +41,12 @@ hidden expectedArtifactPaths = new Mapping { } ["config/.pi/extensions/sce/index.ts"] = true + 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 } @@ -49,6 +57,84 @@ 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") + +/// 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") + +/// Codex's `agents/openai.yaml` disables implicit invocation for the six +/// catalog workflows, so their SKILL.md `## Input` sections must each name a +/// concrete, own-slug `$sce-{slug}` invocation example alongside the +/// `invocation input` prose — the only way to reach these skills is `/skills` +/// discovery or explicit `$sce-` invocation. +local assertCodexSkillInvocationExamples = (artifacts: Mapping) -> + if ( + expectedCodexMetadataArtifactPaths.every((_, slug) -> + let (skillSlug = catalog.workflows[slug].skillSlug) + artifacts["config/.agents/skills/\(skillSlug)/SKILL.md"].contains("`$\(skillSlug)") + ) + ) "Codex skill invocation examples: each Input section names its own $sce-{slug}" + else throw("generated Codex SKILL.md must name a concrete $sce-{slug} invocation example in its Input section") + +/// The six catalog workflows' generated `agents/openai.yaml` paths, keyed by +/// the catalog command slug so each can be checked against its own +/// catalog-derived `CodexSkillMetadata`. +local expectedCodexMetadataArtifactPaths = new Mapping { + for (slug, workflow in catalog.workflows) { + ["config/.agents/skills/\(workflow.skillSlug)/agents/openai.yaml"] = slug + } +} + +/// Every generated Codex `agents/openai.yaml` derives its `interface` values +/// from the shared workflow catalog and disables implicit invocation, since +/// each catalog workflow is a stateful SCE lifecycle transition that must not +/// auto-trigger from conversational relevance alone; `sce-decision` has no +/// user-facing entrypoint on any target and keeps no such file. +local assertCodexSkillMetadataContract = (artifacts: Mapping) -> + if ( + expectedCodexMetadataArtifactPaths.every((path, slug) -> + let (text = artifacts[path]) + let (meta = codexMetadata.metadataByCommandSlug[slug]) + text.contains("allow_implicit_invocation: false") + && text.contains("display_name: \"\(meta.displayName)\"") + && text.contains("short_description: \"\(meta.shortDescription)\"") + && text.contains("default_prompt: \"\(meta.defaultPrompt)\"") + ) + && artifacts.every((path, _) -> + !path.contains("/skills/sce-decision/") || !path.endsWith("agents/openai.yaml") + ) + ) "Codex skill metadata: catalog-derived interface values and implicit-invocation policy present, sce-decision excluded" + else throw("Codex skill metadata must derive interface values from the workflow catalog, disable implicit invocation, and exclude sce-decision") + hidden workflowDocuments = new Mapping { for (path, document in opencode.skillDocuments) { ["config/.opencode/skills/\(path)"] = document.text @@ -59,6 +145,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 +195,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 +350,13 @@ 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, plus Codex's `.codex/hooks.json`, its install-guidance hook script, +/// and its six per-workflow `agents/openai.yaml` files (one per catalog +/// workflow, excluding `sce-decision`). Stating the total as a literal makes +/// an unintended inventory change fail here instead of silently becoming the +/// new expectation. +local expectedArtifactPathCount = 141 local assertExactArtifactPaths = (actual: Mapping) -> if ( @@ -376,7 +468,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 +497,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 +506,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)) ) @@ -571,6 +663,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) { @@ -583,6 +684,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 + || codexArgumentDependentReferencePaths.contains(relativePath) + ) ) ) "target-neutral references: Pi, Claude, and OpenCode bodies match" else throw("target-neutral package references differ between Pi, Claude, and OpenCode") @@ -778,6 +884,10 @@ hidden assertValidateExcludesDecisionAndPlanSync = (documents: Mapping) -> contractChecks { ["artifact-paths"] = assertExactArtifactPaths.apply(generatedArtifacts) + ["codex-hook-invocation"] = assertCodexHookInvocationContract.apply(generatedArtifacts) + ["codex-skills-exclude-arguments"] = assertCodexSkillsExcludeArguments.apply(generatedArtifacts) + ["codex-skill-invocation-examples"] = assertCodexSkillInvocationExamples.apply(generatedArtifacts) + ["codex-skill-metadata"] = assertCodexSkillMetadataContract.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/metadata-coverage-check.pkl b/config/pkl/renderers/metadata-coverage-check.pkl index 004948d6c..228feefb9 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) { @@ -64,6 +65,18 @@ local expectedSkillDocumentPaths = new Mapping { ["sce-decision/references/adr-template.md"] = true } +/// Codex additionally carries `agents/openai.yaml` for each of the six catalog +/// workflow skills, disabling implicit invocation; `sce-decision` has no +/// user-facing entrypoint on any target and keeps no such file. +local expectedCodexSkillDocumentPaths = new Mapping { + for (path, _ in expectedSkillDocumentPaths) { + [path] = true + } + for (_, workflow in catalog.workflows) { + ["\(workflow.skillSlug)/agents/openai.yaml"] = true + } +} + local opencodeAgents = new Mapping { for (slug, document in opencode.agents) { [slug] = document @@ -99,6 +112,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 +155,7 @@ inventoryChecks { for (slug, document in piCommands) { ["pi-command-route-\(slug)"] = assertCommandRoute.apply("Pi", slug, document.text) } + ["codex-skill-documents"] = assertExactKeys.apply(codexSkillDocuments, expectedCodexSkillDocumentPaths, "Codex skill document") } /// Force rendering after exact inventory checks so target-specific metadata @@ -176,3 +195,8 @@ piSkillDocumentCoverage { [path] = document.text } } +codexSkillDocumentCoverage { + for (path, document in codexSkillDocuments) { + [path] = document.text + } +} diff --git a/config/pkl/renderers/opencode-content.pkl b/config/pkl/renderers/opencode-content.pkl index 7b313e1b1..dcc1e745a 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 7152aec59..d08f8661b 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 02d1fbf8f..d1acb21ee 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, invocationExample: String) -> + if (workflow.structuredSource.argumentDependentCommandBody != null) + workflow.structuredSource.argumentDependentCommandBody.apply(argumentsReference, invocationExample) + 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, invocationExample: 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, invocationExample)) """ else new Listing { @@ -125,7 +128,7 @@ local renderSkill = (workflow: CompositeWorkflow, extraFrontmatterLines: String) \(model.helperSkillCompositionRule) """ - renderCanonicalWorkflow.apply(workflow) + renderCanonicalWorkflow.apply(workflow, argumentsReference, invocationExample) when (workflow.structuredSource.phases.length > 0) { "## Embedded phase behavior\n\n" + renderPhases.apply(workflow) } @@ -222,11 +225,18 @@ 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, for a target whose +/// harness does not substitute `$ARGUMENTS` into skill Markdown the prose that +/// names the skill's invocation input, and, per workflow slug, an optional +/// concrete `$sce-{slug}` invocation example appended to `## Input` (empty for +/// a target that needs none). +hidden skillDocuments = (extraSkillFrontmatterLines: String, argumentsReference: String, invocationExample: (String) -> 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, invocationExample.apply(workflow.skillSlug)) + 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 d94875965..12e4ec6e6 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -2,23 +2,23 @@ ## 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`, `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. 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 two-argument functions of the target's arguments-reference token and a per-workflow invocation-example string, letting skill-mode prose name the invocation input instead of a literal `$ARGUMENTS` for a target whose harness does not substitute it, and append a target-specific concrete `$sce-{slug}` example (via the shared `invocationExampleParagraph` helper, empty for a target that supplies none) immediately before `## Workflow` — 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 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,35 +28,38 @@ 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: 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 a per-catalog-workflow `{skillSlug}/agents/openai.yaml` document sourced from `codex-metadata.pkl` for the six catalog workflows (not `sce-decision`), 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` - `config/pkl/renderers/metadata-coverage-check.pkl` - `config/pkl/renderers/generation-contract-check.pkl` +- `config/pkl/renderers/codex-metadata.pkl` (typed Codex skill-interface metadata plus a pure `agents/openai.yaml` renderer for the six catalog workflow skills — `interface.{display_name,short_description,default_prompt}` derived from/authored alongside the catalog, and `policy.allow_implicit_invocation: false`; wired into `codex-content.pkl`'s emitted skill documents, so these six stateful lifecycle workflows are reachable only via explicit `$sce-` invocation or Codex's `/skills` discovery, not implicit conversational activation; `sce-decision` has no user-facing entrypoint on any target and receives no such file) - `config/pkl/generate.pkl` (single multi-file generation entrypoint) - `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). 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. Codex also differs from all three other targets in the two arguments it passes `skillDocuments` beyond frontmatter: the arguments-reference token (OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input`, since its skill loading provides no such substitution) and a per-workflow-slug invocation-example function (empty for OpenCode, Claude, and Pi; for Codex, one authored, runnable `$sce-{slug} ...` example per catalog workflow). So Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by the arguments-reference token, and every Codex skill's `## Input` section additionally carries a trailing "For example: `$sce-{slug} ...`." paragraph that Pi/Claude/OpenCode do not render. - 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`). -- 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 for every shared document 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`) and where every skill's `## Input` section carries Codex's own trailing concrete `$sce-{slug}` invocation-example paragraph, which Pi's `skillDocuments` call passes as empty and so never renders. 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}/`. Codex alone additionally carries `{skillSlug}/agents/openai.yaml` for each of the six catalog workflow skills (not `sce-decision`), rendered by `codex-metadata.pkl` from the same catalog `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or `/skills` discovery, never from conversational relevance alone; no other target has an implicit-invocation policy concept. 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`) — and an `invocationExample` function from skill slug to a concrete `$sce-{slug}` example string, appended by `model.invocationExampleParagraph` immediately before each workflow's `## Workflow` heading when non-empty; OpenCode, Claude, and Pi pass `(_) -> ""` so their `## Input` sections are unaffected, while Codex supplies one authored example per catalog workflow. `renderCommand`'s thin wrapper text is unaffected by either parameter 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 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 shared exact skill-document inventory plus Codex's own inventory (the shared inventory extended with the six catalog workflows' `agents/openai.yaml`, 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` (141) inside the same assertion so an unintended inventory change fails rather than redefining the expectation. It asserts (`codex-skill-metadata`) that each of the six generated `agents/openai.yaml` files contains `policy.allow_implicit_invocation: false` plus catalog-derived `interface.{display_name,short_description,default_prompt}` values sourced from `codex-metadata.pkl`, and that no such file exists under `sce-decision`. 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`. Codex's inventory additionally contains `{slug}/agents/openai.yaml` for each of the six catalog workflow slugs (not `sce-decision`). +- `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 and each skill's trailing `$sce-{slug}` invocation-example paragraph, plus a per-catalog-workflow `agents/openai.yaml` with no Pi/OpenCode/Claude equivalent, 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: @@ -123,10 +126,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. 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 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. 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. @@ -137,7 +140,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. @@ -185,7 +188,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 @@ -199,5 +202,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 671dbd0ae..63891f8c5 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -52,17 +52,17 @@ 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`, `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 `. -`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. 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). @@ -89,10 +89,10 @@ 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`, 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`; `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/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 91e92a12d..8aee06e9f 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/cli/patch-service.md b/context/cli/patch-service.md index f40202f01..a5d6ff760 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 60b3d5601..16c324ef7 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) @@ -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) @@ -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 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) @@ -97,6 +98,11 @@ 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) - `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-canonical-worktree-path-resolution.md b/context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md new file mode 100644 index 000000000..733aa9b14 --- /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/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 000000000..900c86cb9 --- /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-nondestructive-hook-ownership.md b/context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md new file mode 100644 index 000000000..9fdec3b6a --- /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/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 000000000..860dda8a1 --- /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 000000000..2f720e7b2 --- /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 bafda407a..4a2b74069 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -1,31 +1,31 @@ # 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`. - 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. +- `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. - `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. @@ -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,11 +164,11 @@ - `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`). 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 `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`, 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 — 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,8 +184,8 @@ - `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. -- `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. +- `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. - `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 a3c91969b..dd0740088 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, 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 141-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). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits 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. @@ -26,8 +26,8 @@ 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. -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` 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. 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`. @@ -61,24 +61,24 @@ 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. 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`. 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 -- 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 by the extra frontmatter each target supports, by an arguments-reference token, and by a per-workflow-slug invocation-example function: OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute and an empty invocation example, while Codex — whose skill loading provides no `$ARGUMENTS` substitution — passes a plain-prose token (`invocation input`) so its generated skill Markdown never contains the literal, unsubstituted `$ARGUMENTS`, plus one authored, runnable `$sce-{slug} ...` example per catalog workflow appended to that skill's `## Input` section. - 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, 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 where each skill's `## Input` section carries a trailing, Codex-only `$sce-{slug}` invocation example Pi's empty invocation-example function never renders; its `sce-handover`/`sce-brownfield` `references/output.md` also quote the arguments-reference 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/patterns.md b/context/patterns.md index 9aa9ba17d..72ccff40e 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. @@ -97,8 +97,8 @@ - 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`. -- 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. +- 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 141-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. 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. @@ -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. @@ -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 — 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 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 new file mode 100644 index 000000000..430f69cb6 --- /dev/null +++ b/context/plans/codex-cli-integration.md @@ -0,0 +1,581 @@ +# 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). + +`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. + +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. + - Validate: run the command in a scratch git repo; inspect `.sce/config.json` and installed files. +- [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`. +- [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` 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`. + - Validate: integration test feeding a synthetic `Stop` payload to `sce hooks codex` and querying the DB. +- [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. +- [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`. +- [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. +- [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 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_`, 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. +- [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] 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. + +- [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. +- [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. +- [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 (78 tests: 27 + 13 + 23, plus 15 in the new `codex_hook_policy`) — 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. + - Validate (managed-only policy coverage, follow-up fix #3): `codex_hook_policy` tests prove the effective-policy probe's pure response parser (`requirements: null`/absent/`false` `allowManagedHooksOnly` → `ProjectHooksAllowed`; `true` → `PolicyBlocked`; non-boolean/missing-field → `Unknown`) and its bounded subprocess lifecycle (missing executable, malformed JSON, an error response, the process exiting before responding, and a timeout — all `Unknown` with the child always terminated and reaped, confirmed by wall-clock assertions well under the child's own sleep duration). `services::doctor::inspect` tests prove the main regression directly: a registration that is structurally current *and* fully trusted still reports `PolicyBlocked` (not `Match`) when the probed policy is `PolicyBlocked`, with the resulting problem naming policy — never trust — as the cause; a probed `Unknown` policy reports `PolicyUnknown` and is never treated as healthy; structurally `Missing`/`Stale` registrations keep their structural state under both `PolicyBlocked` and `Unknown` policy; the same probed policy value applies identically across all four registrations in one `collect_codex_integration_groups` call; and `sce doctor --fix` never attempts a repair merely because policy is blocked or unknown for an otherwise structurally current document. +- [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. +- [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. +- [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. +- [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 + +- `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/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, 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 + +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, 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); 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 + +- 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 `< 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 + +- [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`. + - 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 + +- [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`) — 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 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. + - 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 + +- [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::'`. + - 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 + +- [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. + - 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 + +- [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)`, 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 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'`. + - 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 + +- [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'`. + - 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 + +- [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: synced + +- [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-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'`. + - 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 + +- [x] T10: `Add a Codex apply_patch grammar parser` (status:done) + - Task ID: T10 + - 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 + +- [x] T11: `Normalize parsed Codex apply_patch operations into an SCE-supported patch` (status:done) + - Task ID: T11 + - 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: 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 + +- [x] T12: `Wire PostToolUse apply_patch tracing end to end` (status:done) + - Task ID: T12 + - 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 + - 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. + - 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 + +- [x] T14: `Align Codex apply_patch outer parsing with current upstream leniency` (status:done) + - Task ID: T14 + - Scope: In — keep `parse_codex_apply_patch` focused on canonical Codex patch grammar, add separate `normalize_outer_apply_patch_input`, and mirror the current upstream lenient boundary behavior for raw input plus exactly the verified `< 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 + - 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 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'` + - 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 + +- [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'` + - Completed: 2026-08-23 + - Files changed: `cli/src/services/codex_hook_config.rs`, `cli/src/services/codex_hook_trust.rs` (new), `cli/src/services/codex_hook_policy.rs` (new, follow-up fix #3), `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`, `context/sce/codex-integration-runtime.md`, `context/sce/doctor-human-text-contract.md` + - 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. + - Follow-up correctness fix #3 (2026-08-24, repair of a PR #229-review gap in the follow-up fixes above): AC28's original implementation modeled Codex hook executability as `structurally current && enabled && trusted_hash == current_hash`, mapping `codex_hook_trust::TrustReadiness::Trusted` directly to `IntegrationContentState::Match`. That is incomplete: current upstream Codex (`hooks/src/engine/discovery.rs` `HookDiscoveryPolicy::allows`: `!allow_managed_hooks_only || source.is_managed`) discards every non-managed hook source — including SCE's project `.codex/hooks.json` registrations (`HookSource::Project`, non-managed per `hook_metadata_for_config_layer_source`) — during discovery whenever the effective `allow_managed_hooks_only` requirement is `true`, regardless of structural currency or trust. `allow_managed_hooks_only` is an effective *requirements* value (`config/src/config_requirements.rs` `ConfigRequirements::allow_managed_hooks_only`), populated only from `requirements.toml`/managed layers and composed from multiple possible sources (system `requirements.toml`, legacy managed config, MDM managed preferences, backend-delivered enterprise/cloud policy — `docs/config.md` confirms plain `config.toml` cannot enable it), so SCE cannot safely re-derive the effective value by reading any single file. Re-verified current upstream at commit `a8468330bb5f45e9f4d2ec630b01ea8c52908be3` (refreshed from the `8e649e3afa5cdddfb09a1b85a090b94775045d9b` this task originally used) and confirmed `codex app-server` exposes the composed answer read-only via `configRequirements/read` (`app-server-protocol/src/protocol/common.rs` `ConfigRequirementsRead => "configRequirements/read"`, no params; response `v2::ConfigRequirementsReadResponse { requirements: Option }`, `allowManagedHooksOnly: Option`), and that `codex app-server --stdio` speaks newline-delimited JSON-RPC with the `initialize`/`initialized` handshake required before any other request. Added a new `cli/src/services/codex_hook_policy.rs` module with a `CodexHookPolicyReadiness` enum (`ProjectHooksAllowed` | `PolicyBlocked` | `Unknown(String)`), kept strictly separate from `codex_hook_trust`'s per-handler enabled/trust bookkeeping (fixed `codex_hook_trust`'s module- and `Trusted`-variant doc comments, which previously described `Trusted` alone as "Codex will execute this handler," to instead state the accurate, narrower meaning and point at `codex_hook_policy` for the other half), and a bounded, injectable probe (`probe_effective_policy`/`probe_default`) that spawns `codex app-server --stdio` directly (no `sh -c`/`bash -c`/`eval`), drives the initialize/initialized/`configRequirements/read` exchange over piped stdio with a background reader thread and a hard wall-clock deadline (default 5s), and unconditionally terminates and reaps the child on every exit path via an RAII `ChildGuard`. Doctor's decision order in `codex_hook_registration_child` (`doctor/inspect.rs`) now is: structurally missing/stale/malformed states win outright (untouched); only a structurally current registration is gated on policy first (`PolicyBlocked` → new `IntegrationContentState::PolicyBlocked(String)`, `Unknown` → new `IntegrationContentState::PolicyUnknown(String)`); only `ProjectHooksAllowed` falls through to the existing, unmodified trust-readiness match. Policy is probed exactly once per doctor invocation (a new `DoctorDependencies.probe_codex_hook_policy` injected dependency, called once in `execute_doctor_with_lifecycle_providers` before building the initial report, running `--fix`, and building the final report) and the resulting `&CodexHookPolicyReadiness` value is threaded through `build_report_with_lifecycle_problems`/`build_report_without_service_owned_problem_checks`/`inspect_repository_integrations`/`collect_codex_integration_groups`/`repair_merge_target_configs` as a plain parameter — none of those functions probe internally, so reuse across all three report builds and all four registrations is structural, not merely observed. Added `ProblemKind`/`HealthProblemKind::CodexHookRegistrationPolicyBlocked` (Error severity, manual-only, administrative remediation — "ask the Codex administrator to allow project hooks…", never suggesting re-trust/reinstall) and `...PolicyUnknown` (Warning severity, manual-only), wired through `doctor/mod.rs`'s bidirectional `ProblemKind`/`HealthProblemKind` mappings, `doctor/types.rs`'s `DoctorDisplayDetail`/`display_node()`, and `doctor/render.rs`'s status/text rendering (`PolicyBlocked` → `[FAIL]`, `PolicyUnknown` → `[WARN]`, matching the existing trust-`Unknown` convention). `repair_codex_hooks_json_if_structurally_unhealthy` is unchanged and still triggers only on structural Missing/Stale/Malformed, so `--fix` never attempts to repair policy or trust state (proven by a dedicated test). `hash_command_handler` in `codex_hook_trust.rs` was widened from private to `pub(crate)` so doctor-level tests can construct matching `trusted_hash` values without duplicating Codex's hashing algorithm. + - Verify (follow-up fix #3): `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml codex_hook` — passed: 64 tests (27 `codex_hook_config` + 13 `codex_hook_trust` + 15 new `codex_hook_policy`, plus 9 unrelated `hooks::codex`/`setup` matches), including the main regression test `registration_child_is_policy_blocked_even_when_structurally_current_and_trusted` (a structurally current, fully trusted registration reports `PolicyBlocked`, not `Match`, and the resulting problem names policy — never trust — as the cause) and 8 more covering trusted+allowed→`Match`, modified/disabled trust unaffected by an allowed policy, `PolicyUnknown` diagnosis and its own problem kind, structural Missing/Stale winning over both `PolicyBlocked` and `Unknown` policy, the single probed value applying identically across all four registrations, and `--fix` never firing merely because policy is blocked/unknown. `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor` — passed: 23 tests (14 pre-existing, unaffected, plus the 9 new policy tests above). `./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (full CLI suite) — passed: 592 tests, 0 failed. `./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets --all-features -- -D warnings` — passed clean (`-D clippy::pedantic`, `-D clippy::all`). `./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed clean. `nix flake check --keep-going` — 15 of 17 checks passed (`cli-tests`, `cli-clippy`, `cli-fmt`, `cli-generated-input`, `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`, `native-portability-audit`, `flatpak-static-validation`); `cargo-sources-parity` and `flatpak-manifest-parity` failed on a pre-existing, unrelated fixed-output-derivation hash mismatch in `sce-flatpak-cargo-sources` (the Python `flatpak-cargo-generator.py` vendoring tool's output no longer matches its pinned hash) — `cli/Cargo.lock` is byte-identical before and after this fix (confirmed via `git status`/`git diff`), so this is flatpak-packaging metadata drift unrelated to any change in this fix and pre-existing on `main`/`codex` before this task started. + - Context impact: root — `context/sce/codex-integration-runtime.md` and `context/sce/doctor-human-text-contract.md` (both already named under this plan's Context sync list) needed the new policy-readiness dimension, its upstream contract, its probe mechanism, and the revised three-stage (structural → policy → trust) decision order documented; both were updated as part of this fix. + - Context synchronization: synced + +- [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` + - 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 + +- [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'` + - 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. + - 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). + +- [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'` + - 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 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). + - 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. + +- [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/`. +- 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 + +**Status:** validated +**Date:** 2026-08-23 + +### 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) +- `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): 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. + +### Success-criteria verification + +- [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::`. 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), plus managed-only hook-discovery *policy* coverage (`allow_managed_hooks_only`) added by T22 follow-up correctness fix #3 -> T22 doctor/shared-service suites including the new `codex_hook_policy` suite, this run: `cli-tests` (592 passed), `codex_hook` (64 passed), `services::doctor` (23 passed), `cargo clippy`/`cargo fmt --check` clean; `nix flake check --keep-going`: 15/17 checks passed, with `cargo-sources-parity`/`flatpak-manifest-parity` failing on a pre-existing, unrelated fixed-output-derivation hash mismatch unconnected to `cli/Cargo.lock` (unchanged by this fix). +- [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. +- [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 + +- None. + +### Residual risks + +- 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/plans/codex-explicit-workflow-invocation.md b/context/plans/codex-explicit-workflow-invocation.md new file mode 100644 index 000000000..6ec14ace6 --- /dev/null +++ b/context/plans/codex-explicit-workflow-invocation.md @@ -0,0 +1,133 @@ +# Plan: codex-explicit-workflow-invocation + +## Change summary + +Extends the existing, completed Codex integration (`context/plans/codex-cli-integration.md`) rather than replacing it. Today every generated Codex skill under `.agents/skills/` — including the six catalog-registered SCE workflows — is only a discoverable skill: Codex may implicitly activate one from conversational relevance alone, the same way any ordinary skill would. This plan adds Codex-specific `agents/openai.yaml` metadata to the six catalog workflow skills (`sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, `sce-brownfield`) so each disables implicit invocation (`policy.allow_implicit_invocation: false`), making them explicit command-like entrypoints reachable only via `$sce-` or Codex's `/skills` UI — closer to how Claude Code, Pi, and OpenCode already treat these stateful lifecycle workflows. It also adds a concrete `$sce-` example to each generated `SKILL.md`'s `## Input` section so the explicit-invocation convention is visible in the instructions themselves, without introducing `$ARGUMENTS` (Codex skill loading has no such substitution). The internal `sce-decision` package is deliberately excluded: it has no user-facing entrypoint on any target and is invoked only by `/next-task`'s own task-synchronization gate, so gating it behind explicit user selection would risk breaking that internal call. No `.codex/prompts`/`.codex/commands` mechanism is introduced, and Codex's hooks, tracing, Bash policy, `apply_patch` evidence, setup, and doctor behavior are unchanged. + +## Acceptance criteria + +- [x] AC1: Each of the six catalog-registered workflow skills generates `.agents/skills/{slug}/agents/openai.yaml` containing a `policy: allow_implicit_invocation: false` block and an `interface` block whose `display_name`/`short_description` are derived from the workflow catalog's `title`/`description`. + - Validate: `nix run .#pkl-generate -- "$(mktemp -d)"` then inspect `.agents/skills/{sce-change-to-plan,sce-next-task,sce-validate,sce-commit,sce-handover,sce-brownfield}/agents/openai.yaml`; `nix run .#pkl-check-generated`. +- [x] AC2: `sce-decision` continues to generate no `agents/openai.yaml` and keeps its current (implicit-eligible) invocation policy, since it is an internal helper invoked by `/next-task`'s own instructions rather than a user-facing entrypoint. + - Validate: `nix run .#pkl-check-generated` assertion; direct inspection of `.agents/skills/sce-decision/` shows only `SKILL.md` and `references/adr-template.md`. +- [x] AC3: Every generated Codex `SKILL.md`'s `## Input` section states a concrete `$sce-{slug}` invocation example and contains no literal `$ARGUMENTS`. + - Validate: direct inspection of generated Codex skill bodies; `nix run .#pkl-check-generated`. +- [x] AC4: `sce setup --codex --non-interactive` installs each workflow's `agents/openai.yaml` beside its `SKILL.md`, honors the existing `integrations.optional_workflows` selection for `sce-brownfield` (installed only when selected), never installs one for `sce-decision`, and `sce doctor`'s Codex `Skills` group reports the new files as healthy rather than missing or stale. + - Validate: in a scratch Git repository, run `sce setup --codex --non-interactive` with and without `--workflow brownfield`, then `sce doctor`, and inspect the installed tree and doctor output. +- [x] AC5: No `.codex/prompts/`, `.codex/commands/`, or other new custom-slash-command mechanism is introduced; `.codex/hooks.json`, the install-guidance hook script, the `sce hooks codex` dispatcher, Bash policy delegation, and `apply_patch` evidence capture are unchanged. + - Validate: `git diff` shows no new path under `.codex/`; `nix flake check`. +- [x] AC6: OpenCode, Claude, and Pi generated output (commands, skills, agents, settings) is unchanged. + - Validate: `nix run .#pkl-check-generated`; diff each target's generated payload against its pre-change output. + +### Full validation + +- `nix run .#pkl-check-generated` +- `nix flake check` + +### Context sync + +- `context/architecture.md`, `context/context-map.md`, `context/overview.md` — describe the Codex renderer also emitting `agents/openai.yaml` per catalog workflow, and the bumped exact generated-artifact count. +- `context/sce/codex-integration-runtime.md` — document the `$sce-*` explicit-invocation convention, `/skills` discovery, the `allow_implicit_invocation: false` mechanism and its rationale (stateful SCE lifecycle transitions must not auto-trigger), and why `sce-decision` is the one catalog-adjacent package deliberately excluded. + +## 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:** `config/pkl/renderers/codex-content.pkl`; a new `config/pkl/renderers/codex-metadata.pkl` (mirroring the existing `opencode-metadata.pkl`/`claude-metadata.pkl` convention); the `## Input` parameterization in `config/pkl/renderers/workflow-composite.pkl` / `config/pkl/base/workflow-content.pkl`; `config/pkl/renderers/metadata-coverage-check.pkl`; `config/pkl/renderers/generation-contract-check.pkl`; `config/pkl/generate.pkl` output wiring; `cli/build.rs` and `cli/src/services/setup/**`/`cli/src/services/doctor/**` only if verification in T02 finds a real asset-staging or reporting gap; the durable context files listed under Context sync. +- **Out of scope:** `cli/src/services/hooks/codex/**` (the `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`PostToolUse(apply_patch)` dispatcher and its evidence pipeline); `.codex/hooks.json`/hook-script generation; `sce-decision`'s own behavior, content, or invocation mechanism; any OpenCode/Claude/Pi command or agent generation change beyond what the shared catalog/renderer plumbing mechanically requires; a Codex slash-command (e.g. `/change-to-plan`) compatibility layer; `cli/migrations/**`; the Agent Trace schema or `apply_patch` normalization/parsing. +- **Constraints:** derive `interface.display_name`/`interface.short_description` from the existing typed `workflow-catalog.pkl` records rather than introducing a second workflow catalog; author the exact upstream-confirmed `agents/openai.yaml` schema (see Assumptions) rather than an invented shape; reuse the existing composite renderer's per-target `argumentsReference` parameterization for Codex's `## Input` divergence rather than forking `workflow-composite.pkl`'s control flow; leave `sce-decision`'s generation path untouched. +- **Non-goal:** a per-repository configurable allowlist of which workflows are explicit-only — the change request treats all six catalog workflows uniformly; Codex UI branding fields (`icon_small`, `icon_large`, `brand_color`) — optional upstream fields with no current asset source in this repository; `dependencies.tools` (MCP tool) declarations — none of these skills depend on an MCP tool. + +## Assumptions + +- Confirmed the current upstream `agents/openai.yaml` schema via `developers.openai.com/codex/skills` (redirects to `learn.chatgpt.com/docs/build-skills`) on 2026-08-24 rather than guessing: three top-level snake_case sections — `interface` (`display_name`, `short_description`, `icon_small`, `icon_large`, `brand_color`, `default_prompt`, all optional), `policy.allow_implicit_invocation` (boolean, default `true`; `false` excludes the skill from implicit/conversational activation while explicit `$skill-name` invocation and `/skills` discovery remain unaffected), and `dependencies.tools`. This plan authors only `interface.{display_name,short_description,default_prompt}` and `policy.allow_implicit_invocation`. +- `sce-decision` is excluded from this change: per `context/glossary.md`'s `decision skill package` entry and `context/architecture.md`, it has no user-facing command or prompt on any target and is invoked only by `/next-task`'s own task-synchronization gate — the change request's own "used internally as a helper capability" carve-out applies to it directly. +- `sce-brownfield` is treated as a full sixth explicit-only workflow (`display_name`/`short_description`/`default_prompt` plus `allow_implicit_invocation: false`), generated under the same existing optional-workflow install-time selection filter that already governs its other assets — no new filtering logic is needed since that filter already excludes or includes its whole skill subtree. +- `interface.default_prompt` has no existing canonical-catalog counterpart (unlike `display_name`/`short_description`, which reuse the catalog's `title`/`description` verbatim); each workflow's `default_prompt` is authored once, directly in the new Codex-metadata renderer module, as one short imperative sentence (for example `"Turn this change request into an SCE plan."` for `sce-change-to-plan`), since it is genuinely Codex-only UI convenience text with no cross-target equivalent to derive it from. + +## Task stack + +- [x] T01: `Add typed Codex skill-metadata model and agents/openai.yaml renderer` (status:complete) + - Task ID: T01 + - Scope: In — new `config/pkl/renderers/codex-metadata.pkl`: a typed record for Codex skill interface metadata, one authored value per catalog workflow (`display_name`/`short_description` derived from `workflow-catalog.pkl`'s `title`/`description`; `default_prompt` authored per workflow per the Assumptions above), and a pure render function producing `agents/openai.yaml` text in the confirmed upstream schema. Out — wiring into `codex-content.pkl`/`generate.pkl`; any `SKILL.md` body change; any check-file update. + - Dependencies: none + - Done when: the module evaluates standalone and, for each of the six catalog workflow slugs, produces YAML text containing `interface: display_name / short_description / default_prompt` and `policy: allow_implicit_invocation: false` in the confirmed nesting, with no other top-level keys. + - Verify: `nix develop -c pkl eval config/pkl/renderers/codex-metadata.pkl` (or an inline eval expression exercising each workflow's rendered output). + - Completed: 2026-08-24 + - Files changed: `config/pkl/renderers/codex-metadata.pkl` (new) + - Result: Added `CodexSkillMetadata` (`displayName`, `shortDescription`, `defaultPrompt`), an authored `defaultPrompts` mapping (one short imperative sentence per catalog workflow), `metadataByCommandSlug` built from `workflow-catalog.pkl`'s `title`/`description`, and a pure `render` function producing `agents/openai.yaml` text (`interface: {display_name, short_description, default_prompt}` / `policy: {allow_implicit_invocation: false}`, no other top-level keys) exposed via `renderedByCommandSlug` for all six catalog workflow slugs. Not wired into `codex-content.pkl`/`generate.pkl` (T02) or any check file (T02). + - Verify (actual): `nix develop -c pkl eval config/pkl/renderers/codex-metadata.pkl` — passed; printed `metadataByCommandSlug`/`renderedByCommandSlug` for `change-to-plan`, `next-task`, `validate`, `commit`, `handover`, `brownfield`, each rendered block containing exactly `interface` (`display_name`/`short_description`/`default_prompt`) and `policy.allow_implicit_invocation: false`. `nix run .#pkl-check-generated` — passed unchanged (`135 files`, same inventory hash as pre-task), confirming the new unwired module has no effect on existing generated output. + - Context impact: Localized. New standalone Pkl renderer module with no consumers yet (wiring is T02); no existing renderer, generated artifact, or root context file changed. No context synchronization required for this task. + - Context synchronization: synced + +- [x] T02: `Emit agents/openai.yaml for the six catalog workflows and verify Codex install/doctor pickup` (status:complete) + - Task ID: T02 + - Scope: In — extend `codex-content.pkl`'s exposed skill-document map to add `{slug}/agents/openai.yaml` for `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield` (not `sce-decision`); wire the addition through `config/pkl/generate.pkl` if the existing flattened-map consumption does not already pick it up; extend `metadata-coverage-check.pkl`'s exact-inventory assertions and `generation-contract-check.pkl` (bump `expectedArtifactPathCount`; assert every one of the six documents contains `policy: allow_implicit_invocation: false` plus catalog-derived `interface` values; assert `sce-decision` has none); verify in a scratch repository that `sce setup --codex --non-interactive` installs the new files (honoring `--workflow brownfield` selection) and that `sce doctor` reports them as healthy, adjusting `cli/build.rs` asset staging or `cli/src/services/doctor/**` only if that verification surfaces a real gap. Out — `## Input` prose changes (T03). + - Dependencies: T01 + - Done when: `nix run .#pkl-generate -- "$(mktemp -d)"` produces `.agents/skills/{slug}/agents/openai.yaml` for the six workflows and none for `sce-decision`; `nix run .#pkl-check-generated` passes with the updated contract; a scratch-repo `sce setup --codex --non-interactive` installs the new files and `sce doctor` reports no problem for them. + - Verify: `nix run .#pkl-check-generated`; manual scratch-repo `sce setup --codex --non-interactive` plus `sce doctor` run, with and without `--workflow brownfield`. + - Completed: 2026-08-24 + - Files changed: `config/pkl/renderers/codex-content.pkl`, `config/pkl/renderers/codex-metadata.pkl`, `config/pkl/renderers/generation-contract-check.pkl`, `config/pkl/renderers/metadata-coverage-check.pkl` + - Result: `codex-content.pkl` now imports `codex-metadata.pkl` and `workflow-catalog.pkl` and adds `{skillSlug}/agents/openai.yaml` (as `model.WorkflowDocument`) to its `skillDocuments` map for all six catalog workflows, keyed off `metadata.renderedByCommandSlug`; `generate.pkl` needed no change since it already consumes `codex.skillDocuments` generically. `metadata-coverage-check.pkl` gained a Codex-only `expectedCodexSkillDocumentPaths` (the shared inventory plus the six `agents/openai.yaml` paths) used solely for the `codex-skill-documents` exact-key assertion, so OpenCode/Claude/Pi's shared `expectedSkillDocumentPaths` stayed untouched. `generation-contract-check.pkl` bumped `expectedArtifactPathCount` from 135 to 141 and added `assertCodexSkillMetadataContract`, asserting each of the six generated `agents/openai.yaml` files contains `allow_implicit_invocation: false` and catalog-derived `display_name`/`short_description`/`default_prompt`, and that no such file exists under `sce-decision`. + - Verify (actual): `nix run .#pkl-check-generated` — passed, "Ephemeral Pkl generation passed: 141 files"; `nix run .#pkl-generate -- "$(mktemp -d)"` — inspected output, confirmed `agents/openai.yaml` present under all six workflow skill directories and absent under `sce-decision`; scratch-repo `sce setup --codex --hooks --non-interactive` without `--workflow brownfield` installed 31 files including the five non-brownfield `agents/openai.yaml` files (none for `sce-brownfield` or `sce-decision`), and with `--workflow brownfield` installed 34 files including all six; `sce doctor` reported Codex `Skills` as `[PASS]` in both scratch repos (only the pre-existing, unrelated Codex hook-trust `[WARN]` remained); manually tampering one installed `agents/openai.yaml` made `sce doctor` correctly report `[FAIL] Skills` with a per-file content mismatch on that exact path, confirming detection (no `cli/build.rs` or `cli/src/services/doctor/**` change was needed — both already handle Codex skill assets generically). `nix flake check` — passed ("all checks passed!"); the `cli-tests` check failed once on a pre-existing, order-dependent `agent_trace_export` test collision unrelated to this task's `.pkl`-only diff, and passed cleanly (592/592) on a clean rebuild and in isolation. + - Context impact: Localized to the Codex renderer/check layer. `context/architecture.md`, `context/context-map.md`, and `context/overview.md` describe the Codex renderer's generated-artifact set and count, which this task changed (135 → 141; new per-workflow `agents/openai.yaml`); `context/sce/codex-integration-runtime.md` documents Codex's `$sce-*`/`allow_implicit_invocation` convention, which this task made real by wiring the previously-unwired `codex-metadata.pkl` renderer. Both are listed under this plan's Context sync and remain pending until the synchronization phase updates them. + - Context synchronization: synced + +- [x] T03: `Add explicit $sce- invocation example to Codex's generated Input section` (status:complete) + - Task ID: T03 + - Scope: In — extend the existing target-specific `argumentsReference` parameterization in `workflow-composite.pkl`/`workflow-content.pkl` so Codex's rendered `## Input` section states a concrete `$sce-{slug}` example beside its existing "invocation input" prose, without introducing `$ARGUMENTS`; extend `generation-contract-check.pkl`'s existing no-`$ARGUMENTS`/target-neutral-reference assertions to cover the new line; confirm OpenCode/Claude/Pi command and skill bodies are unchanged. Out — any other `SKILL.md` section; any hook/runtime code. + - Dependencies: T01, T02 + - Done when: each generated `.agents/skills/{slug}/SKILL.md`'s `## Input` section names a `$sce-{slug}` example, contains no `$ARGUMENTS`, and OpenCode/Claude/Pi generated payloads are byte-identical to their pre-task output. + - Verify: `nix run .#pkl-check-generated`; direct diff of OpenCode/Claude/Pi generated payload before and after this task. + - Completed: 2026-08-24 + - 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: Added a shared `model.invocationExampleParagraph` helper (empty when its input is empty, otherwise a `For example: \`{example}\`.` paragraph) and threaded a second `invocationExample: String` parameter alongside every workflow's existing `argumentsReference: String` parameter — through `nextTaskSkillBody`/`validateSkillBody` (`workflow-content.pkl`), `changeToPlanSkillBody`, `commitSkillBody`, and handover's/brownfield's `renderSkillBody` (plus their dead-code "package" render call sites), then through `workflow-composite.pkl`'s `StructuredCompositeSource.compositeSkillBody`/`argumentDependentCommandBody` types, `renderCanonicalWorkflow`, `renderSkill`, and the `skillDocuments` function (whose new third parameter is `(String) -> String`, keyed by skill slug, so each workflow gets its own example). `codex-content.pkl` supplies `invocationExamplesBySkillSlug`, one authored `$sce-{slug} ...` command per catalog workflow (e.g. `$sce-next-task my-plan T03 approved`, `$sce-validate my-plan`), inserted into each `## Input` section immediately before `## Workflow`. `claude-content.pkl`/`opencode-content.pkl`/`pi-content.pkl` pass a no-op `(_) -> ""` for the new parameter, so their rendered `## Input` text is unaffected. Added `assertCodexSkillInvocationExamples` to `generation-contract-check.pkl`, registered as `codex-skill-invocation-examples`, asserting each of the six generated Codex `SKILL.md` files contains its own `` `$sce-{slug}` `` string. + - Verify (actual): `nix run .#pkl-generate -- "$(mktemp -d)"` — inspected each of the six generated `.agents/skills/{slug}/SKILL.md` `## Input` sections; each now ends with `For example: \`$sce-{slug} ...\`.` immediately before `## Workflow`, with no blank-line-run or duplicated-heading artifacts. `nix run .#pkl-check-generated` — passed, "Ephemeral Pkl generation passed: 141 files" (same count and inventory hash as pre-task), confirming the new/updated contract checks (including the new `codex-skill-invocation-examples` assertion) all pass. Direct `diff -rq` of the generated `config/.claude`, `config/.opencode`, and `config/.pi` trees before and after this task's changes — no differences, confirming OpenCode/Claude/Pi payloads stayed byte-identical. Grepped generated Codex SKILL.md files for `$ARGUMENTS` — none present (unchanged from the existing `codex-skills-exclude-arguments` guarantee). + - Context impact: Localized to the Codex renderer/check layer, same class of change as T02. This plan's Context sync entries (`context/architecture.md`, `context/context-map.md`, `context/overview.md`, `context/sce/codex-integration-runtime.md`) describe the `$sce-*` explicit-invocation convention and generated-artifact set; this task adds the concrete per-workflow invocation example to that convention's generated output without changing the artifact count or the `allow_implicit_invocation` mechanism those files already describe. Remains pending until the synchronization phase updates them. + - Context synchronization: synced + +## Open questions + +None. The change request specifies the exact policy behavior and the one genuinely unresolved technical detail — the upstream `agents/openai.yaml` schema — was confirmed against the current OpenAI Codex documentation before writing this plan rather than guessed at (see Assumptions). The remaining choice this plan makes on the user's behalf, the per-workflow `default_prompt` wording, is a reversible content detail recorded under Assumptions rather than a scope, criteria, or ordering question. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-24 + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 141 files, inventory sha256 b90b604be32d61a4dc774fd3ded5518e6d3949ad35cbce52d778e4b4d2deea7e) +- `nix flake check` -> exit 0 (all checks passed, including `cli-tests`, `cli-clippy`, `cli-fmt`, `pkl-generated`, `codex-hook-command`) +- `nix run .#pkl-generate -- "$(mktemp -d)"` -> exit 0 (generated tree inspected for AC1/AC2/AC3 evidence) +- `nix build .#sce` -> exit 0 (built CLI binary for AC4 scratch-repo verification) +- `sce setup --codex --non-interactive` (scratch repo, no `--workflow brownfield`) -> exit 0 (installed 31 files; 5 `agents/openai.yaml`, none for `sce-brownfield`/`sce-decision`) +- `sce setup --codex --workflow brownfield --non-interactive` (scratch repo) -> exit 0 (installed 34 files; all 6 `agents/openai.yaml` present) +- `sce doctor` (both scratch repos) -> exit 0 (Codex `Skills` group `[PASS]` in both; unrelated pre-existing `Hooks` trust `[WARN]` and missing-git-hooks `[FAIL]` from omitting `--hooks`, out of this plan's scope) +- `diff -rq` of `.claude`/`.opencode`/`.pi` generated trees, pre-plan baseline commit `0d023586` vs. current working tree -> no differences (AC6) + +### Success-criteria verification + +- [x] AC1: Six catalog workflows generate `agents/openai.yaml` with `policy.allow_implicit_invocation: false` and catalog-derived `interface` -> inspected all six generated files; each contains exactly `interface.{display_name,short_description,default_prompt}` and `policy.allow_implicit_invocation: false`. +- [x] AC2: `sce-decision` has no `agents/openai.yaml` -> inspected `.agents/skills/sce-decision/`; contains only `SKILL.md` and `references/adr-template.md`. +- [x] AC3: Every generated Codex `SKILL.md`'s `## Input` ends with a `$sce-{slug}` example; no `$ARGUMENTS` anywhere -> inspected all seven skill bodies (six catalog workflows plus `sce-decision`, which correctly has no example); `grep -rl '\$ARGUMENTS'` over generated Codex skills returned nothing. +- [x] AC4: `sce setup --codex --non-interactive` installs `agents/openai.yaml` honoring the brownfield selection; `sce doctor` reports Codex `Skills` healthy -> verified in two scratch repos (with and without `--workflow brownfield`); file counts (31 and 34) and `Skills` `[PASS]` confirmed in both. +- [x] AC5: No new `.codex/` path; hook/dispatcher/Bash-policy/`apply_patch` machinery unchanged -> `git diff --stat -- .codex/` empty; `nix flake check` passed including `codex-hook-command`. +- [x] AC6: OpenCode/Claude/Pi generated output unchanged -> `diff -rq` of freshly generated `.claude`/`.opencode`/`.pi` trees against a worktree built from the pre-plan baseline commit `0d023586` showed zero differences. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 57683d7d0..5ef76c8af 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 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 @@ -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,6 +203,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` 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 `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-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 0ca47704a..9ef3a903e 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`, 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/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 7ce009ec5..0e4baa7f6 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,6 +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, 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/bash-tool-policy-enforcement-contract.md b/context/sce/bash-tool-policy-enforcement-contract.md index 4ada734be..3a96b06d1 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 new file mode 100644 index 000000000..819a097a1 --- /dev/null +++ b/context/sce/codex-integration-runtime.md @@ -0,0 +1,274 @@ +# 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. + +## 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). + +## 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 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 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). + +An executable SCE project hook requires a third, independent dimension +beyond structure and trust: Codex's effective hook-discovery *policy*. +Current upstream Codex (`hooks/src/engine/discovery.rs` +`HookDiscoveryPolicy::allows`: `!allow_managed_hooks_only || source.is_managed`) +discards every non-managed hook source — including SCE's project +`.codex/hooks.json` registrations (`HookSource::Project`, non-managed) — when +the effective, admin-controlled `allow_managed_hooks_only` requirement is +`true`. That requirement lives only in `requirements.toml`/managed +configuration layers (never plain `config.toml`) and is composed from +multiple possible sources (system `requirements.toml`, legacy managed +config, MDM managed preferences, backend-delivered enterprise policy), so SCE +cannot safely re-derive it by reading any single file. `cli/src/services/codex_hook_policy.rs` +instead asks the installed `codex` binary for its own composed answer over +`codex app-server --stdio`'s read-only `configRequirements/read` method, +bounded by a strict timeout with the child process always terminated and +reaped. Doctor probes this exactly once per invocation and reuses the result +for all four registrations. A structurally current registration is only +`Match`/healthy when policy allows project hooks *and* it is durably trusted; +`allow_managed_hooks_only = true` reports it `PolicyBlocked` (an +Error-severity, manual-only problem) even when fully trusted, and a probe +failure reports `PolicyUnknown` (Warning-severity, manual-only) rather than +ever defaulting to healthy. `sce doctor --fix` cannot change Codex's +managed/enterprise policy and never attempts to. + +## Dispatch skeleton + +- 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` + 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 with empty stdout. +- Malformed/non-JSON STDIN is logged through `sce.hooks.codex.error` and the + 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` trims a Codex model ID, returns `None` for blank + 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 + +`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" 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`; 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`. +- `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 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. +- 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 + +`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 + (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 +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`; `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 `<` 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()`; 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. + +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`, +`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 + +- `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), +[agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md), +[pi-extension-runtime.md](pi-extension-runtime.md) diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index b2737ba86..c47874ad5 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -73,19 +73,51 @@ 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/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 Codex's effective +hook-discovery *policy* before trust is ever consulted. Doctor probes the +installed `codex` binary's own composed `allow_managed_hooks_only` +requirement once per invocation (`codex app-server --stdio`'s read-only +`configRequirements/read`, bounded by a strict timeout) and reuses that one +result for all four registrations: `[FAIL]` when the effective policy +excludes project hooks (`allow_managed_hooks_only = true` — this is an +Error-severity, administrative-only problem, since SCE cannot change Codex's +managed/enterprise policy), `[WARN]` when the policy could not be determined +(no `codex` executable, probe failure, timeout, malformed response). Only +once policy allows project hooks is the registration 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 both Codex hook trust and Codex hook-discovery policy +are outside anything `sce doctor --fix` can perform and it never attempts +either. 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 @@ -103,9 +135,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-githooks-cli-ux.md b/context/sce/setup-githooks-cli-ux.md index 836fadd37..4350f1fff 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 b8c40c24a..8b3eba0b3 100644 --- a/context/sce/setup-no-backup-policy-seam.md +++ b/context/sce/setup-no-backup-policy-seam.md @@ -4,25 +4,26 @@ ## 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. - 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 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 -- 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) diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index d6b1270b9..177c0ac10 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`. diff --git a/flake.nix b/flake.nix index ef47c62c6..af4c735c2 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/nix/flatpak/cargo-sources.nix b/nix/flatpak/cargo-sources.nix index 9347e2fdd..60884987c 100644 --- a/nix/flatpak/cargo-sources.nix +++ b/nix/flatpak/cargo-sources.nix @@ -33,7 +33,7 @@ let outputHashMode = "flat"; outputHashAlgo = "sha256"; - outputHash = "sha256-NGzWSCuHntwcdSl6qwSZYzazEbFspqs5c96xjE47AmA="; + outputHash = "sha256-gFfKqW8lDLnJW8K7281zdOrCQztWy4YbzICQfxGWZ9w="; }; regenerateApp = pkgs.writeShellApplication { diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index ad7ab552b..fc4ea3648 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -4536,6 +4536,19 @@ "dest": "cargo/vendor/serde_repr-0.1.20", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.1.crate", + "sha256": "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26", + "dest": "cargo/vendor/serde_spanned-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26\", \"files\": {}}", + "dest": "cargo/vendor/serde_spanned-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -5238,6 +5251,32 @@ "dest": "cargo/vendor/tokio-util-0.7.18", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml/toml-0.9.12+spec-1.1.0.crate", + "sha256": "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863", + "dest": "cargo/vendor/toml-0.9.12+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863\", \"files\": {}}", + "dest": "cargo/vendor/toml-0.9.12+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_datetime/toml_datetime-0.7.5+spec-1.1.0.crate", + "sha256": "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347", + "dest": "cargo/vendor/toml_datetime-0.7.5+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347\", \"files\": {}}", + "dest": "cargo/vendor/toml_datetime-0.7.5+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -5277,6 +5316,19 @@ "dest": "cargo/vendor/toml_parser-1.1.2+spec-1.1.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.2+spec-1.1.0.crate", + "sha256": "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2", + "dest": "cargo/vendor/toml_writer-1.1.2+spec-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2\", \"files\": {}}", + "dest": "cargo/vendor/toml_writer-1.1.2+spec-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -6460,6 +6512,19 @@ "dest": "cargo/vendor/windows_x86_64_msvc-0.53.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winnow/winnow-0.7.15.crate", + "sha256": "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945", + "dest": "cargo/vendor/winnow-0.7.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945\", \"files\": {}}", + "dest": "cargo/vendor/winnow-0.7.15", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", diff --git a/scripts/test-codex-hook-command.sh b/scripts/test-codex-hook-command.sh new file mode 100755 index 000000000..513a9e4cc --- /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'