@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,628 @@
1
+ /**
2
+ * The `core/skills` view's PURE tree builders (tabs+skills Task 12):
3
+ * no I/O, no process state — sidebar rows, the data document, and the
4
+ * page tree as pure functions of their inputs, so the tree suite pins
5
+ * them without a server (and the 3000-commit case stays cheap).
6
+ *
7
+ * The tree is DATA-BOUND (the Task 4 bump contract): every list binds
8
+ * `{$: ...}` against the data document; `bumpAllInstances("core",
9
+ * "skills")` after any write refetches data against an unchanged tree.
10
+ * Per-row drill-in rides the `edit`+`{itemId}` activation (`openSkill`
11
+ * / `openCommit` handlers return the navigate — navigate params are
12
+ * static literals and can never carry the activated row's id; the
13
+ * core/jobs Critical-2 pattern). Actions live ONLY on blocks whose
14
+ * contract carries them: `page`, `callout`, and card templates.
15
+ */
16
+ /** The history page size (the brief's 25-per-page rule). */
17
+ export const SKILLS_HISTORY_PAGE_SIZE = 25;
18
+ /**
19
+ * Sidebar group ids, in display order (the brief's grouping).
20
+ * Whole-branch review I5 — the spec's semantics: the `<configDir>/skills`
21
+ * root groups under **Config dir**; package skills get PER-PACKAGE groups
22
+ * appended after the declared ones (the collection contract tolerates
23
+ * unknown group keys — webui, TUI and the text renderer all title an
24
+ * undeclared group by its key, so the key IS the display string:
25
+ * `· <pkg>`). Workspace / Learned / Deleted are unchanged.
26
+ */
27
+ export const SKILL_GROUPS = [
28
+ "workspace",
29
+ "learned",
30
+ "config-dir",
31
+ "deleted",
32
+ ];
33
+ /** One package skill's per-package group key — `· <pkg>`. Undeclared
34
+ * groups render under their key, so the key doubles as the title. */
35
+ export function packageGroupKey(pkg) {
36
+ return `· ${pkg}`;
37
+ }
38
+ /** The group titles for the DECLARED groups. */
39
+ export const GROUP_TITLES = {
40
+ workspace: "Workspace",
41
+ learned: "Learned",
42
+ "config-dir": "Config dir",
43
+ deleted: "Deleted",
44
+ };
45
+ /**
46
+ * The TOTAL bytes one commit's inlined before+after texts may occupy
47
+ * across ALL files (whole-branch review I6): `changes()` caps each side
48
+ * per file at 64 KiB, but a many-file commit could still blow the 4 MiB
49
+ * view cap and 500 the tab. Beyond this budget the remaining files are
50
+ * replaced by ONE elision marker row.
51
+ */
52
+ export const SKILLS_COMMIT_TOTAL_BYTES = 512 * 1024;
53
+ /** The elision marker text (the tree pins it; the UI shows it). */
54
+ export function elidedMarker(omitted) {
55
+ return `diff truncated — ${omitted} more file${omitted === 1 ? "" : "s"}`;
56
+ }
57
+ /**
58
+ * Cap a commit's files to the total inline budget (review I6): files
59
+ * embed in path order until the running before+after total would pass
60
+ * {@link SKILLS_COMMIT_TOTAL_BYTES}; the rest collapse into one marker
61
+ * row. A single file always embeds (its sides are already per-file
62
+ * capped at 64 KiB) — the budget is checked BETWEEN files, never
63
+ * against the first.
64
+ */
65
+ export function capCommitFiles(files) {
66
+ let total = 0;
67
+ const kept = [];
68
+ for (const file of files) {
69
+ const size = file.before.length + file.after.length;
70
+ if (kept.length > 0 && total + size > SKILLS_COMMIT_TOTAL_BYTES)
71
+ break;
72
+ kept.push(file);
73
+ total += size;
74
+ }
75
+ const omitted = files.length - kept.length;
76
+ if (omitted === 0)
77
+ return kept;
78
+ return [
79
+ ...kept,
80
+ { path: elidedMarker(omitted), before: "", after: "", elided: true },
81
+ ];
82
+ }
83
+ /** A non-empty string, or undefined (bad types drop). */
84
+ function stringOf(value) {
85
+ return typeof value === "string" && value !== "" ? value : undefined;
86
+ }
87
+ /** Parse the instance params (loose JSON → the typed shape). */
88
+ export function skillsParamsOf(params) {
89
+ const out = {};
90
+ const skill = stringOf(params.skill);
91
+ const root = stringOf(params.root);
92
+ const pkg = stringOf(params.pkg);
93
+ const commit = stringOf(params.commit);
94
+ const cursor = stringOf(params.cursor);
95
+ if (skill !== undefined)
96
+ out.skill = skill;
97
+ if (root === "workspace" ||
98
+ root === "learned" ||
99
+ root === "user dir" ||
100
+ root === "package") {
101
+ out.root = root;
102
+ }
103
+ if (pkg !== undefined)
104
+ out.pkg = pkg;
105
+ if (commit !== undefined)
106
+ out.commit = commit;
107
+ if (cursor !== undefined)
108
+ out.cursor = cursor;
109
+ if (params.edit === true)
110
+ out.edit = true;
111
+ return out;
112
+ }
113
+ /** The brief's instance key: principal-scoped, params-folded. */
114
+ export function skillsInstanceKey(params, principal) {
115
+ return JSON.stringify([principal.user ?? "", params]);
116
+ }
117
+ /** The row id an activated sidebar card reports as `{itemId}` (stable, parseable). */
118
+ export function rowIdOf(root, name, pkg) {
119
+ return `${root}:${name}${pkg === undefined ? "" : `:${pkg}`}`;
120
+ }
121
+ /** Parse a sidebar row id back into its root/name/pkg (undefined on a foreign shape). */
122
+ export function parseRowId(id) {
123
+ const root = id.startsWith("workspace:")
124
+ ? "workspace"
125
+ : id.startsWith("learned:")
126
+ ? "learned"
127
+ : id.startsWith("user dir:")
128
+ ? "user dir"
129
+ : id.startsWith("package:")
130
+ ? "package"
131
+ : undefined;
132
+ if (root === undefined)
133
+ return undefined;
134
+ const rest = id.slice(root.length + 1);
135
+ if (rest === "")
136
+ return undefined;
137
+ if (root !== "package")
138
+ return { root, name: rest };
139
+ const at = rest.lastIndexOf(":");
140
+ if (at <= 0)
141
+ return undefined;
142
+ const pkg = rest.slice(at + 1);
143
+ const name = rest.slice(0, at);
144
+ return pkg === "" || name === "" ? undefined : { root, name, pkg };
145
+ }
146
+ /** One skill's sidebar group (a per-package id for package skills). */
147
+ export function groupOfRow(skill, deleted) {
148
+ if (deleted)
149
+ return "deleted";
150
+ switch (skill.root) {
151
+ case "workspace":
152
+ return "workspace";
153
+ case "learned":
154
+ return "learned";
155
+ // Review I5: the spec's semantics — `<configDir>/skills` IS the
156
+ // Config dir group; a package skill groups under its own package.
157
+ case "user dir":
158
+ return "config-dir";
159
+ case "package":
160
+ return packageGroupKey(skill.packageName ?? "unknown");
161
+ }
162
+ }
163
+ /** The data rows the sidebar collection binds (grouped by {@link groupOfRow}). */
164
+ export function sidebarRows(skills, opts) {
165
+ const deleted = opts?.deleted ?? new Set();
166
+ return skills.map((skill) => ({
167
+ id: rowIdOf(skill.root, skill.name, skill.packageName),
168
+ name: skill.name,
169
+ description: skill.description,
170
+ group: groupOfRow(skill, deleted.has(skill.name)),
171
+ ...(skill.packageName === undefined ? {} : { pkg: skill.packageName }),
172
+ ...(skill.shadowedBy === undefined
173
+ ? {}
174
+ : { shadowedBy: `shadowed by ${skill.shadowedBy}` }),
175
+ ...(skill.hidden ? { hidden: true } : {}),
176
+ ...(skill.error === undefined ? {} : { error: skill.error }),
177
+ }));
178
+ }
179
+ /** The ordered `groups` declaration. The DECLARED groups order the
180
+ * sidebar; per-package groups (`· <pkg>`) are undeclared and append
181
+ * after them, rendered under their key (the collection contract
182
+ * tolerates unknown group keys). */
183
+ function sidebarGroups() {
184
+ return [
185
+ { id: "workspace", title: "Workspace" },
186
+ { id: "learned", title: "Learned" },
187
+ { id: "config-dir", title: "Config dir" },
188
+ { id: "deleted", title: "Deleted" },
189
+ ];
190
+ }
191
+ /** One history row (curator commits flag the review navigation). */
192
+ export function historyRowOf(commit) {
193
+ const curator = commit.actor.kind === "curator" || commit.reviewId !== undefined;
194
+ return {
195
+ id: commit.sha,
196
+ sha: commit.sha,
197
+ at: commit.at,
198
+ summary: commit.summary,
199
+ author: commit.actor.kind === "user" ? commit.actor.user : commit.actor.kind,
200
+ ...(curator ? { curator: true } : { curator: false }),
201
+ ...(commit.reviewId === undefined ? {} : { reviewId: commit.reviewId }),
202
+ };
203
+ }
204
+ /** The history page for the data document (the brief's 25-per-page + cursor). */
205
+ export function historyPage(commits) {
206
+ const page = commits.slice(0, SKILLS_HISTORY_PAGE_SIZE).map(historyRowOf);
207
+ const last = page[page.length - 1];
208
+ return {
209
+ items: page,
210
+ hasMore: commits.length > SKILLS_HISTORY_PAGE_SIZE,
211
+ nextCursor: last === undefined ? null : last.sha,
212
+ };
213
+ }
214
+ /** Whether the caller can write the selected root (the view's surface access). */
215
+ export function canWrite(selection, historyAvailable) {
216
+ if (selection === undefined)
217
+ return true;
218
+ if (selection.root === "workspace")
219
+ return historyAvailable;
220
+ return selection.root === "learned";
221
+ }
222
+ /** One history card's action buttons (per-row literals — navigate params cannot bind). */
223
+ function historyRowButtons(rows) {
224
+ const out = [];
225
+ for (const row of rows) {
226
+ if (row.curator && row.reviewId !== undefined) {
227
+ out.push({
228
+ id: `curator-${row.sha}`,
229
+ label: "Review",
230
+ icon: "link",
231
+ action: {
232
+ type: "navigate",
233
+ target: {
234
+ view: { extension: "curator", id: "curator", params: { review: row.reviewId } },
235
+ },
236
+ },
237
+ });
238
+ }
239
+ }
240
+ return out;
241
+ }
242
+ /** One selected-skill callout's action buttons (per-page literals; handlers resolve params). */
243
+ function skillActions(params, historyAvailable, deleted) {
244
+ // A deleted selection (whole-branch review I2): NO edit/delete/restore
245
+ // — the dir is gone (Edit's save would error "skill not found") — only
246
+ // the restore-deleted affordance, anchored on the selection's commit.
247
+ if (deleted) {
248
+ if (params.commit === undefined || !historyAvailable)
249
+ return [];
250
+ return [
251
+ {
252
+ id: "skill-restore-deleted",
253
+ label: "Restore deleted",
254
+ icon: "refresh",
255
+ action: {
256
+ type: "submit",
257
+ name: "restoreDeleted",
258
+ confirm: {
259
+ title: "Restore this deleted skill?",
260
+ body: "Bring the skill back exactly as it was before the deletion commit.",
261
+ },
262
+ },
263
+ },
264
+ ];
265
+ }
266
+ const nav = (extra) => {
267
+ const navParams = {};
268
+ if (params.skill !== undefined)
269
+ navParams.skill = params.skill;
270
+ if (params.root !== undefined)
271
+ navParams.root = params.root;
272
+ if (params.pkg !== undefined)
273
+ navParams.pkg = params.pkg;
274
+ for (const [key, value] of Object.entries(extra)) {
275
+ if (value === true || typeof value === "string")
276
+ navParams[key] = value;
277
+ }
278
+ return { type: "navigate", params: navParams };
279
+ };
280
+ // Read-only roots (user dir / package) are overlay-only: no edit,
281
+ // no delete, no restore — the caller never owns those files.
282
+ if (params.root === "user dir" || params.root === "package") {
283
+ return [
284
+ {
285
+ id: "skill-overlay",
286
+ label: "Write an overlay",
287
+ icon: "edit",
288
+ action: { type: "submit", name: "overlayPut" },
289
+ },
290
+ {
291
+ id: "skill-overlay-remove",
292
+ label: "Remove the overlay",
293
+ icon: "delete",
294
+ action: {
295
+ type: "submit",
296
+ name: "overlayRemove",
297
+ confirm: { title: "Remove the overlay?", body: "Remove your note from this skill? The skill itself is untouched." },
298
+ },
299
+ },
300
+ ];
301
+ }
302
+ const buttons = [
303
+ {
304
+ id: "skill-edit",
305
+ label: "Edit",
306
+ icon: "edit",
307
+ action: nav({ edit: true }),
308
+ },
309
+ {
310
+ id: "skill-delete",
311
+ label: "Delete",
312
+ icon: "delete",
313
+ action: {
314
+ type: "submit",
315
+ name: "delete",
316
+ confirm: {
317
+ title: "Delete skill?",
318
+ body: "Delete this skill? Its history keeps every version.",
319
+ tone: "bad",
320
+ },
321
+ },
322
+ },
323
+ ];
324
+ if (params.root === "learned") {
325
+ buttons.push({
326
+ id: "skill-pin",
327
+ label: "Pin",
328
+ icon: "tag",
329
+ action: { type: "submit", name: "pin" },
330
+ });
331
+ }
332
+ if (params.commit !== undefined && params.root !== undefined && historyAvailable) {
333
+ buttons.push({
334
+ id: "skill-restore",
335
+ label: "Restore this version",
336
+ icon: "refresh",
337
+ action: {
338
+ type: "submit",
339
+ name: "restoreVersion",
340
+ confirm: {
341
+ title: "Restore this version?",
342
+ body: "Make the skill exactly as it was at this commit (a new commit records the restore).",
343
+ },
344
+ },
345
+ });
346
+ }
347
+ return buttons;
348
+ }
349
+ /** The form fields (new skill vs edit: the name is read-only on an existing skill). */
350
+ function formFields(editingExisting) {
351
+ const whereChoice = {
352
+ type: "choice",
353
+ name: "where",
354
+ label: "Where",
355
+ required: true,
356
+ options: [
357
+ { id: "workspace", label: "Workspace" },
358
+ { id: "learned", label: "Learned" },
359
+ ],
360
+ };
361
+ return [
362
+ ...(editingExisting ? [] : [whereChoice]),
363
+ {
364
+ type: "text",
365
+ name: "name",
366
+ label: "Name",
367
+ required: true,
368
+ pattern: "^[a-z0-9][a-z0-9-]{0,62}$",
369
+ help: "lowercase letters, digits, dashes",
370
+ ...(editingExisting ? { disabled: true } : {}),
371
+ },
372
+ {
373
+ type: "text",
374
+ name: "description",
375
+ label: "Description",
376
+ required: true,
377
+ },
378
+ {
379
+ type: "longtext",
380
+ name: "body",
381
+ label: "Body",
382
+ syntax: "markdown",
383
+ rows: 16,
384
+ },
385
+ ];
386
+ }
387
+ /** The anonymous/none-access tree (an empty page; the route 404s it). */
388
+ export function skillsNoneTree() {
389
+ // SAFETY: the tree object is a JSON literal built here; `Tree`'s
390
+ // interfaces lack the index signature the wire type requires, so the
391
+ // cast only restores that erased identity.
392
+ return {
393
+ v: 1,
394
+ root: {
395
+ type: "page",
396
+ id: "skills-root",
397
+ title: "Skills",
398
+ main: [{ type: "markdown", text: "Sign in to see skills." }],
399
+ },
400
+ };
401
+ }
402
+ /**
403
+ * The full tree for one render: the sidebar collection (bound,
404
+ * grouped), the main column for the selection state (list / edit form /
405
+ * selected skill with history + diffs), and the compact-drill-in
406
+ * `selection` contract (Task 3). Blocks are built as loose JSON
407
+ * objects; the service validates every tree before serving.
408
+ */
409
+ export function buildSkillsTree(params, main,
410
+ // The form VALUES ride the data document (bind "/form"); the tree
411
+ // declares the fields only — the parameter pins the signature the
412
+ // render path fills (pre-filled edit values land in data, not the tree).
413
+ _form, opts) {
414
+ const editing = params.edit === true && (params.skill === undefined || opts.selectedExists);
415
+ const editingExisting = editing && params.skill !== undefined;
416
+ const selected = !editing && params.skill !== undefined && main !== undefined;
417
+ const sidebar = [
418
+ {
419
+ type: "collection",
420
+ id: "skills",
421
+ display: "list",
422
+ items: { $: "/skills" },
423
+ groupBy: "group",
424
+ groups: sidebarGroups(),
425
+ // Per-row drill-in (the core/jobs Critical-2 pattern): the
426
+ // `edit` activation carries {itemId} (the row id) to `openSkill`,
427
+ // whose accepted result carries the navigate back.
428
+ onActivate: { type: "edit", name: "openSkill" },
429
+ item: {
430
+ title: { $: "./name" },
431
+ subtitle: { $: "./description" },
432
+ body: { $: "./error" },
433
+ badges: [
434
+ { text: { $: "./pkg" }, tone: "neutral" },
435
+ { text: { $: "./shadowedBy" }, tone: "neutral" },
436
+ { text: { $: "./hidden" }, tone: "neutral" },
437
+ ],
438
+ },
439
+ },
440
+ ];
441
+ const mainBlocks = [];
442
+ if (editing) {
443
+ mainBlocks.push({
444
+ type: "form",
445
+ id: "skill-form",
446
+ bind: "/form",
447
+ mode: "submit",
448
+ onSubmit: editingExisting ? "save" : "create",
449
+ submitLabel: editingExisting ? "Save" : "Create",
450
+ children: formFields(editingExisting),
451
+ });
452
+ }
453
+ else if (selected && main !== undefined) {
454
+ const historyNav = {};
455
+ if (params.skill !== undefined)
456
+ historyNav.skill = params.skill;
457
+ if (params.root !== undefined)
458
+ historyNav.root = params.root;
459
+ if (params.pkg !== undefined)
460
+ historyNav.pkg = params.pkg;
461
+ mainBlocks.push({
462
+ type: "section",
463
+ id: "skill-summary",
464
+ title: { $: "/main/name" },
465
+ children: [
466
+ {
467
+ type: "fields",
468
+ rows: [
469
+ { label: "Root", value: { $: "/main/root" } },
470
+ { label: "Description", value: { $: "/main/description" } },
471
+ { label: "Path", value: { $: "/main/rootDir" } },
472
+ ],
473
+ },
474
+ ],
475
+ }, ...(opts.writable
476
+ ? [
477
+ {
478
+ type: "callout",
479
+ id: "skill-actions",
480
+ tone: "neutral",
481
+ title: "Actions",
482
+ actions: skillActions(params, opts.historyAvailable, opts.deleted === true),
483
+ },
484
+ ]
485
+ : []), {
486
+ type: "section",
487
+ id: "skill-body",
488
+ title: "SKILL.md",
489
+ children: [{ type: "markdown", text: { $: "/main/body" } }],
490
+ }, ...(main.overlay === null
491
+ ? []
492
+ : [
493
+ {
494
+ type: "section",
495
+ id: "skill-overlay",
496
+ title: "Your notes",
497
+ children: [{ type: "markdown", text: { $: "/main/overlay" } }],
498
+ },
499
+ ]), {
500
+ type: "section",
501
+ id: "skill-files",
502
+ title: "Support files",
503
+ children: [
504
+ {
505
+ type: "collection",
506
+ id: "support-files",
507
+ display: "list",
508
+ items: { $: "/main/supportFiles" },
509
+ item: { title: { $: "./" } },
510
+ },
511
+ ],
512
+ });
513
+ if (main.history.available) {
514
+ const rows = main.history.items;
515
+ const curatorButtons = historyRowButtons(rows);
516
+ mainBlocks.push({
517
+ type: "section",
518
+ id: "skill-history",
519
+ title: "History",
520
+ children: [
521
+ {
522
+ type: "collection",
523
+ id: "history",
524
+ display: "list",
525
+ items: { $: "/main/history/items" },
526
+ onActivate: { type: "edit", name: "openCommit" },
527
+ item: {
528
+ title: { $: "./summary" },
529
+ subtitle: { $: "./author" },
530
+ meta: [{ time: { $: "./at" }, format: "relative" }],
531
+ badges: [{ text: { $: "./curator" }, tone: "neutral" }],
532
+ ...(curatorButtons.length === 0 ? {} : { actions: curatorButtons }),
533
+ },
534
+ },
535
+ ],
536
+ });
537
+ if (main.history.hasMore && main.history.nextCursor !== null) {
538
+ mainBlocks.push({
539
+ type: "callout",
540
+ id: "history-more",
541
+ tone: "neutral",
542
+ title: "More history",
543
+ actions: [
544
+ {
545
+ id: "history-more-load",
546
+ label: "Load more",
547
+ icon: "more",
548
+ action: {
549
+ type: "navigate",
550
+ params: { ...historyNav, cursor: main.history.nextCursor },
551
+ },
552
+ },
553
+ ],
554
+ });
555
+ }
556
+ }
557
+ else {
558
+ mainBlocks.push({
559
+ type: "callout",
560
+ id: "history-unavailable",
561
+ tone: "neutral",
562
+ title: "History unavailable",
563
+ body: { $: "/main/history/reason" },
564
+ });
565
+ }
566
+ // The selected commit: one `diff` block per changed file. The
567
+ // elision marker row (review I6's total-bytes cap) renders as a
568
+ // markdown note, not a diff.
569
+ if (main.commit !== null) {
570
+ const files = main.commit.files.map((file, index) => file.elided === true
571
+ ? { type: "markdown", id: `commit-elided-${index}`, text: `_${file.path}_` }
572
+ : {
573
+ type: "diff",
574
+ id: `commit-diff-${index}`,
575
+ before: file.before,
576
+ after: file.after,
577
+ lang: "markdown",
578
+ });
579
+ mainBlocks.push({
580
+ type: "section",
581
+ id: "commit-section",
582
+ title: `Commit ${main.commit.sha.slice(0, 8)}`,
583
+ children: files.length === 0
584
+ ? [{ type: "markdown", text: "No changes to this skill in this commit." }]
585
+ : files,
586
+ });
587
+ }
588
+ }
589
+ else {
590
+ mainBlocks.push({
591
+ type: "section",
592
+ id: "skill-empty",
593
+ children: [{ type: "markdown", text: "Select a skill" }],
594
+ });
595
+ }
596
+ // SAFETY: the tree object is a JSON literal built here; `Tree`'s
597
+ // interfaces lack the index signature the wire type requires, so the
598
+ // cast only restores that erased identity.
599
+ return {
600
+ v: 1,
601
+ root: {
602
+ type: "page",
603
+ id: "skills-root",
604
+ title: "Skills",
605
+ ...(opts.writable
606
+ ? {
607
+ actions: [
608
+ {
609
+ id: "skill-new",
610
+ label: "New skill",
611
+ icon: "add",
612
+ action: { type: "navigate", params: { edit: true } },
613
+ },
614
+ ],
615
+ }
616
+ : {}),
617
+ sidebar,
618
+ main: mainBlocks,
619
+ selection: {
620
+ selected: editing || (params.skill !== undefined && opts.selectedExists),
621
+ back: {},
622
+ },
623
+ ...(params.skill !== undefined && !editing && !opts.selectedExists
624
+ ? { empty: { icon: "info", title: "Skill not found" } }
625
+ : {}),
626
+ },
627
+ };
628
+ }