@stage5/lumine 0.2.75 → 0.2.78

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.
package/README.md CHANGED
@@ -167,6 +167,10 @@ rewards.getStatus '{}' --build <id>` is read-only and works without
167
167
  `--allow-write` (the endpoint accepts only the `rewards:claim` scope, which is
168
168
  minted for it as a deliberate exception to the read-only rule, but only the
169
169
  status operation is sent).
170
+ `rewards.getReceipt '{"challengeId":"..."}'` is also read-only: it checks
171
+ the exact receipt after an interrupted claim, including earlier days or
172
+ approved versions, without awarding again. It requires the current published
173
+ runtime grant and returns canonical balances with the receipt status.
170
174
  `rewards.start '{"ruleId":"..."}'` and
171
175
  `rewards.claim '{"challengeId":"...","answers":[1,2]}'` mutate real XP/Coins
172
176
  state and require `--allow-write`. Every rewards call first reads the
package/lib/admin.js CHANGED
@@ -31,8 +31,14 @@ import {
31
31
  runManagedBuildReview,
32
32
  } from "./build-review.js";
33
33
  import { runAdminRuntimeLogWorkflow } from "./admin-runtime-logs.js";
34
- import { readApprovedFeaturedPlan, runFeaturedWorkflow } from "./admin-featured.js";
35
- import { FEATURED_HISTORY_BATCH_SIZE, runBatchedFeaturedHistory } from "./admin-featured-history.js";
34
+ import {
35
+ readApprovedFeaturedPlan,
36
+ runFeaturedWorkflow,
37
+ } from "./admin-featured.js";
38
+ import {
39
+ FEATURED_HISTORY_BATCH_SIZE,
40
+ runBatchedFeaturedHistory,
41
+ } from "./admin-featured-history.js";
36
42
 
37
43
  const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
38
44
  const MAX_COMPOSED_TEXT_FILE_BYTES = 64 * 1024;
@@ -166,7 +172,7 @@ export function readRewardConfigFile(filePath) {
166
172
  }
167
173
  if (!Array.isArray(parsed.rules) || parsed.rules.length === 0) {
168
174
  throw cliValidationError(
169
- "Approval needs at least one earning rule in rules[] (id, title, xp, coins, verifier: \"numeric-quiz\", questions[{prompt, answer}] and/or sets[{from, to?, questions}], optional maxAttempts (null = unlimited) and retry {xpPercent, coinsPercent}).",
175
+ 'Approval needs at least one earning rule in rules[] (id, title, xp, coins, verifier: "numeric-quiz", questions[{prompt, answer}] and/or sets[{from, to?, questions}], optional maxAttempts (null = unlimited) and retry {xpPercent, coinsPercent}).',
170
176
  );
171
177
  }
172
178
  return parsed;
@@ -178,18 +184,26 @@ export function readRewardConfigFile(filePath) {
178
184
  export function writeRewardReviewSnapshot({ directory, files }) {
179
185
  const requested = String(directory || "").trim();
180
186
  if (!requested) {
181
- throw cliValidationError("Pass a new or empty directory with --dir <path>.");
187
+ throw cliValidationError(
188
+ "Pass a new or empty directory with --dir <path>.",
189
+ );
182
190
  }
183
191
  const root = path.resolve(requested);
184
192
  if (root === path.resolve("/")) {
185
- throw cliValidationError("Pass a new or empty directory with --dir <path>.");
193
+ throw cliValidationError(
194
+ "Pass a new or empty directory with --dir <path>.",
195
+ );
186
196
  }
187
197
  // The snapshot must land in a directory that holds nothing else, so the
188
198
  // reviewer never reads stale files from another review or clobbers a real
189
199
  // workspace, and so no pre-existing symlink can redirect a write.
190
200
  if (existsSync(root)) {
191
201
  const stat = lstatSync(root);
192
- if (stat.isSymbolicLink() || !stat.isDirectory() || readdirSync(root).length) {
202
+ if (
203
+ stat.isSymbolicLink() ||
204
+ !stat.isDirectory() ||
205
+ readdirSync(root).length
206
+ ) {
193
207
  throw cliValidationError(
194
208
  `--dir ${root} must be a new or empty directory (not a symlink, file, or populated folder).`,
195
209
  );
@@ -382,7 +396,12 @@ export async function adminCommand(options) {
382
396
  viewFilter,
383
397
  });
384
398
  if (operation.featuredWorkflow) {
385
- result = await runFeaturedWorkflow({ options, operation, authToken: auth.token, runId });
399
+ result = await runFeaturedWorkflow({
400
+ options,
401
+ operation,
402
+ authToken: auth.token,
403
+ runId,
404
+ });
386
405
  } else if (operation.name === "build.review") {
387
406
  result = await runManagedBuildReview({
388
407
  options,
@@ -409,9 +428,12 @@ export async function adminCommand(options) {
409
428
  "Use --resume with the scan checkpoint instead of combining --all with --cursor.",
410
429
  );
411
430
  }
412
- const paginate = operation.name === "featured.history" &&
413
- operation.pagination.filters.subjectIds.length > FEATURED_HISTORY_BATCH_SIZE
414
- ? runBatchedFeaturedHistory : runAutomaticPagination;
431
+ const paginate =
432
+ operation.name === "featured.history" &&
433
+ operation.pagination.filters.subjectIds.length >
434
+ FEATURED_HISTORY_BATCH_SIZE
435
+ ? runBatchedFeaturedHistory
436
+ : runAutomaticPagination;
415
437
  result = await paginate({
416
438
  options,
417
439
  operation,
@@ -443,11 +465,16 @@ export async function adminCommand(options) {
443
465
  } catch (error) {
444
466
  if (operation.name === "runtime.evidence" && error.status === 404) {
445
467
  error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
446
- error.message = "The runtime evidence route is not deployed on the requested host. Evidence is unknown; deploy the matching API and activate the collector in an authorized primary-generation release. No restart or host substitution was attempted.";
468
+ error.message =
469
+ "The runtime evidence route is not deployed on the requested host. Evidence is unknown; deploy the matching API and activate the collector in an authorized primary-generation release. No restart or host substitution was attempted.";
447
470
  error.data = {
448
471
  ok: false,
449
472
  status: "unavailable",
450
- error: { code: error.code, message: error.message, details: { httpStatus: 404 } },
473
+ error: {
474
+ code: error.code,
475
+ message: error.message,
476
+ details: { httpStatus: 404 },
477
+ },
451
478
  };
452
479
  }
453
480
  if (operation.featuredWorkflow && error.featuredProgress) {
@@ -456,12 +483,18 @@ export async function adminCommand(options) {
456
483
  ok: false,
457
484
  status: "partial_failure",
458
485
  error: {
459
- code: serverError?.code || error.code || "LUMINE_ADMIN_FEATURED_WORKFLOW_FAILED",
486
+ code:
487
+ serverError?.code ||
488
+ error.code ||
489
+ "LUMINE_ADMIN_FEATURED_WORKFLOW_FAILED",
460
490
  message: error.message,
461
491
  details: {
462
- ...(serverError && typeof serverError === "object" ? serverError.details : {}),
492
+ ...(serverError && typeof serverError === "object"
493
+ ? serverError.details
494
+ : {}),
463
495
  ...error.featuredProgress,
464
- retryInstruction: "Resume the exact command with --resume; confirmed items are not replayed.",
496
+ retryInstruction:
497
+ "Resume the exact command with --resume; confirmed items are not replayed.",
465
498
  },
466
499
  },
467
500
  };
@@ -649,7 +682,11 @@ async function finishAdminOutput({ options, operation, result }) {
649
682
  operation.name !== "news.claim" &&
650
683
  operation.name !== "post.skip-batch"
651
684
  ) {
652
- writeAdminResultOutput({ filePath: options.adminOutput, result, operation });
685
+ writeAdminResultOutput({
686
+ filePath: options.adminOutput,
687
+ result,
688
+ operation,
689
+ });
653
690
  }
654
691
  if (options.json) {
655
692
  if (paginationStorage) {
@@ -1098,8 +1135,16 @@ export function assertAdminOperationAllowedForRunScope({
1098
1135
  }) {
1099
1136
  if (runScope === "newspaper") {
1100
1137
  if (
1101
- ["news.status", "news.claim", "news.submit", "news.print", "daily-run.complete", "daily-run.fail"].includes(operation.name)
1102
- ) return;
1138
+ [
1139
+ "news.status",
1140
+ "news.claim",
1141
+ "news.submit",
1142
+ "news.print",
1143
+ "daily-run.complete",
1144
+ "daily-run.fail",
1145
+ ].includes(operation.name)
1146
+ )
1147
+ return;
1103
1148
  throw cliValidationError(
1104
1149
  `A newspaper-only run does not authorize ${operation.name}.`,
1105
1150
  );
@@ -1137,6 +1182,7 @@ function adminOperationRequiresRun(operation) {
1137
1182
  "escalation.list",
1138
1183
  "escalation.set",
1139
1184
  "notable.add",
1185
+ "notable.remove",
1140
1186
  "notable.status",
1141
1187
  "runtime-logs.start",
1142
1188
  "runtime-logs.status",
@@ -1393,7 +1439,9 @@ export function parseAdminOperation(options) {
1393
1439
  if (action === "show" || action === "get") {
1394
1440
  const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1395
1441
  if (options.dir && !snapshotDir) {
1396
- throw cliValidationError("Pass a new or empty directory with --dir <path>.");
1442
+ throw cliValidationError(
1443
+ "Pass a new or empty directory with --dir <path>.",
1444
+ );
1397
1445
  }
1398
1446
  return readOperation(
1399
1447
  "reward-review.show",
@@ -1420,7 +1468,8 @@ export function parseAdminOperation(options) {
1420
1468
  if (action === "approve") {
1421
1469
  // Without --config the app's own proposal (rewards.json + sheet, frozen
1422
1470
  // in the request) is approved as it stands; --config replaces it.
1423
- if (options.adminConfigFile) body.config = readRewardConfigFile(options.adminConfigFile);
1471
+ if (options.adminConfigFile)
1472
+ body.config = readRewardConfigFile(options.adminConfigFile);
1424
1473
  } else if (options.adminConfigFile) {
1425
1474
  throw cliValidationError(
1426
1475
  "--config is only used with approve; rejections and revocations take --reason.",
@@ -1441,13 +1490,23 @@ export function parseAdminOperation(options) {
1441
1490
 
1442
1491
  if (namespace === "reward-activity" || namespace === "reward-telemetry") {
1443
1492
  // Read-only claim telemetry for the daily run: no run lease, no mutation.
1444
- const days = options.adminDays ? parseRequiredInteger(options.adminDays, "--days", 1) : 7;
1493
+ const date = options.adminDate
1494
+ ? parseUtcDayKey(options.adminDate)
1495
+ : undefined;
1496
+ const days = options.adminDays
1497
+ ? parseRequiredInteger(options.adminDays, "--days", 1)
1498
+ : date
1499
+ ? 1
1500
+ : 7;
1445
1501
  if (days > 31) throw cliValidationError("--days must be at most 31.");
1446
1502
  return readOperation(
1447
1503
  "reward-activity.report",
1448
1504
  withQuery("/cli/admin/reward-activity", {
1449
1505
  days: String(days),
1450
- buildId: options.buildIdFlag ? String(parseRequiredInteger(options.buildIdFlag, "--build", 1)) : "",
1506
+ date,
1507
+ buildId: options.buildIdFlag
1508
+ ? String(parseRequiredInteger(options.buildIdFlag, "--build", 1))
1509
+ : "",
1451
1510
  }),
1452
1511
  { requiresRun: false },
1453
1512
  );
@@ -1623,9 +1682,7 @@ export function parseAdminOperation(options) {
1623
1682
  const runScope = parseDailyRunScope(options.adminScope || "full");
1624
1683
  const commentMode = parseCommentMode(options.commentMode || "off");
1625
1684
  if (runScope !== "full" && commentMode !== "off") {
1626
- throw cliValidationError(
1627
- "A scoped run requires --comment-mode off.",
1628
- );
1685
+ throw cliValidationError("A scoped run requires --comment-mode off.");
1629
1686
  }
1630
1687
  return writeOperation(
1631
1688
  "daily-run.start",
@@ -1899,27 +1956,68 @@ export function parseAdminOperation(options) {
1899
1956
  if (namespace === "featured") {
1900
1957
  if (action === "comments") {
1901
1958
  if (!["scan", "acknowledge", "recommend", "report"].includes(target)) {
1902
- throw cliValidationError("Usage: featured comments scan|acknowledge|recommend|report --checkpoint <file>.");
1959
+ throw cliValidationError(
1960
+ "Usage: featured comments scan|acknowledge|recommend|report --checkpoint <file>.",
1961
+ );
1903
1962
  }
1904
- if (options.adminAll || options.adminCursor || options.adminUnviewed || options.adminViewed || options.adminAfter) {
1905
- throw cliValidationError("The Featured review covers every comment; filtered or caller-supplied cursors are not supported.");
1963
+ if (
1964
+ options.adminAll ||
1965
+ options.adminCursor ||
1966
+ options.adminUnviewed ||
1967
+ options.adminViewed ||
1968
+ options.adminAfter
1969
+ ) {
1970
+ throw cliValidationError(
1971
+ "The Featured review covers every comment; filtered or caller-supplied cursors are not supported.",
1972
+ );
1906
1973
  }
1907
- return { ...writeOperation(`featured.comments.${target}`, "POST", "/cli/admin/subjects/featured/reviews", {}),
1908
- mutates: target !== "report", featuredWorkflow: target };
1974
+ return {
1975
+ ...writeOperation(
1976
+ `featured.comments.${target}`,
1977
+ "POST",
1978
+ "/cli/admin/subjects/featured/reviews",
1979
+ {},
1980
+ ),
1981
+ mutates: target !== "report",
1982
+ featuredWorkflow: target,
1983
+ };
1909
1984
  }
1910
1985
  if (action === "plan") {
1911
- if (!options.adminPostedAfter) throw cliValidationError("A Featured plan requires --posted-after <timestamp>.");
1912
- return writeOperation("featured.plan", "POST", "/cli/admin/subjects/featured/plan", {
1913
- removeIds: parseFeaturedSubjectIds(options.adminRemoveIds, "--remove-subject-ids"),
1914
- addIds: parseFeaturedSubjectIds(options.adminAddIds, "--add-subject-ids"),
1915
- ...(options.adminIds ? { finalIds: parseOrderedIds(options.adminIds) } : {}),
1916
- postedAfter: options.adminPostedAfter,
1917
- });
1986
+ if (!options.adminPostedAfter)
1987
+ throw cliValidationError(
1988
+ "A Featured plan requires --posted-after <timestamp>.",
1989
+ );
1990
+ return writeOperation(
1991
+ "featured.plan",
1992
+ "POST",
1993
+ "/cli/admin/subjects/featured/plan",
1994
+ {
1995
+ removeIds: parseFeaturedSubjectIds(
1996
+ options.adminRemoveIds,
1997
+ "--remove-subject-ids",
1998
+ ),
1999
+ addIds: parseFeaturedSubjectIds(
2000
+ options.adminAddIds,
2001
+ "--add-subject-ids",
2002
+ ),
2003
+ ...(options.adminIds
2004
+ ? { finalIds: parseOrderedIds(options.adminIds) }
2005
+ : {}),
2006
+ postedAfter: options.adminPostedAfter,
2007
+ },
2008
+ );
1918
2009
  }
1919
2010
  if (action === "apply") {
1920
2011
  readApprovedFeaturedPlan(options.adminFile, options.adminApprove);
1921
- return { ...writeOperation("featured.apply", "POST", "/cli/admin/subjects/featured/plan/apply", {}),
1922
- featuredWorkflow: "apply" };
2012
+ return {
2013
+ ...writeOperation(
2014
+ "featured.apply",
2015
+ "POST",
2016
+ "/cli/admin/subjects/featured/plan/apply",
2017
+ {},
2018
+ ),
2019
+ featuredWorkflow: "apply",
2020
+ };
1923
2021
  }
1924
2022
  if (action === "list") {
1925
2023
  return readOperation("featured.list", "/cli/admin/subjects/featured");
@@ -1930,10 +2028,17 @@ export function parseAdminOperation(options) {
1930
2028
  "--subject-ids",
1931
2029
  );
1932
2030
  if (subjectIds.length > 20_000) {
1933
- throw cliValidationError("Featured history accepts at most 20000 subject IDs per CLI scan.");
2031
+ throw cliValidationError(
2032
+ "Featured history accepts at most 20000 subject IDs per CLI scan.",
2033
+ );
1934
2034
  }
1935
- if (subjectIds.length > FEATURED_HISTORY_BATCH_SIZE && !options.adminAll) {
1936
- throw cliValidationError("Pass --all to automatically batch history reads larger than 100 subjects.");
2035
+ if (
2036
+ subjectIds.length > FEATURED_HISTORY_BATCH_SIZE &&
2037
+ !options.adminAll
2038
+ ) {
2039
+ throw cliValidationError(
2040
+ "Pass --all to automatically batch history reads larger than 100 subjects.",
2041
+ );
1937
2042
  }
1938
2043
  return readOperation(
1939
2044
  "featured.history",
@@ -2118,11 +2223,11 @@ export function parseAdminOperation(options) {
2118
2223
  );
2119
2224
  }
2120
2225
 
2121
- if (namespace === "notable" && action === "add") {
2226
+ if (namespace === "notable" && ["add", "remove"].includes(action) && !extra) {
2122
2227
  const rawTarget = String(target || "").trim();
2123
2228
  if (!rawTarget) {
2124
2229
  throw cliValidationError(
2125
- "Usage: lumine admin notable add <userId|username> --note <text>.",
2230
+ `Usage: lumine admin notable ${action} <userId|username> --note <text>.`,
2126
2231
  );
2127
2232
  }
2128
2233
  const body = /^\d+$/.test(rawTarget)
@@ -2133,7 +2238,9 @@ export function parseAdminOperation(options) {
2133
2238
  const note = String(options.note || "").trim();
2134
2239
  if (!note) {
2135
2240
  throw cliValidationError(
2136
- "Pass what made this user notable with --note <text>.",
2241
+ action === "remove"
2242
+ ? "Explain why this user should be removed with --note <text>."
2243
+ : "Pass what made this user notable with --note <text>.",
2137
2244
  );
2138
2245
  }
2139
2246
  if (note.length > MAX_NOTABLE_NOTE_LENGTH) {
@@ -2143,8 +2250,8 @@ export function parseAdminOperation(options) {
2143
2250
  }
2144
2251
  body.note = note;
2145
2252
  return writeOperation(
2146
- "notable.add",
2147
- "POST",
2253
+ `notable.${action}`,
2254
+ action === "remove" ? "DELETE" : "POST",
2148
2255
  "/cli/admin/notable-users",
2149
2256
  body,
2150
2257
  );
@@ -2218,8 +2325,14 @@ export function parseAdminOperation(options) {
2218
2325
  }
2219
2326
 
2220
2327
  if (namespace === "runtime") {
2221
- if (action !== "evidence" || !["primary", "target"].includes(target) || extra) {
2222
- throw cliValidationError("Usage: lumine admin runtime evidence primary|target [--days 1..7].");
2328
+ if (
2329
+ action !== "evidence" ||
2330
+ !["primary", "target"].includes(target) ||
2331
+ extra
2332
+ ) {
2333
+ throw cliValidationError(
2334
+ "Usage: lumine admin runtime evidence primary|target [--days 1..7].",
2335
+ );
2223
2336
  }
2224
2337
  return readOperation(
2225
2338
  "runtime.evidence",
@@ -2233,7 +2346,8 @@ export function parseAdminOperation(options) {
2233
2346
  if (namespace === "runtime-logs") {
2234
2347
  if (
2235
2348
  (action === "start" || action === "status" || action === "read") &&
2236
- (!target || (action === "start" && ["primary", "target"].includes(target))) &&
2349
+ (!target ||
2350
+ (action === "start" && ["primary", "target"].includes(target))) &&
2237
2351
  !extra
2238
2352
  ) {
2239
2353
  if (
@@ -2679,7 +2793,9 @@ function featuredRotateOperation(options) {
2679
2793
  {
2680
2794
  removeIds,
2681
2795
  addIds,
2682
- ...(options.adminPostedAfter ? { postedAfter: options.adminPostedAfter } : {}),
2796
+ ...(options.adminPostedAfter
2797
+ ? { postedAfter: options.adminPostedAfter }
2798
+ : {}),
2683
2799
  },
2684
2800
  );
2685
2801
  }
@@ -3398,7 +3514,9 @@ function printAdminEnergyBudget(energyBudget) {
3398
3514
  `AI Energy budget health, last completed UTC day ${headline.dayKey}: ${formatAdminUsd(headline.chargedUsd)} charged, ${formatAdminUsd(headline.overflowUsd)} overflow, ${headline.users} user(s), ${headline.recharges} recharge(s), ${headline.runs.total.runs} run(s), ${headline.telemetry.metrics.busy_refusal.count} busy refusal(s).`,
3399
3515
  );
3400
3516
  } else {
3401
- console.log("AI Energy budget health: no completed UTC day in the requested window.");
3517
+ console.log(
3518
+ "AI Energy budget health: no completed UTC day in the requested window.",
3519
+ );
3402
3520
  }
3403
3521
  console.log(
3404
3522
  "day | charged | overflow | users | rechg | runs | calls/run avg/p90 | $/run avg/p90 | stops chg/unchg | busy | telemetry rows",
@@ -3416,7 +3534,9 @@ function printAdminEnergyBudget(energyBudget) {
3416
3534
  console.log("Flags: none.");
3417
3535
  return;
3418
3536
  }
3419
- console.log(`Flags (${flags.length}) — record each as a carry-over todo and escalate; never auto-enforce:`);
3537
+ console.log(
3538
+ `Flags (${flags.length}) — record each as a carry-over todo and escalate; never auto-enforce:`,
3539
+ );
3420
3540
  for (const flag of flags) {
3421
3541
  console.log(` ${flag.dayKey} ${flag.code}: ${flag.message}`);
3422
3542
  }
@@ -3616,25 +3736,41 @@ function formatRewardReviewLine(review) {
3616
3736
  }
3617
3737
 
3618
3738
  // One reviewer-facing line per earning rule: amounts, what a later try pays,
3619
- // how many tries, and which Korean days the dated sets cover.
3739
+ // how many tries, and which site days (UTC) the dated sets cover.
3620
3740
  export function formatRewardRuleLine(rule) {
3621
- const parts = [`rule ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins`];
3741
+ const parts = [
3742
+ `rule ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins`,
3743
+ ];
3622
3744
  if (rule.verifier === "completion") {
3623
- parts.push(`completion · pays when the app reports it finished at least ${rule.minSeconds || 0}s after start · once per learner per day`);
3745
+ parts.push(
3746
+ `completion · pays when the app reports it finished at least ${rule.minSeconds || 0}s after start · once per learner per day`,
3747
+ );
3624
3748
  return parts.join(" · ");
3625
3749
  }
3626
3750
  if (rule.retry) {
3627
- parts.push(`retry pays ${Math.floor((rule.xp * rule.retry.xpPercent) / 100)} XP + ${Math.floor((rule.coins * rule.retry.coinsPercent) / 100)} Coins`);
3751
+ parts.push(
3752
+ `retry pays ${Math.floor((rule.xp * rule.retry.xpPercent) / 100)} XP + ${Math.floor((rule.coins * rule.retry.coinsPercent) / 100)} Coins${rule.retry.paidAttempts ? ` through try ${rule.retry.paidAttempts} (later solves pay nothing)` : ""}`,
3753
+ );
3628
3754
  }
3629
- parts.push(rule.maxAttempts === null ? "unlimited tries" : `${rule.maxAttempts ?? 3} tries`);
3755
+ parts.push(
3756
+ rule.maxAttempts === null
3757
+ ? "unlimited tries"
3758
+ : `${rule.maxAttempts ?? 3} tries`,
3759
+ );
3630
3760
  const standing = Array.isArray(rule.questions) ? rule.questions.length : 0;
3631
3761
  const sets = Array.isArray(rule.sets) ? rule.sets : [];
3632
3762
  if (sets.length && rule.progression === "until-earned") {
3633
3763
  const keys = sets.map((set, index) => set.key || `set-${index + 1}`);
3634
- parts.push(`${sets.length} until-earned set(s) in order: ${keys.join(", ")}${standing ? ` · ${standing} standing question(s)` : ""}`);
3764
+ parts.push(
3765
+ `${sets.length} until-earned set(s) in order: ${keys.join(", ")}${standing ? ` · ${standing} standing question(s)` : ""}`,
3766
+ );
3635
3767
  } else if (sets.length) {
3636
- const days = sets.map((set) => (set.to && set.to !== set.from ? `${set.from}..${set.to}` : set.from));
3637
- parts.push(`${sets.length} dated set(s): ${days.join(", ")}${standing ? ` · ${standing} standing question(s)` : " · no standing questions"}`);
3768
+ const days = sets.map((set) =>
3769
+ set.to && set.to !== set.from ? `${set.from}..${set.to}` : set.from,
3770
+ );
3771
+ parts.push(
3772
+ `${sets.length} dated set(s): ${days.join(", ")}${standing ? ` · ${standing} standing question(s)` : " · no standing questions"}`,
3773
+ );
3638
3774
  } else {
3639
3775
  parts.push(`${standing} question(s)`);
3640
3776
  }
@@ -3644,32 +3780,53 @@ export function formatRewardRuleLine(rule) {
3644
3780
  function printRewardReviewResult({ operation, data }) {
3645
3781
  if (operation.name === "reward-review.list") {
3646
3782
  const reviews = Array.isArray(data.reviews) ? data.reviews : [];
3647
- console.log(`${reviews.length} reward review(s) (${data.filter || "pending"}):`);
3648
- for (const review of reviews) console.log(` ${formatRewardReviewLine(review)}`);
3783
+ console.log(
3784
+ `${reviews.length} reward review(s) (${data.filter || "pending"}):`,
3785
+ );
3786
+ for (const review of reviews)
3787
+ console.log(` ${formatRewardReviewLine(review)}`);
3649
3788
  if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
3650
3789
  return;
3651
3790
  }
3652
3791
  const review = data.review || {};
3653
3792
  console.log(formatRewardReviewLine(review));
3654
3793
  if (operation.name === "reward-review.decide") {
3655
- console.log(`Decision recorded: ${operation.decision}. ${review.reason ? `Reason: ${review.reason}` : ""}`.trim());
3794
+ console.log(
3795
+ `Decision recorded: ${operation.decision}. ${review.reason ? `Reason: ${review.reason}` : ""}`.trim(),
3796
+ );
3656
3797
  }
3657
3798
  if (Array.isArray(review.detectedRuleIds)) {
3658
- console.log(`Rule IDs found in source (heuristic): ${review.detectedRuleIds.join(", ") || "none"}`);
3799
+ console.log(
3800
+ `Rule IDs found in source (heuristic): ${review.detectedRuleIds.join(", ") || "none"}`,
3801
+ );
3659
3802
  }
3660
- if (review.isLatest === false) console.log("WARNING: a newer request exists for this app; decide on the latest one.");
3661
- if (review.isLive) console.log("This review is the live approval currently paying out.");
3803
+ if (review.isLatest === false)
3804
+ console.log(
3805
+ "WARNING: a newer request exists for this app; decide on the latest one.",
3806
+ );
3807
+ if (review.isLive)
3808
+ console.log("This review is the live approval currently paying out.");
3662
3809
  if (review.awarded) {
3663
- console.log(`Paid by this review: ${review.awarded.awards} awards to ${review.awarded.earners} people · ${review.awarded.xp} XP · ${review.awarded.coins} Coins (app lifetime ${review.appLifetime?.xp ?? 0} XP / ${review.appLifetime?.coins ?? 0} Coins)`);
3810
+ console.log(
3811
+ `Paid by this review: ${review.awarded.awards} awards to ${review.awarded.earners} people · ${review.awarded.xp} XP · ${review.awarded.coins} Coins (app lifetime ${review.appLifetime?.xp ?? 0} XP / ${review.appLifetime?.coins ?? 0} Coins)`,
3812
+ );
3664
3813
  }
3665
3814
  const config = review.config || {};
3666
3815
  if (Array.isArray(config.rules)) {
3667
- console.log(`Budgets: day ${config.dailyXP} XP/${config.dailyCoins} Coins · per user/day ${config.userDailyXP} XP/${config.userDailyCoins} Coins${config.userDailyClaims ? ` · ${config.userDailyClaims} claim(s)` : ""} · lifetime ${config.lifetimeXP} XP/${config.lifetimeCoins} Coins`);
3668
- for (const rule of config.rules) console.log(` ${formatRewardRuleLine(rule)}`);
3816
+ console.log(
3817
+ `Budgets: day ${config.dailyXP} XP/${config.dailyCoins} Coins · per user/day ${config.userDailyXP} XP/${config.userDailyCoins} Coins${config.userDailyClaims ? ` · ${config.userDailyClaims} claim(s)` : ""} · lifetime ${config.lifetimeXP} XP/${config.lifetimeCoins} Coins`,
3818
+ );
3819
+ for (const rule of config.rules)
3820
+ console.log(` ${formatRewardRuleLine(rule)}`);
3669
3821
  }
3670
3822
  if (Array.isArray(review.files)) {
3671
- console.log(`Source: ${review.files.length} file(s)${data.snapshotDirectory ? ` written to ${data.snapshotDirectory}` : " (pass --dir <path> to write the snapshot)"}`);
3672
- for (const file of review.files) console.log(` ${file.path} (${file.bytes ?? Buffer.byteLength(String(file.content || ""), "utf8")} bytes)`);
3823
+ console.log(
3824
+ `Source: ${review.files.length} file(s)${data.snapshotDirectory ? ` written to ${data.snapshotDirectory}` : " (pass --dir <path> to write the snapshot)"}`,
3825
+ );
3826
+ for (const file of review.files)
3827
+ console.log(
3828
+ ` ${file.path} (${file.bytes ?? Buffer.byteLength(String(file.content || ""), "utf8")} bytes)`,
3829
+ );
3673
3830
  }
3674
3831
  console.log("Use --json for the full record.");
3675
3832
  }
@@ -3677,22 +3834,42 @@ function printRewardReviewResult({ operation, data }) {
3677
3834
  function printRewardActivity(data) {
3678
3835
  const apps = Array.isArray(data.apps) ? data.apps : [];
3679
3836
  const suspects = Array.isArray(data.suspects) ? data.suspects : [];
3680
- console.log(`Reward activity ${data.from} → ${data.to} (${data.days} day(s)): ${apps.length} app(s) paid, ${suspects.length} flagged player-day(s).`);
3837
+ console.log(
3838
+ `Reward activity ${data.from} → ${data.to} (${data.days} day(s)): ${apps.length} app(s) paid, ${suspects.length} flagged player-day(s).`,
3839
+ );
3681
3840
  for (const app of apps) {
3682
- console.log(` app ${app.buildId} ${app.title}: ${app.claims} claim(s) · ${app.earners} earner(s) · ${app.xp} XP · ${app.coins} Coins · ${app.flagged} flagged`);
3841
+ console.log(
3842
+ ` app ${app.buildId} ${app.title}: ${app.claims} claim(s) · ${app.earners} earner(s) · ${app.xp} XP · ${app.coins} Coins · ${app.flagged} flagged`,
3843
+ );
3683
3844
  }
3684
3845
  if (!suspects.length) {
3685
- console.log("Nothing unusual: no claim on the minimum time, no bursts, no sweeps, no repeated cap days, no guessing.");
3846
+ console.log(
3847
+ "Nothing unusual: no claim on the minimum time, no bursts, no sweeps, no repeated cap days, no guessing.",
3848
+ );
3686
3849
  return;
3687
3850
  }
3688
3851
  console.log("Flagged (worst first):");
3689
3852
  for (const s of suspects) {
3690
- const parts = [`${s.flags.join("+")}`, `user ${s.userId}${s.username ? ` ${s.username}` : ""}`, `app ${s.buildId} ${s.title}`, s.dayKey, `${s.claims} claim(s) · ${s.xp} XP`];
3691
- if (s.fastClaims) parts.push(`${s.fastClaims} on the minimum (fastest ${s.minElapsedSeconds}s)`);
3692
- if (s.guessing) parts.push(`${s.guessing} challenge(s) with ${data.limits?.guessingAttempts ?? 15}+ wrong answers`);
3853
+ const parts = [
3854
+ `${s.flags.join("+")}`,
3855
+ `user ${s.userId}${s.username ? ` ${s.username}` : ""}`,
3856
+ `app ${s.buildId} ${s.title}`,
3857
+ s.dayKey,
3858
+ `${s.claims} claim(s) · ${s.xp} XP`,
3859
+ ];
3860
+ if (s.fastClaims)
3861
+ parts.push(
3862
+ `${s.fastClaims} on the minimum (fastest ${s.minElapsedSeconds}s)`,
3863
+ );
3864
+ if (s.guessing)
3865
+ parts.push(
3866
+ `${s.guessing} challenge(s) with ${data.limits?.guessingAttempts ?? 15}+ wrong answers`,
3867
+ );
3693
3868
  console.log(` ${parts.join(" · ")}`);
3694
3869
  }
3695
- console.log("Flags: fast = claimed within 5 s of the rule's minimum; burst = 3+ claims in 10 min; sweep = 75%+ of an app's completion rules within 30 min; cap = at the per-learner day cap; daily-max = at the cap on 3+ days; guessing = 15+ wrong answers on one quiz challenge. None is proof: read the player before acting.");
3870
+ console.log(
3871
+ "Flags: fast = claimed within 5 s of the rule's minimum; burst = 3+ claims in 10 min; sweep = 75%+ of an app's completion rules within 30 min; cap = at the per-learner day cap; daily-max = at the cap on 3+ days; guessing = 15+ wrong answers on one quiz challenge. None is proof: read the player before acting.",
3872
+ );
3696
3873
  }
3697
3874
 
3698
3875
  function printAdminResult({ operation, result }) {
@@ -3707,20 +3884,34 @@ function printAdminResult({ operation, result }) {
3707
3884
  }
3708
3885
  if (operation.name === "runtime.evidence") {
3709
3886
  const evidence = data.evidence || {};
3710
- console.log(`Runtime evidence (${data.host?.requested || "unknown"}): ${evidence.status || "unknown"}.`);
3711
- console.log(`Samples: ${evidence.coverage?.samples ?? "unknown"}; latest: ${evidence.coverage?.lastAtMs ?? "unknown"}; age ms: ${evidence.coverage?.ageMs ?? "unknown"}.`);
3887
+ console.log(
3888
+ `Runtime evidence (${data.host?.requested || "unknown"}): ${evidence.status || "unknown"}.`,
3889
+ );
3890
+ console.log(
3891
+ `Samples: ${evidence.coverage?.samples ?? "unknown"}; latest: ${evidence.coverage?.lastAtMs ?? "unknown"}; age ms: ${evidence.coverage?.ageMs ?? "unknown"}.`,
3892
+ );
3712
3893
  for (const recycle of evidence.recycles || []) {
3713
- console.log(` ${recycle.id}: ${recycle.outcome}; under load: ${recycle.observedUnderLoad ?? "unknown"}.`);
3894
+ console.log(
3895
+ ` ${recycle.id}: ${recycle.outcome}; under load: ${recycle.observedUnderLoad ?? "unknown"}.`,
3896
+ );
3714
3897
  }
3715
- console.log("Missing evidence is unknown, not healthy. A recovered topology does not verify interrupted user work. Use --json for full evidence.");
3898
+ console.log(
3899
+ "Missing evidence is unknown, not healthy. A recovered topology does not verify interrupted user work. Use --json for full evidence.",
3900
+ );
3716
3901
  return;
3717
3902
  }
3718
3903
  if (operation.name === "featured.plan") {
3719
3904
  for (const pair of data.plan?.replacements || []) {
3720
- console.log(`${pair.remove.id} ${pair.remove.title} -> ${pair.add.id} ${pair.add.title}`);
3905
+ console.log(
3906
+ `${pair.remove.id} ${pair.remove.title} -> ${pair.add.id} ${pair.add.title}`,
3907
+ );
3721
3908
  }
3722
- console.log(`Proposed final order: ${(data.plan?.finalIds || []).join(",")}`);
3723
- console.log(`Plan hash: ${data.planHash}. Obtain Mikey's approval before using featured apply --file <plan.json> --approve ${data.planHash}.`);
3909
+ console.log(
3910
+ `Proposed final order: ${(data.plan?.finalIds || []).join(",")}`,
3911
+ );
3912
+ console.log(
3913
+ `Plan hash: ${data.planHash}. Obtain Mikey's approval before using featured apply --file <plan.json> --approve ${data.planHash}.`,
3914
+ );
3724
3915
  return;
