revspec 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +84 -67
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +20 -3
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +69 -41
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +168 -107
  10. package/src/tui/comment-input.ts +21 -14
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +77 -20
  13. package/src/tui/pager.ts +4 -2
  14. package/src/tui/search.ts +9 -4
  15. package/src/tui/spinner.ts +81 -0
  16. package/src/tui/status-bar.ts +9 -8
  17. package/src/tui/thread-list.ts +62 -22
  18. package/src/tui/ui/keymap.ts +55 -0
  19. package/.github/workflows/ci.yml +0 -18
  20. package/CLAUDE.md +0 -27
  21. package/bun.lock +0 -213
  22. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  23. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  24. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  25. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  26. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  27. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  28. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  29. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  30. package/scripts/install-skill.sh +0 -20
  31. package/scripts/release.sh +0 -52
  32. package/test/cli-reply.test.ts +0 -140
  33. package/test/cli-watch.test.ts +0 -216
  34. package/test/cli.test.ts +0 -160
  35. package/test/e2e-live.test.ts +0 -171
  36. package/test/live-interaction.test.ts +0 -398
  37. package/test/opentui-smoke.test.ts +0 -12
  38. package/test/protocol/live-events.test.ts +0 -509
  39. package/test/protocol/live-merge.test.ts +0 -167
  40. package/test/protocol/merge.test.ts +0 -100
  41. package/test/protocol/read.test.ts +0 -92
  42. package/test/protocol/types.test.ts +0 -95
  43. package/test/protocol/write.test.ts +0 -72
  44. package/test/state/review-state.test.ts +0 -399
  45. package/test/tui/pager.test.ts +0 -159
  46. package/test/tui/ui/keybinds.test.ts +0 -71
  47. package/tsconfig.json +0 -14
