@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,96 @@
1
+ /**
2
+ * Curation history migration (tabs+skills plan, Task 9): a per-user
3
+ * one-shot import of the pre-git curation store into the user's git
4
+ * history repo.
5
+ *
6
+ * Runs at boot for every directory user AND lazily the first time
7
+ * `curationOf` constructs a user's stores (users created after boot).
8
+ * The marker `curation/history/MIGRATED` is written LAST: without it,
9
+ * any partial `user.git` is discarded and the import is redone. The
10
+ * legacy artifacts (`feed.jsonl`, `versions/`) are never touched — they
11
+ * stay read-only on disk and outside the repo's pathspec.
12
+ *
13
+ * The discard + import run inside `GitHistory.freshImport` (the
14
+ * per-repo lock): a store write concurrent with the migration either
15
+ * lands before the discard (its commit is discarded and its content is
16
+ * re-imported from disk — nothing lost) or after the import (its own
17
+ * commit on top). The unlocked rm of an earlier revision could delete
18
+ * a repo a concurrent write had just committed (review I3).
19
+ *
20
+ * A "nothing" result ALSO writes the marker (whole-branch review C1):
21
+ * without it, an empty user's first write commits fine, but the NEXT
22
+ * boot's sweep sees a non-empty pathspec with no marker and freshImport
23
+ * would discard that entire first-session history. The marker's "nothing
24
+ * was imported" form is the same file; marker-last ordering is trivially
25
+ * preserved (nothing precedes it).
26
+ *
27
+ * The residual failure window: a crash AFTER freshImport's commit but
28
+ * BEFORE the marker write leaves an unmarked repo holding real import
29
+ * commits — the next boot discards it and re-imports from disk (the
30
+ * content is safe), but any feed entry / undo anchor / review link that
31
+ * already recorded the discarded commits' shas dangles (their trailers
32
+ * are lost with the discarded commits). The window is one writeFile
33
+ * wide; closing it would need the marker inside the commit itself.
34
+ */
35
+ import { access, mkdir, writeFile } from "node:fs/promises";
36
+ import { join } from "node:path";
37
+ import { historyDir, userRepoSpec } from "./paths.js";
38
+ /**
39
+ * Import the user's current curation store into the history repo.
40
+ * "already" when the marker exists; "nothing" when the user has no
41
+ * tracked content at all (the marker IS written — review C1: an
42
+ * unmarked empty user loses their first write's history on the next
43
+ * boot's sweep); "migrated" otherwise.
44
+ *
45
+ * A `user.git` without the marker on a repo this call did not just
46
+ * build is a partial migration: it is discarded and the import runs
47
+ * fresh.
48
+ */
49
+ export async function migrateUserCuration(userArea, history) {
50
+ const marker = join(historyDir(userArea), "MIGRATED");
51
+ if (await present(marker))
52
+ return "already";
53
+ if (!(await pathspecContent(userArea))) {
54
+ // Nothing to import — but the marker must exist anyway: an
55
+ // unmarked user's first write commits into a repo the next boot
56
+ // would treat as a partial migration and discard (review C1).
57
+ await mkdir(historyDir(userArea), { recursive: true });
58
+ await writeFile(marker, `${new Date().toISOString()}\n`);
59
+ return "nothing";
60
+ }
61
+ // The discard + import are ONE locked operation (GitHistory.
62
+ // freshImport): a concurrent store write either lands before the
63
+ // discard (its content is re-imported from disk — nothing lost) or
64
+ // after the import (its own commit on top). The work tree must exist
65
+ // before the git dir can init inside it.
66
+ await mkdir(historyDir(userArea), { recursive: true });
67
+ const sha = await history.freshImport({
68
+ actor: { kind: "outside" },
69
+ summary: "Imported from the curation store",
70
+ });
71
+ if (sha === undefined) {
72
+ // Git failed (fail-soft): no commit, no marker — the next boot
73
+ // retries the whole import.
74
+ return "nothing";
75
+ }
76
+ await mkdir(historyDir(userArea), { recursive: true });
77
+ await writeFile(marker, `${new Date().toISOString()}\n`);
78
+ return "migrated";
79
+ }
80
+ /** Whether any pathspec entry exists in the work tree. */
81
+ async function pathspecContent(userArea) {
82
+ for (const entry of userRepoSpec(userArea).pathspec) {
83
+ if (await present(join(userArea, entry)))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+ async function present(path) {
89
+ try {
90
+ await access(path);
91
+ return true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
@@ -17,17 +17,25 @@
17
17
  * {@link OverlayStore.markOrphans} (present again clears the mark), and
18
18
  * removable.
19
19
  *
20
- * Every overlay change is recorded in the SAME per-user change feed the
21
- * {@link SkillStore} owns (A4): an entry with action `"overlay"` (the
22
- * union member exists for exactly this), appended through the store's
23
- * feed seam so retention and the per-user lock hold. The feed is the
24
- * audit record and the P8 view's data; `OverlayStore` owns the files.
20
+ * Every overlay change commits to the user's curation history repo
21
+ * (tabs+skills Task 9): one commit per put/remove with the Sigrun-*
22
+ * trailers, so overlay changes appear in the same feed as learned-skill
23
+ * changes. `OverlayStore` owns the files.
25
24
  */
26
25
  import { randomUUID, createHash } from "node:crypto";
27
26
  import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
28
27
  import { join } from "node:path";
29
28
  import { curationDir, learnedSkillsDir } from "./paths.js";
30
29
  import { validSkillName } from "./names.js";
30
+ /** The Sigrun actor shape for a history commit. */
31
+ function historyActor(actor, user) {
32
+ switch (actor) {
33
+ case "user": return { kind: "user", user };
34
+ case "curator": return { kind: "curator" };
35
+ case "agent": return { kind: "agent" };
36
+ case "outside": return { kind: "outside" };
37
+ }
38
+ }
31
39
  /**
32
40
  * The heading the served overlay follows: upstream body, then this
33
41
  * heading, then the overlay text (one blank line each side of the
@@ -123,23 +131,52 @@ export class OverlayStore {
123
131
  */
124
132
  async put(key, text, upstreamHash, meta) {
125
133
  this.requireUser();
134
+ await this.options.ready?.();
126
135
  checkKey(key);
127
136
  if (await exists(join(learnedSkillsDir(this.options.userArea), key.skill))) {
128
137
  throw new Error(`an overlay never applies to a learned skill: ${key.skill}`);
129
138
  }
139
+ // The repo's work tree must exist before the git dir can init.
140
+ await mkdir(curationDir(this.options.userArea), { recursive: true });
130
141
  const overlay = { key, text, upstreamHash };
131
- await writeFileAtomic(this.fileOf(key), `${JSON.stringify(overlay, null, 2)}\n`);
132
- return this.appendFeed(key, meta, meta.reason ?? "overlay written");
142
+ const summary = `overlay ${key.root}:${key.skill}`;
143
+ const sha = await this.options.history.write({
144
+ actor: historyActor(meta.actor, this.options.user),
145
+ summary,
146
+ action: "overlay",
147
+ skill: key.skill,
148
+ ...(meta.reason === undefined ? {} : { reason: meta.reason }),
149
+ ...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
150
+ ...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
151
+ }, async () => {
152
+ this.requireUser();
153
+ await writeFileAtomic(this.fileOf(key), `${JSON.stringify(overlay, null, 2)}\n`);
154
+ }, { kind: "outside" });
155
+ return this.entry(key, sha, meta, summary);
133
156
  }
134
157
  /** Remove the overlay for a key; returns the feed entry. Throws when none is stored. */
135
158
  async remove(key, meta) {
136
159
  this.requireUser();
160
+ await this.options.ready?.();
137
161
  checkKey(key);
138
162
  const file = this.fileOf(key);
139
163
  if (!(await exists(file)))
140
164
  throw new Error(`unknown overlay: ${keyString(key)}`);
141
- await rm(file);
142
- return this.appendFeed(key, meta, meta.reason ?? "overlay removed");
165
+ await mkdir(curationDir(this.options.userArea), { recursive: true });
166
+ const summary = `overlay ${key.root}:${key.skill}`;
167
+ const sha = await this.options.history.write({
168
+ actor: historyActor(meta.actor, this.options.user),
169
+ summary,
170
+ action: "overlay",
171
+ skill: key.skill,
172
+ ...(meta.reason === undefined ? {} : { reason: meta.reason }),
173
+ ...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
174
+ ...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
175
+ }, async () => {
176
+ this.requireUser();
177
+ await rm(file);
178
+ }, { kind: "outside" });
179
+ return this.entry(key, sha, meta, summary);
143
180
  }
144
181
  /** Every stored overlay (present upstreams and orphans alike), sorted by root then name. */
145
182
  async list() {
@@ -215,20 +252,19 @@ export class OverlayStore {
215
252
  ...(typeof value.orphanedSince === "string" ? { orphanedSince: value.orphanedSince } : {}),
216
253
  };
217
254
  }
218
- /** Record one overlay change in the skill store's feed (the `"overlay"` action). */
219
- async appendFeed(key, meta, summary) {
220
- const entry = {
221
- id: randomUUID(),
255
+ /** Record one overlay change in the user repo as an `"overlay"` entry. */
256
+ entry(key, sha, meta, summary) {
257
+ return {
258
+ id: sha ?? `(no-commit-${randomUUID()})`,
222
259
  at: new Date().toISOString(),
223
260
  actor: meta.actor,
224
261
  action: "overlay",
225
262
  skill: key.skill,
226
- reason: summary,
263
+ reason: meta.reason ?? summary,
227
264
  ...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }),
228
265
  ...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }),
266
+ ...(sha === undefined ? {} : { after: sha }),
229
267
  };
230
- await this.options.feed.appendFeedEntry(entry);
231
- return entry;
232
268
  }
233
269
  }
234
270
  /**
@@ -15,3 +15,25 @@ export function learnedSkillsDir(userArea) {
15
15
  export function curationDir(userArea) {
16
16
  return join(userArea, "curation");
17
17
  }
18
+ /**
19
+ * The owner's curation history root: `<userArea>/curation/history`
20
+ * (tabs+skills Task 9). Holds `user.git` (the per-user curation repo),
21
+ * later `workspace.git` (Task 10), and the `MIGRATED` marker. A plain
22
+ * directory, never a git work tree.
23
+ */
24
+ export function historyDir(userArea) {
25
+ return join(userArea, "curation", "history");
26
+ }
27
+ /**
28
+ * The ONE per-user curation repo (tabs+skills Task 9): skills,
29
+ * archive, overlays and state in one history, with `userArea` as the
30
+ * work tree. `feed.jsonl` and `versions/` are deliberately OUTSIDE the
31
+ * pathspec — read-only legacy after migration.
32
+ */
33
+ export function userRepoSpec(userArea) {
34
+ return {
35
+ gitDir: join(historyDir(userArea), "user.git"),
36
+ workTree: userArea,
37
+ pathspec: ["skills", "curation/archive", "curation/overlays", "curation/state.json"],
38
+ };
39
+ }
@@ -433,28 +433,45 @@ export async function driveRun(deps, prepare, opts, parent) {
433
433
  }
434
434
  }
