@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +105 -18
  3. package/dist/blocks/build-stamp.json +2 -2
  4. package/dist/blocks/schema.js +5 -0
  5. package/dist/cli.js +85 -22
  6. package/dist/curation/host.js +46 -21
  7. package/dist/curation/migrate.js +96 -0
  8. package/dist/curation/overlays.js +52 -16
  9. package/dist/curation/paths.js +22 -0
  10. package/dist/curation/runs.js +34 -17
  11. package/dist/curation/skill-tool.js +34 -9
  12. package/dist/curation/store.js +382 -254
  13. package/dist/curation/targets.js +21 -0
  14. package/dist/curation/wrap.js +1 -6
  15. package/dist/extension.d.ts +12 -3
  16. package/dist/harness/boot.js +143 -6
  17. package/dist/harness/event-bus.js +19 -0
  18. package/dist/harness/loader.js +22 -0
  19. package/dist/harness/reserved-sections.js +2 -0
  20. package/dist/harness/seam-host.js +71 -16
  21. package/dist/harness/workspace.js +34 -1
  22. package/dist/history/git.js +707 -0
  23. package/dist/history/trailers.js +145 -0
  24. package/dist/history/workspace-history.js +47 -0
  25. package/dist/jobs/index.js +5 -3
  26. package/dist/jobs/reconcile.js +2 -3
  27. package/dist/jobs/state-dir.js +9 -0
  28. package/dist/jobs/transfer.js +6 -6
  29. package/dist/sandbox/grants.js +11 -0
  30. package/dist/sandbox/launch.js +19 -9
  31. package/dist/sandbox/policy.js +6 -1
  32. package/dist/serve.js +3 -0
  33. package/dist/server/auth/proxy-only.js +5 -4
  34. package/dist/server/core-views/index.js +26 -0
  35. package/dist/server/core-views/jobs.js +661 -0
  36. package/dist/server/core-views/skills-tree.js +628 -0
  37. package/dist/server/core-views/skills.js +1090 -0
  38. package/dist/server/index.js +88 -0
  39. package/dist/server/jobs-access.js +62 -0
  40. package/dist/server/notifications.js +24 -12
  41. package/dist/server/routes/extensions.js +9 -1
  42. package/dist/server/routes/jobs.js +19 -52
  43. package/dist/server/routes/meta.js +9 -0
  44. package/dist/server/routes/onboarding.js +13 -1
  45. package/dist/server/surfaces/service.js +38 -0
  46. package/dist/skills/index.js +1 -1
  47. package/dist/skills/roots.js +301 -0
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
  49. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  50. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  52. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  53. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  55. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  56. package/node_modules/@sigrun/shared/package.json +9 -1
  57. package/package.json +7 -7
  58. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  59. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  60. package/webui/dist/favicon.svg +7 -5
  61. package/webui/dist/index.html +3 -3
  62. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  63. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
