revspec 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +60 -68
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +15 -1
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +69 -41
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +145 -108
  10. package/src/tui/comment-input.ts +9 -13
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +13 -16
  13. package/src/tui/spinner.ts +81 -0
  14. package/src/tui/status-bar.ts +9 -6
  15. package/src/tui/thread-list.ts +62 -22
  16. package/src/tui/ui/keymap.ts +55 -0
  17. package/.github/workflows/ci.yml +0 -18
  18. package/CLAUDE.md +0 -29
  19. package/bun.lock +0 -216
  20. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  21. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  22. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  23. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  24. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  25. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  26. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  27. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  28. package/scripts/install-skill.sh +0 -20
  29. package/scripts/release.sh +0 -52
  30. package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
  31. package/test/e2e/fixtures/spec.md +0 -36
  32. package/test/e2e/harness.ts +0 -80
  33. package/test/e2e/snapshot.test.ts +0 -182
  34. package/test/integration/cli-reply.test.ts +0 -140
  35. package/test/integration/cli-watch.test.ts +0 -216
  36. package/test/integration/cli.test.ts +0 -160
  37. package/test/integration/e2e-live.test.ts +0 -171
  38. package/test/integration/live-interaction.test.ts +0 -398
  39. package/test/integration/opentui-smoke.test.ts +0 -12
  40. package/test/unit/protocol/live-events.test.ts +0 -509
  41. package/test/unit/protocol/live-merge.test.ts +0 -167
  42. package/test/unit/protocol/merge.test.ts +0 -100
  43. package/test/unit/protocol/read.test.ts +0 -92
  44. package/test/unit/protocol/types.test.ts +0 -95
  45. package/test/unit/protocol/write.test.ts +0 -72
  46. package/test/unit/state/review-state.test.ts +0 -399
  47. package/test/unit/tui/pager.test.ts +0 -159
  48. package/test/unit/tui/ui/keybinds.test.ts +0 -71
  49. package/tsconfig.json +0 -14
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Revspec
2
2
 
3
- A review tool for AI-generated spec documents with real-time AI conversation. Comment on specific lines, get AI replies instantly, resolve discussions, and approve — all without leaving the terminal.
3
+ A review tool for AI-generated spec documents with real-time AI conversation. Comment on specific lines, get AI replies instantly, resolve discussions, submit for rewrites, and approve — all without leaving the terminal.
4
4
 
5
5
  ## Why
6
6
 
@@ -27,20 +27,7 @@ cd revspec && bun install && bun link
27
27
  revspec spec.md
