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.
- package/README.md +60 -68
- package/bin/revspec.ts +4 -38
- package/package.json +15 -1
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +69 -41
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +145 -108
- package/src/tui/comment-input.ts +9 -13
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +13 -16
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -6
- package/src/tui/thread-list.ts +62 -22
- package/src/tui/ui/keymap.ts +55 -0
- package/.github/workflows/ci.yml +0 -18
- package/CLAUDE.md +0 -29
- package/bun.lock +0 -216
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
- package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
- package/scripts/install-skill.sh +0 -20
- package/scripts/release.sh +0 -52
- package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
- package/test/e2e/fixtures/spec.md +0 -36
- package/test/e2e/harness.ts +0 -80
- package/test/e2e/snapshot.test.ts +0 -182
- package/test/integration/cli-reply.test.ts +0 -140
- package/test/integration/cli-watch.test.ts +0 -216
- package/test/integration/cli.test.ts +0 -160
- package/test/integration/e2e-live.test.ts +0 -171
- package/test/integration/live-interaction.test.ts +0 -398
- package/test/integration/opentui-smoke.test.ts +0 -12
- package/test/unit/protocol/live-events.test.ts +0 -509
- package/test/unit/protocol/live-merge.test.ts +0 -167
- package/test/unit/protocol/merge.test.ts +0 -100
- package/test/unit/protocol/read.test.ts +0 -92
- package/test/unit/protocol/types.test.ts +0 -95
- package/test/unit/protocol/write.test.ts +0 -72
- package/test/unit/state/review-state.test.ts +0 -399
- package/test/unit/tui/pager.test.ts +0 -159
- package/test/unit/tui/ui/keybinds.test.ts +0 -71
- 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
|
|
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
|
|
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
|
-
| `
|
|
70
|
-
| `
|
|
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
|
-
| `:
|
|
77
|
-
| `:
|
|
78
|
-
| `:
|
|
79
|
-
|
|
|
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
|
|
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
|
|
102
|
+
Blocks until the reviewer acts, then returns structured output:
|
|
98
103
|
|
|
99
104
|
```
|
|
100
105
|
=== New Comments ===
|
|
101
|
-
Thread:
|
|
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
|
|
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
|
|
131
|
+
2. AI launches: revspec spec.md (in tmux pane)
|
|
125
132
|
3. AI runs: revspec watch spec.md (blocks)
|
|
126
|
-
4. Reviewer comments
|
|
127
|
-
5.
|
|
128
|
-
6.
|
|
129
|
-
7.
|
|
130
|
-
8. Repeat until
|
|
133
|
+
4. Reviewer comments → AI replies → watch again
|
|
134
|
+
5. Reviewer resolves threads → presses 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
|
|
147
|
-
bun
|
|
148
|
-
bun test
|
|
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
|
|
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.
|
|
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":"
|
|
161
|
-
{"type":"reply","threadId":"
|
|
162
|
-
{"type":"resolve","threadId":"
|
|
163
|
-
{"type":"
|
|
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
|
-
|
|
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` (
|
|
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>
|
|
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.
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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",
|
package/skills/revspec/SKILL.md
CHANGED
|
@@ -38,7 +38,8 @@ echo $TMUX
|
|
|
38
38
|
|
|
39
39
|
**If tmux is available:**
|
|
40
40
|
```bash
|
|
41
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
[t1] line 14 (new):
|
|
59
|
+
=== New Comments ===
|
|
60
|
+
Thread: x1a3f (line 14)
|
|
61
61
|
Context:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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>
|
|
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.
|
|
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
|
|
85
|
+
The watch loop ends in one of three ways:
|
|
86
86
|
|
|
87
|
-
###
|
|
87
|
+
### Submit (reviewer pressed `S`)
|
|
88
|
+
|
|
89
|
+
Watch returns resolved thread summaries:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
=== Submit: Rewrite Requested ===
|
|
88
93
|
|
|
89
|
-
|
|
94
|
+
Resolved threads:
|
|
95
|
+
x1a3f (line 14): "this is unclear"
|
|
96
|
+
→ AI: "I'll clarify the polling vs webhook distinction."
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
127
|
-
5. On
|
|
128
|
-
6.
|
|
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.
|
|
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.
|
|
23
|
-
const offsetPath = join(dir, `${base}.review.
|
|
24
|
-
const lockPath = join(dir, `${base}.review.
|
|
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(
|
|
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(
|
|
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(".
|
|
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
|
-
|
|
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
|
}
|