@sigrun/sigrun 0.4.2 → 0.5.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 (56) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +46 -23
  3. package/dist/commands/expand.js +37 -5
  4. package/dist/curation/attribution.js +135 -0
  5. package/dist/curation/deletion.js +32 -0
  6. package/dist/curation/gate.js +50 -0
  7. package/dist/curation/host.js +342 -0
  8. package/dist/curation/internal.js +54 -0
  9. package/dist/curation/names.js +5 -0
  10. package/dist/curation/overlays.js +325 -0
  11. package/dist/curation/paths.js +17 -0
  12. package/dist/curation/run-doc.js +64 -0
  13. package/dist/curation/runs.js +579 -0
  14. package/dist/curation/scan.js +30 -0
  15. package/dist/curation/skill-tool.js +308 -0
  16. package/dist/curation/store.js +444 -0
  17. package/dist/curation/targets.js +77 -0
  18. package/dist/curation/usage.js +124 -0
  19. package/dist/curation/wrap.js +212 -0
  20. package/dist/extension.d.ts +282 -0
  21. package/dist/harness/boot.js +209 -9
  22. package/dist/harness/builtins.js +12 -9
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/loader.js +114 -1
  25. package/dist/harness/naming.js +6 -0
  26. package/dist/harness/packages.js +14 -9
  27. package/dist/harness/reserved-sections.js +2 -0
  28. package/dist/harness/seam-host.js +139 -26
  29. package/dist/harness/settings.js +2 -2
  30. package/dist/harness/tools.js +70 -0
  31. package/dist/jobs/fire.js +33 -11
  32. package/dist/jobs/skills-refs.js +40 -0
  33. package/dist/sandbox/policy.js +14 -0
  34. package/dist/server/blocks/answers.js +9 -3
  35. package/dist/server/conversation-ops.js +21 -3
  36. package/dist/server/helpers.js +1 -1
  37. package/dist/server/host-bridge.js +6 -1
  38. package/dist/server/notifications.js +6 -0
  39. package/dist/server/routes/meta.js +8 -3
  40. package/dist/server/routes/submissions.js +5 -0
  41. package/dist/server/routes/surfaces.js +3 -1
  42. package/dist/server/routes/users.js +7 -0
  43. package/dist/server/schema-fixture.js +168 -0
  44. package/dist/server/session-meta.js +12 -10
  45. package/dist/settings/core-sections.js +9 -0
  46. package/dist/settings/registry.js +6 -0
  47. package/dist/skills/index.js +88 -19
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +3 -1
  49. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  50. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  51. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  52. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  53. package/node_modules/@sigrun/shared/package.json +1 -1
  54. package/package.json +9 -7
  55. package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
  56. package/webui/dist/index.html +1 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.5.0] - 2026-11-02
9
+
10
+ ### Added
11
+ - **The curator** (sixth built-in, `@sigrun/ext-curator`): per-user
12
+ curation over learned skills and curation targets. Message attribution
13
+ records the submitting user per input; fork reviews (loop 1) run after
14
+ conversation activity past a threshold; the idle maintenance pass
15
+ (loop 2, the `curator-tick` job) digests mixed-authorship
16
+ conversations — per-entry attribution keeps other users' turns out of
17
+ the digest input; the versioned per-user skill store (feed, pins,
18
+ content scan, undo); usage telemetry; internal runs hidden from every
19
+ owner-keyed path; the curation-target contract (`@sigrun/memory-markdown`
20
+ 0.2.0 is the first target: per-user USER.md/MEMORY.md with hard limits
21
+ and versions); notices + the undo view; hint-by-wrapping interception
22
+ with provable authorship.
23
+ - A default `stream.timeoutMs` (5 minutes) via the boot settings getter:
24
+ a model request with no explicit timeout could wedge forever (the
25
+ OpenAI SDK's timeout is a headers-arrival deadline; a request that
26
+ never reaches the provider never gets headers). Caller seam and
27
+ settings file override field-wise.
28
+
29
+ ### Fixed
30
+ - The seam host's pre-bind `curation.skills(user)`/`overlays(user)`
31
+ returned a promise of the store instead of a store (loop 2's archive
32
+ step read `.list` off a promise). A lazy store proxy now preserves the
33
+ sync-store contract through the pre-bind window.
34
+
35
+ ### Changed
36
+ - Reworded the `pi` manifest handling so it no longer implies pi
37
+ extension compatibility. Sigrun reads `pi.extensions`/`pi.skills` only
38
+ to LOCATE files and shares the SKILL.md format; it does not implement
39
+ pi's `ExtensionAPI` (a pi extension's `registerTool`/`registerCommand`
40
+ have no counterpart here). `docs/extending.md` gains a "Not pi
41
+ extensions" section stating the two ABIs are unrelated and that only
42
+ the skills layer transfers. The `.ts` skip and empty-entry warnings now
43
+ say `pi manifest:` instead of `pi-compat:`.
9
44
 
