@stage5/lumine 0.3.68 → 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.
@@ -28,6 +28,8 @@ export function createDailyReviewState(dir, gather, previous = {}) {
28
28
  .map((step) => ({
29
29
  ...(step.ok ? fingerprint(dir, step.file) : { file: step.file }),
30
30
  fetched: step.ok,
31
+ ...(step.deferred ? { deferred: true, reason: step.reason } : {}),
32
+ ...(step.capturedAt ? { capturedAt: step.capturedAt, reused: Boolean(step.reused) } : {}),
31
33
  read: false,
32
34
  }));
33
35
  if (gather.botOutput.complete)
@@ -42,6 +44,14 @@ export function createDailyReviewState(dir, gather, previous = {}) {
42
44
  if (gather.buildReviews && (
43
45
  source.file === "rr-pending.json" || /^build-reviews-\d+\.json$/.test(source.file)
44
46
  )) continue;
47
+ if (gather.paperQueue && /^arena-paper-queue-\d+\.json$/.test(source.file)) continue;
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;
52
+ if (gather.botOutput && (
53
+ source.file === "bot-chats.txt" || /^bot-output-\d+\.json$/.test(source.file)
54
+ )) continue;
45
55
  if (!sources.some((current) => current.file === source.file)) sources.push({ ...source, retained: true, read: false });
46
56
  }
47
57
  return {
@@ -49,6 +59,7 @@ export function createDailyReviewState(dir, gather, previous = {}) {
49
59
  scope: previous.scope || "collection",
50
60
  sources,
51
61
  collectionFailures: gather.failed || [],
62
+ collectionDeferred: gather.deferred || [],
52
63
  requiredDuties: [...new Set(["effort-levels", "report-browser", ...(previous.requiredDuties || [])])],
53
64
  note: "Collection is not a full review. Add every duty required by the run scope and its carryovers. Reading and browser checks require explicit evidence acknowledgments.",
54
65
  };
@@ -157,8 +168,9 @@ export function summarizeDailyProgress(dir, state, evidence = {}) {
157
168
  .filter((item) => item.status !== "completed")
158
169
  .map((item) => item.id);
159
170
  const collectionFailures = state.collectionFailures || [];
171
+ const collectionDeferred = state.collectionDeferred || [];
160
172
  const complete =
161
- collectionFailures.length === 0 && sources.every((item) => item.read) && incomplete.length === 0;
173
+ collectionFailures.length === 0 && collectionDeferred.length === 0 && sources.every((item) => item.read) && incomplete.length === 0;
162
174
  return {
163
175
  scope: state.scope || "declared-duties",
164
176
  coverageBasis: "declared sources and duties only",
@@ -166,6 +178,7 @@ export function summarizeDailyProgress(dir, state, evidence = {}) {
166
178
  effortAssignments,
167
179
  sources,
168
180
  collectionFailures,
181
+ collectionDeferred,
169
182
  duties,
170
183
  complete,
171
184
  reading: {
@@ -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
@@ -2474,6 +2474,7 @@ export function parseArgs(args) {
2474
2474
  const booleanFlags = new Set([
2475
2475
  // lumine admin arena import --live
2476
2476
  "live",
2477
+ "additive",
2477
2478
  "remove",
2478
2479
  "fault",
2479
2480
  "noAnnounce",
@@ -2507,6 +2508,7 @@ export function parseArgs(args) {
2507
2508
  "wake",
2508
2509
  "includeLegacy",
2509
2510
  "includePrivateEvidence",
2511
+ "includeSynthetic",
2510
2512
  "allFlagged",
2511
2513
  "reviewed",
2512
2514
  "noReviewLoop",
@@ -2680,6 +2682,8 @@ export function parseArgs(args) {
2680
2682
  adminApprove: raw.approve ? String(raw.approve) : "",
2681
2683
  adminSinceRun: Boolean(raw.sinceRun),
2682
2684
  adminSince: raw.since ? String(raw.since) : "",
2685
+ adminUntil: raw.until ? String(raw.until) : "",
2686
+ adminIncludeSynthetic: Boolean(raw.includeSynthetic),
2683
2687
  adminPath: raw.path ? String(raw.path) : "",
2684
2688
  adminPerf: Boolean(raw.perf),
2685
2689
  adminWake: Boolean(raw.wake),
@@ -2701,6 +2705,7 @@ export function parseArgs(args) {
2701
2705
  // lumine admin arena (admin-arena.js)
2702
2706
  adminArenaAudio: raw.audio ? String(raw.audio) : "",
2703
2707
  adminArenaLive: parseBoolean(raw.live, false),
2708
+ adminArenaAdditive: parseBoolean(raw.additive, false),
2704
2709
  adminArenaItem: raw.item ? String(raw.item) : "",
2705
2710
  adminArenaTo: raw.to ? String(raw.to) : "",
2706
2711
  adminClaimFile: raw.claim ? String(raw.claim) : "",
@@ -2851,6 +2856,10 @@ export function parseArgs(args) {
2851
2856
  adminTarget: raw.target ? String(raw.target) : "",
2852
2857
  adminActions: raw.actions ? String(raw.actions) : "",
2853
2858
  adminDate: raw.date ? String(raw.date) : "",
2859
+ adminCostDate: raw.costDate ? String(raw.costDate) : "",
2860
+ adminPhase: raw.phase ? String(raw.phase) : "",
2861
+ adminMaxSteps: raw.maxSteps ?? "",
2862
+ adminMaxSeconds: raw.maxSeconds ?? "",
2854
2863
  adminDays: raw.days ? String(raw.days) : "",
2855
2864
  adminSpendingMonth: raw.month,
2856
2865
  adminSpendingFrom: raw.from,
@@ -3316,7 +3325,7 @@ export function printHelp() {
3316
3325
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3317
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)
3318
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)
3319
- 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)
3320
3329
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3321
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)
3322
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>]
@@ -3324,6 +3333,7 @@ export function printHelp() {
3324
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)
3325
3334
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3326
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)
3327
3337
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
3328
3338
  lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
3329
3339
  lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
@@ -3339,13 +3349,14 @@ export function printHelp() {
3339
3349
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
3340
3350
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
3341
3351
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
3342
- lumine admin daily-run gather --output-dir <dir> [--date YYYY-MM-DD] [--json] (all read-only daily reports + bot-output pages + bot-chats.txt)
3352
+ lumine admin daily-run gather --output-dir <dir> [--cost-date YYYY-MM-DD] [--phase priority|evidence|all] [--max-steps 200] [--max-seconds 900] [--resume] [--json] (closed UTC cost day; resumable collection, never automatic review)
3343
3353
  lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] [--json] (reading, canonical effort counts, pending duties)
3344
3354
  lumine admin featured candidates [--days 7] [--after <iso>] [--json] (recent, provably never-Featured Subjects with comment counts)
3345
3355
  lumine admin report serve --file <report.md> [--port <n>] [--open] [--json] (loopback report page with section nav)
3346
3356
  lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
3347
3357
  lumine admin subjects candidates [--since-run|--after <date>|--include-legacy] [--effort unassigned] [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3348
3358
  lumine admin subject get|reveal <subject-url-or-id> [--json]
3359
+ lumine admin subject post-update --file <article.json> [--confirm] [--json]
3349
3360
  lumine admin subject comments <subject-url-or-id> [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3350
3361
  lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
3351
3362
  lumine admin subject creator set-made-by-poster <subject-id> [--json]
@@ -3372,6 +3383,7 @@ export function printHelp() {
3372
3383
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3373
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)
3374
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)
3375
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]
3376
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]
3377
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.68",
3
+ "version": "0.3.70",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {