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
package/src/cli/watch.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
2
2
  import { watch as fsWatch } from "fs";
3
3
  import { resolve, dirname, basename, join } from "path";
4
4
  import {
@@ -8,6 +8,13 @@ import {
8
8
  import type { Thread } from "../protocol/types";
9
9
  import { replayEventsToThreads } from "../protocol/live-events";
10
10
 
11
+ /** Atomic write: write to .tmp then rename (POSIX rename is atomic) */
12
+ function atomicWriteFileSync(filePath: string, content: string): void {
13
+ const tmpPath = filePath + ".tmp";
14
+ writeFileSync(tmpPath, content, "utf8");
15
+ renameSync(tmpPath, filePath);
16
+ }
17
+
11
18
  export async function runWatch(specFile: string): Promise<void> {
12
19
  // Resolve and validate spec path
13
20
  const specPath = resolve(specFile);
@@ -19,10 +26,9 @@ export async function runWatch(specFile: string): Promise<void> {
19
26
  // Derive paths
20
27
  const dir = dirname(specPath);
21
28
  const base = basename(specPath, ".md");
22
- const jsonlPath = join(dir, `${base}.review.live.jsonl`);
23
- const offsetPath = join(dir, `${base}.review.live.offset`);
24
- const lockPath = join(dir, `${base}.review.live.lock`);
25
- const reviewPath = join(dir, `${base}.review.json`);
29
+ const jsonlPath = join(dir, `${base}.review.jsonl`);
30
+ const offsetPath = join(dir, `${base}.review.offset`);
31
+ const lockPath = join(dir, `${base}.review.lock`);
26
32
 
27
33
  // Handle lock file
28
34
  if (existsSync(lockPath)) {
@@ -46,14 +52,21 @@ export async function runWatch(specFile: string): Promise<void> {
46
52
  // Create/overwrite lock with current PID
47
53
  writeFileSync(lockPath, String(process.pid), "utf8");
48
54
 
49
- // Read offset from offset file (or 0)
55
+ // Read offset and last processed submit timestamp from offset file
50
56
  let offset = 0;
57
+ let lastSubmitTs = 0;
51
58
  if (existsSync(offsetPath)) {
52
- const raw = readFileSync(offsetPath, "utf8").trim();
53
- const parsed = parseInt(raw, 10);
59
+ const lines = readFileSync(offsetPath, "utf8").trim().split("\n");
60
+ const parsed = parseInt(lines[0], 10);
54
61
  if (!isNaN(parsed)) {
55
62
  offset = parsed;
56
63
  }
64
+ if (lines.length > 1) {
65
+ const parsedTs = parseInt(lines[1], 10);
66
+ if (!isNaN(parsedTs)) {
67
+ lastSubmitTs = parsedTs;
68
+ }
69
+ }
57
70
  }
58
71
 
59
72
  // Read spec lines for context
@@ -66,14 +79,14 @@ export async function runWatch(specFile: string): Promise<void> {
66
79
  const result = processNewEvents(
67
80
  jsonlPath,
68
81
  offsetPath,
69
- lockPath,
70
- reviewPath,
71
82
  specPath,
72
83
  specLines,
73
- offset
84
+ offset,
85
+ lastSubmitTs,
86
+ true // always check recovery in non-blocking (one-shot)
74
87
  );
75
88
  if (result.approved) {
76
- console.log(`Review approved.\nReview file: ${reviewPath}`);
89
+ console.log("Review approved.");
77
90
  cleanupFiles(lockPath, offsetPath);
78
91
  } else if (result.output) {
79
92
  process.stdout.write(result.output);
@@ -83,23 +96,25 @@ export async function runWatch(specFile: string): Promise<void> {
83
96
 
84
97
  // Blocking mode: watch for JSONL changes
85
98
  let processing = false;
99
+ let firstPoll = true; // only run crash recovery on first poll
86
100
 
87
- const handleChange = async () => {
101
+ const handleChange = () => {
88
102
  if (processing) return;
89
103
  processing = true;
90
104
  try {
91
105
  const result = processNewEvents(
92
106
  jsonlPath,
93
107
  offsetPath,
94
- lockPath,
95
- reviewPath,
96
108
  specPath,
97
109
  specLines,
98
- offset
110
+ offset,
111
+ lastSubmitTs,
112
+ firstPoll
99
113
  );
114
+ firstPoll = false;
100
115
 
101
116
  if (result.approved) {
102
- console.log(`Review approved.\nReview file: ${reviewPath}`);
117
+ console.log("Review approved.");
103
118
  cleanupFiles(lockPath, offsetPath);
104
119
  process.exit(0);
105
120
  }
@@ -110,8 +125,15 @@ export async function runWatch(specFile: string): Promise<void> {
110
125
  process.exit(0);
111
126
  }
112
127
 
113
- // Update offset for next call
128
+ // Update state for next poll iteration
114
129
  offset = result.newOffset;
130
+ if (existsSync(offsetPath)) {
131
+ const lines = readFileSync(offsetPath, "utf8").trim().split("\n");
132
+ if (lines.length > 1) {
133
+ const parsedTs = parseInt(lines[1], 10);
134
+ if (!isNaN(parsedTs)) lastSubmitTs = parsedTs;
135
+ }
136
+ }
115
137
  } finally {
116
138
  processing = false;
117
139
  }
@@ -135,7 +157,7 @@ export async function runWatch(specFile: string): Promise<void> {
135
157
 
136
158
  // Also watch the directory for the JSONL file to appear
137
159
  const dirWatcher = fsWatch(dir, (eventType, filename) => {
138
- if (filename && filename.endsWith(".live.jsonl")) {
160
+ if (filename && filename.endsWith(".jsonl")) {
139
161
  setupWatcher();
140
162
  }
141
163
  });
@@ -168,11 +190,11 @@ interface ProcessResult {
168
190
  function processNewEvents(
169
191
  jsonlPath: string,
170
192
  offsetPath: string,
171
- lockPath: string,
172
- reviewPath: string,
173
193
  specPath: string,
174
194
  specLines: string[],
175
- offset: number
195
+ offset: number,
196
+ lastSubmitTs: number,
197
+ checkRecovery: boolean
176
198
  ): ProcessResult {
177
199
  if (!existsSync(jsonlPath)) {
178
200
  return { approved: false, output: "", newOffset: offset };
@@ -180,12 +202,41 @@ function processNewEvents(
180
202
 
181
203
  const { events, newOffset } = readEventsFromOffset(jsonlPath, offset);
182
204
 
205
+ // Recovery: detect pending unprocessed submit (only on first poll to avoid
206
+ // re-reading the entire JSONL every 500ms in blocking mode)
207
+ if (events.length === 0 && checkRecovery) {
208
+ const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
209
+ const lastSubmitIdx = allEvents.findLastIndex(e => e.type === "submit");
210
+ if (lastSubmitIdx >= 0) {
211
+ const lastSubmitEvent = allEvents[lastSubmitIdx];
212
+ // If we already output this submit, skip (not a crash)
213
+ if (lastSubmitEvent.ts === lastSubmitTs) {
214
+ return { approved: false, output: "", newOffset: offset };
215
+ }
216
+ const afterSubmit = allEvents.slice(lastSubmitIdx + 1);
217
+ const hasNewActivity = afterSubmit.some(e =>
218
+ e.type === "comment" || e.type === "reply" ||
219
+ e.type === "approve" || e.type === "session-end"
220
+ );
221
+ if (!hasNewActivity) {
222
+ const roundStart = findCurrentRoundStartIndex(allEvents);
223
+ const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
224
+ const resolved = currentRoundThreads.filter(t => t.status === "resolved");
225
+ const output = formatSubmitOutput(resolved, specPath);
226
+ // Record this submit's ts so we don't re-output it
227
+ atomicWriteFileSync(offsetPath, `${offset}\n${lastSubmitEvent.ts}`);
228
+ return { approved: false, output, newOffset: offset };
229
+ }
230
+ }
231
+ return { approved: false, output: "", newOffset: offset };
232
+ }
233
+
183
234
  if (events.length === 0) {
184
235
  return { approved: false, output: "", newOffset: offset };
185
236
  }
186
237
 
187
238
  // Save new offset
188
- writeFileSync(offsetPath, String(newOffset), "utf8");
239
+ atomicWriteFileSync(offsetPath, String(newOffset));
189
240
 
190
241
  // Check for approve event
191
242
  const hasApprove = events.some((e) => e.type === "approve");
@@ -193,6 +244,19 @@ function processNewEvents(
193
244
  return { approved: true, output: "", newOffset };
194
245
  }
195
246
 
247
+ // Check for submit event — priority over session-end
248
+ const submitEvent = events.findLast((e) => e.type === "submit");
249
+ if (submitEvent) {
250
+ const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
251
+ const roundStart = findCurrentRoundStartIndex(allEvents);
252
+ const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
253
+ const resolved = currentRoundThreads.filter(t => t.status === "resolved");
254
+ const output = formatSubmitOutput(resolved, specPath);
255
+ // Record submit ts for crash recovery dedup
256
+ atomicWriteFileSync(offsetPath, `${newOffset}\n${submitEvent.ts}`);
257
+ return { approved: false, output, newOffset };
258
+ }
259
+
196
260
  // Check for session-end — TUI exited, break the loop
197
261
  const hasSessionEnd = events.some((e) => e.type === "session-end");
198
262
  if (hasSessionEnd) {
@@ -233,8 +297,6 @@ function formatWatchOutput(
233
297
  // Group events by type
234
298
  const newCommentThreadIds: string[] = [];
235
299
  const replyThreadIds: string[] = [];
236
- const resolvedThreadIds: string[] = [];
237
- const deletedThreadIds: string[] = [];
238
300
 
239
301
  const seen = new Set<string>();
240
302
 
@@ -249,14 +311,6 @@ function formatWatchOutput(
249
311
  if (!replyThreadIds.includes(tid)) {
250
312
  replyThreadIds.push(tid);
251
313
  }
252
- } else if (event.type === "resolve") {
253
- if (!resolvedThreadIds.includes(tid)) {
254
- resolvedThreadIds.push(tid);
255
- }
256
- } else if (event.type === "delete") {
257
- if (!deletedThreadIds.includes(tid)) {
258
- deletedThreadIds.push(tid);
259
- }
260
314
  }
261
315
  }
262
316
 
@@ -315,31 +369,6 @@ function formatWatchOutput(
315
369
  }
316
370
  }
317
371
 
318
- // Resolved threads
319
- if (resolvedThreadIds.length > 0) {
320
- lines.push("=== Resolved ===");
321
- for (const tid of resolvedThreadIds) {
322
- const thread = threadsById.get(tid);
323
- if (!thread) continue;
324
- lines.push(`Thread: ${tid} (line ${thread.line}) — resolved`);
325
- lines.push("");
326
- }
327
- }
328
-
329
- // Deleted threads
330
- if (deletedThreadIds.length > 0) {
331
- lines.push("=== Deleted ===");
332
- for (const tid of deletedThreadIds) {
333
- const thread = threadsById.get(tid);
334
- if (thread) {
335
- lines.push(`Thread: ${tid} (line ${thread.line}) — deleted`);
336
- } else {
337
- lines.push(`Thread: ${tid} — deleted`);
338
- }
339
- lines.push("");
340
- }
341
- }
342
-
343
372
  // Add footer instruction
344
373
  const hasActionable = newCommentThreadIds.length > 0 || replyThreadIds.length > 0;
345
374
  if (hasActionable) {
@@ -368,6 +397,41 @@ function getContext(
368
397
  return result;
369
398
  }
370
399
 
400
+ function findCurrentRoundStartIndex(events: LiveEvent[]): number {
401
+ let count = 0;
402
+ for (let i = events.length - 1; i >= 0; i--) {
403
+ if (events[i].type === "submit") {
404
+ count++;
405
+ if (count === 2) return i + 1;
406
+ }
407
+ }
408
+ return 0;
409
+ }
410
+
411
+ function formatSubmitOutput(
412
+ resolvedThreads: Thread[],
413
+ specPath: string
414
+ ): string {
415
+ const lines: string[] = [];
416
+ lines.push("=== Submit: Rewrite Requested ===");
417
+ lines.push("");
418
+ if (resolvedThreads.length > 0) {
419
+ lines.push("Resolved threads:");
420
+ for (const t of resolvedThreads) {
421
+ const reviewerMsgs = t.messages.filter(m => m.author === "reviewer");
422
+ const ownerMsgs = t.messages.filter(m => m.author === "owner");
423
+ lines.push(` ${t.id} (line ${t.line}): "${reviewerMsgs.map(m => m.text).join("; ")}"`);
424
+ if (ownerMsgs.length > 0) {
425
+ lines.push(` → AI: "${ownerMsgs.map(m => m.text).join("; ")}"`);
426
+ }
427
+ }
428
+ lines.push("");
429
+ }
430
+ lines.push(`Rewrite the spec incorporating the above, then run: revspec watch ${basename(specPath)}`);
431
+ lines.push("");
432
+ return lines.join("\n");
433
+ }
434
+
371
435
  function cleanupFiles(lockPath: string, offsetPath: string): void {
372
436
  if (existsSync(lockPath)) unlinkSync(lockPath);
373
437
  if (existsSync(offsetPath)) unlinkSync(offsetPath);
@@ -9,7 +9,8 @@ export type LiveEventType =
9
9
  | "approve"
10
10
  | "delete"
11
11
  | "round"
12
- | "session-end";
12
+ | "session-end"
13
+ | "submit";
13
14
 
14
15
  export interface LiveEvent {
15
16
  type: LiveEventType;
@@ -30,6 +31,7 @@ const VALID_LIVE_EVENT_TYPES: readonly LiveEventType[] = [
30
31
  "delete",
31
32
  "round",
32
33
  "session-end",
34
+ "submit",
33
35
  ];
34
36
 
35
37
  export function isValidLiveEvent(value: unknown): value is LiveEvent {
@@ -49,7 +51,7 @@ export function isValidLiveEvent(value: unknown): value is LiveEvent {
49
51
  if (typeof v.author !== "string") return false;
50
52
 
51
53
  // threadId required for all except approve, round, and session-end
52
- if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end") {
54
+ if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end" && v.type !== "submit") {
53
55
  if (typeof v.threadId !== "string") return false;
54
56
  }
55
57
 
@@ -186,25 +188,13 @@ export function replayEventsToThreads(events: LiveEvent[]): Thread[] {
186
188
 
187
189
  case "delete": {
188
190
  if (!event.threadId) break;
189
- const thread = threadsMap.get(event.threadId);
190
- if (!thread) break;
191
- // Remove the last reviewer message
192
- for (let i = thread.messages.length - 1; i >= 0; i--) {
193
- if (thread.messages[i].author === "reviewer") {
194
- thread.messages.splice(i, 1);
195
- break;
196
- }
197
- }
198
- // Re-derive status from the new last message
199
- const lastMsg = thread.messages[thread.messages.length - 1];
200
- if (lastMsg) {
201
- thread.status = lastMsg.author === "owner" ? "pending" : "open";
202
- }
191
+ threadsMap.delete(event.threadId);
203
192
  break;
204
193
  }
205
194
 
206
195
  case "approve":
207
196
  case "round":
197
+ case "submit":
208
198
  // Skip these event types
209
199
  break;
210
200
  }
@@ -1,5 +1,16 @@
1
+ import { randomBytes } from "crypto";
1
2
  import type { Thread, Message } from "../protocol/types";
2
3
 
4
+ function nanoid(size = 8): string {
5
+ const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
6
+ const bytes = randomBytes(size);
7
+ let id = "";
8
+ for (let i = 0; i < size; i++) {
9
+ id += alphabet[bytes[i] % alphabet.length];
10
+ }
11
+ return id;
12
+ }
13
+
3
14
  export class ReviewState {
4
15
  specLines: string[];
5
16
  threads: Thread[];
@@ -20,12 +31,7 @@ export class ReviewState {
20
31
  }
21
32
 
22
33
  nextThreadId(): string {
23
- if (this.threads.length === 0) return "t1";
24
- const highest = this.threads.reduce((max, t) => {
25
- const n = parseInt(t.id.replace(/^t/, ""), 10);
26
- return isNaN(n) ? max : Math.max(max, n);
27
- }, 0);
28
- return `t${highest + 1}`;
34
+ return nanoid();
29
35
  }
30
36
 
31
37
  addComment(line: number, text: string): void {
@@ -64,40 +70,47 @@ export class ReviewState {
64
70
  }
65
71
  }
66
72
 
73
+ resolveAll(): void {
74
+ for (const thread of this.threads) {
75
+ if (thread.status !== "resolved" && thread.status !== "outdated") {
76
+ thread.status = "resolved";
77
+ }
78
+ }
79
+ }
80
+
81
+ reset(newSpecLines: string[]): void {
82
+ this.specLines = newSpecLines;
83
+ this.threads = [];
84
+ this.cursorLine = 1;
85
+ this._unreadThreadIds.clear();
86
+ }
87
+
67
88
  threadAtLine(line: number): Thread | null {
68
89
  return this.threads.find((t) => t.line === line) ?? null;
69
90
  }
70
91
 
71
- nextActiveThread(): number | null {
72
- const active = this.threads.filter(
73
- (t) => t.status === "open" || t.status === "pending"
74
- );
75
- if (active.length === 0) return null;
92
+ nextThread(): number | null {
93
+ if (this.threads.length === 0) return null;
76
94
 
77
- // Look for first active thread after cursor (strictly after)
78
- const after = active.filter((t) => t.line > this.cursorLine);
95
+ const after = this.threads.filter((t) => t.line > this.cursorLine);
79
96
  if (after.length > 0) {
80
97
  return after.reduce((min, t) => (t.line < min.line ? t : min)).line;
81
98
  }
82
99
 
83
- // Wrap: return the lowest-line active thread
84
- return active.reduce((min, t) => (t.line < min.line ? t : min)).line;
100
+ // Wrap: return the lowest-line thread
101
+ return this.threads.reduce((min, t) => (t.line < min.line ? t : min)).line;
85
102
  }
86
103
 
87
- prevActiveThread(): number | null {
88
- const active = this.threads.filter(
89
- (t) => t.status === "open" || t.status === "pending"
90
- );
91
- if (active.length === 0) return null;
104
+ prevThread(): number | null {
105
+ if (this.threads.length === 0) return null;
92
106
 
93
- // Look for last active thread before cursor (strictly before)
94
- const before = active.filter((t) => t.line < this.cursorLine);
107
+ const before = this.threads.filter((t) => t.line < this.cursorLine);
95
108
  if (before.length > 0) {
96
109
  return before.reduce((max, t) => (t.line > max.line ? t : max)).line;
97
110
  }
98
111
 
99
- // Wrap: return the highest-line active thread
100
- return active.reduce((max, t) => (t.line > max.line ? t : max)).line;
112
+ // Wrap: return the highest-line thread
113
+ return this.threads.reduce((max, t) => (t.line > max.line ? t : max)).line;
101
114
  }
102
115
 
103
116
  canApprove(): boolean {