@sigrun/ext-curator 0.1.0 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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
+ ## [0.2.0] - 2026-11-03
9
+ ### Changed
10
+ - The curator view follows core Skills: the learned-skills and overlays
11
+ sections are gone (the core `core/skills` view owns them), replaced by
12
+ a "Skills" page action that navigates to the Skills tab. The view keeps
13
+ the review feed, the pause switch, and the memory targets.
14
+ - The feed is git-backed: entry ids are commit SHAs, `actor` includes
15
+ `outside` (rendered "outside sigrun"), undo groups key on the newest
16
+ commit of a review, and a deep-linked `review` param filters the feed
17
+ (paging up to three feed pages to find older reviews). Undo resolves
18
+ the group sha back to the review id before dispatching; a failing undo
19
+ is a named action error, never a thrown action.
20
+
21
+ ## [0.1.1] - 2026-11-03
22
+ ### Fixed
23
+ - Curator questions about your skill notes now open their own conversation
24
+ instead of landing in your latest one; unanswered questions no longer
25
+ accumulate. Questions asked before this version stay answerable where they
26
+ were asked.
27
+ - A review in flight is no longer cancelled by a re-rendered conversation
28
+ view; only genuinely new activity on the parent aborts it.
29
+
8
30
  ## [0.1.0] - 2026-11-02
9
31
  ### Added
10
32
  - Scaffold: the `createCurator` factory (the loader contract) declaring the
package/dist/loop1.js CHANGED
@@ -163,12 +163,24 @@ export function createLoop1(deps) {
163
163
  allow,
164
164
  ...(focus === undefined ? {} : { focus }),
165
165
  });
166
- // The parent abort: any activity on the parent while the review
167
- // runs fires the signal — the review reviews the conversation it
168
- // saw (the host-side driver re-checks the same state itself).
166
+ // The parent abort: CHURN on the parent while the review runs
167
+ // fires the signal — the review reviews the conversation it saw
168
+ // (the host-side driver re-checks the same state itself). The
169
+ // watch's first frame is the acquisition snapshot (the baseline),
170
+ // never churn; only a frame carrying MORE model entries than the
171
+ // baseline means the parent actually moved. A re-rendered view
172
+ // (a status refresh at the same entry count) is not churn and
173
+ // must not kill an in-flight review (the coarse-abort ruling).
169
174
  const abort = new AbortController();
170
- const stopWatch = await deps.host.conversations.watch(id, () => {
171
- abort.abort();
175
+ let baseline;
176
+ const stopWatch = await deps.host.conversations.watch(id, (view) => {
177
+ const count = view.entries.length;
178
+ if (baseline === undefined) {
179
+ baseline = count; // the snapshot is the baseline, never churn
180
+ return;
181
+ }
182
+ if (count > baseline)
183
+ abort.abort();
172
184
  });
173
185
  let result;
174
186
  try {
@@ -27,9 +27,9 @@
27
27
  * step) drains the queue, one `digestRun` per drifted key, and applies
28
28
  * the run's verdict — keep (re-anchor the same text on the new hash),
29
29
  * drop (remove), rewrite (put the verdict's new text), or unsure (ONE
30
- * `curator.overlay-question` interaction in the user's most recent
31
- * owned conversation, applied by `overlayQuestionHandler` from
32
- * `interactionSettled`). An unparseable verdict changes nothing.
30
+ * `curator.overlay-question` interaction per skill in ONE new
31
+ * user-owned conversation per pass, applied by `overlayQuestionHandler`
32
+ * from `interactionSettled`). An unparseable verdict changes nothing.
33
33
  */
34
34
  import type { CurationOverlayKey, SigrunHost, SigrunLifecycleHandlers } from "@sigrun/sigrun/extension";
35
35
  import { type ModelStepDeps } from "./run.js";
@@ -79,10 +79,14 @@ export declare function detectOverlayDrift(host: SigrunHost, user: string): Prom
79
79
  export declare function recheckOverlays(deps: ModelStepDeps): Promise<boolean>;
80
80
  /**
81
81
  * The `interactionSettled` half: apply a settled overlay question's
82
- * answer. Only ANSWERED records with a `verdict` of keep/drop apply;
83
- * every other terminal status (skipped, expired, cancelled) changes
84
- * nothing. `keep` re-anchors the remembered overlay text on the
85
- * remembered hash; `drop` removes the overlay. Idempotent per callId
86
- * (an applied question leaves the pending file).
82
+ * answer. EVERY terminal status (answered, skipped, cancelled, expired)
83
+ * drops the entry from the pending file — the file is bounded by
84
+ * terminal statuses (one open entry per skill key). Only ANSWERED
85
+ * records with a `verdict` of keep/drop apply; `keep` re-anchors the
86
+ * remembered overlay text on the remembered hash; `drop` removes the
87
+ * overlay. The callId is looked up in BOTH the v2 `questions` map and
88
+ * the migrated `legacy` list, so pre-v2 questions stay answerable
89
+ * where they were asked. Idempotent per callId (an applied question
90
+ * leaves the pending file).
87
91
  */
88
92
  export declare function overlayQuestionHandler(host: SigrunHost): Pick<SigrunLifecycleHandlers, "interactionSettled">;
@@ -27,9 +27,9 @@
27
27
  * step) drains the queue, one `digestRun` per drifted key, and applies
28
28
  * the run's verdict — keep (re-anchor the same text on the new hash),
29
29
  * drop (remove), rewrite (put the verdict's new text), or unsure (ONE
30
- * `curator.overlay-question` interaction in the user's most recent
31
- * owned conversation, applied by `overlayQuestionHandler` from
32
- * `interactionSettled`). An unparseable verdict changes nothing.
30
+ * `curator.overlay-question` interaction per skill in ONE new
31
+ * user-owned conversation per pass, applied by `overlayQuestionHandler`
32
+ * from `interactionSettled`). An unparseable verdict changes nothing.
33
33
  */
34
34
  import { randomUUID } from "node:crypto";
35
35
  import { join } from "node:path";
