revspec 0.7.0 → 0.7.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli/watch.ts +58 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
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);
@@ -22,7 +29,6 @@ export async function runWatch(specFile: string): Promise<void> {
22
29
  const jsonlPath = join(dir, `${base}.review.jsonl`);
23
30
  const offsetPath = join(dir, `${base}.review.offset`);
24
31
  const lockPath = join(dir, `${base}.review.lock`);
25
- const reviewPath = join(dir, `${base}.review.json`);
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,11 +79,11 @@ 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
89
  console.log("Review approved.");
@@ -83,20 +96,22 @@ 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
117
  console.log("Review approved.");
@@ -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
  }
@@ -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,11 +202,17 @@ function processNewEvents(
180
202
 
181
203
  const { events, newOffset } = readEventsFromOffset(jsonlPath, offset);
182
204
 
183
- // Recovery: detect pending unprocessed submit
184
- if (events.length === 0) {
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) {
185
208
  const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
186
209
  const lastSubmitIdx = allEvents.findLastIndex(e => e.type === "submit");
187
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
+ }
188
216
  const afterSubmit = allEvents.slice(lastSubmitIdx + 1);
189
217
  const hasNewActivity = afterSubmit.some(e =>
190
218
  e.type === "comment" || e.type === "reply" ||
@@ -195,14 +223,20 @@ function processNewEvents(
195
223
  const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
196
224
  const resolved = currentRoundThreads.filter(t => t.status === "resolved");
197
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}`);
198
228
  return { approved: false, output, newOffset: offset };
199
229
  }
200
230
  }
201
231
  return { approved: false, output: "", newOffset: offset };
202
232
  }
203
233
 
204
- // Save new offset
205
- writeFileSync(offsetPath, String(newOffset), "utf8");
234
+ if (events.length === 0) {
235
+ return { approved: false, output: "", newOffset: offset };
236
+ }
237
+
238
+ // Save new offset (preserve submit_ts if present)
239
+ atomicWriteFileSync(offsetPath, lastSubmitTs ? `${newOffset}\n${lastSubmitTs}` : String(newOffset));
206
240
 
207
241
  // Check for approve event
208
242
  const hasApprove = events.some((e) => e.type === "approve");
@@ -211,13 +245,15 @@ function processNewEvents(
211
245
  }
212
246
 
213
247
  // Check for submit event — priority over session-end
214
- const hasSubmit = events.some((e) => e.type === "submit");
215
- if (hasSubmit) {
248
+ const submitEvent = events.findLast((e) => e.type === "submit");
249
+ if (submitEvent) {
216
250
  const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
217
251
  const roundStart = findCurrentRoundStartIndex(allEvents);
218
252
  const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
219
253
  const resolved = currentRoundThreads.filter(t => t.status === "resolved");
220
254
  const output = formatSubmitOutput(resolved, specPath);
255
+ // Record submit ts for crash recovery dedup
256
+ atomicWriteFileSync(offsetPath, `${newOffset}\n${submitEvent.ts}`);
221
257
  return { approved: false, output, newOffset };
222
258
  }
223
259