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.
Files changed (49) hide show
  1. package/README.md +60 -68
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +15 -1
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +122 -58
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +145 -108
  10. package/src/tui/comment-input.ts +9 -13
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +13 -16
  13. package/src/tui/spinner.ts +81 -0
  14. package/src/tui/status-bar.ts +9 -6
  15. package/src/tui/thread-list.ts +62 -22
  16. package/src/tui/ui/keymap.ts +55 -0
  17. package/.github/workflows/ci.yml +0 -18
  18. package/CLAUDE.md +0 -29
  19. package/bun.lock +0 -216
  20. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  21. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  22. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  23. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  24. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  25. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  26. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  27. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  28. package/scripts/install-skill.sh +0 -20
  29. package/scripts/release.sh +0 -52
  30. package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
  31. package/test/e2e/fixtures/spec.md +0 -36
  32. package/test/e2e/harness.ts +0 -80
  33. package/test/e2e/snapshot.test.ts +0 -182
  34. package/test/integration/cli-reply.test.ts +0 -140
  35. package/test/integration/cli-watch.test.ts +0 -216
  36. package/test/integration/cli.test.ts +0 -160
  37. package/test/integration/e2e-live.test.ts +0 -171
  38. package/test/integration/live-interaction.test.ts +0 -398
  39. package/test/integration/opentui-smoke.test.ts +0 -12
  40. package/test/unit/protocol/live-events.test.ts +0 -509
  41. package/test/unit/protocol/live-merge.test.ts +0 -167
  42. package/test/unit/protocol/merge.test.ts +0 -100
  43. package/test/unit/protocol/read.test.ts +0 -92
  44. package/test/unit/protocol/types.test.ts +0 -95
  45. package/test/unit/protocol/write.test.ts +0 -72
  46. package/test/unit/state/review-state.test.ts +0 -399
  47. package/test/unit/tui/pager.test.ts +0 -159
  48. package/test/unit/tui/ui/keybinds.test.ts +0 -71
  49. package/tsconfig.json +0 -14
@@ -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**