@stage5/lumine 0.3.69 → 0.3.70

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.
@@ -14,7 +14,7 @@ import path from "node:path";
14
14
  // re-importing a folder retires that paper's earlier items
15
15
  // status --item <id> --to live|retired|checked
16
16
  export const ARENA_USAGE =
17
- "Usage: lumine admin arena queue | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] [--json]";
17
+ "Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] [--json]";
18
18
  const MAX_ITEMS = 300;
19
19
 
20
20
  function validationError(message) {
@@ -63,6 +63,12 @@ function readAdditiveItems(file) {
63
63
  }
64
64
 
65
65
  export function buildArenaOperation({ action, options }) {
66
+ if (action === "paper-queue") {
67
+ const cursor = Number(options.adminCursor || 0);
68
+ const limit = Number(options.adminLimit ?? 50);
69
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw validationError("Use a non-negative --cursor and --limit from 1 to 100.");
70
+ return { name: "arena.paper.queue", method: "GET", path: `/cli/admin/arena/paper-queue?cursor=${cursor}&limit=${limit}`, mutates: false, requiresRun: false };
71
+ }
66
72
  if (action === "today-queue") {
67
73
  const cursor = Number(options.adminCursor || 0);
68
74
  const limit = Number(options.adminLimit ?? 50);
@@ -151,6 +157,11 @@ export function buildArenaOperation({ action, options }) {
151
157
  }
152
158
 
153
159
  export function formatArenaResult({ operation, data }) {
160
+ if (operation.name === "arena.paper.queue") return [
161
+ `Checked ${data?.scannedMembers ?? 0} members; ${data?.entries?.length ?? 0} exam tracks are waiting for fresh papers on this page.`,
162
+ ...(data?.entries || []).map(row => `${row.username} (${row.userId}): ${row.examKey} ${row.difficulty}/${row.length}${row.reason === "solo_mastery_paper" ? " — solo mastery ready" : ""}`),
163
+ data?.nextCursor ? `Continue: lumine admin arena paper-queue --cursor ${data.nextCursor} --json` : "End of waiting-member queue.",
164
+ ];
154
165
  if (operation.name === "arena.today.queue") return [
155
166
  ...(data?.members || []).map(m => `#${m.userId} ${m.username}: ${m.ready} questions ready${m.refillDue ? `; refill to ${m.target}` : ""} (preferences v${m.revision})`),
156
167
  data?.nextCursor ? `Continue: lumine admin arena today-queue --cursor ${data.nextCursor} --json` : "End of Today’s Q queue.",
@@ -9,6 +9,7 @@ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
9
9
  import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
10
10
  import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
11
11
  import { createDailyCollection } from "./admin-daily-collection.js";
12
+ import { collectDiagnosticPages, diagnosticCollectionSummary } from "./admin-diagnostic-collection.js";
12
13
  export { dailyRunProgress };
13
14
 
14
15
  // Multi-step conveniences for the full daily management run. Each one runs
@@ -86,7 +87,7 @@ export function runAdmin(
86
87
  sponsor: ["applications"],
87
88
  "bot-output": [null],
88
89
  "math-lab": ["queue"],
89
- arena: ["queue", "today-queue"],
90
+ arena: ["queue", "today-queue", "paper-queue"],
90
91
  "word-games": ["status"],
91
92
  nonogram: ["status"],
92
93
  subjects: ["candidates"],
@@ -179,12 +180,15 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
179
180
  );
180
181
  const collection = createDailyCollection(dir, options, { now, clock });
181
182
  const yesterday = collection.costDate;
183
+ // Resume keeps the original observation boundary; no page slides its window
184
+ // forward while the operator works through a long diagnostic queue.
185
+ const diagnosticUntil = Math.floor(Date.parse(collection.startedAt) / 1000);
182
186
  const steps = [];
183
187
  const failLast = (error) => {
184
188
  collection.invalidate(steps.at(-1).name);
185
189
  Object.assign(steps.at(-1), { ok: false, error });
186
190
  };
187
- const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|owner-wake|bot-output)/;
191
+ const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|owner-wake|bot-output)/;
188
192
  const step = (name, args, file) => {
189
193
  const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
190
194
  steps.push({
@@ -205,6 +209,29 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
205
209
  step("brief", ["brief"], "brief.json");
206
210
  step("todos", ["todo", "list"], "todos.json");
207
211
  step("escalations", ["escalation", "list"], "escalations.json");
212
+ const paperQueue = { complete: false, pages: 0, waitingTracks: 0 };
213
+ let paperCursor = 0;
214
+ for (;;) {
215
+ paperQueue.pages++;
216
+ const res = step(`arena-paper-page-${paperQueue.pages}`,
217
+ ["arena", "paper-queue", "--cursor", String(paperCursor), "--limit", "100"],
218
+ `arena-paper-queue-${paperQueue.pages}.json`);
219
+ if (!res.ok) break;
220
+ const data = res.json?.data;
221
+ const next = data?.nextCursor;
222
+ if (data?.demandBasis !== "waiting-published-papers-v1" || !Array.isArray(data?.entries) ||
223
+ !(next === null || (Number.isSafeInteger(next) && next > paperCursor))) {
224
+ failLast("Invalid or non-advancing Arena paper-waiting page.");
225
+ break;
226
+ }
227
+ paperQueue.waitingTracks += data.entries.length;
228
+ if (next === null) { paperQueue.complete = true; break; }
229
+ if (paperQueue.pages >= 100) {
230
+ failLast("Arena paper-waiting queue reached its 100-page bound.");
231
+ break;
232
+ }
233
+ paperCursor = next;
234
+ }
208
235
  step("math-lab-queue", ["math-lab", "queue"], "math-lab-queue.json");
209
236
  step("arena-queue", ["arena", "queue"], "arena-queue.json");
210
237
  step("word-games-status", ["word-games", "status"], "word-games-status.json");
@@ -245,7 +272,17 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
245
272
  ["reward-activity", "--date", yesterday, "--days", "7"],
246
273
  "reward-activity.json",
247
274
  );
248
- step("reward-diagnostics", ["reward-diagnostics", "--days", "1"], "reward-diagnostics.json");
275
+ const rewardDiagnostics = collectDiagnosticPages({ name: "reward-diagnostics",
276
+ command: ["reward-diagnostics", "--days", "1"], since: diagnosticUntil - 86400,
277
+ until: diagnosticUntil, step, failLast });
278
+ writeAdminJsonFile(path.join(dir, "reward-diagnostics-summary.json"), diagnosticCollectionSummary(rewardDiagnostics), { privateFile: true });
279
+ steps.push({ name: "reward-diagnostics-summary", file: "reward-diagnostics-summary.json", ok: true });
280
+ const jevUntil = Math.floor(diagnosticUntil / 86400) * 86400;
281
+ const jevReview = collectDiagnosticPages({ name: "jev-review", jevReview: true,
282
+ command: ["jev-routing", "review", "--since", String(jevUntil - 86400), "--limit", "20"],
283
+ since: jevUntil - 86400, until: jevUntil, step, failLast });
284
+ writeAdminJsonFile(path.join(dir, "jev-review-summary.json"), diagnosticCollectionSummary(jevReview), { privateFile: true });
285
+ steps.push({ name: "jev-review-summary", file: "jev-review-summary.json", ok: true });
249
286
  // One pending queue covers every Build approval type. Exhaust its cursor;
250
287
  // an empty/malformed receipt must not look like an empty queue.
251
288
  const buildReviews = { complete: false, pages: 0, items: 0 };
@@ -294,17 +331,21 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
294
331
  // flags: a required report section (agent-guides/lumine-admin.md)
295
332
  step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
296
333
  step("personal-followups", ["followup", "list"], "personal-followups.json");
297
- const wake = step("owner-wake", ["owner-trace", "--since", "24h", "--wake", "--limit", "2000"], "owner-wake.json");
334
+ const wake = collectDiagnosticPages({ name: "owner-wake", ownerTrace: true,
335
+ command: ["owner-trace", "--since", String(diagnosticUntil - 86400), "--wake", "--limit", "2000"],
336
+ since: diagnosticUntil - 86400, until: diagnosticUntil, step, failLast });
298
337
  // A phone tab can live longer than the daily window. Retain its version,
299
338
  // browser and device context instead of silently calling it unknown.
300
- const wakeSessions = step("owner-wake-sessions", ["owner-trace", "--since", "30d", "--type", "session", "--limit", "2000"], "owner-wake-sessions.json");
339
+ const wakeSessions = collectDiagnosticPages({ name: "owner-wake-sessions", ownerTrace: true,
340
+ command: ["owner-trace", "--since", String(diagnosticUntil - 30 * 86400), "--type", "session", "--limit", "2000"],
341
+ since: diagnosticUntil - 30 * 86400, until: diagnosticUntil, step, failLast });
301
342
  const ownerWake = summarizeOwnerViewport(
302
- wake.ok ? wake.json.data : null,
303
- wakeSessions.ok ? wakeSessions.json.data : null,
343
+ wake,
344
+ wakeSessions,
304
345
  );
305
346
  ownerWake.collectionFailures = [
306
- ...(!wake.ok ? ["owner-wake"] : []),
307
- ...(!wakeSessions.ok ? ["owner-wake-sessions"] : []),
347
+ ...(!wake.complete ? ["owner-wake"] : []),
348
+ ...(!wakeSessions.complete ? ["owner-wake-sessions"] : []),
308
349
  ];
309
350
  if (ownerWake.collectionFailures.length) {
310
351
  ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
@@ -372,12 +413,17 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
372
413
  costDateUtc: yesterday,
373
414
  collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase, warnings: collection.warnings },
374
415
  personalQueue,
416
+ paperQueue,
375
417
  buildReviews,
418
+ rewardDiagnostics: diagnosticCollectionSummary(rewardDiagnostics),
419
+ jevReview: diagnosticCollectionSummary(jevReview),
376
420
  ownerWake: {
377
421
  coverage: ownerWake.coverage,
378
422
  truncated: ownerWake.truncated,
379
423
  largeGapCycles: ownerWake.largeGapCycles,
380
424
  reportFile: path.join(dir, "owner-wake-summary.md"),
425
+ collection: diagnosticCollectionSummary(wake),
426
+ sessionCollection: diagnosticCollectionSummary(wakeSessions),
381
427
  },
382
428
  botOutput: {
383
429
  complete: botOk,
@@ -0,0 +1,106 @@
1
+ // Fixed-window keyset collection. Every page stays a separate acknowledged
2
+ // source; aggregates are conveniences, never proof that the source was read.
3
+ function jevPosition(cursor, since, until) {
4
+ try {
5
+ if (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor)) return null;
6
+ const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
7
+ if (value.version !== 1 || value.kind !== "jev-routing-review" ||
8
+ value.since !== since || value.until !== until || value.includeSynthetic !== false ||
9
+ !Number.isSafeInteger(value.createdAt) || value.createdAt < since || value.createdAt >= until ||
10
+ !Number.isSafeInteger(value.id) || value.id <= 0) return null;
11
+ return value;
12
+ } catch { return null; }
13
+ }
14
+
15
+ export function collectDiagnosticPages({ name, command, since, until, ownerTrace = false, jevReview = false, step, failLast }) {
16
+ const events = [];
17
+ const ids = new Set();
18
+ const cursors = new Set();
19
+ const sourceFiles = [];
20
+ let cursor = null;
21
+ let pages = 0;
22
+ let duplicateRows = 0;
23
+ let complete = false;
24
+ let deferred = false;
25
+ let failure = null;
26
+ const pageLimit = ownerTrace ? 20 : 100;
27
+ for (;;) {
28
+ pages++;
29
+ const args = [...command, "--until", String(until)];
30
+ if (cursor !== null) args.push("--cursor", String(cursor));
31
+ const file = `${name}${pages === 1 ? "" : `-${pages}`}.json`;
32
+ const result = step(`${name}${pages === 1 ? "" : `-page-${pages}`}`, args, file);
33
+ sourceFiles.push(file);
34
+ if (!result.ok) {
35
+ deferred = Boolean(result.deferred);
36
+ failure = deferred ? result.reason : "source-read-failed";
37
+ break;
38
+ }
39
+ const data = result.json?.data;
40
+ const next = data?.nextCursor;
41
+ const rows = jevReview ? data?.items : data?.events;
42
+ const window = jevReview ? data?.collectionWindow : data;
43
+ const validCursor = next === null || (jevReview ? Boolean(jevPosition(next, since, until)) : ownerTrace
44
+ ? typeof next === "string" && /^\d+:\d+$/.test(next)
45
+ : Number.isSafeInteger(next) && next > 0 && (cursor === null || next < cursor));
46
+ if (!Array.isArray(rows) || window?.since !== since || window?.until !== until ||
47
+ !validCursor || data.coverage === "unavailable" ||
48
+ (jevReview && (window.includeSynthetic !== false || data.after !== cursor ||
49
+ data.hasMore !== (next !== null) || data.coverage?.scope !== "page" ||
50
+ data.coverage?.status !== (next === null ? "exhausted" : "partial"))) ||
51
+ (ownerTrace && data.truncated !== (next !== null))) {
52
+ failure = "Invalid diagnostic page or fixed-window coverage; deploy the matching API before collecting.";
53
+ failLast(failure);
54
+ break;
55
+ }
56
+ const idOf = row => jevReview ? row.eventId : row.id;
57
+ if (rows.some(row => !Number.isSafeInteger(idOf(row)) || idOf(row) <= 0)) {
58
+ failure = "Diagnostic page contains an invalid event ID.";
59
+ failLast(failure);
60
+ break;
61
+ }
62
+ if (jevReview && cursor !== null && next !== null) {
63
+ const old = jevPosition(cursor, since, until), current = jevPosition(next, since, until);
64
+ if (!(current.createdAt > old.createdAt || current.createdAt === old.createdAt && current.id > old.id)) {
65
+ failure = "JEV review cursor did not advance.";
66
+ failLast(failure);
67
+ break;
68
+ }
69
+ }
70
+ if (ownerTrace && cursor !== null && next !== null) {
71
+ const [oldTime, oldId] = cursor.split(":").map(Number);
72
+ const [time, id] = next.split(":").map(Number);
73
+ if (!(time < oldTime || time === oldTime && id < oldId)) {
74
+ failure = "Owner trace cursor did not advance.";
75
+ failLast(failure);
76
+ break;
77
+ }
78
+ }
79
+ for (const row of rows) {
80
+ if (ids.has(idOf(row))) { duplicateRows++; continue; }
81
+ ids.add(idOf(row));
82
+ events.push(row);
83
+ }
84
+ if (next === null) { complete = true; break; }
85
+ if (cursors.has(next) || pages >= pageLimit) {
86
+ failure = `Diagnostic pagination repeated a cursor or reached its ${pageLimit}-page bound.`;
87
+ failLast(failure);
88
+ break;
89
+ }
90
+ cursors.add(next);
91
+ cursor = next;
92
+ }
93
+ if (ownerTrace) events.sort((a, b) => a.serverTime - b.serverTime || a.id - b.id);
94
+ return { since, until, complete, pages, rows: events.length, duplicateRows,
95
+ sourceFiles, deferred, failure, events, truncated: !complete };
96
+ }
97
+
98
+ export function diagnosticCollectionSummary(collection) {
99
+ const { events, ...summary } = collection;
100
+ const outcomes = {};
101
+ for (const row of events) {
102
+ const key = `${row.source || row.type || row.route || "unknown"}:${row.operation || ""}:${row.outcome || row.selectedSource || ""}`;
103
+ outcomes[key] = (outcomes[key] || 0) + 1;
104
+ }
105
+ return { ...summary, outcomes, note: "Metadata only. Read the source pages; this summary does not acknowledge review or prove member outcomes." };
106
+ }
@@ -44,7 +44,11 @@ export function createDailyReviewState(dir, gather, previous = {}) {
44
44
  if (gather.buildReviews && (
45
45
  source.file === "rr-pending.json" || /^build-reviews-\d+\.json$/.test(source.file)
46
46
  )) continue;
47
+ if (gather.paperQueue && /^arena-paper-queue-\d+\.json$/.test(source.file)) continue;
47
48
  if (gather.personalQueue && /^arena-today-queue-\d+\.json$/.test(source.file)) continue;
49
+ if (gather.rewardDiagnostics && /^reward-diagnostics(?:-\d+|-summary)?\.json$/.test(source.file)) continue;
50
+ if (gather.jevReview && /^jev-review(?:-\d+|-summary)?\.json$/.test(source.file)) continue;
51
+ if (gather.ownerWake?.collection && /^owner-wake(?:-sessions)?(?:-\d+)?\.json$/.test(source.file)) continue;
48
52
  if (gather.botOutput && (
49
53
  source.file === "bot-chats.txt" || /^bot-output-\d+\.json$/.test(source.file)
50
54
  )) continue;
@@ -0,0 +1,76 @@
1
+ // Bounded owner-only frozen evidence. Collection never acknowledges reading
2
+ // and never reconstructs missing input from the member's edited chat history.
3
+ export const JEV_REVIEW_USAGE =
4
+ "Usage: lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor>] [--include-synthetic] [--all] [--json].";
5
+
6
+ function invalid(message) {
7
+ const error = new Error(message);
8
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
9
+ throw error;
10
+ }
11
+
12
+ function explicitTimestamp(value, name) {
13
+ if (typeof value !== "string" && typeof value !== "number") invalid(JEV_REVIEW_USAGE);
14
+ const text = String(value).trim();
15
+ if (!/^\d+$/.test(text) && !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(text)) {
16
+ invalid(`--${name} requires an explicit Unix timestamp or ISO date, not a relative window.`);
17
+ }
18
+ const seconds = /^\d+$/.test(text) ? Number(text) : Math.floor(Date.parse(text) / 1000);
19
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) invalid(`Invalid --${name} timestamp.`);
20
+ // Keep ISO input intact for the API's strict calendar/date-zone validation.
21
+ return { text, seconds };
22
+ }
23
+
24
+ export function buildJevRoutingReviewOperation({ action, target, extra, options = {} }) {
25
+ if (action !== "review" || target || extra) invalid(JEV_REVIEW_USAGE);
26
+ if (options.adminAll && options.adminCursor) {
27
+ invalid("Use --all from the beginning of the window, or --resume with its saved checkpoint; use --cursor only for an individual page.");
28
+ }
29
+ const since = explicitTimestamp(options.adminSince, "since");
30
+ const until = explicitTimestamp(options.adminUntil, "until");
31
+ if (until.seconds <= since.seconds || until.seconds - since.seconds > 8 * 86400) {
32
+ invalid("Use a fixed window with since < until, at most eight days.");
33
+ }
34
+ // The global parser's `limit` defaults to 50 for unrelated commands.
35
+ // `adminLimit` records only an explicitly supplied --limit.
36
+ const rawLimit = options.adminLimit;
37
+ const limit = rawLimit == null || rawLimit === "" ? 20 : Number(rawLimit);
38
+ if ((rawLimit != null && !["string", "number"].includes(typeof rawLimit)) ||
39
+ !Number.isSafeInteger(limit) || limit < 1 || limit > 20) invalid("--limit must be 1–20.");
40
+ if (options.adminIncludeSynthetic !== undefined && typeof options.adminIncludeSynthetic !== "boolean") {
41
+ invalid("--include-synthetic is a flag without a value.");
42
+ }
43
+ const includeSynthetic = options.adminIncludeSynthetic === true;
44
+ const query = new URLSearchParams({ since: since.text, until: until.text, limit: String(limit) });
45
+ if (includeSynthetic) query.set("includeSynthetic", "true");
46
+ if (options.adminCursor) query.set("cursor", String(options.adminCursor));
47
+ return {
48
+ name: "jevRouting.review",
49
+ method: "GET",
50
+ path: `/cli/admin/jev-routing/review?${query.toString()}`,
51
+ body: undefined,
52
+ mutates: false,
53
+ requiresRun: false,
54
+ pagination: {
55
+ collectionKey: "items",
56
+ after: since.seconds,
57
+ summaryKeys: ["collectionWindow", "coverage", "notes"],
58
+ filters: { since: since.text, until: until.text, includeSynthetic },
59
+ },
60
+ };
61
+ }
62
+
63
+ export function formatJevRoutingReview(data = {}) {
64
+ const window = data.collectionWindow || {};
65
+ const coverage = data.coverage || {};
66
+ const lines = [
67
+ `JEV review: [${window.since ?? "?"}, ${window.until ?? "?"}) · synthetic ${window.includeSynthetic ? "included" : "excluded"}`,
68
+ `${data.items?.length || 0} events returned · page ${coverage.status || "coverage unknown"}${coverage.fromBeginning === false ? " · resumed after a cursor" : ""}`,
69
+ ];
70
+ for (const item of data.items || []) {
71
+ lines.push(`#${item.eventId} ${item.route} · ${(item.reviewReasons || []).join(", ")} · selected ${item.selectedSource || "not recorded"} · frozen input ${item.contextAvailable ? "available" : item.contextUnavailableReason || "unavailable"}`);
72
+ }
73
+ if (data.nextCursor) lines.push(`Next cursor: ${data.nextCursor}`);
74
+ lines.push("Collection is not review. Read the JSON source pages; an exhausted resumed page does not prove the whole window was collected.");
75
+ return lines;
76
+ }
@@ -4,7 +4,7 @@
4
4
  // no daily run needed. The server keeps 30 days.
5
5
 
6
6
  export const OWNER_TRACE_USAGE =
7
- "Usage: lumine admin owner-trace [--since 2h|30m|3d|<iso>|<unix>] [--type route,feed-open,scroll-anchor,content-scroll,error,...] [--perf] [--wake] [--path <substring>] [--limit 300] [--json].";
7
+ "Usage: lumine admin owner-trace [--since 2h|30m|3d|<iso>|<unix>] [--until <unix-second>] [--cursor <time:id>] [--type route,feed-open,scroll-anchor,content-scroll,error,...] [--perf] [--wake] [--path <substring>] [--limit 300] [--json].";
8
8
 
9
9
  // --perf: only the performance events (tap-to-page timeline, slow API
10
10
  // requests, main-thread stalls, page load).
@@ -59,6 +59,15 @@ export function buildOwnerTraceOperation(options) {
59
59
  const query = new URLSearchParams();
60
60
  query.set("since", normalizeSince(options.adminSince));
61
61
  query.set("limit", String(limit));
62
+ if (options.adminUntil) {
63
+ const until = Number(options.adminUntil);
64
+ if (!Number.isSafeInteger(until) || until <= 0) throw validationError("--until must be a positive unix second.");
65
+ query.set("until", String(until));
66
+ }
67
+ if (options.adminCursor) {
68
+ if (!/^\d+:\d+$/.test(options.adminCursor)) throw validationError("--cursor must be a returned time:id pair.");
69
+ query.set("cursor", options.adminCursor);
70
+ }
62
71
  const types = String(options.adminType || "")
63
72
  .split(",")
64
73
  .map((type) => type.trim())
@@ -333,7 +342,7 @@ export function formatOwnerTrace(data) {
333
342
  const sessions = new Set(events.map((event) => event.sessionId));
334
343
  const since = Number(data?.since || 0);
335
344
  lines.push(
336
- `Owner trace since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ? " · older events cut off (raise --limit or narrow --since)" : ""}`,
345
+ `Owner trace since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ? ` · more pages (keep --since/--until; --cursor ${data.nextCursor || "unavailable"})` : ""}`,
337
346
  );
338
347
  if (!events.length) {
339
348
  lines.push("Nothing recorded in this window.");
package/lib/admin.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  readdirSync,
8
8
  readFileSync,
9
9
  realpathSync,
10
+ statSync,
10
11
  writeFileSync,
11
12
  } from "node:fs";
12
13
  import path from "node:path";
@@ -48,6 +49,8 @@ import { buildSpendingOperation, completeSpendingReport, formatSpending } from "
48
49
  import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
49
50
  import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
50
51
  import { buildArenaOperation, formatArenaResult } from "./admin-arena.js";
52
+ import { buildJevRoutingReviewOperation, formatJevRoutingReview } from "./admin-jev-review.js";
53
+ import { summarizeGameDiagnostics } from "./game-diagnostic-summary.js";
51
54
  import {
52
55
  buildSlidingBlockOperation,
53
56
  formatSlidingBlockResult,
@@ -1314,6 +1317,32 @@ function readEditorialFile(filePath) {
1314
1317
  }
1315
1318
  }
1316
1319
 
1320
+ function gameDiagnosticSummary(options) {
1321
+ const filePath = String(options.adminFile || "").trim();
1322
+ const outputPath = options.adminOutput ? String(options.adminOutput).trim() : null;
1323
+ if (options.positional?.length !== 2 || !filePath || outputPath === "") {
1324
+ throw cliValidationError("Usage: lumine admin game-diagnostics summarize --file <envelope.json> [--json]. This reads retained evidence locally.");
1325
+ }
1326
+ const maxBytes = 16 * 1024 * 1024;
1327
+ let metadata;
1328
+ try { metadata = statSync(filePath); }
1329
+ catch { throw cliValidationError("Could not read the game diagnostic envelope."); }
1330
+ if (!metadata.isFile() || metadata.size > maxBytes) {
1331
+ throw cliValidationError("The game diagnostic envelope must be a regular file under 16 MB.");
1332
+ }
1333
+ if (outputPath) {
1334
+ let outputMetadata;
1335
+ try { outputMetadata = statSync(outputPath); }
1336
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1337
+ if (path.resolve(outputPath) === path.resolve(filePath) ||
1338
+ (outputMetadata?.dev === metadata.dev && outputMetadata?.ino === metadata.ino)) {
1339
+ throw cliValidationError("Keep the raw evidence: summary output must differ from the input file, including aliases.");
1340
+ }
1341
+ }
1342
+ const envelope = readAdminJsonFile(filePath, "game diagnostic envelope", { maxBytes });
1343
+ return { ok: true, status: "success", changed: false, data: summarizeGameDiagnostics(envelope) };
1344
+ }
1345
+
1317
1346
  export async function adminCommand(options) {
1318
1347
  const [namespace, action] = options.positional || [];
1319
1348
  const composite =
@@ -1323,15 +1352,21 @@ export async function adminCommand(options) {
1323
1352
  ? dailyRunProgress
1324
1353
  : namespace === "featured" && action === "candidates"
1325
1354
  ? featuredCandidates
1355
+ : namespace === "game-diagnostics" && action === "summarize"
1356
+ ? gameDiagnosticSummary
1326
1357
  : namespace === "report" && action === "serve"
1327
1358
  ? reportServe
1328
1359
  : null;
1329
1360
  if (composite) {
1330
1361
  const result = await composite(options);
1331
1362
  if (options.adminOutput) {
1332
- writeFileSync(options.adminOutput, JSON.stringify(result), {
1333
- mode: 0o600,
1334
- });
1363
+ if (composite === gameDiagnosticSummary) {
1364
+ writeAdminJsonFile(options.adminOutput, result, { privateFile: true });
1365
+ } else {
1366
+ writeFileSync(options.adminOutput, JSON.stringify(result), {
1367
+ mode: 0o600,
1368
+ });
1369
+ }
1335
1370
  }
1336
1371
  console.log(JSON.stringify(result, null, options.json ? 0 : 2));
1337
1372
  if (result && result.ok === false) process.exitCode = 1;
@@ -2409,6 +2444,7 @@ const FEATURED_RUN_OPERATIONS = new Set([
2409
2444
  // run's write work without the newspaper, sponsor scan or bank seeding (the
2410
2445
  // API holds the same line through the run's scopes).
2411
2446
  const APPROVALS_RUN_EXCLUDED_OPERATIONS = new Set([
2447
+ "subject.website-update.post",
2412
2448
  "news.claim",
2413
2449
  "news.print",
2414
2450
  "news.submit",
@@ -2427,6 +2463,7 @@ export function assertAdminOperationAllowedForRunScope({
2427
2463
  "news.claim",
2428
2464
  "news.submit",
2429
2465
  "news.print",
2466
+ "subject.website-update.post",
2430
2467
  "daily-run.complete",
2431
2468
  "daily-run.fail",
2432
2469
  ].includes(operation.name)
@@ -2674,6 +2711,9 @@ export function parseAdminOperation(options) {
2674
2711
  if (namespace === "owner-trace") {
2675
2712
  return buildOwnerTraceOperation(options);
2676
2713
  }
2714
+ if (namespace === "jev-routing") {
2715
+ return buildJevRoutingReviewOperation({ action, target, extra, options });
2716
+ }
2677
2717
  // Who holds which management level (admin-roles.js); read-only, no run.
2678
2718
  if (namespace === "roles") {
2679
2719
  return buildRolesOperation(options);
@@ -3696,7 +3736,8 @@ export function parseAdminOperation(options) {
3696
3736
  if (days > 30 || userId && !buildId) throw cliValidationError("Use --build with --user; --days must be 1–30.");
3697
3737
  return readOperation("reward-diagnostics.read", withQuery("/cli/admin/reward-diagnostics", {
3698
3738
  buildId: buildId || undefined, userId: userId || undefined, days,
3699
- before: options.adminCursor ? parseRequiredInteger(options.adminCursor, "--cursor", 1) : undefined
3739
+ before: options.adminCursor ? parseRequiredInteger(options.adminCursor, "--cursor", 1) : undefined,
3740
+ until: options.adminUntil ? parseRequiredInteger(options.adminUntil, "--until", 1) : undefined
3700
3741
  }), { requiresRun: false });
3701
3742
  }
3702
3743
 
@@ -4162,6 +4203,22 @@ export function parseAdminOperation(options) {
4162
4203
  }
4163
4204
 
4164
4205
  if (namespace === "subject") {
4206
+ if (action === "post-update") {
4207
+ if (target || extra || !options.adminFile) {
4208
+ throw cliValidationError("Usage: lumine admin subject post-update --file <article.json> [--confirm]. Preview is the default.");
4209
+ }
4210
+ const article = readAdminJsonFile(options.adminFile, "--file", { maxBytes: 256 * 1024 });
4211
+ if (!article || typeof article !== "object" || Array.isArray(article)) {
4212
+ throw cliValidationError("The article file must contain one composed website-update object.");
4213
+ }
4214
+ if (options.adminConfirm && options.adminConfirm !== "true") {
4215
+ throw cliValidationError("Pass --confirm on its own, without a value.");
4216
+ }
4217
+ const confirm = options.adminConfirm === "true";
4218
+ if (confirm && options.adminDryRun) throw cliValidationError("Choose --confirm or --dry-run, not both.");
4219
+ const operation = confirm ? writeOperation : bodyReadOperation;
4220
+ return operation("subject.website-update.post", "POST", "/cli/admin/subjects/website-update", { article, confirm });
4221
+ }
4165
4222
  if (action === "get") return subjectGetOperation(target, options);
4166
4223
  if (action === "comments") {
4167
4224
  const subjectId = parseSubjectId(target);
@@ -7595,6 +7652,10 @@ function printAdminResult({ operation, result }) {
7595
7652
  printOwnerTrace(data);
7596
7653
  return;
7597
7654
  }
7655
+ if (operation.name === "jevRouting.review") {
7656
+ for (const line of formatJevRoutingReview(data)) console.log(line);
7657
+ return;
7658
+ }
7598
7659
  if (operation.name === "roles.read") {
7599
7660
  printRoles(data);
7600
7661
  return;
package/lib/commands.js CHANGED
@@ -2508,6 +2508,7 @@ export function parseArgs(args) {
2508
2508
  "wake",
2509
2509
  "includeLegacy",
2510
2510
  "includePrivateEvidence",
2511
+ "includeSynthetic",
2511
2512
  "allFlagged",
2512
2513
  "reviewed",
2513
2514
  "noReviewLoop",
@@ -2681,6 +2682,8 @@ export function parseArgs(args) {
2681
2682
  adminApprove: raw.approve ? String(raw.approve) : "",
2682
2683
  adminSinceRun: Boolean(raw.sinceRun),
2683
2684
  adminSince: raw.since ? String(raw.since) : "",
2685
+ adminUntil: raw.until ? String(raw.until) : "",
2686
+ adminIncludeSynthetic: Boolean(raw.includeSynthetic),
2684
2687
  adminPath: raw.path ? String(raw.path) : "",
2685
2688
  adminPerf: Boolean(raw.perf),
2686
2689
  adminWake: Boolean(raw.wake),
@@ -3322,7 +3325,7 @@ export function printHelp() {
3322
3325
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3323
3326
  lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | revise --build <id> --pair <pairId|a+b> --why <text> [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
3324
3327
  lumine admin sliding-block status | add --file <levels.json> [--confirm] (sliding-block-v1 campaign refills, RushHour: dry run without --confirm; the server solves each board and appends it)
3325
- lumine admin arena queue | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm (Arena's mock-exam bank: import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
3328
+ lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm (Arena's mock-exam bank: import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
3326
3329
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3327
3330
  lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
3328
3331
  lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
@@ -3330,6 +3333,7 @@ export function printHelp() {
3330
3333
  lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
3331
3334
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3332
3335
  lumine admin reward-diagnostics [--build <id> --user <id>] [--days <1..30>] [--cursor <id>] [--json]
3336
+ lumine admin game-diagnostics summarize --file <envelope.json> [--json] (local retained sharedDb receipts; deduplicates stable events, preserves delivery and coverage limits)
3333
3337
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
3334
3338
  lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
3335
3339
  lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
@@ -3352,6 +3356,7 @@ export function printHelp() {
3352
3356
  lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
3353
3357
  lumine admin subjects candidates [--since-run|--after <date>|--include-legacy] [--effort unassigned] [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3354
3358
  lumine admin subject get|reveal <subject-url-or-id> [--json]
3359
+ lumine admin subject post-update --file <article.json> [--confirm] [--json]
3355
3360
  lumine admin subject comments <subject-url-or-id> [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3356
3361
  lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
3357
3362
  lumine admin subject creator set-made-by-poster <subject-id> [--json]
@@ -3378,6 +3383,7 @@ export function printHelp() {
3378
3383
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3379
3384
  lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake: sessions, 20 s wake profiles, bounded viewport geometry and dropped-event counts after loads or returns; network tags identify the upload network; what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
3380
3385
  lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3386
+ lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements and Auto fallbacks; collection is not review)
3381
3387
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3382
3388
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3383
3389
  lumine admin comment post --draft-id <id> [--json]
@@ -0,0 +1,204 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const MAX_BYTES = 16 * 1024 * 1024;
4
+ const MAX_ROWS = 10000;
5
+ const DELIVERY_NUMBERS = new Set([
6
+ "v", "pending", "acknowledged", "requestFailures", "evicted", "expired",
7
+ "exhausted", "storageFailures", "oversized", "invalid", "attempt", "attempts",
8
+ "failures", "localEvictions", "coalescedBatches", "retryInMs"
9
+ ]);
10
+ const DELIVERY_FIELDS = new Set([...DELIVERY_NUMBERS, "sending", "lastOutcome"]);
11
+ const KINDS = new Set([
12
+ "lifecycle", "launch-outcome", "save-failed", "save-ok", "save-load-failed",
13
+ "save-protected", "save-recovered", "soundtrack-diagnostics", "error", "startup"
14
+ ]);
15
+ const EVENTS = new Set([
16
+ "hidden", "visible", "blur", "focus", "pagehide", "session_started", "action",
17
+ "battle_choice", "board_solved", "board_started", "fight_left", "fight_result",
18
+ "fight_started", "input", "lesson_step", "progress", "puzzle_progress",
19
+ "runtime_error", "save_status", "scene", "stalled"
20
+ ]);
21
+ const SOUNDTRACK_COUNTERS = new Set([
22
+ "ready", "recommendations", "recommendations-empty", "tag-load", "tag-search",
23
+ "tag-submit", "tag-remove", "lock-open", "lock-open-failed", "progress-load", "progress-save"
24
+ ]);
25
+ const record = value => value !== null && typeof value === "object" &&
26
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
27
+ const positiveId = value => Number.isSafeInteger(value) && value > 0;
28
+ const stableId = value => typeof value === "string" && /^[\w:.-]{1,160}$/.test(value);
29
+ const hash = text => createHash("sha256").update(text).digest("hex");
30
+
31
+ // Validate the pure helper too: a caller need not have used the CLI's file cap.
32
+ // Sort keys for semantic comparison, but never mutate the retained source.
33
+ function canonicalJson(value, limit = MAX_BYTES) {
34
+ let bytes = 0;
35
+ const ancestors = new Set();
36
+ const token = text => {
37
+ bytes += Buffer.byteLength(text);
38
+ if (bytes > limit) throw new Error("Game diagnostic JSON exceeds its byte limit.");
39
+ return text;
40
+ };
41
+ function visit(item, depth) {
42
+ if (depth > 32) throw new Error("Game diagnostic JSON exceeds depth 32.");
43
+ if (item === null || typeof item === "boolean") return token(JSON.stringify(item));
44
+ if (typeof item === "number" && Number.isFinite(item)) return token(JSON.stringify(item));
45
+ if (typeof item === "string") {
46
+ if (item.length > limit) throw new Error("Game diagnostic JSON exceeds its byte limit.");
47
+ return token(JSON.stringify(item));
48
+ }
49
+ if ((!Array.isArray(item) && !record(item)) || ancestors.has(item)) throw new Error("Game diagnostics require finite, acyclic JSON values.");
50
+ ancestors.add(item);
51
+ const parts = [];
52
+ if (Array.isArray(item)) {
53
+ parts.push(token("["));
54
+ for (let i = 0; i < item.length; i++) {
55
+ if (i) parts.push(token(","));
56
+ parts.push(visit(item[i], depth + 1));
57
+ }
58
+ parts.push(token("]"));
59
+ } else {
60
+ parts.push(token("{"));
61
+ for (const key of Object.keys(item).sort()) {
62
+ if (parts.length > 1) parts.push(token(","));
63
+ parts.push(token(JSON.stringify(key)), token(":"), visit(item[key], depth + 1));
64
+ }
65
+ parts.push(token("}"));
66
+ }
67
+ ancestors.delete(item);
68
+ return parts.join("");
69
+ }
70
+ return visit(value, 0);
71
+ }
72
+
73
+ function semanticJson(data) {
74
+ if (!record(data.delivery)) return canonicalJson(data, 16384);
75
+ const semantic = { ...data };
76
+ const remaining = Object.entries(data.delivery).filter(([key]) => !DELIVERY_FIELDS.has(key));
77
+ if (remaining.length) semantic.delivery = Object.fromEntries(remaining);
78
+ else delete semantic.delivery;
79
+ return canonicalJson(semantic, 16384);
80
+ }
81
+
82
+ function deliverySnapshot(data) {
83
+ if (!record(data.delivery)) return null;
84
+ const snapshot = {};
85
+ for (const key of DELIVERY_NUMBERS) {
86
+ const value = data.delivery[key];
87
+ if (Number.isSafeInteger(value) && value >= 0) snapshot[key] = value;
88
+ }
89
+ if (typeof data.delivery.sending === "boolean") snapshot.sending = data.delivery.sending;
90
+ if (["none", "viewer-changed", "acknowledged", "failed"].includes(data.delivery.lastOutcome)) snapshot.lastOutcome = data.delivery.lastOutcome;
91
+ return Object.keys(snapshot).length ? snapshot : null;
92
+ }
93
+
94
+ function coverageOf(value) {
95
+ if (value !== undefined && !record(value)) throw new Error("Game diagnostic coverage must be an object.");
96
+ const coverage = { complete: value?.complete === true };
97
+ if (value?.complete !== undefined && typeof value.complete !== "boolean") throw new Error("Coverage complete must be a boolean.");
98
+ for (const key of ["since", "until"]) if (value?.[key] !== undefined) {
99
+ if (!Number.isSafeInteger(value[key]) || value[key] < 0) throw new Error("Coverage boundaries must be nonnegative integers.");
100
+ coverage[key] = value[key];
101
+ }
102
+ if (coverage.since !== undefined && coverage.until !== undefined && coverage.since >= coverage.until) throw new Error("Coverage until must be after since.");
103
+ return coverage;
104
+ }
105
+
106
+ /** Local, metadata-only summary. Input rows remain the canonical evidence. */
107
+ export function summarizeGameDiagnostics(envelope) {
108
+ if (!record(envelope) || !positiveId(envelope.buildId) ||
109
+ typeof envelope.topic !== "string" || !/^[\w.-]{1,100}$/.test(envelope.topic) ||
110
+ !Array.isArray(envelope.entries) || envelope.entries.length > MAX_ROWS) {
111
+ throw new Error("Expected {buildId, topic, entries}; buildId must be positive, topic a technical name and entries at most 10000 rows.");
112
+ }
113
+ canonicalJson(envelope);
114
+ const collection = coverageOf(envelope.coverage);
115
+ const receipts = new Map(), groups = new Map(), legacy = [], observations = [];
116
+ let duplicateInputRows = 0, stableRows = 0, unknownArtifactRows = 0;
117
+ for (const entry of envelope.entries) {
118
+ if (!record(entry) || !positiveId(entry.id) || !positiveId(entry.userId) || !positiveId(entry.topicId) || !record(entry.data)) {
119
+ throw new Error("Every diagnostic entry requires canonical positive id, userId, topicId and object data; payload viewerId is not identity.");
120
+ }
121
+ if (entry.buildId !== undefined && entry.buildId !== envelope.buildId) throw new Error("Entry buildId does not match the envelope.");
122
+ const data = entry.data;
123
+ if (data.artifactVersionId != null && !positiveId(data.artifactVersionId)) throw new Error("artifactVersionId must be positive or unknown.");
124
+ for (const field of ["eventId", "batchId"]) if (data[field] != null && !stableId(data[field])) throw new Error(`Invalid diagnostic ${field}.`);
125
+ const exact = canonicalJson(data, 16384);
126
+ const receiptHash = hash(canonicalJson([entry.userId, entry.topicId, data.artifactVersionId ?? null, exact]));
127
+ if (receipts.has(entry.id)) {
128
+ if (receipts.get(entry.id) !== receiptHash) throw new Error("Conflicting copies of one canonical entry ID; inspect the retained raw source.");
129
+ duplicateInputRows++;
130
+ continue;
131
+ }
132
+ receipts.set(entry.id, receiptHash);
133
+ const artifactVersionId = data.artifactVersionId ?? null;
134
+ if (artifactVersionId === null) unknownArtifactRows++;
135
+ const fields = { entryId: entry.id, userId: entry.userId, topicId: entry.topicId, artifactVersionId };
136
+ const snapshot = deliverySnapshot(data);
137
+ if (snapshot) observations.push({ ...fields, delivery: snapshot });
138
+ const idType = data.eventId != null ? "eventId" : data.batchId != null ? "batchId" : null;
139
+ if (!idType) { legacy.push(data); continue; }
140
+ stableRows++;
141
+ const key = canonicalJson([envelope.buildId, envelope.topic, entry.topicId, entry.userId, artifactVersionId, idType, data[idType]]);
142
+ let group = groups.get(key);
143
+ if (!group) {
144
+ group = { ...fields, idType, stableId: data[idType], hashes: new Map(), rows: 0 };
145
+ groups.set(key, group);
146
+ }
147
+ group.rows++;
148
+ const fingerprint = hash(semanticJson(data));
149
+ let variant = group.hashes.get(fingerprint);
150
+ if (!variant) { variant = { data, entryIds: [] }; group.hashes.set(fingerprint, variant); }
151
+ variant.entryIds.push(entry.id);
152
+ }
153
+
154
+ const counted = [...legacy], collisions = [];
155
+ let uniqueStableEvents = 0, retryDuplicateReceipts = 0, collisionGroups = 0, collidingReceipts = 0;
156
+ for (const group of groups.values()) {
157
+ if (group.hashes.size > 1) {
158
+ collisionGroups++; collidingReceipts += group.rows;
159
+ if (collisions.length < 100) collisions.push({ userId: group.userId, topicId: group.topicId,
160
+ artifactVersionId: group.artifactVersionId, idType: group.idType, stableId: group.stableId,
161
+ receipts: group.rows, variants: [...group.hashes].slice(0, 20).map(([sha256, variant]) => ({ sha256,
162
+ entryIds: variant.entryIds.slice(0, 20), omittedEntryIds: Math.max(0, variant.entryIds.length - 20) })),
163
+ omittedVariants: Math.max(0, group.hashes.size - 20) });
164
+ continue;
165
+ }
166
+ uniqueStableEvents++; retryDuplicateReceipts += group.rows - 1;
167
+ counted.push(group.hashes.values().next().value.data);
168
+ }
169
+ const byKind = {}, alzebraEventNames = {}, soundtrackCounters = {};
170
+ let invalidCounterMetrics = 0;
171
+ for (const data of counted) {
172
+ const kind = data.batchId && !data.kind ? "alzebra-batch" : KINDS.has(data.kind) ? data.kind : "other";
173
+ byKind[kind] = (byKind[kind] || 0) + 1;
174
+ if (kind === "alzebra-batch" && Array.isArray(data.events)) for (const event of data.events) {
175
+ const name = EVENTS.has(event?.name) ? event.name : "other";
176
+ alzebraEventNames[name] = (alzebraEventNames[name] || 0) + 1;
177
+ }
178
+ if (kind === "soundtrack-diagnostics" && record(data.counters)) for (const name of SOUNDTRACK_COUNTERS) {
179
+ const value = data.counters[name];
180
+ if (value === undefined) continue;
181
+ if (!record(value) || !["count", "failed", "durationMs", "maxMs"].every(key => Number.isSafeInteger(value[key]) && value[key] >= 0 && value[key] <= 1e11) || value.failed > value.count) {
182
+ invalidCounterMetrics++;
183
+ continue;
184
+ }
185
+ const total = soundtrackCounters[name] ||= { count: 0, failed: 0, durationMs: 0, maxMs: 0 };
186
+ total.count += value.count; total.failed += value.failed; total.durationMs += value.durationMs;
187
+ total.maxMs = Math.max(total.maxMs, value.maxMs);
188
+ }
189
+ }
190
+ observations.sort((a, b) => b.entryId - a.entryId);
191
+ return {
192
+ schema: 1, buildId: envelope.buildId, topic: envelope.topic, collection,
193
+ rows: { input: envelope.entries.length, canonical: receipts.size, duplicateInputRows,
194
+ stableRows, legacyRows: legacy.length, unknownArtifactRows },
195
+ deduplication: { coverage: receipts.size === 0 ? "empty" : legacy.length || unknownArtifactRows || collisionGroups ? "partial" : "complete",
196
+ uniqueStableEvents, retryDuplicateReceipts, collisionGroups, collidingReceipts,
197
+ countedEvents: counted.length, collisions, omittedCollisions: Math.max(0, collisionGroups - collisions.length) },
198
+ counts: { byKind, alzebraEventNames, soundtrackCounters, invalidCounterMetrics },
199
+ delivery: { observations: observations.slice(0, 100), totalObservations: observations.length,
200
+ omittedObservations: Math.max(0, observations.length - 100),
201
+ note: "Per-receipt client snapshots; cumulative counters are not summed. An acknowledged count concerns earlier client receipts, not this row's gameplay save." },
202
+ note: "Preserve and read the raw receipts. Legacy rows remain separate and may contain retries; unknown artifacts are not version evidence. Collisions are excluded from event counts. Collection completeness describes supplied server rows, not successful delivery of every client event."
203
+ };
204
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.69",
3
+ "version": "0.3.70",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1186,6 +1186,7 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
1186
1186
  ```
1187
1187
 
1188
1188
  These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
1189
+ - **Arena members waiting for papers come first.** `arena paper-queue --cursor <member-id> --limit <1-100> --json` lists members blocked by the lack of a fresh complete published paper, using the member-facing pass/retry and solo-mastery rules. Follow every `nextCursor`, including after an empty page; require `demandBasis: waiting-published-papers-v1`. Group all pages by exam/difficulty and prioritize these waiting members before generic unseen-question depth. Failed ordinary papers remain retryable; open/pending-marking sittings are not publication waits. Ten successful distinct Full Hard clears, with Zero/Ciel allowed, are needed between solo mastery attempts. The pass and full-marks 20× XP/Coins rewards are separately reserved, each once. Publish complete independently reviewed papers and verify the same members can advance. `gather` collects this queue first as `arena-paper-queue-N.json`, with `paperQueue.complete/pages/waitingTracks`; missing/malformed/partial coverage must not be reported as an empty healthy queue. Use the workspace CLI until npm publication is verified. This step grants no new authoring spend or publication authority.
1189
1190
  - **`gather`** saves its fixed set of read-only reports into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json` and all types of pending Build review requests in `build-reviews-N.json`. Build reviews follow `nextCursor` to exhaustion; `gather.json` → `buildReviews` records `complete`, attempted `pages` and collected `items`. A failed/malformed page, cursor loop or 100-page bound reports incomplete coverage, never an empty healthy queue. Read the current collection once; it replaces the rewards-only list. Older packages without `buildReviews` need a separate `review list --status pending` collection through cursor exhaustion. Previous queue receipts remain on disk as history but do not become current evidence or duplicate reading duties after a new gather. It is not the complete daily checklist: app refills/usage, individual review assessments, sponsor integrity and other duties need their own steps. Repository agents follow the current full-run coverage index in `agent-guides/lumine-admin.md`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. It also creates `review-state.json`: collection never acknowledges reading. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
1190
1191
  - **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. `collectionFailures` includes failures without a page file, such as a cursor loop; acknowledging the available files cannot make an incomplete collection complete. A fresh successful gather clears those failures. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
1191
1192
  - **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts, explicit comment truncation/completeness, and whether Mikey has viewed it. Counts from truncated context are partial. An unavailable Subject context fails the collection. Eligibility only: ranking stays editorial.
@@ -1880,6 +1881,10 @@ and seeding coverage for steps 1-3 once the app is approved.
1880
1881
  decisions. The old Math Studio steps 1–3 / 20-per-run recipe is historical,
1881
1882
  not current authorization.
1882
1883
 
1884
+ ### Game diagnostic delivery
1885
+
1886
+ For retained game delivery evidence, `lumine admin game-diagnostics summarize --file <envelope.json> --json` is local-only and requires no login. The envelope is `{buildId,topic,entries,coverage:{complete,since,until}}`; keep the canonical sharedDb `id`, `userId`, `topicId` and parsed `data` on every row. The command deduplicates stable event/batch IDs within their canonical user/topic/artifact scope, flags conflicting copies, and reports legacy/unknown-version coverage. Raw evidence is still required for judgment. Cumulative delivery snapshots are not summed; server collection completeness does not prove complete client delivery. The reviewed October28 producers and CLI are not live until their release receipts confirm them. See `work/games-followup-20261028/` and the repository's daily guide.
1887
+
1883
1888
  ### Reward activity report (standing duty, every full daily review; added 2026-09-12)
1884
1889
 
1885
1890
  Also inspect failed or unconfirmed payouts with `lumine admin reward-diagnostics --days 1 --json`.
@@ -3155,6 +3160,46 @@ type PostSkip = Success<{
3155
3160
 
3156
3161
  ## Twinkle Newspaper
3157
3162
 
3163
+ **Daily website-code update article (Mikey, 2026-10-28):** before claiming an
3164
+ edition, the duty bot (Zero/Ciel) publishes a real Subject at reward/effort level
3165
+ **2** about member-relevant website code changes shipped since the previous
3166
+ paper. Check Git/push history against successful production deployments and the
3167
+ actual live revision, then explain the useful changes in plain language; a push
3168
+ or a proposed fix is not a release. This is website development news, not a
3169
+ roundup of member posts or internal tooling. Cite that Subject's actual event
3170
+ from the claim digest and include the linked article on every front page; use
3171
+ it as the lead only when its significance warrants it. Verify author, level,
3172
+ source link and printed placement, and avoid duplicate posts or repeated release
3173
+ coverage. If no relevant changes shipped, say so briefly without inventing news.
3174
+ The standing instruction authorizes this scoped post and coverage; it grants no
3175
+ unrelated release authority. Keep release evidence and private diagnostics out
3176
+ of the public article. Follow the repository's daily website-code update
3177
+ procedure and the existing dated-revision contract for an approved correction.
3178
+
3179
+ Use the scoped posting command after composing and reviewing the article:
3180
+
3181
+ ```bash
3182
+ lumine admin subject post-update --file article.json --json
3183
+ lumine admin subject post-update --file article.json --confirm --json
3184
+ ```
3185
+
3186
+ The first command is a read-only preview. The second publishes as the selected
3187
+ run's duty bot at level 2; `newspaper` or `full` run scope is required. The file
3188
+ contains `title`, `description`, `coverageStart` and `coverageEnd` (Unix seconds),
3189
+ and `releaseEvidence`: an array of `{repository, commit, deployedAt, summary}`.
3190
+ Use full Git commit hashes and verified deployment times inside the completed
3191
+ coverage interval. The evidence stays private; put only useful member-facing
3192
+ information in the article. An empty evidence array is allowed when no relevant
3193
+ changes shipped, and the article should say that briefly. The command does not
3194
+ independently verify GitHub/Vercel receipts: the operator must do that first.
3195
+
3196
+ One canonical article is permitted per scheduled editorial day. An identical
3197
+ retry returns its existing Subject instead of notifying members again; a
3198
+ changed payload, changed duty bot or edited/deleted post requires inspection
3199
+ and is not silently overwritten or recreated. Preview reports any existing
3200
+ Subject. The already-published October28 article is reserved as a legacy post;
3201
+ reuse Subject37265 rather than trying to republish it with this new command.
3202
+
3158
3203
  **Audience (Mikey, 2026-09-15): children and young Twinkle users aged 10–15.**
3159
3204
  Choose and write stories for what these readers would voluntarily spend time
3160
3205
  reading. Before selecting a story, identify its appeal to them: curiosity,
@@ -5467,6 +5512,10 @@ Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
5467
5512
  `comments get`, and `recommend` remain accepted, but the singular command forms
5468
5513
  shown above are the canonical interface.
5469
5514
 
5515
+ For bounded case evidence, use `lumine admin jev-routing review --since <UTC-day> --until <next-UTC-day> --json` and follow every cursor, or `--all --checkpoint <persistent-file>`. Daily gather retains every20-event source page for the preceding complete UTC day. The reader covers paired disagreements and Auto fallbacks once per event; synthetic traffic is excluded unless `--include-synthetic` is explicit. It returns only currently unexpired frozen input and labels absent/expired input. A missing endpoint is unavailable coverage, not an empty healthy day. Collection never acknowledges reading or proves classification accuracy. This October28 reader and independent Auto-input capture are local until the matching release is verified.
5516
+
5517
+ Before calling a reply unrelated, check its original generation trigger and the bounded `askedAbout` descriptor. A member may have edited the visible turn or asked about linked content. The descriptor explicitly represents current stored metadata, not a separately frozen generation snapshot; never bypass deleted context.
5518
+
5470
5519
  Read `jevRoutingShadow` alongside `jevPilot` in both the admin brief and full
5471
5520
  daily report. The September 21 primary-chat experiment is historical: chat
5472
5521
  routing is off by default and served-route audits were retired September 23.