@stage5/lumine 0.3.75 → 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);
@@ -112,6 +170,13 @@ export function buildArenaOperation({ action, options }) {
112
170
  }
113
171
  if (action === "import") {
114
172
  const additive = Boolean(options.adminArenaAdditive);
173
+ if (!options.adminFile && !options.adminDir) {
174
+ throw validationError(
175
+ additive
176
+ ? "--additive needs --file <reviewed-items.json> or --dir <folder>; it never reads the current folder."
177
+ : ARENA_USAGE,
178
+ );
179
+ }
115
180
  if ((options.adminFile && options.adminDir) || (options.adminFile && !additive)) {
116
181
  throw validationError("Use --dir for a complete paper, or --additive with exactly one of --file or --dir for a refill.");
117
182
  }
@@ -167,6 +232,16 @@ export function formatArenaResult({ operation, data }) {
167
232
  data?.nextCursor ? `Continue: lumine admin arena today-queue --cursor ${data.nextCursor} --json` : "End of Today’s Q queue.",
168
233
  ];
169
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
+ }
170
245
  if (operation.name === "arena.today.preview" || operation.name === "arena.today.import") return [
171
246
  `${data?.dryRun ? "Dry run" : "Saved"}: ${data?.questions?.length || 0} reviewed personal questions.${data?.dryRun ? " Add --confirm to make new questions available." : ""}`,
172
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);
@@ -3063,9 +3070,15 @@ export function parseAdminOperation(options) {
3063
3070
  const raw = readAdminJsonFile(target, "<reviewed-definitions.json>", { maxBytes: 512 * 1024 });
3064
3071
  const items = Array.isArray(raw) ? raw : raw?.items;
3065
3072
  if (!Array.isArray(items) || !items.length || items.length > 100) throw cliValidationError("Review 1 to 100 Korean definitions, keeping exported word IDs, revisions and source hashes.");
3066
- return writeOperation("dictionary.korean-review", "POST", "/cli/admin/dictionary/korean/review", { items, dryRun: Boolean(options.adminDryRun) }, { requiresRun: false, mutates: !options.adminDryRun, timeoutMs: 120000 });
3073
+ // A preview (dry run) unless --confirm, like arena import and today-import.
3074
+ if (options.adminConfirm && options.adminConfirm !== "true") {
3075
+ throw cliValidationError("Pass --confirm on its own, without a value.");
3076
+ }
3077
+ const confirm = options.adminConfirm === "true";
3078
+ if (confirm && options.adminDryRun) throw cliValidationError("Choose --confirm or --dry-run, not both.");
3079
+ return writeOperation("dictionary.korean-review", "POST", "/cli/admin/dictionary/korean/review", { items, dryRun: !confirm }, { requiresRun: false, mutates: confirm, timeoutMs: 120000 });
3067
3080
  }
3068
- throw cliValidationError("Use: lumine admin dictionary korean-status | korean-pending [--cursor <time:id>] [--limit <1-100>] | korean-review <reviewed-definitions.json> [--dry-run]");
3081
+ throw cliValidationError("Use: lumine admin dictionary korean-status | korean-pending [--cursor <time:id>] [--limit <1-100>] | korean-review <reviewed-definitions.json> [--confirm]");
3069
3082
  }
