@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 +22 -0
- package/dist/loop1.js +17 -5
- package/dist/steps/overlays.d.ts +12 -8
- package/dist/steps/overlays.js +179 -61
- package/dist/view.d.ts +46 -33
- package/dist/view.js +174 -167
- package/package.json +1 -1
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:
|
|
167
|
-
//
|
|
168
|
-
//
|
|
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
|
-
|
|
171
|
-
|
|
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 {
|
package/dist/steps/overlays.d.ts
CHANGED
|
@@ -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
|
|
31
|
-
* owned conversation, applied by `overlayQuestionHandler`
|
|
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.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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">;
|
package/dist/steps/overlays.js
CHANGED
|
@@ -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
|
|
31
|
-
* owned conversation, applied by `overlayQuestionHandler`
|
|
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
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
*
|
|
238
|
-
* conversation
|
|
239
|
-
*
|
|
240
|
-
* the
|
|
241
|
-
*
|
|
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
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
|
|
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(
|
|
271
|
-
await writePendingQuestions(host, user,
|
|
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.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
|
322
|
-
if (
|
|
421
|
+
const found = await findQuestion(host, event.callId);
|
|
422
|
+
if (found === undefined)
|
|
323
423
|
return;
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
/**
|
|
343
|
-
* file (the event names the callId, not the user)
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
450
|
+
const file = await readPendingQuestions(host, row.user);
|
|
451
|
+
for (const question of Object.values(file.questions)) {
|
|
348
452
|
if (question.callId === callId)
|
|
349
|
-
|
|
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
|
|
460
|
+
return undefined;
|
|
353
461
|
}
|
|
354
|
-
/** Remove
|
|
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
|
|
357
|
-
|
|
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
|
|
3
|
-
* notice
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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}
|
|
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:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
/**
|
|
88
|
-
*
|
|
89
|
-
|
|
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
|
|
3
|
-
* notice
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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}
|
|
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:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
|
|
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 =
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
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
|
|
203
|
-
if (
|
|
232
|
+
const itemId = itemIdOf(payload);
|
|
233
|
+
if (itemId === undefined) {
|
|
204
234
|
return { ok: false, error: { message: "undoReview expects {itemId}" } };
|
|
205
235
|
}
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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:
|
|
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.
|
|
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": {
|