3725
3916
  }
3726
3917
  if (operation.featuredWorkflow) {
package/lib/commands.js CHANGED
@@ -1287,7 +1287,18 @@ export async function launch(options) {
1287
1287
 
1288
1288
  export function printCheck(result) {
1289
1289
  const checks = result.checks || {};
1290
- console.log(`Launch check: ${result.ok ? "ok" : "fail"}`);
1290
+ console.log(`Saved project validation: ${result.ok ? "ok" : "fail"}`);
1291
+ console.log(
1292
+ `Publishing readiness: ${result.launchOk === true ? "ready" : "blocked"}`,
1293
+ );
1294
+ if (checks.canonicalBuild?.ok === false) {
1295
+ console.log(
1296
+ "This is a contribution branch. Validate local changes, save, then suggest the branch. The main app owner merges and publishes it.",
1297
+ );
1298
+ }
1299
+ console.log(
1300
+ "Saved checks use the server version; local workspace changes must be saved before these results describe them.",
1301
+ );
1291
1302
  if (checks.canonicalBuild) {
1292
1303
  console.log(
1293
1304
  `- canonical build: ${checks.canonicalBuild.ok ? "ok" : "fail"}`,
@@ -1306,6 +1317,13 @@ export function printCheck(result) {
1306
1317
  console.log(` ${checks.publishPermission.reason}`);
1307
1318
  }
1308
1319
  }
1320
+ if (checks.rewardApproval) {
1321
+ console.log(
1322
+ `- XP/Coins approval: ${checks.rewardApproval.ok ? "ok" : "blocked"}`,
1323
+ );
1324
+ if (checks.rewardApproval.reason)
1325
+ console.log(` ${checks.rewardApproval.reason}`);
1326
+ }
1309
1327
  if (typeof result.launchOk === "boolean") {
1310
1328
  console.log(`- launch gate: ${result.launchOk ? "ok" : "fail"}`);
1311
1329
  }
@@ -2882,7 +2900,7 @@ export function printHelp() {
2882
2900
  lumine admin sponsor integrity get <case-id> [--json]
2883
2901
  lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
2884
2902
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
2885
- lumine admin reward-activity [--days <1..31>] [--build <id>] [--json]
2903
+ lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
2886
2904
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
2887
2905
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json]
2888
2906
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
@@ -2936,6 +2954,7 @@ export function printHelp() {
2936
2954
  lumine admin news submit --claim <claim.json> --file <editorial.json> [--model <name>] [--json]
2937
2955
  lumine admin notable status <user-id|username> [--json]
2938
2956
  lumine admin notable add <user-id|username> --note <text> [--json]
2957
+ lumine admin notable remove <user-id|username> --note <text> [--json]
2939
2958
  lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
2940
2959
 
2941
2960
  Examples:
package/lib/constants.js CHANGED
@@ -18,13 +18,19 @@ export const THUMBNAIL_CAPTURE_TIMEOUT_MS = 90 * 1000;
18
18
  export const GENERATE_MODEL_ALIASES = {
19
19
  "gpt-image-2.5-flare": "gpt-image-2.5-flare",
20
20
  "gpt-image-2.5-sunburst": "gpt-image-2.5-sunburst",
21
- "flare": "gpt-image-2.5-flare",
22
- "sunburst": "gpt-image-2.5-sunburst",
21
+ flare: "gpt-image-2.5-flare",
22
+ sunburst: "gpt-image-2.5-sunburst",
23
23
  "gpt-image-2": "gpt-image-2",
24
24
  "nano-banana": "gemini-3-pro-image-preview",
25
25
  "gemini-3-pro-image-preview": "gemini-3-pro-image-preview",
26
26
  };
27
- export const GENERATE_QUALITIES = new Set(["low", "medium", "high", "xhigh", "max"]);
27
+ export const GENERATE_QUALITIES = new Set([
28
+ "low",
29
+ "medium",
30
+ "high",
31
+ "xhigh",
32
+ "max",
33
+ ]);
28
34
  // Server accepts only these thumbnail content types (8MB max).
29
35
  export const THUMBNAIL_CONTENT_TYPE_BY_EXTENSION = {
30
36
  ".jpg": "image/jpeg",
@@ -119,6 +125,8 @@ export const BUNDLED_SDK_REFERENCE_URL = new URL(
119
125
  import.meta.url,
120
126
  );
121
127
  export const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
128
+ export const LUMINE_MOBILE_SELECTION_GUIDANCE = `- Mobile long-press must not select game UI or open the browser's Copy/Look Up/image menu. The SDK protects standard buttons, button-like ARIA controls, and canvases. Mark the entire gameplay wrapper data-twinkle-no-select (including HUD, labels, scores, menus, controls, and empty play space); also apply user-select: none, -webkit-user-select: none, and -webkit-touch-callout: none for local previews. Protecting only one button or the canvas is insufficient.
129
+ - Keep inputs/contenteditable usable. Mark genuinely copyable story/chat/user-written text data-twinkle-selectable and style it with user-select: text, -webkit-user-select: text, and -webkit-touch-callout: default. Do not blanket-disable selection on document/readers or preventDefault on document-wide touch/pointer events. Test holding controls, the HUD, and empty play space in mobile Safari and Chromium, then verify release/cancel, scrolling, typing, and copying. lumine check only detects a missing no-selection rule; a pass does not prove selector coverage or mobile behavior.`;
122
130
  export const LUMINE_WORLD_UPDATE_GUIDANCE = `- For Twinkle.world realtime presence, keep render/input loops local. Queue an update only when relevant state changes, replace any queued snapshot with the newest one, and flush on a fixed 5-15 updates-per-second schedule with at most one updatePresence request in flight. Never call or await updatePresence every animation frame, resend unchanged snapshots, overlap requests, or build a backlog.
123
131
  - Send Twinkle.world actions only when the discrete action happens; do not poll or automatically retry them. On WORLD_EVENT_RATE_LIMITED or another recoverable non-session-ended error, drop that attempted presence/action update without an immediate retry and keep the session. Reconnect with backoff only after session.ended or Twinkle.world.isSessionEndedError(error).`;
124
132
  export const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
@@ -137,6 +145,7 @@ Use these current source-of-truth rules:
137
145
  - Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
138
146
  - Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }. Live web search is enabled by default; pass webSearch: false to disable it for the app.
139
147
  - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
148
+ ${LUMINE_MOBILE_SELECTION_GUIDANCE}
140
149
  ${LUMINE_WORLD_UPDATE_GUIDANCE}
141
150
  - Prefer existing documented Twinkle.* methods over guessing names from old code.
142
151
  `;
@@ -307,7 +316,7 @@ lumine save --summary "Describe the change"
307
316
 
308
317
  - Use local project files with relative or root-local imports only. Do not add package imports, CDN scripts, external network calls, or app-local /api/* routes.
309
318
  - Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
310
- - Interface text must not be selectable on touch devices: long-pressing UI on mobile must not highlight it. Apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls). Keep text inputs and genuinely user-copyable content (story text, chat messages, user-written text) selectable. lumine check flags projects whose reachable files have clickable UI but no user-select: none rule.
319
+ ${LUMINE_MOBILE_SELECTION_GUIDANCE}
311
320
  - CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body (even a decorative background canvas) or game-y words in visible text switch the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. Document-style apps that use a canvas must call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot — any of those opts out of auto game mode — then pad by layout.safeInsets and scroll within layout.viewport.height.
312
321
  - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
313
322
  ${LUMINE_THREE_VENDOR_GUIDANCE}
package/lib/rewards.js CHANGED
@@ -34,11 +34,19 @@ async function readWorkspaceRewardsJson(options) {
34
34
  try {
35
35
  return { present: true, value: JSON.parse(raw), filePath };
36
36
  } catch (error) {
37
- throw new Error(`${REWARDS_FILE} is not valid JSON: ${error?.message || error}`);
37
+ throw new Error(
38
+ `${REWARDS_FILE} is not valid JSON: ${error?.message || error}`,
39
+ );
38
40
  }
39
41
  }
40
42
 
41
- async function checkDeclaration({ options, auth, buildId, rewardsJson, sheet }) {
43
+ async function checkDeclaration({
44
+ options,
45
+ auth,
46
+ buildId,
47
+ rewardsJson,
48
+ sheet,
49
+ }) {
42
50
  return await requestJson({
43
51
  url: `${options.apiUrl}/cli/build/${buildId}/rewards/check`,
44
52
  method: "POST",
@@ -62,12 +70,16 @@ function printDeclaration(result, { prefix = "" } = {}) {
62
70
  rule.verifier === "completion"
63
71
  ? `completion · at least ${rule.minSeconds || 0}s`
64
72
  : `quiz · ${rule.questionSets} set(s)${rule.progression ? ` · ${rule.progression}` : ""}${rule.standingQuestions ? ` · ${rule.standingQuestions} standing` : ""}`;
65
- console.log(`${prefix} ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins · ${what}`);
73
+ console.log(
74
+ `${prefix} ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins · ${what}`,
75
+ );
66
76
  }
77
+ if (result.nextStep) console.log(`${prefix}${result.nextStep}`);
67
78
  return;
68
79
  }
69
80
  console.log(`${prefix}Rewards declaration: NOT ready.`);
70
- for (const error of result?.errors || []) console.log(`${prefix} - ${error}`);
81
+ for (const error of result?.errors || [])
82
+ console.log(`${prefix} - ${error}`);
71
83
  }
72
84
 
73
85
  // Part of `lumine check`: only speaks up when the workspace declares rewards
@@ -92,8 +104,15 @@ export async function reportRewardDeclaration({ options, auth, buildId }) {
92
104
  printDeclaration(result, { prefix: "Local check: " });
93
105
  if (!result.ok) process.exitCode = 1;
94
106
  } catch (error) {
95
- const reason = String(error?.message || error).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 140);
96
- console.error(`Local check warning: rewards declaration not verified (${reason}).`);
107
+ const reason = String(error?.message || error)
108
+ .replace(/<[^>]+>/g, " ")
109
+ .replace(/\s+/g, " ")
110
+ .trim()
111
+ .slice(0, 140);
112
+ console.error(
113
+ `Local check error: rewards declaration not verified (${reason}).`,
114
+ );
115
+ process.exitCode = 1;
97
116
  }
98
117
  }
99
118
 
@@ -114,7 +133,13 @@ export async function rewardsCommand(options) {
114
133
  rewardsJson: local.present ? local.value : undefined,
115
134
  });
116
135
  if (options.json) {
117
- console.log(JSON.stringify({ ...result, source: local.present ? "workspace" : "saved" }, null, 2));
136
+ console.log(
137
+ JSON.stringify(
138
+ { ...result, source: local.present ? "workspace" : "saved" },
139
+ null,
140
+ 2,
141
+ ),
142
+ );
118
143
  } else {
119
144
  console.log(
120
145
  local.present
@@ -134,20 +159,26 @@ export async function rewardsCommand(options) {
134
159
  timeoutMs: options.timeoutMs,
135
160
  });
136
161
  if (options.json) console.log(JSON.stringify(result, null, 2));
137
- else if (!result.sheet) console.log("No question sheet on file for this app.");
162
+ else if (!result.sheet)
163
+ console.log("No question sheet on file for this app.");
138
164
  else {
139
165
  const rules = Object.entries(result.sheet.rules || {});
140
166
  console.log(`Question sheet on file: ${rules.length} rule(s).`);
141
167
  for (const [id, entry] of rules) {
142
168
  const sets = Array.isArray(entry.sets) ? entry.sets.length : 0;
143
- const standing = Array.isArray(entry.questions) ? entry.questions.length : 0;
144
- console.log(` ${id}: ${sets} set(s), ${standing} standing question(s)`);
169
+ const standing = Array.isArray(entry.questions)
170
+ ? entry.questions.length
171
+ : 0;
172
+ console.log(
173
+ ` ${id}: ${sets} set(s), ${standing} standing question(s)`,
174
+ );
145
175
  }
146
176
  }
147
177
  return;
148
178
  }
149
179
  const file = options.positional?.[1];
150
- if (!file) throw new Error("Usage: lumine rewards sheet <file.json> | --show");
180
+ if (!file)
181
+ throw new Error("Usage: lumine rewards sheet <file.json> | --show");
151
182
  await assertAuthScope({ options, auth, scope: "build:write" });
152
183
  let sheet;
153
184
  try {
@@ -164,7 +195,9 @@ export async function rewardsCommand(options) {
164
195
  });
165
196
  if (options.json) console.log(JSON.stringify(result, null, 2));
166
197
  else {
167
- console.log(`Question sheet uploaded for Build ${buildId}. It is kept off the project files and merged with ${REWARDS_FILE} when you send the version for review.`);
198
+ console.log(
199
+ `Question sheet uploaded for Build ${buildId}. It is kept off the project files and merged with ${REWARDS_FILE} when you send the version for review.`,
200
+ );
168
201
  printDeclaration(result);
169
202
  }
170
203
  if (!result.ok) process.exitCode = 1;
@@ -183,6 +216,6 @@ function printRewardsHelp() {
183
216
  rewards.json (project root) declares the economy the reviewer approves:
184
217
  { "dailyXP", "dailyCoins", "userDailyXP", "userDailyCoins", "lifetimeXP", "lifetimeCoins", "userDailyClaims"?,
185
218
  "rules": [{ "id", "title", "xp", "coins", "verifier": "numeric-quiz" | "completion",
186
- "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent" }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
219
+ "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
187
220
  Questions and answer keys never go in project files; they belong in the sheet.`);
188
221
  }
package/lib/sdk.js CHANGED
@@ -162,8 +162,8 @@ export const SDK_CLI_METHODS = {
162
162
  // token PLUS the server-issued published-runtime grant (fetched from the
163
163
  // canonical GET /build/:id/runtime payload, never minted locally). The CLI
164
164
  // holds no award logic; the server checks approval, version and budget.
165
- // getStatus is read-only: the endpoint only accepts rewards:claim, so that
166
- // scope is minted for it, but only the status operation is ever sent.
165
+ // Status and receipt reads use the endpoint's rewards:claim scope, but
166
+ // only their fixed read operation is sent.
167
167
  "rewards.getStatus": {
168
168
  path: "api/rewards/status",
169
169
  special: "rewards",
@@ -172,6 +172,14 @@ export const SDK_CLI_METHODS = {
172
172
  readOnly: true,
173
173
  mapArgs: () => ({}),
174
174
  },
175
+ "rewards.getReceipt": {
176
+ path: "api/rewards/receipt",
177
+ special: "rewards",
178
+ operation: "receipt",
179
+ scopes: ["rewards:claim"],
180
+ readOnly: true,
181
+ mapArgs: (args) => ({ challengeId: args.challengeId }),
182
+ },
175
183
  "rewards.start": {
176
184
  path: "api/rewards/start",
177
185
  special: "rewards",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.75",
3
+ "version": "0.2.78",
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.41.0
4
- Updated: 2026-09-08
5
- Generated: 2026-09-12T06:19:33.743Z
3
+ Version: 1.45.0
4
+ Updated: 2026-09-14
5
+ Generated: 2026-09-14T06:42:36.041Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -24,16 +24,18 @@ Generated: 2026-09-12T06:19:33.743Z
24
24
  - Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
25
25
  - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
26
26
  - Live web search is enabled by default for Twinkle.ai.chat and for Medium/High Twinkle.ai.generateObject and Twinkle.characters.chat requests. App authors can pass webSearch: false to disable it for their app. Search uses the provider's live web-search tool and is included in AI Energy usage; structured and character Lite Mode remains tool-free.
27
- - Interface text must not be selectable on touch devices: apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls) so mobile long-press does not highlight UI. Keep text inputs and genuinely user-copyable content selectable.
27
+ - Mobile long-press must not select game UI or open browser Copy/Look Up/image menus. The SDK provides no-selection/callout defaults for standard buttons, button-like ARIA controls, and canvases. Mark the entire custom gameplay wrapper data-twinkle-no-select, including HUD, labels, scores, menus, controls, and empty play space; also style it with user-select: none, -webkit-user-select: none, and -webkit-touch-callout: none for local previews. Protecting only one button or the canvas is insufficient. This behavior is independent of Twinkle.preview layout mode.
28
+ - Keep inputs/contenteditable usable. Mark genuinely copyable story/chat/user-written text data-twinkle-selectable and style it with user-select: text, -webkit-user-select: text, and -webkit-touch-callout: default. SDK defaults have low specificity so existing explicit copyable-text styles remain effective. Preserve document/reader selection; do not block document-wide touch/pointer events or disable scrolling/zoom to prevent selection.
29
+ - Verify mobile long presses on controls, HUD, and empty play space in Safari and Chromium; confirm held controls still work and release/cancel correctly, and scrolling, typing, and copying still work. lumine check only detects a missing no-selection rule, not selector coverage or mobile behavior.
28
30
  - Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.
29
31
  - Use Twinkle.media for camera photos and camera-only two-second clips. Twinkle confirms each capture or paid processing action. Clips are processed to canonical 480p MP4 assets before they become visible; use sharedDb or privateDb to publish/store the returned asset metadata.
30
32
  - Static media published through sharedDb is app-owned feed data. A public user-generated feed must provide a visible report flow and owner removal, and must not claim that Twinkle globally moderates those posts.
31
33
  - Use Twinkle.live for one-way app livestreams and Twinkle.chat for the accompanying thread. Free livestreams require a verified host, end after at most 15 minutes, and issue at most 10 private viewer grants. Twinkle keeps platform-owned live-status/end controls above active hosts, so app code cannot hide or replace the broadcaster's Stop path.
32
34
  - Media Energy is separate from AI Energy. Replace Media Energy UI only from canonical mediaEnergy/getUsage responses; never decrement, reserve, or synthesize it in app code.
33
35
  - Twinkle.rewards awards real XP and Coins only in the current approved published release. Drafts, local previews, private apps and superseded releases cannot earn. The server supplies a published-runtime grant; app code cannot choose a recipient or award amount.
34
- - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (dailyXP, dailyCoins, userDailyXP, userDailyCoins, lifetimeXP, lifetimeCoins, optional userDailyClaims) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
35
- - Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per Korean day, and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
36
- - v1 verifies numeric quiz answers on the server; client scores, privateDb state, timers and completion booleans are not reward evidence. Daily limits reset at midnight in Korea. Each rule can be earned once per viewer per day, with three answer attempts per challenge. Challenge expiry is 30 minutes. Budgets apply across release changes.
36
+ - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (dailyXP, dailyCoins, userDailyXP, userDailyCoins, lifetimeXP, lifetimeCoins, optional userDailyClaims) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
37
+ - Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
38
+ - Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
37
39
 
38
40
  ## Token Scopes
39
41
  files:read, media:read, media:write, live:read, live:write, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write, rewards:claim
@@ -1012,20 +1014,29 @@ world.updatePresence({ x, y, z, facing });
1012
1014
  - await Twinkle.rewards.getStatus() | scopes: rewards:claim
1013
1015
  - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1014
1016
  - Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
1015
- - rules[].available is false on a Korean day the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until Korean midnight.
1017
+ - rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
1016
1018
  - challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
1017
1019
  - Under progression 'until-earned' the same set stays up day after day until somebody earns it; setKey names the set currently up. Completion rules are always available and have questionCount 0.
1020
+ - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
1021
+ - Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
1022
+ - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
1023
+ - Reconcile a durable local reward outbox after a lost claim reply: awarded confirms the exact payment; pending means no receipt yet for a current unexpired challenge, so retry the same challengeId. expired or not_found confirms no paid receipt and no claim possible for that ID under the current release. Never refund app items just because getStatus history omitted an older claim or a network request failed. Preview has no durable paid receipts; keep it separate from live recovery.
1018
1024
  - await Twinkle.rewards.start({ ruleId }) | scopes: rewards:claim
1019
1025
  - Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1020
- - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until Korean midnight (expiresAt). Resuming after a wrong answer includes each question's guide.
1026
+ - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
1021
1027
  - Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
1022
1028
  - For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
1023
1029
  - await Twinkle.rewards.claim({ challengeId, answers?: [number] }) | scopes: rewards:claim
1024
1030
  - Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry }, questions: [{ prompt, hint?, guide? }], balances: { xp, coins } }
1025
- - Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. The receipt’s xp/coins are what was actually paid: the full amounts on a first try, the retry share after a wrong answer (attempt > 1). Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically.
1031
+ - Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. The receipt’s xp/coins are what was actually paid: the full amounts on a first try, the retry share after a wrong answer (attempt > 1). Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically. Under retry.paidAttempts a correct answer past that attempt returns awarded: true with a zero receipt: solved, not paid.
1026
1032
  - Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
1027
1033
  - A wrong answer within two seconds of the previous one is refused with build_reward_throttled (HTTP 429) and does not count; wait for the person to try again rather than retry-looping.
1028
1034
  - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
1035
+ - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1036
+ - Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
1037
+ - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
1038
+ - available says which boards this app's approved rules can pay: show a Coins board only when available.coins is true (an app whose rules pay XP only has no Coins standings). me is the signed-in viewer's own standing even when they fall outside the page, or null when they earned nothing in the period.
1039
+ - Drafts and previews return mode 'preview' with no entries. Use Twinkle.leaderboards for app-defined scores; use this for real XP and Coins earned.
1029
1040
 
1030
1041
  ## Examples
1031
1042
 
@@ -488,16 +488,24 @@ it never authorizes unrelated daily work or generic recommendation commands.
488
488
 
489
489
  Mikey added Math Lab question design and publishing to the full daily workflow
490
490
  on 2026-09-08. Follow [Math Lab daily question publishing](../../agent-guides/math-lab-daily.md)
491
- for the canonical Build 2460, owner account, 12-grade/36-question editorial
492
- process, verification, repeat-run recovery, release gates, and final reporting.
491
+ for the canonical Build 2460, owner account, twelve grade queues of ordered
492
+ until-earned puzzles, verification, repeat-run recovery, release gates, and
493
+ final reporting. Every full daily run reports each grade's current question,
494
+ whether it was cleared today, uncleared published questions remaining, and
495
+ refill status. Count distinct cleared keys across all users and the app's full
496
+ history against the live approved sheet; the recent usage window and draft
497
+ additions are not the live inventory. At two or fewer remaining, prepare a
498
+ refill to at least ten, with complete interactive guides, and track it until
499
+ approved publication. Report one or zero remaining prominently. Details are
500
+ in the linked guide's **Daily queue monitoring and refill** section.
493
501
  This is not part of Featured-only or newspaper-only work and is not a new
494
502
  scheduler, delegated API scope, or automatic extension of admin permissions.
495
503
  Use the expressly authorized owner Build workflow for Math Lab; retain the
496
504
  normal Zero/Ciel actor separation for other administration.
497
505
 
498
- The initial private draft must remain unpublished until Mikey authorizes its
499
- launch. After launch, routine content releases follow the standing duty but
500
- cannot bypass a reward-enabled app's exact-version approval gate. Local edits
506
+ Mikey authorized Math Lab's initial launch, completed on 2026-09-12. Routine
507
+ refills follow the standing duty but still need his exact-version approval
508
+ and explicit publication authorization. Local edits
501
509
  and draft saves do not require release approval. Real XP/Coins may be changed
502
510
  only by the currently published, approved artifact through server-verified
503
511
  reward claims; private builds, previews, local tests, unpublished branches, and
@@ -507,6 +515,8 @@ has already been implemented. See the guide before adding reward capabilities.
507
515
 
508
516
  ## Escalation to Mikey
509
517
 
518
+ Before closing a full run, reconcile three explicit handoffs: pending reward approvals (`rewardReviews` in intake/report), every carryover todo (with new evidence or a concrete blocker and next action), and earlier-day telemetry that meets a reopening condition. `carryoverWithoutProgressThisRun` in the report identifies surfaced todos without a progress update. A pending human decision can remain open; it must be named with its exact request/version, recommendation and next owner. Never equate reading a summary with inspecting frozen implementation, clearing a stuck flag with producing the intended image, or deploying code with verifying its live outcome. The September 14 omissions were execution failures under already explicit duties; these fields make them visible, not optional.
519
+
510
520
  A full daily management run is not finished when the mutations are done. Curation surfaces things only
511
521
  a human owner can decide, and a finding nobody reports is a finding that did not
512
522
  happen. **Every full run ends with an escalation list**, and it belongs in the run's
@@ -1092,7 +1102,7 @@ Review questions to settle with Mikey before approving:
1092
1102
  day across twelve stages.
1093
1103
  - Quiz rules: fixed questions reachable in seconds are farmable; dated sets
1094
1104
  or `progression: "until-earned"` sets (a set stays up until somebody earns
1095
- it, then the next one comes up the following Korean day) keep them honest.
1105
+ it, then the next one comes up the following site day (UTC midnight, 9:00 AM Korea)) keep them honest.
1096
1106
  - Do the rule IDs in `rewards.json` match what the code starts? Unknown IDs
1097
1107
  simply never pay.
1098
1108
  - Are the amounts and the per-user, per-app and lifetime budgets conservative
@@ -1122,7 +1132,7 @@ Rule fields (all server-enforced, none inferred from app code):
1122
1132
  - `verifier`: `numeric-quiz` (server-checked numeric answers) or `completion`
1123
1133
  (a finished activity; `minSeconds` is the only proof).
1124
1134
  - `sets`: question sets. Dated: `[{ "from": "2026-09-14", "to": "2026-09-14", "questions": [...] }]`
1125
- on Korean calendar days (inclusive, non-overlapping, up to 62). Until-earned
1135
+ on site days (UTC) (inclusive, non-overlapping, up to 62). Until-earned
1126
1136
  (`"progression": "until-earned"`): ordered sets with optional `key`; the
1127
1137
  first set nobody earned before today is up, an unsolved set is never
1128
1138
  replaced, and a set earned today stays up for the rest of that day.
@@ -1130,11 +1140,11 @@ Rule fields (all server-enforced, none inferred from app code):
1130
1140
  after a wrong one, as a share of the rule's amounts (rounded down). Absent:
1131
1141
  every correct answer pays the full amounts.
1132
1142
  - `maxAttempts`: wrong answers allowed per challenge; `null` = unlimited until
1133
- Korean midnight (wrong answers are paced two seconds apart). Absent: 3.
1143
+ the daily reset (UTC midnight, 9:00 AM Korea) (wrong answers are paced two seconds apart). Absent: 3.
1134
1144
  - Per question `hint` (public from the start, ≤ 300 chars) and `guide` (a JSON
1135
1145
  object ≤ 6,000 chars the app renders as the after-answer lesson). The server
1136
1146
  releases a guide only after the learner's first answer.
1137
- - Top-level `userDailyClaims`: receipts one learner may earn per Korean day
1147
+ - Top-level `userDailyClaims`: receipts one learner may earn per site day
1138
1148
  across all rules. `1` is "one bounty a day".
1139
1149
 
1140
1150
  Math Lab's economy (Mikey, 2026-09-12): twelve level rules, one per grade per
@@ -1159,11 +1169,13 @@ Completion rewards (Arcade Typing's stage clears) prove nothing but elapsed
1159
1169
  time, so the run reads the shape of the week's claims instead of trusting them:
1160
1170
 
1161
1171
  ```bash
1162
- lumine admin reward-activity --json # last 7 Korean days, every app
1172
+ lumine admin reward-activity --json # 7 UTC days INCLUDING today’s partial day
1173
+ lumine admin reward-activity --date 2026-09-13 --json # exactly this UTC day
1174
+ lumine admin reward-activity --date 2026-09-13 --days 7 --json # 7 days ending on this date
1163
1175
  lumine admin reward-activity --days 14 --build 333 --json
1164
1176
  ```
1165
1177
 
1166
- Read-only, no run lease. The result lists every app that paid (claims,
1178
+ Read-only, no run lease. For yesterday, always pass its exact UTC `--date`; `--days 1` alone means the current partial day. `from`, `to`, `timezone`, and `includesCurrentDay` make the window explicit. Rules come from each claim’s frozen review, not the current app policy; `missingReviewIds` means rule-based flags lack context. The result lists every app that paid (claims,
1167
1179
  earners, XP, Coins) and the flagged player-days, worst first:
1168
1180
 
1169
1181
  - `fast`: a completion claim within 5 s of the rule's `minSeconds` — a human
@@ -3241,6 +3253,7 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
3241
3253
  canonical writer and returns only the resolved public account identity,
3242
3254
  current membership, and the roster rationale/timestamps when present; it
3243
3255
  does not expose the private roster fields.
3256
+ When Mikey authorizes removal, use `lumine admin notable remove <userId|username> --note "<why removed>" --json`. It is run-independent, transactionally audited as `notable.remove`, verifies canonical absence, and is idempotent. Never use SQL to work around a missing CLI verb. Mikey is the administrator, not a Notable candidate; do not include him in blanket roster additions.
3244
3257
  **Always pass `--note`** with a concrete one-or-two-sentence record of what
3245
3258
  made them notable — real numbers and specifics from the brief window, not
3246
3259
  "active user". It lands in the management page's reason column, which is