3070
3083
  if (namespace === "word-games") {
3071
3084
  if (action === "status") return readOperation("word-games.status", "/cli/admin/word-games/status", { requiresRun: false });
@@ -3173,7 +3186,7 @@ export function parseAdminOperation(options) {
3173
3186
  // as the website. Check-ins provide evidence; every meetup completion
3174
3187
  // waits for Mikey's decision (2026-10-22).
3175
3188
  const usage =
3176
- "Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | review-completion <crewId> --decision approve|reject --fingerprint <review fingerprint> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch <name> [--note <text>] | branches [list|show|approve|reject|merge] | emails [list|send <id> [--fingerprint <revision>]|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | story ... | announce-checkins [--send] [--as zero|ciel].";
3189
+ "Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | review-completion <crewId> --decision approve|reject --fingerprint <review fingerprint> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch <name> [--note <text>] | branches [list|show|approve|reject|merge] | emails [list|send <id> --fingerprint <revision>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | story ... | announce-checkins [--send] [--as zero|ciel].";
3177
3190
  const note = String(options.note || "").trim();
3178
3191
  if (note.length > 1000) {
3179
3192
  throw cliValidationError("--note must be at most 1000 characters.");
@@ -3279,11 +3292,18 @@ export function parseAdminOperation(options) {
3279
3292
  }
3280
3293
  if (sub === "send" || sub === "discard") {
3281
3294
  const id = parseRequiredInteger(extra, "Email ID", 1);
3282
- const revision = sub === "send" ? String(options.adminFingerprint || "").trim() : "";
3295
+ // A send names the exact draft the admin reviewed, so a draft that
3296
+ // changed after review is never sent unseen.
3297
+ const revision = sub === "send" ? String(options.adminFingerprint || "").trim().toLowerCase() : "";
3298
+ if (sub === "send" && !revision) {
3299
+ throw cliValidationError(
3300
+ "Sending a parent email needs --fingerprint <revision>. Run lumine admin meetup emails list, review the draft, then send it with the fingerprint printed beside it.",
3301
+ );
3302
+ }
3283
3303
  if (revision && !/^[0-9a-f]{64}$/.test(revision)) throw cliValidationError("Use --fingerprint from the reviewed email in meetup emails list.");
3284
3304
  return writeOperation("meetup.emails", "POST", `/cli/admin/meetup-quest/emails/${id}/${sub}`, revision ? { revision } : {}, { requiresRun: false });
3285
3305
  }
3286
- throw cliValidationError("lumine admin meetup emails [list | send <id> | discard <id> | review on|off]");
3306
+ throw cliValidationError("lumine admin meetup emails [list | send <id> --fingerprint <revision> | discard <id> | review on|off]");
3287
3307
  }
3288
3308
  if (action === "friends") {
3289
3309
  const sub = target || "status";
@@ -6902,6 +6922,20 @@ function arenaItemSummary(item) {
6902
6922
  return "";
6903
6923
  }
6904
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
+
6905
6939
  function printArenaEvidence(arena) {
6906
6940
  if (!arena) return;
6907
6941
  if (arena.kind === "scoreReport") {
@@ -6917,6 +6951,7 @@ function printArenaEvidence(arena) {
6917
6951
  console.log(arena.duplicate ? `Duplicate: ${arena.duplicate.reason} of paper #${arena.duplicate.duplicateOf}` : "Duplicate: none found");
6918
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)" : ""}`);
6919
6953
  if (arena.reviewNote) console.log(`Review note: ${arena.reviewNote}`);
6954
+ printPaperPrescreen(arena.prescreen);
6920
6955
  }
6921
6956
  if (arena.check) console.log(`Check: ${arena.check}`);
6922
6957
  }
@@ -7660,6 +7695,10 @@ function printAdminResult({ operation, result }) {
7660
7695
  for (const line of formatJevRoutingReview(data)) console.log(line);
7661
7696
  return;
7662
7697
  }
7698
+ if (operation.name.startsWith("jev-images.")) {
7699
+ for (const line of formatJevImagesResult({ operation, data })) console.log(line);
7700
+ return;
7701
+ }
7663
7702
  if (operation.name === "roles.read") {
7664
7703
  printRoles(data);
7665
7704
  return;
package/lib/commands.js CHANGED
@@ -2545,6 +2545,17 @@ export function parseArgs(args) {
2545
2545
  ? `${raw.branch},${inlineValue}`
2546
2546
  : inlineValue;
2547
2547
  } else if (booleanFlags.has(camelKey)) {
2548
+ // A switch never takes a spaced value, so `--live false` would turn the
2549
+ // switch on and leave "false" behind. Refuse it rather than guess.
2550
+ const nextValue = rest[i + 1];
2551
+ if (
2552
+ nextValue !== undefined &&
2553
+ ["false", "no", "off", "0"].includes(nextValue.trim().toLowerCase())
2554
+ ) {
2555
+ throw new Error(
2556
+ `--${key} is a switch and does not take "${nextValue}" after a space. Leave --${key} out (or write --${key}=false) to turn it off.`,
2557
+ );
2558
+ }
2548
2559
  raw[camelKey] = true;
2549
2560
  } else if (
2550
2561
  optionalValueFlags.has(camelKey) &&
@@ -2683,7 +2694,7 @@ export function parseArgs(args) {
2683
2694
  adminSinceRun: Boolean(raw.sinceRun),
2684
2695
  adminSince: raw.since ? String(raw.since) : "",
2685
2696
  adminUntil: raw.until ? String(raw.until) : "",
2686
- adminIncludeSynthetic: Boolean(raw.includeSynthetic),
2697
+ adminIncludeSynthetic: parseBoolean(raw.includeSynthetic, false),
2687
2698
  adminPath: raw.path ? String(raw.path) : "",
2688
2699
  adminPerf: Boolean(raw.perf),
2689
2700
  adminWake: Boolean(raw.wake),
@@ -2708,6 +2719,12 @@ export function parseArgs(args) {
2708
2719
  adminArenaAdditive: parseBoolean(raw.additive, false),
2709
2720
  adminArenaItem: raw.item ? String(raw.item) : "",
2710
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),
2711
2728
  adminClaimFile: raw.claim ? String(raw.claim) : "",
2712
2729
  adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
2713
2730
  adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
@@ -3319,13 +3336,13 @@ export function printHelp() {
3319
3336
  lumine admin teachers audit [--limit <1-100>] [--cursor <c>] [--all] | revoke <userId> --reason <text> (approved teachers, a page at a time or --all: new flags first, then changed since your review; revoke removes the status cleanly)
3320
3337
  lumine admin teachers branches suggest | networks | confirm <networkId> --branch <name> [--branch <name>] [--note <text>] | unconfirm <networkId> | set <userId|username> --branch <name> [--note <text>] | clear <userId|username> | apply | list [--branch <name>|none] (which branch each teacher works at, confirmed by you through the academy networks they use: suggest finds the networks, confirm names their branch (repeat --branch for a building branches share), apply gives teachers the branch most of their actions there point at and lists the unclear ones; set wins over networks)
3321
3338
  lumine admin teachers review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>, required for revoke] | review --all-flagged --expect <id[:fp],...> --note <text> (record your review of the facts shown; --all-flagged records only the --expect teachers; nothing is recorded if they changed since. A reviewed teacher comes back when its flags or linked accounts change; accounts sharing a network count only when they change a network flag)
3322
- lumine admin dictionary korean-status | korean-pending [--cursor <time:id>] [--limit <1-100>] | korean-review <reviewed-definitions.json> [--dry-run] (shared Korean word-bank definitions; daily review, no paid generation)
3339
+ lumine admin dictionary korean-status | korean-pending [--cursor <time:id>] [--limit <1-100>] | korean-review <reviewed-definitions.json> [--confirm] (shared Korean word-bank definitions; daily review, no paid generation; a preview unless --confirm)
3323
3340
  lumine admin english import <verdicts.json> --pass a|b | adjudicate <file> | add-questions <file> | relevel <file> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> | retag <questionId> --skill <code> (English skill tags and bank upkeep for Grammarbles; content written by Claude Code agents)
3324
3341
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3325
3342
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3326
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)
3327
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)
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)
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)
3329
3346
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3330
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)
3331
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>]
@@ -3383,6 +3400,7 @@ export function printHelp() {
3383
3400
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3384
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)
3385
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)
3386
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)
3387
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]
3388
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.75",
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.74.0
4
- Updated: 2026-10-29
5
- Generated: 2026-10-28T20:08:43.889Z
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.
@@ -32,6 +32,7 @@ Features every app needs whenever they apply. When you build a new app or make a
32
32
  - Reward is part of completion: a reward-paying run must stay pending until awarded:true and its canonical receipt (or getReceipt status awarded). Do not show final solved/completed status, advance rewarded progress, or discard finished proof before that. Preserve the exact challenge/proof and acknowledged progress in a durable per-viewer outbox; reconcile getReceipt after a lost reply and retry the same challenge, never inventing a payment. Explain explicit practice, already collected, exhausted approved limits, preview and disclosed zero-value retry outcomes. Use the start response as a conservative elapsed-time reference, and test delayed start, network failure, lost claim reply, reload, day/viewer change and duplicate recovery.
