@stage5/lumine 0.2.39 → 0.2.41

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/lib/forum.js ADDED
@@ -0,0 +1,375 @@
1
+ import { requestJson } from "./http.js";
2
+ import { sleep } from "./util.js";
3
+
4
+ const FORUM_SCOPE_MODES = new Set(["all", "branch", "main"]);
5
+ const FORUM_EVENT_TYPES = new Set(["thread", "reply"]);
6
+ const MAX_FORUM_SNAPSHOT_PAGES = 100_000;
7
+
8
+ function forumProtocolError(message) {
9
+ const error = new Error(`Invalid Forum response: ${message}`);
10
+ error.code = "lumine_forum_protocol_error";
11
+ error.retryable = false;
12
+ return error;
13
+ }
14
+
15
+ function normalizeForumSequence(value, label) {
16
+ const sequence = Number(value);
17
+ if (!Number.isSafeInteger(sequence) || sequence < 0) {
18
+ throw forumProtocolError(`${label} must be a non-negative safe integer`);
19
+ }
20
+ return sequence;
21
+ }
22
+
23
+ function normalizePositiveForumId(value, label) {
24
+ const id = Number(value);
25
+ if (!Number.isSafeInteger(id) || id <= 0) {
26
+ throw forumProtocolError(`${label} must be a positive safe integer`);
27
+ }
28
+ return id;
29
+ }
30
+
31
+ export function buildForumScopeKey(scope) {
32
+ const mode = String(scope?.mode || "");
33
+ if (!FORUM_SCOPE_MODES.has(mode)) {
34
+ throw forumProtocolError("scope.mode is not recognized");
35
+ }
36
+ const rootBuildId = normalizePositiveForumId(
37
+ scope?.rootBuildId,
38
+ "scope.rootBuildId",
39
+ );
40
+ const workspaceBuildId = normalizePositiveForumId(
41
+ scope?.workspaceBuildId,
42
+ "scope.workspaceBuildId",
43
+ );
44
+ const contributionBuildId = scope?.contributionBuildId
45
+ ? normalizePositiveForumId(
46
+ scope.contributionBuildId,
47
+ "scope.contributionBuildId",
48
+ )
49
+ : 0;
50
+ if (mode === "branch" && contributionBuildId !== workspaceBuildId) {
51
+ throw forumProtocolError(
52
+ "branch scope does not match its contribution workspace",
53
+ );
54
+ }
55
+ if (mode !== "branch" && contributionBuildId !== 0) {
56
+ throw forumProtocolError("non-branch scope has a contribution build");
57
+ }
58
+ return `${mode}:${rootBuildId}:${workspaceBuildId}:${contributionBuildId}`;
59
+ }
60
+
61
+ export async function loadBuildForumPage({
62
+ options,
63
+ auth,
64
+ buildId,
65
+ afterActivitySeq,
66
+ snapshotActivitySeq,
67
+ limit,
68
+ }) {
69
+ const url = new URL(`${options.apiUrl}/cli/build/${buildId}/forum`);
70
+ url.searchParams.set("afterActivitySeq", String(afterActivitySeq));
71
+ if (snapshotActivitySeq > 0) {
72
+ url.searchParams.set("snapshotActivitySeq", String(snapshotActivitySeq));
73
+ }
74
+ url.searchParams.set("limit", String(limit));
75
+ return await requestJson({
76
+ url: url.toString(),
77
+ authToken: auth.token,
78
+ timeoutMs: options.timeoutMs,
79
+ });
80
+ }
81
+
82
+ function validateForumPage({
83
+ page,
84
+ buildId,
85
+ pageCursor,
86
+ snapshotActivitySeq,
87
+ expectedScopeKey,
88
+ }) {
89
+ const projectId = normalizePositiveForumId(page?.project?.id, "project.id");
90
+ const requestedBuildId = normalizePositiveForumId(
91
+ page?.requestedBuildId,
92
+ "requestedBuildId",
93
+ );
94
+ if (requestedBuildId !== buildId) {
95
+ throw forumProtocolError("requestedBuildId changed during the read");
96
+ }
97
+ const scopeKey = buildForumScopeKey(page?.scope);
98
+ if (Number(page?.scope?.rootBuildId) !== projectId) {
99
+ throw forumProtocolError("project.id does not match scope.rootBuildId");
100
+ }
101
+ if (expectedScopeKey && scopeKey !== expectedScopeKey) {
102
+ throw forumProtocolError(
103
+ "the authorized Forum workspace changed; restart the listener",
104
+ );
105
+ }
106
+
107
+ const pageSnapshotActivitySeq = normalizeForumSequence(
108
+ page?.pagination?.snapshotActivitySeq,
109
+ "pagination.snapshotActivitySeq",
110
+ );
111
+ if (
112
+ snapshotActivitySeq > 0 &&
113
+ pageSnapshotActivitySeq !== snapshotActivitySeq
114
+ ) {
115
+ throw forumProtocolError("snapshotActivitySeq changed between pages");
116
+ }
117
+ if (pageSnapshotActivitySeq < pageCursor) {
118
+ throw forumProtocolError("snapshotActivitySeq precedes the page cursor");
119
+ }
120
+
121
+ const events = Array.isArray(page?.events) ? page.events : null;
122
+ if (!events) throw forumProtocolError("events is not an array");
123
+ const pageLimit = Number(page?.pagination?.limit);
124
+ if (
125
+ !Number.isSafeInteger(pageLimit) ||
126
+ pageLimit < 1 ||
127
+ pageLimit > 100 ||
128
+ events.length > pageLimit
129
+ ) {
130
+ throw forumProtocolError("pagination.limit does not bound the page");
131
+ }
132
+ let lastActivitySeq = pageCursor;
133
+ for (const event of events) {
134
+ if (!FORUM_EVENT_TYPES.has(String(event?.type || ""))) {
135
+ throw forumProtocolError("event.type is not recognized");
136
+ }
137
+ normalizePositiveForumId(event?.id, "event.id");
138
+ normalizePositiveForumId(event?.threadId, "event.threadId");
139
+ const activitySeq = normalizeForumSequence(
140
+ event?.activitySeq,
141
+ "event.activitySeq",
142
+ );
143
+ if (
144
+ activitySeq <= lastActivitySeq ||
145
+ activitySeq > pageSnapshotActivitySeq
146
+ ) {
147
+ throw forumProtocolError(
148
+ "events are not strictly ordered inside the snapshot",
149
+ );
150
+ }
151
+ lastActivitySeq = activitySeq;
152
+ }
153
+
154
+ if (typeof page?.pagination?.hasMore !== "boolean") {
155
+ throw forumProtocolError("pagination.hasMore is not boolean");
156
+ }
157
+ const nextActivitySeq = normalizeForumSequence(
158
+ page?.pagination?.nextActivitySeq,
159
+ "pagination.nextActivitySeq",
160
+ );
161
+ if (page.pagination.hasMore) {
162
+ if (
163
+ events.length === 0 ||
164
+ nextActivitySeq !== lastActivitySeq ||
165
+ nextActivitySeq <= pageCursor ||
166
+ nextActivitySeq >= pageSnapshotActivitySeq
167
+ ) {
168
+ throw forumProtocolError("the next Forum page cursor is not progressive");
169
+ }
170
+ } else if (nextActivitySeq !== pageSnapshotActivitySeq) {
171
+ throw forumProtocolError(
172
+ "the final Forum page did not confirm the full snapshot cursor",
173
+ );
174
+ }
175
+
176
+ return {
177
+ events,
178
+ nextActivitySeq,
179
+ pageSnapshotActivitySeq,
180
+ scopeKey,
181
+ };
182
+ }
183
+
184
+ export async function readCompleteBuildForumSnapshot({
185
+ options,
186
+ auth,
187
+ buildId,
188
+ afterActivitySeq = 0,
189
+ expectedScopeKey = "",
190
+ loadPage = loadBuildForumPage,
191
+ maxPages = MAX_FORUM_SNAPSHOT_PAGES,
192
+ }) {
193
+ const normalizedBuildId = normalizePositiveForumId(buildId, "buildId");
194
+ const startingActivitySeq = normalizeForumSequence(
195
+ afterActivitySeq,
196
+ "afterActivitySeq",
197
+ );
198
+ let pageCursor = startingActivitySeq;
199
+ let snapshotActivitySeq = 0;
200
+ let scopeKey = expectedScopeKey;
201
+ let firstPage = null;
202
+ const events = [];
203
+
204
+ for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) {
205
+ const page = await loadPage({
206
+ options,
207
+ auth,
208
+ buildId: normalizedBuildId,
209
+ afterActivitySeq: pageCursor,
210
+ snapshotActivitySeq,
211
+ limit: options.limit,
212
+ });
213
+ const validated = validateForumPage({
214
+ page,
215
+ buildId: normalizedBuildId,
216
+ pageCursor,
217
+ snapshotActivitySeq,
218
+ expectedScopeKey: scopeKey,
219
+ });
220
+ if (!firstPage) firstPage = page;
221
+ if (!scopeKey) scopeKey = validated.scopeKey;
222
+ snapshotActivitySeq = validated.pageSnapshotActivitySeq;
223
+ events.push(...validated.events);
224
+ pageCursor = validated.nextActivitySeq;
225
+ if (!page.pagination.hasMore) {
226
+ return {
227
+ project: firstPage.project,
228
+ requestedBuildId: normalizedBuildId,
229
+ scope: firstPage.scope,
230
+ events,
231
+ pagination: {
232
+ fromActivitySeq: startingActivitySeq,
233
+ snapshotActivitySeq,
234
+ nextActivitySeq: pageCursor,
235
+ hasMore: false,
236
+ },
237
+ scopeKey,
238
+ };
239
+ }
240
+ }
241
+
242
+ throw forumProtocolError("snapshot exceeded the safe pagination bound");
243
+ }
244
+
245
+ export function isRetryableForumListenerError(error) {
246
+ if (error?.retryable === false) return false;
247
+ const status = Number(error?.status || 0);
248
+ if (!status) return true;
249
+ return status === 408 || status === 425 || status === 429 || status >= 500;
250
+ }
251
+
252
+ function formatForumTimestamp(value) {
253
+ const timestamp = Number(value || 0);
254
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown time";
255
+ return new Date(timestamp * 1000).toISOString();
256
+ }
257
+
258
+ function formatForumLocation(event) {
259
+ if (!event?.branch) return "Main";
260
+ const branchNumber = Number(event.branch.number || 0);
261
+ return branchNumber > 0
262
+ ? `Branch #${branchNumber}`
263
+ : `Branch build #${event.branch.id}`;
264
+ }
265
+
266
+ function sanitizeForumTerminalText(value) {
267
+ // Forum text is user-authored. Preserve canonical content in JSON output,
268
+ // but prevent control, escape, carriage-return, and bidi override bytes from
269
+ // driving or visually rewriting a human reader's terminal.
270
+ return String(value || "").replace(
271
+ /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,
272
+ "",
273
+ );
274
+ }
275
+
276
+ function printIndented(value) {
277
+ for (const line of sanitizeForumTerminalText(value).split("\n")) {
278
+ console.log(` ${line}`);
279
+ }
280
+ }
281
+
282
+ export function printBuildForumSnapshot(snapshot, { json, kind }) {
283
+ const output = {
284
+ type: kind,
285
+ project: snapshot.project,
286
+ requestedBuildId: snapshot.requestedBuildId,
287
+ scope: snapshot.scope,
288
+ events: snapshot.events,
289
+ cursor: {
290
+ fromActivitySeq: snapshot.pagination.fromActivitySeq,
291
+ throughActivitySeq: snapshot.pagination.nextActivitySeq,
292
+ },
293
+ };
294
+ if (json) {
295
+ console.log(JSON.stringify(output));
296
+ return;
297
+ }
298
+
299
+ const projectTitle =
300
+ sanitizeForumTerminalText(snapshot.project?.title).trim() ||
301
+ `Build #${snapshot.project?.id || snapshot.requestedBuildId}`;
302
+ console.log(`${projectTitle} — Team Forum`);
303
+ if (snapshot.events.length === 0) {
304
+ console.log("No new visible Forum posts or replies.");
305
+ return;
306
+ }
307
+ for (const event of snapshot.events) {
308
+ const author =
309
+ sanitizeForumTerminalText(event?.author?.username).trim() ||
310
+ (event?.author?.role === "lumine" ? "Lumine" : "unknown user");
311
+ const action = event.type === "reply" ? "replied in" : "opened";
312
+ console.log(
313
+ `${formatForumTimestamp(event.createdAt)} ${formatForumLocation(event)} ${author} ${action} #${event.threadId} “${sanitizeForumTerminalText(event.threadTitle)}”`,
314
+ );
315
+ if (event.replyTo) {
316
+ const target =
317
+ sanitizeForumTerminalText(event.replyTo.username).trim() ||
318
+ `reply #${event.replyTo.replyId}`;
319
+ console.log(` ↳ replying to ${target}`);
320
+ }
321
+ printIndented(event.body);
322
+ }
323
+ }
324
+
325
+ export async function runBuildForumCommand({ options, auth, buildId }) {
326
+ const listen = options.forumAction === "listen";
327
+ let cursor = options.forumCursor;
328
+ let scopeKey = "";
329
+ let firstSnapshot = true;
330
+ let consecutiveFailures = 0;
331
+
332
+ while (true) {
333
+ let snapshot;
334
+ try {
335
+ snapshot = await readCompleteBuildForumSnapshot({
336
+ options,
337
+ auth,
338
+ buildId,
339
+ afterActivitySeq: cursor,
340
+ expectedScopeKey: scopeKey,
341
+ });
342
+ } catch (error) {
343
+ if (!listen || !isRetryableForumListenerError(error)) throw error;
344
+ consecutiveFailures += 1;
345
+ const retryDelayMs = Math.min(
346
+ options.forumPollMs * 2 ** Math.min(consecutiveFailures - 1, 4),
347
+ 30_000,
348
+ );
349
+ console.error(
350
+ `Forum listener temporarily lost contact (${error?.message || error}). Retrying from confirmed cursor ${cursor} in ${retryDelayMs}ms.`,
351
+ );
352
+ await sleep(retryDelayMs);
353
+ continue;
354
+ }
355
+
356
+ if (firstSnapshot || snapshot.events.length > 0) {
357
+ printBuildForumSnapshot(snapshot, {
358
+ json: options.json,
359
+ kind: firstSnapshot ? "forum.snapshot" : "forum.update",
360
+ });
361
+ }
362
+ cursor = snapshot.pagination.nextActivitySeq;
363
+ scopeKey = snapshot.scopeKey;
364
+ if (!listen) return;
365
+
366
+ if (firstSnapshot) {
367
+ console.error(
368
+ `Listening for canonical Forum updates from cursor ${cursor}. Press Ctrl-C to stop.`,
369
+ );
370
+ }
371
+ firstSnapshot = false;
372
+ consecutiveFailures = 0;
373
+ await sleep(options.forumPollMs);
374
+ }
375
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.32.0
4
- Updated: 2026-08-02
5
- Generated: 2026-08-13T00:13:49.827Z
3
+ Version: 1.33.0
4
+ Updated: 2026-08-15
5
+ Generated: 2026-08-15T03:00:24.685Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -20,6 +20,7 @@ Generated: 2026-08-13T00:13:49.827Z
20
20
  - Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
21
21
  - Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
22
22
  - Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; world sessions are disposable and durable MMO state belongs in sharedDb/privateDb.
23
+ - Every Twinkle-owned profilePicUrl field returned by the SDK is an absolute HTTPS URL ready for img src, or null. Fields inside app-owned JSON such as sharedDb entry data are not rewritten.
23
24
  - Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
24
25
  - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
25
26
  - Live web search is enabled by default for Twinkle.ai.chat and for Medium/High Twinkle.ai.generateObject and Twinkle.characters.chat requests. App authors can pass webSearch: false to disable it for their app. Search uses the provider's live web-search tool and is included in AI Energy usage; structured and character Lite Mode remains tool-free.
@@ -75,6 +76,27 @@ files:read, user:read, users:read, dailyReflections:read, content:read, content:
75
76
  - Returns: Canonical shareable deep-link URL string, or null when app info is unavailable
76
77
  - Builds a canonical shareable deep link into this app, e.g. https://www.twin-kle.com/app/884/432-the-great-gatsby.
77
78
  - Example: await Twinkle.app.getShareUrl('432-the-great-gatsby');
79
+ - history.getState() | scopes: none
80
+ - Returns: The current app-owned history state object, or null
81
+ - Read the current Build app view state stored through Twinkle.app.history.
82
+ - History state is local to this iframe session and never changes the parent Twinkle URL.
83
+ - Use this for archive/detail/page state inside one Build document; use navigate() to load another project file.
84
+ - history.push(state) | scopes: none
85
+ - Returns: A JSON-cloned copy of the stored state
86
+ - Add a confirmed in-app view transition to browser history so Back stays inside the Build app.
87
+ - State must be a JSON-serializable object no larger than 16 KB.
88
+ - Push only after the requested view has loaded successfully; do not synthesize server-owned state.
89
+ - Example: Twinkle.app.history.push({ view: 'edition', dayIndex: 2080, page: 'scores' });
90
+ - history.replace(state) | scopes: none
91
+ - Returns: A JSON-cloned copy of the stored state
92
+ - Replace the current in-app history entry without adding a Back step.
93
+ - Use this to establish the initial confirmed view or reconcile a loading-only change.
94
+ - history.subscribe(listener, { immediate } = {}) | scopes: none
95
+ - Returns: unsubscribe function
96
+ - Restore app views when the viewer moves through browser Back or Forward history.
97
+ - The listener receives a cloned app state object, or null for an entry not owned by this app.
98
+ - The listener is called immediately by default; pass { immediate: false } to wait for Back or Forward.
99
+ - Example: const off = Twinkle.app.history.subscribe((state) => restoreView(state), { immediate: false });
78
100
  - async navigate(target) | scopes: none
79
101
  - Returns: { success, src }
80
102
  - Navigate to another Build preview route through the parent bridge without dropping Twinkle SDK access.
@@ -672,6 +694,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
672
694
  - Always available in the build iframe.
673
695
  - World state is ephemeral and heartbeat/TTL based. Use sharedDb/privateDb for durable inventory, XP, quests, ownership, and saved progress — but write those LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick); per-frame/live state stays in world presence or client memory. The server rate-limits sharedDb/privateDb writes and returns 429.
