@stage5/lumine 0.3.34 → 0.3.36

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.
@@ -23,6 +23,7 @@ export function runAdmin(
23
23
  args,
24
24
  { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync } = {},
25
25
  ) {
26
+ const startedAt = performance.now();
26
27
  const attemptFile = outputFile
27
28
  ? `${outputFile}.attempt-${randomUUID()}.json`
28
29
  : null;
@@ -78,6 +79,7 @@ export function runAdmin(
78
79
  featured: ["list", "history"],
79
80
  sponsor: ["applications"],
80
81
  "bot-output": [null],
82
+ "math-lab": ["queue"],
81
83
  subjects: ["candidates"],
82
84
  subject: ["get"],
83
85
  };
@@ -96,6 +98,7 @@ export function runAdmin(
96
98
  const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl });
97
99
  return {
98
100
  ...retried,
101
+ durationMs: Math.round(performance.now() - startedAt),
99
102
  attempts: 2,
100
103
  recovered: retried.ok,
101
104
  firstError:
@@ -104,6 +107,7 @@ export function runAdmin(
104
107
  }
105
108
  return {
106
109
  ok,
110
+ durationMs: Math.round(performance.now() - startedAt),
107
111
  attempts: 1,
108
112
  status: result.status,
109
113
  json: error ? { ok: false, status: "unconfirmed", error } : parsed,
@@ -159,6 +163,7 @@ export function renderBotOutput({ chatMessages, comments }, file) {
159
163
  }
160
164
 
161
165
  export async function dailyRunGather(options) {
166
+ const startedAt = performance.now();
162
167
  const dir = ensureDir(
163
168
  path.resolve(options.adminOutputDir || options.adminDir || "."),
164
169
  );
@@ -172,6 +177,7 @@ export async function dailyRunGather(options) {
172
177
  file,
173
178
  ok: res.ok,
174
179
  attempts: res.attempts,
180
+ durationMs: res.durationMs,
175
181
  recovered: Boolean(res.recovered),
176
182
  ...(res.ok ? {} : { error: res.json?.error || res.stderr }),
177
183
  });
@@ -180,6 +186,7 @@ export async function dailyRunGather(options) {
180
186
  step("energy-budget", ["energy-budget"], "energy-budget.json");
181
187
  step("news", ["news"], "news.json");
182
188
  step("brief", ["brief"], "brief.json");
189
+ step("math-lab-queue", ["math-lab", "queue"], "math-lab-queue.json");
183
190
  step("ai-costs-monthly", ["ai-costs", "monthly"], "ai-monthly.json");
184
191
  step("ai-costs-day", ["ai-costs", "day", yesterday], "ai-day.json");
185
192
  step("media-costs", ["media-costs", "monthly"], "media.json");
@@ -239,6 +246,8 @@ export async function dailyRunGather(options) {
239
246
  if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
240
247
 
241
248
  const summary = {
249
+ collectedAt: new Date().toISOString(),
250
+ durationMs: Math.round(performance.now() - startedAt),
242
251
  dir,
243
252
  yesterdayUtc: yesterday,
244
253
  botOutput: {
@@ -377,13 +386,19 @@ fetch('daily-management-report.md',{cache:'no-store'}).then(r=>{if(!r.ok)throw n
377
386
  export function waitForReportServer(child, timeoutMs = 10000) {
378
387
  return new Promise((resolve, reject) => {
379
388
  let buf = "";
389
+ let stderr = "";
380
390
  let settled = false;
391
+ const clean = value => String(value || "")
392
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
393
+ .replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(-2048);
394
+ const diagnostic = message => new Error(`${message}${clean(stderr) ? ` ${clean(stderr)}` : ""}`);
381
395
  const finish = (error, url) => {
382
396
  if (settled) return;
383
397
  settled = true;
384
398
  clearTimeout(timer);
385
399
  child.stdout.removeListener("data", onData);
386
- child.removeListener("exit", onExit);
400
+ child.stderr?.removeListener("data", onStderr);
401
+ child.removeListener("close", onClose);
387
402
  child.removeListener("error", onError);
388
403
  if (error) {
389
404
  child.kill();
@@ -399,29 +414,36 @@ export function waitForReportServer(child, timeoutMs = 10000) {
399
414
  if (!buf.includes("\n")) return;
400
415
  try {
401
416
  const parsed = JSON.parse(buf.split("\n")[0]);
417
+ if (parsed.ok === false) {
418
+ const reason = clean(parsed.error?.message || parsed.message || parsed.error);
419
+ return finish(diagnostic(`Report server failed to start${reason ? `: ${reason}` : "."}`));
420
+ }
402
421
  const url = new URL(parsed.data?.url);
403
422
  if (
404
423
  parsed.ok !== true ||
405
424
  parsed.status !== "success" ||
406
425
  url.protocol !== "http:" ||
407
- url.hostname !== "127.0.0.1"
426
+ url.hostname !== "127.0.0.1" || url.username || url.password
408
427
  )
409
428
  throw new Error("invalid receipt");
410
429
  finish(null, url.href);
411
430
  } catch {
412
- finish(new Error("Report server returned an invalid startup receipt."));
431
+ finish(diagnostic("Report server returned an invalid startup receipt."));
413
432
  }
414
433
  };
415
- const onExit = (code) =>
416
- finish(new Error(`Report server exited (${code}) before it was ready.`));
434
+ const onStderr = chunk => { stderr = (stderr + chunk).slice(-8192); };
435
+ // close follows drained stdio; exit can arrive before the error text.
436
+ const onClose = (code, signal) =>
437
+ finish(diagnostic(`Report server exited (${signal || code}) before it was ready.`));
417
438
  const onError = (error) => finish(error);
418
439
  const timer = setTimeout(
419
440
  () =>
420
- finish(new Error("Report server did not start before its deadline.")),
441
+ finish(diagnostic("Report server did not start before its deadline.")),
421
442
  timeoutMs,
422
443
  );
423
444
  child.stdout.on("data", onData);
424
- child.once("exit", onExit);
445
+ child.stderr?.on("data", onStderr);
446
+ child.once("close", onClose);
425
447
  child.once("error", onError);
426
448
  });
427
449
  }
@@ -488,10 +510,13 @@ export async function reportServe(
488
510
  "--json",
489
511
  "--no-update-check",
490
512
  ],
491
- { detached: true, stdio: ["ignore", "pipe", "ignore"] },
513
+ { detached: true, stdio: ["ignore", "pipe", "pipe"] },
492
514
  );
493
515
  const url = await waitForReportServer(child);
494
516
  child.stdout.destroy();
517
+ // Keep draining diagnostics without retaining them or keeping the CLI up.
518
+ child.stderr?.resume();
519
+ child.stderr?.unref?.();
495
520
  child.unref();
496
521
  const opened = options.adminOpen
497
522
  ? openImpl("open", ["-a", "Google Chrome", url])
package/lib/admin.js CHANGED
@@ -3973,6 +3973,9 @@ export function parseAdminOperation(options) {
3973
3973
  },
3974
3974
  );
3975
3975
  }
3976
+ if (namespace === "math-lab" && action === "queue") {
3977
+ return readOperation("math-lab.queue", "/cli/admin/math-lab/queue");
3978
+ }
3976
3979
  if (namespace === "builds" && action === "review") {
3977
3980
  const parsed = parseAdminCommentTarget({ target, explicitType: "build" });
3978
3981
  if (parsed.type !== "build") {
@@ -7121,6 +7124,13 @@ function printRewardBankReport(data) {
7121
7124
  );
7122
7125
  }
7123
7126
  }
7127
+ if (data.numericAnswers) {
7128
+ const n = data.numericAnswers;
7129
+ console.log(`Numeric answers (${n.serverAnswerContract}): ${n.decimalQuestions} decimal questions / ${n.questions} total · ${n.decimalsCreatedInWindow} new decimals`);
7130
+ console.log(` Decimal follow-up: ${n.decimalClaims.served} served · ${n.decimalClaims.answered} answered · first-try ${pct(n.decimalClaims.firstTryRate)} · ${n.decimalClaims.coverage}`);
7131
+ } else {
7132
+ console.log("Numeric answer telemetry: unavailable on this server version.");
7133
+ }
7124
7134
  console.log(`Learners: ${data.learners.total}`);
7125
7135
  for (const row of data.learners.byRuleStep) {
7126
7136
  console.log(
@@ -7330,6 +7340,14 @@ function printAiBucketRuleResult({ operation, data }) {
7330
7340
 
7331
7341
  function printAdminResult({ operation, result }) {
7332
7342
  const data = result?.data || {};
7343
+ if (operation.name === "math-lab.queue") {
7344
+ console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
7345
+ for (const grade of data.grades || []) console.log(grade.available
7346
+ ? `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7347
+ : `${grade.ruleId}: queue unavailable`);
7348
+ if (!data.complete) console.log("Queue coverage is incomplete; unavailable grades are not empty queues.");
7349
+ return;
7350
+ }
7333
7351
  if (printChatSafetyResult({ operation, data })) return;
7334
7352
  const chatReportLines = formatChatReportResult({ operation, data });
7335
7353
  if (chatReportLines) {
package/lib/api.js CHANGED
@@ -372,7 +372,7 @@ export async function saveProjectFiles({
372
372
  // Required when the workspace has no filesHash (old/stale checkout).
373
373
  ...(force ? { force: true } : {}),
374
374
  },
375
- timeoutMs: options.timeoutMs,
375
+ timeoutMs: options.validationTimeoutMs ?? options.timeoutMs,
376
376
  });
377
377
  }
378
378
 
package/lib/commands.js CHANGED
@@ -1316,7 +1316,7 @@ export async function check(options) {
1316
1316
  const result = await requestJson({
1317
1317
  url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
1318
1318
  authToken: auth.token,
1319
- timeoutMs: options.timeoutMs,
1319
+ timeoutMs: options.validationTimeoutMs ?? options.timeoutMs,
1320
1320
  });
1321
1321
  printCheck(result);
1322
1322
  if (!result.ok) process.exitCode = 1;
@@ -1373,7 +1373,7 @@ export async function launch(options) {
1373
1373
  const checkResult = await requestJson({
1374
1374
  url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
1375
1375
  authToken: auth.token,
1376
- timeoutMs: options.timeoutMs,
1376
+ timeoutMs: options.validationTimeoutMs ?? options.timeoutMs,
1377
1377
  });
1378
1378
  printCheck(checkResult);
1379
1379
  const launchOk =
@@ -2479,6 +2479,7 @@ export function parseArgs(args) {
2479
2479
  "foreground",
2480
2480
  "open",
2481
2481
  "dryRun",
2482
+ "preflight",
2482
2483
  "confirmEmailRule",
2483
2484
  "includePrefix",
2484
2485
  "allowSharedNetwork",
@@ -2802,6 +2803,8 @@ export function parseArgs(args) {
2802
2803
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2803
2804
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2804
2805
  adminFile: raw.file ? String(raw.file) : "",
2806
+ rewardPreflight: Boolean(raw.preflight),
2807
+ rewardApprovalFile: raw.approvalFile ? String(raw.approvalFile) : "",
2805
2808
  // admin craft revise --item <id|name> [--why] [--emoji] [--category]
2806
2809
  // (kept raw: a flag given without a text value is refused in
2807
2810
  // admin-craft.js rather than sent as the string "true")
@@ -2936,6 +2939,13 @@ export function parseArgs(args) {
2936
2939
  DEFAULT_TIMEOUT_MS,
2937
2940
  1000,
2938
2941
  ),
2942
+ // Large saved projects can take longer than the ordinary 20s request
2943
+ // deadline. The server bounds compiler work at 120s; leave transport room.
2944
+ // An explicit --timeout-ms / environment setting still wins.
2945
+ validationTimeoutMs: Math.max(
2946
+ Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) || 150000,
2947
+ 1000,
2948
+ ),
2939
2949
  help: !!raw.help || command === "help",
2940
2950
  };
2941
2951
  }
@@ -3255,6 +3265,7 @@ export function printHelp() {
3255
3265
  lumine admin chat-reports export <report-id> | --hold <hold-id> --out <new-dir> [--json] (evidence package + SHA-256 manifest)
3256
3266
  lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
3257
3267
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
3268
+ lumine admin math-lab queue [--json] (published sets remaining per grade, all-time claims, refill flags)
3258
3269
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
3259
3270
  lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
3260
3271
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
package/lib/rewards.js CHANGED
@@ -18,6 +18,15 @@ import { resolveRequiredBuildIdOrSelected } from "./commands.js";
18
18
 
19
19
  const REWARDS_FILE = "rewards.json";
20
20
 
21
+ export function reviewApprovalFromReceipt(receipt, buildId) {
22
+ if (!receipt || receipt.ready !== true || receipt.buildId !== buildId ||
23
+ !Number.isSafeInteger(receipt.sourceVersionId) || receipt.sourceVersionId <= 0 ||
24
+ !["sourceHash", "sheetHash", "configHash"].every(key => /^[a-f0-9]{64}$/.test(receipt[key] || ""))) {
25
+ throw new Error("Use the complete, ready preflight JSON for this Build as --approval-file.");
26
+ }
27
+ return Object.fromEntries(["buildId", "sourceVersionId", "sourceHash", "sheetHash", "configHash"].map(key => [key, receipt[key]]));
28
+ }
29
+
21
30
  async function readWorkspaceRewardsJson(options) {
22
31
  const localProject = await findLocalProjectMetadata(
23
32
  path.resolve(options.dir || process.cwd()),
@@ -208,13 +217,39 @@ export async function rewardsCommand(options) {
208
217
  return;
209
218
  }
210
219
  if (action === "review") {
220
+ if (options.rewardPreflight && options.rewardApprovalFile)
221
+ throw new Error("Choose --preflight or --approval-file, not both.");
222
+ let receipt;
223
+ if (options.rewardApprovalFile) {
224
+ receipt = JSON.parse(await fs.readFile(path.resolve(options.rewardApprovalFile), "utf8"));
225
+ } else {
226
+ receipt = await requestJson({
227
+ url: `${options.apiUrl}/build/${buildId}/rewards/review-preflight`,
228
+ authToken: auth.token, timeoutMs: options.timeoutMs,
229
+ });
230
+ }
231
+ if (options.rewardPreflight) {
232
+ if (options.json) console.log(JSON.stringify(receipt, null, 2));
233
+ else {
234
+ console.log(`Build ${buildId}, saved v${receipt.savedVersion} (artifact ${receipt.sourceVersionId}).`);
235
+ console.log(receipt.publicationBehavior);
236
+ console.log(`Source: ${receipt.sourceHash}\nQuestion sheet: ${receipt.sheetHash}\nReward configuration: ${receipt.configHash}`);
237
+ for (const error of receipt.errors || []) console.log(` - ${error}`);
238
+ console.log("Save this preflight with --json, review the candidate, then pass --approval-file <receipt.json> after approval.");
239
+ }
240
+ if (!receipt.ready) process.exitCode = 1;
241
+ return;
242
+ }
243
+ const expectedApproval = reviewApprovalFromReceipt(receipt, buildId);
244
+ if (receipt.willPublishImmediately && !options.rewardApprovalFile)
245
+ throw new Error("This owner's reward review publishes immediately. Run `lumine rewards review --preflight --json`, approve that exact candidate, then use `--approval-file <receipt.json>`.");
211
246
  await assertAuthScope({ options, auth, scope: "build:write" });
212
247
  const result = await requestJson({
213
248
  url: `${options.apiUrl}/build/${buildId}/rewards/reviews`,
214
249
  method: "POST",
215
250
  authToken: auth.token,
216
251
  timeoutMs: options.timeoutMs,
217
- body: {},
252
+ body: { expectedApproval },
218
253
  });
219
254
  if (options.json) {
220
255
  console.log(JSON.stringify(result, null, 2));
@@ -222,10 +257,12 @@ export async function rewardsCommand(options) {
222
257
  }
223
258
  const reviewId = result?.policy?.latestReviewId;
224
259
  console.log(
225
- `Sent Build ${buildId} for reward review${reviewId ? ` (request #${reviewId})` : ""}. State: ${result?.state || "unknown"}.`,
260
+ `Reward review for Build ${buildId}${reviewId ? ` (request #${reviewId})` : ""}. Server state: ${result?.state || "unknown"}.`,
226
261
  );
227
262
  console.log(
228
- "The saved version and the question sheet on file are frozen for the reviewer. Saving again closes this request; send it again after the save.",
263
+ receipt.willPublishImmediately
264
+ ? "The server attempted automatic approval and publication. Check the returned state and published artifact before reporting it live."
265
+ : "The approved candidate is frozen for the reviewer. Saving again closes this request.",
229
266
  );
230
267
  return;
231
268
  }
@@ -239,6 +276,8 @@ function printRewardsHelp() {
239
276
  lumine rewards sheet <file.json> Upload the private question sheet ({ rules: { <ruleId>: { questions?, sets? } } })
240
277
  lumine rewards sheet --show Summarize the sheet on file (never prints answer keys)
241
278
  lumine rewards review Send the saved version (with the sheet on file) for XP/Coin review, like "Send for review" on the website
279
+ lumine rewards review --preflight --json Read exact version/source/sheet/config hashes and publication behavior; no mutation
280
+ lumine rewards review --approval-file <receipt.json> Request only the exact approved preflight candidate; owner auto-approval publishes immediately
242
281
 
243
282
  rewards.json (project root) declares the economy the reviewer approves:
244
283
  { "userDailyXP", "userDailyCoins", "userDailyClaims"?,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.34",
3
+ "version": "0.3.36",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1029,9 +1029,9 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1029
1029
  - Example: const { level } = await Twinkle.minecraft.getPlayArea({ id: 'twinkle-keep' });
1030
1030
  - async getPlayAreaChunks({ id, chunks: [[cx, cz], ...] }) | scopes: content:read
1031
1031
  - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, ... } | null }] }
1032
- - Up to 9 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
1032
+ - Up to 24 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
1033
1033
  - Same scene format as getSnap; only blocks with a visible face inside the area are included. scene is null for a chunk that is not part of the area.
1034
- - At most 9 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1034
+ - At most 24 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
1035
1035
  - Example: const { scenes } = await Twinkle.minecraft.getPlayAreaChunks({ id: level.id, chunks: [[64, 29], [65, 29]] });
1036
1036
  - async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
1037
1037
  - Returns: { level }
@@ -622,6 +622,10 @@ additions are not the live inventory. At two or fewer remaining, prepare a
622
622
  refill to at least ten, with complete interactive guides, and track it until
623
623
  approved publication. Report one or zero remaining prominently. Details are
624
624
  in the linked guide's **Daily queue monitoring and refill** section.
625
+ Read this inventory with `lumine admin math-lab queue --json`. Its receipt
626
+ binds the published artifact and live reward review, includes all twelve
627
+ grades, and reports incomplete coverage explicitly. Today’s cleared set stays
628
+ current until UTC midnight, but no longer counts as unearned inventory.
625
629
  This is not part of Featured-only or newspaper-only work and is not a new
626
630
  scheduler, delegated API scope, or automatic extension of admin permissions.
627
631
  Use the expressly authorized owner Build workflow for Math Lab; retain the
@@ -878,6 +882,8 @@ type StandalonePost = {
878
882
  updatedAt: null;
879
883
  title: string | null;
880
884
  question: string | null;
885
+ grade?: "Pass" | "Fail" | "Masterpiece" | null; // Daily Reflection only
886
+ masterpieceType?: "heart" | "mind" | "heart_and_mind" | null;
881
887
  content: string | null;
882
888
  explanation: string | null;
883
889
  imagePath: string | null;
@@ -1152,7 +1158,7 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
1152
1158
  ```
1153
1159
 
1154
1160
  These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
1155
- - **`gather`** saves each read-only report into `<dir>`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. It also creates `review-state.json`: every source is fetched, not read. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
1161
+ - **`gather`** saves each read-only report into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. It also creates `review-state.json`: every source is fetched, not read. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
1156
1162
  - **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
1157
1163
  - **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts, explicit comment truncation/completeness, and whether Mikey has viewed it. Counts from truncated context are partial. An unavailable Subject context fails the collection. Eligibility only: ranking stays editorial.
1158
1164
  - **`report serve`** detaches a loopback server that answers only `/`, `marked.min.js` and the report Markdown. `--open` opens Chrome and reports whether the OS accepted the request. Render, navigation and original-source verification remain pending until actually checked. Startup failures stop only the child this command started.
@@ -1479,9 +1485,8 @@ Rule fields (all server-enforced, none inferred from app code):
1479
1485
  - Top-level `userDailyClaims`: receipts one learner may earn per site day
1480
1486
  across all rules. `1` is "one bounty a day".
1481
1487
 
1482
- Math Lab's economy (Mikey, 2026-09-12): twelve level rules, one per grade per
1483
- day, elementary 50,000 XP + 1,000 Coins, middle 70,000 + 5,000, high
1484
- 100,000 + 10,000; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
1488
+ Math Lab's economy (Mikey, equal pay 2026-10-04): twelve level rules, one per grade per
1489
+ day, all paying 70,000 XP + 5,000 Coins; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
1485
1490
  `userDailyClaims` 1; until-earned sets authored from the Korean curriculum.
1486
1491
  Arcade Typing (Mikey, 2026-09-12): XP for clearing campaign stages, up to
1487
1492
  10,000 XP per learner per day, no Coins. Platform ceilings: 100,000 XP /
@@ -1497,6 +1502,18 @@ also closes itself on that save). Rejection and revocation require a
1497
1502
  needs a new request. Never approve without reading the code; never approve a
1498
1503
  request whose `isLatest` is false.
1499
1504
 
1505
+ Mikey-owned apps automatically attempt approval and publication when the owner
1506
+ requests reward review. Before that write, run
1507
+ `lumine rewards review --preflight --json` and retain its complete receipt.
1508
+ It identifies the saved version, artifact ID, source hash, private sheet hash
1509
+ and effective reward configuration hash, and states the publication behavior.
1510
+ After Mikey approves that exact candidate, use
1511
+ `lumine rewards review --approval-file <receipt.json>`. The API checks those
1512
+ values under the build lock before freezing content; a change returns
1513
+ `build_reward_approval_stale`. There is no silent fallback to an unbound request.
1514
+ Saving a new preflight is read-only and is not approval. These commands require
1515
+ the matching API and CLI release.
1516
+
1500
1517
  `propose <id> --dir <edited> --config rules.json [--reason]` sends the edited
1501
1518
  directory (text files only; dotfiles and tool folders skipped) as the
1502
1519
  reviewer's proposal: the review moves to `changes_offered`, the creator's card
@@ -2480,6 +2497,15 @@ Inspection never silently bypasses secret semantics. Until the selected bot is
2480
2497
  the author or has canonically responded/revealed, secret values and comments
2481
2498
  remain unavailable.
2482
2499
 
2500
+ Daily Reflection post reads and recommendation candidates include the stored
2501
+ `grade` and `masterpieceType` (nullable), independently of XP and recommendations.
2502
+ An absent grade is unknown. Verify `grade === "Masterpiece"` before placing one
2503
+ of Mikey's reflections anywhere on the newspaper front page; lower or missing
2504
+ grades can be considered for inside pages. The CLI does not infer a grade.
2505
+ Masterpiece is only an eligibility condition for his reflections: it does not
2506
+ award front-page placement, the lead, or editorial priority. Read the actual
2507
+ content and compare its significance to members with the day's other stories.
2508
+
2483
2509
  ## Subject and Featured mutations
2484
2510
 
2485
2511
  ```bash
@@ -2996,6 +3022,12 @@ entertainment, likely comments and opportunities to join in are not the only
2996
3022
  measures of importance. Explain the values being communicated; do not reduce
2997
3023
  them to founder biography or an administrative announcement.
2998
3024
 
3025
+ **Editorial judgment (Mikey, 2026-10-15):** a Masterpiece grade never automatically
3026
+ earns a front-page slot or the lead. Use the project's canonical `CORE_VALUES.md`
3027
+ to assess the actual story's relevance to the community and its culture, alongside
3028
+ the other eligible stories. Give the substantive reason for the placement;
3029
+ neither the grade nor Mikey's authorship supplies that reason.
3030
+
2999
3031
  Use clear, lively language that respects readers' intelligence. Give enough
3000
3032
  context for someone who missed the original post, and make the interesting
3001
3033
  part clear in the headline and opening. Avoid talking down to readers, forced