33
33
  - This SDK is injected into Build iframes via the Build preview/runtime.
34
34
  - Widgets call SDK methods; the parent proxies to the API.
35
+ - When an SDK request never got a reply from Twinkle's server (the device went offline, the network dropped, or the connection was cut first), its rejected error has error.details.isTransportError === true. Its error.status is 500 for older apps, so check this flag before treating a 500 as a server failure. The request may or may not have been applied: retry a write only with the same operationId or requestId, and only after a later user action or decision point, never in a loop. Errors that came back from the server never carry the flag.
35
36
  - Data API methods require scoped tokens handled by the parent; some namespaces include write methods.
36
37
  - Use Twinkle.privateDb for LOW-frequency durable private per-user state such as preferences, drafts, settings, inventory checkpoints, and saved progress. It is NOT for high-frequency or per-frame/per-tick writes; the server rate-limits writes and returns 429.
37
38
  - Match storage to update frequency: privateDb and sharedDb are for LOW-frequency durable state that changes on a user action. NEVER write per-frame/per-tick state to them (camera or cursor position, animation, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and flush only occasional durable snapshots (on an interval or on exit, never per frame). The server enforces per-key write rate limits and returns 429 on excess; never retry-loop a 429.
@@ -553,7 +554,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
553
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.
554
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.
555
556
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
556
- - 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.
557
558
  - thinkingMode low uses GPT-6 Luna with low reasoning and consumes the viewer's AI Energy from confirmed provider usage.
558
559
  - thinkingMode medium uses GPT-6 Luna with medium reasoning and consumes normal AI Energy.
559
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.
@@ -1403,17 +1404,18 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1403
1404
  - For oldest-first chronological reads, pass order: 'asc' or order: 'oldest'. sort and direction are accepted as aliases.
1404
1405
  - Supports cursor-based pagination. Newest-first cursors page toward older entries; oldest-first cursors page toward newer entries.
1405
1406
  - async queryEntries(topicName, { limit, pageSize, cursor, order, authorUserId, fields, where, countBy, includeAuthor } = {}) | scopes: sharedDb:read
1406
- - Returns: { entries?, groups?, cursor, hasMore, queryVersion: 1 }
1407
+ - Returns: { entries?, groups?, cursor, hasMore, queryVersion: 1, truncated? }
1407
1408
  - Read selected shared data fields with server-side creator filtering, scalar matching and grouped counts.
1408
1409
  - Returns the same paginated entries as getEntries, plus queryVersion: 1.
1409
1410
  - authorUserId filters to one entry creator on the server. It does not change read or write permissions.
1410
1411
  - fields selects at most 24 top-level data field names; missing selected fields are null. Omit fields for complete data; [] returns only row metadata.
1411
1412
  - where: { field, equals } filters one top-level data field by exact JSON scalar equality (string, number, boolean or null).
1412
1413
  - Alternatively where: { field, truthy: true } requires a present JSON value other than null, false, zero or an empty string.
1413
- - countBy groups matching entries by one top-level data field and returns { groups: [{ value, count }], cursor, hasMore, queryVersion: 1 } instead of entries. This avoids transferring documents just to count comments. Groups page in first-entry order; use the returned cursor with the same query. Per-viewer hidden entries are excluded before counting.
1414
+ - countBy groups matching entries by one top-level data field and returns { groups: [{ value, count }], cursor, hasMore, queryVersion: 1 } instead of entries. This avoids transferring documents just to count comments. Groups page in first-entry order; use the returned cursor with the same query. Per-viewer hidden entries are excluded before counting. Counts cover at most the newest 4000 entries of the topic (or of authorUserId's entries in it); when the topic is larger the result has truncated: true, so keep a running tally field instead of recounting a huge topic.
1414
1415
  - includeAuthor: false skips username/profile-photo enrichment for count/index reads; id, topicId, userId and timestamps remain available.
1415
1416
  - Field names use letters, digits and underscores, starting with a letter or underscore, at most 64 characters. No SQL or nested paths.
1416
- - Keep the same filters/fields/order when passing a cursor. Reads retain app/topic boundaries, published guest opt-ins and per-viewer content visibility. Signed-in queries read canonical writer state.
1417
+ - Keep the same filters/fields/order when passing a cursor. Reads retain app/topic boundaries, published guest opt-ins and per-viewer content visibility. Signed-in entry queries read canonical writer state; grouped counts may trail a just-saved entry by a moment.
1418
+ - queryEntries calls are limited to 60 a minute per viewer (per IP for guests) and 600 a minute per app; a busy app gets HTTP 429 and should retry later rather than loop.
1417
1419
  - Use metadata lists for browsing and getEntriesByIds only when a member opens a full recording/document.
1418
1420
  - async loadMoreEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
1419
1421
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
@@ -1572,14 +1574,14 @@ world.updatePresence({ x, y, z, facing });
1572
1574
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
1573
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.
1574
1576
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
1575
- - 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.
1576
1578
  - async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
1577
1579
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question, dayType, pictureUrl, pictureStyle }], cursor? }
1578
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.
1579
1581
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
1580
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.
1581
1583
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
1582
- - 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.
1583
1585
 
