@stage5/lumine 0.3.76 → 0.3.77

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.
@@ -13,8 +13,13 @@ import path from "node:path";
13
13
  // at once; a dry run (validation only) unless --confirm;
14
14
  // re-importing a folder retires that paper's earlier items
15
15
  // status --item <id> --to live|retired|checked
16
+ // picture-audit [--items <ids> | --cursor <id>] [--limit 1-20] [--renders <dir>]
17
+ // advisory JEV image review list of live picture
18
+ // items; never retires or edits. --renders holds
19
+ // <itemId>.png renders of picture-option items
20
+ // (panels are drawn by the web, not stored)
16
21
  export const ARENA_USAGE =
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]";
22
+ "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] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
18
23
  const MAX_ITEMS = 300;
19
24
 
20
25
  function validationError(message) {
@@ -62,7 +67,60 @@ function readAdditiveItems(file) {
62
67
  return items;
63
68
  }
64
69
 
70
+ const PICTURE_AUDIT_MAX = 20;
71
+ const RENDER_MAX_BYTES = 3 * 1024 * 1024;
72
+ // The API's JSON body limit is 8 MB; keep a margin for base64 framing.
73
+ const RENDERS_TOTAL_MAX_BYTES = 5 * 1024 * 1024;
74
+
75
+ function readRenders(dir, itemIds) {
76
+ const folder = path.resolve(dir);
77
+ let names;
78
+ try {
79
+ names = fs.readdirSync(folder).filter((name) => /^\d{1,10}\.(png|jpe?g)$/i.test(name));
80
+ } catch {
81
+ throw validationError(`Cannot read the renders folder ${folder}.`);
82
+ }
83
+ const renders = {};
84
+ let total = 0;
85
+ for (const name of names) {
86
+ const id = name.replace(/\..*$/, "");
87
+ if (itemIds && !itemIds.includes(Number(id))) continue;
88
+ const bytes = fs.readFileSync(path.join(folder, name));
89
+ if (!bytes.length || bytes.length > RENDER_MAX_BYTES) throw validationError(`${name} must be a PNG or JPEG of at most 3 MB.`);
90
+ total += bytes.length;
91
+ if (total > RENDERS_TOTAL_MAX_BYTES) throw validationError("Renders for one call must total under 5 MB; audit fewer --items at a time.");
92
+ renders[id] = bytes.toString("base64");
93
+ }
94
+ if (Object.keys(renders).length > PICTURE_AUDIT_MAX) throw validationError(`Send renders for at most ${PICTURE_AUDIT_MAX} items per call (use --items).`);
95
+ return renders;
96
+ }
97
+
65
98
  export function buildArenaOperation({ action, options }) {
99
+ if (action === "picture-audit") {
100
+ const limit = Number(options.adminLimit ?? 10);
101
+ if (!Number.isInteger(limit) || limit < 1 || limit > PICTURE_AUDIT_MAX) throw validationError(`Use --limit from 1 to ${PICTURE_AUDIT_MAX}.`);
102
+ let itemIds = null;
103
+ if (options.adminArenaItems) {
104
+ itemIds = options.adminArenaItems.split(",").map((value) => Number(value.trim()));
105
+ if (!itemIds.length || itemIds.length > PICTURE_AUDIT_MAX || itemIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
106
+ throw validationError(`--items is 1-${PICTURE_AUDIT_MAX} comma-separated item ids.`);
107
+ }
108
+ if (options.adminCursor) throw validationError("Use --items or --cursor, not both.");
109
+ }
110
+ const cursor = Number(options.adminCursor || 0);
111
+ if (!Number.isSafeInteger(cursor) || cursor < 0) throw validationError("Use a non-negative --cursor.");
112
+ const body = { limit, ...(itemIds ? { itemIds } : { cursor }) };
113
+ if (options.adminArenaRenders) body.renders = readRenders(options.adminArenaRenders, itemIds);
114
+ return {
115
+ name: "arena.picture-audit",
116
+ method: "POST",
117
+ path: "/cli/admin/arena/picture-audit",
118
+ body,
119
+ mutates: false,
120
+ requiresRun: false,
121
+ timeoutMs: 600000,
122
+ };
123
+ }
66
124
  if (action === "paper-queue") {
67
125
  const cursor = Number(options.adminCursor || 0);
68
126
  const limit = Number(options.adminLimit ?? 50);
@@ -174,6 +232,16 @@ export function formatArenaResult({ operation, data }) {
174
232
  data?.nextCursor ? `Continue: lumine admin arena today-queue --cursor ${data.nextCursor} --json` : "End of Today’s Q queue.",
175
233
  ];
176
234
  if (operation.name === "arena.today.member") return [JSON.stringify(data, null, 2)];
235
+ if (operation.name === "arena.picture-audit") {
236
+ const items = data?.items || [];
237
+ return [
238
+ `Picture audit (advisory; nothing retired or edited): ${items.length} items, ${data?.review?.length || 0} to review, ${data?.needsRender?.length || 0} need a render.`,
239
+ ...items.map((row) => `#${row.itemId} ${row.examFamily} ${row.itemType}${row.number ? ` no ${row.number}` : ""} [${row.kind || "-"}]: ${
240
+ row.status === "audited" ? `${row.verdict === "flag" ? "REVIEW" : "ok"}${row.cached ? " (saved)" : ""}${row.notes?.length ? ` · ${row.notes.join("; ")}` : ""}` : `${row.status} (${row.reason})`}`),
241
+ data?.nextCursor ? `Continue: lumine admin arena picture-audit --cursor ${data.nextCursor} --output <file> --json` : "End of live picture items.",
242
+ data?.notice || "",
243
+ ].filter(Boolean);
244
+ }
177
245
  if (operation.name === "arena.today.preview" || operation.name === "arena.today.import") return [
178
246
  `${data?.dryRun ? "Dry run" : "Saved"}: ${data?.questions?.length || 0} reviewed personal questions.${data?.dryRun ? " Add --confirm to make new questions available." : ""}`,
179
247
  ];
@@ -188,7 +188,7 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
188
188
  collection.invalidate(steps.at(-1).name);
189
189
  Object.assign(steps.at(-1), { ok: false, error });
190
190
  };
191
- const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|owner-wake|bot-output)/;
191
+ const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|jev-images|owner-wake|bot-output)/;
192
192
  const step = (name, args, file) => {
193
193
  const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
194
194
  steps.push({
@@ -283,6 +283,10 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
283
283
  since: jevUntil - 86400, until: jevUntil, step, failLast });
284
284
  writeAdminJsonFile(path.join(dir, "jev-review-summary.json"), diagnosticCollectionSummary(jevReview), { privateFile: true });
285
285
  steps.push({ name: "jev-review-summary", file: "jev-review-summary.json", ok: true });
286
+ // JEV image checks (advisory): per-area health, cost, agreement with final
287
+ // decisions, disagreements and escalations for the report's "JEV images"
288
+ // section. A failed step is unavailable evidence, not a quiet day.
289
+ step("jev-images", ["jev-images", "report", "--days", "1"], "jev-images.json");
286
290
  // One pending queue covers every Build approval type. Exhaust its cursor;
287
291
  // an empty/malformed receipt must not look like an empty queue.
288
292
  const buildReviews = { complete: false, pages: 0, items: 0 };
@@ -0,0 +1,105 @@
1
+ // `lumine admin jev-images report|switch|decide` (twinkle-api
2
+ // controllers/cli/admin/jevImages.ts): JEV image checks are advisory only.
3
+ // report [--days 1..30] per-area health, cost, agreement,
4
+ // disagreements and escalations
5
+ // switch --area <area|all> --mode on|off [--daily-cap <n>] owner only;
6
+ // takes effect within 30 s, no deploy
7
+ // decide --area <area> --key <subject> --decision <decision> record a
8
+ // reviewer's final decision
9
+ export const JEV_IMAGES_AREAS = ["arena_paper", "arena_picture", "study_record", "thumbnail", "sdk_decide"];
10
+ export const JEV_IMAGES_USAGE =
11
+ "Usage: lumine admin jev-images report [--days 1..30] | switch --area <" +
12
+ ["all", ...JEV_IMAGES_AREAS].join("|") +
13
+ "> --mode on|off [--daily-cap <0-5000>] | decide --area <area> --key <subject key, e.g. arenaItem:10176> --decision <decision> [--json]";
14
+
15
+ function invalid(message) {
16
+ const error = new Error(message);
17
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
18
+ return error;
19
+ }
20
+
21
+ export function buildJevImagesOperation({ action, options = {} }) {
22
+ if (!action || action === "report") {
23
+ const days = options.adminDays === "" || options.adminDays == null ? 1 : Number(options.adminDays);
24
+ if (!Number.isInteger(days) || days < 1 || days > 30) throw invalid("--days must be 1 to 30.");
25
+ return {
26
+ name: "jev-images.report",
27
+ method: "GET",
28
+ path: `/cli/admin/jev-images/report?days=${days}`,
29
+ body: undefined,
30
+ mutates: false,
31
+ requiresRun: false,
32
+ };
33
+ }
34
+ if (action === "switch") {
35
+ const area = String(options.adminJevArea || "");
36
+ if (area !== "all" && !JEV_IMAGES_AREAS.includes(area)) throw invalid(JEV_IMAGES_USAGE);
37
+ const mode = String(options.adminMode || "");
38
+ if (mode !== "on" && mode !== "off") throw invalid("--mode is on or off.");
39
+ const body = { area, mode };
40
+ if (options.adminJevDailyCap !== "" && options.adminJevDailyCap != null) {
41
+ const cap = Number(options.adminJevDailyCap);
42
+ if (area === "all" || !Number.isInteger(cap) || cap < 0 || cap > 5000) {
43
+ throw invalid("--daily-cap is set per area, 0 to 5000.");
44
+ }
45
+ body.dailyCap = cap;
46
+ }
47
+ return {
48
+ name: "jev-images.switch",
49
+ method: "POST",
50
+ path: "/cli/admin/jev-images/switch",
51
+ body,
52
+ mutates: true,
53
+ requiresRun: false,
54
+ };
55
+ }
56
+ if (action === "decide") {
57
+ const area = String(options.adminJevArea || "");
58
+ if (!JEV_IMAGES_AREAS.includes(area)) throw invalid(JEV_IMAGES_USAGE);
59
+ const subjectKey = String(options.adminJevKey || "");
60
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,30}:[0-9][A-Za-z0-9:_-]{0,60}$/.test(subjectKey)) {
61
+ throw invalid("--key is a subject key such as arenaItem:10176 or arenaPaper:12.");
62
+ }
63
+ const decision = String(options.adminDecision || "");
64
+ if (!/^[a-z_]{2,24}$/.test(decision)) throw invalid("--decision is required (e.g. keep, fix, retired).");
65
+ return {
66
+ name: "jev-images.decide",
67
+ method: "POST",
68
+ path: "/cli/admin/jev-images/decide",
69
+ body: { area, subjectKey, decision },
70
+ mutates: true,
71
+ requiresRun: false,
72
+ };
73
+ }
74
+ throw invalid(JEV_IMAGES_USAGE);
75
+ }
76
+
77
+ const usd = (value) => `$${Number(value || 0).toFixed(4)}`;
78
+
79
+ export function formatJevImagesResult({ operation, data = {} }) {
80
+ if (operation.name === "jev-images.switch") {
81
+ return [`JEV images ${data.area}: ${data.mode}${data.dailyCap !== undefined ? `, daily cap ${data.dailyCap}` : ""} (all servers within ${data.effectiveWithinSeconds ?? 30} s).`];
82
+ }
83
+ if (operation.name === "jev-images.decide") {
84
+ return [`Recorded ${data.decision} for ${data.subjectKey} (${data.area}) on ${data.updated ?? 0} advisory row(s).`];
85
+ }
86
+ const lines = [
87
+ `JEV images (${data.model || "?"}, advisory only) · last ${data.window?.days ?? "?"} day(s) · coverage ${data.coverage?.status || "unknown"} · ${data.totals?.checks ?? 0} checks · ${usd(data.totals?.costUsd)}`,
88
+ ];
89
+ for (const [area, a] of Object.entries(data.areas || {})) {
90
+ const s = a.setting || {};
91
+ lines.push(
92
+ `${area}: ${s.mode} (${s.source}, cap ${s.dailyCap}, used today ${s.usedToday ?? "?"}) · ${a.checks} checks, ${a.ok} ok, ${a.errors} errors, ${a.timeouts} timeouts, ${a.droppedRetries} dropped-image retries · p95 ${a.latencyMs?.p95 ?? "-"} ms · ${a.tokens?.input ?? 0} tokens ${usd(a.costUsd)} · agreement ${a.agreementRate === null ? "-" : `${a.agreed}/${a.scored}`} · ${a.flaggedUndecided} flagged awaiting a decision`,
93
+ );
94
+ for (const d of a.disagreements || []) {
95
+ lines.push(` disagreement #${d.eventId} ${d.subjectKey}: advisory ${d.advisory}, final ${d.finalDecision}`);
96
+ }
97
+ }
98
+ if ((data.escalations || []).length) {
99
+ lines.push("Escalations:");
100
+ for (const e of data.escalations) lines.push(` ${e.area} ${e.code}: ${e.detail}`);
101
+ } else {
102
+ lines.push("Escalations: none.");
103
+ }
104
+ return lines;
105
+ }
package/lib/admin.js CHANGED
@@ -50,6 +50,7 @@ import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
50
50
  import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