10
45
  ## [0.4.2] - 2026-10-31
11
46
 
@@ -650,3 +685,4 @@ recreate).*
650
685
  surface, settings panel, command surface).
651
686
  - pi-durable upgraded 1.0.2 → 1.2.0: ToolExecutionResult `output` rename across
652
687
  core, registry-driven settings roster, `modelTools` two-layer filtering.
688
+
package/README.md CHANGED
@@ -1,19 +1,19 @@
1
1
  # Sigrun
2
2
 
3
- Sigrun is a **durable, multi-user personal-assistant server** built on
4
- [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable).
5
- One agent loop serves every surface — a web UI, a terminal UI, an HTTP
6
- API, and (via the gateway extension) chat channels — over long-lived
7
- conversations stored in SQLite.
8
- An extension model installs extra tools, providers, prompt sections,
9
- memory systems, and login providers at boot.
10
-
11
- **Why use it?** Conversations survive restarts and resume where they
12
- stopped; memory and integrations are extension-owned and individually
13
- replaceable; multi-user boundaries are explicit (real authentication plus
14
- a per-user execution jail); and it deploys as an npm package, a Docker
15
- compose reference, or a Helm chart. [`docs/why.md`](docs/why.md) is the
16
- longer rationale.
3
+ Sigrun is a **self-hosted, multi-user personal-assistant server**. Each
4
+ user signs in to their own conversations, settings, credentials, and
5
+ tools, and their agent runs in its own execution jail. One agent loop
6
+ serves every surface — a web UI, a terminal UI, an HTTP API, scheduled
7
+ jobs, and (via the gateway extension) chat-channel adapters. The core
8
+ stays small: memory, model providers, tools, MCP support, and login are
9
+ extensions installed at boot.
10
+
11
+ **Why use it?** Several people can share one install without sharing an
12
+ agent: real authentication, per-user extensions and secrets, and a
13
+ per-user execution jail that fails closed. Memory and integrations are
14
+ extension-owned and individually replaceable. Conversations survive
15
+ restarts, and it deploys as an npm package, a Docker compose reference,
16
+ or a Helm chart. [`docs/why.md`](docs/why.md) is the longer rationale.
17
17
 
18
18
  **Status:** 0.x — useful and exercised, but extension and settings
19
19
  contracts can still evolve between minor releases. See