1584
1586
  ### Twinkle.privateDb
1585
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>
@@ -3176,6 +3176,9 @@ unrelated release authority. Keep release evidence and private diagnostics out
3176
3176
  of the public article. Follow the repository's daily website-code update
3177
3177
  procedure and the existing dated-revision contract for an approved correction.
3178
3178
 
3179
+ **Plain words (Mikey, 2026-10-30):** write the article for 10-15-year-olds with no technical background. Describe what members can now do or what feels better in everyday words; avoid developer terms and internal names (API, SDK, frame rate, memory, cache, deploy, version numbers, counterproposal and the like). The same applies to the newspaper's front-page piece about it. A published update Subject cannot be edited through the CLI, so check the wording before `--confirm`.
3180
+ Model to follow (Mikey, 2026-10-30): the Subject 37274 form in `agent-guides/daily-update-post-template.md`: short headline, one-line greeting from the duty bot, the biggest change first in bold with a link, a short bold-labelled paragraph for each other change, and a closing question.
3181
+
3179
3182
  Use the scoped posting command after composing and reviewing the article:
3180
3183
 
3181
3184
  ```bash
@@ -3997,6 +4000,72 @@ input snapshots as described below.
3997
4000
  See `twinkle-api/JEV_PILOT.md` for configuration,
3998
4001
  the synthetic evaluation step and release checks.
3999
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
+
4000
4069
  ### Spending by calendar month
4001
4070
 
4002
4071
  ```bash
