@shiyuphan/follow-me 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 +128 -56
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +836 -82
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +103 -24
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +379 -0
- package/dist/src/install.js +131 -0
- 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 +710 -160
- package/dist/src/session.js +311 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +5 -2
- package/dist/ui/app.js +1703 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +62 -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 +975 -75
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -16
|
@@ -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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { link, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { isSha, neededBlobs, parseDiff } from "./diff.js";
|
|
6
5
|
export class GitError extends Error {
|
|
@@ -24,6 +23,13 @@ function git(repo, args, opts = {}) {
|
|
|
24
23
|
else
|
|
25
24
|
resolve(stdout);
|
|
26
25
|
});
|
|
26
|
+
// git can exit before reading its input (a bad ref, a missing repo). On Linux the
|
|
27
|
+
// pending write then fails with EPIPE; the callback above already carries git's
|
|
28
|
+
// real error, so that write failure is noise.
|
|
29
|
+
child.stdin?.on("error", (error) => {
|
|
30
|
+
if (error.code !== "EPIPE")
|
|
31
|
+
reject(error);
|
|
32
|
+
});
|
|
27
33
|
child.stdin?.end(opts.input ?? "");
|
|
28
34
|
});
|
|
29
35
|
}
|
|
@@ -48,6 +54,10 @@ async function resolveOptional(repo, ref) {
|
|
|
48
54
|
throw error;
|
|
49
55
|
}
|
|
50
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
|
+
}
|
|
51
61
|
export async function treeOf(repo, rev) {
|
|
52
62
|
return toSha(await gitText(repo, ["rev-parse", "--verify", "--end-of-options", `${rev}^{tree}`]), `${rev}^{tree}`);
|
|
53
63
|
}
|
|
@@ -64,6 +74,22 @@ export async function currentBranch(repo) {
|
|
|
64
74
|
throw error;
|
|
65
75
|
}
|
|
66
76
|
}
|
|
77
|
+
/** Each branch checked out in some worktree of the repo, mapped to that worktree's path. */
|
|
78
|
+
export async function checkedOutBranches(repo) {
|
|
79
|
+
const out = (await git(repo, ["worktree", "list", "--porcelain", "-z"])).toString("utf8");
|
|
80
|
+
const found = new Map();
|
|
81
|
+
let path = "";
|
|
82
|
+
for (const field of out.split("\0")) {
|
|
83
|
+
if (field.startsWith("worktree "))
|
|
84
|
+
path = field.slice("worktree ".length);
|
|
85
|
+
if (field.startsWith("branch refs/heads/"))
|
|
86
|
+
found.set(field.slice("branch refs/heads/".length), path);
|
|
87
|
+
}
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
export async function branchExists(repo, name) {
|
|
91
|
+
return (await branchHead(repo, name)) !== null;
|
|
92
|
+
}
|
|
67
93
|
export async function defaultBaseRef(repo) {
|
|
68
94
|
for (const name of ["main", "master"]) {
|
|
69
95
|
if ((await resolveOptional(repo, `refs/heads/${name}`)) !== null)
|
|
@@ -108,6 +134,17 @@ export async function loadDiff(repo, baseRef, tipRef) {
|
|
|
108
134
|
const blobs = await catBlobs(repo, neededBlobs(rawZ, patchText));
|
|
109
135
|
return parseDiff({ base, tip, baseTree, tipTree, rawZ, patch: patchText, blobs });
|
|
110
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
|
+
}
|
|
111
148
|
export class BranchRefusedError extends Error {
|
|
112
149
|
name = "BranchRefusedError";
|
|
113
150
|
}
|
|
@@ -132,7 +169,7 @@ function identityEnv(who, back) {
|
|
|
132
169
|
GIT_COMMITTER_DATE: date(who.committer),
|
|
133
170
|
};
|
|
134
171
|
}
|
|
135
|
-
async function indexLine(repo, change, zero) {
|
|
172
|
+
async function indexLine(repo, change, zero, env) {
|
|
136
173
|
const entry = async () => {
|
|
137
174
|
if (change.op === "delete")
|
|
138
175
|
return `0 ${zero}`;
|
|
@@ -140,6 +177,7 @@ async function indexLine(repo, change, zero) {
|
|
|
140
177
|
return `${change.entry.mode} ${change.entry.blob}`;
|
|
141
178
|
const blob = await gitText(repo, ["hash-object", "-w", "--stdin"], {
|
|
142
179
|
input: Buffer.from(change.bytes, "latin1"),
|
|
180
|
+
env,
|
|
143
181
|
});
|
|
144
182
|
return `${change.mode} ${toSha(blob, change.path)}`;
|
|
145
183
|
};
|
|
@@ -149,9 +187,36 @@ async function indexLine(repo, change, zero) {
|
|
|
149
187
|
Buffer.from([0]),
|
|
150
188
|
]);
|
|
151
189
|
}
|
|
190
|
+
/** Prefix of the quarantine directories writeBranch creates inside the object store. */
|
|
191
|
+
export const QUARANTINE_PREFIX = "follow-me-incoming-";
|
|
192
|
+
export async function objectsDir(repo) {
|
|
193
|
+
return gitText(repo, ["rev-parse", "--path-format=absolute", "--git-path", "objects"]);
|
|
194
|
+
}
|
|
195
|
+
/** Hard-links the quarantined loose objects into the store; an object already there stays. */
|
|
196
|
+
async function adoptObjects(quarantine, objects) {
|
|
197
|
+
for (const fanout of await readdir(quarantine)) {
|
|
198
|
+
if (!/^[0-9a-f]{2}$/.test(fanout))
|
|
199
|
+
continue;
|
|
200
|
+
await mkdir(join(objects, fanout), { recursive: true });
|
|
201
|
+
for (const name of await readdir(join(quarantine, fanout))) {
|
|
202
|
+
if (name.startsWith("tmp_"))
|
|
203
|
+
continue;
|
|
204
|
+
try {
|
|
205
|
+
await link(join(quarantine, fanout, name), join(objects, fanout, name));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
152
214
|
/**
|
|
153
215
|
* Writes the plan as commits on top of the merge-base through a temporary index, so the
|
|
154
216
|
* user's index and worktree are never touched, then moves refs/heads/<name> by compare-and-swap.
|
|
217
|
+
* New objects go to a quarantine inside the object store and are adopted only right before the
|
|
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.
|
|
155
220
|
*/
|
|
156
221
|
export async function writeBranch(repo, diff, plan, opts) {
|
|
157
222
|
const ref = `refs/heads/${opts.name}`;
|
|
@@ -161,15 +226,27 @@ export async function writeBranch(repo, diff, plan, opts) {
|
|
|
161
226
|
catch {
|
|
162
227
|
throw new BranchRefusedError(`"${opts.name}" is not a valid branch name`);
|
|
163
228
|
}
|
|
229
|
+
if (opts.name === opts.source) {
|
|
230
|
+
throw new BranchRefusedError(`${opts.name} is the branch being split; the split always lands on a separate branch, so pick another --name (--force does not change this)`);
|
|
231
|
+
}
|
|
232
|
+
const worktree = (await checkedOutBranches(repo)).get(opts.name);
|
|
233
|
+
if (worktree !== undefined) {
|
|
234
|
+
throw new BranchRefusedError(`branch ${opts.name} is checked out in ${worktree}; moving it would leave that worktree out of sync, so pick another --name (--force does not change this)`);
|
|
235
|
+
}
|
|
164
236
|
const who = await identities(repo, diff.tip);
|
|
165
237
|
const zero = "0".repeat(diff.base.length);
|
|
166
238
|
const n = plan.commits.length;
|
|
167
|
-
const
|
|
239
|
+
const objects = await objectsDir(repo);
|
|
240
|
+
const dir = await mkdtemp(join(objects, `${QUARANTINE_PREFIX}${process.pid}-`));
|
|
168
241
|
const commits = [];
|
|
169
242
|
let head = diff.base;
|
|
170
243
|
let tree = diff.baseTree;
|
|
171
244
|
try {
|
|
172
|
-
const env = {
|
|
245
|
+
const env = {
|
|
246
|
+
GIT_INDEX_FILE: join(dir, "index"),
|
|
247
|
+
GIT_OBJECT_DIRECTORY: dir,
|
|
248
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: objects,
|
|
249
|
+
};
|
|
173
250
|
await git(repo, ["read-tree", diff.baseTree], { env });
|
|
174
251
|
for (const commit of plan.commits) {
|
|
175
252
|
// Deletes go first so a file can give way to a directory of the same name.
|
|
@@ -177,34 +254,36 @@ export async function writeBranch(repo, diff, plan, opts) {
|
|
|
177
254
|
...commit.changes.filter((c) => c.op === "delete"),
|
|
178
255
|
...commit.changes.filter((c) => c.op !== "delete"),
|
|
179
256
|
];
|
|
180
|
-
const lines = await Promise.all(ordered.map((c) => indexLine(repo, c, zero)));
|
|
257
|
+
const lines = await Promise.all(ordered.map((c) => indexLine(repo, c, zero, env)));
|
|
181
258
|
await git(repo, ["update-index", "-z", "--index-info"], { env, input: Buffer.concat(lines) });
|
|
182
259
|
tree = toSha(await gitText(repo, ["write-tree"], { env }), "write-tree");
|
|
183
260
|
const message = commit.message.endsWith("\n") ? commit.message : `${commit.message}\n`;
|
|
184
|
-
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", "-"], {
|
|
185
262
|
input: message,
|
|
186
|
-
env: identityEnv(who, n - 1 - commit.index),
|
|
263
|
+
env: { ...env, ...identityEnv(who, n - 1 - commit.index) },
|
|
187
264
|
}), "commit-tree");
|
|
188
265
|
commits.push(head);
|
|
189
266
|
}
|
|
267
|
+
if (tree !== diff.tipTree) {
|
|
268
|
+
throw new Error(`the last commit's tree ${tree} differs from the tip tree ${diff.tipTree}`);
|
|
269
|
+
}
|
|
270
|
+
const current = await branchHead(repo, opts.name);
|
|
271
|
+
// Unsigned commits, and deterministic signatures such as ssh ed25519, give the same SHAs.
|
|
272
|
+
if (current === head)
|
|
273
|
+
return { kind: "unchanged", branch: opts.name, head, commits };
|
|
274
|
+
if (current !== null && !opts.force) {
|
|
275
|
+
if (opts.expectedOld === null) {
|
|
276
|
+
throw new BranchRefusedError(`branch ${opts.name} already exists and follow-me did not write it; pass --force to overwrite`);
|
|
277
|
+
}
|
|
278
|
+
if (current !== opts.expectedOld) {
|
|
279
|
+
throw new BranchRefusedError(`branch ${opts.name} moved since the last apply (expected ${opts.expectedOld}, found ${current}); pass --force to overwrite`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
await adoptObjects(dir, objects);
|
|
283
|
+
await git(repo, ["update-ref", "-m", "follow-me apply", ref, head, current ?? zero]);
|
|
284
|
+
return { kind: current === null ? "created" : "updated", branch: opts.name, head, commits };
|
|
190
285
|
}
|
|
191
286
|
finally {
|
|
192
287
|
await rm(dir, { recursive: true, force: true });
|
|
193
288
|
}
|
|
194
|
-
if (tree !== diff.tipTree) {
|
|
195
|
-
throw new Error(`the last commit's tree ${tree} differs from the tip tree ${diff.tipTree}`);
|
|
196
|
-
}
|
|
197
|
-
const current = await resolveOptional(repo, ref);
|
|
198
|
-
if (current === head)
|
|
199
|
-
return { kind: "unchanged", branch: opts.name, head, commits };
|
|
200
|
-
if (current !== null && !opts.force) {
|
|
201
|
-
if (opts.expectedOld === null) {
|
|
202
|
-
throw new BranchRefusedError(`branch ${opts.name} already exists and follow-me did not write it; pass --force to overwrite`);
|
|
203
|
-
}
|
|
204
|
-
if (current !== opts.expectedOld) {
|
|
205
|
-
throw new BranchRefusedError(`branch ${opts.name} moved since the last apply (expected ${opts.expectedOld}, found ${current}); pass --force to overwrite`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
await git(repo, ["update-ref", "-m", "follow-me apply", ref, head, current ?? zero]);
|
|
209
|
-
return { kind: current === null ? "created" : "updated", branch: opts.name, head, commits };
|
|
210
289
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export const SKILL_NAME = "follow-me";
|
|
3
|
+
export const HARNESSES = [
|
|
4
|
+
{
|
|
5
|
+
id: "claude",
|
|
6
|
+
name: "Claude Code",
|
|
7
|
+
projectDir: ".claude/skills",
|
|
8
|
+
userDir: ".claude/skills",
|
|
9
|
+
format: "skill",
|
|
10
|
+
probes: [".claude"],
|
|
11
|
+
docs: "https://code.claude.com/docs/en/skills",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "opencode",
|
|
15
|
+
name: "OpenCode",
|
|
16
|
+
projectDir: ".opencode/skills",
|
|
17
|
+
userDir: ".config/opencode/skills",
|
|
18
|
+
format: "skill",
|
|
19
|
+
probes: [".config/opencode", ".opencode"],
|
|
20
|
+
docs: "https://opencode.ai/docs/skills/",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "codex",
|
|
24
|
+
name: "Codex CLI",
|
|
25
|
+
projectDir: ".agents/skills",
|
|
26
|
+
userDir: ".agents/skills",
|
|
27
|
+
format: "skill",
|
|
28
|
+
probes: [".codex"],
|
|
29
|
+
docs: "https://developers.openai.com/codex/skills",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "cursor",
|
|
33
|
+
name: "Cursor",
|
|
34
|
+
projectDir: ".cursor/skills",
|
|
35
|
+
userDir: ".cursor/skills",
|
|
36
|
+
format: "skill",
|
|
37
|
+
probes: [".cursor"],
|
|
38
|
+
docs: "https://cursor.com/docs/context/skills",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "gemini",
|
|
42
|
+
name: "Gemini CLI",
|
|
43
|
+
projectDir: ".gemini/skills",
|
|
44
|
+
userDir: ".gemini/skills",
|
|
45
|
+
format: "skill",
|
|
46
|
+
probes: [".gemini"],
|
|
47
|
+
docs: "https://geminicli.com/docs/cli/skills/",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "copilot",
|
|
51
|
+
name: "GitHub Copilot CLI",
|
|
52
|
+
projectDir: ".github/skills",
|
|
53
|
+
userDir: ".copilot/skills",
|
|
54
|
+
format: "skill",
|
|
55
|
+
probes: [".copilot"],
|
|
56
|
+
docs: "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "agents",
|
|
60
|
+
name: "Any Agent Skills client",
|
|
61
|
+
projectDir: ".agents/skills",
|
|
62
|
+
userDir: ".agents/skills",
|
|
63
|
+
format: "skill",
|
|
64
|
+
probes: [],
|
|
65
|
+
docs: "https://agentskills.io/client-implementation/adding-skills-support",
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
export function findHarness(id) {
|
|
69
|
+
return HARNESSES.find((h) => h.id === id);
|
|
70
|
+
}
|
|
71
|
+
export function plannedInstall(harness, scope, home, repoRoot) {
|
|
72
|
+
const root = scope === "user" ? join(home, harness.userDir) : join(repoRoot, harness.projectDir);
|
|
73
|
+
return [{ path: join(root, SKILL_NAME, "SKILL.md"), source: "SKILL.md" }];
|
|
74
|
+
}
|