51
51
  import { buildArenaOperation, formatArenaResult } from "./admin-arena.js";
52
52
  import { buildJevRoutingReviewOperation, formatJevRoutingReview } from "./admin-jev-review.js";
53
+ import { buildJevImagesOperation, formatJevImagesResult } from "./admin-jev-images.js";
53
54
  import { summarizeGameDiagnostics } from "./game-diagnostic-summary.js";
54
55
  import {
55
56
  buildSlidingBlockOperation,
@@ -377,8 +378,10 @@ function buildApprovalOperation({ action, target, options }) {
377
378
  }
378
379
  const id = parseApprovalId(target);
379
380
  if (action === "show" || action === "get") {
381
+ // An Arena paper's advisory JEV pre-screen can take a while on first view.
380
382
  return readOperation("approvals.show", `/cli/admin/approvals/${id}`, {
381
383
  requiresRun: false,
384
+ timeoutMs: 180000,
382
385
  });
383
386
  }
384
387
  // An Arena paper's page replaced by the reviewer's blurred copy (names,
@@ -2714,6 +2717,10 @@ export function parseAdminOperation(options) {
2714
2717
  if (namespace === "jev-routing") {
2715
2718
  return buildJevRoutingReviewOperation({ action, target, extra, options });
2716
2719
  }
2720
+ // JEV image checks: report, owner switch, final decisions (admin-jev-images.js).
2721
+ if (namespace === "jev-images") {
2722
+ return buildJevImagesOperation({ action, options });
2723
+ }
2717
2724
  // Who holds which management level (admin-roles.js); read-only, no run.
2718
2725
  if (namespace === "roles") {
2719
2726
  return buildRolesOperation(options);
@@ -6915,6 +6922,20 @@ function arenaItemSummary(item) {
6915
6922
  return "";
6916
6923
  }
6917
6924
 
6925
+ // JEV image pre-screen notes (advisory only; the decision stays the reviewer's).
6926
+ function printPaperPrescreen(prescreen) {
6927
+ if (!prescreen) return;
6928
+ console.log(`Pre-screen (advisory, JEV images): ${prescreen.status}${prescreen.reason ? ` (${prescreen.reason})` : ""}`);
6929
+ for (const page of prescreen.pages || []) {
6930
+ console.log(page.status === "ok"
6931
+ ? ` page ${page.page}: ${page.verdict === "flag" ? "LOOK CLOSELY" : "looks like an exam page"}${page.cached ? " (saved)" : ""} · ${(page.notes || []).join("; ")}`
6932
+ : ` page ${page.page}: no pre-screen (${page.reason})`);
6933
+ }
6934
+ if (prescreen.blankLooking?.length) console.log(` blank-looking pages: ${prescreen.blankLooking.join(", ")}`);
6935
+ for (const pair of prescreen.duplicatePages || []) console.log(` pages ${pair.pages.join(" and ")}: ${pair.kind === "same_file" ? "the same file" : "look nearly identical"}`);
6936
+ if (prescreen.notice) console.log(` ${prescreen.notice}`);
6937
+ }
6938
+
6918
6939
  function printArenaEvidence(arena) {
6919
6940
  if (!arena) return;
6920
6941
  if (arena.kind === "scoreReport") {
@@ -6930,6 +6951,7 @@ function printArenaEvidence(arena) {
6930
6951
  console.log(arena.duplicate ? `Duplicate: ${arena.duplicate.reason} of paper #${arena.duplicate.duplicateOf}` : "Duplicate: none found");
6931
6952
  console.log(`Rarity: ${arena.rarity || "-"} · reward ${arena.reward?.xp?.toLocaleString?.("en-US") ?? 0} XP + ${arena.reward?.coins ?? 0} coins${arena.reward?.onApprove ? " on approval" : arena.reward?.paid ? " (paid)" : ""}`);
6932
6953
  if (arena.reviewNote) console.log(`Review note: ${arena.reviewNote}`);
6954
+ printPaperPrescreen(arena.prescreen);
6933
6955
  }
6934
6956
  if (arena.check) console.log(`Check: ${arena.check}`);
6935
6957
  }
@@ -7673,6 +7695,10 @@ function printAdminResult({ operation, result }) {
7673
7695
  for (const line of formatJevRoutingReview(data)) console.log(line);
7674
7696
  return;
7675
7697
  }
7698
+ if (operation.name.startsWith("jev-images.")) {
7699
+ for (const line of formatJevImagesResult({ operation, data })) console.log(line);
7700
+ return;
7701
+ }
7676
7702
  if (operation.name === "roles.read") {
7677
7703
  printRoles(data);
7678
7704
  return;
package/lib/commands.js CHANGED
@@ -2719,6 +2719,12 @@ export function parseArgs(args) {
2719
2719
  adminArenaAdditive: parseBoolean(raw.additive, false),
2720
2720
  adminArenaItem: raw.item ? String(raw.item) : "",
2721
2721
  adminArenaTo: raw.to ? String(raw.to) : "",
2722
+ adminArenaRenders: raw.renders ? String(raw.renders) : "",
2723
+ adminArenaItems: raw.items ? String(raw.items) : "",
2724
+ // lumine admin jev-images (admin-jev-images.js)
2725
+ adminJevArea: raw.area ? String(raw.area) : "",
2726
+ adminJevKey: raw.key ? String(raw.key) : "",
2727
+ adminJevDailyCap: raw.dailyCap === undefined ? "" : String(raw.dailyCap),
2722
2728
  adminClaimFile: raw.claim ? String(raw.claim) : "",
2723
2729
  adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
2724
2730
  adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
@@ -3336,7 +3342,7 @@ export function printHelp() {
3336
3342
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3337
3343
  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)
3338
3344
  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)
3339
- 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)
3345
+ 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 | picture-audit [--items <id,id,...> | --cursor <id>] [--limit 1-20] [--renders <dir of <itemId>.png>] [--output <review-list.json>] (Arena's mock-exam bank; picture-audit is an advisory JEV image review list that never retires or edits items; import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
3340
3346
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3341
3347
  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)
3342
3348
  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>]
@@ -3394,6 +3400,7 @@ export function printHelp() {
3394
3400
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3395
3401
  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)
3396
3402
  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)
3403
+ lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] | decide --area <area> --key <subject key> --decision <decision> [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy)
3397
3404
  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)