674
696
  - Events are room-scoped and include serverTime, seq, eventId, schemaVersion, sessionId, player, and room metadata.
697
+ - Signed-in player identity comes from the canonical Twinkle user record; player.profilePicUrl is only used for guests and is returned only when it is a valid absolute HTTPS URL.
675
698
  - Subscribe to session.ended and catch updatePresence/send errors. Stop using stale handles and reconnect only when Twinkle.world.isSessionEndedError(error) is true; for other Twinkle.world.isRecoverableSessionError(error) cases, drop the transient presence/action and keep the handle.
676
699
  - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
677
700
  - Throttle movement updates in app code, usually 5-15 updates per second. Do not call updatePresence from every animation frame.
@@ -95,6 +95,12 @@ The CLI enforces none of this — it is the standing instruction for the operato
95
95
  or agent making the judgments, and it applies to every verb below: recommends,
96
96
  rewards, effort levels, Featured, skips, comments, and replies.
97
97
 
98
+ Public text authored as Ciel must be English. This is an operator and generation
99
+ instruction, not a script or keyword test: writing systems do not identify a
100
+ language reliably, and the API must not pretend otherwise. This is a
101
+ presentation rule, not an invitation to correct or lecture a member who writes
102
+ in another language; reply naturally in concise English.
103
+
98
104
  **Twinkle is not Reddit.** Do not rank a run's attention by popularity,