@@ -1,509 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
- import { join } from "path";
4
- import { tmpdir } from "os";
5
- import {
6
- isValidLiveEvent,
7
- appendEvent,
8
- readEventsFromOffset,
9
- replayEventsToThreads,
10
- type LiveEvent,
11
- } from "../../src/protocol/live-events";
12
-
13
- function tmpDir() {
14
- return mkdtempSync(join(tmpdir(), "revspec-test-"));
15
- }
16
-
17
- describe("isValidLiveEvent", () => {
18
- it("accepts a valid comment event", () => {
19
- const event: unknown = {
20
- type: "comment",
21
- threadId: "t1",
22
- line: 5,
23
- author: "reviewer",
24
- text: "Hello",
25
- ts: 1000,
26
- };
27
- expect(isValidLiveEvent(event)).toBe(true);
28
- });
29
-
30
- it("accepts a valid reply event", () => {
31
- const event: unknown = {
32
- type: "reply",
33
- threadId: "t1",
34
- author: "owner",
35
- text: "Thanks",
36
- ts: 2000,
37
- };
38
- expect(isValidLiveEvent(event)).toBe(true);
39
- });
40
-
41
- it("accepts a valid resolve event", () => {
42
- const event: unknown = {
43
- type: "resolve",
44
- threadId: "t1",
45
- author: "reviewer",
46
- ts: 3000,
47
- };
48
- expect(isValidLiveEvent(event)).toBe(true);
49
- });
50
-
51
- it("accepts a valid approve event", () => {
52
- const event: unknown = {
53
- type: "approve",
54
- author: "reviewer",
55
- ts: 4000,
56
- };
57
- expect(isValidLiveEvent(event)).toBe(true);
58
- });
59
-
60
- it("accepts a valid round event", () => {
61
- const event: unknown = {
62
- type: "round",
63
- author: "owner",
64
- round: 2,
65
- ts: 5000,
66
- };
67
- expect(isValidLiveEvent(event)).toBe(true);
68
- });
69
-
70
- it("accepts a valid delete event", () => {
71
- const event: unknown = {
72
- type: "delete",
73
- threadId: "t1",
74
- author: "reviewer",
75
- ts: 6000,
76
- };
77
- expect(isValidLiveEvent(event)).toBe(true);
78
- });
79
-
80
- it("accepts a valid unresolve event", () => {
81
- const event: unknown = {
82
- type: "unresolve",
83
- threadId: "t1",
84
- author: "reviewer",
85
- ts: 7000,
86
- };
87
- expect(isValidLiveEvent(event)).toBe(true);
88
- });
89
-
90
- it("rejects event missing type", () => {
91
- expect(isValidLiveEvent({ author: "reviewer", ts: 1000 })).toBe(false);
92
- });
93
-
94
- it("rejects event with invalid type", () => {
95
- expect(
96
- isValidLiveEvent({ type: "invalid", author: "reviewer", ts: 1000 })
97
- ).toBe(false);
98
- });
99
-
100
- it("rejects comment missing text", () => {
101
- expect(
102
- isValidLiveEvent({
103
- type: "comment",
104
- threadId: "t1",
105
- line: 5,
106
- author: "reviewer",
107
- ts: 1000,
108
- })
109
- ).toBe(false);
110
- });
111
-
112
- it("rejects comment missing line", () => {
113
- expect(
114
- isValidLiveEvent({
115
- type: "comment",
116
- threadId: "t1",
117
- author: "reviewer",
118
- text: "Hello",
119
- ts: 1000,
120
- })
121
- ).toBe(false);
122
- });
123
-
124
- it("rejects event missing ts", () => {
125
- expect(
126
- isValidLiveEvent({
127
- type: "approve",
128
- author: "reviewer",
129
- })
130
- ).toBe(false);
131
- });
132
-
133
- it("rejects non-object", () => {
134
- expect(isValidLiveEvent(null)).toBe(false);
135
- expect(isValidLiveEvent("string")).toBe(false);
136
- expect(isValidLiveEvent(42)).toBe(false);
137
- });
138
- });
139
-
140
- describe("appendEvent + readEventsFromOffset", () => {
141
- let dir: string;
142
-
143
- beforeEach(() => {
144
- dir = tmpDir();
145
- });
146
-
147
- afterEach(() => {
148
- rmSync(dir, { recursive: true });
149
- });
150
-
151
- it("appends and reads back events", () => {
152
- const path = join(dir, "events.jsonl");
153
- const event: LiveEvent = {
154
- type: "comment",
155
- threadId: "t1",
156
- line: 3,
157
- author: "reviewer",
158
- text: "hello",
159
- ts: 1000,
160
- };
161
- appendEvent(path, event);
162
- const { events, newOffset } = readEventsFromOffset(path, 0);
163
- expect(events).toHaveLength(1);
164
- expect(events[0]).toEqual(event);
165
- expect(newOffset).toBeGreaterThan(0);
166
- });
167
-
168
- it("reads only new events from offset", () => {
169
- const path = join(dir, "events.jsonl");
170
- const event1: LiveEvent = {
171
- type: "comment",
172
- threadId: "t1",
173
- line: 1,
174
- author: "reviewer",
175
- text: "first",
176
- ts: 1000,
177
- };
178
- const event2: LiveEvent = {
179
- type: "reply",
180
- threadId: "t1",
181
- author: "owner",
182
- text: "second",
183
- ts: 2000,
184
- };
185
- appendEvent(path, event1);
186
- const { newOffset: offset1 } = readEventsFromOffset(path, 0);
187
- appendEvent(path, event2);
188
- const { events, newOffset: offset2 } = readEventsFromOffset(path, offset1);
189
- expect(events).toHaveLength(1);
190
- expect(events[0]).toEqual(event2);
191
- expect(offset2).toBeGreaterThan(offset1);
192
- });
193
-
194
- it("returns empty for non-existent file", () => {
195
- const { events, newOffset } = readEventsFromOffset(
196
- join(dir, "nonexistent.jsonl"),
197
- 0
198
- );
199
- expect(events).toHaveLength(0);
200
- expect(newOffset).toBe(0);
201
- });
202
-
203
- it("discards malformed lines", () => {
204
- const path = join(dir, "events.jsonl");
205
- const validEvent: LiveEvent = {
206
- type: "approve",
207
- author: "reviewer",
208
- ts: 1000,
209
- };
210
- appendEvent(path, validEvent);
211
- // Manually append a malformed line
212
- const Bun_appendFile = require("fs").appendFileSync;
213
- Bun_appendFile(path, "not valid json\n");
214
- appendEvent(path, validEvent);
215
-
216
- const { events } = readEventsFromOffset(path, 0);
217
- expect(events).toHaveLength(2);
218
- expect(events[0]).toEqual(validEvent);
219
- expect(events[1]).toEqual(validEvent);
220
- });
221
-
222
- it("handles mid-line byte offset alignment", () => {
223
- const path = join(dir, "events.jsonl");
224
- const event: LiveEvent = {
225
- type: "comment",
226
- threadId: "t1",
227
- line: 5,
228
- author: "reviewer",
229
- text: "test",
230
- ts: 1000,
231
- };
232
- appendEvent(path, event);
233
- // Start mid-line (offset 3 is in the middle of first line)
234
- const { events } = readEventsFromOffset(path, 3);
235
- // Should skip to next newline and not crash; may return empty or partial depending on alignment
236
- expect(Array.isArray(events)).toBe(true);
237
- });
238
- });
239
-
240
- describe("replayEventsToThreads", () => {
241
- it("creates threads from comment events", () => {
242
- const events: LiveEvent[] = [
243
- {
244
- type: "comment",
245
- threadId: "t1",
246
- line: 3,
247
- author: "reviewer",
248
- text: "First comment",
249
- ts: 1000,
250
- },
251
- {
252
- type: "comment",
253
- threadId: "t2",
254
- line: 7,
255
- author: "reviewer",
256
- text: "Second comment",
257
- ts: 2000,
258
- },
259
- ];
260
- const threads = replayEventsToThreads(events);
261
- expect(threads).toHaveLength(2);
262
- expect(threads[0].id).toBe("t1");
263
- expect(threads[0].line).toBe(3);
264
- expect(threads[0].messages[0].text).toBe("First comment");
265
- expect(threads[1].id).toBe("t2");
266
- });
267
-
268
- it("appends replies to threads", () => {
269
- const events: LiveEvent[] = [
270
- {
271
- type: "comment",
272
- threadId: "t1",
273
- line: 3,
274
- author: "reviewer",
275
- text: "Question",
276
- ts: 1000,
277
- },
278
- {
279
- type: "reply",
280
- threadId: "t1",
281
- author: "owner",
282
- text: "Answer",
283
- ts: 2000,
284
- },
285
- ];
286
- const threads = replayEventsToThreads(events);
287
- expect(threads).toHaveLength(1);
288
- expect(threads[0].messages).toHaveLength(2);
289
- expect(threads[0].messages[1].text).toBe("Answer");
290
- });
291
-
292
- it("sets status to pending after owner reply", () => {
293
- const events: LiveEvent[] = [
294
- {
295
- type: "comment",
296
- threadId: "t1",
297
- line: 3,
298
- author: "reviewer",
299
- text: "Question",
300
- ts: 1000,
301
- },
302
- {
303
- type: "reply",
304
- threadId: "t1",
305
- author: "owner",
306
- text: "Answer",
307
- ts: 2000,
308
- },
309
- ];
310
- const threads = replayEventsToThreads(events);
311
- expect(threads[0].status).toBe("pending");
312
- });
313
-
314
- it("sets status to open after reviewer reply", () => {
315
- const events: LiveEvent[] = [
316
- {
317
- type: "comment",
318
- threadId: "t1",
319
- line: 3,
320
- author: "reviewer",
321
- text: "Question",
322
- ts: 1000,
323
- },
324
- {
325
- type: "reply",
326
- threadId: "t1",
327
- author: "owner",
328
- text: "Answer",
329
- ts: 2000,
330
- },
331
- {
332
- type: "reply",
333
- threadId: "t1",
334
- author: "reviewer",
335
- text: "Follow up",
336
- ts: 3000,
337
- },
338
- ];
339
- const threads = replayEventsToThreads(events);
340
- expect(threads[0].status).toBe("open");
341
- });
342
-
343
- it("resolves thread on resolve event", () => {
344
- const events: LiveEvent[] = [
345
- {
346
- type: "comment",
347
- threadId: "t1",
348
- line: 3,
349
- author: "reviewer",
350
- text: "Issue",
351
- ts: 1000,
352
- },
353
- {
354
- type: "resolve",
355
- threadId: "t1",
356
- author: "reviewer",
357
- ts: 2000,
358
- },
359
- ];
360
- const threads = replayEventsToThreads(events);
361
- expect(threads[0].status).toBe("resolved");
362
- });
363
-
364
- it("unresolved thread on unresolve event", () => {
365
- const events: LiveEvent[] = [
366
- {
367
- type: "comment",
368
- threadId: "t1",
369
- line: 3,
370
- author: "reviewer",
371
- text: "Issue",
372
- ts: 1000,
373
- },
374
- {
375
- type: "resolve",
376
- threadId: "t1",
377
- author: "reviewer",
378
- ts: 2000,
379
- },
380
- {
381
- type: "unresolve",
382
- threadId: "t1",
383
- author: "reviewer",
384
- ts: 3000,
385
- },
386
- ];
387
- const threads = replayEventsToThreads(events);
388
- expect(threads[0].status).toBe("open");
389
- });
390
-
391
- it("delete removes last reviewer message", () => {
392
- const events: LiveEvent[] = [
393
- {
394
- type: "comment",
395
- threadId: "t1",
396
- line: 3,
397
- author: "reviewer",
398
- text: "First",
399
- ts: 1000,
400
- },
401
- {
402
- type: "reply",
403
- threadId: "t1",
404
- author: "reviewer",
405
- text: "Second",
406
- ts: 2000,
407
- },
408
- {
409
- type: "delete",
410
- threadId: "t1",
411
- author: "reviewer",
412
- ts: 3000,
413
- },
414
- ];
415
- const threads = replayEventsToThreads(events);
416
- expect(threads).toHaveLength(1);
417
- expect(threads[0].messages).toHaveLength(1);
418
- expect(threads[0].messages[0].text).toBe("First");
419
- });
420
-
421
- it("delete re-derives status from remaining messages", () => {
422
- const events: LiveEvent[] = [
423
- { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "comment", ts: 1000 },
424
- { type: "reply", threadId: "t1", author: "owner", text: "response", ts: 1001 },
425
- { type: "reply", threadId: "t1", author: "reviewer", text: "follow up", ts: 1002 },
426
- { type: "delete", threadId: "t1", author: "reviewer", ts: 1003 },
427
- ];
428
- const threads = replayEventsToThreads(events);
429
- expect(threads).toHaveLength(1);
430
- // After deleting the reviewer reply, last message is owner reply → pending
431
- expect(threads[0].status).toBe("pending");
432
- expect(threads[0].messages).toHaveLength(2);
433
- });
434
-
435
- it("excludes empty threads after all messages deleted", () => {
436
- const events: LiveEvent[] = [
437
- {
438
- type: "comment",
439
- threadId: "t1",
440
- line: 3,
441
- author: "reviewer",
442
- text: "Only message",
443
- ts: 1000,
444
- },
445
- {
446
- type: "delete",
447
- threadId: "t1",
448
- author: "reviewer",
449
- ts: 2000,
450
- },
451
- ];
452
- const threads = replayEventsToThreads(events);
453
- expect(threads).toHaveLength(0);
454
- });
455
-
456
- it("skips approve events (does not create threads)", () => {
457
- const events: LiveEvent[] = [
458
- {
459
- type: "approve",
460
- author: "reviewer",
461
- ts: 1000,
462
- },
463
- ];
464
- const threads = replayEventsToThreads(events);
465
- expect(threads).toHaveLength(0);
466
- });
467
-
468
- it("skips round events", () => {
469
- const events: LiveEvent[] = [
470
- {
471
- type: "round",
472
- author: "owner",
473
- round: 2,
474
- ts: 1000,
475
- },
476
- ];
477
- const threads = replayEventsToThreads(events);
478
- expect(threads).toHaveLength(0);
479
- });
480
-
481
- it("preserves timestamps on messages", () => {
482
- const events: LiveEvent[] = [
483
- {
484
- type: "comment",
485
- threadId: "t1",
486
- line: 3,
487
- author: "reviewer",
488
- text: "With ts",
489
- ts: 9999,
490
- },
491
- ];
492
- const threads = replayEventsToThreads(events);
493
- expect(threads[0].messages[0].ts).toBe(9999);
494
- });
495
-
496
- it("ignores replies to unknown threads", () => {
497
- const events: LiveEvent[] = [
498
- {
499
- type: "reply",
500
- threadId: "unknown-id",
501
- author: "owner",
502
- text: "Reply to nothing",
503
- ts: 1000,
504
- },
505
- ];
506
- const threads = replayEventsToThreads(events);
507
- expect(threads).toHaveLength(0);
508
- });
509
- });
@@ -1,167 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdtempSync, rmSync } from "fs";
3
- import { join } from "path";
4
- import { tmpdir } from "os";
5
- import { mergeJsonlIntoReview } from "../../src/protocol/live-merge";
6
- import { appendEvent } from "../../src/protocol/live-events";
7
- import type { ReviewFile } from "../../src/protocol/types";
8
-
9
- function tmpDir() {
10
- return mkdtempSync(join(tmpdir(), "revspec-test-"));
11
- }
12
-
13
- describe("mergeJsonlIntoReview", () => {
14
- let dir: string;
15
-
16
- beforeEach(() => {
17
- dir = tmpDir();
18
- });
19
-
20
- afterEach(() => {
21
- rmSync(dir, { recursive: true });
22
- });
23
-
24
- it("creates a new review from JSONL events", () => {
25
- const jsonlPath = join(dir, "events.jsonl");
26
- appendEvent(jsonlPath, {
27
- type: "comment",
28
- threadId: "t1",
29
- line: 3,
30
- author: "reviewer",
31
- text: "First comment",
32
- ts: 1000,
33
- });
34
- appendEvent(jsonlPath, {
35
- type: "comment",
36
- threadId: "t2",
37
- line: 7,
38
- author: "reviewer",
39
- text: "Second comment",
40
- ts: 2000,
41
- });
42
-
43
- const result = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
44
- expect(result.file).toBe("spec.md");
45
- expect(result.threads).toHaveLength(2);
46
- expect(result.threads[0].id).toBe("t1");
47
- expect(result.threads[1].id).toBe("t2");
48
- });
49
-
50
- it("merges with existing review threads (preserves old, adds new)", () => {
51
- const jsonlPath = join(dir, "events.jsonl");
52
- appendEvent(jsonlPath, {
53
- type: "comment",
54
- threadId: "t3",
55
- line: 10,
56
- author: "reviewer",
57
- text: "New from JSONL",
58
- ts: 1000,
59
- });
60
-
61
- const existingReview: ReviewFile = {
62
- file: "spec.md",
63
- threads: [
64
- {
65
- id: "t1",
66
- line: 1,
67
- status: "open",
68
- messages: [{ author: "reviewer", text: "Existing comment" }],
69
- },
70
- ],
71
- };
72
-
73
- const result = mergeJsonlIntoReview(jsonlPath, existingReview, "spec.md");
74
- expect(result.threads).toHaveLength(2);
75
- // Existing thread preserved
76
- expect(result.threads.find((t) => t.id === "t1")).toBeDefined();
77
- // New thread from JSONL added
78
- expect(result.threads.find((t) => t.id === "t3")).toBeDefined();
79
- });
80
-
81
- it("appends new messages to existing thread with same ID", () => {
82
- const jsonlPath = join(dir, "events.jsonl");
83
- // JSONL has comment + reply for t1
84
- appendEvent(jsonlPath, {
85
- type: "comment",
86
- threadId: "t1",
87
- line: 5,
88
- author: "reviewer",
89
- text: "Original",
90
- ts: 1000,
91
- });
92
- appendEvent(jsonlPath, {
93
- type: "reply",
94
- threadId: "t1",
95
- author: "owner",
96
- text: "Owner replied",
97
- ts: 2000,
98
- });
99
-
100
- const existingReview: ReviewFile = {
101
- file: "spec.md",
102
- threads: [
103
- {
104
- id: "t1",
105
- line: 5,
106
- status: "open",
107
- messages: [{ author: "reviewer", text: "Original" }],
108
- },
109
- ],
110
- };
111
-
112
- const result = mergeJsonlIntoReview(jsonlPath, existingReview, "spec.md");
113
- expect(result.threads).toHaveLength(1);
114
- expect(result.threads[0].messages).toHaveLength(2);
115
- expect(result.threads[0].messages[1].text).toBe("Owner replied");
116
- expect(result.threads[0].status).toBe("pending");
117
- });
118
-
119
- it("returns existing review unchanged if JSONL is empty", () => {
120
- const jsonlPath = join(dir, "events.jsonl");
121
- // Create empty JSONL file
122
- const { appendFileSync } = require("fs");
123
- appendFileSync(jsonlPath, "");
124
-
125
- const existingReview: ReviewFile = {
126
- file: "spec.md",
127
- threads: [
128
- {
129
- id: "t1",
130
- line: 1,
131
- status: "resolved",
132
- messages: [{ author: "reviewer", text: "Done" }],
133
- },
134
- ],
135
- };
136
-
137
- const result = mergeJsonlIntoReview(jsonlPath, existingReview, "spec.md");
138
- expect(result.threads).toHaveLength(1);
139
- expect(result.threads[0].id).toBe("t1");
140
- expect(result.threads[0].status).toBe("resolved");
141
- });
142
-
143
- it("returns a new review with empty threads if JSONL is missing and no existing review", () => {
144
- const jsonlPath = join(dir, "nonexistent.jsonl");
145
- const result = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
146
- expect(result.file).toBe("spec.md");
147
- expect(result.threads).toHaveLength(0);
148
- });
149
-
150
- it("is idempotent — replaying from byte 0 each time", () => {
151
- const jsonlPath = join(dir, "events.jsonl");
152
- appendEvent(jsonlPath, {
153
- type: "comment",
154
- threadId: "t1",
155
- line: 3,
156
- author: "reviewer",
157
- text: "Hello",
158
- ts: 1000,
159
- });
160
-
161
- const result1 = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
162
- const result2 = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
163
-
164
- expect(result1).toEqual(result2);
165
- expect(result1.threads).toHaveLength(1);
166
- });
167
- });