435
435
  /** Collect the run's `details.curation` changes, in entry order. */
436
+ /** Scan bound for collectChanges' cursor walk (a broken cursor loops, a count does not). */
437
+ const COLLECT_SCAN_LIMIT = 10_000;
436
438
  async function collectChanges(conversation) {
437
- const page = await conversation.entries({ order: "ascending" }, 500, undefined, BACKGROUND_CONTEXT);
438
439
  const changes = [];
439
- for (const entry of page.items) {
440
- if (entry.kind !== ToolResultEntry.kind)
441
- continue;
442
- for (const message of entry.model ?? []) {
443
- if (message.role !== "toolResult")
444
- continue;
445
- const details = message.details;
446
- const curation = details?.curation;
447
- if (curation === undefined ||
448
- typeof curation.changeId !== "string" ||
449
- typeof curation.summary !== "string") {
440
+ // Cursor-complete walk (the A9 ruling): a run with more tool results
441
+ // than one page loses nothing — pages until exhaustion, bounded so
442
+ // a cursor bug cannot loop forever.
443
+ let cursor;
444
+ let scanned = 0;
445
+ for (;;) {
446
+ const page = await conversation.entries({ order: "ascending" }, 500, cursor, BACKGROUND_CONTEXT);
447
+ for (const entry of page.items) {
448
+ scanned += 1;
449
+ if (entry.kind !== ToolResultEntry.kind)
450
450
  continue;
451
+ for (const message of entry.model ?? []) {
452
+ if (message.role !== "toolResult")
453
+ continue;
454
+ const details = message.details;
455
+ const curation = details?.curation;
456
+ if (curation === undefined ||
457
+ typeof curation.changeId !== "string" ||
458
+ typeof curation.summary !== "string") {
459
+ continue;
460
+ }
461
+ changes.push({
462
+ changeId: curation.changeId,
463
+ summary: curation.summary,
464
+ target: targetOfTool(message.toolName),
465
+ });
451
466
  }
452
- changes.push({
453
- changeId: curation.changeId,
454
- summary: curation.summary,
455
- target: targetOfTool(message.toolName),
456
- });
457
467
  }
468
+ if (page.next === undefined || page.items.length === 0)
469
+ break;
470
+ if (scanned >= COLLECT_SCAN_LIMIT) {
471
+ console.warn(`curation: collectChanges hit the ${COLLECT_SCAN_LIMIT}-entry scan guard — the run's change list is a PREFIX (later changes are unreported)`);
472
+ break;
473
+ }
474
+ cursor = page.next;
458
475
  }
459
476
  return changes;
460
477
  }
@@ -29,6 +29,7 @@ import { Type } from "@earendil-works/pi-ai";
29
29
  import { defineTool } from "@earendil-works/pi-durable";
30
30
  import { OVERLAY_HEADING, withOverlay, } from "./overlays.js";
31
31
  import { curationRun, markViewed } from "./run-doc.js";
32
+ import { SKILL_MANAGE_READ_ACTIONS } from "./targets.js";
32
33
  import { SessionMetaDoc } from "../server/session-meta.js";
33
34
  /** The tool's parameter union (the brief's exact shape). */
34
35
  const skillManageParameters = Type.Object({
@@ -111,7 +112,10 @@ async function viewUpstreamOnly(deps, stores, args, env, ctx) {
111
112
  };
112
113
  const upstream = await deps.upstreamOf(key, env, ctx).catch(() => undefined);
113
114
  if (stored === undefined && upstream === undefined) {
114
- return `unknown skill "${args.name}" — no learned copy, no stored overlay, and no upstream resolved in the ${key.root} root.`;
115
+ return {
116
+ found: false,
117
+ message: `unknown skill "${args.name}" — no learned copy, no stored overlay, and no upstream resolved in the ${key.root} root.`,
118
+ };
115
119
  }
116
120
  const notes = [
117
121
  `(no learned copy of "${args.name}"; showing the ${key.root} upstream${stored === undefined ? "" : " with the stored overlay"})`,
@@ -129,7 +133,7 @@ async function viewUpstreamOnly(deps, stores, args, env, ctx) {
129
133
  else {
130
134
  body = withOverlay(upstream.body, stored.text);
131
135
  }
132
- return `${notes.join(" ")}\n\n${body}`;
136
+ return { found: true, body: `${notes.join(" ")}\n\n${body}` };
133
137
  }
134
138
  /** Render the store's `list` result as the model-facing text. */
135
139
  function renderList(skills) {
@@ -155,7 +159,7 @@ export function buildSkillManageTool(deps) {
155
159
  // Ownerless conversations: nothing to read (and no user to write
156
160
  // for — the mutating refusal below covers those too, but the read
157
161
  // answer is the one the brief pins).
158
- if (args.action === "list" || args.action === "view") {
162
+ if (SKILL_MANAGE_READ_ACTIONS.has(args.action)) {
159
163
  if (owner === undefined) {
160
164
  return error("This conversation has no owner, so there are no learned skills to inspect.");
161
165
  }
@@ -169,18 +173,29 @@ export function buildSkillManageTool(deps) {
169
173
  }
170
174
  if (args.name === undefined)
171
175
  return error("view requires a skill name.");
172
- const learned = await stores.store
173
- .view(args.name, args.file_path ?? "SKILL.md")
174
- .then((body) => body, () => undefined);
176
+ // The learned read: ENOENT (no learned copy) falls through to
177
+ // the upstream/overlay serving below; ANY OTHER store failure
178
+ // (a permissions error, a corrupt root) must surface, never
179
+ // fail open into a phantom "unknown skill".
180
+ let learned;
181
+ try {
182
+ learned = await stores.store.view(args.name, args.file_path ?? "SKILL.md");
183
+ }
184
+ catch (cause) {
185
+ if (cause?.code !== "ENOENT") {
186
+ throw cause;
187
+ }
188
+ learned = undefined;
189
+ }
175
190
  if (learned === undefined) {
176
191
  // Upstream-only names (the overlay lifecycle): serve the stored
177
192
  // overlay and/or the live upstream — without this fallback the
178
193
  // learned-root ENOENT traps an overlay update/remove in an
179
194
  // unrecoverable "view X first" → "unknown skill" loop.
180
195
  const served = await viewUpstreamOnly(deps, stores, { name: args.name, overlay_root: args.overlay_root }, api.env, context);
181
- if (served.startsWith("unknown skill"))
182
- return error(served);
183
- api.output(served);
196
+ if (!served.found)
197
+ return error(served.message);
198
+ api.output(served.body);
184
199
  }
185
200
  else {
186
201
  api.output(learned);
@@ -223,6 +238,16 @@ export function buildSkillManageTool(deps) {
223
238
  conversationId: api.conversationId,
224
239
  ...(args.reason === undefined ? {} : { reason: args.reason }),
225
240
  };
241
+ // Empty content: a create/write_file carrying no text records a
242
+ // phantom empty entry (the cleanup-pass C1). Refuse UP FRONT, at
243
+ // the tool boundary, naming the empty content — the store's
244
+ // frontmatter check would otherwise answer a create with the
245
+ // misleading "frontmatter is required", and a write_file would
246
+ // silently land an empty support file plus its feed entry.
247
+ if ((args.action === "create" || args.action === "write_file") &&
248
+ (args.content ?? "") === "") {
249
+ return error(`${args.action} ${args.name}: empty content — nothing to write.`);
250
+ }
226
251
  // Read-before-write: an existing item must carry its name in the
227
252
  // run doc's viewed ledger. A skill that does not exist yet (a
228
253
  // create, or a first overlay) needs no prior view.