3398
3405
  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]
3399
3406
  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]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.76",
3
+ "version": "0.3.77",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.75.0
4
- Updated: 2026-10-31
5
- Generated: 2026-10-31T07:54:50.319Z
3
+ Version: 1.76.0
4
+ Updated: 2026-11-01
5
+ Generated: 2026-11-01T11:22:03.891Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -554,7 +554,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
554
554
  - Template values describe types, not answers to copy. All object keys are required and extra keys are rejected, including inside nested objects; {} means an exactly empty object, not a free-form dictionary. A non-empty array uses only its first item as the template for every returned item, with no fixed length; mixed examples do not declare a union. [] allows arbitrary JSON items. A null template accepts any JSON primitive (string, number, boolean or null), but the key is still required. Use 'number' for fractional numbers; 0 and other integer examples require integers. State content requirements in prompt or instructions. GPT routes retain descriptive string examples as field guidance, not enforced enum values.
555
555
  - GPT models prefer native JSON Schema output for compatible shapes. Shapes outside the strict provider subset, such as [] with arbitrary items or deeply nested structures, use prompt-based JSON generation with the same final validation. A provider rejection specifically of the native schema triggers one prompt-based fallback; auth, quota, safety and unrelated request failures do not trigger that fallback. Validation retries preserve the original task and instructions. The returned object/result shape and streaming callbacks are unchanged.