@@ -5568,7 +5637,7 @@ Bridge Builder qualifies from QR check-ins: 7 different people, or one meetup a
5568
5637
  - The private note and the change history are admin-only; nobody is messaged.
5569
5638
  - It refuses members who left and crews that are not active.
5570
5639
  - `lumine admin meetup funnel [--days <1-90>]` shows views, crews by stage, and check-ins: pairs, how many were on one network or at an academy, and refusals by reason.
5571
- - `lumine admin meetup emails` lists parent emails held for review. Kind `witness` is a child asking a parent to be the grown-up.
5640
+ - `lumine admin meetup emails` lists parent emails held for review. Kind `witness` is a child asking a parent to be the grown-up. `lumine admin meetup emails send <id> --fingerprint <revision>` sends one; the fingerprint is the reviewed draft's revision that `emails list` prints, and a send without it is refused. If the draft changed after review, list and review it again. `emails discard <id>` drops one.
5572
5641
  - `lumine admin meetup announce-checkins [--send] [--as zero|ciel]` is about the one-time announcement of the check-in rework in each crew chat. It posts itself when the website with check-ins is out. The messages are pre-written; without `--send` this only shows them. `--send` retries a crew whose post failed. Each crew gets it once.
5573
5642
  - **Held unlocks:** every physical-meetup qualification gets a `meetup` approval item (`lumine admin approvals list --type meetup`). `approvals show <id> --json` includes `meetupEvidence` for all participants. After Mikey decides, use `approvals approve|reject <id> --fingerprint <meetupEvidence.fingerprint>`. Proximity never bypasses review.
