@sigrun/sigrun 0.6.0 → 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 (64) hide show
  1. package/CHANGELOG.md +102 -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/packages.js +7 -0
  20. package/dist/harness/reserved-sections.js +2 -0
  21. package/dist/harness/seam-host.js +71 -16
  22. package/dist/harness/workspace.js +34 -1
  23. package/dist/history/git.js +707 -0
  24. package/dist/history/trailers.js +145 -0
  25. package/dist/history/workspace-history.js +47 -0
  26. package/dist/jobs/index.js +5 -3
  27. package/dist/jobs/reconcile.js +2 -3
  28. package/dist/jobs/state-dir.js +9 -0
  29. package/dist/jobs/transfer.js +6 -6
  30. package/dist/sandbox/grants.js +11 -0
  31. package/dist/sandbox/launch.js +19 -9
  32. package/dist/sandbox/policy.js +6 -1
  33. package/dist/serve.js +3 -0
  34. package/dist/server/auth/proxy-only.js +5 -4
  35. package/dist/server/core-views/index.js +26 -0
  36. package/dist/server/core-views/jobs.js +661 -0
  37. package/dist/server/core-views/skills-tree.js +628 -0
  38. package/dist/server/core-views/skills.js +1090 -0
  39. package/dist/server/index.js +88 -0
  40. package/dist/server/jobs-access.js +62 -0
  41. package/dist/server/notifications.js +24 -12
  42. package/dist/server/routes/extensions.js +9 -1
  43. package/dist/server/routes/jobs.js +19 -52
  44. package/dist/server/routes/meta.js +9 -0
  45. package/dist/server/routes/onboarding.js +13 -1
  46. package/dist/server/surfaces/service.js +38 -0
  47. package/dist/skills/index.js +1 -1
  48. package/dist/skills/roots.js +301 -0
  49. package/node_modules/@sigrun/shared/CHANGELOG.md +13 -0
  50. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  51. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  53. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  55. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  56. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  57. package/node_modules/@sigrun/shared/package.json +9 -1
  58. package/package.json +7 -7
  59. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  60. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  61. package/webui/dist/favicon.svg +7 -5
  62. package/webui/dist/index.html +3 -3
  63. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  64. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