556
556
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
557
- - Omit model to use the normal Lite/Medium/High routing. Low and Medium use gpt-6-luna; explicit gpt-6-luna accepts Low, Medium or High thinking. Existing apps naming claude-haiku-5-5 now run on gpt-6-luna at the requested thinking level. Explicit claude-sonnet-5-5, gpt-6.1-sol or claude-opus-5-5 still require thinkingMode: 'high'; unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
557
+ - Omit model to use the normal Lite/Medium/High routing. Low and Medium use gpt-6-luna; explicit gpt-6-luna accepts Low, Medium or High thinking. Existing apps naming claude-haiku-5-5 now run on gpt-6-luna at the requested thinking level. Explicit gpt-6.1-sol requires thinkingMode: 'high' (it reasons at xhigh). Explicit claude-sonnet-5-5 or claude-opus-5-5 think at the requested level (low, medium or high; Opus 5.5's own default is medium). A Claude model's thinking shares one reply with the object, so if a large object fails with code build_ai_output_too_long (cut off at the length limit), offer the player a retry at a lower thinkingMode or a smaller request; a cut-off reply is not charged to the player's AI Energy. Unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
558
558
  - thinkingMode low uses GPT-6 Luna with low reasoning and consumes the viewer's AI Energy from confirmed provider usage.
559
559
  - thinkingMode medium uses GPT-6 Luna with medium reasoning and consumes normal AI Energy.
560
560
  - thinkingMode high without model uses Claude Sonnet 5.5 with high adaptive thinking and consumes high AI Energy. Explicit model: 'gpt-6.1-sol' selects GPT-6.1 Sol with xhigh reasoning at the same High AI Energy tier; model: 'claude-sonnet-5-5' names Sonnet 5.5 directly.
@@ -1574,14 +1574,14 @@ world.updatePresence({ x, y, z, facing });
1574
1574
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
1575
1575
  - Only currently shared public reflections are returned. The response field is the stored shared response version, which may be raw or polished depending on what the author explicitly shared.
1576
1576
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
1577
- - dayType is 'word' (Word Day), 'picture' (Picture Day) or null (an ordinary reflection). On a Picture Day the member described a picture instead of reflecting: pictureUrl is the picture they chose (null when they finished with words only), pictureStyle the style they asked for, and response is their description. Show the picture with the description, not the stock question.
1577
+ - dayType is 'word' (Word Day), 'advice' (Advice Day: a Word Day that asks for a piece of advice; response is the advice, or whatever they chose to write instead), 'picture' (Picture Day) or null (an ordinary reflection). On a Picture Day the member described a picture instead of reflecting: pictureUrl is the picture they chose (null when they finished with words only), pictureStyle the style they asked for, and response is their description. Show the picture with the description, not the stock question.
1578
1578
  - async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
1579
1579
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question, dayType, pictureUrl, pictureStyle }], cursor? }
1580
1580
  - Read one user's currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
