@sigrun/ext-curator 0.0.0-stage → 0.2.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.
package/dist/loop2.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Loop 2 — the scheduled idle maintenance pass (`src/loop2.ts`, Task
3
+ * B3, spec §6.2).
4
+ *
5
+ * Clocked by the `curator-tick` job (the only time-based scheduler);
6
+ * each fired task runs {@link tick} once, and a tick does AT MOST one
7
+ * pass-fragment: every user's pass is a small resumable queue of
8
+ * steps with a durable cursor (`pass.json` under the user's state
9
+ * dir), so a tick interrupted at its self-imposed 5-minute deadline
10
+ * leaves the cursor pointing at the next step and the NEXT tick
11
+ * resumes exactly there. Nothing depends on finishing in one go.
12
+ *
13
+ * The eligibility gate ({@link eligibleUsers}, spec): a user is due
14
+ * when they are QUIET — the newest `lastActivityAt` across their
15
+ * conversations is older than the idle threshold. "No live run" rides
16
+ * the same clock: activity is stamped at submission admission AND at
17
+ * run settlement (core's activity seam), so a user with a live run is
18
+ * never past the idle threshold — the recency check observes it. A
19
+ * user with no activity at all (never used, or quiet since before the
20
+ * seam existed) is quiet too. Plus: settings enabled and not paused,
21
+ * no full pass in the last 24h, and past the install's first-run
22
+ * grace (the FIRST tick instant + 24h, recorded once in the
23
+ * extension's own state — the grace is install-wide, per spec: "no
24
+ * pass until one full interval after install").
25
+ *
26
+ * The cursor (`PassCursor`): `steps` is the pass's queue, `done` how
27
+ * many steps finished (persisted after EVERY step — that is the
28
+ * resume point), `dailyRunsUsed`/`day` the per-user model-run budget
29
+ * Task B4's model steps spend (a calendar day rollover re-arms it).
30
+ *
31
+ * The step table: B3 owns the two deterministic steps (`archive`,
32
+ * `overlay-detect`); the five model steps are declared in the queue
33
+ * and run as no-ops here — Task B4 fills them in.
34
+ */
35
+ import { join } from "node:path";
36
+ import { readStateJson, writeStateJson } from "./queue.js";
37
+ import { readSettings } from "./settings.js";
38
+ import { archiveUnused } from "./steps/archive.js";
39
+ import { detectOverlayDrift, recheckOverlays } from "./steps/overlays.js";
40
+ import { digestsStep } from "./steps/digests.js";
41
+ import { dietStep } from "./steps/diet.js";
42
+ import { mergeStep } from "./steps/merge.js";
43
+ import { maintainTargets } from "./steps/targets.js";
44
+ /**
45
+ * The idle threshold (spec §5 pins SIGRUN_IDLE_THRESHOLD's default at
46
+ * 10 minutes; the extension reads the semantics, not the env — the
47
+ * host's activity seam already carries it).
48
+ */
49
+ export const IDLE_THRESHOLD_MS = 600_000;
50
+ /**
51
+ * The tick's self-imposed deadline: 5 minutes, well under the worker/
52
+ * job deadline, so a pass fragment always outlives its own tick.
53
+ */
54
+ export const LOOP2_TICK_DEADLINE_MS = 300_000;
55
+ /** The first-run grace: no pass until 24h after the first tick ever. */
56
+ export const FIRST_RUN_GRACE_MS = 24 * 60 * 60 * 1000;
57
+ /** How long a completed pass keeps its user ineligible. */
58
+ export const FULL_PASS_INTERVAL_MS = 24 * 60 * 60 * 1000;
59
+ /** The per-user pass-cursor file name (under the user's state dir). */
60
+ export const PASS_FILE = "pass.json";
61
+ /**
62
+ * The extension-scope state file holding the install-wide first-tick
63
+ * instant (the grace anchor). Chosen name; recorded here as the seam.
64
+ */
65
+ export const FIRST_TICK_FILE = "loop2-first-tick.json";
66
+ /** One pass's step kinds, in run order (deterministic steps first). */
67
+ export const PASS_STEPS = [
68
+ "archive",
69
+ "overlay-detect",
70
+ "overlay-recheck",
71
+ "digests",
72
+ "diet",
73
+ "merge",
74
+ "targets",
75
+ ];
76
+ /** The step-queue kinds Task B4 owns (the five model steps). */
77
+ const MODEL_STEPS = new Set([
78
+ "overlay-recheck",
79
+ "digests",
80
+ "diet",
81
+ "merge",
82
+ "targets",
83
+ ]);
84
+ /** The UTC calendar day of `at` (budget days are UTC). */
85
+ function dayOf(at) {
86
+ return at.toISOString().slice(0, 10);
87
+ }
88
+ /** Read the install-wide first-tick instant; absent/malformed → none. */
89
+ async function readFirstTick(host) {
90
+ const parsed = await readStateJson(join(host.stores.paths.state, FIRST_TICK_FILE), {});
91
+ if (typeof parsed.firstTickAt !== "string")
92
+ return undefined;
93
+ const at = Date.parse(parsed.firstTickAt);
94
+ return Number.isFinite(at) ? new Date(at) : undefined;
95
+ }
96
+ /** Record the install's first-tick instant (once — the grace anchor). */
97
+ async function writeFirstTick(host, at) {
98
+ await writeStateJson(host.stores.paths.state, FIRST_TICK_FILE, {
99
+ firstTickAt: at.toISOString(),
100
+ });
101
+ }
102
+ /** Read one user's pass cursor; absent/malformed → none (a fresh pass). */
103
+ async function readPass(host, user) {
104
+ const parsed = await readStateJson(join(host.stores.user(user).paths.state, PASS_FILE), {});
105
+ const known = new Set(PASS_STEPS);
106
+ if (typeof parsed.startedAt !== "string")
107
+ return undefined;
108
+ if (!Array.isArray(parsed.steps) || parsed.steps.length === 0)
109
+ return undefined;
110
+ if (!parsed.steps.every((step) => typeof step === "string" && known.has(step))) {
111
+ return undefined;
112
+ }
113
+ if (typeof parsed.done !== "number" ||
114
+ !Number.isInteger(parsed.done) ||
115
+ parsed.done < 0 ||
116
+ parsed.done > parsed.steps.length) {
117
+ return undefined;
118
+ }
119
+ if (typeof parsed.dailyRunsUsed !== "number" ||
120
+ !Number.isInteger(parsed.dailyRunsUsed) ||
121
+ parsed.dailyRunsUsed < 0) {
122
+ return undefined;
123
+ }
124
+ if (typeof parsed.day !== "string")
125
+ return undefined;
126
+ return {
127
+ startedAt: parsed.startedAt,
128
+ steps: parsed.steps,
129
+ done: parsed.done,
130
+ dailyRunsUsed: parsed.dailyRunsUsed,
131
+ day: parsed.day,
132
+ };
133
+ }
134
+ /** Persist one user's pass cursor (after every step — the resume point). */
135
+ async function writePass(host, user, cursor) {
136
+ await writeStateJson(host.stores.user(user).paths.state, PASS_FILE, cursor);
137
+ }
138
+ /**
139
+ * Whether the user is QUIET: their newest conversation activity is
140
+ * older than the idle threshold. No activity at all is quiet. A live
141
+ * run stamps activity at admission, so it is never quiet mid-run.
142
+ */
143
+ async function isQuiet(host, user, now) {
144
+ const conversations = await host.conversations.list({ owner: user });
145
+ let newest;
146
+ for (const conversation of conversations) {
147
+ if (conversation.lastActivityAt === undefined)
148
+ continue;
149
+ const at = Date.parse(conversation.lastActivityAt);
150
+ if (Number.isFinite(at) && (newest === undefined || at > newest)) {
151
+ newest = at;
152
+ }
153
+ }
154
+ return newest === undefined || now.getTime() - newest >= IDLE_THRESHOLD_MS;
155
+ }
156
+ /**
157
+ * The users due for a pass (spec §6.2): quiet, settings on, past the
158
+ * first-run grace, and without a completed pass in the last 24h. An
159
+ * INCOMPLETE pass does not block — the next tick resumes it.
160
+ */
161
+ export async function eligibleUsers(host, now) {
162
+ const firstTick = await readFirstTick(host);
163
+ if (firstTick === undefined)
164
+ return []; // the grace never started
165
+ const at = now();
166
+ if (at.getTime() - firstTick.getTime() < FIRST_RUN_GRACE_MS)
167
+ return [];
168
+ const due = [];
169
+ for (const row of await host.users.list()) {
170
+ const settings = await readSettings(host, row.user);
171
+ if (!settings.enabled || settings.paused)
172
+ continue;
173
+ if (!(await isQuiet(host, row.user, at)))
174
+ continue;
175
+ const pass = await readPass(host, row.user);
176
+ if (pass !== undefined && pass.done >= pass.steps.length) {
177
+ // A completed pass: due again only past its 24h window.
178
+ if (at.getTime() - Date.parse(pass.startedAt) < FULL_PASS_INTERVAL_MS) {
179
+ continue;
180
+ }
181
+ }
182
+ due.push(row.user);
183
+ }
184
+ return due;
185
+ }
186
+ /**
187
+ * Create loop 2: the tick the factory's task calls (a thin binding of
188
+ * {@link tick} to its deps). The durable cadence stays the job's —
189
+ * this creates no timers.
190
+ */
191
+ export function createLoop2(deps) {
192
+ const now = deps.now ?? (() => new Date());
193
+ return {
194
+ tick: () => tick(deps.host, now, { log: deps.log }),
195
+ };
196
+ }
197
+ /**
198
+ * One tick: as many pass fragments as fit before the 5-minute
199
+ * self-deadline. Per-user failures are logged and never break the
200
+ * tick — the cursor makes every user resumable.
201
+ */
202
+ export async function tick(host, now, opts = {}) {
203
+ const log = opts.log ?? ((message) => console.error(`curator: ${message}`));
204
+ const startedAt = now();
205
+ const firstTick = await readFirstTick(host);
206
+ if (firstTick === undefined) {
207
+ // The install instant: the grace window opens now, and no pass
208
+ // runs before it ends (24h of quiet install first).
209
+ await writeFirstTick(host, startedAt);
210
+ return;
211
+ }
212
+ if (startedAt.getTime() - firstTick.getTime() < FIRST_RUN_GRACE_MS)
213
+ return;
214
+ const deadlineAt = startedAt.getTime() + LOOP2_TICK_DEADLINE_MS;
215
+ for (const user of await eligibleUsers(host, now)) {
216
+ if (now().getTime() >= deadlineAt)
217
+ break;
218
+ await runPass(host, user, now, deadlineAt, log).catch((error) => {
219
+ log(`loop 2: pass for ${user} failed: ${error instanceof Error ? error.message : String(error)}`);
220
+ });
221
+ }
222
+ }
223
+ /** The step table: B3's deterministic steps and B4's model steps. */
224
+ const STEP_RUNNERS = {
225
+ archive: archiveUnused,
226
+ "overlay-detect": detectOverlayDrift,
227
+ "overlay-recheck": recheckOverlays,
228
+ digests: digestsStep,
229
+ diet: dietStep,
230
+ merge: mergeStep,
231
+ targets: maintainTargets,
232
+ };
233
+ /**
234
+ * Walk one user's pass from its cursor: run steps while the tick's
235
+ * deadline holds, persisting `done` after every step. A MODEL step that
236
+ * answers false (the daily budget is spent) STOPS the pass for the day:
237
+ * the cursor keeps pointing at that step, and the next tick — or the
238
+ * next day's re-armed budget — resumes exactly there.
239
+ */
240
+ async function runPass(host, user, now, deadlineAt, log) {
241
+ const today = dayOf(now());
242
+ const read = await readPass(host, user);
243
+ let fresh;
244
+ if (read === undefined || read.done >= read.steps.length) {
245
+ // No pass yet, or the previous pass completed (its 24h window is
246
+ // why the user is eligible again): a fresh cursor from step one.
247
+ fresh = {
248
+ startedAt: now().toISOString(),
249
+ steps: [...PASS_STEPS],
250
+ done: 0,
251
+ dailyRunsUsed: 0,
252
+ day: today,
253
+ };
254
+ }
255
+ else if (read.day !== today) {
256
+ // A new day re-arms the model-run budget mid-pass.
257
+ fresh = { ...read, day: today, dailyRunsUsed: 0 };
258
+ }
259
+ else {
260
+ fresh = read;
261
+ }
262
+ // The cursor holder — one mutable slot every writer goes through, so
263
+ // the budget closure always sees the live cursor.
264
+ const state = { cursor: fresh };
265
+ await writePass(host, user, state.cursor);
266
+ // The daily model-run budget (Task B4): `dailyRuns` minus what today
267
+ // already spent. Every spend persists the cursor BEFORE its run —
268
+ // a crash may waste a run, it never overspends the day.
269
+ const settings = await readSettings(host, user);
270
+ const budget = {
271
+ left: () => Math.max(0, settings.dailyRuns - state.cursor.dailyRunsUsed),
272
+ spend: async () => {
273
+ if (state.cursor.dailyRunsUsed >= settings.dailyRuns)
274
+ return false;
275
+ state.cursor = {
276
+ ...state.cursor,
277
+ dailyRunsUsed: state.cursor.dailyRunsUsed + 1,
278
+ };
279
+ await writePass(host, user, state.cursor);
280
+ return true;
281
+ },
282
+ };
283
+ while (state.cursor.done < state.cursor.steps.length) {
284
+ const cursor = state.cursor;
285
+ // Out of tick budget: stop BEFORE the next step — the cursor is
286
+ // the resume point the next tick picks up.
287
+ if (now().getTime() >= deadlineAt)
288
+ return;
289
+ const step = cursor.steps[cursor.done];
290
+ const runner = STEP_RUNNERS[step];
291
+ const deps = {
292
+ host,
293
+ user,
294
+ now,
295
+ deadlineAt,
296
+ budget,
297
+ log,
298
+ };
299
+ try {
300
+ if (MODEL_STEPS.has(step)) {
301
+ const proceed = await runner(deps);
302
+ if (!proceed) {
303
+ // The daily cap (or the tick deadline) stopped the pass: the
304
+ // cursor stays at this step — the next tick resumes it, and a
305
+ // new day re-arms the budget.
306
+ log(`loop 2: ${user} stopped at step "${step}" for the day`);
307
+ return;
308
+ }
309
+ }
310
+ else {
311
+ await runner(host, user, now);
312
+ }
313
+ }
314
+ catch (error) {
315
+ // The step failed; the pass moves on — a deterministic step
316
+ // wedging the cursor forever would be worse than skipping it
317
+ // this pass (idempotent steps make a later pass safe).
318
+ log(`loop 2: ${user} step "${step}" failed: ${error instanceof Error ? error.message : String(error)}`);
319
+ }
320
+ state.cursor = { ...state.cursor, done: state.cursor.done + 1 };
321
+ await writePass(host, user, state.cursor);
322
+ }
323
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The post-review notice (`src/notices.ts`, Task B5): what the user
3
+ * sees when a review changed something — ONE ambient record on the
4
+ * originating conversation (`curator.notice`, place `status`), one
5
+ * line per change (`Learned: <summary>`), with a `navigate` action to
6
+ * the curator view anchored at the review.
7
+ *
8
+ * The ambient seam is the ONLY carrier (the brief): a notice is never
9
+ * a transcript entry (a fork would replay it) and never a notifier or
10
+ * channel delivery (gateway channels get no notice by default, spec
11
+ * P8). Ambient records are display-only — no action route reaches
12
+ * them — so Undo lives on the curator VIEW; the notice's button just
13
+ * navigates there.
14
+ *
15
+ * The body must pass the server's own ambient validation (a `status`
16
+ * slot allows ONE line-level block, literal content only — ambient
17
+ * bodies carry no data document, so bindings are rejected): a single
18
+ * `callout` whose title joins the change lines and whose actions
19
+ * carry the navigate button. One line-level block per seam rule.
20
+ *
21
+ * DURABILITY: each notice is remembered in `notices.json` under the
22
+ * user's state dir (`readNotices` is the view's lookup) — an Undo
23
+ * after a restart still knows which conversation's ambient record to
24
+ * clear. The file is bounded: recording a notice replaces the entry
25
+ * of the SAME review id (a re-run of one review id is one notice) and
26
+ * the file keeps the newest 50 (matching the feed window the view
27
+ * shows — older notices have nothing left to undo once the feed rolls
28
+ * past them).
29
+ */
30
+ import type { ConversationId } from "@earendil-works/pi-durable";
31
+ import type { CurationRunResult, SigrunHost } from "@sigrun/sigrun/extension";
32
+ import { CURATOR_NOTICE_KIND } from "./index.js";
33
+ export { CURATOR_NOTICE_KIND };
34
+ /** What one recorded notice remembers (the view's undo lookup). */
35
+ export interface NoticeRecord {
36
+ /** The review the notice announced (the undo key). */
37
+ readonly reviewId: string;
38
+ /** The conversation whose ambient slot carries the notice. */
39
+ readonly conversationId: ConversationId;
40
+ /** When the notice was set (ISO). */
41
+ readonly at: string;
42
+ }
43
+ /**
44
+ * Announce one completed review: when the result carries changes, set
45
+ * the `curator.notice` ambient record on the conversation and record
46
+ * the notice durably. No changes → nothing (an unchanged conversation
47
+ * stays untouched). Answers the notice record (undefined when no
48
+ * notice was set).
49
+ */
50
+ export declare function notify(host: SigrunHost, conversationId: ConversationId, user: string, result: CurationRunResult & {
51
+ readonly reviewId?: string;
52
+ }): Promise<NoticeRecord | undefined>;
53
+ /** Read the user's recorded notices (newest first); absent/malformed → []. */
54
+ export declare function readNotices(host: SigrunHost, user: string): Promise<readonly NoticeRecord[]>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The post-review notice (`src/notices.ts`, Task B5): what the user
3
+ * sees when a review changed something — ONE ambient record on the
4
+ * originating conversation (`curator.notice`, place `status`), one
5
+ * line per change (`Learned: <summary>`), with a `navigate` action to
6
+ * the curator view anchored at the review.
7
+ *
8
+ * The ambient seam is the ONLY carrier (the brief): a notice is never
9
+ * a transcript entry (a fork would replay it) and never a notifier or
10
+ * channel delivery (gateway channels get no notice by default, spec
11
+ * P8). Ambient records are display-only — no action route reaches
12
+ * them — so Undo lives on the curator VIEW; the notice's button just
13
+ * navigates there.
14
+ *
15
+ * The body must pass the server's own ambient validation (a `status`
16
+ * slot allows ONE line-level block, literal content only — ambient
17
+ * bodies carry no data document, so bindings are rejected): a single
18
+ * `callout` whose title joins the change lines and whose actions
19
+ * carry the navigate button. One line-level block per seam rule.
20
+ *
21
+ * DURABILITY: each notice is remembered in `notices.json` under the
22
+ * user's state dir (`readNotices` is the view's lookup) — an Undo
23
+ * after a restart still knows which conversation's ambient record to
24
+ * clear. The file is bounded: recording a notice replaces the entry
25
+ * of the SAME review id (a re-run of one review id is one notice) and
26
+ * the file keeps the newest 50 (matching the feed window the view
27
+ * shows — older notices have nothing left to undo once the feed rolls
28
+ * past them).
29
+ */
30
+ import { join } from "node:path";
31
+ import { CURATOR_NOTICE_KIND } from "./index.js";
32
+ export { CURATOR_NOTICE_KIND };
33
+ /** The notice ledger's file name under the user's state dir. */
34
+ const NOTICES_FILE = "notices.json";
35
+ /** How many notices the ledger keeps (the view's feed window). */
36
+ const NOTICE_LEDGER_MAX = 50;
37
+ /** The zero-signal context the ambient write runs under (the review
38
+ * body has no caller signal of its own — the run's deadline bound it). */
39
+ const CONTEXT = { abortSignal: undefined };
40
+ /**
41
+ * Announce one completed review: when the result carries changes, set
42
+ * the `curator.notice` ambient record on the conversation and record
43
+ * the notice durably. No changes → nothing (an unchanged conversation
44
+ * stays untouched). Answers the notice record (undefined when no
45
+ * notice was set).
46
+ */
47
+ export async function notify(host, conversationId, user, result) {
48
+ if (result.status !== "completed")
49
+ return undefined;
50
+ if (result.changes.length === 0)
51
+ return undefined;
52
+ const reviewId = result.reviewId;
53
+ if (reviewId === undefined || reviewId === "")
54
+ return undefined;
55
+ const at = new Date().toISOString();
56
+ await host.setAmbient(conversationId, {
57
+ key: CURATOR_NOTICE_KIND,
58
+ place: "status",
59
+ body: noticeBody(result.changes.map((change) => change.summary), reviewId),
60
+ }, CONTEXT);
61
+ const record = { reviewId, conversationId, at };
62
+ await recordNotice(host, user, record);
63
+ return record;
64
+ }
65
+ /**
66
+ * The notice body: ONE line-level `callout` (the `status` slot's
67
+ * one-line rule) whose title joins the change summaries and whose
68
+ * actions navigate to the curator view anchored at the review.
69
+ */
70
+ function noticeBody(summaries, reviewId) {
71
+ const lines = summaries.map((summary) => `Learned: ${summary}`);
72
+ return {
73
+ v: 1,
74
+ root: {
75
+ type: "callout",
76
+ id: "curator-notice",
77
+ tone: "neutral",
78
+ // The one-line rule makes the title the line the channels show;
79
+ // multiple changes join on the same line (a statusline slot).
80
+ title: lines.join(" · "),
81
+ body: "Open the curator view to review or undo.",
82
+ actions: [
83
+ {
84
+ id: "open",
85
+ label: "Curator",
86
+ icon: "book",
87
+ action: {
88
+ type: "navigate",
89
+ target: {
90
+ view: {
91
+ extension: "curator",
92
+ id: "curator",
93
+ params: { review: reviewId },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ ],
99
+ },
100
+ };
101
+ }
102
+ /** The ledger file path for one user. */
103
+ function noticesFile(host, user) {
104
+ return join(host.stores.user(user).paths.state, NOTICES_FILE);
105
+ }
106
+ /** Read the user's recorded notices (newest first); absent/malformed → []. */
107
+ export async function readNotices(host, user) {
108
+ const { readStateJson } = await import("./queue.js");
109
+ const parsed = await readStateJson(noticesFile(host, user), []);
110
+ if (!Array.isArray(parsed))
111
+ return [];
112
+ return parsed.filter((entry) => typeof entry === "object" &&
113
+ entry !== null &&
114
+ typeof entry.reviewId === "string" &&
115
+ typeof entry.conversationId === "number" &&
116
+ typeof entry.at === "string");
117
+ }
118
+ /** Record one notice: replace the same review's entry, keep the newest 50. */
119
+ async function recordNotice(host, user, record) {
120
+ const { writeStateJson } = await import("./queue.js");
121
+ const current = await readNotices(host, user);
122
+ const next = [
123
+ record,
124
+ ...current.filter((entry) => entry.reviewId !== record.reviewId),
125
+ ].slice(0, NOTICE_LEDGER_MAX);
126
+ await writeStateJson(host.stores.user(user).paths.state, NOTICES_FILE, next);
127
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The review prompt (`src/prompts.ts`, Task B2): what a fork review or
3
+ * digest review submits as its only input. The fork carries the
4
+ * parent's transcript, so the instruction is the reviewer's brief, not
5
+ * the material.
6
+ *
7
+ * The CONTENT is spec §6.2's review-prompt requirements (the wording is
8
+ * ours; the snapshot test pins it once written):
9
+ *
10
+ * - what a skill is — a procedure for a repeatable class of task, for
11
+ * this user; pitfalls as a rule plus one clause of why; no incident
12
+ * narration, dates, PR numbers or quoted chatter;
13
+ * - the do-not-capture list — environment failures, negative claims
14
+ * about tools, transient errors, unresolved failures presented as
15
+ * workflows, one-off tasks, codebase facts (the repo's AGENTS.md),
16
+ * secrets, and personal facts (a memory target when one is offered);
17
+ * - the routing rule — each lesson goes in exactly one place: one
18
+ * skill, one overlay, or one target;
19
+ * - update the skill in play before creating one; class-level names;
20
+ * - "Nothing to save" is a valid outcome;
21
+ * - never store another participant's words;
22
+ * - the hint arguments and exactly the allowed tools.
23
+ *
24
+ * Loop 2's digest reviews reuse this instruction verbatim (Task B4).
25
+ */
26
+ import type { CurationHint } from "@sigrun/sigrun/extension";
27
+ /** What `reviewInstruction` needs (the brief's exact shape). */
28
+ export interface ReviewInstructionOpts {
29
+ /** Owner write attempts the review folds in (empty: none pending). */
30
+ readonly hints: readonly CurationHint[];
31
+ /** Curation targets offered to this run, with their guidance. */
32
+ readonly targets: readonly {
33
+ readonly id: string;
34
+ readonly guidance: readonly string[];
35
+ }[];
36
+ /** The run's allow list — the instruction names these and no others. */
37
+ readonly allow: readonly string[];
38
+ /** `/learn`'s optional focus, riding along when given. */
39
+ readonly focus?: string;
40
+ }
41
+ /**
42
+ * Build the review instruction (the run's only input). Deterministic:
43
+ * fixed inputs produce the fixed text the snapshot pins.
44
+ */
45
+ export declare function reviewInstruction(opts: ReviewInstructionOpts): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The review prompt (`src/prompts.ts`, Task B2): what a fork review or
3
+ * digest review submits as its only input. The fork carries the
4
+ * parent's transcript, so the instruction is the reviewer's brief, not
5
+ * the material.
6
+ *
7
+ * The CONTENT is spec §6.2's review-prompt requirements (the wording is
8
+ * ours; the snapshot test pins it once written):
9
+ *
10
+ * - what a skill is — a procedure for a repeatable class of task, for
11
+ * this user; pitfalls as a rule plus one clause of why; no incident
12
+ * narration, dates, PR numbers or quoted chatter;
13
+ * - the do-not-capture list — environment failures, negative claims
14
+ * about tools, transient errors, unresolved failures presented as
15
+ * workflows, one-off tasks, codebase facts (the repo's AGENTS.md),
16
+ * secrets, and personal facts (a memory target when one is offered);
17
+ * - the routing rule — each lesson goes in exactly one place: one
18
+ * skill, one overlay, or one target;
19
+ * - update the skill in play before creating one; class-level names;
20
+ * - "Nothing to save" is a valid outcome;
21
+ * - never store another participant's words;
22
+ * - the hint arguments and exactly the allowed tools.
23
+ *
24
+ * Loop 2's digest reviews reuse this instruction verbatim (Task B4).
25
+ */
26
+ /**
27
+ * Build the review instruction (the run's only input). Deterministic:
28
+ * fixed inputs produce the fixed text the snapshot pins.
29
+ */
30
+ export function reviewInstruction(opts) {
31
+ const lines = [
32
+ "You are the curator. Review the conversation above and distill durable lessons for this user.",
33
+ ];
34
+ if (opts.focus !== undefined) {
35
+ lines.push(`Focus: ${opts.focus}.`);
36
+ }
37
+ lines.push("", "A skill is a procedure for a repeatable class of task, for this user. State pitfalls as a rule plus one clause of why. No incident narration, no dates, no PR numbers, no quoted chatter.", "", "Do NOT capture: environment failures; negative claims about tools; transient errors; unresolved failures presented as workflows; one-off tasks; codebase facts (those belong in the repo's AGENTS.md); secrets; personal facts (those go to the memory target when one is offered). Never store another participant's words.", "", "Each lesson goes in exactly one place: one skill, one overlay, or one target. Update the skill in play before creating one; pick class-level names.", "", '"Nothing to save" is a valid outcome — when nothing qualifies, finish with that sentence and change nothing.');
38
+ if (opts.targets.length > 0) {
39
+ lines.push("", "Targets:");
40
+ for (const target of opts.targets) {
41
+ for (const line of target.guidance) {
42
+ lines.push(`- ${target.id}: ${line}`);
43
+ }
44
+ if (target.guidance.length === 0) {
45
+ lines.push(`- ${target.id}.`);
46
+ }
47
+ }
48
+ }
49
+ if (opts.hints.length > 0) {
50
+ lines.push("", "The user made these write attempts between reviews — fold their intent in:");
51
+ for (const [index, hint] of opts.hints.entries()) {
52
+ lines.push(`${index + 1}. ${hint.tool} ${JSON.stringify(hint.args)} at ${hint.at}`);
53
+ }
54
+ }
55
+ lines.push("", `Your tools: ${opts.allow.join(", ")}. Nothing else.`);
56
+ return `${lines.join("\n")}\n`;
57
+ }