99
105
  recommendation count, or polish. Most users here are young children, and the
100
106
  posts that most need Zero or Ciel are the ones nobody else answered.
@@ -168,6 +174,13 @@ a human owner can decide, and a finding nobody reports is a finding that did not
168
174
  happen. **Every run ends with an escalation list**, and it belongs in the run's
169
175
  final report whether or not anyone asks for it.
170
176
 
177
+ Keep that list narrow enough to be useful. Escalate concrete child-safety,
178
+ exploitation, privacy, targeted harassment, or platform/system-abuse risk — not
179
+ ordinary children experimenting, arguing, making rumors, proposing informal
180
+ in-site loans or contests, asking where media can be found, or making an
181
+ unverified ownership claim. Those may merit a normal age-appropriate response,
182
+ but they are not escalations without credible harmful conduct or a real victim.
183
+
171
184
  Escalate, with the canonical `https://www.twin-kle.com/subjects/<id>` or
172
185
  `/comments/<id>` URL, a one-line summary, and why it needs him:
173
186
 
@@ -178,11 +191,10 @@ Escalate, with the canonical `https://www.twin-kle.com/subjects/<id>` or
178
191
  happened. These outrank every other category.
179
192
  - **Account integrity** — someone posting from another person's account,
180
193
  impersonation, shared logins, or a user operating a set of alternate accounts.
