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,216 +0,0 @@
1
- import { describe, it, expect, afterEach } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
- import { join, resolve } from "path";
4
- import { tmpdir } from "os";
5
- import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events";
6
-
7
- const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
8
-
9
- interface SpawnResult {
10
- exitCode: number;
11
- stdout: string;
12
- stderr: string;
13
- }
14
-
15
- async function runCli(
16
- args: string[],
17
- env: Record<string, string> = {}
18
- ): Promise<SpawnResult> {
19
- const proc = Bun.spawn(["bun", "run", CLI, ...args], {
20
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1", ...env },
21
- stdout: "pipe",
22
- stderr: "pipe",
23
- });
24
-
25
- const [stdout, stderr] = await Promise.all([
26
- new Response(proc.stdout).text(),
27
- new Response(proc.stderr).text(),
28
- ]);
29
-
30
- const exitCode = await proc.exited;
31
- return { exitCode, stdout, stderr };
32
- }
33
-
34
- function setupTempDir(): string {
35
- return mkdtempSync(join(tmpdir(), "revspec-watch-test-"));
36
- }
37
-
38
- function createSpecWithJsonl(
39
- dir: string,
40
- specContent: string = "# My Spec\n\nLine two\n\nLine four\n"
41
- ): { specPath: string; jsonlPath: string } {
42
- const specPath = join(dir, "spec.md");
43
- writeFileSync(specPath, specContent);
44
-
45
- const jsonlPath = join(dir, "spec.review.live.jsonl");
46
-
47
- return { specPath, jsonlPath };
48
- }
49
-
50
- describe("revspec watch", () => {
51
- let tmpDir: string;
52
-
53
- afterEach(() => {
54
- if (tmpDir) {
55
- rmSync(tmpDir, { recursive: true, force: true });
56
- }
57
- });
58
-
59
- it("returns new comments with context", async () => {
60
- tmpDir = setupTempDir();
61
- const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
62
-
63
- const threadId = "thread-001";
64
- appendEvent(jsonlPath, {
65
- type: "comment",
66
- threadId,
67
- line: 3,
68
- author: "reviewer",
69
- text: "This needs clarification",
70
- ts: Date.now(),
71
- });
72
-
73
- const result = await runCli(["watch", specPath]);
74
-
75
- expect(result.exitCode).toBe(0);
76
- expect(result.stdout).toContain(threadId);
77
- expect(result.stdout).toContain("line 3");
78
- expect(result.stdout).toContain("This needs clarification");
79
- expect(result.stdout).toContain("revspec reply");
80
- });
81
-
82
- it("returns approval message when approve event present", async () => {
83
- tmpDir = setupTempDir();
84
- const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
85
-
86
- appendEvent(jsonlPath, {
87
- type: "approve",
88
- author: "reviewer",
89
- ts: Date.now(),
90
- });
91
-
92
- const result = await runCli(["watch", specPath]);
93
-
94
- expect(result.exitCode).toBe(0);
95
- expect(result.stdout).toContain("Review approved");
96
- expect(result.stdout).toContain("spec.review.json");
97
- });
98
-
99
- it("includes thread history for replies", async () => {
100
- tmpDir = setupTempDir();
101
- const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
102
-
103
- const threadId = "thread-002";
104
- const ts = Date.now();
105
-
106
- appendEvent(jsonlPath, {
107
- type: "comment",
108
- threadId,
109
- line: 1,
110
- author: "reviewer",
111
- text: "Initial comment",
112
- ts,
113
- });
114
-
115
- appendEvent(jsonlPath, {
116
- type: "reply",
117
- threadId,
118
- author: "owner",
119
- text: "Thanks, addressing it",
120
- ts: ts + 1000,
121
- });
122
-
123
- appendEvent(jsonlPath, {
124
- type: "reply",
125
- threadId,
126
- author: "reviewer",
127
- text: "Great, looks good now",
128
- ts: ts + 2000,
129
- });
130
-
131
- // Watch starting at offset 0 should see new comment + replies
132
- const result = await runCli(["watch", specPath]);
133
-
134
- expect(result.exitCode).toBe(0);
135
- // Should show full thread history in replies section
136
- expect(result.stdout).toContain(threadId);
137
- expect(result.stdout).toContain("Initial comment");
138
- expect(result.stdout).toContain("Thanks, addressing it");
139
- expect(result.stdout).toContain("Great, looks good now");
140
- });
141
-
142
- it("does not break watch loop for resolve-only events", async () => {
143
- tmpDir = setupTempDir();
144
- const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
145
-
146
- const threadId = "thread-003";
147
- const ts = Date.now();
148
-
149
- // Write comment then resolve
150
- appendEvent(jsonlPath, {
151
- type: "comment",
152
- threadId,
153
- line: 2,
154
- author: "reviewer",
155
- text: "Fix this please",
156
- ts,
157
- });
158
-
159
- // First watch picks up the comment
160
- const result1 = await runCli(["watch", specPath]);
161
- expect(result1.exitCode).toBe(0);
162
- expect(result1.stdout).toContain("Fix this please");
163
-
164
- // Append resolve — watch should return empty (no actionable events)
165
- appendEvent(jsonlPath, {
166
- type: "resolve",
167
- threadId,
168
- author: "reviewer",
169
- ts: ts + 1000,
170
- });
171
-
172
- const result2 = await runCli(["watch", specPath]);
173
- // No output — resolve is not actionable
174
- expect(result2.stdout.trim()).toBe("");
175
- });
176
-
177
- it("exits 1 for missing spec file", async () => {
178
- tmpDir = setupTempDir();
179
- const missingPath = join(tmpDir, "nonexistent.md");
180
-
181
- const result = await runCli(["watch", missingPath]);
182
-
183
- expect(result.exitCode).toBe(1);
184
- expect(result.stderr).toContain("not found");
185
- });
186
-
187
- it("exits 3 when another watch is running (lock file with live PID)", async () => {
188
- tmpDir = setupTempDir();
189
- const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
190
- const lockPath = join(tmpDir, "spec.review.live.lock");
191
- writeFileSync(lockPath, String(process.pid)); // current process is alive
192
- appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "x", ts: 1 });
193
-
194
- const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
195
- stdout: "pipe", stderr: "pipe",
196
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
197
- });
198
- await proc.exited;
199
- expect(proc.exitCode).toBe(3);
200
- });
201
-
202
- it("proceeds when lock file has dead PID", async () => {
203
- tmpDir = setupTempDir();
204
- const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
205
- const lockPath = join(tmpDir, "spec.review.live.lock");
206
- writeFileSync(lockPath, "999999"); // almost certainly dead
207
- appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "x", ts: 1 });
208
-
209
- const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
210
- stdout: "pipe", stderr: "pipe",
211
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
212
- });
213
- await proc.exited;
214
- expect(proc.exitCode).toBe(0);
215
- });
216
- });
@@ -1,160 +0,0 @@
1
- import { describe, it, expect, afterEach } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
- import { join, resolve } from "path";
4
- import { tmpdir } from "os";
5
- import { appendEvent } from "../../src/protocol/live-events";
6
- import { writeReviewFile } from "../../src/protocol/write";
7
-
8
- const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
9
-
10
- interface SpawnResult {
11
- exitCode: number;
12
- stdout: string;
13
- stderr: string;
14
- }
15
-
16
- async function runCli(
17
- args: string[],
18
- env: Record<string, string> = {}
19
- ): Promise<SpawnResult> {
20
- const proc = Bun.spawn(["bun", "run", CLI, ...args], {
21
- env: { ...process.env, REVSPEC_SKIP_TUI: "1", ...env },
22
- stdout: "pipe",
23
- stderr: "pipe",
24
- });
25
-
26
- const [stdout, stderr] = await Promise.all([
27
- new Response(proc.stdout).text(),
28
- new Response(proc.stderr).text(),
29
- ]);
30
-
31
- const exitCode = await proc.exited;
32
- return { exitCode, stdout, stderr };
33
- }
34
-
35
- describe("CLI entry point", () => {
36
- let tmpDir: string;
37
-
38
- // Create a fresh temp dir before each test
39
- function setup(): string {
40
- tmpDir = mkdtempSync(join(tmpdir(), "revspec-test-"));
41
- return tmpDir;
42
- }
43
-
44
- afterEach(() => {
45
- if (tmpDir) {
46
- rmSync(tmpDir, { recursive: true, force: true });
47
- }
48
- });
49
-
50
- it("exits 1 for missing spec file", async () => {
51
- setup();
52
- const result = await runCli([join(tmpDir, "nonexistent.md")]);
53
- expect(result.exitCode).toBe(1);
54
- expect(result.stderr).toContain("not found");
55
- });
56
-
57
- it("exits 0 with no output when no review file exists", async () => {
58
- const dir = setup();
59
- const specFile = join(dir, "spec.md");
60
- writeFileSync(specFile, "# My Spec\n");
61
-
62
- const result = await runCli([specFile]);
63
- expect(result.exitCode).toBe(0);
64
- expect(result.stdout.trim()).toBe("");
65
- expect(result.stderr.trim()).toBe("");
66
- });
67
-
68
- it("outputs APPROVED when JSONL has approve event and review file exists", async () => {
69
- const dir = setup();
70
- const specFile = join(dir, "spec.md");
71
- writeFileSync(specFile, "# My Spec\n");
72
-
73
- // Write a review file (TUI writes this on approve)
74
- const reviewPath = join(dir, "spec.review.json");
75
- writeReviewFile(reviewPath, { file: specFile, threads: [] });
76
-
77
- // Simulate TUI writing an approve event to JSONL
78
- const jsonlPath = join(dir, "spec.review.live.jsonl");
79
- appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
80
-
81
- const result = await runCli([specFile]);
82
- expect(result.exitCode).toBe(0);
83
- expect(result.stdout).toContain("APPROVED:");
84
- expect(result.stdout).toContain("spec.review.json");
85
- });
86
-
87
- it("outputs review path when review file has open threads", async () => {
88
- const dir = setup();
89
- const specFile = join(dir, "spec.md");
90
- writeFileSync(specFile, "# My Spec\n");
91
-
92
- const reviewPath = join(dir, "spec.review.json");
93
- writeReviewFile(reviewPath, {
94
- file: specFile,
95
- threads: [
96
- {
97
- id: "t1",
98
- line: 5,
99
- status: "open",
100
- messages: [{ author: "reviewer", text: "This needs clarification" }],
101
- },
102
- ],
103
- });
104
-
105
- const result = await runCli([specFile]);
106
- expect(result.exitCode).toBe(0);
107
- expect(result.stdout).toContain("spec.review.json");
108
- });
109
-
110
- it("prints nothing when human adds no comments (no prior review)", async () => {
111
- const dir = setup();
112
- const specFile = join(dir, "spec.md");
113
- writeFileSync(specFile, "# My Spec\n");
114
-
115
- // No JSONL, no review file — TUI is skipped, nothing happened
116
- const result = await runCli([specFile]);
117
- expect(result.exitCode).toBe(0);
118
- expect(result.stdout.trim()).toBe("");
119
- });
120
-
121
- it("prints nothing when review exists but all threads resolved", async () => {
122
- const dir = setup();
123
- const specFile = join(dir, "spec.md");
124
- writeFileSync(specFile, "# My Spec\n");
125
-
126
- const reviewPath = join(dir, "spec.review.json");
127
- writeReviewFile(reviewPath, {
128
- file: specFile,
129
- threads: [
130
- {
131
- id: "t1",
132
- line: 5,
133
- status: "resolved",
134
- messages: [
135
- { author: "reviewer", text: "Was unclear" },
136
- { author: "owner", text: "Fixed" },
137
- ],
138
- },
139
- ],
140
- });
141
-
142
- const result = await runCli([specFile]);
143
- expect(result.exitCode).toBe(0);
144
- expect(result.stdout.trim()).toBe("");
145
- });
146
-
147
- it("does not output APPROVED if JSONL has approve but no review file", async () => {
148
- const dir = setup();
149
- const specFile = join(dir, "spec.md");
150
- writeFileSync(specFile, "# My Spec\n");
151
-
152
- // JSONL has approve but no review file written
153
- const jsonlPath = join(dir, "spec.review.live.jsonl");
154
- appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
155
-
156
- const result = await runCli([specFile]);
157
- expect(result.exitCode).toBe(0);
158
- expect(result.stdout).not.toContain("APPROVED:");
159
- });
160
- });
@@ -1,171 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
- import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"
3
- import { join } from "path"
4
- import { tmpdir } from "os"
5
- import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events"
6
- import { mergeJsonlIntoReview } from "../../src/protocol/live-merge"
7
- import { writeReviewFile } from "../../src/protocol/write"
8
-
9
- const CLI = join(import.meta.dir, "..", "..", "bin", "revspec.ts")
10
-
11
- describe("E2E: live review loop", () => {
12
- let dir: string
13
- let specPath: string
14
- let jsonlPath: string
15
- let reviewPath: string
16
-
17
- beforeEach(() => {
18
- dir = mkdtempSync(join(tmpdir(), "revspec-e2e-"))
19
- specPath = join(dir, "spec.md")
20
- jsonlPath = join(dir, "spec.review.live.jsonl")
21
- reviewPath = join(dir, "spec.review.json")
22
- writeFileSync(specPath, "# My Spec\n\nLine 3 is important.\n\nLine 5 also matters.\n")
23
- })
24
-
25
- afterEach(() => rmSync(dir, { recursive: true }))
26
-
27
- it("simulates full loop: comment → watch → reply → watch → resolve → approve", async () => {
28
- // Step 1: Reviewer adds comments (simulating TUI)
29
- appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "this is unclear", ts: 1000 })
30
- appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 5, author: "reviewer", text: "needs more detail", ts: 1001 })
31
-
32
- // Step 2: AI runs watch, gets comments
33
- const watch1 = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
34
- stdout: "pipe", stderr: "pipe",
35
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
36
- })
37
- const output1 = await new Response(watch1.stdout).text()
38
- await watch1.exited
39
- expect(watch1.exitCode).toBe(0)
40
- expect(output1).toContain("t1")
41
- expect(output1).toContain("t2")
42
- expect(output1).toContain("this is unclear")
43
- expect(output1).toContain("needs more detail")
44
-
45
- // Step 3: AI replies
46
- const reply1 = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "I'll restructure this section"], {
47
- stdout: "pipe", stderr: "pipe",
48
- })
49
- await reply1.exited
50
- expect(reply1.exitCode).toBe(0)
51
-
52
- const reply2 = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t2", "Adding more context now"], {
53
- stdout: "pipe", stderr: "pipe",
54
- })
55
- await reply2.exited
56
- expect(reply2.exitCode).toBe(0)
57
-
58
- // Verify AI replies are in JSONL
59
- const { events } = readEventsFromOffset(jsonlPath, 0)
60
- expect(events).toHaveLength(4) // 2 comments + 2 replies
61
- expect(events[2].author).toBe("owner")
62
- expect(events[3].author).toBe("owner")
63
-
64
- // Step 4: Reviewer resolves and approves (simulating TUI)
65
- appendEvent(jsonlPath, { type: "resolve", threadId: "t1", author: "reviewer", ts: 2000 })
66
- appendEvent(jsonlPath, { type: "resolve", threadId: "t2", author: "reviewer", ts: 2001 })
67
- appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: 2002 })
68
-
69
- // Step 5: AI runs watch, gets approval
70
- const watch2 = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
71
- stdout: "pipe", stderr: "pipe",
72
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
73
- })
74
- const output2 = await new Response(watch2.stdout).text()
75
- await watch2.exited
76
- expect(watch2.exitCode).toBe(0)
77
- expect(output2).toContain("Review approved")
78
-
79
- // Step 6: Merge JSONL → JSON (simulating TUI exit)
80
- const review = mergeJsonlIntoReview(jsonlPath, null, specPath)
81
- writeReviewFile(reviewPath, review)
82
- expect(review.threads).toHaveLength(2)
83
- expect(review.threads[0].status).toBe("resolved")
84
- expect(review.threads[1].status).toBe("resolved")
85
- expect(review.threads[0].messages).toHaveLength(2)
86
- expect(review.threads[1].messages).toHaveLength(2)
87
- expect(review.threads[0].messages[0].ts).toBe(1000) // timestamps preserved
88
- })
89
-
90
- it("handles delete event in the loop", async () => {
91
- appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "wrong comment", ts: 1000 })
92
- appendEvent(jsonlPath, { type: "delete", threadId: "t1", author: "reviewer", ts: 1001 })
93
-
94
- const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
95
- stdout: "pipe", stderr: "pipe",
96
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
97
- })
98
- const output = await new Response(proc.stdout).text()
99
- await proc.exited
100
-
101
- // Watch should show the events
102
- expect(proc.exitCode).toBe(0)
103
-
104
- // Merge should exclude empty thread
105
- const review = mergeJsonlIntoReview(jsonlPath, null, specPath)
106
- expect(review.threads).toHaveLength(0)
107
- })
108
-
109
- it("merges with existing review from prior round", async () => {
110
- // Prior round left a resolved thread
111
- const priorReview = {
112
- file: specPath,
113
- threads: [{
114
- id: "t1", line: 3, status: "resolved" as const,
115
- messages: [
116
- { author: "reviewer" as const, text: "old comment" },
117
- { author: "owner" as const, text: "fixed" },
118
- ]
119
- }],
120
- }
121
- writeReviewFile(reviewPath, priorReview)
122
-
123
- // New round
124
- appendEvent(jsonlPath, { type: "round", author: "reviewer", round: 2, ts: 3000 })
125
- appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 5, author: "reviewer", text: "new issue", ts: 3001 })
126
-
127
- const review = mergeJsonlIntoReview(jsonlPath, priorReview, specPath)
128
- expect(review.threads).toHaveLength(2)
129
- expect(review.threads[0].id).toBe("t1")
130
- expect(review.threads[1].id).toBe("t2")
131
- })
132
-
133
- it("watch output includes spec context lines", async () => {
134
- appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "unclear", ts: 1000 })
135
-
136
- const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
137
- stdout: "pipe", stderr: "pipe",
138
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
139
- })
140
- const output = await new Response(proc.stdout).text()
141
- await proc.exited
142
-
143
- // Should include context lines around line 3
144
- expect(output).toContain("Line 3 is important")
145
- expect(output).toContain(">") // cursor marker on the anchored line
146
- })
147
-
148
- it("reply then watch shows thread history", async () => {
149
- appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "unclear", ts: 1000 })
150
-
151
- // AI replies
152
- const reply = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "I'll clarify"], {
153
- stdout: "pipe", stderr: "pipe",
154
- })
155
- await reply.exited
156
-
157
- // Reviewer replies back
158
- appendEvent(jsonlPath, { type: "reply", threadId: "t1", author: "reviewer", text: "not quite", ts: 2000 })
159
-
160
- const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
161
- stdout: "pipe", stderr: "pipe",
162
- env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
163
- })
164
- const output = await new Response(proc.stdout).text()
165
- await proc.exited
166
-
167
- expect(output).toContain("Replies")
168
- expect(output).toContain("unclear")
169
- expect(output).toContain("not quite")
170
- })
171
- })