1581
1581
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
1582
1582
  - Only currently shared public reflections are returned. The response field is the stored shared response version, which may be raw or polished depending on what the author explicitly shared.
1583
1583
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
1584
- - dayType is 'word' (Word Day), 'picture' (Picture Day) or null (an ordinary reflection). On a Picture Day the member described a picture instead of reflecting: pictureUrl is the picture they chose (null when they finished with words only), pictureStyle the style they asked for, and response is their description. Show the picture with the description, not the stock question.
1584
+ - dayType is 'word' (Word Day), 'advice' (Advice Day: a Word Day that asks for a piece of advice; response is the advice, or whatever they chose to write instead), 'picture' (Picture Day) or null (an ordinary reflection). On a Picture Day the member described a picture instead of reflecting: pictureUrl is the picture they chose (null when they finished with words only), pictureStyle the style they asked for, and response is their description. Show the picture with the description, not the stock question.
1585
1585
 
1586
1586
  ### Twinkle.privateDb
1587
1587
  - async get(key) | scopes: privateDb:read
@@ -1328,7 +1328,7 @@ Birthdate, teacher-signup (`mentor`) and meetup-achievement requests, each with
1328
1328
 
1329
1329
  ```bash
1330
1330
  lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup]
1331
- lumine admin approvals show <id>
1331
+ lumine admin approvals show <id> # an Arena paper also gets advisory JEV pre-screen notes (see JEV images)
1332
1332
  lumine admin approvals approve|reject <id> [--reason <text>]
1333
1333
  lumine admin teachers audit [--limit 1-100] [--cursor <c>] [--all]
1334
1334
  lumine admin teachers revoke <userId> --reason <text>
@@ -4000,6 +4000,72 @@ input snapshots as described below.
4000
4000
  See `twinkle-api/JEV_PILOT.md` for configuration,
4001
4001
  the synthetic evaluation step and release checks.
4002
4002
 
4003
+ ### JEV images (standing duty, every full daily review; added 2026-11-01)
4004
+
4005
+ ```bash
4006
+ lumine admin jev-images report --json # last 24 h; --days 1..30
4007
+ lumine admin jev-images switch --area all --mode off # owner kill switch, no deploy
4008
+ lumine admin jev-images switch --area arena_picture --mode on --daily-cap 100
4009
+ lumine admin jev-images decide --area arena_picture --key arenaItem:10176 --decision keep|fix|retired
4010
+ lumine admin arena picture-audit [--items <id,...> | --cursor <id>] [--limit 1-20] [--renders <dir>] --output <review-list.json> --json
4011
+ ```
4012
+
4013
+ JEV image checks (Mikey, November 1) send pictures to JEV (`jev-1.13.0`,
4014
+ pinned) with the `TypeSafe-Danger-Zone-Images` opt-in. The vendor marks images
4015
+ experimental. **Every answer is advisory:** never the only gate, never a
4016
+ child-safety decision and never a reason to pay or withhold a reward (standing
4017
+ ruling 11-01). Areas: `arena_paper` (paper-upload pre-screen notes in
4018
+ `approvals show`), `arena_picture` (picture-bank audit). `study_record`,
4019
+ `thumbnail` and `sdk_decide` are reserved for later steps and stay off.
4020
+
4021
+ Each check carries a small coloured square and asks its colour. A wrong or
4022
+ unsure colour, or an answer from a different model, means the pictures were
4023
+ dropped. The check is retried once (`droppedRetries`), then returns
4024
+ `images_dropped`. An unavailable check (off, capped, timeout, provider error,
4025
+ dropped images, tables missing) leaves the caller exactly as it was before.
4026
+
4027
+ `daily-run gather` saves the report as `jev-images.json`. Give the full report a
4028
+ **JEV images** section:
4029
+ - per area: mode and source, daily cap and today's use, checks, errors,
4030
+ timeouts, dropped-image retries and give-ups, latency p50/p95, tokens and cost;
4031
+ - agreement between advisories and final decisions (`agreed/scored`), every
4032
+ listed disagreement and the flagged items still awaiting a decision;
4033
+ - every `escalations` entry. Thresholds (`thresholds` in the JSON): error rate
4034
+ above 20% or timeouts above 10% (at least 5 sent checks), dropped-image retries
4035
+ above 5% of requests or any give-up, daily cost above $0.10 and 3x the previous
4036
+ 7-day average, daily cost above $2, agreement below 80% over at least 10
4037
+ decided items, and any check skipped at the cap.
4038
+
4039
+ `coverage.status: store_unavailable` means the tables are not migrated: report
4040
+ "not deployed", not a quiet day. A failed gather step is unavailable evidence.
4041
+ JEV image spend is also in the AI ledger (`surface: jev_images`), so never add
4042
+ it to the AI cost total a second time. Agreement is not accuracy: a
4043
+ disagreement can be JEV's mistake or the reviewer's, so read the item before
4044
+ saying which.
4045
+
4046
+ **Paper pre-screen.** For a pending `arenaPaper`, `approvals show <id>` adds a
4047
+ `prescreen` block: per page, whether it looks like a test page, legibility, a
4048
+ guess at the exam family and printed year, whether it fits its tags, plus
4049
+ blank-looking and duplicate pages (plain image arithmetic). Results are saved
4050
+ per page, so a second `show` makes no new call (`(saved)`). Add
4051
+ `?prescreen=off` to the API read to skip it. The approval criterion is
4052
+ unchanged (a real exam paper matching its tags, ruling 10-29): read every page
4053
+ yourself. Approve/reject records the final decision for agreement.
4054
+
4055
+ **Picture-bank audit.** `arena picture-audit` reads live items with a picture
4056
+ and asks fixed questions. For 수능 listening #4 it asks which labelled object
4057
+ mismatches the script, whether the keyed object is drawn as briefed, whether
4058
+ the ① to ⑤ labels are present, and whether there is stray text. For picture
4059
+ choices it asks which picture matches the heard sentence and whether every
4060
+ picture is readable. For other single pictures it asks whether the picture is
4061
+ readable and whether there is stray text. Picture choices are drawn by the web,
4062
+ so pass `--renders <dir>` containing `<itemId>.png` renders of the item as
4063
+ printed, for example from an Arena render script. Without one, the item is
4064
+ listed under `needsRender`. The command never retires or edits an item. Read
4065
+ each `review` item yourself. Record `keep`, `fix` or `retired` with
4066
+ `jev-images decide`. Retiring an item through `arena status` records `retired`
4067
+ automatically. Fixing or retiring live items needs the usual authorization.
4068
+
4003
4069
  ### Spending by calendar month
4004
4070
 
4005
4071
  ```bash