181
- - **Economy manipulation** — coin or XP farming across alternate accounts,
182
- paid-grinding arrangements, "invest and I will pay you back more" offers,
183
- pay-me-to-win contests, and anything that teaches other children a method for
184
- any of these. Note the recommendation count: a manipulation how-to that other
185
- kids are recommending is spreading, and that is the urgent part.
194
+ - **Economy exploitation** — coordinated coin or XP farming across alternate
195
+ accounts, coercive or deceptive arrangements, or a repeatable abuse of the
196
+ platform economy with concrete evidence. A child offering a voluntary loan,
197
+ repayment, prize, or contest is not enough by itself.
186
198
  - **AI-cost exploits** — patterns that convert free AI allowances into farmable
187
199
  value: clusters of young accounts with heavy AI/battery usage, one person
188
200
  operating many accounts that feed a single build through team branches,
@@ -446,6 +458,11 @@ lumine admin daily-run start --identity auto --comment-mode off --json
446
458
  lumine admin daily-run start --identity ciel --comment-mode draft \
447
459
  --run-key daily:2026-08-06:review --json
448
460
  lumine admin daily-run status --json
461
+ lumine admin daily-run escalation add --target subject:123 \
462
+ --note "Public contact details need owner review" --severity urgent --json
463
+ lumine admin daily-run escalation add --target chatMessage:3768159 \
464
+ --note "Concrete safety issue in a bot-authored chat message" --json
465
+ lumine admin daily-run report --json
449
466
  lumine admin daily-run complete --json
