@sigrun/sigrun 0.6.1 → 0.7.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/CHANGELOG.md +92 -0
- package/README.md +105 -18
- package/dist/blocks/build-stamp.json +2 -2
- package/dist/blocks/schema.js +5 -0
- package/dist/cli.js +85 -22
- package/dist/curation/host.js +46 -21
- package/dist/curation/migrate.js +96 -0
- package/dist/curation/overlays.js +52 -16
- package/dist/curation/paths.js +22 -0
- package/dist/curation/runs.js +34 -17
- package/dist/curation/skill-tool.js +34 -9
- package/dist/curation/store.js +382 -254
- package/dist/curation/targets.js +21 -0
- package/dist/curation/wrap.js +1 -6
- package/dist/extension.d.ts +12 -3
- package/dist/harness/boot.js +143 -6
- package/dist/harness/event-bus.js +19 -0
- package/dist/harness/loader.js +22 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +71 -16
- package/dist/harness/workspace.js +34 -1
- package/dist/history/git.js +707 -0
- package/dist/history/trailers.js +145 -0
- package/dist/history/workspace-history.js +47 -0
- package/dist/jobs/index.js +5 -3
- package/dist/jobs/reconcile.js +2 -3
- package/dist/jobs/state-dir.js +9 -0
- package/dist/jobs/transfer.js +6 -6
- package/dist/sandbox/grants.js +11 -0
- package/dist/sandbox/launch.js +19 -9
- package/dist/sandbox/policy.js +6 -1
- package/dist/serve.js +3 -0
- package/dist/server/auth/proxy-only.js +5 -4
- package/dist/server/core-views/index.js +26 -0
- package/dist/server/core-views/jobs.js +661 -0
- package/dist/server/core-views/skills-tree.js +628 -0
- package/dist/server/core-views/skills.js +1090 -0
- package/dist/server/index.js +88 -0
- package/dist/server/jobs-access.js +62 -0
- package/dist/server/notifications.js +24 -12
- package/dist/server/routes/extensions.js +9 -1
- package/dist/server/routes/jobs.js +19 -52
- package/dist/server/routes/meta.js +9 -0
- package/dist/server/routes/onboarding.js +13 -1
- package/dist/server/surfaces/service.js +38 -0
- package/dist/skills/index.js +1 -1
- package/dist/skills/roots.js +301 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
- package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
- package/node_modules/@sigrun/shared/dist/auth.js +26 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
- package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-BI5k-Oci.js +55 -0
- package/webui/dist/assets/index-EGluyRXk.css +1 -0
- package/webui/dist/favicon.svg +7 -5
- package/webui/dist/index.html +3 -3
- package/webui/dist/assets/index-BR0di0OG.css +0 -1
- package/webui/dist/assets/index-BzrA1T4g.js +0 -55
package/dist/curation/store.js
CHANGED
|
@@ -1,26 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillStore (tabs+skills plan, Task 9): the per-user learned-skill
|
|
3
|
+
* store on git. Every mutation is one commit in the user's curation
|
|
4
|
+
* repo (`userRepoSpec(userArea)`) with the Sigrun-* trailers; the feed
|
|
5
|
+
* is the log (id/before/after are commit SHAs); undo is a path-restore
|
|
6
|
+
* commit (never git revert).
|
|
7
|
+
*
|
|
8
|
+
* The store's public methods and FeedEntry shape are unchanged; the
|
|
9
|
+
* versioning feed.jsonl and withUserLock machinery are gone (git is the
|
|
10
|
+
* lock and the feed).
|
|
11
|
+
*/
|
|
12
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, join } from "node:path";
|
|
1
14
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { join, dirname } from "node:path";
|
|
4
|
-
import { curationDir, learnedSkillsDir } from "./paths.js";
|
|
15
|
+
import { curationDir, learnedSkillsDir, userRepoSpec } from "./paths.js";
|
|
5
16
|
import { validSkillName } from "./names.js";
|
|
6
17
|
import { scanContent } from "./scan.js";
|
|
7
|
-
const VERSIONS = 10;
|
|
8
|
-
const FEED_ENTRIES = 500;
|
|
9
18
|
const SUPPORT_ROOTS = ["references", "templates", "scripts", "assets"];
|
|
10
|
-
const locks = new Map();
|
|
11
|
-
async function withUserLock(user, fn) {
|
|
12
|
-
const previous = locks.get(user) ?? Promise.resolve();
|
|
13
|
-
const current = previous.then(fn, fn);
|
|
14
|
-
const tail = current.then(() => undefined, () => undefined);
|
|
15
|
-
locks.set(user, tail);
|
|
16
|
-
try {
|
|
17
|
-
return await current;
|
|
18
|
-
}
|
|
19
|
-
finally {
|
|
20
|
-
if (locks.get(user) === tail)
|
|
21
|
-
locks.delete(user);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
19
|
function requireUser(options) {
|
|
25
20
|
if (!options.userExists())
|
|
26
21
|
throw new Error("user removed");
|
|
@@ -71,21 +66,6 @@ async function exists(path) {
|
|
|
71
66
|
throw error;
|
|
72
67
|
}
|
|
73
68
|
}
|
|
74
|
-
async function copyTree(source, target) {
|
|
75
|
-
const info = await lstat(source);
|
|
76
|
-
if (info.isSymbolicLink())
|
|
77
|
-
throw new Error(`symlinks are not allowed: ${source}`);
|
|
78
|
-
if (info.isDirectory()) {
|
|
79
|
-
await mkdir(target, { recursive: true });
|
|
80
|
-
for (const entry of await readdir(source))
|
|
81
|
-
await copyTree(join(source, entry), join(target, entry));
|
|
82
|
-
}
|
|
83
|
-
else if (info.isFile()) {
|
|
84
|
-
await cp(source, target);
|
|
85
|
-
}
|
|
86
|
-
else
|
|
87
|
-
throw new Error(`unsupported file type: ${source}`);
|
|
88
|
-
}
|
|
89
69
|
async function assertNoSymlinks(root, file) {
|
|
90
70
|
const parts = file.split("/");
|
|
91
71
|
let path = root;
|
|
@@ -121,41 +101,57 @@ async function writeFileAtomic(path, text) {
|
|
|
121
101
|
const temp = `${path}.${randomUUID()}.tmp`;
|
|
122
102
|
await writeFile(temp, text);
|
|
123
103
|
await rename(temp, path);
|
|
104
|
+
// Sweep crash residue: a process killed between the write and the
|
|
105
|
+
// rename leaves a `<target>.<uuid>.tmp` orphan beside the target. The
|
|
106
|
+
// next successful write of that target sweeps every sibling orphan —
|
|
107
|
+
// a live writer's own temp can never match (its uuid is fresh, and
|
|
108
|
+
// the sweep runs only after OUR rename landed).
|
|
109
|
+
sweepTempSiblings(path).catch(() => undefined);
|
|
124
110
|
}
|
|
125
|
-
|
|
111
|
+
/** Remove orphaned `<path>.<uuid>.tmp` siblings (crash residue). */
|
|
112
|
+
async function sweepTempSiblings(path) {
|
|
113
|
+
const dir = dirname(path);
|
|
114
|
+
const base = `${basename(path)}.`;
|
|
115
|
+
let entries;
|
|
126
116
|
try {
|
|
127
|
-
|
|
128
|
-
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line)).reverse();
|
|
129
|
-
}
|
|
130
|
-
catch (error) {
|
|
131
|
-
if (error.code === "ENOENT")
|
|
132
|
-
return [];
|
|
133
|
-
throw error;
|
|
117
|
+
entries = await readdir(dir);
|
|
134
118
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const root = join(dir, "versions", name);
|
|
138
|
-
let entries = [];
|
|
139
|
-
try {
|
|
140
|
-
entries = await readdir(root);
|
|
119
|
+
catch {
|
|
120
|
+
return; // the directory is the caller's problem, not the sweep's
|
|
141
121
|
}
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.startsWith(base) || !entry.endsWith(".tmp"))
|
|
124
|
+
continue;
|
|
125
|
+
await rm(join(dir, entry), { force: true }).catch(() => undefined);
|
|
145
126
|
}
|
|
146
|
-
const highest = entries.reduce((max, entry) => Math.max(max, /^v(\d+)$/.exec(entry)?.[1] === undefined ? 0 : Number(/^v(\d+)$/.exec(entry)[1])), 0);
|
|
147
|
-
return `v${highest + 1}`;
|
|
148
127
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
throw error;
|
|
128
|
+
/** The Sigrun actor shape the store's FeedEntry carries. */
|
|
129
|
+
function actorOf(meta, user) {
|
|
130
|
+
switch (meta.actor) {
|
|
131
|
+
case "user": return { kind: "user", user };
|
|
132
|
+
case "curator": return { kind: "curator" };
|
|
133
|
+
case "agent": return { kind: "agent" };
|
|
134
|
+
case "outside": return { kind: "outside" };
|
|
157
135
|
}
|
|
158
136
|
}
|
|
137
|
+
/** Map one history commit to a feed entry; untrailered commits get action "change", skill "". */
|
|
138
|
+
function entryOf(commit, user) {
|
|
139
|
+
const actor = commit.actor.kind === "user"
|
|
140
|
+
? (commit.actor.user === user ? "user" : "outside")
|
|
141
|
+
: commit.actor.kind;
|
|
142
|
+
const action = (commit.action ?? "change");
|
|
143
|
+
const skill = commit.skill ?? "";
|
|
144
|
+
return {
|
|
145
|
+
id: commit.sha,
|
|
146
|
+
at: commit.at,
|
|
147
|
+
actor,
|
|
148
|
+
action,
|
|
149
|
+
skill,
|
|
150
|
+
...(commit.reason === undefined ? {} : { reason: commit.reason }),
|
|
151
|
+
...(commit.reviewId === undefined ? {} : { reviewId: commit.reviewId }),
|
|
152
|
+
...(commit.conversationId === undefined ? {} : { conversationId: commit.conversationId }),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
159
155
|
export class SkillStore {
|
|
160
156
|
options;
|
|
161
157
|
root;
|
|
@@ -167,11 +163,14 @@ export class SkillStore {
|
|
|
167
163
|
this.skills = learnedSkillsDir(options.userArea);
|
|
168
164
|
this.archive = join(this.root, "archive");
|
|
169
165
|
}
|
|
166
|
+
get history() {
|
|
167
|
+
return this.options.history;
|
|
168
|
+
}
|
|
170
169
|
async list() {
|
|
171
170
|
requireUser(this.options);
|
|
172
171
|
const state = await readState(this.root);
|
|
173
172
|
const names = new Set();
|
|
174
|
-
for (const root of [this.skills,
|
|
173
|
+
for (const root of [this.skills, this.archive]) {
|
|
175
174
|
try {
|
|
176
175
|
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
177
176
|
if (entry.isDirectory() && validSkillName(entry.name))
|
|
@@ -186,7 +185,7 @@ export class SkillStore {
|
|
|
186
185
|
const result = [];
|
|
187
186
|
for (const name of [...names].sort()) {
|
|
188
187
|
const archived = state.archived.includes(name);
|
|
189
|
-
const path = join(archived ?
|
|
188
|
+
const path = join(archived ? this.archive : this.skills, name, "SKILL.md");
|
|
190
189
|
try {
|
|
191
190
|
const text = await readFile(path, "utf8");
|
|
192
191
|
const meta = frontmatter(text);
|
|
@@ -208,237 +207,366 @@ export class SkillStore {
|
|
|
208
207
|
}
|
|
209
208
|
async apply(name, change, meta) {
|
|
210
209
|
requireUser(this.options);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
210
|
+
await this.options.ready?.();
|
|
211
|
+
skillName(name);
|
|
212
|
+
// The mutation's validations run BEFORE the history write: a thrown
|
|
213
|
+
// validation must not become a commit (the fail-soft rule is for git
|
|
214
|
+
// failures, not store rejections).
|
|
215
|
+
const state = await readState(this.root);
|
|
216
|
+
const archived = state.archived.includes(name);
|
|
217
|
+
const root = join(archived ? this.archive : this.skills, name);
|
|
218
|
+
if (change.kind === "create") {
|
|
219
|
+
if (await exists(root))
|
|
220
|
+
throw new Error(`skill already exists: ${name}`);
|
|
221
|
+
const taken = await this.options.nameTakenElsewhere(name);
|
|
222
|
+
if (taken !== undefined)
|
|
223
|
+
throw new Error(`skill name ${name} is already used by ${taken}; use overlay instead`);
|
|
224
|
+
checkContent(change.content);
|
|
225
|
+
if (frontmatter(change.content).name !== name)
|
|
226
|
+
throw new Error(`frontmatter name must match the directory ${name}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
231
229
|
if (!(await exists(root)))
|
|
232
230
|
throw new Error(`unknown skill: ${name}`);
|
|
233
|
-
if (change.kind === "archive")
|
|
231
|
+
if (change.kind === "archive" && archived)
|
|
232
|
+
throw new Error(`skill is archived: ${name}`);
|
|
233
|
+
if (change.kind === "restore" && !archived)
|
|
234
|
+
throw new Error(`skill is not archived: ${name}`);
|
|
235
|
+
if (change.kind !== "archive" && change.kind !== "restore") {
|
|
234
236
|
if (archived)
|
|
235
237
|
throw new Error(`skill is archived: ${name}`);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (change.kind === "restore") {
|
|
239
|
-
if (!archived)
|
|
240
|
-
throw new Error(`skill is not archived: ${name}`);
|
|
241
|
-
const version = change.version === undefined ? undefined : join(this.root, "versions", name, change.version);
|
|
242
|
-
if (version !== undefined && !(await exists(version)))
|
|
243
|
-
throw new Error(`unknown version: ${change.version}`);
|
|
244
|
-
return this.commit(name, "restore", meta, state, root, join(this.skills, name), undefined, version);
|
|
245
|
-
}
|
|
246
|
-
if (archived)
|
|
247
|
-
throw new Error(`skill is archived: ${name}`);
|
|
248
|
-
if (change.kind !== "patch" && change.kind !== "write_file" && change.kind !== "remove_file")
|
|
249
|
-
throw new Error("unsupported skill change");
|
|
250
|
-
return this.commit(name, change.kind, meta, state, root, root, undefined, undefined, async () => {
|
|
251
|
-
if (change.kind === "patch") {
|
|
252
|
-
const file = change.file === undefined ? "SKILL.md" : supportFile(change.file);
|
|
253
|
-
await assertNoSymlinks(root, file);
|
|
254
|
-
const oldText = await readFile(join(root, file), "utf8");
|
|
255
|
-
const first = oldText.indexOf(change.old);
|
|
256
|
-
if (first < 0 || oldText.indexOf(change.old, first + 1) >= 0 || change.old === "")
|
|
257
|
-
throw new Error("patch old text must be a unique span");
|
|
258
|
-
const text = oldText.slice(0, first) + change.new + oldText.slice(first + change.old.length);
|
|
259
|
-
checkContent(text);
|
|
260
|
-
if (file === "SKILL.md" && frontmatter(text).name !== name)
|
|
261
|
-
throw new Error(`frontmatter name must match the directory ${name}`);
|
|
262
|
-
await writeFile(join(root, file), text);
|
|
263
|
-
}
|
|
264
|
-
else if (change.kind === "write_file") {
|
|
265
|
-
const file = supportFile(change.file);
|
|
266
|
-
await assertNoSymlinks(root, file);
|
|
267
|
-
checkContent(change.content);
|
|
268
|
-
await mkdir(join(root, file, ".."), { recursive: true });
|
|
269
|
-
await writeFile(join(root, file), change.content);
|
|
238
|
+
if (change.kind !== "patch" && change.kind !== "write_file" && change.kind !== "remove_file") {
|
|
239
|
+
throw new Error("unsupported skill change");
|
|
270
240
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const before = await this.headSha();
|
|
244
|
+
// The repo's work tree must exist before the git dir can init
|
|
245
|
+
// inside it (mkdir the root, never the work tree itself).
|
|
246
|
+
await mkdir(this.root, { recursive: true });
|
|
247
|
+
const sha = await this.history.write({
|
|
248
|
+
actor: actorOf(meta, this.options.user),
|
|
249
|
+
summary: `${change.kind} ${name}`,
|
|
250
|
+
action: change.kind,
|
|
251
|
+
skill: name,
|
|
252
|
+
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
253
|
+
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
254
|
+
...(meta.reason === undefined && !(change.kind === "archive" && change.absorbedInto !== undefined)
|
|
255
|
+
? {}
|
|
256
|
+
: { reason: change.kind === "archive" && change.absorbedInto !== undefined ? change.absorbedInto : meta.reason }),
|
|
257
|
+
}, async () => {
|
|
258
|
+
requireUser(this.options);
|
|
259
|
+
await this.mutate(name, change);
|
|
260
|
+
}, { kind: "outside" });
|
|
261
|
+
return this.entry(sha, change.kind, name, meta, before, change.kind === "archive" ? change.absorbedInto : undefined);
|
|
278
262
|
}
|
|
279
|
-
async pin(name, pinned) {
|
|
263
|
+
async pin(name, pinned, reviewId) {
|
|
280
264
|
requireUser(this.options);
|
|
281
|
-
await
|
|
265
|
+
await this.options.ready?.();
|
|
266
|
+
skillName(name);
|
|
267
|
+
if (!(await this.list()).some((skill) => skill.name === name))
|
|
268
|
+
throw new Error(`unknown skill: ${name}`);
|
|
269
|
+
const before = await this.headSha();
|
|
270
|
+
const action = pinned ? "pin" : "unpin";
|
|
271
|
+
const sha = await this.history.write({
|
|
272
|
+
actor: { kind: "user", user: this.options.user },
|
|
273
|
+
summary: `${action} ${name}`,
|
|
274
|
+
action,
|
|
275
|
+
skill: name,
|
|
276
|
+
...(reviewId === undefined ? {} : { reviewId }),
|
|
277
|
+
}, async () => {
|
|
282
278
|
requireUser(this.options);
|
|
283
|
-
skillName(name);
|
|
284
279
|
const state = await readState(this.root);
|
|
285
|
-
if (!(await this.list()).some((skill) => skill.name === name))
|
|
286
|
-
throw new Error(`unknown skill: ${name}`);
|
|
287
280
|
state.pins[name] = pinned;
|
|
288
281
|
await writeState(this.root, state);
|
|
289
|
-
|
|
290
|
-
});
|
|
282
|
+
}, { kind: "outside" });
|
|
283
|
+
return this.entry(sha, action, name, { actor: "user", reviewId }, before);
|
|
291
284
|
}
|
|
292
285
|
async feed(limit, before) {
|
|
293
286
|
requireUser(this.options);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
287
|
+
// Over-fetch: feed skips untrailered commits (catch-ups, the
|
|
288
|
+
// import), so a page may hold fewer than limit entries.
|
|
289
|
+
const commits = await this.history.log({ limit: limit * 4, ...(before === undefined ? {} : { before }) });
|
|
290
|
+
const entries = [];
|
|
291
|
+
for (const commit of commits) {
|
|
292
|
+
if (commit.action === undefined && commit.skill === undefined)
|
|
293
|
+
continue;
|
|
294
|
+
const entry = entryOf(commit, this.options.user);
|
|
295
|
+
const parent = await this.history.parentOf(commit.sha);
|
|
296
|
+
entries.push({
|
|
297
|
+
...entry,
|
|
298
|
+
...(parent === undefined ? {} : { before: parent }),
|
|
299
|
+
after: commit.sha,
|
|
300
|
+
});
|
|
301
|
+
if (entries.length >= limit)
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
return entries;
|
|
299
305
|
}
|
|
300
306
|
async undo(entryIds) {
|
|
301
307
|
requireUser(this.options);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
308
|
+
await this.options.ready?.();
|
|
309
|
+
// The selected commits, oldest first (the restore target is the
|
|
310
|
+
// earliest one's parent).
|
|
311
|
+
const log = await this.history.log({ limit: 1000 });
|
|
312
|
+
// The selected commits, OLDEST first (the restore target is the
|
|
313
|
+
// earliest one's parent): the log is newest-first.
|
|
314
|
+
const selected = log
|
|
315
|
+
.filter((commit) => entryIds.includes(commit.sha))
|
|
316
|
+
.reverse();
|
|
317
|
+
if (selected.length === 0)
|
|
318
|
+
return [];
|
|
319
|
+
// Already undone: a commit whose Sigrun-Undoes names a selected
|
|
320
|
+
// commit's sha. (The earlier reviewId comparison was dead code:
|
|
321
|
+
// undoes carry SHAs, never reviewIds.)
|
|
322
|
+
const selectedShas = new Set(selected.map((commit) => commit.sha));
|
|
323
|
+
const already = log.some((commit) => commit.undoes !== undefined &&
|
|
324
|
+
commit.undoes.split(",").map((part) => part.trim()).some((sha) => selectedShas.has(sha)));
|
|
325
|
+
if (already)
|
|
326
|
+
return [];
|
|
327
|
+
const reviewIds = new Set(selected.map((commit) => commit.reviewId).filter((id) => id !== undefined));
|
|
328
|
+
const createOnly = [];
|
|
329
|
+
const restorable = [];
|
|
330
|
+
for (const commit of selected) {
|
|
331
|
+
// An undo-of-create ARCHIVED the skill: the redo must restore
|
|
332
|
+
// those paths too, so an undo commit whose undoes point at a
|
|
333
|
+
// create is restorable, not create-only.
|
|
334
|
+
if (commit.action === "create" && commit.skill !== undefined)
|
|
335
|
+
createOnly.push(commit);
|
|
336
|
+
else
|
|
337
|
+
restorable.push(commit);
|
|
338
|
+
}
|
|
339
|
+
// Oldest first → the earliest selected commit is FIRST.
|
|
340
|
+
const earliest = restorable[0];
|
|
341
|
+
// The restore is scoped to the SELECTED commits' skills: for each
|
|
342
|
+
// skill, both of its homes (skills/ AND curation/archive/ — a state
|
|
343
|
+
// move only ever names one side in a commit's path list), plus
|
|
344
|
+
// curation/state.json only when a selected commit touched it.
|
|
345
|
+
// Non-selected state under the roots is never reverted.
|
|
346
|
+
const selectedSkills = new Set(selected.map((commit) => commit.skill).filter((name) => name !== undefined && name !== ""));
|
|
347
|
+
const paths = [];
|
|
348
|
+
for (const name of selectedSkills) {
|
|
349
|
+
paths.push(`skills/${name}`, `curation/archive/${name}`);
|
|
350
|
+
}
|
|
351
|
+
if (selected.some((commit) => commit.paths.includes("curation/state.json"))) {
|
|
352
|
+
paths.push("curation/state.json");
|
|
353
|
+
}
|
|
354
|
+
const undoes = selected.map((commit) => commit.sha).sort().join(",");
|
|
355
|
+
const reviewId = [...reviewIds].sort().join(",");
|
|
356
|
+
const skill = earliest?.skill ?? selected[0].skill ?? "";
|
|
357
|
+
const before = await this.headSha();
|
|
358
|
+
// The restore target: the earliest selected commit's PARENT's tree,
|
|
359
|
+
// restricted to the touched paths. Computed OUTSIDE the write's lock
|
|
360
|
+
// (pure git reads), applied inside it.
|
|
361
|
+
const parent = restorable.length > 0 && earliest !== undefined
|
|
362
|
+
? await this.history.parentOf(earliest.sha)
|
|
363
|
+
: undefined;
|
|
364
|
+
const atParent = new Map();
|
|
365
|
+
if (restorable.length > 0 && parent !== undefined) {
|
|
366
|
+
for (const path of await this.lsTree(parent, paths)) {
|
|
367
|
+
atParent.set(path, await this.catFile(parent, path));
|
|
311
368
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
369
|
+
}
|
|
370
|
+
await mkdir(this.root, { recursive: true });
|
|
371
|
+
const sha = await this.history.write({
|
|
372
|
+
actor: { kind: "user", user: this.options.user },
|
|
373
|
+
summary: `undo ${skill}`.trim(),
|
|
374
|
+
action: "undo",
|
|
375
|
+
skill,
|
|
376
|
+
...(reviewId === "" ? {} : { reviewId }),
|
|
377
|
+
undoes,
|
|
378
|
+
}, async () => {
|
|
379
|
+
requireUser(this.options);
|
|
380
|
+
if (restorable.length > 0) {
|
|
381
|
+
await this.restorePaths(atParent, paths);
|
|
319
382
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// rule — `restore` brings it back from versions).
|
|
326
|
-
if (state.archived.includes(entry.skill))
|
|
383
|
+
if (createOnly.length > 0) {
|
|
384
|
+
const state = await readState(this.root);
|
|
385
|
+
for (const commit of createOnly) {
|
|
386
|
+
const name = commit.skill;
|
|
387
|
+
if (state.archived.includes(name))
|
|
327
388
|
continue;
|
|
328
|
-
const from = join(this.skills,
|
|
389
|
+
const from = join(this.skills, name);
|
|
329
390
|
if (!(await exists(from)))
|
|
330
391
|
continue;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
392
|
+
await mkdir(this.archive, { recursive: true });
|
|
393
|
+
await renameForce(from, join(this.archive, name));
|
|
394
|
+
state.archived = [...new Set([...state.archived, name])];
|
|
334
395
|
}
|
|
335
|
-
|
|
336
|
-
const previous = join(this.root, "versions", entry.skill, entry.before);
|
|
337
|
-
if (!(await exists(previous)) || !(await exists(current)))
|
|
338
|
-
continue;
|
|
339
|
-
const location = await versionLocation(previous);
|
|
340
|
-
if (location === undefined)
|
|
341
|
-
throw new Error(`unknown version location: ${entry.before}`);
|
|
342
|
-
const destination = join(location === "archive" ? this.archive : this.skills, entry.skill);
|
|
343
|
-
const next = await this.commit(entry.skill, "undo", { actor: "user" }, state, current, destination, undefined, previous);
|
|
344
|
-
result.push(next);
|
|
396
|
+
await writeState(this.root, state);
|
|
345
397
|
}
|
|
346
|
-
|
|
347
|
-
|
|
398
|
+
}, { kind: "outside" });
|
|
399
|
+
if (sha === undefined)
|
|
400
|
+
return [];
|
|
401
|
+
// (main's b992485 report-and-continue semantics carry over
|
|
402
|
+
// structurally: the git-based undo can never silently
|
|
403
|
+
// zero-revert "successfully" — no change means no commit means
|
|
404
|
+
// [] here, which the caller reports as "nothing to undo" — and
|
|
405
|
+
// non-revertible entries never abort the batch: the restore and
|
|
406
|
+
// archive arms above proceed for everything they CAN act on.)
|
|
407
|
+
// One FeedEntry per skill touched, all carrying the undo commit's sha.
|
|
408
|
+
const skills = [...new Set(selected.map((commit) => commit.skill ?? ""))];
|
|
409
|
+
return skills.map((one) => this.entry(sha, "undo", one, { actor: "user", ...(reviewId === "" ? {} : { reviewId }) }, before));
|
|
348
410
|
}
|
|
349
|
-
/**
|
|
350
|
-
|
|
351
|
-
* owns its files but records every change in the SAME per-user feed
|
|
352
|
-
* (action `"overlay"`), so retention, the per-user lock and the P8
|
|
353
|
-
* view hold for overlay changes too. Entry ids/at values are the
|
|
354
|
-
* caller's.
|
|
355
|
-
*/
|
|
356
|
-
async appendFeedEntry(entry) {
|
|
411
|
+
/** Restore a learned skill's files AND its state.json archived/pin entries to `sha`, in one commit. Restoring a version of an archived skill un-archives it. */
|
|
412
|
+
async restoreVersion(name, sha, meta) {
|
|
357
413
|
requireUser(this.options);
|
|
358
|
-
await
|
|
414
|
+
await this.options.ready?.();
|
|
415
|
+
skillName(name);
|
|
416
|
+
const before = await this.headSha();
|
|
417
|
+
// The skill's own dirs plus state.json: the restore puts the files
|
|
418
|
+
// back where they were at sha (skills/ when live, archive/ when
|
|
419
|
+
// archived) and state.json carries the archived/pin rows.
|
|
420
|
+
const paths = [`skills/${name}`, `curation/archive/${name}`, "curation/state.json"];
|
|
421
|
+
// The snapshot is computed BEFORE the write (the write's own
|
|
422
|
+
// staging moves HEAD; reading "the tree at sha" inside the mutate
|
|
423
|
+
// would follow it).
|
|
424
|
+
const snapshot = new Map();
|
|
425
|
+
for (const path of await this.lsTree(sha, paths)) {
|
|
426
|
+
snapshot.set(path, await this.catFile(sha, path));
|
|
427
|
+
}
|
|
428
|
+
await mkdir(this.root, { recursive: true });
|
|
429
|
+
const undoSha = await this.history.write({
|
|
430
|
+
actor: actorOf(meta, this.options.user),
|
|
431
|
+
summary: `restore ${name}@${sha.slice(0, 8)}`,
|
|
432
|
+
action: "restore",
|
|
433
|
+
skill: name,
|
|
434
|
+
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
435
|
+
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
436
|
+
...(meta.reason === undefined ? {} : { reason: meta.reason }),
|
|
437
|
+
}, async () => {
|
|
359
438
|
requireUser(this.options);
|
|
360
|
-
await this.
|
|
361
|
-
});
|
|
439
|
+
await this.restorePaths(snapshot, paths);
|
|
440
|
+
}, { kind: "outside" });
|
|
441
|
+
return this.entry(undoSha, "restore", name, meta, before);
|
|
362
442
|
}
|
|
443
|
+
// ---- internals ------------------------------------------------------
|
|
363
444
|
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* the snapshot it belongs to.
|
|
445
|
+
* Inside a history write: make `paths` exactly as the snapshot
|
|
446
|
+
* (path → content at the target commit). Removes every tracked path
|
|
447
|
+
* under `paths` absent from the snapshot, writes the rest. Deepest
|
|
448
|
+
* first so a moved-away directory's old path never shadows the
|
|
449
|
+
* restore of its new location.
|
|
370
450
|
*/
|
|
371
|
-
async
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
451
|
+
async restorePaths(snapshot, paths) {
|
|
452
|
+
const head = new Set(await this.lsTree("HEAD", paths));
|
|
453
|
+
const remove = [...head].filter((path) => !snapshot.has(path) && paths.some((entry) => path === entry || path.startsWith(`${entry}/`)));
|
|
454
|
+
for (const path of remove.sort((a, b) => b.length - a.length)) {
|
|
455
|
+
await rm(join(this.options.userArea, path), { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
for (const [path, content] of snapshot) {
|
|
458
|
+
const target = join(this.options.userArea, path);
|
|
459
|
+
await mkdir(dirname(target), { recursive: true });
|
|
460
|
+
await writeFile(target, content);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** git ls-tree -r --name-only <tree> -- <paths>, via the history's own git. */
|
|
464
|
+
async lsTree(tree, paths) {
|
|
465
|
+
const { execFile } = await import("node:child_process");
|
|
466
|
+
const { promisify } = await import("node:util");
|
|
467
|
+
const spec = userRepoSpec(this.options.userArea);
|
|
468
|
+
const { stdout } = await promisify(execFile)("git", [`--git-dir=${spec.gitDir}`, `--work-tree=${spec.workTree}`, "-c", `safe.directory=${spec.workTree}`, "ls-tree", "-r", "--name-only", tree, "--", ...paths], { env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" } });
|
|
469
|
+
return stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
470
|
+
}
|
|
471
|
+
/** git cat-file blob <sha>:<path>, via the history's own git. */
|
|
472
|
+
async catFile(sha, path) {
|
|
473
|
+
const { execFile } = await import("node:child_process");
|
|
474
|
+
const { promisify } = await import("node:util");
|
|
475
|
+
const spec = userRepoSpec(this.options.userArea);
|
|
476
|
+
const { stdout } = await promisify(execFile)("git", [`--git-dir=${spec.gitDir}`, `--work-tree=${spec.workTree}`, "-c", `safe.directory=${spec.workTree}`, "cat-file", "blob", `${sha}:${path}`], { env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }, encoding: "buffer" });
|
|
477
|
+
return stdout;
|
|
478
|
+
}
|
|
479
|
+
// ---- internals ------------------------------------------------------
|
|
480
|
+
async mutate(name, change) {
|
|
481
|
+
// Validations already ran in apply(); this replays the decision tree
|
|
482
|
+
// against the (possibly catch-up-shifted) on-disk state.
|
|
483
|
+
const state = await readState(this.root);
|
|
484
|
+
const archived = state.archived.includes(name);
|
|
485
|
+
const root = join(archived ? this.archive : this.skills, name);
|
|
486
|
+
if (change.kind === "create") {
|
|
487
|
+
// Re-validate against the post-catch-up state: two concurrent
|
|
488
|
+
// same-name creates both pass the pre-write check; the second
|
|
489
|
+
// must not clobber the first.
|
|
490
|
+
if (await exists(root))
|
|
491
|
+
throw new Error(`skill already exists: ${name}`);
|
|
492
|
+
await mkdir(root, { recursive: true });
|
|
493
|
+
await writeFile(join(root, "SKILL.md"), change.content);
|
|
494
|
+
state.createdAt[name] = this.options.now().toISOString();
|
|
495
|
+
await writeState(this.root, state);
|
|
496
|
+
return;
|
|
378
497
|
}
|
|
379
|
-
if (
|
|
380
|
-
await mutate();
|
|
381
|
-
if (action === "archive") {
|
|
498
|
+
if (change.kind === "archive") {
|
|
382
499
|
await mkdir(this.archive, { recursive: true });
|
|
383
|
-
await
|
|
500
|
+
await renameForce(root, join(this.archive, name));
|
|
384
501
|
state.archived = [...new Set([...state.archived, name])];
|
|
502
|
+
await writeState(this.root, state);
|
|
503
|
+
return;
|
|
385
504
|
}
|
|
386
|
-
|
|
505
|
+
if (change.kind === "restore") {
|
|
387
506
|
await mkdir(this.skills, { recursive: true });
|
|
388
|
-
|
|
389
|
-
await rename(beforeRoot, afterRoot);
|
|
390
|
-
}
|
|
391
|
-
else {
|
|
392
|
-
const staged = await this.stageVersion(restoreFrom);
|
|
393
|
-
await rm(beforeRoot, { recursive: true, force: true });
|
|
394
|
-
await rename(staged, afterRoot);
|
|
395
|
-
}
|
|
507
|
+
await renameForce(root, join(this.skills, name));
|
|
396
508
|
state.archived = state.archived.filter((item) => item !== name);
|
|
509
|
+
await writeState(this.root, state);
|
|
510
|
+
return;
|
|
397
511
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
512
|
+
if (change.kind === "patch") {
|
|
513
|
+
const file = change.file === undefined ? "SKILL.md" : supportFile(change.file);
|
|
514
|
+
await assertNoSymlinks(root, file);
|
|
515
|
+
const oldText = await readFile(join(root, file), "utf8");
|
|
516
|
+
const first = oldText.indexOf(change.old);
|
|
517
|
+
if (first < 0 || oldText.indexOf(change.old, first + 1) >= 0 || change.old === "")
|
|
518
|
+
throw new Error("patch old text must be a unique span");
|
|
519
|
+
const text = oldText.slice(0, first) + change.new + oldText.slice(first + change.old.length);
|
|
520
|
+
checkContent(text);
|
|
521
|
+
if (file === "SKILL.md" && frontmatter(text).name !== name)
|
|
522
|
+
throw new Error(`frontmatter name must match the directory ${name}`);
|
|
523
|
+
await writeFile(join(root, file), text);
|
|
524
|
+
}
|
|
525
|
+
else if (change.kind === "write_file") {
|
|
526
|
+
const file = supportFile(change.file);
|
|
527
|
+
await assertNoSymlinks(root, file);
|
|
528
|
+
// An empty body is a phantom write (the tool layer refuses
|
|
529
|
+
// it too — this is the belt for direct store callers; main's
|
|
530
|
+
// 1c2b9c8 semantics carried into the git-based store).
|
|
531
|
+
if (change.content === "")
|
|
532
|
+
throw new Error(`write_file ${name}: empty content`);
|
|
533
|
+
checkContent(change.content);
|
|
534
|
+
await mkdir(join(root, file, ".."), { recursive: true });
|
|
535
|
+
await writeFile(join(root, file), change.content);
|
|
415
536
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
await copyTree(current, join(versionRoot, after));
|
|
421
|
-
await writeFile(join(versionRoot, after, ".location"), state.archived.includes(name) ? "archive" : "skills");
|
|
537
|
+
else {
|
|
538
|
+
const file = supportFile(change.file);
|
|
539
|
+
await assertNoSymlinks(root, file);
|
|
540
|
+
await rm(join(root, file));
|
|
422
541
|
}
|
|
423
|
-
await writeState(this.root, state);
|
|
424
|
-
const entry = { id: randomUUID(), at: this.options.now().toISOString(), actor: meta.actor, action, skill: name, ...(absorbedInto === undefined && meta.reason === undefined ? {} : { reason: absorbedInto ?? meta.reason }), ...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }), ...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }), ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) };
|
|
425
|
-
await this.appendFeed(entry);
|
|
426
|
-
const versions = (await readdir(versionRoot)).filter((entry) => /^v\d+$/.test(entry)).sort((a, b) => Number(b.slice(1)) - Number(a.slice(1)));
|
|
427
|
-
for (const old of versions.slice(VERSIONS))
|
|
428
|
-
await rm(join(versionRoot, old), { recursive: true, force: true });
|
|
429
|
-
return entry;
|
|
430
542
|
}
|
|
431
|
-
/**
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
543
|
+
/** Build the returned FeedEntry: sha-backed, with before/after. */
|
|
544
|
+
entry(sha, action, skill, meta, before, absorbedInto) {
|
|
545
|
+
const id = sha ?? `(no-commit-${randomUUID()})`;
|
|
546
|
+
const reason = absorbedInto ?? meta.reason;
|
|
547
|
+
return {
|
|
548
|
+
id,
|
|
549
|
+
// The write clock: feed() serves the commit's authored-at on
|
|
550
|
+
// read-back; the returned entry carries the write's own time.
|
|
551
|
+
at: this.options.now().toISOString(),
|
|
552
|
+
actor: meta.actor,
|
|
553
|
+
action,
|
|
554
|
+
skill,
|
|
555
|
+
...(reason === undefined ? {} : { reason }),
|
|
556
|
+
...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
|
|
557
|
+
...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
|
|
558
|
+
...(before === undefined ? {} : { before }),
|
|
559
|
+
...(sha === undefined ? {} : { after: sha }),
|
|
560
|
+
};
|
|
437
561
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
await writeFileAtomic(join(this.root, "feed.jsonl"), entries.slice(-FEED_ENTRIES).map((item) => JSON.stringify(item)).join("\n") + "\n");
|
|
562
|
+
/** HEAD's sha, or undefined on an empty/failed repo. */
|
|
563
|
+
async headSha() {
|
|
564
|
+
const log = await this.history.log({ limit: 1 });
|
|
565
|
+
return log[0]?.sha;
|
|
443
566
|
}
|
|
444
567
|
}
|
|
568
|
+
/** rename(2) with the destination's parent ensured. */
|
|
569
|
+
async function renameForce(from, to) {
|
|
570
|
+
await mkdir(dirname(to), { recursive: true });
|
|
571
|
+
await rename(from, to);
|
|
572
|
+
}
|