@@ -24,12 +24,12 @@ posture, and [`CONTRIBUTING.md`](CONTRIBUTING.md) to build and test.
24
24
  [Install](#install) and [Quickstart](#quickstart) for the model/credential
25
25
  setup every install needs.
26
26
 
27
- - **Durable** — conversations, submissions, and schedules live in one
28
- SQLite database (`SIGRUN_STORAGE_PATH`); a restart resumes where it
29
- stopped.
30
- - **pi-durable core** — the harness primitives (conversations, inbox
31
- steering/follow-up modes, retries, streaming, compaction) come from
32
- pi-durable; sigrun is the deployable shell around them.
27
+ - **Multi-user** — token, proxy, and OIDC/GitHub/Discord sign-in, with
28
+ invites after the first admin claims the install; per-user extension
29
+ settings, secrets, and services; and a Landlock execution jail per user.
30
+ A second user is refused when isolation is unavailable. See
31
+ [Authentication](#authentication) and
32
+ [Multi-user execution isolation](#multi-user-execution-isolation).
33
33
  - **Extensions** — separate npm packages loaded from `SIGRUN_EXTENSIONS` (or the settings file's
34
34
  top-level `extensions` list); a failing extension is quarantined and
35
35
  reported, never a boot abort.
@@ -42,6 +42,12 @@ setup every install needs.
42
42
  API the web UI consumes (feature parity by design): conversation
43
43
  sidebar, streaming conversation pane, fork, the interaction and
44
44
  ambient seams, slash commands. See [Launch modes](#launch-modes).
45
+ - **Restart-safe** — conversations, submissions, and schedules live in one
46
+ SQLite database (`SIGRUN_STORAGE_PATH`), and a restart resumes where it
47
+ stopped. The harness primitives (conversations, inbox steering/follow-up
48
+ modes, retries, streaming, compaction) come from
49
+ [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable);
50
+ sigrun is the deployable shell around them.
45
51
 
46
52
  ## Install
47
53
 
@@ -716,7 +722,8 @@ links, stops their worker and services, and removes their per-user tool
716
722
  entries. Extensions then receive `userDeleted` while the old record and
717
723
  secrets remain readable, so they can revoke external provider
718
724
  credentials. Finally core removes the directory record, per-user secrets,
719
- user-settings directory, and per-user cache directories.
725
+ user-settings directory, per-user cache directories, and that user's
726
+ learned skills and curation state (never transferred).
720
727
 
721
728
  **Credentials belong to a person and never transfer.** Data and state
722
729
  stay for the extension to retain, move, or delete according to its own
@@ -1008,11 +1015,11 @@ Entry grammar:
1008
1015
  checkout `FETCH_HEAD`; present without a ref → fetch only (the
1009
1016
  checkout stays where you left it). Entry discovery: the object form's
1010
1017
  `entry` → a `sigrun` manifest (`{"sigrun": {"extension": "./index.js"}}`)
1011
- → a pi-compat manifest (the first non-`.ts` `pi.extensions` entry; each
1018
+ → a `pi` manifest (the first non-`.ts` `pi.extensions` entry; each
1012
1019
  `.ts` entry is skipped with one warning — it cannot load under plain
1013
1020
  node) → `dist/index.js`.
1014
1021
  - **Skills from packages** — a package may ship skills instead of (or
1015
- beside) extension code: manifest `sigrun.skills` or pi-compat
1022
+ beside) extension code: manifest `sigrun.skills` or the `pi`-shaped
1016
1023
  `pi.skills` (`["./skills"]`, the same SKILL.md format), or a
1017
1024
  factory-result `skills` field (absolute dirs). A package that declares
1018
1025
  skills and has no loadable entry is a valid **skills-only** package
@@ -1193,6 +1200,22 @@ typed — the submission's user entry carries
1193
1200
  `{kind: "sigrun.user.expanded", data: {typed, command}}` while the
1194
1201
  model receives the expanded text.
1195
1202
 
1203
+ **The curator and learned skills.** Beyond the three roots above, every
1204
+ user has a private fourth root — learned skills the curator records for
1205
+ them under `<data-dir>/users/<user>/skills/`. The curator reviews the
1206
+ user's conversations (owner-authored tool calls are captured as
1207
+ "Noted" hints, never executed on the spot) and writes through the
1208
+ versioned `skill_manage` surface, so every learned skill keeps its last
1209
+ ten versions, a change feed, and one-call undo. Learned skills never
1210
+ leave their user: they are not shared, not transferred on ownership
1211
+ moves, and removed with the user on `DELETE /v1/users/{user}`. Each
1212
+ user can pause or disable the curator in their own per-user settings —
1213
+ `PUT /v1/me/extensions/curator/settings` with `{"paused": true}` stops
1214
+ new reviews until flipped back, `{"enabled": false}` turns the curator
1215
+ off entirely (both are per-user and take effect on the next run) — and
1216
+ an operator can disable it server-wide with
1217
+ `"builtinExtensions": {"curator": false}` (restart required).
1218
+
1196
1219
  ## Jobs
1197
1220
 
1198
1221
  A job is a schedule plus an action, durable by construction — one
@@ -31,6 +31,7 @@
31
31
  * kind must not start with `pi.`).
32
32
  */
33
33
  import { listSkills, } from "../skills/index.js";
34
+ import { OVERLAY_HEADING, overlayForSkill } from "../curation/overlays.js";
34
35
  import { discoverTemplates, } from "./templates.js";
35
36
  /** The user entry kind an expanded input is placed as. */
36
37
  export const EXPANDED_ENTRY_KIND = "sigrun.user.expanded";
@@ -149,8 +150,13 @@ export function expandTemplate(text, templates) {
149
150
  * shape): the `<skill …>` block, plus a blank line and the trimmed
150
151
  * arguments ONLY when arguments were given. An unknown skill — or any
151
152
  * other text — passes through UNCHANGED.
153
+ *
154
+ * `overlays` (curator A5, P2): the acting owner's overlay text per
155
+ * skill name — when present, the block's body carries the upstream
156
+ * body followed by `OVERLAY_HEADING` and the overlay text, the same
157
+ * shape `load_skill` serves. Absent = the plain pi shape.
152
158
  */
153
- export function expandSkillCommand(text, skills) {
159
+ export function expandSkillCommand(text, skills, overlays) {
154
160
  if (!text.startsWith("/skill:"))
155
161
  return text;
156
162
  const spaceIndex = text.indexOf(" ");
@@ -160,9 +166,12 @@ export function expandSkillCommand(text, skills) {
160
166
  if (skill === undefined)
161
167
  return text;
162
168
  const location = `${skill.dir}/SKILL.md`;
169
+ const overlay = overlays?.get(skill.name);
163
170
  // Pi parity: the body is the frontmatter-stripped SKILL.md, trimmed —
164
171
  // `</skill>` sits on its own line with no stray blank lines.
165
- const body = skill.body.trim();
172
+ const body = (overlay === undefined
173
+ ? skill.body
174
+ : `${skill.body}\n${OVERLAY_HEADING}\n\n${overlay}`).trim();
166
175
  const block = `<skill name="${skill.name}" location="${location}">\n` +
167
176
  `References are relative to ${skill.dir}.\n` +
168
177
  `\n${body}\n` +
@@ -205,7 +214,7 @@ export function expandableTemplates(templates, commandNames) {
205
214
  * error `parseCommandArgs` raises) — the submit path maps it to a
206
215
  * client error.
207
216
  */
208
- export async function expandInput(parts, expansion, env, context) {
217
+ export async function expandInput(parts, expansion, env, context, owner) {
209
218
  // Only a LEADING text part starting with `/` is a command: the
210
219
  // command is what the user typed first (attachments and later text
211
220
  // parts pass through untouched, in place).
@@ -223,10 +232,33 @@ export async function expandInput(parts, expansion, env, context) {
223
232
  if (reserved.has(commandName))
224
233
  return { parts };
225
234
  if (commandName.startsWith("skill:")) {
226
- const skills = await listSkills(env, context, expansion.skills);
227
- const expanded = expandSkillCommand(text, skills);
235
+ const skillName = commandName.slice("skill:".length);
236
+ const skills = await listSkills(env, context, expansion.skills, { owner });
237
+ // Curator A5 (P2): the owner's overlay rides on the upstream body,
238
+ // the same shape `load_skill` serves — discovered skills without
239
+ // one (or an owner without a store) map nothing, and the block
240
+ // keeps pi's plain shape.
241
+ const overlays = new Map();
242
+ for (const skill of skills) {
243
+ const overlay = await overlayForSkill(expansion.skills, owner, skill, env);
244
+ if (overlay !== undefined)
245
+ overlays.set(skill.name, overlay);
246
+ }
247
+ const expanded = expandSkillCommand(text, skills, overlays);
228
248
  if (expanded === text)
229
249
  return { parts };
250
+ // Curator A6 (P4): a `/skill:name` that actually expanded counts as
251
+ // usage for the owner (the discovered skill was a learned one).
252
+ if (owner !== undefined) {
253
+ const skill = skills.find((candidate) => candidate.name === skillName);
254
+ if (skill?.root === "learned") {
255
+ const usage = expansion.skills.usage?.(owner);
256
+ if (usage !== undefined) {
257
+ usage.bump(skill.name, "expand");
258
+ void usage.flush();
259
+ }
260
+ }
261
+ }
230
262
  return {
231
263
  parts: parts.map((part) => (part === textPart ? { type: "text", text: expanded } : part)),
232
264
  typed: text,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Message attribution (curator P1, spec §6.1): every user input records
3
+ * the submitting user through every admission path — HTTP submit (the
4
+ * resolved principal), the extension host bridge (`opts.user`, recorded
5
+ * verbatim; extensions own their user mapping), a block answer (the
6
+ * acting user), and a job fire (the job's owner).
7
+ *
8
+ * The carrier is a SIDE record, the `sigrun.authors` conversation doc:
9
+ * `pi.user` entries are `Entry<never>` (they cannot carry data), and a
10
+ * new input entry kind would have to be taught to every `UserEntry.is`
11
+ * consumer. The doc keys the author two ways:
12
+ *
13
+ * - `byRequest` — written BEFORE `conversation.submit` (the requestId
14
+ * exists pre-admission, so this never races). An orphan record from a
15
+ * failed submit is harmless.
16
+ * - `bySubmission` — written right after admission returns: the key the
17
+ * live run's input ids resolve against, readable from a tool through
18
+ * any `DocumentReader`. A tool call that lands before this write reads
19
+ * "unattributed" and fails closed.
20
+ *
21
+ * An `undefined` user (an unidentified caller) records nothing: its
22
+ * entries read unattributed, exactly like every entry admitted before
23
+ * this doc existed — unattributed entries are never curation input.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { defineDoc, UserEntry } from "@earendil-works/pi-durable";
27
+ import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
28
+ import { BlocksAnswerEntry } from "../harness/blocks/entries.js";
29
+ /** The `sigrun.authors` doc (latest-only singleton per conversation). */
30
+ export const AuthorsDoc = defineDoc({
31
+ kind: "sigrun.authors",
32
+ version: 1,
33
+ scope: "conversation",
34
+ checkpointWhen: () => false,
35
+ history: "latest",
36
+ fork: "current",
37
+ initial: () => ({ byRequest: {}, bySubmission: {} }),
38
+ });
39
+ /** The entry kinds an input submission places (the author carriers). */
40
+ const INPUT_KINDS = new Set([
41
+ UserEntry.kind,
42
+ EXPANDED_ENTRY_KIND,
43
+ BlocksAnswerEntry.kind,
44
+ ]);
45
+ /** Whether one entry is a placed user input (any of {@link INPUT_KINDS}). */
46
+ function isInputEntry(entry) {
47
+ return INPUT_KINDS.has(entry.kind);
48
+ }
49
+ /**
50
+ * The requestId an input is admitted under: the caller's own id, or a
51
+ * generated `sigrun:`-prefixed UUID when the caller passed none — every
52
+ * input submission carries one, so `byRequest` can key every admission.
53
+ */
54
+ export function ensureRequestId(requestId) {
55
+ return requestId ?? `sigrun:${randomUUID()}`;
56
+ }
57
+ /**
58
+ * Record `user` as the author of one input, keyed by its requestId
59
+ * (pre-admission) or its submission id (post-admission). A no-op for an
60
+ * `undefined` user: an unidentified caller leaves no record and its
61
+ * entries read unattributed.
62
+ */
63
+ export async function recordAuthor(conversation, key, user, ctx) {
64
+ if (user === undefined)
65
+ return;
66
+ await conversation.commit(async (tx) => {
67
+ const doc = await tx.doc(AuthorsDoc, conversation.id);
68
+ if ("requestId" in key)
69
+ doc.byRequest[key.requestId] = user;
70
+ else
71
+ doc.bySubmission[String(key.submissionId)] = user;
72
+ }, ctx);
73
+ }
74
+ /**
75
+ * The author of one submission, from any `DocumentReader` — including the
76
+ * `ToolExecutionApi` a tool runs with. Reads `bySubmission` only, so a
77
+ * tool call that lands before the post-admission write resolves
78
+ * `undefined` (unattributed — fail closed, never guess).
79
+ */
80
+ export async function authorOfSubmission(read, conversationId, submissionId, ctx) {
81
+ const doc = await read.snapshot(AuthorsDoc, conversationId, ctx);
82
+ return doc?.bySubmission[String(submissionId)];
83
+ }
84
+ /**
85
+ * Every user-input entry in the conversation's ACTIVE context mapped to
86
+ * its author — host-side (full `Session` access; the harness resolves
87
+ * both record keys through the submission's `entry` link and its
88
+ * `requestId`). Entries with no attribution (everything admitted before
89
+ * this doc, or by an unidentified caller) are present with `undefined`:
90
+ * the map's presence set is the input entries, the values are authors.
91
+ */
92
+ export async function authorsOfEntries(harness, conversationId, ctx) {
93
+ const result = new Map();
94
+ const conversation = await harness.conversation(conversationId, ctx);
95
+ if (conversation === undefined)
96
+ return result;
97
+ const view = await conversation.context(ctx);
98
+ for (const entry of view.entries) {
99
+ if (isInputEntry(entry))
100
+ result.set(entry.id, undefined);
101
+ }
102
+ const doc = await harness.snapshot(AuthorsDoc, conversationId, ctx);
103
+ if (doc === undefined)
104
+ return result;
105
+ // The byRequest join: the requestId resolves to the submission (and
106
+ // its placed entry) through the transaction's conversation-scoped
107
+ // lookup — the one typed path from a requestId to the record.
108
+ const byRequest = await harness.commit(async (tx) => {
109
+ const resolved = [];
110
+ for (const [requestId, user] of Object.entries(doc.byRequest)) {
111
+ const submission = await tx.submissionByRequest(conversationId, requestId);
112
+ if (submission?.entry !== undefined) {
113
+ resolved.push({ entry: submission.entry, user });
114
+ }
115
+ }
116
+ return resolved;
117
+ }, ctx);
118
+ for (const { entry, user } of byRequest) {
119
+ if (result.has(entry))
120
+ result.set(entry, user);
121
+ }
122
+ // The bySubmission join (authoritative: keyed by the exact id). The
123
+ // submission handle re-reads the live record, so a still-queued input
124
+ // joins once its entry places.
125
+ for (const [id, user] of Object.entries(doc.bySubmission)) {
126
+ const submission = await harness.submission(Number(id), ctx);
127
+ if (submission === undefined)
128
+ continue;
129
+ const record = await submission.status(ctx);
130
+ if (record.entry !== undefined && result.has(record.entry)) {
131
+ result.set(record.entry, user);
132
+ }
133
+ }
134
+ return result;
135
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Curation state removal on user deletion (curator A10, spec §6.4):
3
+ * a departing user's learned skills and curation state are REMOVED with
4
+ * them, never transferred — a skill distilled from their conversations
5
+ * can carry their paths, hosts and preferences, and must not load for
6
+ * the recipient (memory's shape; the spec's call).
7
+ *
8
+ * Core owns exactly the two roots under the managed user area:
9
+ * `<userArea>/skills` (the learned root) and `<userArea>/curation`
10
+ * (versions, overlays, the feed, usage counters). The user area itself,
11
+ * workspaces and every extension-owned per-user dir stay for the §10.6
12
+ * recipient/retention rules; targets clean their own data through their
13
+ * `userDeleted` handler.
14
+ *
15
+ * Fail-closed counterpart (Review Focus 1): the per-user stores gate
16
+ * EVERY operation on the live directory record (A4/A5's `userExists`),
17
+ * so a curator run in flight when its user is deleted cannot write —
18
+ * its late calls fail `user removed` and create nothing. This function
19
+ * only removes what exists; it never recreates a root.
20
+ */
21
+ import { rm } from "node:fs/promises";
22
+ import { curationDir, learnedSkillsDir } from "./paths.js";
23
+ /**
24
+ * Remove one user's learned skills root and curation state directory.
25
+ * Idempotent (`force`): absent roots are a no-op, and nothing is ever
26
+ * created. Symlink-safe by construction — `rm` removes a symlinked
27
+ * root itself, never its target.
28
+ */
29
+ export async function removeUserCuration(userArea) {
30
+ await rm(learnedSkillsDir(userArea), { recursive: true, force: true });
31
+ await rm(curationDir(userArea), { recursive: true, force: true });
32
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The curator-run gate (curator P6): `sigrun.curation-gate` — a
3
+ * hooks-only core extension that confines an internal curator run to
4
+ * its allow list.
5
+ *
6
+ * Selected ONLY in the run conversations A9's producer creates (this
7
+ * task installs it; boot keeps it OUT of the host default extension
8
+ * selection, the same never-defaulted treatment as per-user entries).
9
+ * Its `beforeTool` blocks any tool not in `CurationRunDoc.allow` with
10
+ * `"<tool> is not available in this review; use one of: <allow,
11
+ * comma-separated>"` — a decision only; it writes nothing (a hook
12
+ * cannot commit, and the gate has no write to make). A conversation
13
+ * with no run doc (never, for a correctly-created run) allows
14
+ * everything: the gate is a confinement, not a filter on ordinary
15
+ * conversations.
16
+ */
17
+ import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
18
+ import { curationRun } from "./run-doc.js";
19
+ /** The gate extension's registered name. */
20
+ export const CURATION_GATE_EXTENSION = "sigrun.curation-gate";
21
+ /**
22
+ * Build the gate extension: one `pi.tool` hook, no tools, no sections,
23
+ * no prompt bytes. Boot installs it into the registry; A9's producer
24
+ * selects it in the internal run conversations it creates.
25
+ */
26
+ export function createCurationGateExtension() {
27
+ return defineExtension({
28
+ name: CURATION_GATE_EXTENSION,
29
+ hooks: [
30
+ hook(ToolTask, {
31
+ /**
32
+ * Block any tool outside the run's allow list, naming the
33
+ * allowed list so the model can recover without another
34
+ * round of guessing. `undefined` (no run doc, or the tool
35
+ * is allowed) lets the call proceed.
36
+ */
37
+ async beforeTool(call, api, context) {
38
+ const run = await curationRun(api, api.conversationId, context);
39
+ if (run === undefined)
40
+ return undefined;
41
+ if (run.allow.includes(call.name))
42
+ return undefined;
43
+ return {
44
+ block: `${call.name} is not available in this review; use one of: ${run.allow.join(", ")}`,
45
+ };
46
+ },
47
+ }),
48
+ ],
49
+ });
50
+ }