28
28
  ```
29
29
 
30
- Opens a TUI in line mode with vim-style navigation. Press `c` on any line to open a thread and start commenting.
31
-
32
- ### Markdown rendering
33
-
34
- Revspec renders markdown in-place (toggle with `m`):
35
-
36
- - **Headings** — colored and bold, `#`–`######`
37
- - **Inline** — bold (`**`/`__`), italic (`*`/`_`), bold-italic (`***`), strikethrough (`~~`), `code`, [links](url)
38
- - **Fenced code blocks** — ` ``` ` markers dimmed, body in green
39
- - **Tables** — box-drawing borders, header row bolded, auto-column-widths
40
- - **Lists** — unordered (`•`), ordered, task lists (`☐`/`☑`)
41
- - **Blockquotes** — bar gutter, italicized text
42
- - **Cursor line** highlighting across all elements
43
- - **Search highlights** — colored match segments
30
+ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread and start commenting.
44
31
 
45
32
  ### Keybindings
46
33
 
@@ -52,7 +39,7 @@ Revspec renders markdown in-place (toggle with `m`):
52
39
  | `gg` / `G` | Go to top / bottom |
53
40
  | `Ctrl+D/U` | Half page down/up |
54
41
  | `zz` | Center cursor line in viewport |
55
- | `/` | Search (smartcase: lowercase = case-insensitive, any uppercase = case-sensitive) |
42
+ | `/` | Search (smartcase) |
56
43
  | `n/N` | Next/prev search match |
57
44
  | `Esc` | Clear search highlights |
58
45
  | `]t/[t` | Next/prev thread |
@@ -66,68 +53,88 @@ Revspec renders markdown in-place (toggle with `m`):
66
53
  | `r` | Resolve thread (toggle) |
67
54
  | `R` | Resolve all pending |
68
55
  | `dd` | Delete thread (with confirm) |
69
- | `T` | List threads |
70
- | `a` | Approve spec |
56
+ | `t` | List threads |
57
+ | `S` | Submit for rewrite (AI updates spec, TUI reloads) |
58
+ | `A` | Approve spec (finalize and exit) |
71
59
 
72
60
  **Commands**
73
61
 
74
62
  | Key | Action |
75
63
  |-----|--------|
76
- | `:w` | Merge changes to review JSON |
77
- | `:wq` / `:qw` | Merge and quit |
78
- | `:q` | Quit (blocks if unsaved) |
79
- | `:q!` | Quit without merging |
80
- | `:{N}` | Jump to line N (e.g. `:42`) |
81
- | `Ctrl+C` | Quit without merging |
64
+ | `:q` | Quit (warns if unresolved threads) |
65
+ | `:q!` | Force quit |
66
+ | `:{N}` | Jump to line N |
67
+ | `Ctrl+C` | Force quit |
82
68
  | `?` | Help |
83
69
 
70
+ **Popups**
71
+
72
+ | Key | Action |
73
+ |-----|--------|
74
+ | `y/Enter` | Confirm / select |
75
+ | `q/Esc` | Cancel / close |
76
+
84
77
  ### Thread popup
85
78
 
86
79
  The thread popup has two modes:
87
80
 
88
81
  - **Insert mode** — type your comment, `Tab` sends, `Esc` switches to normal mode
89
- - **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `Esc` to close
82
+ - **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
83
+
84
+ ### Markdown rendering
85
+
86
+ Revspec renders markdown in-place:
87
+
88
+ - **Headings** — colored and bold, `#`–`######`
89
+ - **Inline** — bold, italic, bold-italic, strikethrough, `code`, links
90
+ - **Fenced code blocks** — markers dimmed, body in green
91
+ - **Tables** — box-drawing borders, header row bolded, auto-column-widths
92
+ - **Lists** — unordered, ordered, task lists
93
+ - **Blockquotes** — bar gutter, italicized text
94
+ - **Cursor line** highlighting and **search highlights**
90
95
 
91
96
  ## Live AI Integration
92
97
 
93
- Revspec supports real-time communication with AI coding tools (Claude Code, opencode, etc.) via two CLI subcommands:
98
+ Revspec communicates with AI coding tools (Claude Code, etc.) via CLI subcommands:
94
99
 
95
100
  ### `revspec watch <file.md>`
96
101
 
97
- Blocks until the reviewer adds comments, then returns them with spec context:
102
+ Blocks until the reviewer acts, then returns structured output:
98
103
 
99
104
  ```
100
105
  === New Comments ===
101
- Thread: t1 (line 14)
106
+ Thread: x1a3f (line 14)
102
107
  Context:
103
108
  12: The system uses polling...
104
109
  > 14: it sends a notification via webhook.
105
110
  16: resource state.
106
111
  [reviewer]: this is unclear
107
112
 
108
- To reply: revspec reply spec.md t1 "<your response>"
113
+ To reply: revspec reply spec.md x1a3f "<your response>"
109
114
  When done replying, run: revspec watch spec.md
110
115
  ```
111
116
 
117
+ Watch exits on three events:
118
+ - **Comment/reply** — returns thread content for AI to respond
119
+ - **Submit (`S`)** — returns resolved thread summaries for AI to rewrite the spec
120
+ - **Approve (`A`)** — spec is finalized
121
+ - **Session end** — reviewer quit the TUI
122
+
112
123
  ### `revspec reply <file.md> <threadId> "<text>"`
113
124
 
114
- Sends an AI reply that appears instantly in the reviewer's TUI:
115
-
116
- ```bash
117
- revspec reply spec.md t1 "Good point. I'll clarify the polling vs webhook distinction."
118
- ```
125
+ Sends an AI reply that appears instantly in the reviewer's TUI.
119
126
 
120
127
  ### The loop
121
128
 
122
129
  ```
123
130
  1. AI generates spec
124
- 2. AI launches: revspec spec.md (in tmux pane or separate terminal)
131
+ 2. AI launches: revspec spec.md (in tmux pane)
125
132
  3. AI runs: revspec watch spec.md (blocks)
126
- 4. Reviewer comments on lines in the TUI
127
- 5. Watch returns with comments AI replies → watch again
128
- 6. Reviewer resolves threadsapproves
129
- 7. AI reads review JSON, rewrites spec, launches new round
130
- 8. Repeat until clean approval
133
+ 4. Reviewer comments AI replies watch again
134
+ 5. Reviewer resolves threadspresses S (submit)
135
+ 6. Watch returns resolved thread summaries AI rewrites spec
136
+ 7. TUI reloads with new spec reviewer continues reviewing
137
+ 8. Repeat 3-7 until A (approve)
131
138
  ```
132
139
 
133
140
  ### Claude Code skill
@@ -143,46 +150,31 @@ Then use `/revspec` in Claude Code after generating a spec.
143
150
  ## Testing
144
151
 
145
152
  ```bash
146
- bun test # Run all tests (~70s)
147
- bun test test/e2e # E2E snapshot tests only (~66s)
148
- bun test --update-snapshots # Regenerate snapshots after UI changes
153
+ bun run test # Unit + integration (~3s)
154
+ bun run test:e2e # E2E snapshot tests (~7s)
155
+ bun run test:all # Everything
149
156
  ```
150
157
 
151
- E2E tests use `bun-pty` to spawn revspec in a pseudo-terminal (80x24), send keystrokes, capture plain-text screen output, and compare against saved snapshots. Covers: navigation, search, overlays (help, comment, thread list, confirm), thread creation/resolve/delete, command mode, and context-sensitive hints.
158
+ E2E tests use `bun-pty` to spawn revspec in a pseudo-terminal (80x24), send keystrokes, capture plain-text output, and compare snapshots.
152
159
 
153
160
  ## Protocol
154
161
 
155
- Communication happens through a JSONL file (`spec.review.live.jsonl`) — append-only, both sides write to it. On session end, events are merged into `spec.review.json`.
162
+ Communication happens through a JSONL file (`spec.review.jsonl`) — append-only, both sides write to it. The JSONL is the single source of truth for the review session.
156
163
 
157
164
  ### Event types
158
165
 
159
166
  ```jsonl
160
- {"type":"comment","threadId":"t1","line":14,"author":"reviewer","text":"unclear","ts":1710400000}
161
- {"type":"reply","threadId":"t1","author":"owner","text":"I'll fix it","ts":1710400005}
162
- {"type":"resolve","threadId":"t1","author":"reviewer","ts":1710400010}
163
- {"type":"approve","author":"reviewer","ts":1710400050}
167
+ {"type":"comment","threadId":"x1a3f","line":14,"author":"reviewer","text":"unclear","ts":1710400000}
168
+ {"type":"reply","threadId":"x1a3f","author":"owner","text":"I'll fix it","ts":1710400005}
169
+ {"type":"resolve","threadId":"x1a3f","author":"reviewer","ts":1710400010}
170
+ {"type":"submit","author":"reviewer","ts":1710400050}
171
+ {"type":"approve","author":"reviewer","ts":1710400060}
172
+ {"type":"session-end","author":"reviewer","ts":1710400070}
164
173
  ```
165
174
 
166
- ### Review JSON
167
-
168
- ```json
169
- {
170
- "file": "spec.md",
171
- "threads": [
172
- {
173
- "id": "t1",
174
- "line": 14,
175
- "status": "resolved",
176
- "messages": [
177
- { "author": "reviewer", "text": "this is unclear", "ts": 1710400000 },
178
- { "author": "owner", "text": "I'll restructure this section", "ts": 1710400005 }
179
- ]
180
- }
181
- ]
182
- }
183
- ```
175
+ The `submit` event acts as a round delimiter — the AI rewrites the spec, and the TUI reloads. Events before a `submit` reference the previous spec version.
184
176
 
185
- Thread statuses: `open` (owner's turn), `pending` (reviewer's turn), `resolved`, `outdated`.
177
+ Thread statuses: `open` (awaiting AI reply), `pending` (AI replied, awaiting reviewer), `resolved`, `outdated`.
186
178
 
187
179
  ## License
188
180
 
package/bin/revspec.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { existsSync } from "fs";
3
3
  import { resolve, basename, extname, dirname, join } from "path";
4
- import { readReviewFile } from "../src/protocol/read";
5
4
  import { runTui } from "../src/tui/app";
6
- import { readEventsFromOffset } from "../src/protocol/live-events";
7
5
 
8
6
  const args = process.argv.slice(2);
9
7
  const subcommand = args[0];
@@ -33,7 +31,7 @@ if (subcommand === "reply") {
33
31
  }
34
32
 
35
33
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
36
- console.log("Usage: revspec <file.md> [--tui|--nvim|--web]");
34
+ console.log("Usage: revspec <file.md>");
37
35
  process.exit(0);
38
36
  }