450
467
  lumine admin daily-run fail --reason "operator stopped" --json
451
468
  ```
@@ -465,6 +482,14 @@ type DailyRunComplete = Success<{
465
482
  type DailyRunFail = DailyRunComplete;
466
483
  ```
467
484
 
485
+ Record only qualifying escalations as they are confirmed. `daily-run report`
486
+ then composes the active run's canonical audit events, successful mutations,
487
+ completed queue scans, recorded escalations, and the most useful brief deltas
488
+ into one result. Generate it before `complete`, because run-scoped reads require
489
+ the current active run. Queue coverage is written automatically only after an
490
+ `--all` traversal reaches canonical exhaustion; an interrupted scan remains in
491
+ its local checkpoint and cannot be misreported as complete.
492
+
468
493
  `lastRun` makes a lost-response retry of `complete` or `fail` possible after
469
494
  the active pointer has been cleared. Other run-scoped commands accept only the
470
495
  current unexpired `active` run. Completion first finalizes any mutation whose
@@ -498,13 +523,17 @@ JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
498
523
 
499
524
  ```bash
500
525
  lumine admin recommendations list --kind recommend \
501
- --content-types comment,dailyReflection --cursor '<cursor>' --json
526
+ --content-types comment,dailyReflection --all --json
527
+ lumine admin recommendations list --after 2026-08-14T00:00:00Z \
528
+ --all --checkpoint recommendations.json --json
529
+ lumine admin recommendations list --include-legacy --all --json
502
530
  lumine admin recommendations list --unviewed --json
503
531
  lumine admin subjects candidates --after 2026-08-01T00:00:00Z \
504
- --cursor '<cursor>' --json
532
+ --all --checkpoint subjects.json --json
505
533
  lumine admin subjects candidates --effort unassigned --json
506
534
  lumine admin subjects candidates --unviewed --json
507
- lumine admin builds candidates --cursor '<cursor>' --limit 50 --json
535
+ lumine admin builds candidates --all --limit 50 --json
536
+ lumine admin builds review build:884 --output-dir ./build-review --json
508
537
  ```
