@shiyuphan/follow-me 0.1.1 → 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 +127 -66
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +632 -81
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +20 -3
- package/dist/src/host.js +92 -6
- package/dist/src/install.js +1 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +461 -137
- package/dist/src/session.js +104 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/app.js +1676 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +61 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +955 -90
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -6
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { isRepoPath, isUnitId } from "./diff.js";
|
|
2
|
+
import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, } from "./plan.js";
|
|
3
|
+
/**
|
|
4
|
+
* The wire formats of review state, parsed once at the boundary: comments, replies, concern
|
|
5
|
+
* decisions, the review file, its autosaved draft and the events a hosted session logs. Every
|
|
6
|
+
* parser returns the domain value or the reason it is not one, so a caller decides whether a bad
|
|
7
|
+
* item rejects a request or is skipped when reading a file.
|
|
8
|
+
*/
|
|
9
|
+
const REPLY_ID = /^r_[a-z2-7]{6}$/;
|
|
10
|
+
export function isReplyId(value) {
|
|
11
|
+
return typeof value === "string" && REPLY_ID.test(value);
|
|
12
|
+
}
|
|
13
|
+
function isIndex(v) {
|
|
14
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
15
|
+
}
|
|
16
|
+
function parseAnchor(v) {
|
|
17
|
+
if (!isFields(v))
|
|
18
|
+
return "anchor is not an object";
|
|
19
|
+
if (v.kind === "plan")
|
|
20
|
+
return { kind: "plan" };
|
|
21
|
+
if (!isIndex(v.commit))
|
|
22
|
+
return "anchor.commit is not a commit index";
|
|
23
|
+
const commit = v.commit;
|
|
24
|
+
if (v.kind === "message" || v.kind === "position")
|
|
25
|
+
return { kind: v.kind, commit };
|
|
26
|
+
if (v.kind !== "hunk")
|
|
27
|
+
return "anchor.kind is unknown";
|
|
28
|
+
if (typeof v.unit !== "string" || !isUnitId(v.unit))
|
|
29
|
+
return "anchor.unit is not a unit id";
|
|
30
|
+
if (v.changes === null)
|
|
31
|
+
return { kind: "hunk", unit: v.unit, commit, changes: null };
|
|
32
|
+
if (!isArray(v.changes) || !v.changes.every(isIndex)) {
|
|
33
|
+
return "anchor.changes is not null or a list of change indices";
|
|
34
|
+
}
|
|
35
|
+
return { kind: "hunk", unit: v.unit, commit, changes: v.changes.filter(isIndex) };
|
|
36
|
+
}
|
|
37
|
+
/** A stored reply. Replies a client posts go through parseNewReply, which mints id and time. */
|
|
38
|
+
export function parseReply(v) {
|
|
39
|
+
if (!isFields(v))
|
|
40
|
+
return "not an object";
|
|
41
|
+
if (!isReplyId(v.id))
|
|
42
|
+
return "id is not a reply id";
|
|
43
|
+
if (v.author !== "agent" && v.author !== "reviewer")
|
|
44
|
+
return 'author is not "agent" or "reviewer"';
|
|
45
|
+
if (typeof v.body !== "string" || typeof v.createdAt !== "string") {
|
|
46
|
+
return "body and createdAt must be strings";
|
|
47
|
+
}
|
|
48
|
+
if (v.diagram !== undefined && typeof v.diagram !== "string")
|
|
49
|
+
return "diagram is not a string";
|
|
50
|
+
return {
|
|
51
|
+
id: v.id,
|
|
52
|
+
author: v.author,
|
|
53
|
+
body: v.body,
|
|
54
|
+
...(v.diagram === undefined ? {} : { diagram: v.diagram }),
|
|
55
|
+
createdAt: v.createdAt,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** The body of POST .../replies: who speaks, what, and an optional Mermaid diagram. */
|
|
59
|
+
export function parseNewReply(v) {
|
|
60
|
+
if (!isFields(v))
|
|
61
|
+
return "the reply is not an object";
|
|
62
|
+
if (v.author !== "agent" && v.author !== "reviewer")
|
|
63
|
+
return 'author is not "agent" or "reviewer"';
|
|
64
|
+
if (typeof v.body !== "string" || v.body.trim() === "")
|
|
65
|
+
return "body is not a non-empty string";
|
|
66
|
+
if (v.diagram === undefined)
|
|
67
|
+
return { author: v.author, body: v.body };
|
|
68
|
+
if (typeof v.diagram !== "string")
|
|
69
|
+
return "diagram is not a string";
|
|
70
|
+
const problem = diagramProblem(v.diagram);
|
|
71
|
+
if (problem !== null)
|
|
72
|
+
return `diagram: ${problem}`;
|
|
73
|
+
return { author: v.author, body: v.body, diagram: v.diagram };
|
|
74
|
+
}
|
|
75
|
+
function isString(v) {
|
|
76
|
+
return typeof v === "string";
|
|
77
|
+
}
|
|
78
|
+
/** Boundary parse of a comment, from a file or a request. Returns the reason when it is not one. */
|
|
79
|
+
export function parseComment(v) {
|
|
80
|
+
if (!isFields(v))
|
|
81
|
+
return "not an object";
|
|
82
|
+
if (typeof v.id !== "string" || !isCommentId(v.id))
|
|
83
|
+
return "id is not a comment id";
|
|
84
|
+
const anchor = parseAnchor(v.anchor);
|
|
85
|
+
if (typeof anchor === "string")
|
|
86
|
+
return anchor;
|
|
87
|
+
const ctx = v.context;
|
|
88
|
+
if (!isFields(ctx) || typeof ctx.commitMessage !== "string") {
|
|
89
|
+
return "context.commitMessage is not a string";
|
|
90
|
+
}
|
|
91
|
+
const path = ctx.path;
|
|
92
|
+
if (path !== null && (typeof path !== "string" || !isRepoPath(path))) {
|
|
93
|
+
return "context.path is not null or a path";
|
|
94
|
+
}
|
|
95
|
+
const lines = ctx.lines;
|
|
96
|
+
if (lines !== undefined && !(isArray(lines) && lines.every(isString))) {
|
|
97
|
+
return "context.lines is not a list of strings";
|
|
98
|
+
}
|
|
99
|
+
if (typeof v.body !== "string" || typeof v.createdAt !== "string") {
|
|
100
|
+
return "body and createdAt must be strings";
|
|
101
|
+
}
|
|
102
|
+
if (v.kind !== undefined && v.kind !== "question")
|
|
103
|
+
return 'kind is not "question"';
|
|
104
|
+
const follows = v.follows;
|
|
105
|
+
if (follows !== undefined && !(typeof follows === "string" && isCommentId(follows))) {
|
|
106
|
+
return "follows is not a comment id";
|
|
107
|
+
}
|
|
108
|
+
const severity = v.severity;
|
|
109
|
+
if (severity !== undefined && !isSeverity(severity))
|
|
110
|
+
return "severity is unknown";
|
|
111
|
+
const replies = parseList(v.replies, parseReply, "replies");
|
|
112
|
+
if (typeof replies === "string")
|
|
113
|
+
return replies;
|
|
114
|
+
return {
|
|
115
|
+
id: v.id,
|
|
116
|
+
anchor,
|
|
117
|
+
context: {
|
|
118
|
+
commitMessage: ctx.commitMessage,
|
|
119
|
+
path,
|
|
120
|
+
...(lines === undefined ? {} : { lines: lines.filter(isString) }),
|
|
121
|
+
},
|
|
122
|
+
body: v.body,
|
|
123
|
+
createdAt: v.createdAt,
|
|
124
|
+
...(v.kind === "question" ? { kind: "question" } : {}),
|
|
125
|
+
...(follows === undefined ? {} : { follows }),
|
|
126
|
+
...(severity === undefined ? {} : { severity }),
|
|
127
|
+
...(replies.length === 0 ? {} : { replies }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function withDecision(concern, v) {
|
|
131
|
+
if (v.decision !== "kept" && v.decision !== "dismissed") {
|
|
132
|
+
return 'decision is not "kept" or "dismissed"';
|
|
133
|
+
}
|
|
134
|
+
if (v.severity !== undefined && !isSeverity(v.severity))
|
|
135
|
+
return "severity is unknown";
|
|
136
|
+
if (v.note !== undefined && typeof v.note !== "string")
|
|
137
|
+
return "note is not a string";
|
|
138
|
+
return {
|
|
139
|
+
concern,
|
|
140
|
+
decision: v.decision,
|
|
141
|
+
...(v.severity === undefined ? {} : { severity: v.severity }),
|
|
142
|
+
...(v.note === undefined || v.note.trim() === "" ? {} : { note: v.note }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** A stored decision. */
|
|
146
|
+
export function parseDecision(v) {
|
|
147
|
+
if (!isFields(v))
|
|
148
|
+
return "not an object";
|
|
149
|
+
if (!isConcernId(v.concern))
|
|
150
|
+
return "concern is not a concern id";
|
|
151
|
+
return withDecision(v.concern, v);
|
|
152
|
+
}
|
|
153
|
+
/** The body of POST .../concerns/<id>; the id comes from the URL. */
|
|
154
|
+
export function parseNewDecision(concern, v) {
|
|
155
|
+
if (!isFields(v))
|
|
156
|
+
return "the decision is not an object";
|
|
157
|
+
return withDecision(concern, v);
|
|
158
|
+
}
|
|
159
|
+
/** Absent reads as none. The first item that does not parse names its index and reason. */
|
|
160
|
+
function parseList(raw, parse, what) {
|
|
161
|
+
if (raw === undefined)
|
|
162
|
+
return [];
|
|
163
|
+
if (!isArray(raw))
|
|
164
|
+
return `${what} is not an array`;
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const [i, item] of raw.entries()) {
|
|
167
|
+
const parsed = parse(item);
|
|
168
|
+
if (typeof parsed === "string")
|
|
169
|
+
return `${what}[${i}]: ${parsed}`;
|
|
170
|
+
out.push(parsed);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
function asCommentId(item) {
|
|
175
|
+
return typeof item === "string" && isCommentId(item) ? item : null;
|
|
176
|
+
}
|
|
177
|
+
function parseCommentId(item) {
|
|
178
|
+
return asCommentId(item) ?? "not a comment id";
|
|
179
|
+
}
|
|
180
|
+
/** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
|
|
181
|
+
export function parseReview(v) {
|
|
182
|
+
if (!isFields(v)) {
|
|
183
|
+
return "not an object";
|
|
184
|
+
}
|
|
185
|
+
if (v.schema !== "follow-me/review@1")
|
|
186
|
+
return 'schema is not "follow-me/review@1"';
|
|
187
|
+
const mode = v.mode ?? "split";
|
|
188
|
+
if (mode !== "split" && mode !== "walk")
|
|
189
|
+
return 'mode is not "split" or "walk"';
|
|
190
|
+
if (!isPlanVersion(v.planVersion))
|
|
191
|
+
return "planVersion is not a positive integer";
|
|
192
|
+
if (typeof v.planHash !== "string" || typeof v.endedAt !== "string") {
|
|
193
|
+
return "planHash and endedAt must be strings";
|
|
194
|
+
}
|
|
195
|
+
if (!isArray(v.comments))
|
|
196
|
+
return "comments is not an array";
|
|
197
|
+
const comments = parseList(v.comments, parseComment, "comments");
|
|
198
|
+
if (typeof comments === "string")
|
|
199
|
+
return comments;
|
|
200
|
+
const concerns = parseList(v.concerns, parseDecision, "concerns");
|
|
201
|
+
if (typeof concerns === "string")
|
|
202
|
+
return concerns;
|
|
203
|
+
const resolved = parseList(v.resolved, parseCommentId, "resolved");
|
|
204
|
+
if (typeof resolved === "string")
|
|
205
|
+
return resolved;
|
|
206
|
+
return {
|
|
207
|
+
schema: v.schema,
|
|
208
|
+
mode,
|
|
209
|
+
planVersion: v.planVersion,
|
|
210
|
+
planHash: v.planHash,
|
|
211
|
+
endedAt: v.endedAt,
|
|
212
|
+
comments,
|
|
213
|
+
...(concerns.length === 0 ? {} : { concerns }),
|
|
214
|
+
...(resolved.length === 0 ? {} : { resolved }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function lenient(items, parse) {
|
|
218
|
+
if (!isArray(items))
|
|
219
|
+
return [];
|
|
220
|
+
return items.flatMap((item) => {
|
|
221
|
+
const parsed = parse(item);
|
|
222
|
+
return typeof parsed === "string" ? [] : [parsed];
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Boundary parse of reviews/vN.draft.json. Unlike a finished review it is read leniently: an
|
|
227
|
+
* item that does not parse is dropped, so one bad entry never loses the rest of an autosave.
|
|
228
|
+
*/
|
|
229
|
+
export function parseDraft(v) {
|
|
230
|
+
if (!isFields(v) || !isArray(v.comments)) {
|
|
231
|
+
return "not a review draft";
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
comments: lenient(v.comments, parseComment),
|
|
235
|
+
concerns: lenient(v.concerns, parseDecision),
|
|
236
|
+
resolved: lenient(v.resolved, parseCommentId),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export function parseEvent(v) {
|
|
240
|
+
if (!isFields(v))
|
|
241
|
+
return "not an object";
|
|
242
|
+
const { seq } = v;
|
|
243
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
|
|
244
|
+
return "seq is not a number";
|
|
245
|
+
switch (v.type) {
|
|
246
|
+
case "comment":
|
|
247
|
+
case "comment-updated": {
|
|
248
|
+
const comment = parseComment(v.comment);
|
|
249
|
+
return typeof comment === "string" ? comment : { seq, type: v.type, comment };
|
|
250
|
+
}
|
|
251
|
+
case "comment-deleted": {
|
|
252
|
+
const id = asCommentId(v.id);
|
|
253
|
+
return id === null ? "id is not a comment id" : { seq, type: v.type, id };
|
|
254
|
+
}
|
|
255
|
+
case "reply": {
|
|
256
|
+
const id = asCommentId(v.commentId);
|
|
257
|
+
const reply = parseReply(v.reply);
|
|
258
|
+
if (id === null)
|
|
259
|
+
return "commentId is not a comment id";
|
|
260
|
+
return typeof reply === "string" ? reply : { seq, type: v.type, commentId: id, reply };
|
|
261
|
+
}
|
|
262
|
+
case "concern": {
|
|
263
|
+
if (!isConcernId(v.concernId))
|
|
264
|
+
return "concernId is not a concern id";
|
|
265
|
+
const decision = withDecision(v.concernId, v);
|
|
266
|
+
if (typeof decision === "string")
|
|
267
|
+
return decision;
|
|
268
|
+
const { concern: concernId, ...rest } = decision;
|
|
269
|
+
return { seq, type: v.type, concernId, ...rest };
|
|
270
|
+
}
|
|
271
|
+
case "resolved": {
|
|
272
|
+
const id = asCommentId(v.commentId);
|
|
273
|
+
if (id === null || typeof v.resolved !== "boolean") {
|
|
274
|
+
return "malformed resolved event";
|
|
275
|
+
}
|
|
276
|
+
return { seq, type: v.type, commentId: id, resolved: v.resolved };
|
|
277
|
+
}
|
|
278
|
+
case "version": {
|
|
279
|
+
if (!isPlanVersion(v.version) || typeof v.hash !== "string")
|
|
280
|
+
return "malformed version event";
|
|
281
|
+
return { seq, type: v.type, version: v.version, hash: v.hash };
|
|
282
|
+
}
|
|
283
|
+
case "ended":
|
|
284
|
+
return typeof v.review === "string"
|
|
285
|
+
? { seq, type: v.type, review: v.review }
|
|
286
|
+
: "review is not a path";
|
|
287
|
+
default:
|
|
288
|
+
return "unknown event type";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** The body of GET .../events: the latest seq and the events after the asked-for one. */
|
|
292
|
+
export function parseEventBatch(v) {
|
|
293
|
+
if (!isFields(v))
|
|
294
|
+
return "not an object";
|
|
295
|
+
if (typeof v.seq !== "number" || !Number.isInteger(v.seq) || v.seq < 0) {
|
|
296
|
+
return "seq is not a number";
|
|
297
|
+
}
|
|
298
|
+
const events = parseList(v.events, parseEvent, "events");
|
|
299
|
+
return typeof events === "string" ? events : { seq: v.seq, events };
|
|
300
|
+
}
|
package/dist/src/git.js
CHANGED
|
@@ -54,6 +54,10 @@ async function resolveOptional(repo, ref) {
|
|
|
54
54
|
throw error;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
/** The ref refs/heads/<name> points at, or null when there is no such branch. */
|
|
58
|
+
export function branchHead(repo, name) {
|
|
59
|
+
return resolveOptional(repo, `refs/heads/${name}`);
|
|
60
|
+
}
|
|
57
61
|
export async function treeOf(repo, rev) {
|
|
58
62
|
return toSha(await gitText(repo, ["rev-parse", "--verify", "--end-of-options", `${rev}^{tree}`]), `${rev}^{tree}`);
|
|
59
63
|
}
|
|
@@ -84,7 +88,7 @@ export async function checkedOutBranches(repo) {
|
|
|
84
88
|
return found;
|
|
85
89
|
}
|
|
86
90
|
export async function branchExists(repo, name) {
|
|
87
|
-
return (await
|
|
91
|
+
return (await branchHead(repo, name)) !== null;
|
|
88
92
|
}
|
|
89
93
|
export async function defaultBaseRef(repo) {
|
|
90
94
|
for (const name of ["main", "master"]) {
|
|
@@ -130,6 +134,17 @@ export async function loadDiff(repo, baseRef, tipRef) {
|
|
|
130
134
|
const blobs = await catBlobs(repo, neededBlobs(rawZ, patchText));
|
|
131
135
|
return parseDiff({ base, tip, baseTree, tipTree, rawZ, patch: patchText, blobs });
|
|
132
136
|
}
|
|
137
|
+
export async function signing(repo, mode) {
|
|
138
|
+
const configured = await gitText(repo, [
|
|
139
|
+
"config",
|
|
140
|
+
"--type=bool",
|
|
141
|
+
"--default=false",
|
|
142
|
+
"commit.gpgsign",
|
|
143
|
+
]);
|
|
144
|
+
const format = await gitText(repo, ["config", "--default=openpgp", "gpg.format"]);
|
|
145
|
+
const on = mode === "config" ? configured === "true" : mode === "on";
|
|
146
|
+
return { on, format };
|
|
147
|
+
}
|
|
133
148
|
export class BranchRefusedError extends Error {
|
|
134
149
|
name = "BranchRefusedError";
|
|
135
150
|
}
|
|
@@ -201,6 +216,7 @@ async function adoptObjects(quarantine, objects) {
|
|
|
201
216
|
* user's index and worktree are never touched, then moves refs/heads/<name> by compare-and-swap.
|
|
202
217
|
* New objects go to a quarantine inside the object store and are adopted only right before the
|
|
203
218
|
* ref moves, so a refused or failed write leaves neither refs nor objects behind.
|
|
219
|
+
* With opts.sign, commit-tree signs with gpg.format and user.signingkey.
|
|
204
220
|
*/
|
|
205
221
|
export async function writeBranch(repo, diff, plan, opts) {
|
|
206
222
|
const ref = `refs/heads/${opts.name}`;
|
|
@@ -242,7 +258,7 @@ export async function writeBranch(repo, diff, plan, opts) {
|
|
|
242
258
|
await git(repo, ["update-index", "-z", "--index-info"], { env, input: Buffer.concat(lines) });
|
|
243
259
|
tree = toSha(await gitText(repo, ["write-tree"], { env }), "write-tree");
|
|
244
260
|
const message = commit.message.endsWith("\n") ? commit.message : `${commit.message}\n`;
|
|
245
|
-
head = toSha(await gitText(repo, ["commit-tree", "--no-gpg-sign", tree, "-p", head, "-F", "-"], {
|
|
261
|
+
head = toSha(await gitText(repo, ["commit-tree", opts.sign ? "-S" : "--no-gpg-sign", tree, "-p", head, "-F", "-"], {
|
|
246
262
|
input: message,
|
|
247
263
|
env: { ...env, ...identityEnv(who, n - 1 - commit.index) },
|
|
248
264
|
}), "commit-tree");
|
|
@@ -251,7 +267,8 @@ export async function writeBranch(repo, diff, plan, opts) {
|
|
|
251
267
|
if (tree !== diff.tipTree) {
|
|
252
268
|
throw new Error(`the last commit's tree ${tree} differs from the tip tree ${diff.tipTree}`);
|
|
253
269
|
}
|
|
254
|
-
const current = await
|
|
270
|
+
const current = await branchHead(repo, opts.name);
|
|
271
|
+
// Unsigned commits, and deterministic signatures such as ssh ed25519, give the same SHAs.
|
|
255
272
|
if (current === head)
|
|
256
273
|
return { kind: "unchanged", branch: opts.name, head, commits };
|
|
257
274
|
if (current !== null && !opts.force) {
|
package/dist/src/host.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { closeSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { parseEvent, parseEventBatch } from "./comments.js";
|
|
6
7
|
import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
|
|
7
8
|
import { HEALTH_APP, isSessionId, loadDraftComments, PortInUseError, RegistrationError, startServer, } from "./server.js";
|
|
8
9
|
import { BranchMovedError, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
|
|
@@ -103,7 +104,7 @@ function removeRecord(dir, pid) {
|
|
|
103
104
|
if (readServerRecord(dir)?.pid === pid)
|
|
104
105
|
rmSync(join(dir, RECORD), { force: true });
|
|
105
106
|
}
|
|
106
|
-
async function loadHosted(registration) {
|
|
107
|
+
async function loadHosted(registration, held) {
|
|
107
108
|
const { dir, mode, version, hash, lockToken } = registration;
|
|
108
109
|
try {
|
|
109
110
|
const session = await openSessionAt(dir);
|
|
@@ -112,8 +113,11 @@ async function loadHosted(registration) {
|
|
|
112
113
|
if (stored === undefined || stored.hash !== hash) {
|
|
113
114
|
throw new RegistrationError(`${branch} has no plan v${version} with hash ${hash}`);
|
|
114
115
|
}
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
// Each mode has its own slot, so a walk can follow the author's review of the same plan.
|
|
117
|
+
const reviewed = mode === "walk" ? session.walkReviews() : session.reviews();
|
|
118
|
+
if (reviewed.some((r) => r.version === version)) {
|
|
119
|
+
const what = mode === "walk" ? "code review" : "review";
|
|
120
|
+
throw new RegistrationError(`plan v${version} of ${branch} already has a ${what}`);
|
|
117
121
|
}
|
|
118
122
|
const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
|
|
119
123
|
lastReview: session.lastReview(),
|
|
@@ -121,14 +125,25 @@ async function loadHosted(registration) {
|
|
|
121
125
|
if (!verdict.ok) {
|
|
122
126
|
throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
|
|
123
127
|
}
|
|
124
|
-
const reviewsDir =
|
|
128
|
+
const reviewsDir = session.reviewsDir(mode);
|
|
125
129
|
const comments = await loadDraftComments(reviewsDir, version);
|
|
130
|
+
const prior = session.priorReview(mode, version);
|
|
131
|
+
const previous = prior === null || prior.comments.length === 0
|
|
132
|
+
? null
|
|
133
|
+
: {
|
|
134
|
+
version: prior.planVersion,
|
|
135
|
+
comments: prior.comments,
|
|
136
|
+
responses: stored.file.responses,
|
|
137
|
+
};
|
|
126
138
|
const command = mode === "walk" ? "walk" : "review";
|
|
127
|
-
|
|
139
|
+
// A version swap runs inside the server that already owns this session's lock.
|
|
140
|
+
const release = held ??
|
|
141
|
+
(lockToken === null ? session.lock(command) : session.adoptLock(lockToken, command)).release;
|
|
128
142
|
return {
|
|
129
143
|
repo: session.root,
|
|
130
144
|
reviewsDir,
|
|
131
|
-
release
|
|
145
|
+
release,
|
|
146
|
+
previous,
|
|
132
147
|
state: {
|
|
133
148
|
mode,
|
|
134
149
|
anchor: session.anchor,
|
|
@@ -251,6 +266,64 @@ export async function registerWith(port, registration) {
|
|
|
251
266
|
throw new RegistrationError("the server answered with a malformed session");
|
|
252
267
|
return summary;
|
|
253
268
|
}
|
|
269
|
+
/** Moves the hosted session to a newer frozen plan; its id and URL stay. */
|
|
270
|
+
export async function swapVersion(port, summary, version, hash) {
|
|
271
|
+
const swapped = parseSummary(await call(port, "POST", `${summary.url}api/version`, { version, hash }));
|
|
272
|
+
if (swapped === null)
|
|
273
|
+
throw new RegistrationError("the server answered with a malformed session");
|
|
274
|
+
return swapped;
|
|
275
|
+
}
|
|
276
|
+
/** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
|
|
277
|
+
export async function pollEvents(port, summary, since, timeoutMs) {
|
|
278
|
+
const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}`;
|
|
279
|
+
const batch = parseEventBatch(await call(port, "GET", path));
|
|
280
|
+
if (typeof batch === "string")
|
|
281
|
+
throw new RegistrationError(`the server sent bad events: ${batch}`);
|
|
282
|
+
return batch;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Follows the session's event stream from `since`, calling `onEvent` for each event. Resolves when
|
|
286
|
+
* the server closes the stream, which it does after the `ended` event.
|
|
287
|
+
*/
|
|
288
|
+
export async function followEvents(port, summary, since, onEvent) {
|
|
289
|
+
const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}`);
|
|
290
|
+
if (!res.ok || res.body === null) {
|
|
291
|
+
throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
|
|
292
|
+
}
|
|
293
|
+
const decoder = new TextDecoder();
|
|
294
|
+
const gap = "\n\n";
|
|
295
|
+
let buffer = "";
|
|
296
|
+
for await (const chunk of res.body) {
|
|
297
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
298
|
+
for (let end = buffer.indexOf(gap); end !== -1; end = buffer.indexOf(gap)) {
|
|
299
|
+
const frame = buffer.slice(0, end);
|
|
300
|
+
buffer = buffer.slice(end + 2);
|
|
301
|
+
const data = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
302
|
+
if (data === undefined)
|
|
303
|
+
continue;
|
|
304
|
+
const event = parseEvent(JSON.parse(data.slice(6)));
|
|
305
|
+
if (typeof event === "string")
|
|
306
|
+
throw new RegistrationError(`bad event on the stream: ${event}`);
|
|
307
|
+
onEvent(event);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Posts the agent's reply under a comment and returns the new reply's id. */
|
|
312
|
+
export async function postReply(port, summary, commentId, reply) {
|
|
313
|
+
const path = `${summary.url}api/comments/${encodeURIComponent(commentId)}/replies`;
|
|
314
|
+
const body = await call(port, "POST", path, reply);
|
|
315
|
+
const id = isFields(body) ? body["id"] : undefined;
|
|
316
|
+
if (typeof id !== "string")
|
|
317
|
+
throw new RegistrationError("the server answered without a reply id");
|
|
318
|
+
return id;
|
|
319
|
+
}
|
|
320
|
+
/** The consolidated report of the hosted session as it stands. */
|
|
321
|
+
export async function fetchReport(port, summary) {
|
|
322
|
+
const res = await fetch(`${sessionUrl(port, summary)}api/report`);
|
|
323
|
+
if (!res.ok)
|
|
324
|
+
throw new RegistrationError(`GET api/report failed with ${res.status}`);
|
|
325
|
+
return res.text();
|
|
326
|
+
}
|
|
254
327
|
export async function listSessions(port) {
|
|
255
328
|
const raw = await call(port, "GET", "/api/sessions");
|
|
256
329
|
if (!Array.isArray(raw))
|
|
@@ -263,6 +336,19 @@ export async function listSessions(port) {
|
|
|
263
336
|
export function sessionUrl(port, summary) {
|
|
264
337
|
return `http://127.0.0.1:${port}${summary.url}`;
|
|
265
338
|
}
|
|
339
|
+
/** Whether the server on this port still serves the session's state; a gone server serves none. */
|
|
340
|
+
export async function stillHosted(port, summary) {
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch(`${sessionUrl(port, summary)}api/state`, {
|
|
343
|
+
signal: AbortSignal.timeout(2_000),
|
|
344
|
+
});
|
|
345
|
+
await res.body?.cancel();
|
|
346
|
+
return res.ok;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
266
352
|
/**
|
|
267
353
|
* Stops the recorded server and waits for it to exit. Only a pid that answers as our server on
|
|
268
354
|
* the recorded port is signalled, so a reused pid is never killed. A stale record is removed.
|
package/dist/src/install.js
CHANGED
|
@@ -51,7 +51,7 @@ function chooseHarnesses(options, home) {
|
|
|
51
51
|
* and the generic one) plan the same file once.
|
|
52
52
|
*/
|
|
53
53
|
export function planSkillFiles(options, home, repoRoot) {
|
|
54
|
-
const scope = options.
|
|
54
|
+
const scope = options.project ? "project" : "user";
|
|
55
55
|
const byPath = new Map();
|
|
56
56
|
for (const harness of chooseHarnesses(options, home)) {
|
|
57
57
|
for (const file of plannedInstall(harness, scope, home, repoRoot)) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { PLAN_SCHEMA } from "./plan.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plan that needs no planner: one commit per touched file in diff order, holding every unit
|
|
4
|
+
* of that file. It tells no story, but it runs the whole pipeline on a real branch. Returns the
|
|
5
|
+
* draft as the wire format, for validatePlan to judge like any other draft.
|
|
6
|
+
*/
|
|
7
|
+
export function naiveDraft(diff) {
|
|
8
|
+
const byPath = new Map();
|
|
9
|
+
for (const unit of diff.units.values()) {
|
|
10
|
+
byPath.set(unit.path, [...(byPath.get(unit.path) ?? []), unit]);
|
|
11
|
+
}
|
|
12
|
+
const commits = [...byPath].map(([path, units]) => ({
|
|
13
|
+
message: `wip: ${path}${suffix(units)}`,
|
|
14
|
+
units: units.map((u) => u.id),
|
|
15
|
+
}));
|
|
16
|
+
return { schema: PLAN_SCHEMA, base: diff.base, tip: diff.tip, commits, responses: [] };
|
|
17
|
+
}
|
|
18
|
+
/** Names what a file with no text hunk is, so `wip: logo.bin (binary)` says why it has no lines. */
|
|
19
|
+
function suffix(units) {
|
|
20
|
+
if (units.some((u) => u.kind === "hunk"))
|
|
21
|
+
return "";
|
|
22
|
+
const first = units[0];
|
|
23
|
+
if (first === undefined)
|
|
24
|
+
return "";
|
|
25
|
+
return first.kind === "mode" ? " (mode)" : first.kind === "file" ? ` (${first.reason})` : "";
|
|
26
|
+
}
|