@@ -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 { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
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
- async function readFeed(dir) {
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
- const text = await readFile(join(dir, "feed.jsonl"), "utf8");
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
- async function nextVersion(dir, name) {
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
- catch (error) {
143
- if (error.code !== "ENOENT")
144
- throw error;
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
- async function versionLocation(version) {
150
- try {
151
- return (await readFile(join(version, ".location"), "utf8")).trim() === "archive" ? "archive" : "skills";
152
- }
153
- catch (error) {
154
- if (error.code === "ENOENT")
155
- return undefined;
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, join(this.root, "archive")]) {
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 ? join(this.root, "archive") : this.skills, name, "SKILL.md");
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
- return withUserLock(this.options.user, async () => {
212
- requireUser(this.options);
213
- skillName(name);
214
- const state = await readState(this.root);
215
- const archived = state.archived.includes(name);
216
- const root = join(archived ? this.archive : this.skills, name);
217
- if (change.kind === "create") {
218
- if (await exists(root))
219
- throw new Error(`skill already exists: ${name}`);
220
- const taken = await this.options.nameTakenElsewhere(name);
221
- if (taken !== undefined)
222
- throw new Error(`skill name ${name} is already used by ${taken}; use overlay instead`);
223
- checkContent(change.content);
224
- if (frontmatter(change.content).name !== name)
225
- throw new Error(`frontmatter name must match the directory ${name}`);
226
- await mkdir(root, { recursive: true });
227
- await writeFile(join(root, "SKILL.md"), change.content);
228
- state.createdAt[name] = this.options.now().toISOString();
229
- return this.commit(name, "create", meta, state, undefined, root);
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
- return this.commit(name, "archive", meta, state, root, join(this.root, "archive", name), change.absorbedInto);
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
- else {
272
- const file = supportFile(change.file);
273
- await assertNoSymlinks(root, file);
274
- await rm(join(root, file));
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 withUserLock(this.options.user, async () => {
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
- await this.appendFeed({ id: randomUUID(), at: this.options.now().toISOString(), actor: "user", action: pinned ? "pin" : "unpin", skill: name });
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
- const entries = await readFeed(this.root);
295
- const start = before === undefined ? 0 : entries.findIndex((entry) => entry.id === before);
296
- if (start < 0)
297
- return [];
298
- return entries.slice(start + (before === undefined ? 0 : 1), start + (before === undefined ? 0 : 1) + Math.max(0, limit));
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
- return withUserLock(this.options.user, async () => {
303
- requireUser(this.options);
304
- const all = await readFeed(this.root);
305
- const selected = all.filter((entry) => entryIds.includes(entry.id) && entry.before !== undefined);
306
- const earliest = new Map();
307
- for (const entry of selected) {
308
- const previous = earliest.get(entry.skill);
309
- if (previous === undefined || all.indexOf(entry) > all.indexOf(previous))
310
- earliest.set(entry.skill, entry);
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
- // The create-undo companion: an undone CREATE has no before
313
- // snapshot (nothing existed) — reverting it means REMOVING the
314
- // skill (archive, never hard delete; restorable from versions).
315
- const createdOnly = all.filter((entry) => entryIds.includes(entry.id) && entry.before === undefined && entry.action === "create");
316
- for (const entry of createdOnly) {
317
- if (!earliest.has(entry.skill))
318
- earliest.set(entry.skill, entry);
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
- const result = [];
321
- const state = await readState(this.root);
322
- for (const entry of earliest.values()) {
323
- if (entry.before === undefined) {
324
- // Undo of a create: archive the skill (the no-delete
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, entry.skill);
389
+ const from = join(this.skills, name);
329
390
  if (!(await exists(from)))
330
391
  continue;
331
- const next = await this.commit(entry.skill, "undo", { actor: "user" }, state, from, join(this.root, "archive", entry.skill));
332
- result.push(next);
333
- continue;
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
- const current = join(state.archived.includes(entry.skill) ? this.archive : this.skills, entry.skill);
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
- return result;
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
- * Append one feed entry verbatim — the A5 overlay seam. OverlayStore
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 withUserLock(this.options.user, async () => {
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.appendFeed(entry);
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
- * Persists a change: snapshots the before state, applies the mutation,
365
- * snapshots the after state, records the feed entry and runs retention.
366
- * Every version directory carries a `.location` file recording where its
367
- * content lived (skills or archive) when the snapshot was taken, so undo
368
- * can pick the right destination and retention removes the metadata with
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 commit(name, action, meta, state, beforeRoot, afterRoot, absorbedInto, restoreFrom, mutate) {
372
- const versionRoot = join(this.root, "versions", name);
373
- let before;
374
- if (beforeRoot !== undefined && await exists(beforeRoot)) {
375
- before = await nextVersion(this.root, name);
376
- await copyTree(beforeRoot, join(versionRoot, before));
377
- await writeFile(join(versionRoot, before, ".location"), beforeRoot.startsWith(this.archive + "/") ? "archive" : "skills");
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 (mutate !== undefined)
380
- await mutate();
381
- if (action === "archive") {
498
+ if (change.kind === "archive") {
382
499
  await mkdir(this.archive, { recursive: true });
383
- await rename(beforeRoot, afterRoot);
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
- else if (action === "restore") {
505
+ if (change.kind === "restore") {
387
506
  await mkdir(this.skills, { recursive: true });
388
- if (restoreFrom === undefined) {
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
- else if (action === "undo") {
399
- // A create-undo (no restoreFrom): the move alone archives the
400
- // skill; an edit-undo stages the before-snapshot into place.
401
- if (restoreFrom !== undefined) {
402
- const staged = await this.stageVersion(restoreFrom);
403
- await rm(beforeRoot, { recursive: true, force: true });
404
- await mkdir(dirname(afterRoot), { recursive: true });
405
- await rename(staged, afterRoot);
406
- }
407
- else {
408
- await mkdir(dirname(afterRoot), { recursive: true });
409
- await rename(beforeRoot, afterRoot);
410
- }
411
- if (afterRoot.startsWith(this.archive + "/"))
412
- state.archived = [...new Set([...state.archived, name])];
413
- else
414
- state.archived = state.archived.filter((item) => item !== name);
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
- let after;
417
- if (action !== "archive") {
418
- after = await nextVersion(this.root, name);
419
- const current = afterRoot ?? beforeRoot;
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
- /** Copies a version snapshot to a staging directory, minus the `.location` metadata file. */
432
- async stageVersion(version) {
433
- const staged = join(this.root, `.staged-${randomUUID()}`);
434
- await copyTree(version, staged);
435
- await rm(join(staged, ".location"), { force: true });
436
- return staged;
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
- async appendFeed(entry) {
439
- await mkdir(this.root, { recursive: true });
440
- const entries = (await readFeed(this.root)).reverse();
441
- entries.push(entry);
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
+ }