revspec 0.6.0 → 0.7.1
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 +122 -58
- 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
|
@@ -1,1877 +0,0 @@
|
|
|
1
|
-
# Live AI Integration Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Enable real-time human-AI conversation within the revspec TUI via a shared JSONL file, with `watch` and `reply` CLI subcommands for AI tool integration.
|
|
6
|
-
|
|
7
|
-
**Architecture:** JSONL append-only event log bridges the TUI (reviewer) and AI tool (owner). The TUI writes reviewer events and watches for owner replies. Two new CLI subcommands (`watch`, `reply`) let any AI tool participate without knowing revspec internals. On session end, JSONL replays into structured review JSON.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Bun + TypeScript, `fs.watch()` / `fs.watchFile()` for change detection, `bun:test` for testing.
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-03-14-live-ai-integration-design.md`
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## File Structure
|
|
16
|
-
|
|
17
|
-
### New files
|
|
18
|
-
|
|
19
|
-
| File | Responsibility |
|
|
20
|
-
|------|---------------|
|
|
21
|
-
| `src/protocol/paths.ts` | Derive sibling file paths from spec path (review, jsonl, offset, lock) |
|
|
22
|
-
| `src/protocol/live-events.ts` | JSONL event types, validation, append, read-from-offset, replay-to-threads |
|
|
23
|
-
| `src/protocol/live-merge.ts` | Replay JSONL events → ReviewFile (merge with existing JSON) |
|
|
24
|
-
| `src/tui/live-watcher.ts` | File watcher that detects incoming owner replies, updates state, triggers re-render |
|
|
25
|
-
| `test/protocol/live-events.test.ts` | Tests for event types, validation, append, read, replay |
|
|
26
|
-
| `test/protocol/live-merge.test.ts` | Tests for JSONL → JSON merge |
|
|
27
|
-
| `test/cli-watch.test.ts` | Tests for `revspec watch` subcommand |
|
|
28
|
-
| `test/cli-reply.test.ts` | Tests for `revspec reply` subcommand |
|
|
29
|
-
| `test/e2e-live.test.ts` | End-to-end test: TUI writes → watch detects → reply appends → TUI reads |
|
|
30
|
-
|
|
31
|
-
### Modified files
|
|
32
|
-
|
|
33
|
-
| File | Changes |
|
|
34
|
-
|------|---------|
|
|
35
|
-
| `src/protocol/types.ts` | `Message.author` → `"reviewer" \| "owner"`, add optional `ts` field |
|
|
36
|
-
| `src/state/review-state.ts` | Add unread tracking, `addOwnerReply()`, `nextUnreadThread()`, `prevUnreadThread()`, `unreadCount()`, `markRead()` |
|
|
37
|
-
| `bin/revspec.ts` | Subcommand routing (`watch`, `reply`), updated merge-on-exit to use JSONL |
|
|
38
|
-
| `src/tui/app.ts` | Write to JSONL on actions, start live watcher, `]r`/`[r` keybindings, updated quit semantics |
|
|
39
|
-
| `src/tui/pager.ts` | Unread indicator on thread lines |
|
|
40
|
-
| `src/tui/comment-input.ts` | Timestamp display in thread popup |
|
|
41
|
-
| `src/tui/status-bar.ts` | Show unread owner reply count |
|
|
42
|
-
| `test/protocol/types.test.ts` | Update for reviewer/owner, ts field |
|
|
43
|
-
| `test/state/review-state.test.ts` | Tests for unread tracking |
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## Chunk 1: Protocol Foundation
|
|
48
|
-
|
|
49
|
-
### Task 1: Update Message type (reviewer/owner + ts)
|
|
50
|
-
|
|
51
|
-
**Files:**
|
|
52
|
-
- Modify: `src/protocol/types.ts`
|
|
53
|
-
- Modify: `test/protocol/types.test.ts`
|
|
54
|
-
- Modify: `src/tui/comment-input.ts` (author label references)
|
|
55
|
-
|
|
56
|
-
- [ ] **Step 1: Update type definition**
|
|
57
|
-
|
|
58
|
-
In `src/protocol/types.ts`, change `Message`:
|
|
59
|
-
```typescript
|
|
60
|
-
export interface Message {
|
|
61
|
-
author: "reviewer" | "owner"
|
|
62
|
-
text: string
|
|
63
|
-
ts?: number
|
|
64
|
-
}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Update `isValidThread` to accept both old (`human`/`ai`) and new (`reviewer`/`owner`) author values for backward compat during migration.
|
|
68
|
-
|
|
69
|
-
- [ ] **Step 2: Update tests**
|
|
70
|
-
|
|
71
|
-
In `test/protocol/types.test.ts`, update all test fixtures from `"human"`/`"ai"` to `"reviewer"`/`"owner"`. Add test that `ts` is optional.
|
|
72
|
-
|
|
73
|
-
- [ ] **Step 3: Update comment-input.ts author labels**
|
|
74
|
-
|
|
75
|
-
In `src/tui/comment-input.ts`, change the author display labels:
|
|
76
|
-
- `"human"` → `"reviewer"`, display as `"You"`
|
|
77
|
-
- `"ai"` → `"owner"`, display as `" AI"` (or keep generic based on author value)
|
|
78
|
-
|
|
79
|
-
- [ ] **Step 4: Update all test fixtures across the codebase**
|
|
80
|
-
|
|
81
|
-
Update `test/protocol/merge.test.ts`, `test/protocol/read.test.ts`, `test/protocol/write.test.ts`, `test/state/review-state.test.ts`, `test/cli.test.ts` — replace all `"human"` with `"reviewer"` and `"ai"` with `"owner"` in test data.
|
|
82
|
-
|
|
83
|
-
- [ ] **Step 5: Run all tests**
|
|
84
|
-
|
|
85
|
-
Run: `bun test`
|
|
86
|
-
Expected: All tests pass with updated author values.
|
|
87
|
-
|
|
88
|
-
- [ ] **Step 6: Commit**
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
git add -A && git commit -m "refactor: change Message author from human/ai to reviewer/owner, add optional ts"
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
### Task 2: JSONL event types and validation
|
|
97
|
-
|
|
98
|
-
**Files:**
|
|
99
|
-
- Create: `src/protocol/live-events.ts`
|
|
100
|
-
- Create: `test/protocol/live-events.test.ts`
|
|
101
|
-
|
|
102
|
-
- [ ] **Step 1: Write failing tests for event types**
|
|
103
|
-
|
|
104
|
-
In `test/protocol/live-events.test.ts`:
|
|
105
|
-
```typescript
|
|
106
|
-
import { describe, it, expect } from "bun:test"
|
|
107
|
-
import {
|
|
108
|
-
type LiveEvent,
|
|
109
|
-
isValidLiveEvent,
|
|
110
|
-
appendEvent,
|
|
111
|
-
readEventsFromOffset,
|
|
112
|
-
replayEventsToThreads,
|
|
113
|
-
} from "../../src/protocol/live-events"
|
|
114
|
-
|
|
115
|
-
describe("isValidLiveEvent", () => {
|
|
116
|
-
it("accepts a valid comment event", () => {
|
|
117
|
-
const event = { type: "comment", threadId: "t1", line: 14, author: "reviewer", text: "unclear", ts: 1000 }
|
|
118
|
-
expect(isValidLiveEvent(event)).toBe(true)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it("accepts a valid reply event", () => {
|
|
122
|
-
const event = { type: "reply", threadId: "t1", author: "owner", text: "fixed", ts: 1001 }
|
|
123
|
-
expect(isValidLiveEvent(event)).toBe(true)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it("accepts a valid resolve event", () => {
|
|
127
|
-
const event = { type: "resolve", threadId: "t1", author: "reviewer", ts: 1002 }
|
|
128
|
-
expect(isValidLiveEvent(event)).toBe(true)
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it("accepts a valid approve event (no threadId)", () => {
|
|
132
|
-
const event = { type: "approve", author: "reviewer", ts: 1003 }
|
|
133
|
-
expect(isValidLiveEvent(event)).toBe(true)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it("accepts a valid round event (no threadId)", () => {
|
|
137
|
-
const event = { type: "round", author: "reviewer", round: 2, ts: 1004 }
|
|
138
|
-
expect(isValidLiveEvent(event)).toBe(true)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it("accepts a valid delete event", () => {
|
|
142
|
-
const event = { type: "delete", threadId: "t1", author: "reviewer", ts: 1005 }
|
|
143
|
-
expect(isValidLiveEvent(event)).toBe(true)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it("rejects event missing type", () => {
|
|
147
|
-
expect(isValidLiveEvent({ threadId: "t1", author: "reviewer", ts: 1 })).toBe(false)
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
it("rejects event with invalid type", () => {
|
|
151
|
-
expect(isValidLiveEvent({ type: "unknown", threadId: "t1", author: "reviewer", ts: 1 })).toBe(false)
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
it("rejects comment missing text", () => {
|
|
155
|
-
expect(isValidLiveEvent({ type: "comment", threadId: "t1", line: 1, author: "reviewer", ts: 1 })).toBe(false)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it("rejects comment missing line", () => {
|
|
159
|
-
expect(isValidLiveEvent({ type: "comment", threadId: "t1", author: "reviewer", text: "x", ts: 1 })).toBe(false)
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it("rejects event missing ts", () => {
|
|
163
|
-
expect(isValidLiveEvent({ type: "resolve", threadId: "t1", author: "reviewer" })).toBe(false)
|
|
164
|
-
})
|
|
165
|
-
})
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
169
|
-
|
|
170
|
-
Run: `bun test test/protocol/live-events.test.ts`
|
|
171
|
-
Expected: FAIL — module not found
|
|
172
|
-
|
|
173
|
-
- [ ] **Step 3: Implement event types and validation**
|
|
174
|
-
|
|
175
|
-
Create `src/protocol/live-events.ts`:
|
|
176
|
-
```typescript
|
|
177
|
-
import { appendFileSync, readFileSync, existsSync, statSync } from "fs"
|
|
178
|
-
import type { Thread, Message, Status } from "./types"
|
|
179
|
-
|
|
180
|
-
export type LiveEventType = "comment" | "reply" | "resolve" | "unresolve" | "approve" | "delete" | "round"
|
|
181
|
-
|
|
182
|
-
export interface LiveEvent {
|
|
183
|
-
type: LiveEventType
|
|
184
|
-
threadId?: string
|
|
185
|
-
line?: number
|
|
186
|
-
author: "reviewer" | "owner"
|
|
187
|
-
text?: string
|
|
188
|
-
ts: number
|
|
189
|
-
round?: number
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const VALID_TYPES: LiveEventType[] = ["comment", "reply", "resolve", "unresolve", "approve", "delete", "round"]
|
|
193
|
-
|
|
194
|
-
export function isValidLiveEvent(value: unknown): value is LiveEvent {
|
|
195
|
-
if (typeof value !== "object" || value === null) return false
|
|
196
|
-
const v = value as Record<string, unknown>
|
|
197
|
-
|
|
198
|
-
if (!VALID_TYPES.includes(v.type as LiveEventType)) return false
|
|
199
|
-
if (typeof v.ts !== "number") return false
|
|
200
|
-
if (typeof v.author !== "string") return false
|
|
201
|
-
|
|
202
|
-
const type = v.type as LiveEventType
|
|
203
|
-
|
|
204
|
-
// threadId required for all except approve and round
|
|
205
|
-
if (type !== "approve" && type !== "round") {
|
|
206
|
-
if (typeof v.threadId !== "string") return false
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// text required for comment and reply
|
|
210
|
-
if (type === "comment" || type === "reply") {
|
|
211
|
-
if (typeof v.text !== "string") return false
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// line required for comment
|
|
215
|
-
if (type === "comment") {
|
|
216
|
-
if (typeof v.line !== "number") return false
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// round field required for round event
|
|
220
|
-
if (type === "round") {
|
|
221
|
-
if (typeof v.round !== "number") return false
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return true
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export function appendEvent(jsonlPath: string, event: LiveEvent): void {
|
|
228
|
-
appendFileSync(jsonlPath, JSON.stringify(event) + "\n")
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export interface ReadResult {
|
|
232
|
-
events: LiveEvent[]
|
|
233
|
-
newOffset: number
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function readEventsFromOffset(jsonlPath: string, offset: number): ReadResult {
|
|
237
|
-
if (!existsSync(jsonlPath)) return { events: [], newOffset: offset }
|
|
238
|
-
|
|
239
|
-
const stat = statSync(jsonlPath)
|
|
240
|
-
if (stat.size <= offset) return { events: [], newOffset: offset }
|
|
241
|
-
|
|
242
|
-
const buf = readFileSync(jsonlPath)
|
|
243
|
-
const actualSize = buf.length // Use actual bytes read, not stat.size, to avoid race with concurrent writers
|
|
244
|
-
let startOffset = offset
|
|
245
|
-
|
|
246
|
-
// Alignment safety: if offset > 0, skip to next \n boundary
|
|
247
|
-
if (startOffset > 0 && startOffset < buf.length) {
|
|
248
|
-
while (startOffset < buf.length && buf[startOffset] !== 0x0a) {
|
|
249
|
-
startOffset++
|
|
250
|
-
}
|
|
251
|
-
if (startOffset < buf.length) startOffset++ // skip the \n itself
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const chunk = buf.subarray(startOffset).toString("utf-8")
|
|
255
|
-
const lines = chunk.split("\n").filter((l) => l.trim().length > 0)
|
|
256
|
-
const events: LiveEvent[] = []
|
|
257
|
-
|
|
258
|
-
for (const line of lines) {
|
|
259
|
-
try {
|
|
260
|
-
const parsed = JSON.parse(line)
|
|
261
|
-
if (isValidLiveEvent(parsed)) {
|
|
262
|
-
events.push(parsed)
|
|
263
|
-
}
|
|
264
|
-
} catch {
|
|
265
|
-
// Discard malformed lines (partial writes)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return { events, newOffset: actualSize }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function replayEventsToThreads(events: LiveEvent[]): Thread[] {
|
|
273
|
-
const threadMap = new Map<string, Thread>()
|
|
274
|
-
const deleteCounts = new Map<string, number>() // track deletes per thread
|
|
275
|
-
|
|
276
|
-
for (const event of events) {
|
|
277
|
-
if (event.type === "approve" || event.type === "round") continue
|
|
278
|
-
|
|
279
|
-
const tid = event.threadId!
|
|
280
|
-
|
|
281
|
-
if (event.type === "comment") {
|
|
282
|
-
threadMap.set(tid, {
|
|
283
|
-
id: tid,
|
|
284
|
-
line: event.line!,
|
|
285
|
-
status: "open" as Status,
|
|
286
|
-
messages: [{ author: event.author, text: event.text!, ts: event.ts }],
|
|
287
|
-
})
|
|
288
|
-
deleteCounts.set(tid, 0)
|
|
289
|
-
} else if (event.type === "reply") {
|
|
290
|
-
const thread = threadMap.get(tid)
|
|
291
|
-
if (!thread) continue
|
|
292
|
-
thread.messages.push({ author: event.author, text: event.text!, ts: event.ts })
|
|
293
|
-
thread.status = event.author === "owner" ? "pending" : "open"
|
|
294
|
-
} else if (event.type === "resolve") {
|
|
295
|
-
const thread = threadMap.get(tid)
|
|
296
|
-
if (thread) thread.status = "resolved"
|
|
297
|
-
} else if (event.type === "unresolve") {
|
|
298
|
-
const thread = threadMap.get(tid)
|
|
299
|
-
if (thread) thread.status = "open"
|
|
300
|
-
} else if (event.type === "delete") {
|
|
301
|
-
const thread = threadMap.get(tid)
|
|
302
|
-
if (!thread) continue
|
|
303
|
-
// Find last reviewer message and remove it
|
|
304
|
-
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
305
|
-
if (thread.messages[i].author === "reviewer") {
|
|
306
|
-
thread.messages.splice(i, 1)
|
|
307
|
-
break
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Exclude empty threads (all messages deleted)
|
|
314
|
-
return Array.from(threadMap.values()).filter((t) => t.messages.length > 0)
|
|
315
|
-
}
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
- [ ] **Step 4: Run tests**
|
|
319
|
-
|
|
320
|
-
Run: `bun test test/protocol/live-events.test.ts`
|
|
321
|
-
Expected: All validation tests pass.
|
|
322
|
-
|
|
323
|
-
- [ ] **Step 5: Commit**
|
|
324
|
-
|
|
325
|
-
```bash
|
|
326
|
-
git add src/protocol/live-events.ts test/protocol/live-events.test.ts
|
|
327
|
-
git commit -m "feat: add JSONL live event types, validation, append, read, replay"
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
---
|
|
331
|
-
|
|
332
|
-
### Task 3: JSONL append and read-from-offset tests
|
|
333
|
-
|
|
334
|
-
**Files:**
|
|
335
|
-
- Modify: `test/protocol/live-events.test.ts`
|
|
336
|
-
|
|
337
|
-
- [ ] **Step 1: Write tests for append and read**
|
|
338
|
-
|
|
339
|
-
Add to `test/protocol/live-events.test.ts`:
|
|
340
|
-
```typescript
|
|
341
|
-
import { mkdtempSync, rmSync } from "fs"
|
|
342
|
-
import { join } from "path"
|
|
343
|
-
import { tmpdir } from "os"
|
|
344
|
-
|
|
345
|
-
describe("appendEvent + readEventsFromOffset", () => {
|
|
346
|
-
let dir: string
|
|
347
|
-
let jsonlPath: string
|
|
348
|
-
|
|
349
|
-
beforeEach(() => {
|
|
350
|
-
dir = mkdtempSync(join(tmpdir(), "revspec-live-"))
|
|
351
|
-
jsonlPath = join(dir, "test.review.live.jsonl")
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
afterEach(() => rmSync(dir, { recursive: true }))
|
|
355
|
-
|
|
356
|
-
it("appends events and reads them back from offset 0", () => {
|
|
357
|
-
const e1: LiveEvent = { type: "comment", threadId: "t1", line: 5, author: "reviewer", text: "fix this", ts: 1000 }
|
|
358
|
-
const e2: LiveEvent = { type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 }
|
|
359
|
-
appendEvent(jsonlPath, e1)
|
|
360
|
-
appendEvent(jsonlPath, e2)
|
|
361
|
-
|
|
362
|
-
const result = readEventsFromOffset(jsonlPath, 0)
|
|
363
|
-
expect(result.events).toHaveLength(2)
|
|
364
|
-
expect(result.events[0].type).toBe("comment")
|
|
365
|
-
expect(result.events[1].type).toBe("reply")
|
|
366
|
-
expect(result.newOffset).toBeGreaterThan(0)
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
it("reads only new events from a given offset", () => {
|
|
370
|
-
const e1: LiveEvent = { type: "comment", threadId: "t1", line: 5, author: "reviewer", text: "fix this", ts: 1000 }
|
|
371
|
-
appendEvent(jsonlPath, e1)
|
|
372
|
-
|
|
373
|
-
const first = readEventsFromOffset(jsonlPath, 0)
|
|
374
|
-
expect(first.events).toHaveLength(1)
|
|
375
|
-
|
|
376
|
-
const e2: LiveEvent = { type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 }
|
|
377
|
-
appendEvent(jsonlPath, e2)
|
|
378
|
-
|
|
379
|
-
const second = readEventsFromOffset(jsonlPath, first.newOffset)
|
|
380
|
-
expect(second.events).toHaveLength(1)
|
|
381
|
-
expect(second.events[0].type).toBe("reply")
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
it("returns empty for non-existent file", () => {
|
|
385
|
-
const result = readEventsFromOffset(join(dir, "nope.jsonl"), 0)
|
|
386
|
-
expect(result.events).toHaveLength(0)
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
it("discards malformed lines gracefully", () => {
|
|
390
|
-
const { appendFileSync } = require("fs")
|
|
391
|
-
appendFileSync(jsonlPath, '{"type":"comment","threadId":"t1","line":1,"author":"reviewer","text":"ok","ts":1}\n')
|
|
392
|
-
appendFileSync(jsonlPath, "this is not json\n")
|
|
393
|
-
appendFileSync(jsonlPath, '{"type":"reply","threadId":"t1","author":"owner","text":"yes","ts":2}\n')
|
|
394
|
-
|
|
395
|
-
const result = readEventsFromOffset(jsonlPath, 0)
|
|
396
|
-
expect(result.events).toHaveLength(2)
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
it("handles byte offset alignment (mid-line offset)", () => {
|
|
400
|
-
const e1: LiveEvent = { type: "comment", threadId: "t1", line: 5, author: "reviewer", text: "fix", ts: 1000 }
|
|
401
|
-
const e2: LiveEvent = { type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 }
|
|
402
|
-
appendEvent(jsonlPath, e1)
|
|
403
|
-
appendEvent(jsonlPath, e2)
|
|
404
|
-
|
|
405
|
-
// Read from offset 5 (middle of first line) — should skip to second line
|
|
406
|
-
const result = readEventsFromOffset(jsonlPath, 5)
|
|
407
|
-
expect(result.events).toHaveLength(1)
|
|
408
|
-
expect(result.events[0].type).toBe("reply")
|
|
409
|
-
})
|
|
410
|
-
})
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
- [ ] **Step 2: Run tests**
|
|
414
|
-
|
|
415
|
-
Run: `bun test test/protocol/live-events.test.ts`
|
|
416
|
-
Expected: All pass.
|
|
417
|
-
|
|
418
|
-
- [ ] **Step 3: Commit**
|
|
419
|
-
|
|
420
|
-
```bash
|
|
421
|
-
git add test/protocol/live-events.test.ts
|
|
422
|
-
git commit -m "test: add append and read-from-offset tests for live events"
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
---
|
|
426
|
-
|
|
427
|
-
### Task 4: Replay events to threads
|
|
428
|
-
|
|
429
|
-
**Files:**
|
|
430
|
-
- Modify: `test/protocol/live-events.test.ts`
|
|
431
|
-
|
|
432
|
-
- [ ] **Step 1: Write tests for replayEventsToThreads**
|
|
433
|
-
|
|
434
|
-
Add to `test/protocol/live-events.test.ts`:
|
|
435
|
-
```typescript
|
|
436
|
-
describe("replayEventsToThreads", () => {
|
|
437
|
-
it("creates threads from comment events", () => {
|
|
438
|
-
const events: LiveEvent[] = [
|
|
439
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix this", ts: 1000 },
|
|
440
|
-
{ type: "comment", threadId: "t2", line: 20, author: "reviewer", text: "and this", ts: 1001 },
|
|
441
|
-
]
|
|
442
|
-
const threads = replayEventsToThreads(events)
|
|
443
|
-
expect(threads).toHaveLength(2)
|
|
444
|
-
expect(threads[0].id).toBe("t1")
|
|
445
|
-
expect(threads[0].line).toBe(10)
|
|
446
|
-
expect(threads[0].status).toBe("open")
|
|
447
|
-
expect(threads[0].messages).toHaveLength(1)
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
it("appends replies to existing threads", () => {
|
|
451
|
-
const events: LiveEvent[] = [
|
|
452
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix", ts: 1000 },
|
|
453
|
-
{ type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 },
|
|
454
|
-
]
|
|
455
|
-
const threads = replayEventsToThreads(events)
|
|
456
|
-
expect(threads[0].messages).toHaveLength(2)
|
|
457
|
-
expect(threads[0].status).toBe("pending")
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
it("sets status to open on reviewer reply", () => {
|
|
461
|
-
const events: LiveEvent[] = [
|
|
462
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix", ts: 1000 },
|
|
463
|
-
{ type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 },
|
|
464
|
-
{ type: "reply", threadId: "t1", author: "reviewer", text: "not quite", ts: 1002 },
|
|
465
|
-
]
|
|
466
|
-
const threads = replayEventsToThreads(events)
|
|
467
|
-
expect(threads[0].status).toBe("open")
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
it("handles resolve and unresolve", () => {
|
|
471
|
-
const events: LiveEvent[] = [
|
|
472
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix", ts: 1000 },
|
|
473
|
-
{ type: "resolve", threadId: "t1", author: "reviewer", ts: 1001 },
|
|
474
|
-
]
|
|
475
|
-
const threads = replayEventsToThreads(events)
|
|
476
|
-
expect(threads[0].status).toBe("resolved")
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
it("handles delete — removes last reviewer message", () => {
|
|
480
|
-
const events: LiveEvent[] = [
|
|
481
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "wrong comment", ts: 1000 },
|
|
482
|
-
{ type: "reply", threadId: "t1", author: "owner", text: "hmm", ts: 1001 },
|
|
483
|
-
{ type: "delete", threadId: "t1", author: "reviewer", ts: 1002 },
|
|
484
|
-
]
|
|
485
|
-
const threads = replayEventsToThreads(events)
|
|
486
|
-
// The original comment (last reviewer msg) should be removed
|
|
487
|
-
expect(threads[0].messages).toHaveLength(1)
|
|
488
|
-
expect(threads[0].messages[0].author).toBe("owner")
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
it("excludes empty threads after all messages deleted", () => {
|
|
492
|
-
const events: LiveEvent[] = [
|
|
493
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "oops", ts: 1000 },
|
|
494
|
-
{ type: "delete", threadId: "t1", author: "reviewer", ts: 1001 },
|
|
495
|
-
]
|
|
496
|
-
const threads = replayEventsToThreads(events)
|
|
497
|
-
expect(threads).toHaveLength(0)
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
it("skips approve and round events", () => {
|
|
501
|
-
const events: LiveEvent[] = [
|
|
502
|
-
{ type: "round", author: "reviewer", round: 1, ts: 999 },
|
|
503
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix", ts: 1000 },
|
|
504
|
-
{ type: "approve", author: "reviewer", ts: 1001 },
|
|
505
|
-
]
|
|
506
|
-
const threads = replayEventsToThreads(events)
|
|
507
|
-
expect(threads).toHaveLength(1)
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
it("preserves timestamps on messages", () => {
|
|
511
|
-
const events: LiveEvent[] = [
|
|
512
|
-
{ type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix", ts: 1000 },
|
|
513
|
-
{ type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1005 },
|
|
514
|
-
]
|
|
515
|
-
const threads = replayEventsToThreads(events)
|
|
516
|
-
expect(threads[0].messages[0].ts).toBe(1000)
|
|
517
|
-
expect(threads[0].messages[1].ts).toBe(1005)
|
|
518
|
-
})
|
|
519
|
-
|
|
520
|
-
it("ignores replies to unknown threads", () => {
|
|
521
|
-
const events: LiveEvent[] = [
|
|
522
|
-
{ type: "reply", threadId: "t99", author: "owner", text: "huh?", ts: 1000 },
|
|
523
|
-
]
|
|
524
|
-
const threads = replayEventsToThreads(events)
|
|
525
|
-
expect(threads).toHaveLength(0)
|
|
526
|
-
})
|
|
527
|
-
})
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
- [ ] **Step 2: Run tests**
|
|
531
|
-
|
|
532
|
-
Run: `bun test test/protocol/live-events.test.ts`
|
|
533
|
-
Expected: All pass.
|
|
534
|
-
|
|
535
|
-
- [ ] **Step 3: Commit**
|
|
536
|
-
|
|
537
|
-
```bash
|
|
538
|
-
git add test/protocol/live-events.test.ts
|
|
539
|
-
git commit -m "test: add replay-events-to-threads tests"
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
---
|
|
543
|
-
|
|
544
|
-
### Task 5: JSONL → JSON merge
|
|
545
|
-
|
|
546
|
-
**Files:**
|
|
547
|
-
- Create: `src/protocol/live-merge.ts`
|
|
548
|
-
- Create: `test/protocol/live-merge.test.ts`
|
|
549
|
-
|
|
550
|
-
- [ ] **Step 1: Write failing tests**
|
|
551
|
-
|
|
552
|
-
Create `test/protocol/live-merge.test.ts`:
|
|
553
|
-
```typescript
|
|
554
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
555
|
-
import { mkdtempSync, rmSync } from "fs"
|
|
556
|
-
import { join } from "path"
|
|
557
|
-
import { tmpdir } from "os"
|
|
558
|
-
import { mergeJsonlIntoReview } from "../../src/protocol/live-merge"
|
|
559
|
-
import { appendEvent, type LiveEvent } from "../../src/protocol/live-events"
|
|
560
|
-
import type { ReviewFile } from "../../src/protocol/types"
|
|
561
|
-
|
|
562
|
-
describe("mergeJsonlIntoReview", () => {
|
|
563
|
-
let dir: string
|
|
564
|
-
|
|
565
|
-
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "revspec-merge-")) })
|
|
566
|
-
afterEach(() => rmSync(dir, { recursive: true }))
|
|
567
|
-
|
|
568
|
-
it("creates new review from JSONL events", () => {
|
|
569
|
-
const jsonlPath = join(dir, "test.review.live.jsonl")
|
|
570
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 10, author: "reviewer", text: "fix", ts: 1000 })
|
|
571
|
-
appendEvent(jsonlPath, { type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 })
|
|
572
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t1", author: "reviewer", ts: 1002 })
|
|
573
|
-
|
|
574
|
-
const result = mergeJsonlIntoReview(jsonlPath, null, "spec.md")
|
|
575
|
-
expect(result.file).toBe("spec.md")
|
|
576
|
-
expect(result.threads).toHaveLength(1)
|
|
577
|
-
expect(result.threads[0].status).toBe("resolved")
|
|
578
|
-
expect(result.threads[0].messages).toHaveLength(2)
|
|
579
|
-
})
|
|
580
|
-
|
|
581
|
-
it("merges JSONL threads with existing review threads", () => {
|
|
582
|
-
const jsonlPath = join(dir, "test.review.live.jsonl")
|
|
583
|
-
const existing: ReviewFile = {
|
|
584
|
-
file: "spec.md",
|
|
585
|
-
threads: [
|
|
586
|
-
{ id: "t1", line: 5, status: "resolved", messages: [{ author: "reviewer", text: "old" }] },
|
|
587
|
-
],
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 20, author: "reviewer", text: "new", ts: 2000 })
|
|
591
|
-
|
|
592
|
-
const result = mergeJsonlIntoReview(jsonlPath, existing, "spec.md")
|
|
593
|
-
expect(result.threads).toHaveLength(2)
|
|
594
|
-
expect(result.threads[0].id).toBe("t1") // existing preserved
|
|
595
|
-
expect(result.threads[1].id).toBe("t2") // new added
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
it("appends new messages to existing thread (same ID)", () => {
|
|
599
|
-
const jsonlPath = join(dir, "test.review.live.jsonl")
|
|
600
|
-
const existing: ReviewFile = {
|
|
601
|
-
file: "spec.md",
|
|
602
|
-
threads: [
|
|
603
|
-
{ id: "t1", line: 10, status: "pending", messages: [
|
|
604
|
-
{ author: "reviewer", text: "fix", ts: 1000 },
|
|
605
|
-
{ author: "owner", text: "done", ts: 1001 },
|
|
606
|
-
]},
|
|
607
|
-
],
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// New round: reviewer replies again
|
|
611
|
-
appendEvent(jsonlPath, { type: "reply", threadId: "t1", author: "reviewer", text: "not quite", ts: 2000 })
|
|
612
|
-
|
|
613
|
-
const result = mergeJsonlIntoReview(jsonlPath, existing, "spec.md")
|
|
614
|
-
expect(result.threads[0].messages).toHaveLength(3)
|
|
615
|
-
expect(result.threads[0].status).toBe("open") // reviewer replied, so open
|
|
616
|
-
})
|
|
617
|
-
|
|
618
|
-
it("returns existing review unchanged if JSONL is empty", () => {
|
|
619
|
-
const jsonlPath = join(dir, "test.review.live.jsonl")
|
|
620
|
-
const existing: ReviewFile = {
|
|
621
|
-
file: "spec.md",
|
|
622
|
-
threads: [{ id: "t1", line: 5, status: "resolved", messages: [{ author: "reviewer", text: "ok" }] }],
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Empty JSONL (file doesn't exist)
|
|
626
|
-
const result = mergeJsonlIntoReview(jsonlPath, existing, "spec.md")
|
|
627
|
-
expect(result.threads).toHaveLength(1)
|
|
628
|
-
expect(result.threads[0].status).toBe("resolved")
|
|
629
|
-
})
|
|
630
|
-
})
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
634
|
-
|
|
635
|
-
Run: `bun test test/protocol/live-merge.test.ts`
|
|
636
|
-
Expected: FAIL — module not found
|
|
637
|
-
|
|
638
|
-
- [ ] **Step 3: Implement live-merge**
|
|
639
|
-
|
|
640
|
-
Create `src/protocol/live-merge.ts`:
|
|
641
|
-
```typescript
|
|
642
|
-
import { readEventsFromOffset, replayEventsToThreads } from "./live-events"
|
|
643
|
-
import type { ReviewFile, Thread } from "./types"
|
|
644
|
-
|
|
645
|
-
export function mergeJsonlIntoReview(
|
|
646
|
-
jsonlPath: string,
|
|
647
|
-
existingReview: ReviewFile | null,
|
|
648
|
-
specFile: string
|
|
649
|
-
): ReviewFile {
|
|
650
|
-
const { events } = readEventsFromOffset(jsonlPath, 0) // Always replay from byte 0
|
|
651
|
-
const jsonlThreads = replayEventsToThreads(events)
|
|
652
|
-
|
|
653
|
-
const review: ReviewFile = existingReview
|
|
654
|
-
? { file: existingReview.file, threads: [...existingReview.threads] }
|
|
655
|
-
: { file: specFile, threads: [] }
|
|
656
|
-
|
|
657
|
-
for (const jsonlThread of jsonlThreads) {
|
|
658
|
-
const existingIdx = review.threads.findIndex((t) => t.id === jsonlThread.id)
|
|
659
|
-
|
|
660
|
-
if (existingIdx === -1) {
|
|
661
|
-
// New thread — add it
|
|
662
|
-
review.threads.push(jsonlThread)
|
|
663
|
-
} else {
|
|
664
|
-
// Existing thread — append only new messages, update status
|
|
665
|
-
const existing = review.threads[existingIdx]
|
|
666
|
-
const newMessages = jsonlThread.messages.slice(existing.messages.length)
|
|
667
|
-
existing.messages.push(...newMessages)
|
|
668
|
-
existing.status = jsonlThread.status
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
return review
|
|
673
|
-
}
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
- [ ] **Step 4: Run tests**
|
|
677
|
-
|
|
678
|
-
Run: `bun test test/protocol/live-merge.test.ts`
|
|
679
|
-
Expected: All pass.
|
|
680
|
-
|
|
681
|
-
- [ ] **Step 5: Commit**
|
|
682
|
-
|
|
683
|
-
```bash
|
|
684
|
-
git add src/protocol/live-merge.ts test/protocol/live-merge.test.ts
|
|
685
|
-
git commit -m "feat: add JSONL to review JSON merge"
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
---
|
|
689
|
-
|
|
690
|
-
## Chunk 2: CLI Subcommands
|
|
691
|
-
|
|
692
|
-
### Task 6: Subcommand routing in CLI entry point
|
|
693
|
-
|
|
694
|
-
**Files:**
|
|
695
|
-
- Modify: `bin/revspec.ts`
|
|
696
|
-
|
|
697
|
-
- [ ] **Step 1: Add subcommand dispatch**
|
|
698
|
-
|
|
699
|
-
At the top of `bin/revspec.ts`, before the existing logic, add subcommand routing:
|
|
700
|
-
```typescript
|
|
701
|
-
const args = process.argv.slice(2)
|
|
702
|
-
const subcommand = args[0]
|
|
703
|
-
|
|
704
|
-
if (subcommand === "watch") {
|
|
705
|
-
const specFile = args[1]
|
|
706
|
-
if (!specFile) { console.error("Usage: revspec watch <file.md>"); process.exit(1) }
|
|
707
|
-
const { runWatch } = await import("../src/cli/watch")
|
|
708
|
-
await runWatch(specFile)
|
|
709
|
-
process.exit(0)
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
if (subcommand === "reply") {
|
|
713
|
-
const specFile = args[1]
|
|
714
|
-
const threadId = args[2]
|
|
715
|
-
const text = args[3]
|
|
716
|
-
if (!specFile || !threadId || !text) {
|
|
717
|
-
console.error("Usage: revspec reply <file.md> <threadId> \"<text>\"")
|
|
718
|
-
process.exit(1)
|
|
719
|
-
}
|
|
720
|
-
const { runReply } = await import("../src/cli/reply")
|
|
721
|
-
runReply(specFile, threadId, text)
|
|
722
|
-
process.exit(0)
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// ... existing revspec <file.md> logic continues
|
|
726
|
-
```
|
|
727
|
-
|
|
728
|
-
- [ ] **Step 2: Run existing tests to ensure no regression**
|
|
729
|
-
|
|
730
|
-
Run: `bun test test/cli.test.ts`
|
|
731
|
-
Expected: All pass — existing behavior unchanged for `revspec <file.md>`.
|
|
732
|
-
|
|
733
|
-
- [ ] **Step 3: Commit**
|
|
734
|
-
|
|
735
|
-
```bash
|
|
736
|
-
git add bin/revspec.ts
|
|
737
|
-
git commit -m "feat: add subcommand routing for watch and reply"
|
|
738
|
-
```
|
|
739
|
-
|
|
740
|
-
---
|
|
741
|
-
|
|
742
|
-
### Task 7: `revspec reply` subcommand
|
|
743
|
-
|
|
744
|
-
**Files:**
|
|
745
|
-
- Create: `src/cli/reply.ts`
|
|
746
|
-
- Create: `test/cli-reply.test.ts`
|
|
747
|
-
|
|
748
|
-
- [ ] **Step 1: Write failing tests**
|
|
749
|
-
|
|
750
|
-
Create `test/cli-reply.test.ts`:
|
|
751
|
-
```typescript
|
|
752
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
753
|
-
import { mkdtempSync, rmSync, writeFileSync } from "fs"
|
|
754
|
-
import { join } from "path"
|
|
755
|
-
import { tmpdir } from "os"
|
|
756
|
-
import { appendEvent, readEventsFromOffset } from "../src/protocol/live-events"
|
|
757
|
-
|
|
758
|
-
const CLI = join(import.meta.dir, "..", "bin", "revspec.ts")
|
|
759
|
-
|
|
760
|
-
describe("revspec reply", () => {
|
|
761
|
-
let dir: string
|
|
762
|
-
let specPath: string
|
|
763
|
-
let jsonlPath: string
|
|
764
|
-
|
|
765
|
-
beforeEach(() => {
|
|
766
|
-
dir = mkdtempSync(join(tmpdir(), "revspec-reply-"))
|
|
767
|
-
specPath = join(dir, "spec.md")
|
|
768
|
-
jsonlPath = join(dir, "spec.review.live.jsonl")
|
|
769
|
-
writeFileSync(specPath, "# Test Spec\nLine 2\nLine 3\n")
|
|
770
|
-
// Create a comment so thread t1 exists
|
|
771
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "fix this", ts: 1000 })
|
|
772
|
-
})
|
|
773
|
-
|
|
774
|
-
afterEach(() => rmSync(dir, { recursive: true }))
|
|
775
|
-
|
|
776
|
-
it("appends an owner reply event to the JSONL", async () => {
|
|
777
|
-
const proc = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "I fixed it"], {
|
|
778
|
-
stdout: "pipe", stderr: "pipe",
|
|
779
|
-
})
|
|
780
|
-
await proc.exited
|
|
781
|
-
expect(proc.exitCode).toBe(0)
|
|
782
|
-
|
|
783
|
-
const { events } = readEventsFromOffset(jsonlPath, 0)
|
|
784
|
-
expect(events).toHaveLength(2)
|
|
785
|
-
expect(events[1].type).toBe("reply")
|
|
786
|
-
expect(events[1].author).toBe("owner")
|
|
787
|
-
expect(events[1].text).toBe("I fixed it")
|
|
788
|
-
})
|
|
789
|
-
|
|
790
|
-
it("exits 1 for unknown thread ID", async () => {
|
|
791
|
-
const proc = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t99", "text"], {
|
|
792
|
-
stdout: "pipe", stderr: "pipe",
|
|
793
|
-
})
|
|
794
|
-
await proc.exited
|
|
795
|
-
expect(proc.exitCode).toBe(1)
|
|
796
|
-
})
|
|
797
|
-
|
|
798
|
-
it("exits 1 for empty text", async () => {
|
|
799
|
-
const proc = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", ""], {
|
|
800
|
-
stdout: "pipe", stderr: "pipe",
|
|
801
|
-
})
|
|
802
|
-
await proc.exited
|
|
803
|
-
expect(proc.exitCode).toBe(1)
|
|
804
|
-
})
|
|
805
|
-
|
|
806
|
-
it("preserves newlines in reply text via JSON escaping", async () => {
|
|
807
|
-
const proc = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "line 1\nline 2"], {
|
|
808
|
-
stdout: "pipe", stderr: "pipe",
|
|
809
|
-
})
|
|
810
|
-
await proc.exited
|
|
811
|
-
expect(proc.exitCode).toBe(0)
|
|
812
|
-
|
|
813
|
-
const { events } = readEventsFromOffset(jsonlPath, 0)
|
|
814
|
-
expect(events[1].text).toBe("line 1\nline 2")
|
|
815
|
-
})
|
|
816
|
-
})
|
|
817
|
-
```
|
|
818
|
-
|
|
819
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
820
|
-
|
|
821
|
-
Run: `bun test test/cli-reply.test.ts`
|
|
822
|
-
Expected: FAIL — module not found
|
|
823
|
-
|
|
824
|
-
- [ ] **Step 3: Implement reply subcommand**
|
|
825
|
-
|
|
826
|
-
Create `src/cli/reply.ts`:
|
|
827
|
-
```typescript
|
|
828
|
-
import { existsSync } from "fs"
|
|
829
|
-
import { resolve, dirname, basename } from "path"
|
|
830
|
-
import { appendEvent, readEventsFromOffset } from "../protocol/live-events"
|
|
831
|
-
|
|
832
|
-
export function runReply(specFile: string, threadId: string, text: string): void {
|
|
833
|
-
const specPath = resolve(specFile)
|
|
834
|
-
if (!existsSync(specPath)) {
|
|
835
|
-
console.error(`Spec file not found: ${specPath}`)
|
|
836
|
-
process.exit(1)
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
if (!text || text.trim().length === 0) {
|
|
840
|
-
console.error("Reply text cannot be empty")
|
|
841
|
-
process.exit(1)
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const dir = dirname(specPath)
|
|
845
|
-
const base = basename(specPath, ".md")
|
|
846
|
-
const jsonlPath = `${dir}/${base}.review.live.jsonl`
|
|
847
|
-
|
|
848
|
-
if (!existsSync(jsonlPath)) {
|
|
849
|
-
console.error(`No live session found: ${jsonlPath}`)
|
|
850
|
-
process.exit(1)
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
// Validate thread exists
|
|
854
|
-
const { events } = readEventsFromOffset(jsonlPath, 0)
|
|
855
|
-
const threadExists = events.some((e) => e.threadId === threadId)
|
|
856
|
-
if (!threadExists) {
|
|
857
|
-
console.error(`Thread ${threadId} not found`)
|
|
858
|
-
process.exit(1)
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
appendEvent(jsonlPath, {
|
|
862
|
-
type: "reply",
|
|
863
|
-
threadId,
|
|
864
|
-
author: "owner",
|
|
865
|
-
text,
|
|
866
|
-
ts: Date.now(),
|
|
867
|
-
})
|
|
868
|
-
}
|
|
869
|
-
```
|
|
870
|
-
|
|
871
|
-
- [ ] **Step 4: Run tests**
|
|
872
|
-
|
|
873
|
-
Run: `bun test test/cli-reply.test.ts`
|
|
874
|
-
Expected: All pass.
|
|
875
|
-
|
|
876
|
-
- [ ] **Step 5: Commit**
|
|
877
|
-
|
|
878
|
-
```bash
|
|
879
|
-
git add src/cli/reply.ts test/cli-reply.test.ts
|
|
880
|
-
git commit -m "feat: add revspec reply subcommand"
|
|
881
|
-
```
|
|
882
|
-
|
|
883
|
-
---
|
|
884
|
-
|
|
885
|
-
### Task 8: `revspec watch` subcommand
|
|
886
|
-
|
|
887
|
-
**Files:**
|
|
888
|
-
- Create: `src/cli/watch.ts`
|
|
889
|
-
- Create: `test/cli-watch.test.ts`
|
|
890
|
-
|
|
891
|
-
- [ ] **Step 1: Write failing tests**
|
|
892
|
-
|
|
893
|
-
Create `test/cli-watch.test.ts`:
|
|
894
|
-
```typescript
|
|
895
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
896
|
-
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs"
|
|
897
|
-
import { join } from "path"
|
|
898
|
-
import { tmpdir } from "os"
|
|
899
|
-
import { appendEvent } from "../src/protocol/live-events"
|
|
900
|
-
|
|
901
|
-
const CLI = join(import.meta.dir, "..", "bin", "revspec.ts")
|
|
902
|
-
|
|
903
|
-
describe("revspec watch", () => {
|
|
904
|
-
let dir: string
|
|
905
|
-
let specPath: string
|
|
906
|
-
let jsonlPath: string
|
|
907
|
-
|
|
908
|
-
beforeEach(() => {
|
|
909
|
-
dir = mkdtempSync(join(tmpdir(), "revspec-watch-"))
|
|
910
|
-
specPath = join(dir, "spec.md")
|
|
911
|
-
jsonlPath = join(dir, "spec.review.live.jsonl")
|
|
912
|
-
writeFileSync(specPath, "# Title\nLine 2\nLine 3\nLine 4\nLine 5\n")
|
|
913
|
-
})
|
|
914
|
-
|
|
915
|
-
afterEach(() => rmSync(dir, { recursive: true }))
|
|
916
|
-
|
|
917
|
-
it("returns new comments with context when JSONL has reviewer events", async () => {
|
|
918
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "fix this line", ts: 1000 })
|
|
919
|
-
|
|
920
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
921
|
-
stdout: "pipe", stderr: "pipe",
|
|
922
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
923
|
-
})
|
|
924
|
-
const output = await new Response(proc.stdout).text()
|
|
925
|
-
await proc.exited
|
|
926
|
-
|
|
927
|
-
expect(proc.exitCode).toBe(0)
|
|
928
|
-
expect(output).toContain("[t1]")
|
|
929
|
-
expect(output).toContain("line 3")
|
|
930
|
-
expect(output).toContain("fix this line")
|
|
931
|
-
expect(output).toContain("revspec reply")
|
|
932
|
-
})
|
|
933
|
-
|
|
934
|
-
it("returns approval message when approve event present", async () => {
|
|
935
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "ok", ts: 1000 })
|
|
936
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t1", author: "reviewer", ts: 1001 })
|
|
937
|
-
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: 1002 })
|
|
938
|
-
|
|
939
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
940
|
-
stdout: "pipe", stderr: "pipe",
|
|
941
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
942
|
-
})
|
|
943
|
-
const output = await new Response(proc.stdout).text()
|
|
944
|
-
await proc.exited
|
|
945
|
-
|
|
946
|
-
expect(proc.exitCode).toBe(0)
|
|
947
|
-
expect(output).toContain("Review approved")
|
|
948
|
-
})
|
|
949
|
-
|
|
950
|
-
it("includes thread history for replies", async () => {
|
|
951
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "fix", ts: 1000 })
|
|
952
|
-
appendEvent(jsonlPath, { type: "reply", threadId: "t1", author: "owner", text: "done", ts: 1001 })
|
|
953
|
-
appendEvent(jsonlPath, { type: "reply", threadId: "t1", author: "reviewer", text: "not quite", ts: 1002 })
|
|
954
|
-
|
|
955
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
956
|
-
stdout: "pipe", stderr: "pipe",
|
|
957
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
958
|
-
})
|
|
959
|
-
const output = await new Response(proc.stdout).text()
|
|
960
|
-
await proc.exited
|
|
961
|
-
|
|
962
|
-
expect(output).toContain("Thread history")
|
|
963
|
-
expect(output).toContain("not quite")
|
|
964
|
-
})
|
|
965
|
-
|
|
966
|
-
it("shows resolved threads separately", async () => {
|
|
967
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "fix", ts: 1000 })
|
|
968
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t1", author: "reviewer", ts: 1001 })
|
|
969
|
-
|
|
970
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
971
|
-
stdout: "pipe", stderr: "pipe",
|
|
972
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
973
|
-
})
|
|
974
|
-
const output = await new Response(proc.stdout).text()
|
|
975
|
-
await proc.exited
|
|
976
|
-
|
|
977
|
-
expect(output).toContain("Resolved")
|
|
978
|
-
expect(output).toContain("resolved by reviewer")
|
|
979
|
-
})
|
|
980
|
-
|
|
981
|
-
it("exits 1 for missing spec file", async () => {
|
|
982
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", join(dir, "nope.md")], {
|
|
983
|
-
stdout: "pipe", stderr: "pipe",
|
|
984
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
985
|
-
})
|
|
986
|
-
await proc.exited
|
|
987
|
-
expect(proc.exitCode).toBe(1)
|
|
988
|
-
})
|
|
989
|
-
})
|
|
990
|
-
```
|
|
991
|
-
|
|
992
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
993
|
-
|
|
994
|
-
Run: `bun test test/cli-watch.test.ts`
|
|
995
|
-
Expected: FAIL — module not found
|
|
996
|
-
|
|
997
|
-
- [ ] **Step 3: Implement watch subcommand**
|
|
998
|
-
|
|
999
|
-
Create `src/cli/watch.ts`:
|
|
1000
|
-
```typescript
|
|
1001
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, watch as fsWatch, statSync } from "fs"
|
|
1002
|
-
import { resolve, dirname, basename } from "path"
|
|
1003
|
-
import { readEventsFromOffset, type LiveEvent } from "../protocol/live-events"
|
|
1004
|
-
|
|
1005
|
-
export async function runWatch(specFile: string): Promise<void> {
|
|
1006
|
-
const specPath = resolve(specFile)
|
|
1007
|
-
if (!existsSync(specPath)) {
|
|
1008
|
-
console.error(`Spec file not found: ${specPath}`)
|
|
1009
|
-
process.exit(1)
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
const dir = dirname(specPath)
|
|
1013
|
-
const base = basename(specPath, ".md")
|
|
1014
|
-
const jsonlPath = `${dir}/${base}.review.live.jsonl`
|
|
1015
|
-
const offsetPath = `${dir}/${base}.review.live.offset`
|
|
1016
|
-
const lockPath = `${dir}/${base}.review.live.lock`
|
|
1017
|
-
const reviewPath = `${dir}/${base}.review.json`
|
|
1018
|
-
|
|
1019
|
-
// Lock file check
|
|
1020
|
-
if (existsSync(lockPath)) {
|
|
1021
|
-
const lockPid = parseInt(readFileSync(lockPath, "utf-8").trim(), 10)
|
|
1022
|
-
if (!isNaN(lockPid) && isProcessAlive(lockPid)) {
|
|
1023
|
-
console.error("Another watch process is already running")
|
|
1024
|
-
process.exit(3)
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
writeFileSync(lockPath, String(process.pid))
|
|
1028
|
-
|
|
1029
|
-
// Read last offset
|
|
1030
|
-
let offset = 0
|
|
1031
|
-
if (existsSync(offsetPath)) {
|
|
1032
|
-
offset = parseInt(readFileSync(offsetPath, "utf-8").trim(), 10) || 0
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
const specLines = readFileSync(specPath, "utf-8").split("\n")
|
|
1036
|
-
|
|
1037
|
-
// Non-blocking mode for tests
|
|
1038
|
-
if (process.env.REVSPEC_WATCH_NO_BLOCK === "1") {
|
|
1039
|
-
const result = processNewEvents(jsonlPath, offset, specPath, specLines, reviewPath)
|
|
1040
|
-
if (result) {
|
|
1041
|
-
writeFileSync(offsetPath, String(result.newOffset))
|
|
1042
|
-
console.log(result.output)
|
|
1043
|
-
}
|
|
1044
|
-
return
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// Blocking mode: wait for JSONL changes
|
|
1048
|
-
await waitForEvents(jsonlPath, offset, specPath, specLines, offsetPath, lockPath, reviewPath)
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function processNewEvents(
|
|
1052
|
-
jsonlPath: string,
|
|
1053
|
-
offset: number,
|
|
1054
|
-
specPath: string,
|
|
1055
|
-
specLines: string[],
|
|
1056
|
-
reviewPath: string
|
|
1057
|
-
): { output: string; newOffset: number } | null {
|
|
1058
|
-
const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
|
|
1059
|
-
if (events.length === 0) return null
|
|
1060
|
-
|
|
1061
|
-
// Check for approve
|
|
1062
|
-
const approveEvent = events.find((e) => e.type === "approve")
|
|
1063
|
-
if (approveEvent) {
|
|
1064
|
-
return {
|
|
1065
|
-
output: `Review approved.\nReview file: ${reviewPath}`,
|
|
1066
|
-
newOffset,
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
// Filter reviewer events only
|
|
1071
|
-
const reviewerEvents = events.filter((e) => e.author === "reviewer" && e.type !== "round")
|
|
1072
|
-
if (reviewerEvents.length === 0) return null
|
|
1073
|
-
|
|
1074
|
-
// Build full thread state for context
|
|
1075
|
-
const { events: allEvents } = readEventsFromOffset(jsonlPath, 0)
|
|
1076
|
-
const output = formatWatchOutput(reviewerEvents, allEvents, specPath, specLines)
|
|
1077
|
-
|
|
1078
|
-
return { output, newOffset }
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
function formatWatchOutput(
|
|
1082
|
-
newEvents: LiveEvent[],
|
|
1083
|
-
allEvents: LiveEvent[],
|
|
1084
|
-
specPath: string,
|
|
1085
|
-
specLines: string[]
|
|
1086
|
-
): string {
|
|
1087
|
-
const sections: string[] = []
|
|
1088
|
-
|
|
1089
|
-
// Group by type
|
|
1090
|
-
const newComments = newEvents.filter((e) => e.type === "comment")
|
|
1091
|
-
const replies = newEvents.filter((e) => e.type === "reply")
|
|
1092
|
-
const resolves = newEvents.filter((e) => e.type === "resolve")
|
|
1093
|
-
const deletes = newEvents.filter((e) => e.type === "delete")
|
|
1094
|
-
|
|
1095
|
-
if (resolves.length > 0) {
|
|
1096
|
-
sections.push("--- Resolved ---\n")
|
|
1097
|
-
for (const e of resolves) {
|
|
1098
|
-
const line = findThreadLine(allEvents, e.threadId!)
|
|
1099
|
-
sections.push(`[${e.threadId}] line ${line}: resolved by reviewer\n`)
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
if (deletes.length > 0) {
|
|
1104
|
-
sections.push("--- Deleted ---\n")
|
|
1105
|
-
for (const e of deletes) {
|
|
1106
|
-
const line = findThreadLine(allEvents, e.threadId!)
|
|
1107
|
-
sections.push(`[${e.threadId}] line ${line}: reviewer retracted last message\n`)
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
if (newComments.length > 0) {
|
|
1112
|
-
sections.push("--- New threads ---\n")
|
|
1113
|
-
for (const e of newComments) {
|
|
1114
|
-
const context = getContext(specLines, e.line!)
|
|
1115
|
-
sections.push(`[${e.threadId}] line ${e.line} (new):`)
|
|
1116
|
-
sections.push(` Context:`)
|
|
1117
|
-
sections.push(context)
|
|
1118
|
-
sections.push(` Comment: "${e.text}"\n`)
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
if (replies.length > 0) {
|
|
1123
|
-
sections.push("--- Replies ---\n")
|
|
1124
|
-
for (const e of replies) {
|
|
1125
|
-
const line = findThreadLine(allEvents, e.threadId!)
|
|
1126
|
-
const history = getThreadHistory(allEvents, e.threadId!)
|
|
1127
|
-
sections.push(`[${e.threadId}] line ${line} (reply):`)
|
|
1128
|
-
sections.push(` Thread history:`)
|
|
1129
|
-
sections.push(history)
|
|
1130
|
-
sections.push(` Comment: "${e.text}"\n`)
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
if (newComments.length > 0 || replies.length > 0) {
|
|
1135
|
-
const specBase = basename(specPath)
|
|
1136
|
-
sections.push(`To reply: revspec reply ${specBase} <threadId> "<your response>"`)
|
|
1137
|
-
sections.push(`When done replying, run: revspec watch ${specBase}`)
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
return sections.join("\n")
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function getContext(specLines: string[], line: number): string {
|
|
1144
|
-
const lines: string[] = []
|
|
1145
|
-
const start = Math.max(0, line - 3)
|
|
1146
|
-
const end = Math.min(specLines.length - 1, line + 1)
|
|
1147
|
-
for (let i = start; i <= end; i++) {
|
|
1148
|
-
const lineNum = i + 1
|
|
1149
|
-
const prefix = lineNum === line ? " >" : " "
|
|
1150
|
-
lines.push(`${prefix}${lineNum}: ${specLines[i]}`)
|
|
1151
|
-
}
|
|
1152
|
-
return lines.join("\n")
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function getThreadHistory(allEvents: LiveEvent[], threadId: string): string {
|
|
1156
|
-
const msgs = allEvents
|
|
1157
|
-
.filter((e) => e.threadId === threadId && (e.type === "comment" || e.type === "reply"))
|
|
1158
|
-
.map((e) => ` ${e.author}: "${e.text}"`)
|
|
1159
|
-
return msgs.join("\n")
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
function findThreadLine(allEvents: LiveEvent[], threadId: string): number {
|
|
1163
|
-
const comment = allEvents.find((e) => e.type === "comment" && e.threadId === threadId)
|
|
1164
|
-
return comment?.line ?? 0
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
async function waitForEvents(
|
|
1168
|
-
jsonlPath: string,
|
|
1169
|
-
offset: number,
|
|
1170
|
-
specPath: string,
|
|
1171
|
-
specLines: string[],
|
|
1172
|
-
offsetPath: string,
|
|
1173
|
-
lockPath: string,
|
|
1174
|
-
reviewPath: string
|
|
1175
|
-
): Promise<void> {
|
|
1176
|
-
return new Promise<void>((resolvePromise) => {
|
|
1177
|
-
let currentOffset = offset
|
|
1178
|
-
let watcher: ReturnType<typeof fsWatch> | null = null
|
|
1179
|
-
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
1180
|
-
|
|
1181
|
-
function cleanup() {
|
|
1182
|
-
if (watcher) { watcher.close(); watcher = null }
|
|
1183
|
-
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function check(): boolean {
|
|
1187
|
-
const result = processNewEvents(jsonlPath, currentOffset, specPath, specLines, reviewPath)
|
|
1188
|
-
if (result) {
|
|
1189
|
-
currentOffset = result.newOffset
|
|
1190
|
-
writeFileSync(offsetPath, String(currentOffset))
|
|
1191
|
-
console.log(result.output)
|
|
1192
|
-
|
|
1193
|
-
cleanup()
|
|
1194
|
-
|
|
1195
|
-
// Clean up lock/offset if approved
|
|
1196
|
-
if (result.output.includes("Review approved")) {
|
|
1197
|
-
try { unlinkSync(lockPath) } catch {}
|
|
1198
|
-
try { unlinkSync(offsetPath) } catch {}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
resolvePromise()
|
|
1202
|
-
return true
|
|
1203
|
-
}
|
|
1204
|
-
return false
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// Try immediately
|
|
1208
|
-
if (check()) return
|
|
1209
|
-
|
|
1210
|
-
// If file doesn't exist yet, wait for it
|
|
1211
|
-
if (!existsSync(jsonlPath)) {
|
|
1212
|
-
const dirWatcher = fsWatch(dirname(jsonlPath), (_, filename) => {
|
|
1213
|
-
if (filename === basename(jsonlPath)) {
|
|
1214
|
-
dirWatcher.close()
|
|
1215
|
-
startWatching()
|
|
1216
|
-
}
|
|
1217
|
-
})
|
|
1218
|
-
return
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
startWatching()
|
|
1222
|
-
|
|
1223
|
-
function startWatching() {
|
|
1224
|
-
try {
|
|
1225
|
-
watcher = fsWatch(jsonlPath, () => { check() })
|
|
1226
|
-
} catch {}
|
|
1227
|
-
|
|
1228
|
-
// Polling fallback every 500ms
|
|
1229
|
-
pollInterval = setInterval(() => { check() }, 500)
|
|
1230
|
-
}
|
|
1231
|
-
})
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
function isProcessAlive(pid: number): boolean {
|
|
1235
|
-
try {
|
|
1236
|
-
process.kill(pid, 0)
|
|
1237
|
-
return true
|
|
1238
|
-
} catch {
|
|
1239
|
-
return false
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
```
|
|
1243
|
-
|
|
1244
|
-
- [ ] **Step 4: Run tests**
|
|
1245
|
-
|
|
1246
|
-
Run: `bun test test/cli-watch.test.ts`
|
|
1247
|
-
Expected: All pass.
|
|
1248
|
-
|
|
1249
|
-
- [ ] **Step 5: Run all tests**
|
|
1250
|
-
|
|
1251
|
-
Run: `bun test`
|
|
1252
|
-
Expected: All pass.
|
|
1253
|
-
|
|
1254
|
-
- [ ] **Step 6: Commit**
|
|
1255
|
-
|
|
1256
|
-
```bash
|
|
1257
|
-
git add src/cli/watch.ts test/cli-watch.test.ts
|
|
1258
|
-
git commit -m "feat: add revspec watch subcommand with context output"
|
|
1259
|
-
```
|
|
1260
|
-
|
|
1261
|
-
---
|
|
1262
|
-
|
|
1263
|
-
## Chunk 3: ReviewState Updates
|
|
1264
|
-
|
|
1265
|
-
### Task 9: Add unread tracking to ReviewState
|
|
1266
|
-
|
|
1267
|
-
**Files:**
|
|
1268
|
-
- Modify: `src/state/review-state.ts`
|
|
1269
|
-
- Modify: `test/state/review-state.test.ts`
|
|
1270
|
-
|
|
1271
|
-
- [ ] **Step 1: Write failing tests for unread tracking**
|
|
1272
|
-
|
|
1273
|
-
Add to `test/state/review-state.test.ts`:
|
|
1274
|
-
```typescript
|
|
1275
|
-
describe("unread tracking", () => {
|
|
1276
|
-
it("tracks unread owner replies", () => {
|
|
1277
|
-
const state = new ReviewState(["line1", "line2"], [])
|
|
1278
|
-
state.addComment(1, "fix this")
|
|
1279
|
-
state.addOwnerReply("t1", "done", 1001)
|
|
1280
|
-
expect(state.unreadCount()).toBe(1)
|
|
1281
|
-
})
|
|
1282
|
-
|
|
1283
|
-
it("markRead clears unread for a thread", () => {
|
|
1284
|
-
const state = new ReviewState(["line1"], [])
|
|
1285
|
-
state.addComment(1, "fix")
|
|
1286
|
-
state.addOwnerReply("t1", "done", 1001)
|
|
1287
|
-
state.markRead("t1")
|
|
1288
|
-
expect(state.unreadCount()).toBe(0)
|
|
1289
|
-
})
|
|
1290
|
-
|
|
1291
|
-
it("nextUnreadThread returns line of next unread thread", () => {
|
|
1292
|
-
const state = new ReviewState(["a", "b", "c", "d", "e"], [])
|
|
1293
|
-
state.addComment(2, "fix")
|
|
1294
|
-
state.addComment(4, "fix too")
|
|
1295
|
-
state.addOwnerReply("t1", "done", 1001)
|
|
1296
|
-
state.addOwnerReply("t2", "done", 1002)
|
|
1297
|
-
state.cursorLine = 1
|
|
1298
|
-
expect(state.nextUnreadThread()).toBe(2)
|
|
1299
|
-
})
|
|
1300
|
-
|
|
1301
|
-
it("prevUnreadThread returns line of prev unread thread", () => {
|
|
1302
|
-
const state = new ReviewState(["a", "b", "c", "d", "e"], [])
|
|
1303
|
-
state.addComment(2, "fix")
|
|
1304
|
-
state.addComment(4, "fix too")
|
|
1305
|
-
state.addOwnerReply("t1", "done", 1001)
|
|
1306
|
-
state.addOwnerReply("t2", "done", 1002)
|
|
1307
|
-
state.cursorLine = 5
|
|
1308
|
-
expect(state.prevUnreadThread()).toBe(4)
|
|
1309
|
-
})
|
|
1310
|
-
|
|
1311
|
-
it("nextUnreadThread returns null when no unread", () => {
|
|
1312
|
-
const state = new ReviewState(["a"], [])
|
|
1313
|
-
expect(state.nextUnreadThread()).toBeNull()
|
|
1314
|
-
})
|
|
1315
|
-
|
|
1316
|
-
it("isThreadUnread returns correct state", () => {
|
|
1317
|
-
const state = new ReviewState(["a"], [])
|
|
1318
|
-
state.addComment(1, "fix")
|
|
1319
|
-
expect(state.isThreadUnread("t1")).toBe(false)
|
|
1320
|
-
state.addOwnerReply("t1", "done", 1001)
|
|
1321
|
-
expect(state.isThreadUnread("t1")).toBe(true)
|
|
1322
|
-
state.markRead("t1")
|
|
1323
|
-
expect(state.isThreadUnread("t1")).toBe(false)
|
|
1324
|
-
})
|
|
1325
|
-
})
|
|
1326
|
-
```
|
|
1327
|
-
|
|
1328
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
1329
|
-
|
|
1330
|
-
Run: `bun test test/state/review-state.test.ts`
|
|
1331
|
-
Expected: FAIL — methods not found
|
|
1332
|
-
|
|
1333
|
-
- [ ] **Step 3: Implement unread tracking**
|
|
1334
|
-
|
|
1335
|
-
In `src/state/review-state.ts`, add:
|
|
1336
|
-
|
|
1337
|
-
```typescript
|
|
1338
|
-
// New property
|
|
1339
|
-
private unreadThreadIds: Set<string> = new Set()
|
|
1340
|
-
|
|
1341
|
-
// New methods
|
|
1342
|
-
addOwnerReply(threadId: string, text: string, ts?: number): void {
|
|
1343
|
-
const thread = this.threads.find((t) => t.id === threadId)
|
|
1344
|
-
if (!thread) return
|
|
1345
|
-
const msg: Message = { author: "owner", text }
|
|
1346
|
-
if (ts !== undefined) msg.ts = ts
|
|
1347
|
-
thread.messages.push(msg)
|
|
1348
|
-
thread.status = "pending"
|
|
1349
|
-
this.unreadThreadIds.add(threadId)
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
unreadCount(): number {
|
|
1353
|
-
return this.unreadThreadIds.size
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
isThreadUnread(threadId: string): boolean {
|
|
1357
|
-
return this.unreadThreadIds.has(threadId)
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
markRead(threadId: string): void {
|
|
1361
|
-
this.unreadThreadIds.delete(threadId)
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
nextUnreadThread(): number | null {
|
|
1365
|
-
const unreadThreads = this.threads.filter((t) => this.unreadThreadIds.has(t.id))
|
|
1366
|
-
// Find first unread after cursor, wrapping
|
|
1367
|
-
const after = unreadThreads.find((t) => t.line > this.cursorLine)
|
|
1368
|
-
if (after) return after.line
|
|
1369
|
-
return unreadThreads.length > 0 ? unreadThreads[0].line : null
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
prevUnreadThread(): number | null {
|
|
1373
|
-
const unreadThreads = this.threads.filter((t) => this.unreadThreadIds.has(t.id))
|
|
1374
|
-
// Find last unread before cursor, wrapping
|
|
1375
|
-
const before = [...unreadThreads].reverse().find((t) => t.line < this.cursorLine)
|
|
1376
|
-
if (before) return before.line
|
|
1377
|
-
return unreadThreads.length > 0 ? unreadThreads[unreadThreads.length - 1].line : null
|
|
1378
|
-
}
|
|
1379
|
-
```
|
|
1380
|
-
|
|
1381
|
-
Also update existing `addComment` to use `"reviewer"` as author, and `replyToThread` to use the passed author or default `"reviewer"`.
|
|
1382
|
-
|
|
1383
|
-
- [ ] **Step 4: Run tests**
|
|
1384
|
-
|
|
1385
|
-
Run: `bun test test/state/review-state.test.ts`
|
|
1386
|
-
Expected: All pass.
|
|
1387
|
-
|
|
1388
|
-
- [ ] **Step 5: Commit**
|
|
1389
|
-
|
|
1390
|
-
```bash
|
|
1391
|
-
git add src/state/review-state.ts test/state/review-state.test.ts
|
|
1392
|
-
git commit -m "feat: add unread tracking to ReviewState"
|
|
1393
|
-
```
|
|
1394
|
-
|
|
1395
|
-
---
|
|
1396
|
-
|
|
1397
|
-
## Chunk 4: TUI Integration
|
|
1398
|
-
|
|
1399
|
-
### Task 10: TUI writes to JSONL and watches for owner replies
|
|
1400
|
-
|
|
1401
|
-
**Files:**
|
|
1402
|
-
- Create: `src/tui/live-watcher.ts`
|
|
1403
|
-
- Modify: `src/tui/app.ts`
|
|
1404
|
-
|
|
1405
|
-
- [ ] **Step 1: Create live-watcher module**
|
|
1406
|
-
|
|
1407
|
-
Create `src/tui/live-watcher.ts`:
|
|
1408
|
-
```typescript
|
|
1409
|
-
import { watch, existsSync, statSync } from "fs"
|
|
1410
|
-
import { readEventsFromOffset, type LiveEvent } from "../protocol/live-events"
|
|
1411
|
-
|
|
1412
|
-
export interface LiveWatcher {
|
|
1413
|
-
start(): void
|
|
1414
|
-
stop(): void
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
export function createLiveWatcher(
|
|
1418
|
-
jsonlPath: string,
|
|
1419
|
-
onOwnerEvents: (events: LiveEvent[]) => void
|
|
1420
|
-
): LiveWatcher {
|
|
1421
|
-
let offset = existsSync(jsonlPath) ? statSync(jsonlPath).size : 0
|
|
1422
|
-
let fsWatcher: ReturnType<typeof watch> | null = null
|
|
1423
|
-
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
1424
|
-
|
|
1425
|
-
function check() {
|
|
1426
|
-
const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
|
|
1427
|
-
if (events.length > 0) {
|
|
1428
|
-
offset = newOffset
|
|
1429
|
-
const ownerEvents = events.filter((e) => e.author === "owner")
|
|
1430
|
-
if (ownerEvents.length > 0) {
|
|
1431
|
-
onOwnerEvents(ownerEvents)
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
return {
|
|
1437
|
-
start() {
|
|
1438
|
-
try {
|
|
1439
|
-
fsWatcher = watch(jsonlPath, () => check())
|
|
1440
|
-
} catch {}
|
|
1441
|
-
// Polling fallback
|
|
1442
|
-
pollTimer = setInterval(check, 500)
|
|
1443
|
-
},
|
|
1444
|
-
stop() {
|
|
1445
|
-
if (fsWatcher) { fsWatcher.close(); fsWatcher = null }
|
|
1446
|
-
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
1447
|
-
},
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
```
|
|
1451
|
-
|
|
1452
|
-
- [ ] **Step 2: Integrate into app.ts**
|
|
1453
|
-
|
|
1454
|
-
In `src/tui/app.ts`, modify `runTui`:
|
|
1455
|
-
|
|
1456
|
-
1. Derive `jsonlPath` from spec file path
|
|
1457
|
-
2. On startup, create JSONL if not exists, replay events for crash recovery
|
|
1458
|
-
3. Create `LiveWatcher`, pass callback that calls `state.addOwnerReply()` and `refreshPager()`
|
|
1459
|
-
4. Replace `saveDraft()` calls with JSONL append calls (`appendEvent`)
|
|
1460
|
-
5. On comment: `appendEvent(jsonlPath, { type: "comment", ... })`
|
|
1461
|
-
6. On reply: `appendEvent(jsonlPath, { type: "reply", author: "reviewer", ... })`
|
|
1462
|
-
7. On resolve: `appendEvent(jsonlPath, { type: "resolve", ... })`
|
|
1463
|
-
8. On unresolve: add unresolve event
|
|
1464
|
-
9. On delete (dd): `appendEvent(jsonlPath, { type: "delete", ... })`
|
|
1465
|
-
10. On approve: `appendEvent(jsonlPath, { type: "approve", ... })`
|
|
1466
|
-
11. On `:q` / `:wq`: merge JSONL → JSON via `mergeJsonlIntoReview`, write review file
|
|
1467
|
-
12. On `:q!`: exit without merge
|
|
1468
|
-
13. Start watcher on init, stop on exit
|
|
1469
|
-
|
|
1470
|
-
- [ ] **Step 3: Add `]r`/`[r` keybindings**
|
|
1471
|
-
|
|
1472
|
-
In the bracket-pending handler in `app.ts`, add:
|
|
1473
|
-
```typescript
|
|
1474
|
-
if (bracketPending === "]" && key === "r") {
|
|
1475
|
-
bracketPending = null
|
|
1476
|
-
const nextLine = state.nextUnreadThread()
|
|
1477
|
-
if (nextLine !== null) {
|
|
1478
|
-
state.cursorLine = nextLine
|
|
1479
|
-
ensureCursorVisible()
|
|
1480
|
-
refreshPager()
|
|
1481
|
-
}
|
|
1482
|
-
return
|
|
1483
|
-
}
|
|
1484
|
-
if (bracketPending === "[" && key === "r") {
|
|
1485
|
-
bracketPending = null
|
|
1486
|
-
const prevLine = state.prevUnreadThread()
|
|
1487
|
-
if (prevLine !== null) {
|
|
1488
|
-
state.cursorLine = prevLine
|
|
1489
|
-
ensureCursorVisible()
|
|
1490
|
-
refreshPager()
|
|
1491
|
-
}
|
|
1492
|
-
return
|
|
1493
|
-
}
|
|
1494
|
-
```
|
|
1495
|
-
|
|
1496
|
-
- [ ] **Step 4: Mark thread as read when viewing**
|
|
1497
|
-
|
|
1498
|
-
In `showCommentInput`, after opening the overlay for an existing thread, call `state.markRead(thread.id)`.
|
|
1499
|
-
|
|
1500
|
-
- [ ] **Step 5: Run all tests**
|
|
1501
|
-
|
|
1502
|
-
Run: `bun test`
|
|
1503
|
-
Expected: All pass (TUI changes are not unit-testable, but no regressions).
|
|
1504
|
-
|
|
1505
|
-
- [ ] **Step 6: Commit**
|
|
1506
|
-
|
|
1507
|
-
```bash
|
|
1508
|
-
git add src/tui/live-watcher.ts src/tui/app.ts
|
|
1509
|
-
git commit -m "feat: TUI writes to JSONL, watches for owner replies, ]r/[r navigation"
|
|
1510
|
-
```
|
|
1511
|
-
|
|
1512
|
-
---
|
|
1513
|
-
|
|
1514
|
-
### Task 11: Unread indicators in pager and status bar
|
|
1515
|
-
|
|
1516
|
-
**Files:**
|
|
1517
|
-
- Modify: `src/tui/pager.ts`
|
|
1518
|
-
- Modify: `src/tui/status-bar.ts`
|
|
1519
|
-
- Modify: `src/tui/comment-input.ts`
|
|
1520
|
-
|
|
1521
|
-
- [ ] **Step 1: Pass unread state to pager**
|
|
1522
|
-
|
|
1523
|
-
In `src/tui/pager.ts`, update `buildPagerContent` to accept unread thread IDs and show a distinct indicator (`[+]` or different icon) for threads with unread replies.
|
|
1524
|
-
|
|
1525
|
-
- [ ] **Step 2: Update status bar to show unread count**
|
|
1526
|
-
|
|
1527
|
-
In `src/tui/status-bar.ts`, update `buildTopBarText` to include unread count:
|
|
1528
|
-
```
|
|
1529
|
-
spec.md | Threads: 1 open, 2 resolved | 2 new replies
|
|
1530
|
-
```
|
|
1531
|
-
|
|
1532
|
-
Pass `unreadCount` as a parameter.
|
|
1533
|
-
|
|
1534
|
-
- [ ] **Step 3: Add timestamp display in comment-input**
|
|
1535
|
-
|
|
1536
|
-
In `src/tui/comment-input.ts`, when rendering thread history, format `msg.ts` as ISO time string if present:
|
|
1537
|
-
```typescript
|
|
1538
|
-
const tsStr = msg.ts ? new Date(msg.ts).toISOString().replace("T", " ").slice(0, 19) : ""
|
|
1539
|
-
const label = msg.author === "reviewer" ? "You" : " AI"
|
|
1540
|
-
// Render: "You [2026-03-14 15:30:05]: message text"
|
|
1541
|
-
```
|
|
1542
|
-
|
|
1543
|
-
- [ ] **Step 4: Run all tests**
|
|
1544
|
-
|
|
1545
|
-
Run: `bun test`
|
|
1546
|
-
Expected: All pass.
|
|
1547
|
-
|
|
1548
|
-
- [ ] **Step 5: Commit**
|
|
1549
|
-
|
|
1550
|
-
```bash
|
|
1551
|
-
git add src/tui/pager.ts src/tui/status-bar.ts src/tui/comment-input.ts
|
|
1552
|
-
git commit -m "feat: unread indicators, reply count in status bar, timestamp display"
|
|
1553
|
-
```
|
|
1554
|
-
|
|
1555
|
-
---
|
|
1556
|
-
|
|
1557
|
-
### Task 11.5: Spec file mutation guard
|
|
1558
|
-
|
|
1559
|
-
**Files:**
|
|
1560
|
-
- Modify: `src/tui/app.ts`
|
|
1561
|
-
|
|
1562
|
-
- [ ] **Step 1: Record spec mtime on startup**
|
|
1563
|
-
|
|
1564
|
-
In `runTui`, after reading the spec file, record `statSync(specFile).mtimeMs`.
|
|
1565
|
-
|
|
1566
|
-
- [ ] **Step 2: Check mtime on each pager refresh**
|
|
1567
|
-
|
|
1568
|
-
In `refreshPager`, check if spec mtime has changed. If so, show warning in status bar: `"Spec file changed externally — line anchors may be stale"`.
|
|
1569
|
-
|
|
1570
|
-
- [ ] **Step 3: Commit**
|
|
1571
|
-
|
|
1572
|
-
```bash
|
|
1573
|
-
git add src/tui/app.ts
|
|
1574
|
-
git commit -m "feat: add spec file mutation guard warning"
|
|
1575
|
-
```
|
|
1576
|
-
|
|
1577
|
-
---
|
|
1578
|
-
|
|
1579
|
-
### Task 11.6: Clean up lock/offset files on TUI exit
|
|
1580
|
-
|
|
1581
|
-
**Files:**
|
|
1582
|
-
- Modify: `src/tui/app.ts`
|
|
1583
|
-
|
|
1584
|
-
- [ ] **Step 1: On `:q` / `a` exit, clean up offset file**
|
|
1585
|
-
|
|
1586
|
-
After merging JSONL → JSON on exit, delete the offset file (`spec.review.live.offset`). The lock file is managed by the `watch` process, not the TUI.
|
|
1587
|
-
|
|
1588
|
-
- [ ] **Step 2: Commit**
|
|
1589
|
-
|
|
1590
|
-
```bash
|
|
1591
|
-
git add src/tui/app.ts
|
|
1592
|
-
git commit -m "feat: clean up offset file on TUI exit"
|
|
1593
|
-
```
|
|
1594
|
-
|
|
1595
|
-
---
|
|
1596
|
-
|
|
1597
|
-
### Task 11.7: Add missing tests for lock file and crash recovery
|
|
1598
|
-
|
|
1599
|
-
**Files:**
|
|
1600
|
-
- Modify: `test/cli-watch.test.ts`
|
|
1601
|
-
|
|
1602
|
-
- [ ] **Step 1: Add lock file enforcement test**
|
|
1603
|
-
|
|
1604
|
-
```typescript
|
|
1605
|
-
it("exits 3 when another watch is running (lock file with live PID)", async () => {
|
|
1606
|
-
const lockPath = join(dir, "spec.review.live.lock")
|
|
1607
|
-
writeFileSync(lockPath, String(process.pid)) // current process is alive
|
|
1608
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "x", ts: 1 })
|
|
1609
|
-
|
|
1610
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
1611
|
-
stdout: "pipe", stderr: "pipe",
|
|
1612
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
1613
|
-
})
|
|
1614
|
-
await proc.exited
|
|
1615
|
-
expect(proc.exitCode).toBe(3)
|
|
1616
|
-
})
|
|
1617
|
-
|
|
1618
|
-
it("proceeds when lock file has dead PID", async () => {
|
|
1619
|
-
const lockPath = join(dir, "spec.review.live.lock")
|
|
1620
|
-
writeFileSync(lockPath, "999999") // almost certainly dead
|
|
1621
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "x", ts: 1 })
|
|
1622
|
-
|
|
1623
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
1624
|
-
stdout: "pipe", stderr: "pipe",
|
|
1625
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
1626
|
-
})
|
|
1627
|
-
await proc.exited
|
|
1628
|
-
expect(proc.exitCode).toBe(0)
|
|
1629
|
-
})
|
|
1630
|
-
```
|
|
1631
|
-
|
|
1632
|
-
- [ ] **Step 2: Run tests**
|
|
1633
|
-
|
|
1634
|
-
Run: `bun test test/cli-watch.test.ts`
|
|
1635
|
-
Expected: All pass.
|
|
1636
|
-
|
|
1637
|
-
- [ ] **Step 3: Commit**
|
|
1638
|
-
|
|
1639
|
-
```bash
|
|
1640
|
-
git add test/cli-watch.test.ts
|
|
1641
|
-
git commit -m "test: add lock file enforcement tests"
|
|
1642
|
-
```
|
|
1643
|
-
|
|
1644
|
-
---
|
|
1645
|
-
|
|
1646
|
-
## Chunk 5: End-to-End Integration Test
|
|
1647
|
-
|
|
1648
|
-
### Task 12: E2E test — full watch/reply loop
|
|
1649
|
-
|
|
1650
|
-
**Files:**
|
|
1651
|
-
- Create: `test/e2e-live.test.ts`
|
|
1652
|
-
|
|
1653
|
-
- [ ] **Step 1: Write E2E test**
|
|
1654
|
-
|
|
1655
|
-
Create `test/e2e-live.test.ts`:
|
|
1656
|
-
```typescript
|
|
1657
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
1658
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"
|
|
1659
|
-
import { join } from "path"
|
|
1660
|
-
import { tmpdir } from "os"
|
|
1661
|
-
import { appendEvent, readEventsFromOffset } from "../src/protocol/live-events"
|
|
1662
|
-
import { mergeJsonlIntoReview } from "../src/protocol/live-merge"
|
|
1663
|
-
import { readReviewFile } from "../src/protocol/read"
|
|
1664
|
-
import { writeReviewFile } from "../src/protocol/write"
|
|
1665
|
-
|
|
1666
|
-
const CLI = join(import.meta.dir, "..", "bin", "revspec.ts")
|
|
1667
|
-
|
|
1668
|
-
describe("E2E: live review loop", () => {
|
|
1669
|
-
let dir: string
|
|
1670
|
-
let specPath: string
|
|
1671
|
-
let jsonlPath: string
|
|
1672
|
-
let reviewPath: string
|
|
1673
|
-
|
|
1674
|
-
beforeEach(() => {
|
|
1675
|
-
dir = mkdtempSync(join(tmpdir(), "revspec-e2e-"))
|
|
1676
|
-
specPath = join(dir, "spec.md")
|
|
1677
|
-
jsonlPath = join(dir, "spec.review.live.jsonl")
|
|
1678
|
-
reviewPath = join(dir, "spec.review.json")
|
|
1679
|
-
writeFileSync(specPath, "# My Spec\n\nLine 3 is important.\n\nLine 5 also matters.\n")
|
|
1680
|
-
})
|
|
1681
|
-
|
|
1682
|
-
afterEach(() => rmSync(dir, { recursive: true }))
|
|
1683
|
-
|
|
1684
|
-
it("simulates full loop: comment → watch → reply → watch → resolve → approve", async () => {
|
|
1685
|
-
// Step 1: Reviewer adds comments (simulating TUI)
|
|
1686
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "this is unclear", ts: 1000 })
|
|
1687
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 5, author: "reviewer", text: "needs more detail", ts: 1001 })
|
|
1688
|
-
|
|
1689
|
-
// Step 2: AI runs watch, gets comments
|
|
1690
|
-
const watch1 = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
1691
|
-
stdout: "pipe", stderr: "pipe",
|
|
1692
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
1693
|
-
})
|
|
1694
|
-
const output1 = await new Response(watch1.stdout).text()
|
|
1695
|
-
await watch1.exited
|
|
1696
|
-
expect(watch1.exitCode).toBe(0)
|
|
1697
|
-
expect(output1).toContain("[t1]")
|
|
1698
|
-
expect(output1).toContain("[t2]")
|
|
1699
|
-
expect(output1).toContain("this is unclear")
|
|
1700
|
-
expect(output1).toContain("needs more detail")
|
|
1701
|
-
|
|
1702
|
-
// Step 3: AI replies
|
|
1703
|
-
const reply1 = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "I'll restructure this section"], {
|
|
1704
|
-
stdout: "pipe", stderr: "pipe",
|
|
1705
|
-
})
|
|
1706
|
-
await reply1.exited
|
|
1707
|
-
expect(reply1.exitCode).toBe(0)
|
|
1708
|
-
|
|
1709
|
-
const reply2 = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t2", "Adding more context now"], {
|
|
1710
|
-
stdout: "pipe", stderr: "pipe",
|
|
1711
|
-
})
|
|
1712
|
-
await reply2.exited
|
|
1713
|
-
expect(reply2.exitCode).toBe(0)
|
|
1714
|
-
|
|
1715
|
-
// Verify AI replies are in JSONL
|
|
1716
|
-
const { events } = readEventsFromOffset(jsonlPath, 0)
|
|
1717
|
-
expect(events).toHaveLength(4) // 2 comments + 2 replies
|
|
1718
|
-
expect(events[2].author).toBe("owner")
|
|
1719
|
-
expect(events[3].author).toBe("owner")
|
|
1720
|
-
|
|
1721
|
-
// Step 4: Reviewer resolves and approves (simulating TUI)
|
|
1722
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t1", author: "reviewer", ts: 2000 })
|
|
1723
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t2", author: "reviewer", ts: 2001 })
|
|
1724
|
-
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: 2002 })
|
|
1725
|
-
|
|
1726
|
-
// Step 5: AI runs watch, gets approval
|
|
1727
|
-
const watch2 = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
1728
|
-
stdout: "pipe", stderr: "pipe",
|
|
1729
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
1730
|
-
})
|
|
1731
|
-
const output2 = await new Response(watch2.stdout).text()
|
|
1732
|
-
await watch2.exited
|
|
1733
|
-
expect(watch2.exitCode).toBe(0)
|
|
1734
|
-
expect(output2).toContain("Review approved")
|
|
1735
|
-
|
|
1736
|
-
// Step 6: Merge JSONL → JSON (simulating TUI exit)
|
|
1737
|
-
const review = mergeJsonlIntoReview(jsonlPath, null, specPath)
|
|
1738
|
-
writeReviewFile(reviewPath, review)
|
|
1739
|
-
expect(review.threads).toHaveLength(2)
|
|
1740
|
-
expect(review.threads[0].status).toBe("resolved")
|
|
1741
|
-
expect(review.threads[1].status).toBe("resolved")
|
|
1742
|
-
expect(review.threads[0].messages).toHaveLength(2) // comment + reply
|
|
1743
|
-
expect(review.threads[1].messages).toHaveLength(2)
|
|
1744
|
-
|
|
1745
|
-
// Timestamps preserved
|
|
1746
|
-
expect(review.threads[0].messages[0].ts).toBe(1000)
|
|
1747
|
-
})
|
|
1748
|
-
|
|
1749
|
-
it("handles delete event in the loop", async () => {
|
|
1750
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "wrong comment", ts: 1000 })
|
|
1751
|
-
appendEvent(jsonlPath, { type: "delete", threadId: "t1", author: "reviewer", ts: 1001 })
|
|
1752
|
-
|
|
1753
|
-
// Watch should show delete
|
|
1754
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
1755
|
-
stdout: "pipe", stderr: "pipe",
|
|
1756
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
1757
|
-
})
|
|
1758
|
-
const output = await new Response(proc.stdout).text()
|
|
1759
|
-
await proc.exited
|
|
1760
|
-
expect(output).toContain("Deleted")
|
|
1761
|
-
expect(output).toContain("retracted")
|
|
1762
|
-
|
|
1763
|
-
// Merge should exclude empty thread
|
|
1764
|
-
const review = mergeJsonlIntoReview(jsonlPath, null, specPath)
|
|
1765
|
-
expect(review.threads).toHaveLength(0)
|
|
1766
|
-
})
|
|
1767
|
-
|
|
1768
|
-
it("merges with existing review from prior round", async () => {
|
|
1769
|
-
// Prior round left a resolved thread
|
|
1770
|
-
const priorReview = {
|
|
1771
|
-
file: specPath,
|
|
1772
|
-
threads: [{ id: "t1", line: 3, status: "resolved" as const, messages: [
|
|
1773
|
-
{ author: "reviewer" as const, text: "old comment" },
|
|
1774
|
-
{ author: "owner" as const, text: "fixed" },
|
|
1775
|
-
]}],
|
|
1776
|
-
}
|
|
1777
|
-
writeReviewFile(reviewPath, priorReview)
|
|
1778
|
-
|
|
1779
|
-
// New round
|
|
1780
|
-
appendEvent(jsonlPath, { type: "round", author: "reviewer", round: 2, ts: 3000 })
|
|
1781
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 5, author: "reviewer", text: "new issue", ts: 3001 })
|
|
1782
|
-
|
|
1783
|
-
const review = mergeJsonlIntoReview(jsonlPath, priorReview, specPath)
|
|
1784
|
-
expect(review.threads).toHaveLength(2)
|
|
1785
|
-
expect(review.threads[0].id).toBe("t1") // prior preserved
|
|
1786
|
-
expect(review.threads[1].id).toBe("t2") // new added
|
|
1787
|
-
})
|
|
1788
|
-
})
|
|
1789
|
-
```
|
|
1790
|
-
|
|
1791
|
-
- [ ] **Step 2: Run E2E tests**
|
|
1792
|
-
|
|
1793
|
-
Run: `bun test test/e2e-live.test.ts`
|
|
1794
|
-
Expected: All pass.
|
|
1795
|
-
|
|
1796
|
-
- [ ] **Step 3: Run full test suite**
|
|
1797
|
-
|
|
1798
|
-
Run: `bun test`
|
|
1799
|
-
Expected: All tests pass.
|
|
1800
|
-
|
|
1801
|
-
- [ ] **Step 4: Commit**
|
|
1802
|
-
|
|
1803
|
-
```bash
|
|
1804
|
-
git add test/e2e-live.test.ts
|
|
1805
|
-
git commit -m "test: add end-to-end live review loop tests"
|
|
1806
|
-
```
|
|
1807
|
-
|
|
1808
|
-
---
|
|
1809
|
-
|
|
1810
|
-
## Chunk 6: CLI Entry Point Update
|
|
1811
|
-
|
|
1812
|
-
### Task 13: Update main CLI to use JSONL merge on exit
|
|
1813
|
-
|
|
1814
|
-
**Files:**
|
|
1815
|
-
- Modify: `bin/revspec.ts`
|
|
1816
|
-
|
|
1817
|
-
- [ ] **Step 1: Update post-TUI processing**
|
|
1818
|
-
|
|
1819
|
-
In `bin/revspec.ts`, update the post-TUI processing (lines 73-106) to:
|
|
1820
|
-
1. Check for JSONL file instead of draft file
|
|
1821
|
-
2. If JSONL exists: use `mergeJsonlIntoReview` to merge into review JSON
|
|
1822
|
-
3. If approved: output `APPROVED: <reviewPath>`
|
|
1823
|
-
4. If has threads: output `<reviewPath>`
|
|
1824
|
-
5. Remove draft file handling (replaced by JSONL)
|
|
1825
|
-
|
|
1826
|
-
The TUI now handles the merge on `:q` / `a` exit. The CLI post-processing just checks the result.
|
|
1827
|
-
|
|
1828
|
-
- [ ] **Step 2: Run all tests**
|
|
1829
|
-
|
|
1830
|
-
Run: `bun test`
|
|
1831
|
-
Expected: All pass (including existing CLI tests, which may need updates for the new flow).
|
|
1832
|
-
|
|
1833
|
-
- [ ] **Step 3: Commit**
|
|
1834
|
-
|
|
1835
|
-
```bash
|
|
1836
|
-
git add bin/revspec.ts
|
|
1837
|
-
git commit -m "feat: update CLI entry point to use JSONL merge flow"
|
|
1838
|
-
```
|
|
1839
|
-
|
|
1840
|
-
---
|
|
1841
|
-
|
|
1842
|
-
### Task 14: Manual smoke test
|
|
1843
|
-
|
|
1844
|
-
- [ ] **Step 1: Test the TUI flow**
|
|
1845
|
-
|
|
1846
|
-
```bash
|
|
1847
|
-
# Create a test spec
|
|
1848
|
-
echo "# Test Spec\n\nThis is line 3.\n\nThis is line 5.\n" > /tmp/test-spec.md
|
|
1849
|
-
|
|
1850
|
-
# Launch revspec
|
|
1851
|
-
bun run bin/revspec.ts /tmp/test-spec.md
|
|
1852
|
-
```
|
|
1853
|
-
|
|
1854
|
-
In the TUI:
|
|
1855
|
-
1. Press `c` on line 3, type "this is unclear", press Tab — verify JSONL file created
|
|
1856
|
-
2. Press `:q` to exit — verify `spec.review.json` created with the comment
|
|
1857
|
-
|
|
1858
|
-
- [ ] **Step 2: Test the watch/reply loop**
|
|
1859
|
-
|
|
1860
|
-
```bash
|
|
1861
|
-
# Terminal 1: Launch revspec
|
|
1862
|
-
bun run bin/revspec.ts /tmp/test-spec.md
|
|
1863
|
-
|
|
1864
|
-
# Terminal 2: Watch for comments
|
|
1865
|
-
bun run bin/revspec.ts watch /tmp/test-spec.md
|
|
1866
|
-
|
|
1867
|
-
# Terminal 1: Add a comment in the TUI
|
|
1868
|
-
# Terminal 2: Verify watch returns the comment
|
|
1869
|
-
|
|
1870
|
-
# Terminal 2: Reply
|
|
1871
|
-
bun run bin/revspec.ts reply /tmp/test-spec.md t1 "I'll fix this"
|
|
1872
|
-
|
|
1873
|
-
# Terminal 1: Verify the reply appears in the TUI (unread indicator, status bar)
|
|
1874
|
-
# Terminal 1: Press ]r to jump to unread, press c to view thread with timestamp
|
|
1875
|
-
```
|
|
1876
|
-
|
|
1877
|
-
- [ ] **Step 3: Commit any fixes from smoke testing**
|