@@ -0,0 +1,1090 @@
1
+ /**
2
+ * The `core/skills` view (tabs+skills Task 12): every skill root the
3
+ * deployment serves — Workspace / Learned / Config dir (the
4
+ * `<configDir>/skills` root) / one group per package (`· <pkg>`) /
5
+ * Deleted (whole-branch review I5: the spec's label semantics) — with
6
+ * owned-skill CRUD, git history with per-file diffs, and overlays for
7
+ * the read-only roots.
8
+ *
9
+ * - The caller sees THEIR OWN roots: workspace skills under
10
+ * `workspaceRoot(user)`, learned skills + overlays + the learned
11
+ * history through `curationOf(user)` (Task 9), the workspace history
12
+ * through `workspaceHistoryFor(user)` (Task 10), and the deployment's
13
+ * shared roots (`userDir`, the package roots) through Task 11's
14
+ * `listAllRoots` / `readSkillFile` / `listSupportFiles`. A principal
15
+ * without a user gets access `none`.
16
+ * - The tree is DATA-BOUND (the Task 4 bump contract): every action
17
+ * ends with `bumpAllInstances("core","skills")`; the tree itself
18
+ * carries no per-render rows.
19
+ * - Catch-up of outside edits runs on BOTH of the caller's repos at
20
+ * most once per repo per 30 s (an in-memory per-repo timestamp —
21
+ * renders re-run on every data refetch, and the throttle is what
22
+ * keeps "when the tab opens" true without spawning git per render),
23
+ * bounded at 5 s with errors swallowed (a broken repo shows its
24
+ * reason in the history pane; listing and editing still work).
25
+ * - Writes: workspace skills live in the workspace repo
26
+ * (`workspaceHistory.write`, frontmatter + `scanContent` + the name
27
+ * rule, refuse-if-exists); learned skills go through the SkillStore
28
+ * (`apply` create/patch/archive, `restoreVersion`, `pin`); user-dir
29
+ * and package skills are READ-ONLY here — the caller's per-user
30
+ * overlay (`resolveUpstream` → `OverlayStore.put` with the upstream
31
+ * hash) is their only write.
32
+ * - `restoreDeleted` refuses a reused name with "a skill named <name>
33
+ * exists in <root>; rename or delete it first" (Review Focus 5) — and
34
+ * a deleted selection's ONLY action is the *Restore deleted* button
35
+ * that dispatches it (whole-branch review I2: Edit/Delete/restore a
36
+ * version never render there). `restoreVersion` on a workspace skill
37
+ * refuses the same way when the target commit predates the name's
38
+ * newest delete while a live row holds the name (the reused skill's
39
+ * history chain spans the old skill's commits; a plain restore would
40
+ * silently overwrite it).
41
+ */
42
+ import { mkdir, rm, writeFile } from "node:fs/promises";
43
+ import { join } from "node:path";
44
+ import { resolveUpstream } from "../../curation/overlays.js";
45
+ import { validSkillName } from "../../curation/names.js";
46
+ import { scanContent } from "../../curation/scan.js";
47
+ import { listAllRoots, listSupportFiles, readSkillFile, } from "../../skills/roots.js";
48
+ import { parseSkillFile } from "../../skills/index.js";
49
+ import { buildSkillsTree, capCommitFiles, parseRowId, rowIdOf, SKILLS_HISTORY_PAGE_SIZE, skillsInstanceKey, skillsParamsOf, skillsNoneTree, historyPage, sidebarRows, } from "./skills-tree.js";
50
+ /** The catch-up contract (the brief, verbatim): once per repo per 30 s, 5 s bound. */
51
+ export const SKILLS_CATCHUP_INTERVAL_MS = 30_000;
52
+ export const SKILLS_CATCHUP_DEADLINE_MS = 5_000;
53
+ /** The one SKILL.md (frontmatter + body) for a write. */
54
+ function skillFileText(name, description, body) {
55
+ const trimmed = body.replace(/^\s+/, "").replace(/\s+$/, "");
56
+ return `---\nname: ${name}\ndescription: ${description}\n---\n\n${trimmed === "" ? "" : `${trimmed}\n`}`;
57
+ }
58
+ /** Whether `payload` is a plain object. */
59
+ function isObject(payload) {
60
+ return typeof payload === "object" && payload !== null && !Array.isArray(payload);
61
+ }
62
+ /**
63
+ * One string field of the payload. Form dispatches wrap the fields as
64
+ * `{values: {...}, rev}` (the webui FormBlock contract); callout-level
65
+ * buttons dispatch a null payload and their targets ride the instance
66
+ * params. This reads the top level first, then the `values` envelope,
67
+ * so both shapes resolve.
68
+ */
69
+ function fieldOf(payload, key) {
70
+ if (!isObject(payload))
71
+ return undefined;
72
+ const top = payload[key];
73
+ if (typeof top === "string")
74
+ return top;
75
+ const values = payload.values;
76
+ if (isObject(values)) {
77
+ const nested = values[key];
78
+ if (typeof nested === "string")
79
+ return nested;
80
+ }
81
+ return undefined;
82
+ }
83
+ /** The `{itemId}` a collection activation carries. */
84
+ function itemIdOf(payload) {
85
+ return fieldOf(payload, "itemId");
86
+ }
87
+ /** The action result for a thrown error. */
88
+ function failure(error) {
89
+ return {
90
+ ok: false,
91
+ error: { message: error instanceof Error ? error.message : String(error) },
92
+ };
93
+ }
94
+ /** One skill's combined "is present anywhere" check (the restoreDeleted refusal). */
95
+ function presentRootOf(skills, name) {
96
+ return skills.find((skill) => skill.name === name)?.root;
97
+ }
98
+ /** `git log --format=%H -- <pathspec>` rows, oldest-order irrelevant. */
99
+ async function logShas(history, path, limit) {
100
+ const commits = await history.log({ path, limit });
101
+ return commits.map((commit) => ({ sha: commit.sha, summary: commit.summary }));
102
+ }
103
+ /**
104
+ * Names the workspace repo's log touches that have NO live row AND
105
+ * whose newest commit REMOVED the dir (a deleted skill). A name the
106
+ * log touches that is LIVE again (deleted then re-created) stays
107
+ * live — its newest commit re-adds files, so the newest-summary
108
+ * check excludes it.
109
+ */
110
+ async function deletedWorkspaceSkills(history, liveNames) {
111
+ const touched = new Set();
112
+ for (const commit of await history.log({ limit: 1000 })) {
113
+ for (const path of commit.paths) {
114
+ const name = path.split("/")[0] ?? "";
115
+ if (name !== "" && validSkillName(name))
116
+ touched.add(name);
117
+ }
118
+ }
119
+ const names = [];
120
+ for (const name of touched) {
121
+ if (liveNames.has(name))
122
+ continue;
123
+ const newest = (await history.log({ path: `${name}/`, limit: 1 }))[0];
124
+ if (newest === undefined)
125
+ continue;
126
+ if (newest.summary === `delete ${name}`)
127
+ names.push(name);
128
+ }
129
+ return names.sort();
130
+ }
131
+ /** The newest deletion commit's sha for one workspace skill (`{deletionSha}` row field). */
132
+ async function deletionShaOf(history, name) {
133
+ const newest = (await history.log({ path: `${name}/`, limit: 1 }))[0];
134
+ return newest !== undefined && newest.summary === `delete ${name}`
135
+ ? { deletionSha: newest.sha }
136
+ : {};
137
+ }
138
+ /**
139
+ * The newest `delete <name>` commit's sha for one WORKSPACE skill, or
140
+ * undefined when the name was never deleted (whole-branch review I2's
141
+ * reused-name refusal needs the anchor independent of the newest-touch
142
+ * rule {@link deletionShaOf} applies).
143
+ */
144
+ async function newestDeletionSha(history, name) {
145
+ // A name's commits are few; the cap only bounds a pathological repo.
146
+ for (const commit of await history.log({ path: `${name}/`, limit: 1000 })) {
147
+ if (commit.summary === `delete ${name}`)
148
+ return commit.sha;
149
+ }
150
+ return undefined;
151
+ }
152
+ /**
153
+ * The exact reused-name refusal text (restoreDeleted's existing message,
154
+ * shared with restoreVersion's workspace branch — whole-branch review I2).
155
+ */
156
+ function reusedNameError(name, root) {
157
+ return `a skill named ${name} exists in ${root}; rename or delete it first`;
158
+ }
159
+ /** One sha's index in a log page (-1 when absent). */
160
+ function pathLogIndex(log, sha) {
161
+ return log.findIndex((one) => one.sha === sha);
162
+ }
163
+ /** The newest commit's sha on one path (the learned archive's restore anchor). */
164
+ async function lastActionSha(history, path) {
165
+ const commits = await logShas(history, path, 1);
166
+ return commits[0]?.sha;
167
+ }
168
+ /** The view surface (`SurfaceDef<"view">`), built over the Task 9/10/11 deps. */
169
+ export function createSkillsView(deps) {
170
+ const now = deps.now ?? (() => new Date());
171
+ // The per-repo catch-up throttle (the brief's once-per-30s rule).
172
+ const lastCatchUp = new Map();
173
+ /** The caller's EFFECTIVE username (review I3): an anonymous
174
+ * principal → undefined (the surface 404s); the userless superuser
175
+ * resolves through {@link SkillsViewDeps.resolveUserlessCaller} to
176
+ * the primary admin when the dep is wired (else it keeps its own
177
+ * undefined scope — the empty-workspace posture). */
178
+ const userOf = (ctx) => deps.resolveUserlessCaller === undefined
179
+ ? ctx.principal.user
180
+ : deps.resolveUserlessCaller(ctx.principal);
181
+ /** The caller's shared root listing (workspace/learned scoped to them). */
182
+ const listFor = (user) => {
183
+ const workspace = deps.workspaceRoot(user);
184
+ const learned = deps.learnedDirOf(user);
185
+ return listAllRoots({
186
+ ...(workspace === undefined ? {} : { workspace: join(workspace, "skills") }),
187
+ ...(learned === undefined ? {} : { learned }),
188
+ userDir: deps.userDir,
189
+ packageRoots: deps.packageRoots(),
190
+ });
191
+ };
192
+ /** One caller's FULL sidebar: the live rows plus a Deleted-group row
193
+ * for every skill gone from disk whose history still holds it. */
194
+ const rowsFor = async (user) => {
195
+ const skills = await listFor(user);
196
+ const curation = deps.curationOf(user);
197
+ const archived = new Set(curation === undefined
198
+ ? []
199
+ : (await curation.store.list()).filter((info) => info.archived).map((info) => info.name));
200
+ const rows = sidebarRows(skills, { deleted: archived });
201
+ // Deleted-group rows: a name with NO live row whose repo log still
202
+ // holds the skill. WORKSPACE: the workspace repo's log names it
203
+ // (its dir is gone for good — a present live name stays live).
204
+ // LEARNED: an archived skill's files moved to curation/archive,
205
+ // OUTSIDE the learned discovery root, so its row is synthesized
206
+ // from the store's archived list (listAllRoots never sees it).
207
+ const liveNames = new Set(rows.map((row) => row.name).filter((name) => typeof name === "string"));
208
+ const workspaceHistory = deps.workspaceHistoryFor(user);
209
+ if (workspaceHistory !== undefined) {
210
+ for (const name of await deletedWorkspaceSkills(workspaceHistory, liveNames)) {
211
+ rows.push({
212
+ id: rowIdOf("workspace", name),
213
+ name,
214
+ description: "",
215
+ group: "deleted",
216
+ ...(await deletionShaOf(workspaceHistory, name)),
217
+ });
218
+ }
219
+ }
220
+ if (curation !== undefined) {
221
+ const infos = await curation.store.list();
222
+ for (const info of infos) {
223
+ if (!info.archived)
224
+ continue;
225
+ const existing = rows.find((one) => typeof one.id === "string" && one.id === rowIdOf("learned", info.name));
226
+ const sha = await lastActionSha(curation.history, `curation/archive/${info.name}`);
227
+ if (existing === undefined) {
228
+ rows.push({
229
+ id: rowIdOf("learned", info.name),
230
+ name: info.name,
231
+ description: info.description,
232
+ group: "deleted",
233
+ ...(sha === undefined ? {} : { deletionSha: sha }),
234
+ });
235
+ }
236
+ else {
237
+ existing.group = "deleted";
238
+ if (sha !== undefined)
239
+ existing.deletionSha = sha;
240
+ }
241
+ }
242
+ }
243
+ return rows;
244
+ };
245
+ /** Catch up ONE repo, throttled (once per 30 s) + deadline-bounded + fail-swallowed. */
246
+ async function catchUpThrottled(history) {
247
+ const last = lastCatchUp.get(history);
248
+ const at = now().getTime();
249
+ if (last !== undefined && at - last < SKILLS_CATCHUP_INTERVAL_MS)
250
+ return;
251
+ lastCatchUp.set(history, at);
252
+ const controller = new AbortController();
253
+ const timer = setTimeout(() => controller.abort(), SKILLS_CATCHUP_DEADLINE_MS);
254
+ void controller;
255
+ try {
256
+ await Promise.race([
257
+ history.catchUp({ actor: { kind: "outside" }, summary: "outside changes" }),
258
+ new Promise((resolve) => setTimeout(resolve, SKILLS_CATCHUP_DEADLINE_MS)),
259
+ ]);
260
+ }
261
+ catch {
262
+ // Swallowed by design: a broken repo shows its reason in the
263
+ // history pane (availability below); rendering never fails.
264
+ }
265
+ finally {
266
+ clearTimeout(timer);
267
+ }
268
+ }
269
+ /** The outside-edits catch-up for BOTH of the caller's repos. */
270
+ async function catchUpBoth(user) {
271
+ const repos = [
272
+ deps.curationOf(user)?.history,
273
+ deps.workspaceHistoryFor(user),
274
+ ];
275
+ for (const history of repos) {
276
+ if (history === undefined)
277
+ continue;
278
+ await catchUpThrottled(history);
279
+ }
280
+ }
281
+ /** The selected skill's history availability (the pane's reason when down). */
282
+ async function availabilityOf(history, missingReason) {
283
+ if (history === undefined)
284
+ return { available: false, reason: missingReason };
285
+ const state = await history.availability();
286
+ return state.ok ? { available: true, reason: null } : { available: false, reason: state.reason };
287
+ }
288
+ /** Resolve the params' selection against the caller's rows. */
289
+ async function resolveSelection(user, params, skills) {
290
+ if (params.skill === undefined)
291
+ return undefined;
292
+ const row = skills.find((skill) => {
293
+ if (skill.name !== params.skill)
294
+ return false;
295
+ if (params.root !== undefined && skill.root !== params.root)
296
+ return false;
297
+ if (params.root === "package" || skill.root === "package") {
298
+ return skill.root === "package" && skill.packageName === params.pkg;
299
+ }
300
+ return true;
301
+ });
302
+ if (row === undefined)
303
+ return undefined;
304
+ const workspace = deps.workspaceRoot(user);
305
+ const curation = deps.curationOf(user);
306
+ let history;
307
+ let missingReason;
308
+ let historyPath;
309
+ if (row.root === "workspace") {
310
+ history = deps.workspaceHistoryFor(user);
311
+ missingReason =
312
+ workspace === undefined
313
+ ? "no workspace is configured for this user"
314
+ : "workspace history is unavailable";
315
+ historyPath = `${row.name}/`;
316
+ }
317
+ else if (row.root === "learned") {
318
+ history = curation?.history;
319
+ missingReason = "learned history is unavailable";
320
+ // An archived skill's history spans BOTH homes (the archive
321
+ // move's commit names curation/archive/<name>; everything
322
+ // earlier names skills/<name>) — the log runs pathless and
323
+ // filters to commits that touched either side.
324
+ historyPath = `skills/${row.name}`;
325
+ }
326
+ else {
327
+ history = undefined;
328
+ missingReason =
329
+ row.root === "package"
330
+ ? "package skills ship with their extension — no local history"
331
+ : "the config dir has no history";
332
+ historyPath = `${row.name}/`;
333
+ }
334
+ const availability = await availabilityOf(history, missingReason);
335
+ return {
336
+ params,
337
+ skill: row,
338
+ history,
339
+ historyAvailable: availability.available,
340
+ historyReason: availability.reason,
341
+ historyPaths: row.root === "learned"
342
+ ? [`skills/${row.name}`, `curation/archive/${row.name}`, "curation/state.json"]
343
+ : [historyPath],
344
+ deleted: false,
345
+ };
346
+ }
347
+ /** The caller's overlay key for one non-learned selection. */
348
+ function overlayKeyOf(user, row) {
349
+ const workspace = deps.workspaceRoot(user);
350
+ return {
351
+ root: row.root,
352
+ skill: row.name,
353
+ ...(row.root === "workspace" && workspace !== undefined ? { workspace } : {}),
354
+ };
355
+ }
356
+ /** The main data branch for a resolved selection. */
357
+ async function mainDataOf(user, selection) {
358
+ const row = selection.skill;
359
+ let text = "";
360
+ let description = row.description;
361
+ let body = "";
362
+ if (!selection.deleted && row.error === undefined) {
363
+ try {
364
+ text = await readSkillFile(row.rootDir, row.name, "SKILL.md");
365
+ const parsed = parseSkillFile(text);
366
+ description = parsed?.description ?? row.description;
367
+ body = parsed?.body ?? "";
368
+ }
369
+ catch (error) {
370
+ body = `(unreadable: ${error instanceof Error ? error.message : String(error)})`;
371
+ }
372
+ }
373
+ const supportFiles = await listSupportFiles(row.rootDir, row.name).catch(() => []);
374
+ let overlay = null;
375
+ if (row.root !== "learned") {
376
+ const curation = deps.curationOf(user);
377
+ const stored = curation === undefined ? undefined : await curation.overlays.get(overlayKeyOf(user, row)).catch(() => undefined);
378
+ overlay = stored === undefined || stored.orphanedSince !== undefined ? null : stored.text;
379
+ }
380
+ let history = {
381
+ available: selection.historyAvailable,
382
+ reason: selection.historyReason,
383
+ items: [],
384
+ hasMore: false,
385
+ nextCursor: null,
386
+ };
387
+ let commit = null;
388
+ if (selection.historyAvailable && selection.history !== undefined) {
389
+ // Multi-path selections (an archived learned skill) page
390
+ // pathless and filter: git's own -- <path> can only take ONE
391
+ // pathspec through history.log, and the union matters more
392
+ // than the fetch margin (the log is per-repo, bounded).
393
+ const pathless = selection.historyPaths.length > 1;
394
+ const raw = await selection.history.log({
395
+ ...(pathless ? {} : { path: selection.historyPaths[0] }),
396
+ limit: pathless
397
+ ? (SKILLS_HISTORY_PAGE_SIZE + 1) * 4
398
+ : SKILLS_HISTORY_PAGE_SIZE + 1,
399
+ ...(selection.params.cursor === undefined
400
+ ? {}
401
+ : { before: selection.params.cursor }),
402
+ });
403
+ const commits = pathless
404
+ ? raw.filter((one) => one.paths.some((path) => selection.historyPaths.some((root) => path === root || path.startsWith(`${root}/`))))
405
+ : raw;
406
+ const page = historyPage(commits);
407
+ history = {
408
+ available: true,
409
+ reason: null,
410
+ items: page.items,
411
+ hasMore: page.hasMore,
412
+ nextCursor: page.nextCursor,
413
+ };
414
+ if (selection.params.commit !== undefined) {
415
+ const files = await selection.history.changes(selection.params.commit, selection.historyPaths[0]);
416
+ // Review I6: cap the TOTAL inlined bytes across the commit's
417
+ // files (changes() caps each side per file at 64 KiB, but a
418
+ // many-file commit could still blow the 4 MiB view cap and 500
419
+ // the tab) — the overflow collapses into one elision marker.
420
+ commit = { sha: selection.params.commit, files: capCommitFiles(files.map((file) => ({ ...file }))) };
421
+ }
422
+ }
423
+ return {
424
+ name: row.name,
425
+ root: row.root,
426
+ ...(row.packageName === undefined ? {} : { pkg: row.packageName }),
427
+ rootDir: row.dir,
428
+ description,
429
+ body,
430
+ supportFiles: [...supportFiles],
431
+ overlay,
432
+ history,
433
+ commit,
434
+ };
435
+ }
436
+ /** The form's pre-filled values (a new skill starts empty; an edit reads the current text). */
437
+ async function formDataOf(params, selection) {
438
+ if (params.skill === undefined || selection === undefined) {
439
+ return { name: "", description: "", body: "", where: "workspace" };
440
+ }
441
+ try {
442
+ const text = await readSkillFile(selection.skill.rootDir, selection.skill.name, "SKILL.md");
443
+ const parsed = parseSkillFile(text);
444
+ return {
445
+ name: selection.skill.name,
446
+ description: parsed?.description ?? selection.skill.description,
447
+ body: parsed?.body ?? "",
448
+ where: "workspace",
449
+ };
450
+ }
451
+ catch {
452
+ return {
453
+ name: selection.skill.name,
454
+ description: selection.skill.description,
455
+ body: "",
456
+ where: "workspace",
457
+ };
458
+ }
459
+ }
460
+ /** One render's full data document (the tree binds it). */
461
+ async function readData(user, params) {
462
+ const skills = await listFor(user);
463
+ const rows = await rowsFor(user);
464
+ let selection = await resolveSelection(user, params, skills);
465
+ // A Deleted-group row's selection: no live dir, but the repo log
466
+ // still holds the skill — the pane renders its history (and the
467
+ // restore affordance), never a content read.
468
+ if (selection === undefined && params.skill !== undefined) {
469
+ const deletedRow = rows.find((row) => typeof row === "object" &&
470
+ row !== null &&
471
+ row.group === "deleted" &&
472
+ row.name === params.skill &&
473
+ (params.root === undefined ||
474
+ row.id ===
475
+ rowIdOf(params.root, params.skill, params.pkg)));
476
+ if (deletedRow !== undefined) {
477
+ const root = params.root ?? "workspace";
478
+ const history = root === "learned"
479
+ ? deps.curationOf(user)?.history
480
+ : deps.workspaceHistoryFor(user);
481
+ const missingReason = root === "learned"
482
+ ? "learned history is unavailable"
483
+ : "workspace history is unavailable";
484
+ const availability = await availabilityOf(history, missingReason);
485
+ const synthetic = {
486
+ name: params.skill,
487
+ description: "",
488
+ root,
489
+ rootDir: "",
490
+ dir: "",
491
+ hidden: false,
492
+ };
493
+ selection = {
494
+ params,
495
+ skill: synthetic,
496
+ history,
497
+ historyAvailable: availability.available,
498
+ historyReason: availability.reason,
499
+ historyPaths: root === "learned"
500
+ ? [`skills/${params.skill}`, `curation/archive/${params.skill}`, "curation/state.json"]
501
+ : [`${params.skill}/`],
502
+ deleted: true,
503
+ };
504
+ }
505
+ }
506
+ const main = selection === undefined ? undefined : await mainDataOf(user, selection);
507
+ const form = await formDataOf(params, selection);
508
+ const writable = selection === undefined || selection.skill.root === "learned" || selection.skill.root === "workspace";
509
+ // The actions callout shows for overlay-capable roots too (their
510
+ // buttons are overlay-only) — `writable` is the OWN-roots gate.
511
+ const actionable = writable || selection?.skill.root === "user dir" || selection?.skill.root === "package";
512
+ const data = {
513
+ skills: rows,
514
+ main: main ?? null,
515
+ form,
516
+ };
517
+ // SAFETY: the data document is a JSON mirror (every field a
518
+ // literal); the interfaces lack the index signature JsonObject
519
+ // requires, so the cast only restores that erased identity.
520
+ return { data: data, selection, main, form, writable: actionable };
521
+ }
522
+ /** The caller's own-root write gate for actions (read-only roots refuse). */
523
+ const selectedParams = (ctx) => skillsParamsOf(ctx.params);
524
+ /** One action's SELECTION: the params' row, or the archived/deleted
525
+ * synthetic row the params name (restoreVersion/restoreDeleted reach
526
+ * skills whose live dir is gone — listAllRoots never sees them). */
527
+ async function actionSelection(user, root, name) {
528
+ const skills = await listFor(user);
529
+ const live = await resolveSelection(user, { skill: name, root }, skills);
530
+ if (live !== undefined)
531
+ return live;
532
+ // The archived/deleted synthetic (the Deleted-group selection):
533
+ // learned → the curation repo; anything else → the workspace repo.
534
+ if (root !== "learned" && root !== "workspace")
535
+ return undefined;
536
+ const history = root === "learned" ? deps.curationOf(user)?.history : deps.workspaceHistoryFor(user);
537
+ const missingReason = root === "learned" ? "learned history is unavailable" : "workspace history is unavailable";
538
+ const availability = await availabilityOf(history, missingReason);
539
+ if (!availability.available)
540
+ return undefined;
541
+ const synthetic = {
542
+ name,
543
+ description: "",
544
+ root,
545
+ rootDir: "",
546
+ dir: "",
547
+ hidden: false,
548
+ };
549
+ return {
550
+ params: { skill: name, root },
551
+ skill: synthetic,
552
+ history,
553
+ historyAvailable: true,
554
+ historyReason: null,
555
+ historyPaths: root === "learned"
556
+ ? [`skills/${name}`, `curation/archive/${name}`, "curation/state.json"]
557
+ : [`${name}/`],
558
+ deleted: true,
559
+ };
560
+ }
561
+ /** The row an action targets: payload override, else the instance params. */
562
+ async function targetRow(ctx, payload) {
563
+ const user = userOf(ctx);
564
+ if (user === undefined)
565
+ return { error: { ok: false, error: { message: "forbidden" } } };
566
+ const skills = await listFor(user);
567
+ const root = (fieldOf(payload, "root") ?? selectedParams(ctx).root);
568
+ const name = fieldOf(payload, "name") ?? selectedParams(ctx).skill;
569
+ const pkg = fieldOf(payload, "pkg") ?? selectedParams(ctx).pkg;
570
+ if (name === undefined || root === undefined) {
571
+ return { error: { ok: false, error: { message: "the action needs a selected skill (params.skill + params.root)" } } };
572
+ }
573
+ const selection = pkg === undefined
574
+ ? await actionSelection(user, root, name)
575
+ : await resolveSelection(user, { ...selectedParams(ctx), skill: name, root, pkg }, skills);
576
+ if (selection === undefined) {
577
+ return { error: { ok: false, error: { message: `skill not found: ${name}` } } };
578
+ }
579
+ return { user, skills, selection };
580
+ }
581
+ /** The workspace write: content checks + refuse-if-exists (create) + history write. */
582
+ async function workspaceWrite(ctx, payload, opts) {
583
+ const user = userOf(ctx);
584
+ if (user === undefined)
585
+ return { ok: false, error: { message: "forbidden" } };
586
+ const history = deps.workspaceHistoryFor(user);
587
+ const workspace = deps.workspaceRoot(user);
588
+ if (history === undefined || workspace === undefined) {
589
+ return { ok: false, error: { message: "workspace history is unavailable" } };
590
+ }
591
+ const name = fieldOf(payload, "name") ?? selectedParams(ctx).skill;
592
+ const description = fieldOf(payload, "description");
593
+ const body = fieldOf(payload, "body");
594
+ if (name === undefined || !validSkillName(name)) {
595
+ return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
596
+ }
597
+ if (description === undefined || description.trim() === "") {
598
+ return { ok: false, error: { message: "a description is required", field: "description" } };
599
+ }
600
+ const text = skillFileText(name, description.trim(), body ?? "");
601
+ const scanned = scanContent(text);
602
+ if (!scanned.ok) {
603
+ return { ok: false, error: { message: `content scan refused line ${scanned.line}: ${scanned.rule}`, field: "body" } };
604
+ }
605
+ // The work tree (<workspace>/skills) must exist BEFORE the
606
+ // history write: prepare()'s access check fails a missing tree,
607
+ // and the mutation would land with no commit (the e2e race: a
608
+ // fresh workspace has no skills dir until the first skill).
609
+ const skillsRoot = join(workspace, "skills");
610
+ await mkdir(skillsRoot, { recursive: true });
611
+ const skills = await listFor(user);
612
+ const existing = skills.find((skill) => skill.name === name && skill.root === "workspace");
613
+ if (opts.mode === "create" && existing !== undefined) {
614
+ return { ok: false, error: { message: `skill already exists: ${name}`, field: "name" } };
615
+ }
616
+ if (opts.mode === "save" && existing === undefined) {
617
+ return { ok: false, error: { message: `skill not found: ${name}` } };
618
+ }
619
+ try {
620
+ const sha = await history.write({
621
+ actor: { kind: "user", user },
622
+ summary: opts.mode === "create" ? `create ${name}` : `edit ${name}`,
623
+ action: opts.mode === "create" ? "create" : "patch",
624
+ skill: name,
625
+ }, async () => {
626
+ const dir = join(skillsRoot, name);
627
+ await mkdir(dir, { recursive: true });
628
+ await writeFile(join(dir, "SKILL.md"), text);
629
+ }, { kind: "outside" });
630
+ void sha;
631
+ deps.bump();
632
+ return {
633
+ ok: true,
634
+ toast: `${opts.mode === "create" ? "Created" : "Saved"} ${name}`,
635
+ navigate: { type: "navigate", params: { skill: name, root: "workspace" } },
636
+ };
637
+ }
638
+ catch (error) {
639
+ return failure(error);
640
+ }
641
+ }
642
+ return {
643
+ id: "skills",
644
+ title: "Skills",
645
+ icon: "book",
646
+ placement: "view",
647
+ instanceKey: skillsInstanceKey,
648
+ render: async (ctx) => {
649
+ const user = userOf(ctx);
650
+ if (user === undefined) {
651
+ return { tree: skillsNoneTree(), data: {}, access: "none" };
652
+ }
653
+ await catchUpBoth(user);
654
+ const params = skillsParamsOf(ctx.params);
655
+ const { data, selection, main, form, writable } = await readData(user, params);
656
+ return {
657
+ tree: buildSkillsTree(params, main, form, {
658
+ writable,
659
+ historyAvailable: selection?.historyAvailable ?? false,
660
+ selectedExists: selection !== undefined,
661
+ ...(selection?.deleted === true ? { deleted: true } : {}),
662
+ }),
663
+ data,
664
+ access: "write",
665
+ };
666
+ },
667
+ data: async (ctx) => {
668
+ const user = userOf(ctx);
669
+ if (user === undefined)
670
+ return { data: {}, access: "none" };
671
+ // The data path renders too (a refetch IS a tab-open cycle), so
672
+ // the throttle — not the route — is the git-spawn guard.
673
+ await catchUpBoth(user);
674
+ const params = skillsParamsOf(ctx.params);
675
+ const { data } = await readData(user, params);
676
+ return { data, access: "write" };
677
+ },
678
+ actions: {
679
+ // The sidebar row's drill-in: {itemId} = the row id (root:name[:pkg]).
680
+ openSkill: async (ctx, payload) => {
681
+ if (userOf(ctx) === undefined)
682
+ return { ok: false, error: { message: "forbidden" } };
683
+ const parsed = parseRowId(itemIdOf(payload) ?? "");
684
+ if (parsed === undefined) {
685
+ return { ok: false, error: { message: "openSkill expects {itemId}" } };
686
+ }
687
+ return {
688
+ ok: true,
689
+ navigate: {
690
+ type: "navigate",
691
+ params: {
692
+ skill: parsed.name,
693
+ root: parsed.root,
694
+ ...(parsed.pkg === undefined ? {} : { pkg: parsed.pkg }),
695
+ },
696
+ },
697
+ };
698
+ },
699
+ // A history row's drill-in: {itemId} = the commit sha.
700
+ openCommit: async (ctx, payload) => {
701
+ if (userOf(ctx) === undefined)
702
+ return { ok: false, error: { message: "forbidden" } };
703
+ const sha = itemIdOf(payload);
704
+ if (sha === undefined) {
705
+ return { ok: false, error: { message: "openCommit expects {itemId}" } };
706
+ }
707
+ const params = selectedParams(ctx);
708
+ return {
709
+ ok: true,
710
+ navigate: {
711
+ type: "navigate",
712
+ params: {
713
+ ...(params.skill === undefined ? {} : { skill: params.skill }),
714
+ ...(params.root === undefined ? {} : { root: params.root }),
715
+ ...(params.pkg === undefined ? {} : { pkg: params.pkg }),
716
+ commit: sha,
717
+ },
718
+ },
719
+ };
720
+ },
721
+ create: async (ctx, payload) => {
722
+ const user = userOf(ctx);
723
+ if (user === undefined)
724
+ return { ok: false, error: { message: "forbidden" } };
725
+ const where = fieldOf(payload, "where") ?? "workspace";
726
+ const name = fieldOf(payload, "name");
727
+ const description = fieldOf(payload, "description");
728
+ const body = fieldOf(payload, "body");
729
+ if (name === undefined || !validSkillName(name)) {
730
+ return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
731
+ }
732
+ if (description === undefined || description.trim() === "") {
733
+ return { ok: false, error: { message: "a description is required", field: "description" } };
734
+ }
735
+ if (where === "learned") {
736
+ const curation = deps.curationOf(user);
737
+ if (curation === undefined) {
738
+ return { ok: false, error: { message: "learned skills are unavailable" } };
739
+ }
740
+ try {
741
+ await curation.store.apply(name, { kind: "create", content: skillFileText(name, description.trim(), body ?? "") }, { actor: "user" });
742
+ }
743
+ catch (error) {
744
+ return failure(error);
745
+ }
746
+ deps.bump();
747
+ return {
748
+ ok: true,
749
+ toast: `Created ${name}`,
750
+ navigate: { type: "navigate", params: { skill: name, root: "learned" } },
751
+ };
752
+ }
753
+ return workspaceWrite(ctx, payload, { mode: "create" });
754
+ },
755
+ save: async (ctx, payload) => {
756
+ const user = userOf(ctx);
757
+ if (user === undefined)
758
+ return { ok: false, error: { message: "forbidden" } };
759
+ // The form's name field is DISABLED on an existing skill
760
+ // (names are read-only after creation) — a disabled field never
761
+ // rides the submit envelope, so the name comes from the params.
762
+ const name = selectedParams(ctx).skill ?? fieldOf(payload, "name");
763
+ const description = fieldOf(payload, "description");
764
+ const body = fieldOf(payload, "body");
765
+ if (name === undefined) {
766
+ return { ok: false, error: { message: "save needs a selected skill (params.skill)" } };
767
+ }
768
+ if (description === undefined || description.trim() === "") {
769
+ return { ok: false, error: { message: "a description is required", field: "description" } };
770
+ }
771
+ const root = (fieldOf(payload, "root") ?? selectedParams(ctx).root);
772
+ if (root === "learned") {
773
+ const curation = deps.curationOf(user);
774
+ if (curation === undefined) {
775
+ return { ok: false, error: { message: "learned skills are unavailable" } };
776
+ }
777
+ try {
778
+ const current = await curation.store.view(name);
779
+ const next = skillFileText(name, description.trim(), body ?? "");
780
+ await curation.store.apply(name, { kind: "patch", old: current, new: next }, { actor: "user" });
781
+ }
782
+ catch (error) {
783
+ return failure(error);
784
+ }
785
+ deps.bump();
786
+ return {
787
+ ok: true,
788
+ toast: `Saved ${name}`,
789
+ navigate: { type: "navigate", params: { skill: name, root: "learned" } },
790
+ };
791
+ }
792
+ return workspaceWrite(ctx, { ...(isObject(payload) ? payload : {}), name }, { mode: "save" });
793
+ },
794
+ delete: async (ctx, payload) => {
795
+ const targeted = await targetRow(ctx, payload);
796
+ if ("error" in targeted)
797
+ return targeted.error;
798
+ const { user, selection } = targeted;
799
+ const name = selection.skill.name;
800
+ if (selection.skill.root === "learned") {
801
+ const curation = deps.curationOf(user);
802
+ if (curation === undefined)
803
+ return { ok: false, error: { message: "learned skills are unavailable" } };
804
+ try {
805
+ await curation.store.apply(name, { kind: "archive" }, { actor: "user" });
806
+ }
807
+ catch (error) {
808
+ return failure(error);
809
+ }
810
+ deps.bump();
811
+ return { ok: true, toast: `Deleted ${name} (archived)`, navigate: { type: "navigate", params: {} } };
812
+ }
813
+ if (selection.skill.root === "workspace") {
814
+ const workspace = deps.workspaceRoot(user);
815
+ if (workspace === undefined) {
816
+ return { ok: false, error: { message: "no workspace is configured for this user" } };
817
+ }
818
+ try {
819
+ const history = deps.workspaceHistoryFor(user);
820
+ // The mutation runs with or without the repo (GitHistory's
821
+ // fail-soft contract): no history → the delete still lands.
822
+ if (history === undefined) {
823
+ await rm(join(workspace, "skills", name), { recursive: true, force: true });
824
+ }
825
+ else {
826
+ await history.write({
827
+ actor: { kind: "user", user },
828
+ summary: `delete ${name}`,
829
+ action: "remove_file",
830
+ skill: name,
831
+ }, async () => {
832
+ await rm(join(workspace, "skills", name), { recursive: true, force: true });
833
+ }, { kind: "outside" });
834
+ }
835
+ }
836
+ catch (error) {
837
+ return failure(error);
838
+ }
839
+ deps.bump();
840
+ return { ok: true, toast: `Deleted ${name}`, navigate: { type: "navigate", params: {} } };
841
+ }
842
+ return { ok: false, error: { message: `${selection.skill.root} skills are read-only here` } };
843
+ },
844
+ restoreVersion: async (ctx, payload) => {
845
+ const targeted = await targetRow(ctx, payload);
846
+ if ("error" in targeted)
847
+ return targeted.error;
848
+ const { user, selection } = targeted;
849
+ const sha = fieldOf(payload, "sha") ?? selectedParams(ctx).commit;
850
+ if (sha === undefined) {
851
+ return { ok: false, error: { message: "restoreVersion needs a commit (params.commit)" } };
852
+ }
853
+ const name = selection.skill.name;
854
+ if (selection.skill.root === "learned") {
855
+ const curation = deps.curationOf(user);
856
+ if (curation === undefined)
857
+ return { ok: false, error: { message: "learned skills are unavailable" } };
858
+ try {
859
+ await curation.store.restoreVersion(name, sha, { actor: "user" });
860
+ }
861
+ catch (error) {
862
+ return failure(error);
863
+ }
864
+ deps.bump();
865
+ return { ok: true, toast: `Restored ${name} to ${sha.slice(0, 8)}` };
866
+ }
867
+ if (selection.skill.root === "workspace") {
868
+ const history = deps.workspaceHistoryFor(user);
869
+ if (history === undefined) {
870
+ return { ok: false, error: { message: "workspace history is unavailable" } };
871
+ }
872
+ // Whole-branch review I2: the reused-name hole. When the
873
+ // name was deleted and then RE-created, the live skill's
874
+ // history chain includes the old skill's commits (same
875
+ // path), so a restore anchored on a PRE-DELETE commit would
876
+ // silently overwrite the live (reused) skill. Refuse the
877
+ // moment the target predates the newest delete while the
878
+ // live row exists — restoreDeleted's refusal, applied to
879
+ // the reachable path.
880
+ const skills = await listFor(user);
881
+ const live = skills.some((one) => one.name === name && one.root === "workspace");
882
+ if (live) {
883
+ const deletionSha = await newestDeletionSha(history, name);
884
+ if (deletionSha !== undefined) {
885
+ // The name's own history, newest-first: every commit
886
+ // listed AFTER the delete is older than it (the
887
+ // pre-reuse chain — restoring from it would silently
888
+ // overwrite the live, reused skill).
889
+ const pathLog = await history.log({ path: `${name}/`, limit: 10_000 });
890
+ const deleteAt = pathLogIndex(pathLog, deletionSha);
891
+ const targetAt = pathLogIndex(pathLog, sha);
892
+ if (deleteAt !== -1 &&
893
+ (targetAt === -1 || targetAt > deleteAt)) {
894
+ return {
895
+ ok: false,
896
+ error: { message: reusedNameError(name, "workspace") },
897
+ };
898
+ }
899
+ }
900
+ }
901
+ try {
902
+ await history.restore(sha, [name], {
903
+ actor: { kind: "user", user },
904
+ summary: `restore ${name}@${sha.slice(0, 8)}`,
905
+ action: "restore",
906
+ skill: name,
907
+ });
908
+ }
909
+ catch (error) {
910
+ return failure(error);
911
+ }
912
+ deps.bump();
913
+ return { ok: true, toast: `Restored ${name} to ${sha.slice(0, 8)}` };
914
+ }
915
+ return { ok: false, error: { message: `${selection.skill.root} skills are read-only here` } };
916
+ },
917
+ restoreDeleted: async (ctx, payload) => {
918
+ const user = userOf(ctx);
919
+ if (user === undefined)
920
+ return { ok: false, error: { message: "forbidden" } };
921
+ const name = fieldOf(payload, "name") ?? selectedParams(ctx).skill;
922
+ const sha = fieldOf(payload, "sha") ?? selectedParams(ctx).commit;
923
+ if (name === undefined || sha === undefined) {
924
+ return { ok: false, error: { message: "restoreDeleted needs a name and a commit sha" } };
925
+ }
926
+ const skills = await listFor(user);
927
+ // Review Focus 5: a reused name refuses, naming the root that has it.
928
+ const present = presentRootOf(skills, name);
929
+ if (present !== undefined) {
930
+ return {
931
+ ok: false,
932
+ error: { message: reusedNameError(name, present) },
933
+ };
934
+ }
935
+ const root = (fieldOf(payload, "root") ?? selectedParams(ctx).root ?? "workspace");
936
+ if (root === "learned") {
937
+ const curation = deps.curationOf(user);
938
+ if (curation === undefined)
939
+ return { ok: false, error: { message: "learned skills are unavailable" } };
940
+ try {
941
+ // The anchor: the row's sha is the newest commit touching
942
+ // the ARCHIVE path — the archive-move commit itself. The
943
+ // live state (files in skills/ + the state.json row) is
944
+ // its PARENT's tree; restoring it un-archives the name.
945
+ const parent = await curation.history.parentOf(sha);
946
+ if (parent === undefined) {
947
+ return { ok: false, error: { message: "the deletion commit has no parent to restore from" } };
948
+ }
949
+ await curation.store.restoreVersion(name, parent, { actor: "user" });
950
+ }
951
+ catch (error) {
952
+ return failure(error);
953
+ }
954
+ deps.bump();
955
+ return { ok: true, toast: `Restored ${name}`, navigate: { type: "navigate", params: { skill: name, root: "learned" } } };
956
+ }
957
+ const history = deps.workspaceHistoryFor(user);
958
+ if (history === undefined) {
959
+ return { ok: false, error: { message: "workspace history is unavailable" } };
960
+ }
961
+ try {
962
+ const parent = await history.parentOf(sha);
963
+ await history.restore(parent, [name], {
964
+ actor: { kind: "user", user },
965
+ summary: `restore ${name} (was deleted)`,
966
+ action: "restore",
967
+ skill: name,
968
+ });
969
+ }
970
+ catch (error) {
971
+ return failure(error);
972
+ }
973
+ deps.bump();
974
+ return { ok: true, toast: `Restored ${name}`, navigate: { type: "navigate", params: { skill: name, root: "workspace" } } };
975
+ },
976
+ pin: async (ctx, payload) => {
977
+ const targeted = await targetRow(ctx, payload);
978
+ if ("error" in targeted)
979
+ return targeted.error;
980
+ const { user, selection } = targeted;
981
+ if (selection.skill.root !== "learned") {
982
+ return { ok: false, error: { message: "only learned skills pin" } };
983
+ }
984
+ const curation = deps.curationOf(user);
985
+ if (curation === undefined)
986
+ return { ok: false, error: { message: "learned skills are unavailable" } };
987
+ try {
988
+ await curation.store.pin(selection.skill.name, true);
989
+ }
990
+ catch (error) {
991
+ return failure(error);
992
+ }
993
+ deps.bump();
994
+ return { ok: true, toast: `Pinned ${selection.skill.name}` };
995
+ },
996
+ archive: async (ctx, payload) => {
997
+ const targeted = await targetRow(ctx, payload);
998
+ if ("error" in targeted)
999
+ return targeted.error;
1000
+ const { user, selection } = targeted;
1001
+ if (selection.skill.root !== "learned") {
1002
+ return { ok: false, error: { message: "only learned skills archive" } };
1003
+ }
1004
+ const curation = deps.curationOf(user);
1005
+ if (curation === undefined)
1006
+ return { ok: false, error: { message: "learned skills are unavailable" } };
1007
+ try {
1008
+ await curation.store.apply(selection.skill.name, { kind: "archive" }, { actor: "user" });
1009
+ }
1010
+ catch (error) {
1011
+ return failure(error);
1012
+ }
1013
+ deps.bump();
1014
+ return { ok: true, toast: `Archived ${selection.skill.name}` };
1015
+ },
1016
+ overlayPut: async (ctx, payload) => {
1017
+ const targeted = await targetRow(ctx, payload);
1018
+ if ("error" in targeted)
1019
+ return targeted.error;
1020
+ const { user, selection } = targeted;
1021
+ if (selection.skill.root === "learned") {
1022
+ return { ok: false, error: { message: "an overlay never applies to a learned skill" } };
1023
+ }
1024
+ const curation = deps.curationOf(user);
1025
+ if (curation === undefined)
1026
+ return { ok: false, error: { message: "overlays are unavailable" } };
1027
+ const text = fieldOf(payload, "text");
1028
+ if (text === undefined || text.trim() === "") {
1029
+ return { ok: false, error: { message: "overlay text is required", field: "text" } };
1030
+ }
1031
+ const workspace = deps.workspaceRoot(user);
1032
+ const key = {
1033
+ root: selection.skill.root,
1034
+ skill: selection.skill.name,
1035
+ ...(selection.skill.root === "workspace" && workspace !== undefined
1036
+ ? { workspace }
1037
+ : {}),
1038
+ };
1039
+ try {
1040
+ const upstream = await resolveUpstream(key, undefined, BACKGROUND, {
1041
+ userDir: deps.userDir,
1042
+ packageRoots: deps.packageRoots,
1043
+ });
1044
+ if (upstream === undefined) {
1045
+ return { ok: false, error: { message: `the upstream skill is absent: ${selection.skill.name}` } };
1046
+ }
1047
+ await curation.overlays.put(key, text, upstream.hash, { actor: "user" });
1048
+ }
1049
+ catch (error) {
1050
+ return failure(error);
1051
+ }
1052
+ deps.bump();
1053
+ return { ok: true, toast: `Overlay saved for ${selection.skill.name}` };
1054
+ },
1055
+ overlayRemove: async (ctx, payload) => {
1056
+ const targeted = await targetRow(ctx, payload);
1057
+ if ("error" in targeted)
1058
+ return targeted.error;
1059
+ const { user, selection } = targeted;
1060
+ if (selection.skill.root === "learned") {
1061
+ return { ok: false, error: { message: "an overlay never applies to a learned skill" } };
1062
+ }
1063
+ const curation = deps.curationOf(user);
1064
+ if (curation === undefined)
1065
+ return { ok: false, error: { message: "overlays are unavailable" } };
1066
+ const workspace = deps.workspaceRoot(user);
1067
+ const key = {
1068
+ root: selection.skill.root,
1069
+ skill: selection.skill.name,
1070
+ ...(selection.skill.root === "workspace" && workspace !== undefined
1071
+ ? { workspace }
1072
+ : {}),
1073
+ };
1074
+ try {
1075
+ await curation.overlays.remove(key, { actor: "user" });
1076
+ }
1077
+ catch (error) {
1078
+ return failure(error);
1079
+ }
1080
+ deps.bump();
1081
+ return { ok: true, toast: `Overlay removed for ${selection.skill.name}` };
1082
+ },
1083
+ },
1084
+ };
1085
+ }
1086
+ /** The chord background context for overlay resolution (no conversation env). */
1087
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
1088
+ const BACKGROUND = BACKGROUND_CONTEXT;
1089
+ // Re-exported so the server's attach and the tests share the row-id shape.
1090
+ export { rowIdOf };