5574
5643
  - **Crew completions:** `meetup show <crewId> --json` includes the same owner-only scan packet. After Mikey decides, use `meetup review-completion <crewId> --decision approve|reject --fingerprint <meetupEvidence.fingerprint>`. The decision fails if the shown evidence or proposed attendance changed. New attendees and tier upgrades need a new decision.
@@ -5621,6 +5690,6 @@ Every full management run includes this dedicated review, requested by Mikey Oct
5621
5690
 
5622
5691
  - `lumine admin dictionary korean-status --json`: current generated/reviewed/pending coverage, oldest pending time, outstanding jobs and recent failures.
5623
5692
  - `lumine admin dictionary korean-pending --limit 100 [--cursor <time:id>] --json`: full text, English reference meanings, exact IDs/revisions/source hashes. Continue through `nextCursor` until null. Read and assess every entry; collection alone is not review.
5624
- - `lumine admin dictionary korean-review <reviewed.json> --dry-run --json`, then omit `--dry-run` to apply the same reviewed payload within authorized content-maintenance scope. Accepts an array or `{ "items": [...] }`, 1–100 entries, each `{ "wordId": 123, "word": "example", "expectedRevision": 1, "expectedSourceHash": "<exported SHA-256>", "koreanDefinition": "<checked Korean meaning>", "reason": "<what was checked or corrected>" }`. Keep exact exported identity/version fields; supply a reason even when accepting unchanged text. All entries commit together with a mutation audit. Conflicts require re-export and re-review, never blind retry with new revisions.
5693
+ - `lumine admin dictionary korean-review <reviewed.json> --json` previews (validation only, nothing written); then add `--confirm` to apply the same reviewed payload within authorized content-maintenance scope. Accepts an array or `{ "items": [...] }`, 1–100 entries, each `{ "wordId": 123, "word": "example", "expectedRevision": 1, "expectedSourceHash": "<exported SHA-256>", "koreanDefinition": "<checked Korean meaning>", "reason": "<what was checked or corrected>" }`. Keep exact exported identity/version fields; supply a reason even when accepting unchanged text. All entries commit together with a mutation audit. Conflicts require re-export and re-review, never blind retry with new revisions.
5625
5694
 
5626
5695
  Assess common senses, part of speech and inflections, precision and natural Korean for learners. Use your own language judgment and source checks where needed; no paid batch-generation calls. Corrections update the shared word-bank field for all apps; successful reviews mark it reviewed and advance its revision. Future paid generation never overwrites existing text. The final run report includes reviewed/corrected/remaining counts, oldest waiting age, generation/Battery recovery failures, pagination coverage and carryovers. Re-read status after applying. Missing tables/endpoints mean the release is unavailable, not an empty healthy queue.