509
538
 
510
539
  Schemas:
@@ -575,12 +604,28 @@ type BuildCandidates = Success<{
575
604
  }>;
576
605
  ```
577
606
 
578
- Both cursors freeze a primary-key high-water mark and traverse descending IDs,
579
- so concurrent inserts cannot shift or duplicate later pages. Both walks scan a
580
- bounded primary-key window (500 rows) per call before applying their residual
581
- filters, so a page — recommendation or subject — can be empty while `hasMore`
582
- remains true; continue until `exhausted`. Subject `--after` is inclusive, and
583
- the opaque cursor is bound to its original date and effort filters.
607
+ Subject cursors freeze a primary-key high-water mark and traverse descending
608
+ IDs. Bounded recommendation cursors freeze both the feed-ID high-water mark and
609
+ the server timestamp, then traverse the indexed `(timeStamp, id)` order; this
610
+ also catches a Daily Reflection whose old feed row moved forward when it was
611
+ reshared. Explicit legacy scans retain the descending primary-key walk. A page
612
+ can be empty while `hasMore` remains true; continue until `exhausted`. `--all`
613
+ does that automatically and writes a private checkpoint after every
614
+ server-confirmed page; `--resume` continues only when the checkpoint belongs to
615
+ the same API, run, and exact request. The final result can be copied to
616
+ `--output`, while `--checkpoint` is resumable operational state. Subject
617
+ `--after` is inclusive, and every opaque cursor is bound to its original
618
+ filters.
619
+
620
+ Recommendations default to `--since-run`: the server uses the previous
621
+ completed run's start time (or the same bounded seven-day fallback used by the
622
+ brief on a first run). That deliberate start-to-start overlap gives the queue
623
+ at-least-once coverage when content arrives after the prior snapshot but before
624
+ that run completes. `--after` supplies an explicit inclusive timestamp.
625
+ All-history traversal is deliberately available only through
626
+ `--include-legacy`. The CLI requires the API to echo the canonical `after`
627
+ boundary for bounded modes, so deploying a new CLI against an older API cannot
628
+ silently fall back to a million-row historical scan.
584
629
 
585
630
  `builds candidates` is a management-agent discovery view over the canonical
586
631
  public Build browser, ordered by the current published release. It is
@@ -592,6 +637,15 @@ genuinely try the published runtime, or pull and read an open-source project,
592
637
  before making that judgment. Direct API/persona automation is never a review
593
638
  substitute.
594
639
 
640
+ `builds review` is the managed runtime path: it fetches the current published
641
+ artifact identity, launches the app in an isolated temporary Chromium profile,
642
+ captures a screenshot and bounded console evidence, then fetches the identity
643
+ again. It writes `review.json` in a unique per-review subdirectory only when
644
+ the browser completed, the screenshot exists, and the artifact did not change
645
+ mid-review. Attach the returned `receiptPath` with
646
+ `comment draft ... --review-receipt review.json`; this binds the draft to the
647
+ exact reviewed artifact without copying a version number by hand.
648
+
595
649
  During every management run, scan recent Build candidates back through the
596
650
  run's review window alongside Subjects and the recommendation queue. An app
597
651
  that is thin, broken, private, unchanged since a prior substantive bot
@@ -919,6 +973,10 @@ If recommendation succeeds but reward fails, the command exits nonzero with
919
973
  ```bash
