spectater 0.1.0 → 0.2.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.
- package/README.md +86 -33
- package/bin/spectater.js +346 -306
- package/package.json +18 -9
- package/skills/spectater/SKILL.md +13 -0
- package/src/agent.js +184 -0
- package/src/discovery.js +4 -4
- package/src/hooks.js +145 -0
- package/src/review.js +195 -140
- package/src/server.js +143 -57
- package/src/storage.js +56 -9
- package/web/app.js +51 -11
- package/web/index.html +15 -2
- package/web/style.css +12 -0
- package/SECURITY.md +0 -11
- package/docs/licenses/open-design.txt +0 -201
- package/docs/security-review.md +0 -33
- package/docs/verification.md +0 -41
- package/docs/workflow.md +0 -65
- package/integration/spectater/SKILL.md +0 -17
- package/src/control.js +0 -236
package/src/review.js
CHANGED
|
@@ -103,6 +103,7 @@ export async function openReview(options, dependencies = {}) {
|
|
|
103
103
|
.filter((c) => c.deletedAt)
|
|
104
104
|
.map((c) => ({ ...c, ...locate(c, current.get(c.documentId)) })),
|
|
105
105
|
endedAt: state.endedAt,
|
|
106
|
+
endRequestedAt: state.endRequestedAt,
|
|
106
107
|
changes: sourceChanges(state),
|
|
107
108
|
hasPreviousSource: Boolean(state.previousDocuments),
|
|
108
109
|
activity: state.activity || {},
|
|
@@ -110,7 +111,7 @@ export async function openReview(options, dependencies = {}) {
|
|
|
110
111
|
pendingCommentIds: pendingComments(state).map((comment) => comment.id),
|
|
111
112
|
batches: state.batches.map(({ entries, ...batch }) => ({
|
|
112
113
|
...batch,
|
|
113
|
-
count: entries.length,
|
|
114
|
+
count: entries.length + (batch.message ? 1 : 0),
|
|
114
115
|
commentIds: entries.map((entry) => entry.id),
|
|
115
116
|
})),
|
|
116
117
|
snapshots: Object.fromEntries(state.comments.map((c) => [c.hash, state.snapshots[c.hash]])),
|
|
@@ -128,6 +129,153 @@ export async function openReview(options, dependencies = {}) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
}
|
|
132
|
+
const now = () => new Date().toISOString();
|
|
133
|
+
const outstanding = (state) =>
|
|
134
|
+
state.batches.some((batch) => ['queued', 'editing'].includes(batchStatus(state, batch.id)));
|
|
135
|
+
const finishPendingEnd = (state) => {
|
|
136
|
+
if (state.endRequestedAt && !outstanding(state)) {
|
|
137
|
+
state.endedAt = now();
|
|
138
|
+
delete state.endRequestedAt;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const text = (value, label) => {
|
|
142
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 10000)
|
|
143
|
+
throw new Error(`${label} must contain 1–10,000 characters.`);
|
|
144
|
+
return value.trim();
|
|
145
|
+
};
|
|
146
|
+
const metadataActions = new Set(['end', 'resume', 'cancel-batch', 'edit-batch']);
|
|
147
|
+
const findComment = (state, id) => {
|
|
148
|
+
const comment = state.comments.find((c) => c.id === id);
|
|
149
|
+
if (!comment) throw new Error('Unknown comment.');
|
|
150
|
+
return comment;
|
|
151
|
+
};
|
|
152
|
+
const liveComment = (state, id) => {
|
|
153
|
+
const comment = findComment(state, id);
|
|
154
|
+
if (comment.deletedAt) throw new Error('Comment is deleted. Restore it before editing.');
|
|
155
|
+
return comment;
|
|
156
|
+
};
|
|
157
|
+
const queuedBatch = (state, id) => {
|
|
158
|
+
const batch = state.batches.find((item) => item.id === id);
|
|
159
|
+
if (!batch) throw new Error('Unknown feedback batch.');
|
|
160
|
+
if (!['queued', 'editing'].includes(batchStatus(state, batch.id)))
|
|
161
|
+
throw new Error('Batch already handed off or withdrawn. Send a correction instead.');
|
|
162
|
+
return batch;
|
|
163
|
+
};
|
|
164
|
+
const actions = {
|
|
165
|
+
end(state) {
|
|
166
|
+
if (outstanding(state))
|
|
167
|
+
throw new Error('Deliver or cancel queued submissions before ending the review.');
|
|
168
|
+
state.endedAt ||= now();
|
|
169
|
+
delete state.endRequestedAt;
|
|
170
|
+
},
|
|
171
|
+
resume(state) {
|
|
172
|
+
delete state.endedAt;
|
|
173
|
+
delete state.endRequestedAt;
|
|
174
|
+
},
|
|
175
|
+
'cancel-batch'(state, input) {
|
|
176
|
+
const batch = queuedBatch(state, input.id);
|
|
177
|
+
state.queue ||= {};
|
|
178
|
+
state.queue[batch.id] = { status: 'cancelled', at: now() };
|
|
179
|
+
finishPendingEnd(state);
|
|
180
|
+
},
|
|
181
|
+
'edit-batch'(state, input) {
|
|
182
|
+
const batch = queuedBatch(state, input.id);
|
|
183
|
+
if (
|
|
184
|
+
state.batches.some(
|
|
185
|
+
(item) => item.id !== batch.id && batchStatus(state, item.id) === 'editing',
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
throw new Error('Finish or cancel the current queued edit first.');
|
|
189
|
+
state.queue ||= {};
|
|
190
|
+
state.queue[batch.id] = { status: 'editing', at: now() };
|
|
191
|
+
},
|
|
192
|
+
comment(state, input) {
|
|
193
|
+
const snapshot = current.get(input.documentId);
|
|
194
|
+
if (!snapshot || snapshot.hash !== input.hash)
|
|
195
|
+
throw new Error('Revision conflict: select the current document.');
|
|
196
|
+
state.comments.push({
|
|
197
|
+
id: randomUUID(),
|
|
198
|
+
documentId: input.documentId,
|
|
199
|
+
path: snapshot.path,
|
|
200
|
+
hash: snapshot.hash,
|
|
201
|
+
...anchor(snapshot, input),
|
|
202
|
+
body: text(input.body, 'Comment'),
|
|
203
|
+
createdAt: now(),
|
|
204
|
+
state: 'open',
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
submit(state, input) {
|
|
208
|
+
const message = input.message ? text(input.message, 'Message') : undefined;
|
|
209
|
+
const entries = pendingComments(state).map((c) => ({
|
|
210
|
+
...c,
|
|
211
|
+
line: state.snapshots[c.hash].source.slice(0, c.start).split('\n').length,
|
|
212
|
+
}));
|
|
213
|
+
const editing = state.batches.find((batch) => batchStatus(state, batch.id) === 'editing');
|
|
214
|
+
if (!entries.length && !message) {
|
|
215
|
+
if (input.end && !editing) {
|
|
216
|
+
if (outstanding(state)) state.endRequestedAt = now();
|
|
217
|
+
else actions.end(state);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(
|
|
221
|
+
editing
|
|
222
|
+
? 'Keep an open comment or cancel the queued batch.'
|
|
223
|
+
: 'Add a message or an open comment that is new or changed before submitting.',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const batch = {
|
|
227
|
+
id: randomUUID(),
|
|
228
|
+
sequence: state.batches.length + 1,
|
|
229
|
+
submittedAt: now(),
|
|
230
|
+
...(message ? { message } : {}),
|
|
231
|
+
entries,
|
|
232
|
+
};
|
|
233
|
+
state.batches.push(batch);
|
|
234
|
+
if (editing)
|
|
235
|
+
state.queue[editing.id] = {
|
|
236
|
+
status: 'superseded',
|
|
237
|
+
at: batch.submittedAt,
|
|
238
|
+
replacementId: batch.id,
|
|
239
|
+
};
|
|
240
|
+
if (input.end) state.endRequestedAt = batch.submittedAt;
|
|
241
|
+
},
|
|
242
|
+
restore(state, input) {
|
|
243
|
+
delete findComment(state, input.id).deletedAt;
|
|
244
|
+
},
|
|
245
|
+
delete(state, input) {
|
|
246
|
+
liveComment(state, input.id).deletedAt = now();
|
|
247
|
+
},
|
|
248
|
+
edit(state, input) {
|
|
249
|
+
const comment = liveComment(state, input.id);
|
|
250
|
+
comment.body = text(input.body, 'Comment');
|
|
251
|
+
comment.updatedAt = now();
|
|
252
|
+
},
|
|
253
|
+
resolve(state, input) {
|
|
254
|
+
const comment = liveComment(state, input.id);
|
|
255
|
+
comment.state = comment.state === 'open' ? 'resolved' : 'open';
|
|
256
|
+
},
|
|
257
|
+
reattach(state, input) {
|
|
258
|
+
const comment = liveComment(state, input.id);
|
|
259
|
+
const snapshot = current.get(comment.documentId);
|
|
260
|
+
const result = locate(comment, snapshot);
|
|
261
|
+
if (result.status !== 'stale')
|
|
262
|
+
throw new Error('No unique suggested anchor. Select source and add a new comment.');
|
|
263
|
+
comment.previousAnchors = [
|
|
264
|
+
...(comment.previousAnchors || []),
|
|
265
|
+
{ hash: comment.hash, start: comment.start, end: comment.end, slice: comment.slice },
|
|
266
|
+
];
|
|
267
|
+
Object.assign(
|
|
268
|
+
comment,
|
|
269
|
+
anchor(snapshot, {
|
|
270
|
+
start: result.suggestion,
|
|
271
|
+
end: result.suggestion + comment.slice.length,
|
|
272
|
+
precision: 'exact',
|
|
273
|
+
quote: comment.slice,
|
|
274
|
+
}),
|
|
275
|
+
{ hash: snapshot.hash },
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
131
279
|
try {
|
|
132
280
|
await refresh(review);
|
|
133
281
|
} catch (error) {
|
|
@@ -149,38 +297,50 @@ export async function openReview(options, dependencies = {}) {
|
|
|
149
297
|
return view();
|
|
150
298
|
}),
|
|
151
299
|
compare: (id) => compareSources(store.state, id, dependencies.diff),
|
|
152
|
-
|
|
300
|
+
// Delivers the oldest batch the agent has not replied to, so a lost response is redelivered.
|
|
301
|
+
deliver: () =>
|
|
153
302
|
serial(async () => {
|
|
154
303
|
const state = store.state;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
if (!deliverable(state, batch))
|
|
165
|
-
throw new Error('Batch is withdrawn or being edited. Use --history for an archive copy.');
|
|
166
|
-
if (batchStatus(state, batch.id) !== 'claimed') {
|
|
304
|
+
const batch = state.batches.find(
|
|
305
|
+
(item) => deliverable(state, item) && state.activity?.[item.id]?.status !== 'replied',
|
|
306
|
+
);
|
|
307
|
+
// An ended review still redelivers a claimed batch until the agent replies to it.
|
|
308
|
+
if (!batch || (state.endedAt && batchStatus(state, batch.id) !== 'claimed')) return null;
|
|
309
|
+
if (batchStatus(state, batch.id) === 'queued') {
|
|
310
|
+
const at = new Date().toISOString();
|
|
167
311
|
state.queue ||= {};
|
|
168
|
-
state.queue[batch.id] = { status: 'claimed', at
|
|
312
|
+
state.queue[batch.id] = { status: 'claimed', at };
|
|
313
|
+
state.activity ||= {};
|
|
314
|
+
state.activity[batch.id] = { receivedAt: at, status: 'received', replies: [] };
|
|
315
|
+
finishPendingEnd(state);
|
|
169
316
|
state.revision++;
|
|
170
317
|
await store.commit(state);
|
|
171
318
|
notify({ type: 'activity', revision: state.revision });
|
|
319
|
+
if (state.endedAt) notify({ type: 'lifecycle', revision: state.revision, ended: true });
|
|
172
320
|
}
|
|
173
321
|
return feedback(state, batch.id);
|
|
174
322
|
}),
|
|
175
|
-
recordActivity: (
|
|
323
|
+
recordActivity: (sequence, input) =>
|
|
176
324
|
serial(async () => {
|
|
177
325
|
const state = store.state;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
326
|
+
const batch =
|
|
327
|
+
sequence === undefined
|
|
328
|
+
? state.batches.find(
|
|
329
|
+
(item) =>
|
|
330
|
+
batchStatus(state, item.id) === 'claimed' &&
|
|
331
|
+
state.activity?.[item.id]?.status !== 'replied',
|
|
332
|
+
)
|
|
333
|
+
: state.batches.find((item) => item.sequence === sequence);
|
|
334
|
+
if (!batch)
|
|
335
|
+
throw new Error(
|
|
336
|
+
sequence === undefined
|
|
337
|
+
? 'No delivered batch is awaiting a reply. Pass --batch to reply to an earlier one.'
|
|
338
|
+
: `Unknown feedback batch: ${sequence}.`,
|
|
339
|
+
);
|
|
340
|
+
if (batchStatus(state, batch.id) !== 'claimed')
|
|
341
|
+
throw new Error('Batch has not been delivered to the agent.');
|
|
342
|
+
if (!['working', 'done'].includes(input.status))
|
|
343
|
+
throw new Error('Reply status must be working or done.');
|
|
184
344
|
const body = input.body ?? '';
|
|
185
345
|
if (
|
|
186
346
|
typeof body !== 'string' ||
|
|
@@ -188,13 +348,7 @@ export async function openReview(options, dependencies = {}) {
|
|
|
188
348
|
(input.status === 'done' && !body.trim())
|
|
189
349
|
)
|
|
190
350
|
throw new Error('A reply must contain 1–10,000 characters.');
|
|
191
|
-
state.activity
|
|
192
|
-
state.activity[batchId] ||= {
|
|
193
|
-
receivedAt: new Date().toISOString(),
|
|
194
|
-
status: 'received',
|
|
195
|
-
replies: [],
|
|
196
|
-
};
|
|
197
|
-
const activity = state.activity[batchId];
|
|
351
|
+
const activity = state.activity[batch.id];
|
|
198
352
|
const previous = JSON.stringify(activity);
|
|
199
353
|
if (input.status === 'working' && activity.status !== 'replied') {
|
|
200
354
|
activity.startedAt ||= new Date().toISOString();
|
|
@@ -208,130 +362,31 @@ export async function openReview(options, dependencies = {}) {
|
|
|
208
362
|
});
|
|
209
363
|
activity.status = 'replied';
|
|
210
364
|
}
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
365
|
+
if (previous !== JSON.stringify(activity)) {
|
|
366
|
+
state.revision++;
|
|
367
|
+
await store.commit(state);
|
|
368
|
+
notify({ type: 'activity', revision: state.revision });
|
|
369
|
+
}
|
|
370
|
+
return { batch: batch.sequence, activity: activity.status };
|
|
216
371
|
}),
|
|
217
372
|
mutate: (input) =>
|
|
218
373
|
serial(async () => {
|
|
374
|
+
const handler = actions[input.action];
|
|
375
|
+
if (!handler) throw new Error('Unknown review action.');
|
|
219
376
|
if (store.state.endedAt && !['resume', 'end'].includes(input.action))
|
|
220
377
|
throw new Error('Review ended. Resume explicitly to change feedback.');
|
|
221
|
-
if (
|
|
378
|
+
if (metadataActions.has(input.action)) {
|
|
222
379
|
if (input.revision !== store.state.revision)
|
|
223
380
|
throw new Error('Revision conflict. Refresh review state and retry.');
|
|
224
381
|
} else await checkRevision(input);
|
|
225
382
|
if (input.action === 'resume' && store.state.endedAt) await refresh();
|
|
226
383
|
const state = store.state;
|
|
227
|
-
|
|
228
|
-
if (input.action === 'end') {
|
|
229
|
-
if (
|
|
230
|
-
state.batches.some((batch) =>
|
|
231
|
-
['queued', 'editing'].includes(batchStatus(state, batch.id)),
|
|
232
|
-
)
|
|
233
|
-
)
|
|
234
|
-
throw new Error('Deliver or cancel queued submissions before ending the review.');
|
|
235
|
-
state.endedAt ||= new Date().toISOString();
|
|
236
|
-
} else delete state.endedAt;
|
|
237
|
-
} else if (['cancel-batch', 'edit-batch'].includes(input.action)) {
|
|
238
|
-
const batch = state.batches.find((item) => item.id === input.id);
|
|
239
|
-
if (!batch) throw new Error('Unknown feedback batch.');
|
|
240
|
-
const status = batchStatus(state, batch.id);
|
|
241
|
-
if (!['queued', 'editing'].includes(status))
|
|
242
|
-
throw new Error('Batch already handed off or withdrawn. Send a correction instead.');
|
|
243
|
-
if (
|
|
244
|
-
input.action === 'edit-batch' &&
|
|
245
|
-
state.batches.some(
|
|
246
|
-
(item) => item.id !== batch.id && batchStatus(state, item.id) === 'editing',
|
|
247
|
-
)
|
|
248
|
-
)
|
|
249
|
-
throw new Error('Finish or cancel the current queued edit first.');
|
|
250
|
-
state.queue ||= {};
|
|
251
|
-
state.queue[batch.id] = {
|
|
252
|
-
status: input.action === 'edit-batch' ? 'editing' : 'cancelled',
|
|
253
|
-
at: new Date().toISOString(),
|
|
254
|
-
};
|
|
255
|
-
} else if (input.action === 'comment') {
|
|
256
|
-
const snapshot = current.get(input.documentId);
|
|
257
|
-
if (!snapshot || snapshot.hash !== input.hash)
|
|
258
|
-
throw new Error('Revision conflict: select the current document.');
|
|
259
|
-
if (typeof input.body !== 'string' || !input.body.trim() || input.body.length > 10000)
|
|
260
|
-
throw new Error('Comment must contain 1–10,000 characters.');
|
|
261
|
-
state.comments.push({
|
|
262
|
-
id: randomUUID(),
|
|
263
|
-
documentId: input.documentId,
|
|
264
|
-
path: snapshot.path,
|
|
265
|
-
hash: snapshot.hash,
|
|
266
|
-
...anchor(snapshot, input),
|
|
267
|
-
body: input.body.trim(),
|
|
268
|
-
createdAt: new Date().toISOString(),
|
|
269
|
-
state: 'open',
|
|
270
|
-
});
|
|
271
|
-
} else if (input.action === 'submit') {
|
|
272
|
-
const entries = pendingComments(state).map((c) => ({
|
|
273
|
-
...c,
|
|
274
|
-
line: state.snapshots[c.hash].source.slice(0, c.start).split('\n').length,
|
|
275
|
-
}));
|
|
276
|
-
const editing = state.batches.find((batch) => batchStatus(state, batch.id) === 'editing');
|
|
277
|
-
if (!entries.length)
|
|
278
|
-
throw new Error(
|
|
279
|
-
editing
|
|
280
|
-
? 'Keep an open comment or cancel the queued batch.'
|
|
281
|
-
: 'Add an open comment that is new or changed before submitting.',
|
|
282
|
-
);
|
|
283
|
-
const batch = {
|
|
284
|
-
id: randomUUID(),
|
|
285
|
-
sequence: state.batches.length + 1,
|
|
286
|
-
submittedAt: new Date().toISOString(),
|
|
287
|
-
entries,
|
|
288
|
-
};
|
|
289
|
-
state.batches.push(batch);
|
|
290
|
-
if (editing)
|
|
291
|
-
state.queue[editing.id] = {
|
|
292
|
-
status: 'superseded',
|
|
293
|
-
at: batch.submittedAt,
|
|
294
|
-
replacementId: batch.id,
|
|
295
|
-
};
|
|
296
|
-
} else if (['resolve', 'reattach', 'edit', 'delete', 'restore'].includes(input.action)) {
|
|
297
|
-
const comment = state.comments.find((c) => c.id === input.id);
|
|
298
|
-
if (!comment) throw new Error('Unknown comment.');
|
|
299
|
-
if (input.action === 'restore') delete comment.deletedAt;
|
|
300
|
-
else if (comment.deletedAt)
|
|
301
|
-
throw new Error('Comment is deleted. Restore it before editing.');
|
|
302
|
-
else if (input.action === 'delete') comment.deletedAt = new Date().toISOString();
|
|
303
|
-
else if (input.action === 'edit') {
|
|
304
|
-
if (typeof input.body !== 'string' || !input.body.trim() || input.body.length > 10000)
|
|
305
|
-
throw new Error('Comment must contain 1–10,000 characters.');
|
|
306
|
-
comment.body = input.body.trim();
|
|
307
|
-
comment.updatedAt = new Date().toISOString();
|
|
308
|
-
} else if (input.action === 'resolve')
|
|
309
|
-
comment.state = comment.state === 'open' ? 'resolved' : 'open';
|
|
310
|
-
else {
|
|
311
|
-
const snapshot = current.get(comment.documentId);
|
|
312
|
-
const result = locate(comment, snapshot);
|
|
313
|
-
if (result.status !== 'stale')
|
|
314
|
-
throw new Error('No unique suggested anchor. Select source and add a new comment.');
|
|
315
|
-
comment.previousAnchors = [
|
|
316
|
-
...(comment.previousAnchors || []),
|
|
317
|
-
{ hash: comment.hash, start: comment.start, end: comment.end, slice: comment.slice },
|
|
318
|
-
];
|
|
319
|
-
Object.assign(
|
|
320
|
-
comment,
|
|
321
|
-
anchor(snapshot, {
|
|
322
|
-
start: result.suggestion,
|
|
323
|
-
end: result.suggestion + comment.slice.length,
|
|
324
|
-
precision: 'exact',
|
|
325
|
-
quote: comment.slice,
|
|
326
|
-
}),
|
|
327
|
-
{ hash: snapshot.hash },
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
} else throw new Error('Unknown review action.');
|
|
384
|
+
handler(state, input);
|
|
331
385
|
state.revision++;
|
|
332
386
|
await store.commit(state);
|
|
387
|
+
const lifecycle = ['end', 'resume'].includes(input.action) || Boolean(state.endedAt);
|
|
333
388
|
notify({
|
|
334
|
-
type:
|
|
389
|
+
type: lifecycle ? 'lifecycle' : 'activity',
|
|
335
390
|
revision: state.revision,
|
|
336
391
|
ended: Boolean(state.endedAt),
|
|
337
392
|
});
|