@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
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Hints by wrapping (curator P9, spec §6.6): the interception that turns
3
+ * a curation-tool call outside a curator run into a review hint instead
4
+ * of a write.
5
+ *
6
+ * WHY WRAPPING, NOT A HOOK: a `beforeTool` hook gets a read-only
7
+ * `HookApi` and cannot commit a hint, and a hook only runs where its
8
+ * extension is selected. Core instead rewrites the registration of every
9
+ * curation tool at load, so the interception travels with the tool into
10
+ * every conversation, whatever its selection, and runs inside
11
+ * `execute` where `api.commit` exists.
12
+ *
13
+ * The wrap oracle is the FEED (`createCurationToolFeed`): a mutable set
14
+ * of curation tool names that starts with the core set (`skill_manage`)
15
+ * and grows as the loader validates each extension's `curationTargets`.
16
+ * Boot wraps the skills extension's tools at its install (the feed's
17
+ * core entries cover `skill_manage`); the loader wraps any of an
18
+ * extension's tools whose names are in the feed right before
19
+ * `registry.install`. The read-side API stays
20
+ * `CurationRegistry.isCurationTool` (A7) — the feed is only the
21
+ * write-side wrap oracle, not the registry.
22
+ *
23
+ * Wrap semantics (`wrapCurationTool`):
24
+ *
25
+ * 1. inside a curator run (`sigrun.curation.run` present) → delegate to
26
+ * the original, untouched (the gate — `sigrun.curation-gate` — owns
27
+ * the allow enforcement there);
28
+ * 2. otherwise a MUTATING call never executes. `skill_manage`'s read
29
+ * actions (`list`/`view`) pass through — they are safe anywhere. The
30
+ * current run's input submissions (LiveDoc's `run.inputs`) are each
31
+ * resolved with `authorOfSubmission` and matched against the
32
+ * conversation owner: owner present AND every input authored by the
33
+ * owner → append to the `sigrun.curation.hints` doc and answer
34
+ * "Noted. The curator will review this after your reply."; ANYTHING
35
+ * else (a non-owner or unattributed input, an ownerless or runless
36
+ * conversation) → "Not recorded: this is only learned from the
37
+ * owner's own messages." Neither case executes the original.
38
+ */
39
+ import { randomUUID } from "node:crypto";
40
+ import { copyJson } from "@earendil-works/chord";
41
+ import { defineDoc, LiveDoc } from "@earendil-works/pi-durable";
42
+ import { authorOfSubmission } from "./attribution.js";
43
+ import { curationRun } from "./run-doc.js";
44
+ import { CORE_CURATION_TOOLS } from "./targets.js";
45
+ import { SessionMetaDoc } from "../server/session-meta.js";
46
+ /** The answer an owner-authored call receives (contract text). */
47
+ export const HINT_NOTED_TEXT = "Noted. The curator will review this after your reply.";
48
+ /** The answer every other call receives (contract text). */
49
+ export const HINT_NOT_RECORDED_TEXT = "Not recorded: this is only learned from the owner's own messages.";
50
+ /**
51
+ * The `sigrun.curation.hints` doc (latest-only singleton per
52
+ * conversation): the pending hints the next review of THIS conversation
53
+ * consumes. `fork: "current"` (the session-meta/authors pattern) — a
54
+ * fork review (P6) inherits the parent's pending hints, exactly the
55
+ * transcript it reviews.
56
+ */
57
+ export const HintsDoc = defineDoc({
58
+ kind: "sigrun.curation.hints",
59
+ version: 1,
60
+ scope: "conversation",
61
+ checkpointWhen: () => false,
62
+ history: "latest",
63
+ fork: "current",
64
+ initial: () => ({ hints: [] }),
65
+ });
66
+ /** `skill_manage`'s read actions — safe anywhere, never intercepted. */
67
+ const SKILL_MANAGE_READ_ACTIONS = new Set([
68
+ "list",
69
+ "view",
70
+ ]);
71
+ /**
72
+ * Whether one call passes through to the original outside a curator
73
+ * run: only `skill_manage`'s read actions. Every other curation-tool
74
+ * call is a write intent — the target's listed tools ARE its write
75
+ * surface, so the action set needs no per-target spelling.
76
+ */
77
+ function passesThrough(tool, args) {
78
+ if (tool !== "skill_manage")
79
+ return false;
80
+ const action = args?.action;
81
+ return typeof action === "string" && SKILL_MANAGE_READ_ACTIONS.has(action);
82
+ }
83
+ /** The conversation owner through any document reader (fail-closed). */
84
+ async function ownerOf(api, ctx) {
85
+ try {
86
+ return (await api.snapshot(SessionMetaDoc, api.conversationId, ctx))?.owner;
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ /** The current run's input submission ids (fail-closed to none). */
93
+ async function liveInputsOf(api, ctx) {
94
+ try {
95
+ return (await api.snapshot(LiveDoc, api.conversationId, ctx))?.run?.inputs ?? [];
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ }
101
+ /**
102
+ * Whether `owner` authored EVERY one of the inputs. An input with no
103
+ * author record (the pre-write window, or a caller that records none)
104
+ * resolves `undefined` and fails the check — never a guess. A read
105
+ * failure also fails (fail closed).
106
+ */
107
+ async function everyInputOwnerAuthored(api, inputs, owner, ctx) {
108
+ for (const input of inputs) {
109
+ let author;
110
+ try {
111
+ author = await authorOfSubmission(api, api.conversationId, input, ctx);
112
+ }
113
+ catch {
114
+ author = undefined;
115
+ }
116
+ if (author !== owner)
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+ /** One text tool result. */
122
+ function text(text) {
123
+ return { output: [{ type: "text", text }] };
124
+ }
125
+ /**
126
+ * Wrap one curation tool's registration: byte-identical name, schema,
127
+ * description and every other field (the spread keeps each reference) —
128
+ * only `execute` is rewritten, per the wrap semantics in the module
129
+ * doc. Generic so a typed registration comes back typed.
130
+ */
131
+ export function wrapCurationTool(tool) {
132
+ const original = tool.execute;
133
+ const wrapped = {
134
+ ...tool,
135
+ async execute(args, api, context) {
136
+ // 1. Inside a curator run the original runs untouched (the gate
137
+ // owns the allow enforcement; the store user is the run's).
138
+ const run = await curationRun(api, api.conversationId, context);
139
+ if (run !== undefined) {
140
+ return original.call(tool, args, api, context);
141
+ }
142
+ // 2. Outside a run: the read actions of `skill_manage` pass
143
+ // through; every other call is a write intent that never
144
+ // executes.
145
+ if (passesThrough(tool.name, args)) {
146
+ return original.call(tool, args, api, context);
147
+ }
148
+ const owner = await ownerOf(api, context);
149
+ // No live run means no owner-authored evidence — the hint needs
150
+ // inputs, and every one of them authored by the owner.
151
+ const inputs = await liveInputsOf(api, context);
152
+ const ownerAuthored = owner !== undefined &&
153
+ inputs.length > 0 &&
154
+ (await everyInputOwnerAuthored(api, inputs, owner, context));
155
+ if (ownerAuthored) {
156
+ const hint = {
157
+ id: randomUUID(),
158
+ tool: tool.name,
159
+ args: args,
160
+ at: new Date().toISOString(),
161
+ };
162
+ await api.commit(async (tx) => {
163
+ const doc = await tx.doc(HintsDoc, api.conversationId);
164
+ doc.hints = [...doc.hints, hint];
165
+ }, context);
166
+ return text(HINT_NOTED_TEXT);
167
+ }
168
+ return text(HINT_NOT_RECORDED_TEXT);
169
+ },
170
+ };
171
+ return wrapped;
172
+ }
173
+ /**
174
+ * The wrap feed: the mutable set of curation tool names — the core set
175
+ * (`skill_manage`) plus every listed target tool the loader validates.
176
+ * Boot creates the ONE feed and threads it into the loader; the built
177
+ * `CurationRegistry` (A7) is the read-side view, never this set.
178
+ */
179
+ export function createCurationToolFeed() {
180
+ return new Set(CORE_CURATION_TOOLS);
181
+ }
182
+ /**
183
+ * Wrap every curation-tool registration of one extension in place of
184
+ * its install. Identity when the feed is absent or names none of the
185
+ * extension's tools — the untouched object, not a copy, so nothing
186
+ * downstream observes a swap.
187
+ */
188
+ export function wrapExtensionCurationTools(extension, feed) {
189
+ if (feed === undefined)
190
+ return extension;
191
+ if (!extension.tools?.some((tool) => feed.has(tool.name)))
192
+ return extension;
193
+ return {
194
+ ...extension,
195
+ tools: extension.tools.map((tool) => feed.has(tool.name) ? wrapCurationTool(tool) : tool),
196
+ };
197
+ }
198
+ /**
199
+ * Read and clear a conversation's hints in ONE commit (the host-side
200
+ * drain): the hints come out exactly once — the review that takes them
201
+ * owns them, and a second take reads empty.
202
+ */
203
+ export async function takeHints(conversation, ctx) {
204
+ return conversation.commit(async (tx) => {
205
+ const doc = await tx.doc(HintsDoc, conversation.id);
206
+ // copyJson INSIDE the callback: the draft's values are tracker
207
+ // overlays that die with the commit — the caller reads plain JSON.
208
+ const taken = copyJson(doc.hints);
209
+ doc.hints = [];
210
+ return taken;
211
+ }, ctx);
212
+ }
@@ -211,6 +211,273 @@ export interface ExtensionWorker {
211
211
  /** The work; rejects (or overruns the worker deadline) to fail the run. */
212
212
  run(context: HandlerContext, wake?: WorkerWake): Promise<void>;
213
213
  }
214
+ /**
215
+ * The outcome of one curator-run driven call (a fork review or a digest
216
+ * run): the final text (so a target can ask for operations as text and
217
+ * apply them itself), the changes its tools reported, and how it ended.
218
+ */
219
+ export type CurationRunResult = {
220
+ /** How the run ended: answered, human-cancelled, failed, or deadlined. */
221
+ readonly status: "completed" | "cancelled" | "failed" | "deadline";
222
+ /** The run's final assistant text ("" when it produced none). */
223
+ readonly text: string;
224
+ /** Changes the run's tools reported, in tool-result order. */
225
+ readonly changes: readonly {
226
+ readonly changeId: string;
227
+ readonly summary: string;
228
+ /** The qualified target id the change belongs to. */
229
+ readonly target: string;
230
+ }[];
231
+ /** Why a non-completed run ended (the failure/cancellation reason). */
232
+ readonly error?: string;
233
+ };
234
+ /**
235
+ * The budget one `maintain` call runs under: a wall-clock deadline, a
236
+ * digest-run allowance, and the `run` entry point that starts one
237
+ * bounded internal digest run on the user's curator model and RETURNS
238
+ * its result (including the final text) — so the target can ask for
239
+ * operations as text and apply them itself.
240
+ */
241
+ export interface MaintenanceBudget {
242
+ /** Wall-clock deadline (epoch ms); work past it is abandoned. */
243
+ readonly deadline: number;
244
+ /** Internal runs the target may start (P6 digest runs). */
245
+ readonly runs: number;
246
+ /**
247
+ * Start one internal digest run on the user's curator model, bounded
248
+ * to the listed tools; resolves with the run's result.
249
+ */
250
+ run(input: string, tools: readonly string[]): Promise<CurationRunResult>;
251
+ }
252
+ /** One change a maintenance pass reports, undoable through `undo`. */
253
+ export type MaintenanceReport = {
254
+ /** Changes made, each undoable through the target's `undo`. */
255
+ readonly changes: readonly {
256
+ readonly changeId: string;
257
+ readonly summary: string;
258
+ }[];
259
+ /** True when the target stopped early and wants the next pass. */
260
+ readonly more?: boolean;
261
+ };
262
+ /**
263
+ * One curation target (the curator contract, spec P7): an extension's
264
+ * writable per-user surface the curator learns into. The `tools` list is
265
+ * the interception surface — every LISTED tool call outside a curator
266
+ * run becomes a review hint (A8's wrapping), while an unlisted direct
267
+ * write tool stays the extension's own. `undo` is REQUIRED: a target
268
+ * without it fails the load (no undo, no magic).
269
+ */
270
+ export interface CurationTarget {
271
+ /** Unique within the extension; core qualifies it as `<slug>:<name>`. */
272
+ readonly name: string;
273
+ /** Tools the review may call to write this target; registered by this extension. */
274
+ readonly tools: readonly string[];
275
+ /** Routing guidance for one user; `[]` = unavailable for this user. */
276
+ guidance(user: string): readonly string[] | Promise<readonly string[]>;
277
+ /** Revert one change this target reported. Required. */
278
+ undo(user: string, changeId: string, ctx: HandlerContext): Promise<void>;
279
+ /** Optional idle maintenance, called by the curator's idle pass. */
280
+ maintain?(user: string, ctx: HandlerContext, budget: MaintenanceBudget): Promise<MaintenanceReport>;
281
+ }
282
+ /**
283
+ * The `details.curation` shape every target tool's mutating result
284
+ * carries: the change id for the undo surface and a one-line summary
285
+ * for the UI. A type alias (not an interface): the structural JSON
286
+ * mirror of the tool-result details field.
287
+ */
288
+ export type CurationChangeDetails = {
289
+ readonly changeId: string;
290
+ readonly summary: string;
291
+ };
292
+ /**
293
+ * One recorded review hint (P9): an owner-authored call to a curation
294
+ * tool outside a curator run, captured by A8's wrapper for the next
295
+ * review. `args` is the verbatim tool arguments; `at` is the capture
296
+ * instant (ISO).
297
+ */
298
+ export type CurationHint = {
299
+ readonly id: string;
300
+ readonly tool: string;
301
+ readonly args: JsonObject;
302
+ readonly at: string;
303
+ };
304
+ /**
305
+ * One change to a learned skill (the `skill_manage` mutating union,
306
+ * structural mirror of the store's `SkillChange`). A type alias (not
307
+ * an interface): the implicit index signature the JSON doc shapes need.
308
+ */
309
+ export type CurationSkillChange = {
310
+ readonly kind: "create";
311
+ readonly content: string;
312
+ } | {
313
+ readonly kind: "patch";
314
+ readonly old: string;
315
+ readonly new: string;
316
+ readonly file?: string;
317
+ } | {
318
+ readonly kind: "write_file";
319
+ readonly file: string;
320
+ readonly content: string;
321
+ } | {
322
+ readonly kind: "remove_file";
323
+ readonly file: string;
324
+ } | {
325
+ readonly kind: "archive";
326
+ readonly absorbedInto?: string;
327
+ } | {
328
+ readonly kind: "restore";
329
+ readonly version?: string;
330
+ };
331
+ /**
332
+ * One change-feed entry (structural mirror of the store's `FeedEntry`).
333
+ * The undo-relevant fields (`before`, `after`) are opaque version ids.
334
+ */
335
+ export type CurationFeedEntry = {
336
+ readonly id: string;
337
+ readonly at: string;
338
+ readonly actor: "curator" | "agent" | "user";
339
+ readonly action: string;
340
+ readonly skill: string;
341
+ readonly reason?: string;
342
+ readonly reviewId?: string;
343
+ readonly conversationId?: number;
344
+ readonly before?: string;
345
+ readonly after?: string;
346
+ };
347
+ /** One learned skill's listing row (structural mirror). */
348
+ export type CurationSkillInfo = {
349
+ readonly name: string;
350
+ readonly description: string;
351
+ readonly archived: boolean;
352
+ readonly pinned: boolean;
353
+ readonly bytes: number;
354
+ readonly createdAt: string;
355
+ };
356
+ /** Identifies one upstream skill an overlay attaches to (structural mirror). */
357
+ export type CurationOverlayKey = {
358
+ readonly root: "workspace" | "user dir" | "package";
359
+ readonly skill: string;
360
+ /** The canonical workspace root path; required for workspace keys only. */
361
+ readonly workspace?: string;
362
+ };
363
+ /** One stored overlay (structural mirror). */
364
+ export type CurationOverlay = {
365
+ readonly key: CurationOverlayKey;
366
+ readonly text: string;
367
+ readonly upstreamHash: string;
368
+ readonly orphanedSince?: string;
369
+ };
370
+ /** The actor-side metadata an overlay change records in the feed. */
371
+ export type CurationOverlayMeta = {
372
+ readonly actor: "curator" | "agent" | "user";
373
+ readonly reason?: string;
374
+ readonly reviewId?: string;
375
+ readonly conversationId?: number;
376
+ };
377
+ /** The bounds and identity one curator run drives under. */
378
+ export type CurationRunOpts = {
379
+ /** The instruction the run submits as its only input. */
380
+ readonly instruction: string;
381
+ /** The tools the run's gate allows (non-empty — the gate's message names them). */
382
+ readonly allow: readonly string[];
383
+ /** The tool-result budget: the run aborts at this many tool results. */
384
+ readonly maxToolCalls: number;
385
+ /** The output-token budget (advisory; see the run-driver docs). */
386
+ readonly maxOutputTokens: number;
387
+ /** The wall-clock budget in milliseconds. */
388
+ readonly deadlineMs: number;
389
+ /** The review (or digest) identity stamped on every feed entry. */
390
+ readonly reviewId: string;
391
+ /** Caller cancellation: fires → the run aborts as cancelled. */
392
+ readonly signal?: AbortSignal;
393
+ };
394
+ /** What a review's `eligibility` read answers. */
395
+ export type CurationEligibility = {
396
+ readonly owner?: string;
397
+ readonly internal: boolean;
398
+ /** Every user entry attributed to the owner. */
399
+ readonly ownerOnly: boolean;
400
+ /** Unattributed user entries. */
401
+ readonly unattributed: number;
402
+ readonly newestEntry?: EntryId;
403
+ };
404
+ /** One served curation target, projected for the caller. */
405
+ export type CurationTargetInfo = {
406
+ readonly id: string;
407
+ readonly tools: readonly string[];
408
+ readonly guidance: readonly string[];
409
+ readonly maintain: boolean;
410
+ };
411
+ /** The per-user skill-store subset the curation host serves. */
412
+ export type CurationSkillStore = {
413
+ list(): Promise<readonly CurationSkillInfo[]>;
414
+ view(name: string, file?: string): Promise<string>;
415
+ feed(limit: number, before?: string): Promise<readonly CurationFeedEntry[]>;
416
+ pin(name: string, pinned: boolean): Promise<void>;
417
+ undo(entryIds: readonly string[]): Promise<readonly CurationFeedEntry[]>;
418
+ /** Apply one change as the curator (actor "curator"). */
419
+ apply(name: string, change: CurationSkillChange, meta: {
420
+ readonly reviewId?: string;
421
+ readonly reason?: string;
422
+ }): Promise<CurationFeedEntry>;
423
+ };
424
+ /** The per-user overlay-store subset the curation host serves. */
425
+ export type CurationOverlayStore = {
426
+ list(): Promise<readonly CurationOverlay[]>;
427
+ get(key: CurationOverlayKey): Promise<CurationOverlay | undefined>;
428
+ put(key: CurationOverlayKey, text: string, upstreamHash: string, meta: CurationOverlayMeta): Promise<CurationFeedEntry>;
429
+ remove(key: CurationOverlayKey, meta: CurationOverlayMeta): Promise<CurationFeedEntry>;
430
+ markOrphans(present: (key: CurationOverlayKey) => boolean, now: string): Promise<void>;
431
+ };
432
+ /**
433
+ * The curation surface (curator P6, A9): the curator's read APIs and
434
+ * run producers over the curated user's per-user stores. The host
435
+ * owns the internals; this is the extension-facing projection — the
436
+ * run driver, the stores and the registry stay server-side.
437
+ */
438
+ export type SigrunCurationHost = {
439
+ eligibility(id: ConversationId): Promise<CurationEligibility>;
440
+ /**
441
+ * Every user-input entry in the ACTIVE context mapped to its author
442
+ * (P1). An entry admitted before attribution existed, or by an
443
+ * unidentified caller, is present with `undefined` — unattributed,
444
+ * never guessed (fail closed: unattributed entries are never curation
445
+ * input).
446
+ */
447
+ authors(id: ConversationId): Promise<ReadonlyMap<EntryId, string | undefined>>;
448
+ /** Count the conversation's assistant entries after (or all without) an anchor entry. */
449
+ modelCallsSince(id: ConversationId, entryId?: EntryId): Promise<number>;
450
+ /** Read and clear the conversation's pending review hints (A8's doc). */
451
+ takeHints(id: ConversationId): Promise<readonly CurationHint[]>;
452
+ /** Targets whose tools are all in the conversation's selection (no id: all). */
453
+ targets(user: string, id?: ConversationId): Promise<readonly CurationTargetInfo[]>;
454
+ /** The conversation's model-visible tool names (for the allow-list). */
455
+ tools(id: ConversationId): Promise<readonly string[]>;
456
+ /** Fork the conversation at its newest entry and drive the review run. */
457
+ forkReview(id: ConversationId, opts: CurationRunOpts): Promise<CurationRunResult>;
458
+ /** A fresh internal digest conversation for `user`, driven the same way. */
459
+ digestRun(user: string, opts: CurationRunOpts & {
460
+ readonly input: string;
461
+ readonly model?: string;
462
+ }): Promise<CurationRunResult>;
463
+ /** Run one target's maintenance pass with a digest-run budget. */
464
+ maintain(user: string, targetId: string, budget: Omit<MaintenanceBudget, "run">): Promise<MaintenanceReport>;
465
+ /** The curated user's skill store (apply = actor "curator"). */
466
+ skills(user: string): CurationSkillStore;
467
+ /** The curated user's overlay store. */
468
+ overlays(user: string): CurationOverlayStore;
469
+ /** The current upstream body + hash for one overlay key. */
470
+ upstream(user: string, key: CurationOverlayKey): Promise<{
471
+ readonly body: string;
472
+ readonly hash: string;
473
+ } | undefined>;
474
+ /** Skill names in the user's job prompts, paused jobs included. */
475
+ jobSkillRefs(user: string): Promise<ReadonlySet<string>>;
476
+ /** Revert every change one review made; the number of reverts. */
477
+ undoReview(user: string, reviewId: string): Promise<number>;
478
+ /** The skill → lastUsed ISO map (P4's counters). */
479
+ usage(user: string): Promise<Readonly<Record<string, string | undefined>>>;
480
+ };
214
481
  /** The command and extra environment/read rights for one per-user process. */
215
482
  export interface ServiceSpec {
216
483
  readonly command: string;
@@ -1036,6 +1303,14 @@ export interface SigrunHost {
1036
1303
  readonly notice?: string;
1037
1304
  }>;
1038
1305
  };
1306
+ /**
1307
+ * The curation surface (curator P6, A9): the curator's read APIs
1308
+ * (eligibility, hints, targets, tools, skills, overlays, usage, job
1309
+ * references) and the run producers (fork review, digest, maintain).
1310
+ * Binds lazily like every bridged member — a factory-body call WAITS
1311
+ * for the bridge, never a silent drop.
1312
+ */
1313
+ readonly curation: SigrunCurationHost;
1039
1314
  }
1040
1315
  /**
1041
1316
  * The documented factory-result contract: EVERY field an extension may
@@ -1073,6 +1348,13 @@ export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
1073
1348
  readonly ambientKinds?: readonly string[];
1074
1349
  /** Idle workers (the curator contract — `wakeOn` only, `every` retired). */
1075
1350
  readonly workers?: readonly ExtensionWorker[];
1351
+ /**
1352
+ * Curation targets (the curator contract, spec P7): this extension's
1353
+ * writable per-user surfaces the curator learns into. Every listed
1354
+ * tool's outside-run calls become review hints (A8's wrapping); a
1355
+ * target without `undo` fails the load.
1356
+ */
1357
+ readonly curationTargets?: readonly CurationTarget[];
1076
1358
  /** Declared jobs (the only time-based scheduler); read-only through the API under `ext:<slug>:<name>` ids. */
1077
1359
  readonly jobs?: readonly JobSpec[];
1078
1360
  /** HTTP routes mounted at `/v1/ext/<slug>/<path>` (lands in Task 2). */