39
37
 
@@ -56,40 +54,8 @@ if (!existsSync(specPath)) {
56
54
  process.exit(1);
57
55
  }
58
56
 
59
- // 2. Derive review/jsonl paths from spec filename
60
- // e.g. spec.md -> spec.review.json, spec.review.live.jsonl
61
- const specDir = dirname(specPath);
62
- const specBase = basename(specPath, extname(specPath)); // e.g. "spec"
63
- const reviewPath = join(specDir, `${specBase}.review.json`);
64
- const jsonlPath = join(specDir, `${specBase}.review.live.jsonl`);
65
- const draftPath = join(specDir, `${specBase}.review.draft.json`);
57
+ // 2. Launch TUI
58
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
59
+ await runTui(specPath, pkg.version);
66
60
 
67
- // 3. Launch TUI (skip if REVSPEC_SKIP_TUI=1)
68
- if (process.env.REVSPEC_SKIP_TUI !== "1") {
69
- const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
70
- await runTui(specPath, reviewPath, draftPath, pkg.version);
71
- }
72
-
73
- // 4. After TUI exits, check if approved via JSONL
74
- if (existsSync(jsonlPath)) {
75
- const { events } = readEventsFromOffset(jsonlPath, 0);
76
- const wasApproved = events.some(e => e.type === "approve");
77
- if (wasApproved && existsSync(reviewPath)) {
78
- console.log(`APPROVED: ${reviewPath}`);
79
- process.exit(0);
80
- }
81
- }
82
-
83
- // 5. Check if review file exists with open/pending threads
84
- const existingReview = readReviewFile(reviewPath);
85
- if (existingReview) {
86
- const hasOpenOrPending = existingReview.threads.some(
87
- (t) => t.status === "open" || t.status === "pending"
88
- );
89
- if (hasOpenOrPending) {
90
- process.stdout.write(`${reviewPath}\n`);
91
- }
92
- }
93
-
94
- // Otherwise print nothing
95
61
  process.exit(0);