920
974
  lumine admin post skip dailyReflection:99 --json
921
975
  lumine admin post skip comment:456 --reason "one-line answer, nothing to add" --json
976
+ lumine admin post skip-batch --target-file skip-targets.json \
977
+ --checkpoint skip-progress.json --json
978
+ lumine admin post skip-batch --target-file skip-targets.json \
979
+ --checkpoint skip-progress.json --resume --json
922
980
  ```
923
981
 
924
982
  A skip records that the management rotation has judged a recommend-queue item
@@ -937,6 +995,13 @@ metadata — it is the agent's memory of the judgment, not public content.
937
995
  The skip requires the `recommendation:write` scope and is audited like every
938
996
  other mutation.
939
997
 
998
+ `skip-batch` accepts either a JSON array (strings or `{ "target", "reason" }`
999
+ objects), `{ "targets": [...] }`, or one target per text line. It deduplicates
1000
+ targets, submits them sequentially through the same canonical audited endpoint,
1001
+ and checkpoints only after each response is confirmed. `--resume` verifies the
1002
+ exact target-set fingerprint and run ID before continuing; it never guesses
1003
+ which writes succeeded.
1004
+
940
1005
  ```ts
941
1006
  type PostSkip = Success<{
942
1007
  skip: {
@@ -953,9 +1018,10 @@ type PostSkip = Success<{
953
1018
 
954
1019
  ```bash
955
1020
  lumine admin news --json
956
- lumine admin news claim --json
957
- lumine admin news submit --edition-id 42 --lease-token <token> \
958
- --file editorial.json --model "Claude" --json
1021
+ lumine admin news claim --output claim.json --scaffold editorial.json --json
1022
+ lumine admin news validate --claim claim.json --file editorial.json --json
1023
+ lumine admin news submit --claim claim.json --file editorial.json \
1024
+ --model "Ciel" --json
959
1025
  lumine admin news print --json
960
1026
  ```
961
1027
 
@@ -968,9 +1034,18 @@ the run, and if `printedToday` is false with no edition `pending` or
968
1034
  **Preferred: write the editorial yourself.** `news claim` reserves today's
969
1035
  edition under the server's generation lease and returns the exact canonical
970
1036
  event digest the server would otherwise send to its own model, so no provider
971
- API credits are spent. Write a `GeneratedEditorial` JSON and send it back with
972
- `news submit` within the ten-minute lease. The server treats the editorial as
973
- untrusted regardless of author: every story must cite an exact `eventKey`
1037
+ API credits are spent. With `--output` and `--scaffold`, the CLI writes that
1038
+ lease/digest to a private claim file and creates an editable editorial shell.
1039
+ `news validate` runs locally, before authentication or a network request, and
1040
+ checks the complete citation graph plus byte-exact quote boundaries. Submit the
1041
+ validated pair with `news submit --claim`; the CLI reads the edition and lease
1042
+ from the claim file and validates again immediately before the request. The
1043
+ explicit `--edition-id` / `--lease-token` form remains available for backwards
1044
+ compatibility.
1045
+
1046
+ Write a `GeneratedEditorial` JSON and send it back within the ten-minute lease.
1047
+ The server still treats the editorial as untrusted regardless of author: every
1048
+ story must cite an exact `eventKey`
974
1049
  from the digest, front-page `sourceQuote`s must be verbatim contiguous
975
1050
  passages of the cited event's summary (invalid quotes are replaced with
976
1051
  canonical text), section and page layout are server-enforced, announcements