@@ -115,12 +115,9 @@ export async function detectOverlayDrift(host, user) {
115
115
  const RECHECK_ALLOW = ["skill_manage"];
116
116
  /** The pending questions file under the user's state dir. */
117
117
  const PENDING_QUESTIONS_FILE = "overlay-questions.json";
118
- /** Read the user's pending overlay questions; absent/malformed → []. */
119
- async function readPendingQuestions(host, user) {
120
- const parsed = await readStateJson(join(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE), []);
121
- if (!Array.isArray(parsed))
122
- return [];
123
- return parsed.filter((entry) => typeof entry === "object" &&
118
+ /** Structural check: one parsed entry is a QuestionDetails. */
119
+ function isQuestion(entry) {
120
+ return (typeof entry === "object" &&
124
121
  entry !== null &&
125
122
  typeof entry.user === "string" &&
126
123
  typeof entry.key?.root === "string" &&
@@ -129,9 +126,38 @@ async function readPendingQuestions(host, user) {
129
126
  typeof entry.overlayText === "string" &&
130
127
  typeof entry.callId === "string");
131
128
  }
132
- /** Write the user's pending overlay questions. */
133
- async function writePendingQuestions(host, user, questions) {
134
- await writeStateJson(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE, questions);
129
+ /** Read the user's pending overlay questions (absent/malformed → empty;
130
+ * a legacy top-level ARRAY reads as v1: every entry lands in `legacy`
131
+ * unchanged). The write shape is always v2. */
132
+ async function readPendingQuestions(host, user) {
133
+ const parsed = await readStateJson(join(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE), { v: 2, questions: {}, legacy: [] });
134
+ if (Array.isArray(parsed)) {
135
+ return { questions: {}, legacy: parsed.filter(isQuestion) };
136
+ }
137
+ if (typeof parsed !== "object" || parsed === null) {
138
+ return { questions: {}, legacy: [] };
139
+ }
140
+ const raw = parsed;
141
+ const questions = {};
142
+ if (typeof raw.questions === "object" &&
143
+ raw.questions !== null &&
144
+ !Array.isArray(raw.questions)) {
145
+ for (const [id, entry] of Object.entries(raw.questions)) {
146
+ if (isQuestion(entry) &&
147
+ typeof entry.conversationId === "number") {
148
+ questions[id] = entry;
149
+ }
150
+ }
151
+ }
152
+ const legacy = Array.isArray(raw.legacy)
153
+ ? raw.legacy.filter(isQuestion)
154
+ : [];
155
+ return { questions, legacy };
156
+ }
157
+ /** Write the user's pending overlay questions (the v2 shape — legacy
158
+ * questions keep their entries but the file is v2 from here on). */
159
+ async function writePendingQuestions(host, user, file) {
160
+ await writeStateJson(host.stores.user(user).paths.state, PENDING_QUESTIONS_FILE, { v: 2, questions: file.questions, legacy: file.legacy });
135
161
  }
136
162
  /**
137
163
  * The overlay-recheck step (loop 2 step `overlay-recheck`): drain the
@@ -155,10 +181,13 @@ export async function recheckOverlays(deps) {
155
181
  if (deps.budget.left() < 1)
156
182
  return false; // the day is spent: stop the pass
157
183
  const keys = [...(await new OverlayRecheckQueue(host, user).take())];
184
+ // ONE lazily created question conversation for the whole pass (spec
185
+ // §5.6: one conversation per recheck call, not one per question).
186
+ const conversation = { promised: undefined, seeded: 0 };
158
187
  for (const key of keys) {
159
188
  if (deps.now().getTime() >= deps.deadlineAt)
160
189
  return false;
161
- const applied = await recheckOne(deps, key).catch((error) => {
190
+ const applied = await recheckOne(deps, key, conversation).catch((error) => {
162
191
  log(`loop 2: ${user} overlay re-check of ${key.skill} failed: ${error instanceof Error ? error.message : String(error)}`);
163
192
  return true;
164
193
  });
@@ -168,7 +197,7 @@ export async function recheckOverlays(deps) {
168
197
  return true;
169
198
  }
170
199
  /** Re-check one key: run, parse the verdict, apply it. */
171
- async function recheckOne(deps, key) {
200
+ async function recheckOne(deps, key, conversation) {
172
201
  const { host, user } = deps;
173
202
  const store = host.curation.overlays(user);
174
203
  const overlay = await store.get(key);
@@ -204,7 +233,7 @@ async function recheckOne(deps, key) {
204
233
  await store.put(key, verdict.text, upstream.hash, meta);
205
234
  return true;
206
235
  case "unsure":
207
- await askOverlayQuestion(deps, key, overlay.text, upstream.hash);
236
+ await askOverlayQuestion(deps, key, overlay.text, upstream.hash, conversation);
208
237
  return true;
209
238
  default:
210
239
  return true; // unknown verdict word: no change
@@ -233,22 +262,66 @@ function recheckInput(overlayText, oldHash, newBody, newHash) {
233
262
  /** The zero-signal context the seed call runs under (a tick has no
234
263
  * caller signal — the job's deadline is the bound that matters). */
235
264
  const SEED_CONTEXT = { abortSignal: undefined };
265
+ /** The conversation's title: named for what it holds. */
266
+ function questionConversationTitle(seeded, skill) {
267
+ return seeded === 1 && skill !== undefined
268
+ ? `Your notes on "${skill}"`
269
+ : `Your notes on ${seeded} skills`;
270
+ }
271
+ /** The id of the pass's question conversation, creating it on the
272
+ * first question (owned by the user, titled for what it holds). */
273
+ async function questionConversation(deps, conversation, skill) {
274
+ if (conversation.promised === undefined) {
275
+ conversation.seeded = 1;
276
+ conversation.promised = deps.host.conversations.create({
277
+ owner: deps.user,
278
+ name: questionConversationTitle(1, skill),
279
+ });
280
+ return conversation.promised;
281
+ }
282
+ const id = await conversation.promised;
283
+ conversation.seeded += 1;
284
+ // A second question renames the conversation to cover what it now
285
+ // holds; a failed rename never loses the question (logged only).
286
+ try {
287
+ await deps.host.conversations.patchMeta(id, {
288
+ name: questionConversationTitle(conversation.seeded),
289
+ });
290
+ }
291
+ catch (error) {
292
+ stepLog(deps)(`loop 2: renaming the overlay-question conversation failed: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ return id;
295
+ }
236
296
  /**
237
- * Seed ONE `curator.overlay-question` in the user's MOST RECENT owned
238
- * conversation and remember it durably (the settled handler needs the
239
- * overlay text and hash — the interaction's `details` carry them, and
240
- * the pending-questions file is the crash-safe copy: an answered
241
- * question whose apply crashed re-applies on the next handler fire).
297
+ * Ask ONE `curator.overlay-question` for a drifted overlay: the pass's
298
+ * shared NEW conversation (created lazily, owned by the user) gets the
299
+ * question, and the pending file keeps one open question per skill —
300
+ * the bounding rule (spec §5.5, ruling 8: no timers, no auto-expiry;
301
+ * keying and terminal statuses bound the file, never age).
302
+ *
303
+ * Order per question: create the conversation if needed, cancel the
304
+ * skill's old open question (a re-ask replaces it; a GONE old
305
+ * conversation — settle resolves false or rejects — still lets the new
306
+ * question be asked), seed, write the file. Only then, for the first
307
+ * question of the pass, send the owner a notice linking to the
308
+ * conversation; a FAILED notice never loses the question (try/catch-log).
242
309
  */
243
- async function askOverlayQuestion(deps, key, overlayText, upstreamHash) {
310
+ async function askOverlayQuestion(deps, key, overlayText, upstreamHash, conversation) {
244
311
  const { host, user } = deps;
245
- const owned = await host.conversations.list({ owner: user });
246
- const withActivity = owned
247
- .filter((row) => row.lastActivityAt !== undefined)
248
- .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt));
249
- const target = withActivity[0] ?? owned[owned.length - 1];
250
- if (target === undefined)
251
- return; // no owned conversation to ask in
312
+ const log = stepLog(deps);
313
+ const file = await readPendingQuestions(host, user);
314
+ const id = keyId(key);
315
+ const old = file.questions[id];
316
+ const conversationId = await questionConversation(deps, conversation, key.skill);
317
+ if (old !== undefined) {
318
+ try {
319
+ await host.settleInteraction(old.conversationId, old.callId, "cancelled", SEED_CONTEXT);
320
+ }
321
+ catch (error) {
322
+ log(`loop 2: cancelling the previous overlay question for ${key.skill} failed: ${error instanceof Error ? error.message : String(error)}`);
323
+ }
324
+ }
252
325
  const callId = `overlay-question-${randomUUID()}`;
253
326
  const details = {
254
327
  user,
@@ -267,8 +340,28 @@ async function askOverlayQuestion(deps, key, overlayText, upstreamHash) {
267
340
  // contract; the structural QuestionDetails shape is JSON-safe.
268
341
  details: details,
269
342
  };
270
- await host.seedInteraction(target.id, record, SEED_CONTEXT);
271
- await writePendingQuestions(host, user, [...(await readPendingQuestions(host, user)).filter((q) => q.callId !== callId), details]);
343
+ await host.seedInteraction(conversationId, record, SEED_CONTEXT);
344
+ await writePendingQuestions(host, user, {
345
+ questions: {
346
+ ...file.questions,
347
+ [id]: { ...details, conversationId: conversationId },
348
+ },
349
+ legacy: file.legacy,
350
+ });
351
+ if (conversation.seeded !== 1)
352
+ return; // only the first question notifies
353
+ try {
354
+ await host.notify({
355
+ user,
356
+ title: "Notes to review",
357
+ body: `The curator has a question about your notes on "${key.skill}".`,
358
+ kind: CURATOR_OVERLAY_QUESTION_KIND,
359
+ target: { conversation: conversationId },
360
+ });
361
+ }
362
+ catch (error) {
363
+ log(`loop 2: the overlay-question notice for ${user} failed: ${error instanceof Error ? error.message : String(error)}`);
364
+ }
272
365
  }
273
366
  /** The question's form body: exactly keep / drop. The overlay text
274
367
  * rides a section (a form's children are fields and sections only;
@@ -305,54 +398,79 @@ function questionBody(key, overlayText) {
305
398
  }
306
399
  /**
307
400
  * The `interactionSettled` half: apply a settled overlay question's
308
- * answer. Only ANSWERED records with a `verdict` of keep/drop apply;
309
- * every other terminal status (skipped, expired, cancelled) changes
310
- * nothing. `keep` re-anchors the remembered overlay text on the
311
- * remembered hash; `drop` removes the overlay. Idempotent per callId
312
- * (an applied question leaves the pending file).
401
+ * answer. EVERY terminal status (answered, skipped, cancelled, expired)
402
+ * drops the entry from the pending file — the file is bounded by
403
+ * terminal statuses (one open entry per skill key). Only ANSWERED
404
+ * records with a `verdict` of keep/drop apply; `keep` re-anchors the
405
+ * remembered overlay text on the remembered hash; `drop` removes the
406
+ * overlay. The callId is looked up in BOTH the v2 `questions` map and
407
+ * the migrated `legacy` list, so pre-v2 questions stay answerable
408
+ * where they were asked. Idempotent per callId (an applied question
409
+ * leaves the pending file).
313
410
  */
314
411
  export function overlayQuestionHandler(host) {
315
412
  return {
316
413
  interactionSettled: async (event, _api, _context) => {
317
414
  if (event.kind !== CURATOR_OVERLAY_QUESTION_KIND)
318
415
  return;
319
- if (event.status !== "answered")
416
+ if (event.status !== "answered" &&
417
+ event.status !== "skipped" &&
418
+ event.status !== "cancelled" &&
419
+ event.status !== "expired")
320
420
  return;
321
- const verdict = event.answer?.["verdict"];
322
- if (verdict !== "keep" && verdict !== "drop")
421
+ const found = await findQuestion(host, event.callId);
422
+ if (found === undefined)
323
423
  return;
324
- const questions = await readQuestionsFor(host, event.callId);
325
- for (const question of questions) {
326
- const store = host.curation.overlays(question.user);
327
- const meta = {
328
- actor: "curator",
329
- reason: `user chose to ${verdict} the overlay`,
330
- };
331
- if (verdict === "keep") {
332
- await store.put(question.key, question.overlayText, question.upstreamHash, meta);
424
+ const { user, question } = found;
425
+ if (event.status === "answered") {
426
+ const verdict = event.answer?.["verdict"];
427
+ if (verdict === "keep" || verdict === "drop") {
428
+ const store = host.curation.overlays(user);
429
+ const meta = {
430
+ actor: "curator",
431
+ reason: `user chose to ${verdict} the overlay`,
432
+ };
433
+ if (verdict === "keep") {
434
+ await store.put(question.key, question.overlayText, question.upstreamHash, meta);
435
+ }
436
+ else {
437
+ await store.remove(question.key, meta);
438
+ }
333
439
  }
334
- else {
335
- await store.remove(question.key, meta);
336
- }
337
- await dropQuestion(host, question.user, question.callId);
338
440
  }
441
+ await dropQuestion(host, user, question.callId);
339
442
  },
340
443
  };
341
444
  }
342
- /** Read one user's pending questions — the handler scans every user's
343
- * file (the event names the callId, not the user). */
344
- async function readQuestionsFor(host, callId) {
345
- const found = [];
445
+ /** Find one pending question by callId — the handler scans every user's
446
+ * file (the event names the callId, not the user), both the v2
447
+ * `questions` map and the migrated `legacy` list. */
448
+ async function findQuestion(host, callId) {
346
449
  for (const row of await host.users.list()) {
347
- for (const question of await readPendingQuestions(host, row.user)) {
450
+ const file = await readPendingQuestions(host, row.user);
451
+ for (const question of Object.values(file.questions)) {
348
452
  if (question.callId === callId)
349
- found.push(question);
453
+ return { user: row.user, question };
454
+ }
455
+ for (const question of file.legacy) {
456
+ if (question.callId === callId)
457
+ return { user: row.user, question };
350
458
  }
351
459
  }
352
- return found;
460
+ return undefined;
353
461
  }
354
- /** Remove an applied question from its user's pending file. */
462
+ /** Remove a question from its user's pending file — from BOTH the v2
463
+ * map (by matching callId, in case the file drifted) and the legacy
464
+ * list. */
355
465
  async function dropQuestion(host, user, callId) {
356
- const remaining = (await readPendingQuestions(host, user)).filter((question) => question.callId !== callId);
357
- await writePendingQuestions(host, user, remaining);
466
+ const file = await readPendingQuestions(host, user);
467
+ const questions = {};
468
+ for (const [id, question] of Object.entries(file.questions)) {
469
+ if (question.callId !== callId)
470
+ questions[id] = question;
471
+ }
472
+ await writePendingQuestions(host, user, {
473
+ questions,
474
+ legacy: file.legacy.filter((question) => question.callId !== callId),
475
+ });
358
476
  }
package/dist/view.d.ts CHANGED
@@ -1,9 +1,23 @@
1
1
  /**
2
- * The curator view (`src/view.ts`, Task B5): the per-user surface the
3
- * notice navigates to — learned skills (name, description, last used,
4
- * pin toggle, archive/restore), overlays (orphaned flag), the change
5
- * feed (newest 50) grouped by review with an **Undo review** action,
6
- * and a pause switch.
2
+ * The curator view (`src/view.ts`, tabs+skills Task 13): the per-user
3
+ * surface the notice and the core Skills history link navigate to —
4
+ * the change FEED (newest 50) with an **Undo review** action per
5
+ * review, and a pause switch. Skill management (list, pin,
6
+ * archive/restore, overlays) moved to the core Skills view when it
7
+ * landed (core-views/skills.ts): this view lists no learned skills and
8
+ * no overlays any more, and its **Skills** page action navigates
9
+ * `{target:{view:{extension:"core", id:"skills"}}}`.
10
+ *
11
+ * A `review` param filters the feed to that review's commits (the core
12
+ * skills history's curator-commit link and the notice both navigate
13
+ * here with it). Feed entry ids are commit SHAs in the user's curation
14
+ * history repo (tabs+skills Task 9), so the undo groups key on the
15
+ * SHA, never on the review id: a review reverted through
16
+ * `host.curation.undoReview` needs every commit the review authored,
17
+ * and grouping by review id would merge two interleaved reviews into
18
+ * one undo. Actors render as their names — `curator`, `agent`, the
19
+ * user, and `outside sigrun` for catch-up commits the store itself
20
+ * did not author (the git trailer's actor name).
7
21
  *
8
22
  * Access follows `accessOf` (spec P8: "the user, plus the superuser as
9
23
  * with conversations"): the resource is the caller's OWN curation data,
@@ -17,19 +31,16 @@
17
31
  *
18
32
  * The actions (what an ambient record can never carry — a view's action
19
33
  * routes DO reach the extension). Per-item actions ride the block
20
- * set's item-scoped dispatches, which carry `{itemId}` (a card action
21
- * button's submit dispatches a `null` payload — it cannot name an
22
- * item, `onCheck`/`onActivate` can):
34
+ * set's item-scoped dispatches, which carry `{itemId}`:
23
35
  *
24
- * - `pin` — the skills collection's `checkable`/`onCheck`:
25
- * `{itemId: skill, value}` → the skill store's `pin`;
26
- * - `toggleArchive` — the skills collection's `onActivate`
27
- * (`{itemId: skill}`): reads the store's current state and archives
28
- * or restores through `apply` (feed-recorded, undoable);
29
36
  * - `undoReview` — the feed collection's `onActivate`
30
- * (`{itemId: reviewId}`): `host.curation.undoReview`, then
31
- * `host.clearAmbient` on the notice conversation's `curator.notice`
32
- * slot (the notice ledger in `src/notices.ts` remembers it);
37
+ * (`{itemId: groupId}` where the group id is `review-<newest sha>`):
38
+ * resolves the sha back to the entry's review id through the feed
39
+ * (core's `undoReview` matches `entry.reviewId`, never the group
40
+ * key), then `host.curation.undoReview`, then `host.clearAmbient` on
41
+ * the notice conversation's `curator.notice` slot (the notice ledger
42
+ * in `src/notices.ts` remembers it); a commit with no review id
43
+ * answers "Nothing to undo" without calling the host;
33
44
  * - `setPaused` — the page header's pause button (a submit dispatching
34
45
  * `{}`): toggles the user's `paused` setting, read live by both
35
46
  * loops — pausing takes effect on the next event, restart-free.
@@ -43,6 +54,11 @@ import type { CurationFeedEntry, Principal, SigrunHost, SurfaceDef } from "@sigr
43
54
  import type { Access } from "@sigrun/shared/access";
44
55
  /** How many feed entries the view lists (the brief: newest 50). */
45
56
  export declare const VIEW_FEED_LIMIT = 50;
57
+ /** How many pages a `review` deep-link walks before giving up (the
58
+ * link arrives from the core skills history at ANY depth; core's
59
+ * own `feedOf` walks until the entries run out — this view bounds
60
+ * the walk and says so when the bound elapses). */
61
+ export declare const VIEW_FEED_MAX_PAGES = 3;
46
62
  /** One grouped review block in the feed (newest first). */
47
63
  export interface ReviewGroup {
48
64
  readonly id: string;
@@ -52,6 +68,7 @@ export interface ReviewGroup {
52
68
  readonly skill: string;
53
69
  readonly action: string;
54
70
  readonly at: string;
71
+ readonly actor: string;
55
72
  readonly reason?: string;
56
73
  }[];
57
74
  }
@@ -59,21 +76,11 @@ export interface ReviewGroup {
59
76
  export interface CuratorViewData {
60
77
  readonly user: string;
61
78
  readonly paused: boolean;
62
- readonly skills: readonly {
63
- readonly id: string;
64
- readonly name: string;
65
- readonly description: string;
66
- readonly archived: boolean;
67
- readonly pinned: boolean;
68
- readonly lastUsedLabel: string;
69
- }[];
70
- readonly overlays: readonly {
71
- readonly id: string;
72
- readonly skill: string;
73
- readonly root: string;
74
- readonly orphaned: boolean;
75
- }[];
76
79
  readonly reviews: readonly ReviewGroup[];
80
+ /** True when a `review` deep-link missed after the bounded walk
81
+ * (the empty state is then "older than the recent changes", never
82
+ * the false "Nothing learned yet"). */
83
+ readonly reviewMissed: boolean;
77
84
  }
78
85
  /**
79
86
  * Resolve one principal's access to their per-user view instance (spec
@@ -84,9 +91,15 @@ export interface CuratorViewData {
84
91
  * rule would widen to household write, so anonymous is none BY HAND.
85
92
  */
86
93
  export declare function viewAccess(principal: Principal, user: string): Access;
87
- /** Group a feed by review: entries sharing a reviewId form one group
88
- * (the feed's newest-first order preserved, outside and inside). An
89
- * entry with no reviewId (user pins, direct edits) groups alone. */
94
+ /** One actor's display name (the git trailer's name: outside
95
+ * catch-up commits render as "outside sigrun"). */
96
+ export declare function actorLabel(entry: CurationFeedEntry): string;
97
+ /** Group a feed for undo: entries SHARING A REVIEW ID AND ADJACENT in
98
+ * the newest-first feed form one group — the group's id is its newest
99
+ * commit sha (`review-<sha>`). Keying on the sha (never the review
100
+ * id) keeps two interleaved reviews apart: undoing one must revert
101
+ * exactly the commits it authored. An entry with no reviewId groups
102
+ * alone (a lone commit is never undoable as a review). */
90
103
  export declare function groupFeed(feed: readonly CurationFeedEntry[]): readonly ReviewGroup[];
91
104
  /** The view surface (`SurfaceDef<"view">`), built over the host. */
92
105
  export declare function createCuratorView(host: SigrunHost): SurfaceDef<"view">;
package/dist/view.js CHANGED
@@ -1,9 +1,23 @@
1
1
  /**
2
- * The curator view (`src/view.ts`, Task B5): the per-user surface the
3
- * notice navigates to — learned skills (name, description, last used,
4
- * pin toggle, archive/restore), overlays (orphaned flag), the change
5
- * feed (newest 50) grouped by review with an **Undo review** action,
6
- * and a pause switch.
2
+ * The curator view (`src/view.ts`, tabs+skills Task 13): the per-user
3
+ * surface the notice and the core Skills history link navigate to —
4
+ * the change FEED (newest 50) with an **Undo review** action per
5
+ * review, and a pause switch. Skill management (list, pin,
6
+ * archive/restore, overlays) moved to the core Skills view when it
7
+ * landed (core-views/skills.ts): this view lists no learned skills and
8
+ * no overlays any more, and its **Skills** page action navigates
9
+ * `{target:{view:{extension:"core", id:"skills"}}}`.
10
+ *
11
+ * A `review` param filters the feed to that review's commits (the core
12
+ * skills history's curator-commit link and the notice both navigate
13
+ * here with it). Feed entry ids are commit SHAs in the user's curation
14
+ * history repo (tabs+skills Task 9), so the undo groups key on the
15
+ * SHA, never on the review id: a review reverted through
16
+ * `host.curation.undoReview` needs every commit the review authored,
17
+ * and grouping by review id would merge two interleaved reviews into
18
+ * one undo. Actors render as their names — `curator`, `agent`, the
19
+ * user, and `outside sigrun` for catch-up commits the store itself
20
+ * did not author (the git trailer's actor name).
7
21
  *
8
22
  * Access follows `accessOf` (spec P8: "the user, plus the superuser as
9
23
  * with conversations"): the resource is the caller's OWN curation data,
@@ -17,19 +31,16 @@
17
31
  *
18
32
  * The actions (what an ambient record can never carry — a view's action
19
33
  * routes DO reach the extension). Per-item actions ride the block
20
- * set's item-scoped dispatches, which carry `{itemId}` (a card action
21
- * button's submit dispatches a `null` payload — it cannot name an
22
- * item, `onCheck`/`onActivate` can):
34
+ * set's item-scoped dispatches, which carry `{itemId}`:
23
35
  *
24
- * - `pin` — the skills collection's `checkable`/`onCheck`:
25
- * `{itemId: skill, value}` → the skill store's `pin`;
26
- * - `toggleArchive` — the skills collection's `onActivate`
27
- * (`{itemId: skill}`): reads the store's current state and archives
28
- * or restores through `apply` (feed-recorded, undoable);
29
36
  * - `undoReview` — the feed collection's `onActivate`
30
- * (`{itemId: reviewId}`): `host.curation.undoReview`, then
31
- * `host.clearAmbient` on the notice conversation's `curator.notice`
32
- * slot (the notice ledger in `src/notices.ts` remembers it);
37
+ * (`{itemId: groupId}` where the group id is `review-<newest sha>`):
38
+ * resolves the sha back to the entry's review id through the feed
39
+ * (core's `undoReview` matches `entry.reviewId`, never the group
40
+ * key), then `host.curation.undoReview`, then `host.clearAmbient` on
41
+ * the notice conversation's `curator.notice` slot (the notice ledger
42
+ * in `src/notices.ts` remembers it); a commit with no review id
43
+ * answers "Nothing to undo" without calling the host;
33
44
  * - `setPaused` — the page header's pause button (a submit dispatching
34
45
  * `{}`): toggles the user's `paused` setting, read live by both
35
46
  * loops — pausing takes effect on the next event, restart-free.
@@ -45,6 +56,11 @@ import { CURATOR_NOTICE_KIND } from "./notices.js";
45
56
  import { parseCuratorSettings } from "./settings.js";
46
57
  /** How many feed entries the view lists (the brief: newest 50). */
47
58
  export const VIEW_FEED_LIMIT = 50;
59
+ /** How many pages a `review` deep-link walks before giving up (the
60
+ * link arrives from the core skills history at ANY depth; core's
61
+ * own `feedOf` walks until the entries run out — this view bounds
62
+ * the walk and says so when the bound elapses). */
63
+ export const VIEW_FEED_MAX_PAGES = 3;
48
64
  /** The zero-signal context the ambient clear runs under. */
49
65
  const CONTEXT = { abortSignal: undefined };
50
66
  /**
@@ -60,84 +76,98 @@ export function viewAccess(principal, user) {
60
76
  return "none";
61
77
  return accessOf({ user: principal.user, superuser: principal.service }, { owner: user });
62
78
  }
63
- /** Group a feed by review: entries sharing a reviewId form one group
64
- * (the feed's newest-first order preserved, outside and inside). An
65
- * entry with no reviewId (user pins, direct edits) groups alone. */
79
+ /** One actor's display name (the git trailer's name: outside
80
+ * catch-up commits render as "outside sigrun"). */
81
+ export function actorLabel(entry) {
82
+ return entry.actor === "outside" ? "outside sigrun" : entry.actor;
83
+ }
84
+ /** Group a feed for undo: entries SHARING A REVIEW ID AND ADJACENT in
85
+ * the newest-first feed form one group — the group's id is its newest
86
+ * commit sha (`review-<sha>`). Keying on the sha (never the review
87
+ * id) keeps two interleaved reviews apart: undoing one must revert
88
+ * exactly the commits it authored. An entry with no reviewId groups
89
+ * alone (a lone commit is never undoable as a review). */
66
90
  export function groupFeed(feed) {
67
- const groups = new Map();
91
+ const groups = [];
92
+ let current;
93
+ let currentReview;
68
94
  for (const entry of feed) {
69
- const key = entry.reviewId ?? entry.id;
70
95
  const row = {
71
96
  id: entry.id,
72
97
  skill: entry.skill,
73
98
  action: entry.action,
74
99
  at: entry.at,
100
+ actor: actorLabel(entry),
75
101
  ...(entry.reason === undefined ? {} : { reason: entry.reason }),
76
102
  };
77
- const existing = groups.get(key);
78
- if (existing === undefined) {
79
- groups.set(key, { id: key, at: entry.at, entries: [row] });
103
+ if (entry.reviewId !== undefined &&
104
+ current !== undefined &&
105
+ currentReview === entry.reviewId) {
106
+ current = { ...current, entries: [...current.entries, row] };
107
+ groups[groups.length - 1] = current;
80
108
  }
81
109
  else {
82
- groups.set(key, { ...existing, entries: [...existing.entries, row] });
110
+ current = {
111
+ id: `review-${entry.id}`,
112
+ at: entry.at,
113
+ entries: [row],
114
+ };
115
+ groups.push(current);
83
116
  }
117
+ currentReview = entry.reviewId;
84
118
  }
85
- return [...groups.values()];
119
+ return groups;
120
+ }
121
+ /** The `review` param a render was anchored at (the core skills
122
+ * history link and the notice navigate with it), or undefined. */
123
+ function reviewParam(ctx) {
124
+ const review = ctx.params.review;
125
+ return typeof review === "string" && review !== "" ? review : undefined;
86
126
  }
87
- /** A human label for one skill's last-used stamp ("never" or the date). */
88
- function lastUsedLabel(at) {
89
- if (at === undefined)
90
- return "never";
91
- const date = new Date(at);
92
- return Number.isNaN(date.getTime()) ? "never" : date.toISOString().slice(0, 10);
127
+ /** Read the feed: the plain view is the newest VIEW_FEED_LIMIT only
128
+ * (the section's "newest 50" contract — every rendered group's sha
129
+ * resolves for undo from that same page). A `review` deep-link pages
130
+ * on (last sha as the cursor, core's feedOf shape, bounded) until
131
+ * the review's entries appear or the walk elapses; a miss on ANY
132
+ * exit path — short page or bound — is reported, never rendered as
133
+ * the false "Nothing learned yet". */
134
+ async function feedFor(host, user, review) {
135
+ const store = host.curation.skills(user);
136
+ const collected = [];
137
+ let before;
138
+ do {
139
+ const rows = await store.feed(VIEW_FEED_LIMIT, before);
140
+ collected.push(...rows);
141
+ if (rows.length < VIEW_FEED_LIMIT)
142
+ break; // the last page
143
+ if (review !== undefined &&
144
+ rows.some((entry) => entry.reviewId === review))
145
+ break; // the deep-link found its review
146
+ before = rows[rows.length - 1]?.id;
147
+ } while (review !== undefined && collected.length < VIEW_FEED_LIMIT * VIEW_FEED_MAX_PAGES);
148
+ return {
149
+ feed: collected,
150
+ reviewMissed: review !== undefined &&
151
+ !collected.some((entry) => entry.reviewId === review),
152
+ };
93
153
  }
94
154
  /** Build the view's data document for one user (the render's model). */
95
- async function readData(host, user) {
155
+ async function readData(host, user, review) {
96
156
  const settings = parseCuratorSettings(await host.stores.user(user).settings.get());
97
- const usage = await host.curation.usage(user);
98
- const skills = await host.curation.skills(user).list();
99
- const overlays = await host.curation.overlays(user).list();
100
- const feed = await host.curation.skills(user).feed(VIEW_FEED_LIMIT);
157
+ const { feed, reviewMissed } = await feedFor(host, user, review);
101
158
  return {
102
159
  user,
103
160
  paused: settings.paused,
104
- skills: [...skills]
105
- .sort((a, b) => a.name.localeCompare(b.name))
106
- .map((row) => ({
107
- id: row.name,
108
- name: row.name,
109
- description: row.description,
110
- archived: row.archived,
111
- pinned: row.pinned,
112
- lastUsedLabel: lastUsedLabel(usage[row.name]),
113
- })),
114
- overlays: [...overlays]
115
- .map((row) => ({
116
- id: row.key.skill,
117
- skill: row.key.skill,
118
- root: row.key.root,
119
- orphaned: row.orphanedSince !== undefined,
120
- }))
121
- .sort((a, b) => a.skill.localeCompare(b.skill)),
122
- reviews: groupFeed(feed),
161
+ reviews: groupFeed(review === undefined
162
+ ? feed
163
+ : feed.filter((entry) => entry.reviewId === review)),
164
+ reviewMissed,
123
165
  };
124
166
  }
125
167
  /** Whether `payload` (an action's JSON argument) is a plain object. */
126
168
  function isObject(payload) {
127
169
  return typeof payload === "object" && payload !== null && !Array.isArray(payload);
128
170
  }
129
- /** The `{itemId, value}` dispatch an `onCheck` toggle carries. */
130
- function itemToggle(payload) {
131
- if (!isObject(payload))
132
- return undefined;
133
- const itemId = payload.itemId;
134
- const value = payload.value;
135
- if (typeof itemId !== "string" || itemId === "")
136
- return undefined;
137
- if (typeof value !== "boolean")
138
- return undefined;
139
- return { itemId, value };
140
- }
141
171
  /** The `{itemId}` dispatch an `onActivate` click carries. */
142
172
  function itemIdOf(payload) {
143
173
  if (!isObject(payload))
@@ -174,7 +204,7 @@ export function createCuratorView(host) {
174
204
  if ("reject" in who) {
175
205
  return { tree: emptyTree(), data: {}, access: "none" };
176
206
  }
177
- const data = await readData(host, who.user);
207
+ const data = await readData(host, who.user, reviewParam(ctx));
178
208
  return {
179
209
  tree: viewTree(data),
180
210
  // SAFETY: the data document is a JSON mirror (every field a
@@ -190,7 +220,7 @@ export function createCuratorView(host) {
190
220
  return { data: {}, access: "none" };
191
221
  return {
192
222
  // SAFETY: same JSON-mirror cast as `render`'s (the comment there).
193
- data: (await readData(host, who.user)),
223
+ data: (await readData(host, who.user, reviewParam(ctx))),
194
224
  access: "write",
195
225
  };
196
226
  },
@@ -199,11 +229,65 @@ export function createCuratorView(host) {
199
229
  const who = callerOf(ctx);
200
230
  if ("reject" in who)
201
231
  return who.reject;
202
- const reviewId = itemIdOf(payload);
203
- if (reviewId === undefined) {
232
+ const itemId = itemIdOf(payload);
233
+ if (itemId === undefined) {
204
234
  return { ok: false, error: { message: "undoReview expects {itemId}" } };
205
235
  }
206
- const reverted = await host.curation.undoReview(who.user, reviewId);
236
+ // A failing undo (the store's report, or a host error) is a
237
+ // named action error — never a thrown action the surface
238
+ // layer renders generically (main's 774c660 semantics,
239
+ // kept through the git-history rework).
240
+ const fail = (cause) => ({
241
+ ok: false,
242
+ error: { message: cause instanceof Error ? cause.message : String(cause) },
243
+ });
244
+ // On a deep-link the param already names the review — and
245
+ // the group's sha may sit past page 1 of the feed, where
246
+ // the single-page lookup below cannot see it. Dispatch the
247
+ // param's review id directly.
248
+ const anchored = reviewParam(ctx);
249
+ if (anchored !== undefined) {
250
+ let reverted;
251
+ try {
252
+ reverted = await host.curation.undoReview(who.user, anchored);
253
+ }
254
+ catch (cause) {
255
+ return fail(cause);
256
+ }
257
+ const anchoredNotice = (await readNotices(host, who.user)).find((entry) => entry.reviewId === anchored);
258
+ if (anchoredNotice !== undefined) {
259
+ await host.clearAmbient(anchoredNotice.conversationId, CURATOR_NOTICE_KIND, CONTEXT).catch(() => undefined);
260
+ }
261
+ return {
262
+ ok: true,
263
+ toast: reverted > 0 ? `Undid ${reverted} change(s)` : "Nothing to undo",
264
+ };
265
+ }
266
+ // The dispatch carries the GROUP key (`review-<sha>`, minted
267
+ // by groupFeed); core's undoReview matches entry.reviewId.
268
+ // Resolve the sha back to the review id through the feed — a
269
+ // verbatim dispatch would match nothing and silently revert 0.
270
+ // A bare review id (the pre-upgrade coordinate, and digest
271
+ // ids like "digest-<date>" that already carry the prefix) is
272
+ // the fallback when no entry's sha matches.
273
+ const sha = itemId.startsWith("review-")
274
+ ? itemId.slice("review-".length)
275
+ : itemId;
276
+ const feed = await host.curation.skills(who.user).feed(VIEW_FEED_LIMIT);
277
+ const reviewId = feed.find((entry) => entry.id === sha)?.reviewId ??
278
+ (feed.some((entry) => entry.reviewId === itemId)
279
+ ? itemId
280
+ : undefined);
281
+ if (reviewId === undefined) {
282
+ return { ok: true, toast: "Nothing to undo" };
283
+ }
284
+ let reverted;
285
+ try {
286
+ reverted = await host.curation.undoReview(who.user, reviewId);
287
+ }
288
+ catch (cause) {
289
+ return fail(cause);
290
+ }
207
291
  // Clear the notice this review set, when the ledger remembers
208
292
  // one (a digest review with no notice clears nothing).
209
293
  const notice = (await readNotices(host, who.user)).find((entry) => entry.reviewId === reviewId);
@@ -218,45 +302,6 @@ export function createCuratorView(host) {
218
302
  toast: reverted > 0 ? `Undid ${reverted} change(s)` : "Nothing to undo",
219
303
  };
220
304
  },
221
- pin: async (ctx, payload) => {
222
- const who = callerOf(ctx);
223
- if ("reject" in who)
224
- return who.reject;
225
- const toggle = itemToggle(payload);
226
- if (toggle === undefined) {
227
- return { ok: false, error: { message: "pin expects {itemId, value}" } };
228
- }
229
- await host.curation.skills(who.user).pin(toggle.itemId, toggle.value);
230
- return {
231
- ok: true,
232
- toast: toggle.value ? `Pinned ${toggle.itemId}` : `Unpinned ${toggle.itemId}`,
233
- };
234
- },
235
- toggleArchive: async (ctx, payload) => {
236
- const who = callerOf(ctx);
237
- if ("reject" in who)
238
- return who.reject;
239
- const name = itemIdOf(payload);
240
- if (name === undefined) {
241
- return { ok: false, error: { message: "toggleArchive expects {itemId}" } };
242
- }
243
- // The current store state decides the direction: click an
244
- // active skill → archive; click an archived one → restore.
245
- const row = (await host.curation.skills(who.user).list()).find((skill) => skill.name === name);
246
- if (row === undefined) {
247
- return { ok: false, error: { message: `unknown skill: ${name}` } };
248
- }
249
- const change = row.archived
250
- ? { kind: "restore" }
251
- : { kind: "archive" };
252
- await host.curation
253
- .skills(who.user)
254
- .apply(name, change, { reason: `${change.kind === "archive" ? "archived" : "restored"} from the curator view` });
255
- return {
256
- ok: true,
257
- toast: change.kind === "archive" ? `Archived ${name}` : `Restored ${name}`,
258
- };
259
- },
260
305
  setPaused: async (ctx, payload) => {
261
306
  const who = callerOf(ctx);
262
307
  if ("reject" in who)
@@ -295,7 +340,8 @@ function emptyTree() {
295
340
  },
296
341
  };
297
342
  }
298
- /** The view tree: header with the pause switch, skills, overlays, feed. */
343
+ /** The view tree: header actions (pause switch + the Skills
344
+ * link to the core view), then the feed grouped for undo. */
299
345
  function viewTree(data) {
300
346
  return {
301
347
  v: 1,
@@ -311,60 +357,19 @@ function viewTree(data) {
311
357
  icon: data.paused ? "sun" : "moon",
312
358
  action: { type: "submit", name: "setPaused" },
313
359
  },
314
- ],
315
- main: [
316
360
  {
317
- type: "section",
318
- id: "skills-section",
319
- title: "Learned skills",
320
- children: [
321
- {
322
- type: "markdown",
323
- text: "Toggle the checkbox to pin or unpin. Click a skill to archive it; click an archived one to restore.",
361
+ id: "open-skills",
362
+ label: "Skills",
363
+ icon: "book",
364
+ action: {
365
+ type: "navigate",
366
+ target: {
367
+ view: { extension: "core", id: "skills" },
324
368
  },
325
- {
326
- type: "collection",
327
- id: "skills",
328
- display: "list",
329
- items: data.skills,
330
- checkable: "pinned",
331
- onCheck: "pin",
332
- onActivate: { type: "edit", name: "toggleArchive" },
333
- item: {
334
- title: { $: "./name" },
335
- subtitle: { $: "./description" },
336
- body: { $: "./lastUsedLabel" },
337
- badges: [
338
- { text: "pinned", tone: "good" },
339
- { text: "archived", tone: "neutral" },
340
- ],
341
- },
342
- },
343
- ],
344
- },
345
- {
346
- type: "section",
347
- id: "overlays-section",
348
- title: "Overlays",
349
- children: [
350
- data.overlays.length === 0
351
- ? {
352
- type: "markdown",
353
- text: "No overlays on upstream skills.",
354
- }
355
- : {
356
- type: "collection",
357
- id: "overlays",
358
- display: "list",
359
- items: data.overlays,
360
- item: {
361
- title: { $: "./skill" },
362
- subtitle: { $: "./root" },
363
- badges: [{ text: "orphaned", tone: "warn" }],
364
- },
365
- },
366
- ],
369
+ },
367
370
  },
371
+ ],
372
+ main: [
368
373
  {
369
374
  type: "section",
370
375
  id: "feed-section",
@@ -372,12 +377,14 @@ function viewTree(data) {
372
377
  children: [
373
378
  {
374
379
  type: "markdown",
375
- text: "Click a review to undo every change it made.",
380
+ text: "Click a review to undo every change it made. Outside sigrun marks catch-up commits made outside sigrun.",
376
381
  },
377
382
  data.reviews.length === 0
378
383
  ? {
379
384
  type: "markdown",
380
- text: "Nothing learned yet. Use /learn to ask for a review now.",
385
+ text: data.reviewMissed
386
+ ? "This review is older than the recent changes."
387
+ : "Nothing learned yet. Use /learn to ask for a review now.",
381
388
  }
382
389
  : {
383
390
  type: "collection",
@@ -387,7 +394,7 @@ function viewTree(data) {
387
394
  id: group.id,
388
395
  at: group.at,
389
396
  summary: group.entries
390
- .map((entry) => `${entry.action} ${entry.skill}`)
397
+ .map((entry) => `${entry.action} ${entry.skill} (${entry.actor})`)
391
398
  .join(", "),
392
399
  })),
393
400
  onActivate: { type: "edit", name: "undoReview" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/ext-curator",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Curator extension for sigrun (pi-durable): the per-user fork review and idle maintenance pass over learned skills and curation targets",
5
5
  "type": "module",
6
6
  "engines": {