package/package.json CHANGED
@@ -1,10 +1,24 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
+ "description": "Terminal-based spec review tool with real-time AI conversation",
4
5
  "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/icyrainz/revspec.git"
10
+ },
11
+ "keywords": ["spec", "review", "tui", "ai", "claude", "vim", "terminal"],
5
12
  "bin": {
6
13
  "revspec": "./bin/revspec.ts"
7
14
  },
15
+ "files": [
16
+ "bin/",
17
+ "src/",
18
+ "skills/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
8
22
  "scripts": {
9
23
  "start": "bun run bin/revspec.ts",
10
24
  "test": "bun test test/unit test/integration",
@@ -38,7 +38,8 @@ echo $TMUX
38
38
 
39
39
  **If tmux is available:**
40
40
  ```bash
41
- tmux split-window -v "revspec <spec-file>"
41
+ # Split from Claude Code's own pane (not the user's active window)
42
+ tmux split-window -t "$TMUX_PANE" -v "revspec <spec-file>"
42
43
  ```
43
44
 
44
45
  **If no tmux:**
@@ -55,57 +56,64 @@ revspec watch <spec-file>
55
56
  This blocks until the reviewer adds comments. When it returns, you'll see output like:
56
57
 
57
58
  ```
58
- --- New threads ---
59
-
60
- [t1] line 14 (new):
59
+ === New Comments ===
60
+ Thread: x1a3f (line 14)
61
61
  Context:
62
- 12: The system uses polling...
63
- >14: it sends a notification via webhook.
64
- 16: resource state.
65
- Comment: "this is unclear"
62
+ 12: The system uses polling...
63
+ > 14: it sends a notification via webhook.
64
+ 16: resource state.
65
+ [reviewer]: this is unclear
66
+ To reply: revspec reply spec.md x1a3f "<your reply>"
66
67
 
67
- To reply: revspec reply spec.md <threadId> "<your response>"
68
68
  When done replying, run: revspec watch spec.md
69
69
  ```
70
70
 
71
71
  **For each comment:** Read the context, understand the concern, and reply thoughtfully:
72
72
 
73
73
  ```bash
74
- revspec reply <spec-file> t1 "Good point. I'll clarify — it uses polling to detect changes, then sends a webhook notification to downstream services."
74
+ revspec reply <spec-file> x1a3f "Good point. I'll clarify — it uses polling to detect changes, then sends a webhook notification to downstream services."
75
75
  ```
76
76
 
77
77
  After replying to all comments, run `revspec watch` again to wait for the next batch.
78
78
 
79
- **If watch returns "Session ended. Reviewer exited revspec."** — the reviewer closed the TUI. Check the review JSON for resolved threads that require spec changes (see Step 4). If no resolved threads, stop and wait — the user can invoke `/revspec` again later to resume.
79
+ **If watch returns "Session ended. Reviewer exited revspec."** — the reviewer closed the TUI. Stop and wait — the user can invoke `/revspec` again later.
80
80
 
81
81
  **Important:** Your replies should be substantive — address the concern, explain your reasoning, or acknowledge the change you'll make. Don't just say "noted" or "will fix."
82
82
 
83
- ## Step 4: Handle Session End or Approval
83
+ ## Step 4: Handle Submit, Session End, or Approval
84
84
 
85
- The watch loop ends in one of two ways:
85
+ The watch loop ends in one of three ways:
86
86
 
87
- ### Session ended (reviewer exited with `:q`)
87
+ ### Submit (reviewer pressed `S`)
88
+
89
+ Watch returns resolved thread summaries:
90
+
91
+ ```
92
+ === Submit: Rewrite Requested ===
88
93
 
89
- Read the review JSON and check for **resolved threads**. Resolved = the reviewer acknowledged your reply and wants you to make that change.
94
+ Resolved threads:
95
+ x1a3f (line 14): "this is unclear"
96
+ → AI: "I'll clarify the polling vs webhook distinction."
90
97
 
91
- **If resolved threads with actionable feedback exist:**
92
- 1. Rewrite the spec incorporating the feedback from resolved threads
93
- 2. Commit the updated spec
94
- 3. Append a round marker to the JSONL:
95
- ```bash
96
- echo '{"type":"round","author":"owner","round":2,"ts":'$(date +%s000)'}' >> <spec-file>.review.live.jsonl
97
- ```
98
- 4. Launch a new revspec session (go back to Step 2) so the reviewer can verify the changes
98
+ Rewrite the spec incorporating the above, then run: revspec watch spec.md
99
+ ```
100
+
101
+ 1. Read the current spec and compare against the resolved threads
102
+ 2. If changes are needed: rewrite the spec and save — this triggers the TUI to reload automatically
103
+ 3. If the spec already reflects the feedback (e.g., crash recovery): skip rewrite
104
+ 4. Run `revspec watch` again — the reviewer is still in the TUI and will see the new content
105
+
106
+ **Important:** Watch may re-output the submit summary on crash recovery. Always verify whether the spec already incorporates the feedback before rewriting.
107
+
108
+ ### Session ended (reviewer exited with `:q`)
99
109
 
100
- **If no resolved threads (or only open/pending threads):**
101
- The reviewer left without resolving anything — stop and wait. The user can invoke `/revspec` again later.
110
+ The reviewer closed the TUI. Stop and wait — the user can invoke `/revspec` again later.
102
111
 
103
- ### Approved (reviewer pressed `a`)
112
+ ### Approved (reviewer pressed `A`)
104
113
 
105
114
  Watch returns:
106
115
  ```
107
116
  Review approved.
108
- Review file: <path-to-review.json>
109
117
  ```
110
118
 
111
119
  The spec is finalized. Report: "Spec approved and finalized at `<spec-file>`. Ready to proceed with implementation."
@@ -115,7 +123,6 @@ The spec is finalized. Report: "Spec approved and finalized at `<spec-file>`. Re
115
123
  - **Open** — under discussion, AI replied, reviewer hasn't responded yet
116
124
  - **Pending** — owner replied, waiting for reviewer to read
117
125
  - **Resolved** — reviewer acknowledged the plan, AI should make the change
118
- - **Approve** — spec is final, proceed to implementation
119
126
 
120
127
  ## Loop Summary
121
128
 
@@ -123,9 +130,9 @@ The spec is finalized. Report: "Spec approved and finalized at `<spec-file>`. Re
123
130
  1. Launch revspec on spec file
124
131
  2. Watch for comments
125
132
  3. Reply to each comment
126
- 4. Watch again (repeat 2-3 until session-end or approval)
127
- 5. On session-end: check for resolved threads
128
- 6. If resolved threads need spec changes: rewrite spec, launch new round (go to 1)
133
+ 4. Watch again (repeat 2-3 until submit/session-end/approval)
134
+ 5. On submit: verify and rewrite spec if needed (triggers TUI reload), go to step 2
135
+ 6. On session-end: stop, user will invoke /revspec again if needed
129
136
  7. On approval: spec is finalized, done
130
137
  ```
131
138
 
package/src/cli/reply.ts CHANGED
@@ -26,7 +26,7 @@ export function runReply(
26
26
  // Derive JSONL path
27
27
  const dir = dirname(specPath);
28
28
  const base = basename(specPath, ".md");
29
- const jsonlPath = `${dir}/${base}.review.live.jsonl`;
29
+ const jsonlPath = `${dir}/${base}.review.jsonl`;
30
30
 
31
31
  // Validate JSONL exists
32
32
  if (!existsSync(jsonlPath)) {
package/src/cli/watch.ts CHANGED
@@ -19,9 +19,9 @@ export async function runWatch(specFile: string): Promise<void> {
19
19
  // Derive paths
20
20
  const dir = dirname(specPath);
21
21
  const base = basename(specPath, ".md");
22
- const jsonlPath = join(dir, `${base}.review.live.jsonl`);
23
- const offsetPath = join(dir, `${base}.review.live.offset`);
24
- const lockPath = join(dir, `${base}.review.live.lock`);
22
+ const jsonlPath = join(dir, `${base}.review.jsonl`);
23
+ const offsetPath = join(dir, `${base}.review.offset`);
24
+ const lockPath = join(dir, `${base}.review.lock`);
25
25
  const reviewPath = join(dir, `${base}.review.json`);
26
26
 
27
27
  // Handle lock file
@@ -73,7 +73,7 @@ export async function runWatch(specFile: string): Promise<void> {
73
73
  offset
74
74
  );
75
75
  if (result.approved) {
76
- console.log(`Review approved.\nReview file: ${reviewPath}`);
76
+ console.log("Review approved.");
77
77
  cleanupFiles(lockPath, offsetPath);
78
78
  } else if (result.output) {
79
79
  process.stdout.write(result.output);
@@ -99,7 +99,7 @@ export async function runWatch(specFile: string): Promise<void> {
99
99
  );
100
100
 
101
101
  if (result.approved) {
102
- console.log(`Review approved.\nReview file: ${reviewPath}`);
102
+ console.log("Review approved.");
103
103
  cleanupFiles(lockPath, offsetPath);
104
104
  process.exit(0);
105
105
  }
@@ -135,7 +135,7 @@ export async function runWatch(specFile: string): Promise<void> {
135
135
 
136
136
  // Also watch the directory for the JSONL file to appear
137
137
  const dirWatcher = fsWatch(dir, (eventType, filename) => {
138
- if (filename && filename.endsWith(".live.jsonl")) {
138
+ if (filename && filename.endsWith(".jsonl")) {
139
139
  setupWatcher();
140
140
  }
141
141
  });
@@ -180,7 +180,24 @@ function processNewEvents(
180
180
 
181
181
  const { events, newOffset } = readEventsFromOffset(jsonlPath, offset);
182
182
 
183
+ // Recovery: detect pending unprocessed submit
183
184
  if (events.length === 0) {
185
+ const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
186
+ const lastSubmitIdx = allEvents.findLastIndex(e => e.type === "submit");
187
+ if (lastSubmitIdx >= 0) {
188
+ const afterSubmit = allEvents.slice(lastSubmitIdx + 1);
189
+ const hasNewActivity = afterSubmit.some(e =>
190
+ e.type === "comment" || e.type === "reply" ||
191
+ e.type === "approve" || e.type === "session-end"
192
+ );
193
+ if (!hasNewActivity) {
194
+ const roundStart = findCurrentRoundStartIndex(allEvents);
195
+ const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
196
+ const resolved = currentRoundThreads.filter(t => t.status === "resolved");
197
+ const output = formatSubmitOutput(resolved, specPath);
198
+ return { approved: false, output, newOffset: offset };
199
+ }
200
+ }
184
201
  return { approved: false, output: "", newOffset: offset };
185
202
  }
186
203
 
@@ -193,6 +210,17 @@ function processNewEvents(
193
210
  return { approved: true, output: "", newOffset };
194
211
  }
195
212
 
213
+ // Check for submit event — priority over session-end
214
+ const hasSubmit = events.some((e) => e.type === "submit");
215
+ if (hasSubmit) {
216
+ const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
217
+ const roundStart = findCurrentRoundStartIndex(allEvents);
218
+ const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
219
+ const resolved = currentRoundThreads.filter(t => t.status === "resolved");
220
+ const output = formatSubmitOutput(resolved, specPath);
221
+ return { approved: false, output, newOffset };
222
+ }
223
+
196
224
  // Check for session-end — TUI exited, break the loop
197
225
  const hasSessionEnd = events.some((e) => e.type === "session-end");
198
226
  if (hasSessionEnd) {
@@ -233,8 +261,6 @@ function formatWatchOutput(
233
261
  // Group events by type
234
262
  const newCommentThreadIds: string[] = [];
235
263
  const replyThreadIds: string[] = [];
236
- const resolvedThreadIds: string[] = [];
237
- const deletedThreadIds: string[] = [];
238
264
 
239
265
  const seen = new Set<string>();
240
266
 
@@ -249,14 +275,6 @@ function formatWatchOutput(
249
275
  if (!replyThreadIds.includes(tid)) {
250
276
  replyThreadIds.push(tid);
251
277
  }
252
- } else if (event.type === "resolve") {
253
- if (!resolvedThreadIds.includes(tid)) {
254
- resolvedThreadIds.push(tid);
255
- }
256
- } else if (event.type === "delete") {
257
- if (!deletedThreadIds.includes(tid)) {
258
- deletedThreadIds.push(tid);
259
- }
260
278
  }
261
279
  }
262
280
 
@@ -315,31 +333,6 @@ function formatWatchOutput(
315
333
  }
316
334
  }
317
335
 
318
- // Resolved threads
319
- if (resolvedThreadIds.length > 0) {
320
- lines.push("=== Resolved ===");
321
- for (const tid of resolvedThreadIds) {
322
- const thread = threadsById.get(tid);
323
- if (!thread) continue;
324
- lines.push(`Thread: ${tid} (line ${thread.line}) — resolved`);
325
- lines.push("");
326
- }
327
- }
328
-
329
- // Deleted threads
330
- if (deletedThreadIds.length > 0) {
331
- lines.push("=== Deleted ===");
332
- for (const tid of deletedThreadIds) {
333
- const thread = threadsById.get(tid);
334
- if (thread) {
335
- lines.push(`Thread: ${tid} (line ${thread.line}) — deleted`);
336
- } else {
337
- lines.push(`Thread: ${tid} — deleted`);
338
- }
339
- lines.push("");
340
- }
341
- }
342
-
343
336
  // Add footer instruction
344
337
  const hasActionable = newCommentThreadIds.length > 0 || replyThreadIds.length > 0;
345
338
  if (hasActionable) {
@@ -368,6 +361,41 @@ function getContext(
368
361
  return result;
369
362
  }
370
363
 
364
+ function findCurrentRoundStartIndex(events: LiveEvent[]): number {
365
+ let count = 0;
366
+ for (let i = events.length - 1; i >= 0; i--) {
367
+ if (events[i].type === "submit") {
368
+ count++;
369
+ if (count === 2) return i + 1;
370
+ }
371
+ }
372
+ return 0;
373
+ }
374
+
375
+ function formatSubmitOutput(
376
+ resolvedThreads: Thread[],
377
+ specPath: string
378
+ ): string {
379
+ const lines: string[] = [];
380
+ lines.push("=== Submit: Rewrite Requested ===");
381
+ lines.push("");
382
+ if (resolvedThreads.length > 0) {
383
+ lines.push("Resolved threads:");
384
+ for (const t of resolvedThreads) {
385
+ const reviewerMsgs = t.messages.filter(m => m.author === "reviewer");
386
+ const ownerMsgs = t.messages.filter(m => m.author === "owner");
387
+ lines.push(` ${t.id} (line ${t.line}): "${reviewerMsgs.map(m => m.text).join("; ")}"`);
388
+ if (ownerMsgs.length > 0) {
389
+ lines.push(` → AI: "${ownerMsgs.map(m => m.text).join("; ")}"`);
390
+ }
391
+ }
392
+ lines.push("");
393
+ }
394
+ lines.push(`Rewrite the spec incorporating the above, then run: revspec watch ${basename(specPath)}`);
395
+ lines.push("");
396
+ return lines.join("\n");
397
+ }
398
+
371
399
  function cleanupFiles(lockPath: string, offsetPath: string): void {
372
400
  if (existsSync(lockPath)) unlinkSync(lockPath);
373
401
  if (existsSync(offsetPath)) unlinkSync(offsetPath);
@@ -9,7 +9,8 @@ export type LiveEventType =
9
9
  | "approve"
10
10
  | "delete"
11
11
  | "round"
12
- | "session-end";
12
+ | "session-end"
13
+ | "submit";
13
14
 
14
15
  export interface LiveEvent {
15
16
  type: LiveEventType;
@@ -30,6 +31,7 @@ const VALID_LIVE_EVENT_TYPES: readonly LiveEventType[] = [
30
31
  "delete",
31
32
  "round",
32
33
  "session-end",
34
+ "submit",
33
35
  ];
34
36
 
35
37
  export function isValidLiveEvent(value: unknown): value is LiveEvent {
@@ -49,7 +51,7 @@ export function isValidLiveEvent(value: unknown): value is LiveEvent {
49
51
  if (typeof v.author !== "string") return false;
50
52
 
51
53
  // threadId required for all except approve, round, and session-end
52
- if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end") {
54
+ if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end" && v.type !== "submit") {
53
55
  if (typeof v.threadId !== "string") return false;
54
56
  }
55
57
 
@@ -186,25 +188,13 @@ export function replayEventsToThreads(events: LiveEvent[]): Thread[] {
186
188
 
187
189
  case "delete": {
188
190
  if (!event.threadId) break;
189
- const thread = threadsMap.get(event.threadId);
190
- if (!thread) break;
191
- // Remove the last reviewer message
192
- for (let i = thread.messages.length - 1; i >= 0; i--) {
193
- if (thread.messages[i].author === "reviewer") {
194
- thread.messages.splice(i, 1);
195
- break;
196
- }
197
- }
198
- // Re-derive status from the new last message
199
- const lastMsg = thread.messages[thread.messages.length - 1];
200
- if (lastMsg) {
201
- thread.status = lastMsg.author === "owner" ? "pending" : "open";
202
- }
191
+ threadsMap.delete(event.threadId);
203
192
  break;
204
193
  }
205
194
 
206
195
  case "approve":
207
196
  case "round":
197
+ case "submit":
208
198
  // Skip these event types
209
199
  break;
210
200
  }