Compare commits

...

2 Commits

Author SHA1 Message Date
Felipe Coury
f4016e6829 feat(review-story): generate stories progressively 2026-05-24 13:48:34 -03:00
Felipe Coury
5e8a5b2c7c feat(review-story): add model-backed review story 2026-05-23 18:05:09 -03:00
59 changed files with 7146 additions and 5 deletions

280
CONTEXT.md Normal file
View File

@@ -0,0 +1,280 @@
(eval):5: parse error near `end'
# Codex Review Experience
Language for features that help people understand, navigate, and evaluate code changes in Codex.
## Language
**Review Story**:
A structured explanation of a change that organizes the diff into a small number of ordered, cohesive steps for reviewer understanding. A **Review Story** is a navigation and explanation artifact, not a finding engine or correctness verdict.
_Avoid_: Review module, PR story, change story
**Story Source**:
The change set that a **Review Story** explains. A **Story Source** may be a branch comparison, uncommitted changes, or a single commit; it is not limited to a hosted pull request.
_Avoid_: Pull request, GitHub PR
**Concrete Story Source**:
A **Story Source** that resolves to a deterministic diff and **Source Fingerprint**, such as a branch comparison, uncommitted changes, or a single commit. V1 **Review Stories** require a **Concrete Story Source**.
_Avoid_: Custom instructions
**Source Fingerprint**:
A deterministic identity for a **Story Source** used to decide whether a **Story Snapshot** still matches the underlying change. The fingerprint is derived from resolved refs, SHAs, and diff content rather than model output.
_Avoid_: Cache key, model context hash
**Stale Story**:
A **Story Snapshot** whose saved **Source Fingerprint** no longer matches the current **Story Source**. A **Stale Story** remains readable, but reviewers should refresh it before relying on its anchors or ordering.
_Avoid_: Invalid story, expired cache
**Story Step**:
One ordered unit inside a **Review Story** that explains a cohesive part of the change. A **Story Step** has a goal, a summary, and references to the specific changed files or ranges that support that explanation.
_Avoid_: Cohort, layer, phase
**Step Goal**:
The reason a **Story Step** exists in the **Review Story**. A **Step Goal** explains the role of the step in the change, while the step summary explains what changed.
_Avoid_: Step title, implementation detail
**Review Focus**:
Non-verdict guidance on what a reviewer should pay attention to while reading a **Story Step**. **Review Focus** can highlight assumptions, dependencies, or areas worth checking, but it is not a finding.
_Avoid_: Finding, issue, warning
**Change Anchor**:
An exact reference from a **Story Step** to evidence in the **Story Source**, such as a changed file, hunk, or line range. A **Change Anchor** must point to an actual changed region, so the reviewer can verify the story against the diff.
_Avoid_: File mention, related file
**Anchor Id**:
A stable identifier assigned by Codex to a **Change Anchor** before model story generation begins. Models choose from **Anchor Ids** instead of inventing file paths or line ranges.
_Avoid_: Model anchor, generated location
**Evidence Graph**:
The system-derived structure of the **Story Source**, including changed files, hunks, commits, renames, and cheap dependency signals. The **Evidence Graph** constrains the model-authored **Review Story** but is not itself the story shown to reviewers.
_Avoid_: Dependency map
**Evidence Signal**:
A cheap, local fact included in the **Evidence Graph**, such as commit order, changed file status, hunk ranges, rename metadata, path relationships, or obvious test/source pairing. **Evidence Signals** are intentionally lighter than full semantic indexing.
_Avoid_: Semantic index, full dependency graph
**Story Review**:
A review-adjacent workflow that generates a **Review Story** for understanding and navigation before findings are produced. A **Story Review** is distinct from the findings-oriented review workflow.
_Avoid_: Review mode, code review
**Story Snapshot**:
A persisted version of a **Review Story** tied to the identity of its **Story Source**. A **Story Snapshot** gives reviewers stable step numbers and lets Codex detect when the story is stale because the underlying change has moved.
_Avoid_: Cached story, generated output
**Snapshot Lineage**:
The relationship between a refreshed **Story Snapshot** and the older snapshot it replaces for the same **Story Source**. **Snapshot Lineage** preserves what reviewers saw before refresh while letting surfaces default to the newest story.
_Avoid_: In-place refresh
**Snapshot Status**:
The lifecycle state of a **Story Snapshot**, such as outline generation, outline ready, enrichment, completion, failure, or staleness. **Snapshot Status** lets **Story Surfaces** show progress without guessing from individual fields.
_Avoid_: Generation state
**Snapshot Update**:
A full replacement notification for the latest version of a **Story Snapshot**. V1 **Story Surfaces** apply **Snapshot Updates** by replacing their local snapshot instead of merging granular patches.
_Avoid_: Step patch, delta event
**Story Store**:
The structured persistence location for **Story Snapshots**, keyed by thread and **Story Source**. The **Story Store** holds the evolving snapshot data while thread history records lifecycle events that point to snapshot ids.
_Avoid_: Thread history, transcript
**Story Database**:
The SQLite database managed by the shared state layer that backs the **Story Store**. The **Story Database** is separate from thread metadata storage so story-specific migrations, cleanup, and larger structured payloads remain isolated.
_Avoid_: Thread database, metadata database
**Story Record**:
The persisted database representation of a **Story Snapshot**. A **Story Record** stores the canonical snapshot as structured JSON plus indexed fields for lookup, status, timestamps, source identity, and step readiness.
_Avoid_: Normalized story schema, raw blob
**Progressive Story Snapshot**:
A **Story Snapshot** that becomes useful before every **Story Step** is fully enriched. A **Progressive Story Snapshot** first exposes a validated outline, then updates individual steps as their descriptions and rationale become ready.
_Avoid_: Streaming story, partial JSON
**Step Enrichment**:
The background work that fills in detailed descriptions, rationale, and summaries for **Story Steps** after the validated outline exists. **Step Enrichment** may run in small batches so reviewers can begin navigating before every step is complete.
_Avoid_: Step generation, eager loading
**Story Schema**:
The strict structured output contract used by model calls that create a **Review Story**. A **Story Schema** lets Codex validate step order, **Anchor Ids**, and enriched fields before updating a **Story Snapshot**.
_Avoid_: Markdown format, prose contract
**Enrichment Context**:
The bounded source context given to **Step Enrichment**, including the selected **Change Anchors**, nearby file context, and cheap dependency neighbors from the **Evidence Graph**. **Enrichment Context** supports explanation but does not replace the anchored evidence.
_Avoid_: Full repo context, diff-only context
**Step Readiness**:
The per-step state that tells a **Story Surface** whether a **Story Step** has only outline data or also has completed **Step Enrichment**. **Step Readiness** lets surfaces render useful pending states without assuming steps finish in story order.
_Avoid_: Loading flag
**Partial Story**:
A **Story Snapshot** whose outline is usable even though one or more **Story Steps** have not completed **Step Enrichment**. A **Partial Story** may include failed steps, but it still preserves navigation through validated anchors.
_Avoid_: Failed story
**Story Surface**:
A product surface that presents a **Story Snapshot** to a reviewer. The TUI and App UI are separate **Story Surfaces** that should read the same underlying **Review Story** data.
_Avoid_: UI implementation, frontend
**Story Overlay**:
The TUI **Story Surface** for navigating a **Story Snapshot**. The **Story Overlay** presents ordered steps, anchored diffs, and step explanation outside the normal transcript flow.
_Avoid_: Markdown story, transcript summary
**Read-Only Story Overlay**:
The v1 **Story Overlay** mode that lets reviewers navigate, inspect, copy, and refresh **Story Snapshots** without leaving comments or submitting hosted reviews.
_Avoid_: Review submission, comment mode
**/story**:
The TUI slash command that starts or opens a **Story Review**. The existing review picker should also expose **Review Story** creation for discoverability.
_Avoid_: /review-story
**Story API**:
The app-server v2 contract used by **Story Surfaces** to create, read, and refresh **Story Snapshots**. The **Story API** is the shared product boundary for the TUI and App UI.
_Avoid_: TUI API, local story service
**reviewStory**:
The app-server v2 API namespace for **Story API** methods. **reviewStory** is separate from the findings-oriented `review` namespace.
_Avoid_: story, review
**Story Turn**:
A thread-scoped model run that generates or refreshes a **Story Snapshot** from a **Story Source**. A **Story Turn** uses Codex's normal execution lifecycle while producing a persisted story artifact.
_Avoid_: Background job, standalone task
## Example Dialogue
Dev: "Can the reviewer find bugs from the Review Story?"
Domain expert: "Findings may later attach to Review Story steps, but the Review Story itself exists to explain the change in a useful review order."
Dev: "Is every Review Story about a GitHub pull request?"
Domain expert: "No. A hosted pull request is one possible Story Source, but local branch comparisons and commits should use the same language."
Dev: "Can arbitrary custom review instructions create a Review Story in v1?"
Domain expert: "No. V1 requires a Concrete Story Source so every Story Step can be anchored to a deterministic diff."
Dev: "How does Codex know a Story Snapshot is stale?"
Domain expert: "Codex compares the current Story Source to the Source Fingerprint saved with the Story Snapshot."
Dev: "Should Codex automatically rewrite a stale story?"
Domain expert: "No. V1 should mark a Stale Story visibly and let the reviewer choose when to refresh it."
Dev: "Should the story have cohorts and layers?"
Domain expert: "Not initially. A Review Story is a single ordered list of Story Steps unless we later introduce a separate concept for independent sub-stories."
Dev: "What is the difference between a Story Step goal and summary?"
Domain expert: "The Step Goal explains why this step belongs in the story; the summary explains what changed in the anchored code."
Dev: "Can a Story Step tell the reviewer what to inspect?"
Domain expert: "Yes, as Review Focus. It should guide attention without claiming the code is wrong."
Dev: "Can a Story Step just mention the files it talks about?"
Domain expert: "No. Each Story Step should carry Change Anchors to the changed ranges that support the explanation."
Dev: "Can the model write paths and line numbers for step anchors?"
Domain expert: "No. Codex assigns Anchor Ids first, and the model may only reference those ids."
Dev: "Does the model decide what changed?"
Domain expert: "The model explains and orders the change, but the Evidence Graph defines the changed evidence it is allowed to reference."
Dev: "Does v1 need a full semantic dependency graph?"
Domain expert: "No. V1 should use cheap local Evidence Signals and leave deeper indexing for later."
Dev: "Should the Review Story be part of the bug-finding review?"
Domain expert: "No. A Story Review helps the reviewer understand the change; a code review looks for issues."
Dev: "Can the story be regenerated whenever the reviewer opens it?"
Domain expert: "No. A Review Story should be saved as a Story Snapshot so reviewers can rely on stable steps and know when the source changed."
Dev: "Does refreshing a stale story overwrite the old snapshot?"
Domain expert: "No. Refresh creates a new Story Snapshot linked to the previous one through Snapshot Lineage."
Dev: "How does a Story Surface know whether the story is ready?"
Domain expert: "It reads the Snapshot Status and Step Readiness rather than inferring readiness from missing prose."
Dev: "Should progressive story updates be sent as small patches?"
Domain expert: "Not in v1. Story Surfaces should receive Snapshot Updates and replace their local snapshot with the newest version."
Dev: "Should the whole Story Snapshot live inside chat history?"
Domain expert: "No. Thread history should record story lifecycle events, while the Story Store holds structured snapshot data."
Dev: "Should Story Snapshots be stored in the thread metadata database?"
Domain expert: "No. Story Snapshots belong in a separate Story Database managed by the state layer."
Dev: "Should the Story Database fully normalize every step and anchor?"
Domain expert: "Not in v1. A Story Record should keep a canonical snapshot JSON while indexing the fields needed for lookup and progress."
Dev: "Does the reviewer have to wait until every step is fully written?"
Domain expert: "No. A Progressive Story Snapshot can expose a validated outline first, then fill in step details while the reviewer navigates."
Dev: "Should Codex enrich every Story Step in a separate model call?"
Domain expert: "Not by default. Step Enrichment should use small batches with bounded parallelism so navigation feels fast without creating excessive model work."
Dev: "Can story generation return Markdown?"
Domain expert: "No. Story generation should return data that matches the Story Schema so Codex can validate and render it across surfaces."
Dev: "Can Step Enrichment inspect context outside the changed ranges?"
Domain expert: "Yes, but only as Enrichment Context. The Story Step still has to explain the anchored changed evidence."
Dev: "Should navigating to a step reprioritize enrichment in v1?"
Domain expert: "Not initially. V1 should enrich the first step, nearby steps, and then the remaining steps in order, while Step Readiness keeps the UI ready for out-of-order completion later."
Dev: "If one Step Enrichment batch fails, is the whole story failed?"
Domain expert: "No. Once the outline exists, Codex should keep a Partial Story usable and mark only the affected steps as failed."
Dev: "Should Review Stories include diagrams in v1?"
Domain expert: "No. V1 should focus on trustworthy step ordering, anchors, progressive readiness, and diff navigation."
Dev: "Can a Review Story include code-review findings?"
Domain expert: "No. Findings belong to the findings-oriented review workflow, though a future surface may attach findings to Story Steps."
Dev: "Will the TUI story and App UI story be different concepts?"
Domain expert: "No. They are different Story Surfaces over the same Review Story data."
Dev: "Should the TUI print the Review Story as a normal assistant message?"
Domain expert: "No. The TUI should use a Story Overlay so reviewers can move between steps and anchored diffs."
Dev: "Can reviewers submit comments from the Story Overlay in v1?"
Domain expert: "No. V1 is a Read-Only Story Overlay; comments can attach to Change Anchors in a later version."
Dev: "How does a TUI user start a Story Review?"
Domain expert: "Use /story directly, or choose the Review Story option from the review picker."
Dev: "Can the TUI define its own story shape first?"
Domain expert: "No. The Story API defines the shared shape, even if the TUI is the first Story Surface to render it."
Dev: "Should story APIs live under review/start?"
Domain expert: "No. Story APIs use the reviewStory namespace because they support review understanding, not findings review."
Dev: "Is story generation a separate background job?"
Domain expert: "No. Story generation should be a Story Turn so progress, cancellation, and replay follow the rest of Codex."

View File

@@ -2671,6 +2671,61 @@
],
"type": "object"
},
"ReviewStoryListParams": {
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ReviewStoryReadParams": {
"properties": {
"storySnapshotId": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"storySnapshotId",
"threadId"
],
"type": "object"
},
"ReviewStoryStartParams": {
"properties": {
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
}
},
"required": [
"target",
"threadId"
],
"type": "object"
},
"ReviewTarget": {
"oneOf": [
{
@@ -5425,6 +5480,78 @@
"title": "Review/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"reviewStory/start"
],
"title": "ReviewStory/startRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStoryStartParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"reviewStory/read"
],
"title": "ReviewStory/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStoryReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"reviewStory/list"
],
"title": "ReviewStory/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStoryListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/listRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -2946,6 +2946,294 @@
},
"type": "object"
},
"ReviewStoryAnchor": {
"properties": {
"anchorId": {
"type": "string"
},
"changeKind": {
"$ref": "#/definitions/ReviewStoryAnchorKind"
},
"diff": {
"type": "string"
},
"filePath": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": [
"anchorId",
"changeKind",
"diff",
"filePath",
"summary"
],
"type": "object"
},
"ReviewStoryAnchorKind": {
"enum": [
"added",
"modified",
"deleted",
"renamed",
"copied",
"unknown"
],
"type": "string"
},
"ReviewStorySnapshot": {
"properties": {
"anchors": {
"items": {
"$ref": "#/definitions/ReviewStoryAnchor"
},
"type": "array"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"overview": {
"type": "string"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"stale": {
"type": "boolean"
},
"status": {
"$ref": "#/definitions/ReviewStorySnapshotStatus"
},
"steps": {
"items": {
"$ref": "#/definitions/ReviewStoryStep"
},
"type": "array"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"anchors",
"createdAt",
"overview",
"sourceFingerprint",
"stale",
"status",
"steps",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotStatus": {
"enum": [
"building",
"ready",
"partial",
"failed"
],
"type": "string"
},
"ReviewStorySnapshotUpdatedNotification": {
"properties": {
"snapshot": {
"$ref": "#/definitions/ReviewStorySnapshot"
},
"threadId": {
"type": "string"
}
},
"required": [
"snapshot",
"threadId"
],
"type": "object"
},
"ReviewStoryStep": {
"properties": {
"anchorIds": {
"items": {
"type": "string"
},
"type": "array"
},
"dependencyRationale": {
"type": "string"
},
"error": {
"type": [
"string",
"null"
]
},
"goal": {
"type": "string"
},
"index": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"readiness": {
"$ref": "#/definitions/ReviewStoryStepReadiness"
},
"reviewFocus": {
"items": {
"type": "string"
},
"type": "array"
},
"stepId": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"anchorIds",
"dependencyRationale",
"goal",
"index",
"readiness",
"reviewFocus",
"stepId",
"summary",
"title"
],
"type": "object"
},
"ReviewStoryStepReadiness": {
"enum": [
"outline",
"enriching",
"ready",
"failed"
],
"type": "string"
},
"ReviewTarget": {
"oneOf": [
{
"description": "Review the working tree: staged, unstaged, and untracked files.",
"properties": {
"type": {
"enum": [
"uncommittedChanges"
],
"title": "UncommittedChangesReviewTargetType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UncommittedChangesReviewTarget",
"type": "object"
},
{
"description": "Review changes between the current branch and the given base branch.",
"properties": {
"branch": {
"type": "string"
},
"type": {
"enum": [
"baseBranch"
],
"title": "BaseBranchReviewTargetType",
"type": "string"
}
},
"required": [
"branch",
"type"
],
"title": "BaseBranchReviewTarget",
"type": "object"
},
{
"description": "Review the changes introduced by a specific commit.",
"properties": {
"sha": {
"type": "string"
},
"title": {
"description": "Optional human-readable label (e.g., commit subject) for UIs.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"commit"
],
"title": "CommitReviewTargetType",
"type": "string"
}
},
"required": [
"sha",
"type"
],
"title": "CommitReviewTarget",
"type": "object"
},
{
"description": "Arbitrary instructions, equivalent to the old free-form prompt.",
"properties": {
"instructions": {
"type": "string"
},
"type": {
"enum": [
"custom"
],
"title": "CustomReviewTargetType",
"type": "string"
}
},
"required": [
"instructions",
"type"
],
"title": "CustomReviewTarget",
"type": "object"
}
]
},
"SandboxPolicy": {
"oneOf": [
{
@@ -5557,6 +5845,26 @@
"title": "Turn/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"reviewStory/snapshot/updated"
],
"title": "ReviewStory/snapshot/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStorySnapshotUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "ReviewStory/snapshot/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {

View File

@@ -1429,6 +1429,78 @@
"title": "Review/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"reviewStory/start"
],
"title": "ReviewStory/startRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ReviewStoryStartParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"reviewStory/read"
],
"title": "ReviewStory/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ReviewStoryReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"reviewStory/list"
],
"title": "ReviewStory/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ReviewStoryListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4227,6 +4299,26 @@
"title": "Turn/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"reviewStory/snapshot/updated"
],
"title": "ReviewStory/snapshot/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ReviewStorySnapshotUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "ReviewStory/snapshot/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -14411,6 +14503,379 @@
"title": "ReviewStartResponse",
"type": "object"
},
"ReviewStoryAnchor": {
"properties": {
"anchorId": {
"type": "string"
},
"changeKind": {
"$ref": "#/definitions/v2/ReviewStoryAnchorKind"
},
"diff": {
"type": "string"
},
"filePath": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": [
"anchorId",
"changeKind",
"diff",
"filePath",
"summary"
],
"type": "object"
},
"ReviewStoryAnchorKind": {
"enum": [
"added",
"modified",
"deleted",
"renamed",
"copied",
"unknown"
],
"type": "string"
},
"ReviewStoryListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ReviewStoryListParams",
"type": "object"
},
"ReviewStoryListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/ReviewStorySnapshotSummary"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ReviewStoryListResponse",
"type": "object"
},
"ReviewStoryReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"storySnapshotId": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"storySnapshotId",
"threadId"
],
"title": "ReviewStoryReadParams",
"type": "object"
},
"ReviewStoryReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"snapshot": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReviewStorySnapshot"
},
{
"type": "null"
}
]
}
},
"title": "ReviewStoryReadResponse",
"type": "object"
},
"ReviewStorySnapshot": {
"properties": {
"anchors": {
"items": {
"$ref": "#/definitions/v2/ReviewStoryAnchor"
},
"type": "array"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"overview": {
"type": "string"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"stale": {
"type": "boolean"
},
"status": {
"$ref": "#/definitions/v2/ReviewStorySnapshotStatus"
},
"steps": {
"items": {
"$ref": "#/definitions/v2/ReviewStoryStep"
},
"type": "array"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/v2/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"anchors",
"createdAt",
"overview",
"sourceFingerprint",
"stale",
"status",
"steps",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotStatus": {
"enum": [
"building",
"ready",
"partial",
"failed"
],
"type": "string"
},
"ReviewStorySnapshotSummary": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"status": {
"$ref": "#/definitions/v2/ReviewStorySnapshotStatus"
},
"stepCount": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/v2/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"sourceFingerprint",
"status",
"stepCount",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"snapshot": {
"$ref": "#/definitions/v2/ReviewStorySnapshot"
},
"threadId": {
"type": "string"
}
},
"required": [
"snapshot",
"threadId"
],
"title": "ReviewStorySnapshotUpdatedNotification",
"type": "object"
},
"ReviewStoryStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"target": {
"$ref": "#/definitions/v2/ReviewTarget"
},
"threadId": {
"type": "string"
}
},
"required": [
"target",
"threadId"
],
"title": "ReviewStoryStartParams",
"type": "object"
},
"ReviewStoryStartResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"snapshot": {
"$ref": "#/definitions/v2/ReviewStorySnapshot"
},
"storySnapshotId": {
"type": "string"
},
"turn": {
"$ref": "#/definitions/v2/Turn"
}
},
"required": [
"snapshot",
"storySnapshotId",
"turn"
],
"title": "ReviewStoryStartResponse",
"type": "object"
},
"ReviewStoryStep": {
"properties": {
"anchorIds": {
"items": {
"type": "string"
},
"type": "array"
},
"dependencyRationale": {
"type": "string"
},
"error": {
"type": [
"string",
"null"
]
},
"goal": {
"type": "string"
},
"index": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"readiness": {
"$ref": "#/definitions/v2/ReviewStoryStepReadiness"
},
"reviewFocus": {
"items": {
"type": "string"
},
"type": "array"
},
"stepId": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"anchorIds",
"dependencyRationale",
"goal",
"index",
"readiness",
"reviewFocus",
"stepId",
"summary",
"title"
],
"type": "object"
},
"ReviewStoryStepReadiness": {
"enum": [
"outline",
"enriching",
"ready",
"failed"
],
"type": "string"
},
"ReviewTarget": {
"oneOf": [
{

View File

@@ -2155,6 +2155,78 @@
"title": "Review/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"reviewStory/start"
],
"title": "ReviewStory/startRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStoryStartParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"reviewStory/read"
],
"title": "ReviewStory/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStoryReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"reviewStory/list"
],
"title": "ReviewStory/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStoryListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ReviewStory/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -10940,6 +11012,379 @@
"title": "ReviewStartResponse",
"type": "object"
},
"ReviewStoryAnchor": {
"properties": {
"anchorId": {
"type": "string"
},
"changeKind": {
"$ref": "#/definitions/ReviewStoryAnchorKind"
},
"diff": {
"type": "string"
},
"filePath": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": [
"anchorId",
"changeKind",
"diff",
"filePath",
"summary"
],
"type": "object"
},
"ReviewStoryAnchorKind": {
"enum": [
"added",
"modified",
"deleted",
"renamed",
"copied",
"unknown"
],
"type": "string"
},
"ReviewStoryListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ReviewStoryListParams",
"type": "object"
},
"ReviewStoryListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ReviewStorySnapshotSummary"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ReviewStoryListResponse",
"type": "object"
},
"ReviewStoryReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"storySnapshotId": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"storySnapshotId",
"threadId"
],
"title": "ReviewStoryReadParams",
"type": "object"
},
"ReviewStoryReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"snapshot": {
"anyOf": [
{
"$ref": "#/definitions/ReviewStorySnapshot"
},
{
"type": "null"
}
]
}
},
"title": "ReviewStoryReadResponse",
"type": "object"
},
"ReviewStorySnapshot": {
"properties": {
"anchors": {
"items": {
"$ref": "#/definitions/ReviewStoryAnchor"
},
"type": "array"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"overview": {
"type": "string"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"stale": {
"type": "boolean"
},
"status": {
"$ref": "#/definitions/ReviewStorySnapshotStatus"
},
"steps": {
"items": {
"$ref": "#/definitions/ReviewStoryStep"
},
"type": "array"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"anchors",
"createdAt",
"overview",
"sourceFingerprint",
"stale",
"status",
"steps",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotStatus": {
"enum": [
"building",
"ready",
"partial",
"failed"
],
"type": "string"
},
"ReviewStorySnapshotSummary": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"status": {
"$ref": "#/definitions/ReviewStorySnapshotStatus"
},
"stepCount": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"sourceFingerprint",
"status",
"stepCount",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"snapshot": {
"$ref": "#/definitions/ReviewStorySnapshot"
},
"threadId": {
"type": "string"
}
},
"required": [
"snapshot",
"threadId"
],
"title": "ReviewStorySnapshotUpdatedNotification",
"type": "object"
},
"ReviewStoryStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
}
},
"required": [
"target",
"threadId"
],
"title": "ReviewStoryStartParams",
"type": "object"
},
"ReviewStoryStartResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"snapshot": {
"$ref": "#/definitions/ReviewStorySnapshot"
},
"storySnapshotId": {
"type": "string"
},
"turn": {
"$ref": "#/definitions/Turn"
}
},
"required": [
"snapshot",
"storySnapshotId",
"turn"
],
"title": "ReviewStoryStartResponse",
"type": "object"
},
"ReviewStoryStep": {
"properties": {
"anchorIds": {
"items": {
"type": "string"
},
"type": "array"
},
"dependencyRationale": {
"type": "string"
},
"error": {
"type": [
"string",
"null"
]
},
"goal": {
"type": "string"
},
"index": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"readiness": {
"$ref": "#/definitions/ReviewStoryStepReadiness"
},
"reviewFocus": {
"items": {
"type": "string"
},
"type": "array"
},
"stepId": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"anchorIds",
"dependencyRationale",
"goal",
"index",
"readiness",
"reviewFocus",
"stepId",
"summary",
"title"
],
"type": "object"
},
"ReviewStoryStepReadiness": {
"enum": [
"outline",
"enriching",
"ready",
"failed"
],
"type": "string"
},
"ReviewTarget": {
"oneOf": [
{
@@ -11493,6 +11938,26 @@
"title": "Turn/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"reviewStory/snapshot/updated"
],
"title": "ReviewStory/snapshot/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ReviewStorySnapshotUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "ReviewStory/snapshot/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {

View File

@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ReviewStoryListParams",
"type": "object"
}

View File

@@ -0,0 +1,177 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ReviewStorySnapshotStatus": {
"enum": [
"building",
"ready",
"partial",
"failed"
],
"type": "string"
},
"ReviewStorySnapshotSummary": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"status": {
"$ref": "#/definitions/ReviewStorySnapshotStatus"
},
"stepCount": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"sourceFingerprint",
"status",
"stepCount",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewTarget": {
"oneOf": [
{
"description": "Review the working tree: staged, unstaged, and untracked files.",
"properties": {
"type": {
"enum": [
"uncommittedChanges"
],
"title": "UncommittedChangesReviewTargetType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UncommittedChangesReviewTarget",
"type": "object"
},
{
"description": "Review changes between the current branch and the given base branch.",
"properties": {
"branch": {
"type": "string"
},
"type": {
"enum": [
"baseBranch"
],
"title": "BaseBranchReviewTargetType",
"type": "string"
}
},
"required": [
"branch",
"type"
],
"title": "BaseBranchReviewTarget",
"type": "object"
},
{
"description": "Review the changes introduced by a specific commit.",
"properties": {
"sha": {
"type": "string"
},
"title": {
"description": "Optional human-readable label (e.g., commit subject) for UIs.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"commit"
],
"title": "CommitReviewTargetType",
"type": "string"
}
},
"required": [
"sha",
"type"
],
"title": "CommitReviewTarget",
"type": "object"
},
{
"description": "Arbitrary instructions, equivalent to the old free-form prompt.",
"properties": {
"instructions": {
"type": "string"
},
"type": {
"enum": [
"custom"
],
"title": "CustomReviewTargetType",
"type": "string"
}
},
"required": [
"instructions",
"type"
],
"title": "CustomReviewTarget",
"type": "object"
}
]
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ReviewStorySnapshotSummary"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ReviewStoryListResponse",
"type": "object"
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"storySnapshotId": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"storySnapshotId",
"threadId"
],
"title": "ReviewStoryReadParams",
"type": "object"
}

View File

@@ -0,0 +1,292 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ReviewStoryAnchor": {
"properties": {
"anchorId": {
"type": "string"
},
"changeKind": {
"$ref": "#/definitions/ReviewStoryAnchorKind"
},
"diff": {
"type": "string"
},
"filePath": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": [
"anchorId",
"changeKind",
"diff",
"filePath",
"summary"
],
"type": "object"
},
"ReviewStoryAnchorKind": {
"enum": [
"added",
"modified",
"deleted",
"renamed",
"copied",
"unknown"
],
"type": "string"
},
"ReviewStorySnapshot": {
"properties": {
"anchors": {
"items": {
"$ref": "#/definitions/ReviewStoryAnchor"
},
"type": "array"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"overview": {
"type": "string"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"stale": {
"type": "boolean"
},
"status": {
"$ref": "#/definitions/ReviewStorySnapshotStatus"
},
"steps": {
"items": {
"$ref": "#/definitions/ReviewStoryStep"
},
"type": "array"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"anchors",
"createdAt",
"overview",
"sourceFingerprint",
"stale",
"status",
"steps",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotStatus": {
"enum": [
"building",
"ready",
"partial",
"failed"
],
"type": "string"
},
"ReviewStoryStep": {
"properties": {
"anchorIds": {
"items": {
"type": "string"
},
"type": "array"
},
"dependencyRationale": {
"type": "string"
},
"error": {
"type": [
"string",
"null"
]
},
"goal": {
"type": "string"
},
"index": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"readiness": {
"$ref": "#/definitions/ReviewStoryStepReadiness"
},
"reviewFocus": {
"items": {
"type": "string"
},
"type": "array"
},
"stepId": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"anchorIds",
"dependencyRationale",
"goal",
"index",
"readiness",
"reviewFocus",
"stepId",
"summary",
"title"
],
"type": "object"
},
"ReviewStoryStepReadiness": {
"enum": [
"outline",
"enriching",
"ready",
"failed"
],
"type": "string"
},
"ReviewTarget": {
"oneOf": [
{
"description": "Review the working tree: staged, unstaged, and untracked files.",
"properties": {
"type": {
"enum": [
"uncommittedChanges"
],
"title": "UncommittedChangesReviewTargetType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UncommittedChangesReviewTarget",
"type": "object"
},
{
"description": "Review changes between the current branch and the given base branch.",
"properties": {
"branch": {
"type": "string"
},
"type": {
"enum": [
"baseBranch"
],
"title": "BaseBranchReviewTargetType",
"type": "string"
}
},
"required": [
"branch",
"type"
],
"title": "BaseBranchReviewTarget",
"type": "object"
},
{
"description": "Review the changes introduced by a specific commit.",
"properties": {
"sha": {
"type": "string"
},
"title": {
"description": "Optional human-readable label (e.g., commit subject) for UIs.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"commit"
],
"title": "CommitReviewTargetType",
"type": "string"
}
},
"required": [
"sha",
"type"
],
"title": "CommitReviewTarget",
"type": "object"
},
{
"description": "Arbitrary instructions, equivalent to the old free-form prompt.",
"properties": {
"instructions": {
"type": "string"
},
"type": {
"enum": [
"custom"
],
"title": "CustomReviewTargetType",
"type": "string"
}
},
"required": [
"instructions",
"type"
],
"title": "CustomReviewTarget",
"type": "object"
}
]
}
},
"properties": {
"snapshot": {
"anyOf": [
{
"$ref": "#/definitions/ReviewStorySnapshot"
},
{
"type": "null"
}
]
}
},
"title": "ReviewStoryReadResponse",
"type": "object"
}

View File

@@ -0,0 +1,292 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ReviewStoryAnchor": {
"properties": {
"anchorId": {
"type": "string"
},
"changeKind": {
"$ref": "#/definitions/ReviewStoryAnchorKind"
},
"diff": {
"type": "string"
},
"filePath": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": [
"anchorId",
"changeKind",
"diff",
"filePath",
"summary"
],
"type": "object"
},
"ReviewStoryAnchorKind": {
"enum": [
"added",
"modified",
"deleted",
"renamed",
"copied",
"unknown"
],
"type": "string"
},
"ReviewStorySnapshot": {
"properties": {
"anchors": {
"items": {
"$ref": "#/definitions/ReviewStoryAnchor"
},
"type": "array"
},
"createdAt": {
"format": "int64",
"type": "integer"
},
"overview": {
"type": "string"
},
"previousStorySnapshotId": {
"type": [
"string",
"null"
]
},
"sourceFingerprint": {
"type": "string"
},
"stale": {
"type": "boolean"
},
"status": {
"$ref": "#/definitions/ReviewStorySnapshotStatus"
},
"steps": {
"items": {
"$ref": "#/definitions/ReviewStoryStep"
},
"type": "array"
},
"storySnapshotId": {
"type": "string"
},
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"anchors",
"createdAt",
"overview",
"sourceFingerprint",
"stale",
"status",
"steps",
"storySnapshotId",
"target",
"threadId",
"title",
"updatedAt"
],
"type": "object"
},
"ReviewStorySnapshotStatus": {
"enum": [
"building",
"ready",
"partial",
"failed"
],
"type": "string"
},
"ReviewStoryStep": {
"properties": {
"anchorIds": {
"items": {
"type": "string"
},
"type": "array"
},
"dependencyRationale": {
"type": "string"
},
"error": {
"type": [
"string",
"null"
]
},
"goal": {
"type": "string"
},
"index": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"readiness": {
"$ref": "#/definitions/ReviewStoryStepReadiness"
},
"reviewFocus": {
"items": {
"type": "string"
},
"type": "array"
},
"stepId": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"anchorIds",
"dependencyRationale",
"goal",
"index",
"readiness",
"reviewFocus",
"stepId",
"summary",
"title"
],
"type": "object"
},
"ReviewStoryStepReadiness": {
"enum": [
"outline",
"enriching",
"ready",
"failed"
],
"type": "string"
},
"ReviewTarget": {
"oneOf": [
{
"description": "Review the working tree: staged, unstaged, and untracked files.",
"properties": {
"type": {
"enum": [
"uncommittedChanges"
],
"title": "UncommittedChangesReviewTargetType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UncommittedChangesReviewTarget",
"type": "object"
},
{
"description": "Review changes between the current branch and the given base branch.",
"properties": {
"branch": {
"type": "string"
},
"type": {
"enum": [
"baseBranch"
],
"title": "BaseBranchReviewTargetType",
"type": "string"
}
},
"required": [
"branch",
"type"
],
"title": "BaseBranchReviewTarget",
"type": "object"
},
{
"description": "Review the changes introduced by a specific commit.",
"properties": {
"sha": {
"type": "string"
},
"title": {
"description": "Optional human-readable label (e.g., commit subject) for UIs.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"commit"
],
"title": "CommitReviewTargetType",
"type": "string"
}
},
"required": [
"sha",
"type"
],
"title": "CommitReviewTarget",
"type": "object"
},
{
"description": "Arbitrary instructions, equivalent to the old free-form prompt.",
"properties": {
"instructions": {
"type": "string"
},
"type": {
"enum": [
"custom"
],
"title": "CustomReviewTargetType",
"type": "string"
}
},
"required": [
"instructions",
"type"
],
"title": "CustomReviewTarget",
"type": "object"
}
]
}
},
"properties": {
"snapshot": {
"$ref": "#/definitions/ReviewStorySnapshot"
},
"threadId": {
"type": "string"
}
},
"required": [
"snapshot",
"threadId"
],
"title": "ReviewStorySnapshotUpdatedNotification",
"type": "object"
}

View File

@@ -0,0 +1,110 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ReviewTarget": {
"oneOf": [
{
"description": "Review the working tree: staged, unstaged, and untracked files.",
"properties": {
"type": {
"enum": [
"uncommittedChanges"
],
"title": "UncommittedChangesReviewTargetType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UncommittedChangesReviewTarget",
"type": "object"
},
{
"description": "Review changes between the current branch and the given base branch.",
"properties": {
"branch": {
"type": "string"
},
"type": {
"enum": [
"baseBranch"
],
"title": "BaseBranchReviewTargetType",
"type": "string"
}
},
"required": [
"branch",
"type"
],
"title": "BaseBranchReviewTarget",
"type": "object"
},
{
"description": "Review the changes introduced by a specific commit.",
"properties": {
"sha": {
"type": "string"
},
"title": {
"description": "Optional human-readable label (e.g., commit subject) for UIs.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"commit"
],
"title": "CommitReviewTargetType",
"type": "string"
}
},
"required": [
"sha",
"type"
],
"title": "CommitReviewTarget",
"type": "object"
},
{
"description": "Arbitrary instructions, equivalent to the old free-form prompt.",
"properties": {
"instructions": {
"type": "string"
},
"type": {
"enum": [
"custom"
],
"title": "CustomReviewTargetType",
"type": "string"
}
},
"required": [
"instructions",
"type"
],
"title": "CustomReviewTarget",
"type": "object"
}
]
}
},
"properties": {
"target": {
"$ref": "#/definitions/ReviewTarget"
},
"threadId": {
"type": "string"
}
},
"required": [
"target",
"threadId"
],
"title": "ReviewStoryStartParams",
"type": "object"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStoryAnchorKind } from "./ReviewStoryAnchorKind";
export type ReviewStoryAnchor = { anchorId: string, filePath: string, changeKind: ReviewStoryAnchorKind, summary: string, diff: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReviewStoryAnchorKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReviewStoryListParams = { threadId: string, cursor?: string | null, limit?: number | null, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStorySnapshotSummary } from "./ReviewStorySnapshotSummary";
export type ReviewStoryListResponse = { data: Array<ReviewStorySnapshotSummary>, nextCursor: string | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReviewStoryReadParams = { threadId: string, storySnapshotId: string, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStorySnapshot } from "./ReviewStorySnapshot";
export type ReviewStoryReadResponse = { snapshot: ReviewStorySnapshot | null, };

View File

@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStoryAnchor } from "./ReviewStoryAnchor";
import type { ReviewStorySnapshotStatus } from "./ReviewStorySnapshotStatus";
import type { ReviewStoryStep } from "./ReviewStoryStep";
import type { ReviewTarget } from "./ReviewTarget";
export type ReviewStorySnapshot = { storySnapshotId: string, threadId: string, title: string, overview: string, target: ReviewTarget, sourceFingerprint: string, status: ReviewStorySnapshotStatus, createdAt: bigint, updatedAt: bigint, previousStorySnapshotId: string | null, stale: boolean, steps: Array<ReviewStoryStep>, anchors: Array<ReviewStoryAnchor>, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReviewStorySnapshotStatus = "building" | "ready" | "partial" | "failed";

View File

@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStorySnapshotStatus } from "./ReviewStorySnapshotStatus";
import type { ReviewTarget } from "./ReviewTarget";
export type ReviewStorySnapshotSummary = { storySnapshotId: string, threadId: string, title: string, target: ReviewTarget, sourceFingerprint: string, status: ReviewStorySnapshotStatus, createdAt: bigint, updatedAt: bigint, previousStorySnapshotId: string | null, stepCount: number, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStorySnapshot } from "./ReviewStorySnapshot";
export type ReviewStorySnapshotUpdatedNotification = { threadId: string, snapshot: ReviewStorySnapshot, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewTarget } from "./ReviewTarget";
export type ReviewStoryStartParams = { threadId: string, target: ReviewTarget, };

View File

@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStorySnapshot } from "./ReviewStorySnapshot";
import type { Turn } from "./Turn";
export type ReviewStoryStartResponse = { turn: Turn, storySnapshotId: string, snapshot: ReviewStorySnapshot, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewStoryStepReadiness } from "./ReviewStoryStepReadiness";
export type ReviewStoryStep = { stepId: string, index: number, title: string, goal: string, summary: string, dependencyRationale: string, anchorIds: Array<string>, reviewFocus: Array<string>, readiness: ReviewStoryStepReadiness, error: string | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReviewStoryStepReadiness = "outline" | "enriching" | "ready" | "failed";

View File

@@ -323,6 +323,20 @@ export type { ResidencyRequirement } from "./ResidencyRequirement";
export type { ReviewDelivery } from "./ReviewDelivery";
export type { ReviewStartParams } from "./ReviewStartParams";
export type { ReviewStartResponse } from "./ReviewStartResponse";
export type { ReviewStoryAnchor } from "./ReviewStoryAnchor";
export type { ReviewStoryAnchorKind } from "./ReviewStoryAnchorKind";
export type { ReviewStoryListParams } from "./ReviewStoryListParams";
export type { ReviewStoryListResponse } from "./ReviewStoryListResponse";
export type { ReviewStoryReadParams } from "./ReviewStoryReadParams";
export type { ReviewStoryReadResponse } from "./ReviewStoryReadResponse";
export type { ReviewStorySnapshot } from "./ReviewStorySnapshot";
export type { ReviewStorySnapshotStatus } from "./ReviewStorySnapshotStatus";
export type { ReviewStorySnapshotSummary } from "./ReviewStorySnapshotSummary";
export type { ReviewStorySnapshotUpdatedNotification } from "./ReviewStorySnapshotUpdatedNotification";
export type { ReviewStoryStartParams } from "./ReviewStoryStartParams";
export type { ReviewStoryStartResponse } from "./ReviewStoryStartResponse";
export type { ReviewStoryStep } from "./ReviewStoryStep";
export type { ReviewStoryStepReadiness } from "./ReviewStoryStepReadiness";
export type { ReviewTarget } from "./ReviewTarget";
export type { SandboxMode } from "./SandboxMode";
export type { SandboxPolicy } from "./SandboxPolicy";

View File

@@ -794,6 +794,21 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ReviewStartResponse,
},
ReviewStoryStart => "reviewStory/start" {
params: v2::ReviewStoryStartParams,
serialization: thread_id(params.thread_id),
response: v2::ReviewStoryStartResponse,
},
ReviewStoryRead => "reviewStory/read" {
params: v2::ReviewStoryReadParams,
serialization: thread_id(params.thread_id),
response: v2::ReviewStoryReadResponse,
},
ReviewStoryList => "reviewStory/list" {
params: v2::ReviewStoryListParams,
serialization: thread_id(params.thread_id),
response: v2::ReviewStoryListResponse,
},
ModelList => "model/list" {
params: v2::ModelListParams,
@@ -1484,6 +1499,7 @@ server_notification_definitions! {
TurnStarted => "turn/started" (v2::TurnStartedNotification),
HookStarted => "hook/started" (v2::HookStartedNotification),
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
ReviewStorySnapshotUpdated => "reviewStory/snapshot/updated" (v2::ReviewStorySnapshotUpdatedNotification),
HookCompleted => "hook/completed" (v2::HookCompletedNotification),
TurnDiffUpdated => "turn/diff/updated" (v2::TurnDiffUpdatedNotification),
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),

View File

@@ -21,6 +21,7 @@ mod process;
mod realtime;
mod remote_control;
mod review;
mod review_story;
mod thread;
mod thread_data;
mod turn;
@@ -47,6 +48,7 @@ pub use process::*;
pub use realtime::*;
pub use remote_control::*;
pub use review::*;
pub use review_story::*;
pub use shared::*;
pub use thread::*;
pub use thread_data::*;

View File

@@ -0,0 +1,161 @@
use super::ReviewTarget;
use super::Turn;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryStartParams {
pub thread_id: String,
pub target: ReviewTarget,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryStartResponse {
pub turn: Turn,
pub story_snapshot_id: String,
pub snapshot: ReviewStorySnapshot,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryReadParams {
pub thread_id: String,
pub story_snapshot_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryReadResponse {
pub snapshot: Option<ReviewStorySnapshot>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryListParams {
pub thread_id: String,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryListResponse {
pub data: Vec<ReviewStorySnapshotSummary>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStorySnapshotUpdatedNotification {
pub thread_id: String,
pub snapshot: ReviewStorySnapshot,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStorySnapshotSummary {
pub story_snapshot_id: String,
pub thread_id: String,
pub title: String,
pub target: ReviewTarget,
pub source_fingerprint: String,
pub status: ReviewStorySnapshotStatus,
pub created_at: i64,
pub updated_at: i64,
pub previous_story_snapshot_id: Option<String>,
pub step_count: u32,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStorySnapshot {
pub story_snapshot_id: String,
pub thread_id: String,
pub title: String,
pub overview: String,
pub target: ReviewTarget,
pub source_fingerprint: String,
pub status: ReviewStorySnapshotStatus,
pub created_at: i64,
pub updated_at: i64,
pub previous_story_snapshot_id: Option<String>,
pub stale: bool,
pub steps: Vec<ReviewStoryStep>,
pub anchors: Vec<ReviewStoryAnchor>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ReviewStorySnapshotStatus {
Building,
Ready,
Partial,
Failed,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryStep {
pub step_id: String,
pub index: u32,
pub title: String,
pub goal: String,
pub summary: String,
pub dependency_rationale: String,
pub anchor_ids: Vec<String>,
pub review_focus: Vec<String>,
pub readiness: ReviewStoryStepReadiness,
pub error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ReviewStoryStepReadiness {
Outline,
Enriching,
Ready,
Failed,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ReviewStoryAnchor {
pub anchor_id: String,
pub file_path: String,
pub change_kind: ReviewStoryAnchorKind,
pub summary: String,
pub diff: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ReviewStoryAnchorKind {
Added,
Modified,
Deleted,
Renamed,
Copied,
Unknown,
}

View File

@@ -76,6 +76,7 @@ clap = { workspace = true, features = ["derive"] }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true }

View File

@@ -167,6 +167,7 @@ Example with notification opt-out:
- `thread/realtime/appendText` — append text input to the active realtime session (experimental); returns `{}`.
- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`.
- `review/start` — kick off Codexs automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `reviewStory/start` — build a persisted, read-only review story for a concrete source (`uncommittedChanges`, `baseBranch`, or `commit`). Codex collects stable change anchors from the diff, asks a constrained model task to create a validated outline, and returns a `building` snapshot as soon as that ordered outline is useful. Background enrichment fills in richer step explanations in small batches while the server persists and emits full replacement snapshots through `reviewStory/snapshot/updated`; a failed batch leaves the outlined story usable with `partial` status. Empty diffs return `ready` immediately, and outline failures fall back to enrichable file-level steps. Use `reviewStory/read` to fetch the latest snapshot by id and `reviewStory/list` to page saved stories for a thread.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `command/exec/write` — write base64-decoded stdin bytes to a running `command/exec` session or close stdin; returns `{}`.
- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`.

View File

@@ -31,6 +31,7 @@ use crate::request_processors::McpRequestProcessor;
use crate::request_processors::PluginRequestProcessor;
use crate::request_processors::ProcessExecRequestProcessor;
use crate::request_processors::RemoteControlRequestProcessor;
use crate::request_processors::ReviewStoryRequestProcessor;
use crate::request_processors::SearchRequestProcessor;
use crate::request_processors::ThreadGoalRequestProcessor;
use crate::request_processors::ThreadRequestProcessor;
@@ -177,6 +178,7 @@ pub(crate) struct MessageProcessor {
mcp_processor: McpRequestProcessor,
plugin_processor: PluginRequestProcessor,
remote_control_processor: RemoteControlRequestProcessor,
review_story_processor: ReviewStoryRequestProcessor,
search_processor: SearchRequestProcessor,
thread_goal_processor: ThreadGoalRequestProcessor,
thread_processor: ThreadRequestProcessor,
@@ -402,6 +404,11 @@ impl MessageProcessor {
workspace_settings_cache,
);
let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle);
let review_story_processor = ReviewStoryRequestProcessor::new(
Arc::clone(&thread_manager),
outgoing.clone(),
state_db.clone(),
);
let search_processor = SearchRequestProcessor::new(outgoing.clone());
let thread_goal_processor = ThreadGoalRequestProcessor::new(
Arc::clone(&thread_manager),
@@ -498,6 +505,7 @@ impl MessageProcessor {
mcp_processor,
plugin_processor,
remote_control_processor,
review_story_processor,
search_processor,
thread_goal_processor,
thread_processor,
@@ -1222,6 +1230,15 @@ impl MessageProcessor {
ClientRequest::ReviewStart { params, .. } => {
self.turn_processor.review_start(&request_id, params).await
}
ClientRequest::ReviewStoryStart { params, .. } => {
self.review_story_processor.start(&request_id, params).await
}
ClientRequest::ReviewStoryRead { params, .. } => {
self.review_story_processor.read(params).await
}
ClientRequest::ReviewStoryList { params, .. } => {
self.review_story_processor.list(params).await
}
ClientRequest::McpServerOauthLogin { params, .. } => {
self.mcp_processor.mcp_server_oauth_login(params).await
}

View File

@@ -466,6 +466,8 @@ mod mcp_processor;
mod plugins;
mod process_exec_processor;
mod remote_control_processor;
mod review_story_generation;
mod review_story_processor;
mod search;
mod thread_processor;
mod token_usage_replay;
@@ -488,6 +490,7 @@ pub(crate) use mcp_processor::McpRequestProcessor;
pub(crate) use plugins::PluginRequestProcessor;
pub(crate) use process_exec_processor::ProcessExecRequestProcessor;
pub(crate) use remote_control_processor::RemoteControlRequestProcessor;
pub(crate) use review_story_processor::ReviewStoryRequestProcessor;
pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;

View File

@@ -0,0 +1,485 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_app_server_protocol::ReviewStorySnapshot;
use codex_app_server_protocol::ReviewStorySnapshotStatus;
use codex_app_server_protocol::ReviewStorySnapshotUpdatedNotification;
use codex_app_server_protocol::ReviewStoryStepReadiness;
use codex_app_server_protocol::ServerNotification;
use codex_protocol::protocol::SubAgentSource;
use codex_rollout::state_db::StateDbHandle;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use serde::Deserialize;
use serde_json::json;
use crate::outgoing_message::OutgoingMessageSender;
use super::review_story_processor::snapshot_record;
const ENRICHMENT_BATCH_SIZE: usize = 2;
const ENRICHMENT_CONCURRENCY: usize = 2;
pub(super) fn spawn_enrichment(
thread: Arc<codex_core::CodexThread>,
snapshot: ReviewStorySnapshot,
outline_degraded: bool,
state_db: StateDbHandle,
outgoing: Arc<OutgoingMessageSender>,
) {
tokio::spawn(async move {
run_enrichment(thread, snapshot, outline_degraded, state_db, outgoing).await;
});
}
async fn run_enrichment(
thread: Arc<codex_core::CodexThread>,
mut snapshot: ReviewStorySnapshot,
outline_degraded: bool,
state_db: StateDbHandle,
outgoing: Arc<OutgoingMessageSender>,
) {
let batches = snapshot
.steps
.chunks(/*chunk_size*/ ENRICHMENT_BATCH_SIZE)
.map(|steps| {
steps
.iter()
.map(|step| step.step_id.clone())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
for pending_batches in batches.chunks(/*chunk_size*/ ENRICHMENT_CONCURRENCY) {
for batch in pending_batches {
mark_batch_enriching(&mut snapshot, batch, outline_degraded);
if !publish_snapshot(&state_db, &outgoing, &mut snapshot).await {
return;
}
}
let mut running = FuturesUnordered::new();
for batch in pending_batches.iter().cloned() {
let thread = Arc::clone(&thread);
let prompt_snapshot = snapshot.clone();
running.push(async move {
let result = generate_batch_enrichment(&thread, &prompt_snapshot, &batch).await;
(batch, result)
});
}
while let Some((batch, result)) = running.next().await {
apply_batch_result(&mut snapshot, &batch, result, outline_degraded);
if !publish_snapshot(&state_db, &outgoing, &mut snapshot).await {
return;
}
}
}
}
async fn publish_snapshot(
state_db: &StateDbHandle,
outgoing: &OutgoingMessageSender,
snapshot: &mut ReviewStorySnapshot,
) -> bool {
snapshot.updated_at = chrono::Utc::now().timestamp();
let record = match snapshot_record(snapshot) {
Ok(record) => record,
Err(err) => {
tracing::warn!("failed to encode review story enrichment snapshot: {err:?}");
return false;
}
};
if let Err(err) = state_db.review_stories().upsert_snapshot(record).await {
tracing::warn!("failed to store review story enrichment snapshot: {err}");
return false;
}
outgoing
.send_server_notification(ServerNotification::ReviewStorySnapshotUpdated(
ReviewStorySnapshotUpdatedNotification {
thread_id: snapshot.thread_id.clone(),
snapshot: snapshot.clone(),
},
))
.await;
true
}
fn mark_batch_enriching(
snapshot: &mut ReviewStorySnapshot,
batch: &[String],
outline_degraded: bool,
) {
for step in &mut snapshot.steps {
if batch.contains(&step.step_id) {
step.readiness = ReviewStoryStepReadiness::Enriching;
step.error = None;
}
}
update_status(snapshot, outline_degraded);
}
fn apply_batch_result(
snapshot: &mut ReviewStorySnapshot,
batch: &[String],
result: Result<ModelEnrichmentOutput, String>,
outline_degraded: bool,
) {
let result = result.and_then(|output| enrichment_by_step_id(batch, output));
match result {
Ok(mut enrichments) => {
for step in &mut snapshot.steps {
if let Some(enrichment) = enrichments.remove(&step.step_id) {
step.goal = enrichment.goal;
step.summary = enrichment.summary;
step.dependency_rationale = enrichment.dependency_rationale;
step.review_focus = enrichment.review_focus;
step.readiness = ReviewStoryStepReadiness::Ready;
step.error = None;
}
}
}
Err(error) => {
for step in &mut snapshot.steps {
if batch.contains(&step.step_id) {
step.readiness = ReviewStoryStepReadiness::Failed;
step.error = Some(error.clone());
}
}
}
}
update_status(snapshot, outline_degraded);
}
fn update_status(snapshot: &mut ReviewStorySnapshot, outline_degraded: bool) {
if snapshot.steps.iter().any(|step| {
matches!(
step.readiness,
ReviewStoryStepReadiness::Outline | ReviewStoryStepReadiness::Enriching
)
}) {
snapshot.status = ReviewStorySnapshotStatus::Building;
} else if outline_degraded
|| snapshot
.steps
.iter()
.any(|step| step.readiness == ReviewStoryStepReadiness::Failed)
{
snapshot.status = ReviewStorySnapshotStatus::Partial;
} else {
snapshot.status = ReviewStorySnapshotStatus::Ready;
}
}
async fn generate_batch_enrichment(
thread: &codex_core::CodexThread,
snapshot: &ReviewStorySnapshot,
batch: &[String],
) -> Result<ModelEnrichmentOutput, String> {
let prompt = enrichment_prompt(snapshot, batch);
match thread
.run_structured_model_task(
prompt,
ENRICHMENT_MODEL_INSTRUCTIONS.to_string(),
enrichment_output_schema(),
SubAgentSource::Other("review_story_enrichment".to_string()),
)
.await
{
Ok(Some(output_text)) => parse_enrichment_output(&output_text)
.map_err(|err| format!("model returned invalid step enrichment: {err}")),
Ok(None) => Err("model did not return step enrichment".to_string()),
Err(err) => Err(format!("failed to enrich story step: {err}")),
}
}
const ENRICHMENT_MODEL_INSTRUCTIONS: &str = r#"You enrich ordered review story steps for code changes.
Write detailed reviewer-facing explanations only for the supplied step ids. Preserve each step's anchored scope: explain its changed evidence, its purpose, and its dependency on nearby steps without inventing files, anchors, or behavior."#;
fn enrichment_prompt(snapshot: &ReviewStorySnapshot, batch: &[String]) -> String {
let anchors = snapshot
.anchors
.iter()
.map(|anchor| (anchor.anchor_id.as_str(), anchor))
.collect::<HashMap<_, _>>();
let mut prompt = format!(
"Enrich the selected steps in this review story.\n\nStory title: {}\nOverview: {}\n\n",
snapshot.title, snapshot.overview
);
for step in snapshot
.steps
.iter()
.filter(|step| batch.contains(&step.step_id))
{
prompt.push_str(&format!(
"---\nstepId: {}\ntitle: {}\noutline goal: {}\nanchorIds: {}\n",
step.step_id,
step.title,
step.goal,
step.anchor_ids.join(", ")
));
for anchor_id in &step.anchor_ids {
if let Some(anchor) = anchors.get(anchor_id.as_str()) {
prompt.push_str(&format!(
"anchorId: {}\nfilePath: {}\ndiff:\n{}\n",
anchor.anchor_id,
anchor.file_path,
truncate_for_prompt(&anchor.diff, /*max_chars*/ 12_000)
));
}
}
}
prompt.push_str(
"\nReturn JSON only, with exactly one enriched object for each requested stepId.",
);
truncate_for_prompt(&prompt, /*max_chars*/ 80_000)
}
fn truncate_for_prompt(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut truncated = text.chars().take(max_chars).collect::<String>();
truncated.push_str("\n[truncated]");
truncated
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelEnrichmentOutput {
steps: Vec<ModelEnrichmentStep>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelEnrichmentStep {
step_id: String,
goal: String,
summary: String,
dependency_rationale: String,
review_focus: Vec<String>,
}
fn enrichment_by_step_id(
batch: &[String],
output: ModelEnrichmentOutput,
) -> Result<HashMap<String, ModelEnrichmentStep>, String> {
let mut enrichments = HashMap::new();
for enrichment in output.steps {
if !batch.contains(&enrichment.step_id) {
return Err(format!(
"model referenced unrequested step id: {}",
enrichment.step_id
));
}
let step_id = enrichment.step_id.clone();
if enrichments.insert(step_id.clone(), enrichment).is_some() {
return Err(format!("model returned duplicate step id: {step_id}"));
}
}
if enrichments.len() != batch.len() {
return Err("model omitted one or more requested step ids".to_string());
}
Ok(enrichments)
}
fn enrichment_output_schema() -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"stepId": { "type": "string" },
"goal": { "type": "string" },
"summary": { "type": "string" },
"dependencyRationale": { "type": "string" },
"reviewFocus": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["stepId", "goal", "summary", "dependencyRationale", "reviewFocus"]
}
}
},
"required": ["steps"]
})
}
fn parse_enrichment_output(text: &str) -> Result<ModelEnrichmentOutput, serde_json::Error> {
if let Ok(output) = serde_json::from_str::<ModelEnrichmentOutput>(text) {
return Ok(output);
}
if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}'))
&& start < end
&& let Some(slice) = text.get(start..=end)
{
return serde_json::from_str::<ModelEnrichmentOutput>(slice);
}
serde_json::from_str::<ModelEnrichmentOutput>(text)
}
#[cfg(test)]
mod tests {
use codex_app_server_protocol::ReviewStoryAnchor;
use codex_app_server_protocol::ReviewStoryAnchorKind;
use codex_app_server_protocol::ReviewStoryStep;
use codex_app_server_protocol::ReviewTarget;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn scheduling_changes_only_requested_steps_to_enriching() {
let mut snapshot = sample_snapshot();
mark_batch_enriching(
&mut snapshot,
&["step-1".to_string()],
/*outline_degraded*/ false,
);
assert_eq!(snapshot.status, ReviewStorySnapshotStatus::Building);
assert_eq!(
snapshot.steps[0].readiness,
ReviewStoryStepReadiness::Enriching
);
assert_eq!(
snapshot.steps[1].readiness,
ReviewStoryStepReadiness::Outline
);
}
#[test]
fn successful_batches_merge_without_losing_prior_completion() {
let mut snapshot = sample_snapshot();
mark_batch_enriching(
&mut snapshot,
&["step-1".to_string()],
/*outline_degraded*/ false,
);
mark_batch_enriching(
&mut snapshot,
&["step-2".to_string()],
/*outline_degraded*/ false,
);
apply_batch_result(
&mut snapshot,
&["step-2".to_string()],
Ok(enrichment("step-2", "second rich summary")),
/*outline_degraded*/ false,
);
apply_batch_result(
&mut snapshot,
&["step-1".to_string()],
Ok(enrichment("step-1", "first rich summary")),
/*outline_degraded*/ false,
);
assert_eq!(snapshot.status, ReviewStorySnapshotStatus::Ready);
assert_eq!(snapshot.steps[0].summary, "first rich summary");
assert_eq!(snapshot.steps[1].summary, "second rich summary");
assert_eq!(snapshot.steps[0].readiness, ReviewStoryStepReadiness::Ready);
assert_eq!(snapshot.steps[1].readiness, ReviewStoryStepReadiness::Ready);
}
#[test]
fn failed_or_degraded_enrichment_finishes_partial() {
let mut failed = sample_snapshot();
mark_batch_enriching(
&mut failed,
&["step-1".to_string()],
/*outline_degraded*/ false,
);
apply_batch_result(
&mut failed,
&["step-1".to_string()],
Err("generation failed".to_string()),
/*outline_degraded*/ false,
);
apply_batch_result(
&mut failed,
&["step-2".to_string()],
Ok(enrichment("step-2", "ready")),
/*outline_degraded*/ false,
);
assert_eq!(failed.status, ReviewStorySnapshotStatus::Partial);
assert_eq!(failed.steps[0].readiness, ReviewStoryStepReadiness::Failed);
let mut degraded = sample_snapshot();
apply_batch_result(
&mut degraded,
&["step-1".to_string(), "step-2".to_string()],
Ok(ModelEnrichmentOutput {
steps: vec![
enriched_step("step-1", "first"),
enriched_step("step-2", "second"),
],
}),
/*outline_degraded*/ true,
);
assert_eq!(degraded.status, ReviewStorySnapshotStatus::Partial);
}
fn enrichment(step_id: &str, summary: &str) -> ModelEnrichmentOutput {
ModelEnrichmentOutput {
steps: vec![enriched_step(step_id, summary)],
}
}
fn enriched_step(step_id: &str, summary: &str) -> ModelEnrichmentStep {
ModelEnrichmentStep {
step_id: step_id.to_string(),
goal: format!("goal {step_id}"),
summary: summary.to_string(),
dependency_rationale: "rationale".to_string(),
review_focus: vec!["focus".to_string()],
}
}
fn sample_snapshot() -> ReviewStorySnapshot {
ReviewStorySnapshot {
story_snapshot_id: "story-1".to_string(),
thread_id: "thread-1".to_string(),
title: "Story".to_string(),
overview: "Overview".to_string(),
target: ReviewTarget::UncommittedChanges,
source_fingerprint: "sha256:one".to_string(),
status: ReviewStorySnapshotStatus::Building,
created_at: 1,
updated_at: 1,
previous_story_snapshot_id: None,
stale: false,
steps: vec![step("step-1", /*index*/ 1), step("step-2", /*index*/ 2)],
anchors: vec![ReviewStoryAnchor {
anchor_id: "anchor-1".to_string(),
file_path: "src/lib.rs".to_string(),
change_kind: ReviewStoryAnchorKind::Modified,
summary: "Modified src/lib.rs".to_string(),
diff: "+line".to_string(),
}],
}
}
fn step(step_id: &str, index: u32) -> ReviewStoryStep {
ReviewStoryStep {
step_id: step_id.to_string(),
index,
title: step_id.to_string(),
goal: "outline goal".to_string(),
summary: "outline summary".to_string(),
dependency_rationale: "outline rationale".to_string(),
anchor_ids: vec!["anchor-1".to_string()],
review_focus: vec!["outline focus".to_string()],
readiness: ReviewStoryStepReadiness::Outline,
error: None,
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
use crate::agent::AgentStatus;
use crate::codex_delegate::run_codex_thread_one_shot;
use crate::config::Constrained;
use crate::config::ConstraintResult;
use crate::goals::ExternalGoalSet;
use crate::goals::GoalRuntimeEvent;
@@ -11,6 +13,7 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -23,10 +26,12 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::ThreadSource;
@@ -41,11 +46,13 @@ use codex_thread_store::ThreadStoreError;
use codex_thread_store::ThreadStoreResult;
use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::ReadResourceRequestParams;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use codex_rollout::state_db::StateDbHandle;
@@ -502,6 +509,68 @@ impl CodexThread {
self.codex.session.get_config().await
}
/// Run a short-lived model task that produces structured JSON for a caller-owned workflow.
///
/// This keeps app-server features from needing to know how to spawn sub-agents while still
/// reusing the same constrained delegate machinery as review mode.
pub async fn run_structured_model_task(
&self,
prompt: String,
base_instructions: String,
final_output_json_schema: Value,
subagent_source: SubAgentSource,
) -> CodexResult<Option<String>> {
let ctx = self
.codex
.session
.new_default_turn_with_sub_id(format!("structured-task-{}", uuid::Uuid::now_v7()))
.await;
let mut sub_agent_config = ctx.config.as_ref().clone();
if let Err(err) = sub_agent_config
.web_search_mode
.set(WebSearchMode::Disabled)
{
panic!(
"by construction Constrained<WebSearchMode> must always support Disabled: {err}"
);
}
let _ = sub_agent_config.features.disable(Feature::SpawnCsv);
let _ = sub_agent_config.features.disable(Feature::Collab);
let _ = sub_agent_config.features.disable(Feature::MultiAgentV2);
sub_agent_config.permissions.approval_policy =
Constrained::allow_only(AskForApproval::Never);
sub_agent_config.base_instructions = Some(base_instructions);
let input = vec![UserInput::Text {
text: prompt,
text_elements: Vec::new(),
}];
let io = run_codex_thread_one_shot(
sub_agent_config,
Arc::clone(&self.codex.session.services.auth_manager),
Arc::clone(&self.codex.session.services.models_manager),
input,
Arc::clone(&self.codex.session),
ctx,
CancellationToken::new(),
subagent_source,
Some(final_output_json_schema),
/*initial_history*/ None,
)
.await?;
while let Ok(event) = io.next_event().await {
match event.msg {
EventMsg::TurnComplete(turn_complete) => {
return Ok(turn_complete.last_agent_message);
}
EventMsg::TurnAborted(_) => {
return Ok(None);
}
_ => {}
}
}
Ok(None)
}
/// Refresh the thread's layer-backed user config state from a caller-supplied
/// config snapshot. Thread-scoped layers and session-static settings remain
/// unchanged.

View File

@@ -3,5 +3,5 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "state",
crate_name = "codex_state",
compile_data = glob(["goals_migrations/**", "logs_migrations/**", "migrations/**"]),
compile_data = glob(["goals_migrations/**", "logs_migrations/**", "migrations/**", "review_story_migrations/**"]),
)

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS review_story_snapshots (
story_snapshot_id TEXT PRIMARY KEY NOT NULL,
thread_id TEXT NOT NULL,
source_fingerprint TEXT NOT NULL,
status TEXT NOT NULL,
title TEXT NOT NULL,
step_count INTEGER NOT NULL,
target_json TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
previous_story_snapshot_id TEXT,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS review_story_snapshots_thread_updated_idx
ON review_story_snapshots(thread_id, updated_at_ms DESC, story_snapshot_id DESC);
CREATE INDEX IF NOT EXISTS review_story_snapshots_thread_fingerprint_idx
ON review_story_snapshots(thread_id, source_fingerprint, updated_at_ms DESC);

View File

@@ -53,12 +53,17 @@ pub use runtime::GoalAccountingOutcome;
pub use runtime::GoalStore;
pub use runtime::GoalUpdate;
pub use runtime::RemoteControlEnrollmentRecord;
pub use runtime::ReviewStoryRecord;
pub use runtime::ReviewStoryStore;
pub use runtime::ReviewStorySummaryRecord;
pub use runtime::RuntimeDbPath;
pub use runtime::ThreadFilterOptions;
pub use runtime::goals_db_filename;
pub use runtime::goals_db_path;
pub use runtime::logs_db_filename;
pub use runtime::logs_db_path;
pub use runtime::review_story_db_filename;
pub use runtime::review_story_db_path;
pub use runtime::runtime_db_paths;
pub use runtime::sqlite_integrity_check;
pub use runtime::state_db_filename;
@@ -74,6 +79,7 @@ pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME";
pub const LOGS_DB_FILENAME: &str = "logs_2.sqlite";
pub const GOALS_DB_FILENAME: &str = "goals_1.sqlite";
pub const REVIEW_STORY_DB_FILENAME: &str = "review_stories_1.sqlite";
pub const STATE_DB_FILENAME: &str = "state_5.sqlite";
/// Errors encountered during DB operations. Tags: [stage]

View File

@@ -5,6 +5,7 @@ use sqlx::migrate::Migrator;
pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations");
pub(crate) static LOGS_MIGRATOR: Migrator = sqlx::migrate!("./logs_migrations");
pub(crate) static GOALS_MIGRATOR: Migrator = sqlx::migrate!("./goals_migrations");
pub(crate) static REVIEW_STORY_MIGRATOR: Migrator = sqlx::migrate!("./review_story_migrations");
/// Allow an older Codex binary to open a database that has already been
/// migrated by a newer binary running in parallel.
@@ -32,3 +33,7 @@ pub(crate) fn runtime_logs_migrator() -> Migrator {
pub(crate) fn runtime_goals_migrator() -> Migrator {
runtime_migrator(&GOALS_MIGRATOR)
}
pub(crate) fn runtime_review_story_migrator() -> Migrator {
runtime_migrator(&REVIEW_STORY_MIGRATOR)
}

View File

@@ -10,6 +10,7 @@ use crate::LOGS_DB_FILENAME;
use crate::LogEntry;
use crate::LogQuery;
use crate::LogRow;
use crate::REVIEW_STORY_DB_FILENAME;
use crate::STATE_DB_FILENAME;
use crate::SortKey;
use crate::ThreadMetadata;
@@ -18,6 +19,7 @@ use crate::ThreadsPage;
use crate::apply_rollout_item;
use crate::migrations::runtime_goals_migrator;
use crate::migrations::runtime_logs_migrator;
use crate::migrations::runtime_review_story_migrator;
use crate::migrations::runtime_state_migrator;
use crate::model::AgentJobRow;
use crate::model::ThreadRow;
@@ -62,6 +64,7 @@ mod goals;
mod logs;
mod memories;
mod remote_control;
mod review_stories;
#[cfg(test)]
mod test_support;
mod threads;
@@ -71,6 +74,9 @@ pub use goals::GoalAccountingOutcome;
pub use goals::GoalStore;
pub use goals::GoalUpdate;
pub use remote_control::RemoteControlEnrollmentRecord;
pub use review_stories::ReviewStoryRecord;
pub use review_stories::ReviewStoryStore;
pub use review_stories::ReviewStorySummaryRecord;
pub use threads::ThreadFilterOptions;
// "Partition" is the retained-log-content bucket we cap at 10 MiB:
@@ -121,7 +127,15 @@ const GOALS_DB: RuntimeDbSpec = RuntimeDbSpec {
migrate_phase: "migrate_goals",
};
const RUNTIME_DBS: [RuntimeDbSpec; 3] = [STATE_DB, LOGS_DB, GOALS_DB];
const REVIEW_STORY_DB: RuntimeDbSpec = RuntimeDbSpec {
label: "review story DB",
filename: REVIEW_STORY_DB_FILENAME,
kind: DbKind::ReviewStories,
open_phase: "open_review_stories",
migrate_phase: "migrate_review_stories",
};
const RUNTIME_DBS: [RuntimeDbSpec; 4] = [STATE_DB, LOGS_DB, GOALS_DB, REVIEW_STORY_DB];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeDbPath {
@@ -136,6 +150,7 @@ pub struct StateRuntime {
pool: Arc<sqlx::SqlitePool>,
logs_pool: Arc<sqlx::SqlitePool>,
thread_goals: GoalStore,
review_stories: ReviewStoryStore,
thread_updated_at_millis: Arc<AtomicI64>,
}
@@ -172,9 +187,11 @@ impl StateRuntime {
let state_migrator = runtime_state_migrator();
let logs_migrator = runtime_logs_migrator();
let goals_migrator = runtime_goals_migrator();
let review_story_migrator = runtime_review_story_migrator();
let state_path = STATE_DB.path(codex_home.as_path());
let logs_path = LOGS_DB.path(codex_home.as_path());
let goals_path = GOALS_DB.path(codex_home.as_path());
let review_story_path = REVIEW_STORY_DB.path(codex_home.as_path());
let pool = match open_state_sqlite(&state_path, &state_migrator, telemetry_override).await {
Ok(db) => Arc::new(db),
Err(err) => {
@@ -198,6 +215,22 @@ impl StateRuntime {
return Err(err);
}
};
let review_story_pool = match open_review_story_sqlite(
&review_story_path,
&review_story_migrator,
telemetry_override,
)
.await
{
Ok(db) => Arc::new(db),
Err(err) => {
warn!(
"failed to open review story db at {}: {err}",
review_story_path.display()
);
return Err(err);
}
};
let started = Instant::now();
let backfill_state_result = ensure_backfill_state_row_in_pool(pool.as_ref()).await;
crate::telemetry::record_init_result(
@@ -225,6 +258,7 @@ impl StateRuntime {
let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0);
let runtime = Arc::new(Self {
thread_goals: GoalStore::new(Arc::clone(&goals_pool)),
review_stories: ReviewStoryStore::new(review_story_pool),
pool,
logs_pool,
codex_home,
@@ -248,6 +282,10 @@ impl StateRuntime {
pub fn thread_goals(&self) -> &GoalStore {
&self.thread_goals
}
pub fn review_stories(&self) -> &ReviewStoryStore {
&self.review_stories
}
}
fn base_sqlite_options(path: &Path) -> SqliteConnectOptions {
@@ -287,6 +325,14 @@ async fn open_goals_sqlite(
open_sqlite(path, migrator, GOALS_DB, telemetry_override).await
}
async fn open_review_story_sqlite(
path: &Path,
migrator: &Migrator,
telemetry_override: Option<&dyn DbTelemetry>,
) -> anyhow::Result<SqlitePool> {
open_sqlite(path, migrator, REVIEW_STORY_DB, telemetry_override).await
}
async fn open_sqlite(
path: &Path,
migrator: &Migrator,
@@ -363,6 +409,14 @@ pub fn goals_db_path(codex_home: &Path) -> PathBuf {
GOALS_DB.path(codex_home)
}
pub fn review_story_db_filename() -> String {
REVIEW_STORY_DB.filename.to_string()
}
pub fn review_story_db_path(codex_home: &Path) -> PathBuf {
REVIEW_STORY_DB.path(codex_home)
}
pub fn runtime_db_paths(codex_home: &Path) -> Vec<RuntimeDbPath> {
RUNTIME_DBS
.iter()
@@ -579,6 +633,8 @@ mod tests {
"migrate_logs",
"open_goals",
"migrate_goals",
"open_review_stories",
"migrate_review_stories",
"ensure_backfill_state",
"post_init_query",
]

View File

@@ -0,0 +1,220 @@
use super::*;
#[derive(Clone)]
pub struct ReviewStoryStore {
pool: Arc<SqlitePool>,
}
impl ReviewStoryStore {
pub(crate) fn new(pool: Arc<SqlitePool>) -> Self {
Self { pool }
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewStoryRecord {
pub story_snapshot_id: String,
pub thread_id: String,
pub source_fingerprint: String,
pub status: String,
pub title: String,
pub step_count: i64,
pub target_json: Value,
pub snapshot_json: Value,
pub previous_story_snapshot_id: Option<String>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewStorySummaryRecord {
pub story_snapshot_id: String,
pub thread_id: String,
pub source_fingerprint: String,
pub status: String,
pub title: String,
pub step_count: i64,
pub target_json: Value,
pub previous_story_snapshot_id: Option<String>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
impl ReviewStoryStore {
pub async fn upsert_snapshot(&self, record: ReviewStoryRecord) -> anyhow::Result<()> {
let ReviewStoryRecord {
story_snapshot_id,
thread_id,
source_fingerprint,
status,
title,
step_count,
target_json,
snapshot_json,
previous_story_snapshot_id,
created_at_ms,
updated_at_ms,
} = record;
let target_json = serde_json::to_string(&target_json)?;
let snapshot_json = serde_json::to_string(&snapshot_json)?;
sqlx::query(
r#"
INSERT INTO review_story_snapshots (
story_snapshot_id,
thread_id,
source_fingerprint,
status,
title,
step_count,
target_json,
snapshot_json,
previous_story_snapshot_id,
created_at_ms,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(story_snapshot_id) DO UPDATE SET
thread_id = excluded.thread_id,
source_fingerprint = excluded.source_fingerprint,
status = excluded.status,
title = excluded.title,
step_count = excluded.step_count,
target_json = excluded.target_json,
snapshot_json = excluded.snapshot_json,
previous_story_snapshot_id = excluded.previous_story_snapshot_id,
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms
"#,
)
.bind(story_snapshot_id)
.bind(thread_id)
.bind(source_fingerprint)
.bind(status)
.bind(title)
.bind(step_count)
.bind(target_json)
.bind(snapshot_json)
.bind(previous_story_snapshot_id)
.bind(created_at_ms)
.bind(updated_at_ms)
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn get_snapshot(
&self,
thread_id: ThreadId,
story_snapshot_id: &str,
) -> anyhow::Result<Option<ReviewStoryRecord>> {
let row = sqlx::query(
r#"
SELECT
story_snapshot_id,
thread_id,
source_fingerprint,
status,
title,
step_count,
target_json,
snapshot_json,
previous_story_snapshot_id,
created_at_ms,
updated_at_ms
FROM review_story_snapshots
WHERE thread_id = ? AND story_snapshot_id = ?
"#,
)
.bind(thread_id.to_string())
.bind(story_snapshot_id)
.fetch_optional(self.pool.as_ref())
.await?;
row.map(|row| review_story_record_from_row(&row))
.transpose()
}
pub async fn list_snapshots(
&self,
thread_id: ThreadId,
cursor: Option<String>,
limit: Option<u32>,
) -> anyhow::Result<(Vec<ReviewStorySummaryRecord>, Option<String>)> {
let limit = limit
.unwrap_or(/*default*/ 50)
.clamp(/*min*/ 1, /*max*/ 100);
let offset = cursor
.as_deref()
.and_then(|cursor| cursor.parse::<u32>().ok())
.unwrap_or(/*default*/ 0);
let rows = sqlx::query(
r#"
SELECT
story_snapshot_id,
thread_id,
source_fingerprint,
status,
title,
step_count,
target_json,
previous_story_snapshot_id,
created_at_ms,
updated_at_ms
FROM review_story_snapshots
WHERE thread_id = ?
ORDER BY updated_at_ms DESC, story_snapshot_id DESC
LIMIT ? OFFSET ?
"#,
)
.bind(thread_id.to_string())
.bind(i64::from(limit) + 1)
.bind(i64::from(offset))
.fetch_all(self.pool.as_ref())
.await?;
let has_next = rows.len() > limit as usize;
let rows = rows.into_iter().take(limit as usize);
let records = rows
.map(|row| review_story_summary_record_from_row(&row))
.collect::<anyhow::Result<Vec<_>>>()?;
let next_cursor = has_next.then(|| (offset + limit).to_string());
Ok((records, next_cursor))
}
}
fn review_story_record_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> anyhow::Result<ReviewStoryRecord> {
let target_json: String = row.try_get("target_json")?;
let snapshot_json: String = row.try_get("snapshot_json")?;
Ok(ReviewStoryRecord {
story_snapshot_id: row.try_get("story_snapshot_id")?,
thread_id: row.try_get("thread_id")?,
source_fingerprint: row.try_get("source_fingerprint")?,
status: row.try_get("status")?,
title: row.try_get("title")?,
step_count: row.try_get("step_count")?,
target_json: serde_json::from_str(&target_json)?,
snapshot_json: serde_json::from_str(&snapshot_json)?,
previous_story_snapshot_id: row.try_get("previous_story_snapshot_id")?,
created_at_ms: row.try_get("created_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
}
fn review_story_summary_record_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> anyhow::Result<ReviewStorySummaryRecord> {
let target_json: String = row.try_get("target_json")?;
Ok(ReviewStorySummaryRecord {
story_snapshot_id: row.try_get("story_snapshot_id")?,
thread_id: row.try_get("thread_id")?,
source_fingerprint: row.try_get("source_fingerprint")?,
status: row.try_get("status")?,
title: row.try_get("title")?,
step_count: row.try_get("step_count")?,
target_json: serde_json::from_str(&target_json)?,
previous_story_snapshot_id: row.try_get("previous_story_snapshot_id")?,
created_at_ms: row.try_get("created_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
}

View File

@@ -40,6 +40,7 @@ pub(crate) enum DbKind {
State,
Logs,
Goals,
ReviewStories,
}
impl DbKind {
@@ -48,6 +49,7 @@ impl DbKind {
Self::State => "state",
Self::Logs => "logs",
Self::Goals => "goals",
Self::ReviewStories => "review_stories",
}
}
}

View File

@@ -68,6 +68,9 @@ pub(super) fn server_notification_thread_target(
ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ReviewStorySnapshotUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()),

View File

@@ -1835,12 +1835,29 @@ impl App {
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}
AppEvent::OpenReviewStoryPopup => {
self.chat_widget.open_review_story_popup();
}
AppEvent::OpenReviewStoryBranchPicker(cwd) => {
self.chat_widget.show_review_story_branch_picker(&cwd).await;
}
AppEvent::OpenReviewCommitPicker(cwd) => {
self.chat_widget.show_review_commit_picker(&cwd).await;
}
AppEvent::OpenReviewStoryCommitPicker(cwd) => {
self.chat_widget.show_review_story_commit_picker(&cwd).await;
}
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::ReviewStoryReady(snapshot) => {
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_static_with_lines(
review_story_overlay_lines(snapshot),
"R E V I E W S T O R Y".to_string(),
self.keymap.pager.clone(),
));
}
AppEvent::SubmitUserMessageWithMode {
text,
collaboration_mode,
@@ -2212,3 +2229,68 @@ impl App {
}
}
}
fn review_story_overlay_lines(
snapshot: codex_app_server_protocol::ReviewStorySnapshot,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(snapshot.title.bold().into());
lines.push(Line::from(""));
push_plain(&mut lines, "Overview", snapshot.overview.as_str());
lines.push(Line::from(vec![
"Source fingerprint: ".dim(),
snapshot.source_fingerprint.dim(),
]));
lines.push(Line::from(""));
for step in snapshot.steps {
lines.push(Line::from(vec![
format!("{}. ", step.index).cyan().bold(),
step.title.bold(),
]));
push_plain(&mut lines, "Goal", step.goal.as_str());
push_plain(&mut lines, "Summary", step.summary.as_str());
if !step.dependency_rationale.is_empty() {
push_plain(&mut lines, "Order", step.dependency_rationale.as_str());
}
if !step.review_focus.is_empty() {
lines.push("Focus".bold().into());
for focus in step.review_focus {
lines.push(Line::from(vec![" - ".dim(), focus.into()]));
}
}
for anchor_id in step.anchor_ids {
if let Some(anchor) = snapshot
.anchors
.iter()
.find(|candidate| candidate.anchor_id == anchor_id)
{
lines.push(Line::from(vec![
"Change ".bold(),
anchor.anchor_id.clone().cyan(),
": ".into(),
anchor.file_path.clone().into(),
]));
if !anchor.summary.is_empty() {
lines.push(Line::from(vec![" ".into(), anchor.summary.clone().into()]));
}
for diff_line in anchor.diff.lines() {
lines.push(diff_line.to_string().dim().into());
}
}
}
lines.push(Line::from(""));
}
lines
}
fn push_plain(lines: &mut Vec<Line<'static>>, label: &str, text: &str) {
if text.trim().is_empty() {
return;
}
lines.push(label.to_string().bold().into());
for wrapped in textwrap::wrap(text, /*width*/ 100) {
lines.push(Line::from(wrapped.into_owned()));
}
}

View File

@@ -654,6 +654,14 @@ impl App {
app_server.review_start(thread_id, target.clone()).await?;
Ok(true)
}
AppCommand::ReviewStory { target } => {
let response = app_server
.review_story_start(thread_id, target.clone())
.await?;
self.app_event_tx
.send(AppEvent::ReviewStoryReady(response.snapshot));
Ok(true)
}
AppCommand::CleanBackgroundTerminals => {
app_server
.thread_background_terminals_clean(thread_id)

View File

@@ -105,6 +105,9 @@ pub(crate) enum AppCommand {
Review {
target: ReviewTarget,
},
ReviewStory {
target: ReviewTarget,
},
ApproveGuardianDeniedAction {
event: GuardianAssessmentEvent,
},
@@ -272,6 +275,10 @@ impl AppCommand {
Self::Review { target }
}
pub(crate) fn review_story(target: ReviewTarget) -> Self {
Self::ReviewStory { target }
}
pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self {
Self::ApproveGuardianDeniedAction { event }
}

View File

@@ -24,6 +24,7 @@ use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::ReviewStorySnapshot;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_file_search::FileMatch;
@@ -885,13 +886,19 @@ pub(crate) enum AppEvent {
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),
OpenReviewStoryPopup,
OpenReviewStoryBranchPicker(PathBuf),
/// Open the commit picker option from the review popup.
OpenReviewCommitPicker(PathBuf),
OpenReviewStoryCommitPicker(PathBuf),
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Open the review story overlay for a freshly generated snapshot.
ReviewStoryReady(ReviewStorySnapshot),
/// Submit a user message with an explicit collaboration mask.
SubmitUserMessageWithMode {
text: String,

View File

@@ -59,6 +59,10 @@ impl AppEventSender {
self.send(AppEvent::CodexOp(AppCommand::review(target)));
}
pub(crate) fn review_story(&self, target: ReviewTarget) {
self.send(AppEvent::CodexOp(AppCommand::review_story(target)));
}
pub(crate) fn list_skills(&self, cwds: Vec<PathBuf>, force_reload: bool) {
self.send(AppEvent::CodexOp(AppCommand::list_skills(
cwds,

View File

@@ -40,6 +40,8 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewStoryStartParams;
use codex_app_server_protocol::ReviewStoryStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
@@ -978,6 +980,24 @@ impl AppServerSession {
.wrap_err("review/start failed in TUI")
}
pub(crate) async fn review_story_start(
&mut self,
thread_id: ThreadId,
target: ReviewTarget,
) -> Result<ReviewStoryStartResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ReviewStoryStart {
request_id,
params: ReviewStoryStartParams {
thread_id: thread_id.to_string(),
target,
},
})
.await
.wrap_err("reviewStory/start failed in TUI")
}
pub(crate) async fn skills_list(
&mut self,
params: SkillsListParams,

View File

@@ -219,6 +219,7 @@ impl ChatWidget {
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::ThreadStarted(_)
| ServerNotification::ReviewStorySnapshotUpdated(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadUnarchived(_)

View File

@@ -6,6 +6,17 @@ impl ChatWidget {
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Create review story".to_string(),
description: Some("Step-by-step walkthrough".into()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReviewStoryPopup);
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: Some("(PR Style)".into()),
@@ -60,6 +71,53 @@ impl ChatWidget {
});
}
pub(crate) fn open_review_story_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Story against a base branch".to_string(),
description: Some("(PR Style)".into()),
actions: vec![Box::new({
let cwd = self.config.cwd.to_path_buf();
move |tx| {
tx.send(AppEvent::OpenReviewStoryBranchPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
items.push(SelectionItem {
name: "Story for uncommitted changes".to_string(),
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.review_story(ReviewTarget::UncommittedChanges);
})],
dismiss_on_select: true,
..Default::default()
});
items.push(SelectionItem {
name: "Story for a commit".to_string(),
actions: vec![Box::new({
let cwd = self.config.cwd.to_path_buf();
move |tx| {
tx.send(AppEvent::OpenReviewStoryCommitPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a review story source".into()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) {
let branches = local_git_branches(cwd).await;
let current_branch = current_branch_name(cwd)
@@ -92,6 +150,38 @@ impl ChatWidget {
});
}
pub(crate) async fn show_review_story_branch_picker(&mut self, cwd: &Path) {
let branches = local_git_branches(cwd).await;
let current_branch = current_branch_name(cwd)
.await
.unwrap_or_else(|| "(detached HEAD)".to_string());
let mut items: Vec<SelectionItem> = Vec::with_capacity(branches.len());
for option in branches {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review_story(ReviewTarget::BaseBranch {
branch: branch.clone(),
});
})],
dismiss_on_select: true,
search_value: Some(option),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a base branch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
..Default::default()
});
}
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
let commits = recent_commits(cwd, /*limit*/ 100).await;
@@ -125,6 +215,39 @@ impl ChatWidget {
});
}
pub(crate) async fn show_review_story_commit_picker(&mut self, cwd: &Path) {
let commits = recent_commits(cwd, /*limit*/ 100).await;
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
for entry in commits {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review_story(ReviewTarget::Commit {
sha: sha.clone(),
title: Some(subject.clone()),
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit for the story".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
pub(crate) fn show_review_custom_prompt(&mut self) {
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new(

View File

@@ -195,6 +195,9 @@ impl ChatWidget {
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Story => {
self.open_review_story_popup();
}
SlashCommand::Rename => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
@@ -955,6 +958,7 @@ impl ChatWidget {
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Review
| SlashCommand::Story
| SlashCommand::Model
| SlashCommand::Realtime
| SlashCommand::Settings

View File

@@ -1038,7 +1038,8 @@ async fn review_popup_custom_prompt_action_sends_event() {
// Open the preset selection popup
chat.open_review_popup();
// Move selection down to the fourth item: "Custom review instructions"
// Move selection down to the fifth item: "Custom review instructions"
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));

View File

@@ -28,6 +28,7 @@ pub enum SlashCommand {
Skills,
Hooks,
Review,
Story,
Rename,
New,
Resume,
@@ -84,6 +85,7 @@ impl SlashCommand {
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Story => "explain changes as a step-by-step review story",
SlashCommand::Rename => "rename the current thread",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Clear => "clear the terminal and start a new chat",
@@ -148,6 +150,7 @@ impl SlashCommand {
matches!(
self,
SlashCommand::Review
| SlashCommand::Story
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::Goal
@@ -194,6 +197,7 @@ impl SlashCommand {
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Review
| SlashCommand::Story
| SlashCommand::Plan
| SlashCommand::Clear
| SlashCommand::Logout

View File

@@ -0,0 +1,42 @@
(eval):5: parse error near `end'
# Review Story v1
Status: accepted
Codex will introduce a Review Story workflow that explains a concrete code change as an ordered, anchored story for reviewers. The story is a navigation and understanding artifact, distinct from findings-oriented code review, and v1 will expose it through app-server v2 plus a TUI surface.
## Context
Large PRs are hard to review when the reviewer only sees an alphabetical file list or a flat diff. Recent review tools, especially CodeRabbit Change Stack and PR Walkthroughs, show that reviewers benefit from a logical reading order, grouped explanations, and range-specific summaries. Codex already has `/review`, but that flow is optimized for finding issues and producing verdicts, not for helping a human understand the shape of a change step by step.
## Decision
Review Story v1 will use a shared app-server v2 API namespace, `reviewStory/*`, backed by a reusable Rust implementation and a separate SQLite story database managed by the state layer. The TUI will be the first Story Surface and will provide a read-only Story Overlay; the App UI can later consume the same API and snapshot model.
A Review Story is generated from a Concrete Story Source: branch comparison, uncommitted changes, or a single commit. Custom review instructions are out of scope for v1 unless they resolve to a concrete source. Each Story Source receives a deterministic Source Fingerprint so saved Story Snapshots can be marked stale instead of silently rewritten.
Story generation is progressive. Codex first builds a deterministic Evidence Graph with stable Anchor Ids for files and hunks. A model then creates a strict JSON outline that orders Story Steps using only those Anchor Ids. After the outline is validated, Codex exposes a Progressive Story Snapshot and enriches steps in small batches with bounded parallelism. Story Surfaces receive full Snapshot Updates and replace their local snapshot with the newest version.
Story Snapshots are persisted in a Story Store, not embedded wholesale in thread history. Thread history records lifecycle events that point to snapshot ids. Refreshing a stale story creates a new snapshot linked to the previous one, preserving Snapshot Lineage.
## Considered Options
- Reuse `/review`: rejected because findings and correctness verdicts should remain separate from explanatory navigation.
- Store snapshots only in thread history: rejected because snapshots are structured, mutable during generation, and shared by multiple surfaces.
- Generate the whole story in one model call: rejected because users should be able to navigate once a validated outline exists.
- Let the model invent file paths and line ranges: rejected because Change Anchors must be system-validated.
- Fully normalize the story database schema in v1: rejected because the API usually reads full snapshots, while indexed lookup/status fields are enough initially.
## V1 Shape
The initial `StorySnapshot` should expose snapshot identity, thread id, source, source fingerprint, status, timestamps, optional previous snapshot id, title, overview, steps, anchors, and staleness.
Each `StoryStep` should expose a stable id, display index, title, goal, summary, dependency rationale, anchor ids, review focus, readiness, and optional error. Step readiness can be `outline`, `enriching`, `ready`, or `failed`.
The Story Overlay should show an ordered step list, a diff view filtered to the selected step's Change Anchors, and the selected step's goal, summary, dependency rationale, and Review Focus. V1 is read-only: no GitHub comments or review submission from the overlay.
## Consequences
This design gives reviewers fast, trustworthy navigation without turning Review Story into another finding engine. It also creates durable product boundaries: app-server v2 owns the contract, the Story Database owns structured persistence, and Story Surfaces render snapshots without re-deriving the diff story themselves.
The trade-off is more infrastructure in v1: a story database, source fingerprints, snapshot lifecycle notifications, and structured model schemas are required before the feature feels complete. The payoff is that TUI and App UI can share one artifact, and future features such as comments, diagrams, snapshot comparison, or findings attached to Story Steps can be added without replacing the core model.