@stage5/lumine 0.2.71 → 0.2.76

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/lib/admin.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { once } from "node:events";
3
- import { readFileSync } from "node:fs";
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import path from "node:path";
4
13
  import { assertAuthScope, resolveAuth } from "./auth.js";
5
14
  import { requestJson } from "./http.js";
6
15
  import {
@@ -121,6 +130,105 @@ function readBuildReviewContextFile(filePath) {
121
130
  return understanding;
122
131
  }
123
132
 
133
+ const MAX_REWARD_CONFIG_FILE_BYTES = 256 * 1024;
134
+ const REWARD_REVIEW_STATUSES = ["pending", "approved", "all"];
135
+ const REWARD_REVIEW_DECISIONS = ["approve", "reject", "revoke"];
136
+
137
+ // The reviewer's earning rules for one Build reward approval: budgets plus
138
+ // server-verified numeric-quiz rules keyed by the rule IDs the app source
139
+ // starts challenges with. Validation is the server's; this only reads JSON.
140
+ export function readRewardConfigFile(filePath) {
141
+ const normalizedPath = String(filePath || "").trim();
142
+ if (!normalizedPath) {
143
+ throw cliValidationError(
144
+ "Pass the earning rules with --config <rules.json> (dailyXP, dailyCoins, userDailyXP, userDailyCoins, lifetimeXP, lifetimeCoins, rules[]).",
145
+ );
146
+ }
147
+ let contents;
148
+ try {
149
+ contents = readFileSync(normalizedPath, "utf8");
150
+ } catch {
151
+ throw cliValidationError(`Could not read ${normalizedPath}.`);
152
+ }
153
+ if (Buffer.byteLength(contents, "utf8") > MAX_REWARD_CONFIG_FILE_BYTES) {
154
+ throw cliValidationError("The earning rules file must be under 256KB.");
155
+ }
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(contents);
159
+ } catch {
160
+ throw cliValidationError(`${normalizedPath} is not valid JSON.`);
161
+ }
162
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
163
+ throw cliValidationError(
164
+ "The earning rules file must be a JSON object with budgets and a rules array.",
165
+ );
166
+ }
167
+ if (!Array.isArray(parsed.rules) || parsed.rules.length === 0) {
168
+ 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}).",
170
+ );
171
+ }
172
+ return parsed;
173
+ }
174
+
175
+ // Writes the frozen source snapshot of a reward review into a local directory
176
+ // so the reviewing agent can read the exact code under review with ordinary
177
+ // tools. Paths are confined to the target directory; files are private.
178
+ export function writeRewardReviewSnapshot({ directory, files }) {
179
+ const requested = String(directory || "").trim();
180
+ if (!requested) {
181
+ throw cliValidationError("Pass a new or empty directory with --dir <path>.");
182
+ }
183
+ const root = path.resolve(requested);
184
+ if (root === path.resolve("/")) {
185
+ throw cliValidationError("Pass a new or empty directory with --dir <path>.");
186
+ }
187
+ // The snapshot must land in a directory that holds nothing else, so the
188
+ // reviewer never reads stale files from another review or clobbers a real
189
+ // workspace, and so no pre-existing symlink can redirect a write.
190
+ if (existsSync(root)) {
191
+ const stat = lstatSync(root);
192
+ if (stat.isSymbolicLink() || !stat.isDirectory() || readdirSync(root).length) {
193
+ throw cliValidationError(
194
+ `--dir ${root} must be a new or empty directory (not a symlink, file, or populated folder).`,
195
+ );
196
+ }
197
+ } else {
198
+ mkdirSync(root, { recursive: true, mode: 0o700 });
199
+ }
200
+ const realRoot = realpathSync(root);
201
+ const written = [];
202
+ for (const file of Array.isArray(files) ? files : []) {
203
+ const relative = String(file?.path || "").replace(/^\/+/, "");
204
+ const target = path.resolve(realRoot, relative);
205
+ if (
206
+ !relative ||
207
+ relative.includes("\\") ||
208
+ !target.startsWith(`${realRoot}${path.sep}`)
209
+ ) {
210
+ throw cliValidationError(
211
+ `Refusing to write snapshot path outside ${realRoot}: ${file?.path}`,
212
+ );
213
+ }
214
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
215
+ if (!realpathSync(path.dirname(target)).startsWith(realRoot)) {
216
+ throw cliValidationError(
217
+ `Refusing to write snapshot path outside ${realRoot}: ${file?.path}`,
218
+ );
219
+ }
220
+ const content = String(file?.content ?? "");
221
+ // 'wx' creates a fresh file only; an existing entry (or symlink) fails.
222
+ writeFileSync(target, content, { mode: 0o600, flag: "wx" });
223
+ written.push({
224
+ path: `/${relative}`,
225
+ bytes: Buffer.byteLength(content, "utf8"),
226
+ savedTo: target,
227
+ });
228
+ }
229
+ return { directory: realRoot, files: written };
230
+ }
231
+
124
232
  function readEditorialFile(filePath) {
125
233
  const normalizedPath = String(filePath || "").trim();
126
234
  if (!normalizedPath) {
@@ -410,6 +518,23 @@ export async function adminCommand(options) {
410
518
  if (operation.name === "daily-run.start") {
411
519
  assertAdminTodoHandoffResult(result, operation.body.scope);
412
520
  }
521
+ if (operation.name === "reward-review.show" && operation.snapshotDir) {
522
+ // The snapshot leaves the JSON result and lands on disk, where the agent
523
+ // reads it like any pulled workspace; the result keeps sizes and paths.
524
+ const review = result?.data?.review || {};
525
+ const snapshot = writeRewardReviewSnapshot({
526
+ directory: operation.snapshotDir,
527
+ files: review.files,
528
+ });
529
+ result = {
530
+ ...result,
531
+ data: {
532
+ ...(result.data || {}),
533
+ review: { ...review, files: snapshot.files },
534
+ snapshotDirectory: snapshot.directory,
535
+ },
536
+ };
537
+ }
413
538
  if (operation.name === "news.claim") {
414
539
  const artifacts = writeNewsClaimArtifacts({
415
540
  result,
@@ -1241,6 +1366,93 @@ export function parseAdminOperation(options) {
1241
1366
  }
1242
1367
  }
1243
1368
 
1369
+ if (
1370
+ namespace === "reward-review" ||
1371
+ namespace === "reward-reviews" ||
1372
+ namespace === "rewards"
1373
+ ) {
1374
+ // Build XP/Coin reward approvals are the administrator's own decision and
1375
+ // never part of a delegated Zero/Ciel daily run: they can happen any time.
1376
+ const snapshotDir = String(options.dir || "").trim();
1377
+ if (!action || action === "list") {
1378
+ return readOperation(
1379
+ "reward-review.list",
1380
+ withQuery("/cli/admin/reward-reviews", {
1381
+ status: parseChoice(
1382
+ options.adminStatus || "pending",
1383
+ "--status",
1384
+ REWARD_REVIEW_STATUSES,
1385
+ ),
1386
+ beforeId: options.adminCursor
1387
+ ? parseRequiredInteger(options.adminCursor, "--cursor", 1)
1388
+ : "",
1389
+ }),
1390
+ { requiresRun: false },
1391
+ );
1392
+ }
1393
+ if (action === "show" || action === "get") {
1394
+ const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1395
+ if (options.dir && !snapshotDir) {
1396
+ throw cliValidationError("Pass a new or empty directory with --dir <path>.");
1397
+ }
1398
+ return readOperation(
1399
+ "reward-review.show",
1400
+ withQuery(`/cli/admin/reward-reviews/${reviewId}`, {
1401
+ // Without --dir the source is listed with sizes only; with --dir the
1402
+ // full snapshot is fetched and written locally for reading.
1403
+ files: snapshotDir ? "1" : "0",
1404
+ }),
1405
+ { requiresRun: false, reviewId, snapshotDir },
1406
+ );
1407
+ }
1408
+ if (REWARD_REVIEW_DECISIONS.includes(action)) {
1409
+ const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1410
+ const reason = String(options.adminReason || "").trim();
1411
+ if (action !== "approve" && !reason) {
1412
+ throw cliValidationError(
1413
+ `lumine admin reward-review ${action} <id> needs --reason <text> the creator will read.`,
1414
+ );
1415
+ }
1416
+ if (reason.length > 1000) {
1417
+ throw cliValidationError("--reason must be at most 1000 characters.");
1418
+ }
1419
+ const body = { decision: action, reason };
1420
+ if (action === "approve") {
1421
+ // Without --config the app's own proposal (rewards.json + sheet, frozen
1422
+ // in the request) is approved as it stands; --config replaces it.
1423
+ if (options.adminConfigFile) body.config = readRewardConfigFile(options.adminConfigFile);
1424
+ } else if (options.adminConfigFile) {
1425
+ throw cliValidationError(
1426
+ "--config is only used with approve; rejections and revocations take --reason.",
1427
+ );
1428
+ }
1429
+ return writeOperation(
1430
+ "reward-review.decide",
1431
+ "POST",
1432
+ `/cli/admin/reward-reviews/${reviewId}`,
1433
+ body,
1434
+ { requiresRun: false, reviewId, decision: action },
1435
+ );
1436
+ }
1437
+ throw cliValidationError(
1438
+ "Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
1439
+ );
1440
+ }
1441
+
1442
+ if (namespace === "reward-activity" || namespace === "reward-telemetry") {
1443
+ // Read-only claim telemetry for the daily run: no run lease, no mutation.
1444
+ const days = options.adminDays ? parseRequiredInteger(options.adminDays, "--days", 1) : 7;
1445
+ if (days > 31) throw cliValidationError("--days must be at most 31.");
1446
+ return readOperation(
1447
+ "reward-activity.report",
1448
+ withQuery("/cli/admin/reward-activity", {
1449
+ days: String(days),
1450
+ buildId: options.buildIdFlag ? String(parseRequiredInteger(options.buildIdFlag, "--build", 1)) : "",
1451
+ }),
1452
+ { requiresRun: false },
1453
+ );
1454
+ }
1455
+
1244
1456
  if (namespace === "todo" || namespace === "todos") {
1245
1457
  if (!action || action === "list") {
1246
1458
  return readOperation(
@@ -3395,8 +3607,104 @@ async function printSpooledAdminResult({ operation, result, storage }) {
3395
3607
  printPagination(data.pagination);
3396
3608
  }
3397
3609
 
3610
+ function formatRewardReviewLine(review) {
3611
+ const owner = review.ownerUsername ? ` by ${review.ownerUsername}` : "";
3612
+ const rules = Array.isArray(review.config?.rules)
3613
+ ? review.config.rules.length
3614
+ : Number(review.ruleCount || 0);
3615
+ return `#${review.id} ${String(review.status || "").toUpperCase()} · ${review.title || `App ${review.buildId}`}${owner} · app ${review.buildId} · saved version ${review.sourceVersionId} · ${rules} rule${rules === 1 ? "" : "s"}`;
3616
+ }
3617
+
3618
+ // 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.
3620
+ export function formatRewardRuleLine(rule) {
3621
+ const parts = [`rule ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins`];
3622
+ 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`);
3624
+ return parts.join(" · ");
3625
+ }
3626
+ 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`);
3628
+ }
3629
+ parts.push(rule.maxAttempts === null ? "unlimited tries" : `${rule.maxAttempts ?? 3} tries`);
3630
+ const standing = Array.isArray(rule.questions) ? rule.questions.length : 0;
3631
+ const sets = Array.isArray(rule.sets) ? rule.sets : [];
3632
+ if (sets.length && rule.progression === "until-earned") {
3633
+ 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)` : ""}`);
3635
+ } 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"}`);
3638
+ } else {
3639
+ parts.push(`${standing} question(s)`);
3640
+ }
3641
+ return parts.join(" · ");
3642
+ }
3643
+
3644
+ function printRewardReviewResult({ operation, data }) {
3645
+ if (operation.name === "reward-review.list") {
3646
+ 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)}`);
3649
+ if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
3650
+ return;
3651
+ }
3652
+ const review = data.review || {};
3653
+ console.log(formatRewardReviewLine(review));
3654
+ if (operation.name === "reward-review.decide") {
3655
+ console.log(`Decision recorded: ${operation.decision}. ${review.reason ? `Reason: ${review.reason}` : ""}`.trim());
3656
+ }
3657
+ if (Array.isArray(review.detectedRuleIds)) {
3658
+ console.log(`Rule IDs found in source (heuristic): ${review.detectedRuleIds.join(", ") || "none"}`);
3659
+ }
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.");
3662
+ 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)`);
3664
+ }
3665
+ const config = review.config || {};
3666
+ 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)}`);
3669
+ }
3670
+ 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)`);
3673
+ }
3674
+ console.log("Use --json for the full record.");
3675
+ }
3676
+
3677
+ function printRewardActivity(data) {
3678
+ const apps = Array.isArray(data.apps) ? data.apps : [];
3679
+ 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).`);
3681
+ 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`);
3683
+ }
3684
+ if (!suspects.length) {
3685
+ console.log("Nothing unusual: no claim on the minimum time, no bursts, no sweeps, no repeated cap days, no guessing.");
3686
+ return;
3687
+ }
3688
+ console.log("Flagged (worst first):");
3689
+ 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`);
3693
+ console.log(` ${parts.join(" · ")}`);
3694
+ }
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.");
3696
+ }
3697
+
3398
3698
  function printAdminResult({ operation, result }) {
3399
3699
  const data = result?.data || {};
3700
+ if (operation.name === "reward-activity.report") {
3701
+ printRewardActivity(data);
3702
+ return;
3703
+ }
3704
+ if (operation.name.startsWith("reward-review.")) {
3705
+ printRewardReviewResult({ operation, data });
3706
+ return;
3707
+ }
3400
3708
  if (operation.name === "runtime.evidence") {
3401
3709
  const evidence = data.evidence || {};
3402
3710
  console.log(`Runtime evidence (${data.host?.requested || "unknown"}): ${evidence.status || "unknown"}.`);
package/lib/assets.js CHANGED
@@ -141,6 +141,7 @@ export async function assetsGenerate(options) {
141
141
  model,
142
142
  ...(quality ? { quality } : {}),
143
143
  ...(fileName ? { fileName } : {}),
144
+ ...(options.transparent ? { transparentBackground: true } : {}),
144
145
  };
145
146
 
146
147
  if (!options.assumeYes) {
package/lib/commands.js CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  writeAssetsManifest,
56
56
  } from "./assets.js";
57
57
  import { thumbnailCommand } from "./thumbnail.js";
58
+ import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
58
59
  import {
59
60
  assertAuthScope,
60
61
  ensureAuth,
@@ -272,6 +273,10 @@ export async function main() {
272
273
  await assetsCommand(options);
273
274
  return;
274
275
  }
276
+ if (options.command === "rewards") {
277
+ await rewardsCommand(options);
278
+ return;
279
+ }
275
280
  if (options.command === "thumbnail") {
276
281
  await thumbnailCommand(options);
277
282
  return;
@@ -1166,6 +1171,7 @@ export async function check(options) {
1166
1171
  const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
1167
1172
  const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
1168
1173
  await reportLocalProjectFindings(options, canonicalBuild?.projectLimits);
1174
+ await reportRewardDeclaration({ options, auth, buildId });
1169
1175
  const result = await requestJson({
1170
1176
  url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
1171
1177
  authToken: auth.token,
@@ -2262,6 +2268,7 @@ export function parseArgs(args) {
2262
2268
  "noUpdateCheck",
2263
2269
  "allowWrite",
2264
2270
  "main",
2271
+ "transparent",
2265
2272
  "yes",
2266
2273
  "json",
2267
2274
  "keepAssets",
@@ -2279,6 +2286,7 @@ export function parseArgs(args) {
2279
2286
  "reviewed",
2280
2287
  "noReviewLoop",
2281
2288
  "acceptAgreement",
2289
+ "show",
2282
2290
  ]);
2283
2291
 
2284
2292
  for (let i = 0; i < rest.length; i += 1) {
@@ -2495,9 +2503,11 @@ export function parseArgs(args) {
2495
2503
  adminActions: raw.actions ? String(raw.actions) : "",
2496
2504
  adminDate: raw.date ? String(raw.date) : "",
2497
2505
  adminDays: raw.days ? String(raw.days) : "",
2506
+ adminBuild: raw.build ? String(raw.build) : "",
2498
2507
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2499
2508
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2500
2509
  adminFile: raw.file ? String(raw.file) : "",
2510
+ adminConfigFile: raw.config ? String(raw.config) : "",
2501
2511
  adminReviewedBuildVersion: raw.reviewedVersion
2502
2512
  ? String(raw.reviewedVersion)
2503
2513
  : "",
@@ -2518,6 +2528,7 @@ export function parseArgs(args) {
2518
2528
  model: raw.model ? String(raw.model) : "",
2519
2529
  quality: raw.quality ? String(raw.quality) : "",
2520
2530
  assetName: raw.name ? String(raw.name) : "",
2531
+ transparent: parseBoolean(raw.transparent, false),
2521
2532
  out: raw.out ? String(raw.out) : "",
2522
2533
  target:
2523
2534
  raw.url ||
@@ -2838,6 +2849,9 @@ export function printHelp() {
2838
2849
  lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
2839
2850
  lumine assets delete <assetId>
2840
2851
  lumine assets prune [--yes]
2852
+ lumine rewards check
2853
+ lumine rewards sheet <file.json>
2854
+ lumine rewards sheet --show
2841
2855
  lumine thumbnail set <file>
2842
2856
  lumine thumbnail capture [--out <file>]
2843
2857
  lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
@@ -2867,6 +2881,11 @@ export function printHelp() {
2867
2881
  lumine admin sponsor integrity cases [--status open|pending|held|flagged|cleared|disqualified] [--json]
2868
2882
  lumine admin sponsor integrity get <case-id> [--json]
2869
2883
  lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
2884
+ lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
2885
+ lumine admin reward-activity [--days <1..31>] [--build <id>] [--json]
2886
+ lumine admin reward-review show <review-id> [--dir <path>] [--json]
2887
+ lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json]
2888
+ lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
2870
2889
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
2871
2890
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
2872
2891
  lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
@@ -2977,7 +2996,8 @@ Options:
2977
2996
  --preview-url <url> Twinkle Build preview origin
2978
2997
  --auth-file <path> Saved login path
2979
2998
  --auth-token <token> Override saved login
2980
- --dir <path> Directory for pulled project files
2999
+ --dir <path> Directory for pulled project files or a reward-review source snapshot
3000
+ --config <file> Replacement earning rules JSON for reward-review approve (default: the app's own proposal)
2981
3001
  --provider <agent> Subscription agent for lumine agent: codex or claude-code
2982
3002
  --provider-path <p> Override the selected agent CLI executable
2983
3003
  --effort <level> Optional provider reasoning effort
@@ -3058,6 +3078,7 @@ Options:
3058
3078
  --quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require 2.5)
3059
3079
  --name <fileName> File name hint for a generated asset
3060
3080
  --out <path> With thumbnail capture: also save the capture locally
3081
+ --transparent Ask GPT Image for a transparent background (assets generate)
3061
3082
  --yes Skip confirmation prompts (assets prune/generate, thumbnail)
3062
3083
  `);
3063
3084
  }
package/lib/constants.js CHANGED
@@ -429,6 +429,7 @@ export const COMMANDS = new Set([
429
429
  "restore",
430
430
  "sdk",
431
431
  "assets",
432
+ "rewards",
432
433
  "thumbnail",
433
434
  "doctor",
434
435
  "help",
package/lib/rewards.js ADDED
@@ -0,0 +1,188 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+
4
+ import { requestJson } from "./http.js";
5
+ import { findLocalProjectMetadata } from "./workspace.js";
6
+ import { ensureAuth, assertAuthScope } from "./auth.js";
7
+ import { resolveRequiredBuildIdOrSelected } from "./commands.js";
8
+
9
+ // Creator-side reward tooling.
10
+ //
11
+ // An app that pays XP or Coins declares its economy in `rewards.json` at the
12
+ // project root: rule ids, titles, amounts, tries, retry share, budgets. The
13
+ // reviewer reads that file next to the code. Quiz rules also need questions
14
+ // with answer keys, and those must never be project files (published source
15
+ // is readable by every player), so they travel separately as the private
16
+ // question sheet: `lumine rewards sheet <file.json>` uploads it to Twinkle,
17
+ // where it is merged with rewards.json when the version is sent for review.
18
+
19
+ const REWARDS_FILE = "rewards.json";
20
+
21
+ async function readWorkspaceRewardsJson(options) {
22
+ const localProject = await findLocalProjectMetadata(
23
+ path.resolve(options.dir || process.cwd()),
24
+ );
25
+ if (!localProject?.rootDir) return { present: false, value: undefined };
26
+ const filePath = path.join(localProject.rootDir, REWARDS_FILE);
27
+ let raw;
28
+ try {
29
+ raw = await fs.readFile(filePath, "utf8");
30
+ } catch (error) {
31
+ if (error?.code === "ENOENT") return { present: false, value: undefined };
32
+ throw error;
33
+ }
34
+ try {
35
+ return { present: true, value: JSON.parse(raw), filePath };
36
+ } catch (error) {
37
+ throw new Error(`${REWARDS_FILE} is not valid JSON: ${error?.message || error}`);
38
+ }
39
+ }
40
+
41
+ async function checkDeclaration({ options, auth, buildId, rewardsJson, sheet }) {
42
+ return await requestJson({
43
+ url: `${options.apiUrl}/cli/build/${buildId}/rewards/check`,
44
+ method: "POST",
45
+ authToken: auth.token,
46
+ timeoutMs: options.timeoutMs,
47
+ body: {
48
+ ...(rewardsJson === undefined ? {} : { rewardsJson }),
49
+ ...(sheet === undefined ? {} : { sheet }),
50
+ },
51
+ });
52
+ }
53
+
54
+ function printDeclaration(result, { prefix = "" } = {}) {
55
+ const rules = Array.isArray(result?.rules) ? result.rules : [];
56
+ if (result?.ok) {
57
+ console.log(
58
+ `${prefix}Rewards declaration: ok (${rules.length} rule${rules.length === 1 ? "" : "s"}${result.sheetPresent ? ", question sheet on file" : ""}).`,
59
+ );
60
+ for (const rule of rules) {
61
+ const what =
62
+ rule.verifier === "completion"
63
+ ? `completion · at least ${rule.minSeconds || 0}s`
64
+ : `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}`);
66
+ }
67
+ return;
68
+ }
69
+ console.log(`${prefix}Rewards declaration: NOT ready.`);
70
+ for (const error of result?.errors || []) console.log(`${prefix} - ${error}`);
71
+ }
72
+
73
+ // Part of `lumine check`: only speaks up when the workspace declares rewards
74
+ // or the server says the code uses the rewards SDK.
75
+ export async function reportRewardDeclaration({ options, auth, buildId }) {
76
+ let local;
77
+ try {
78
+ local = await readWorkspaceRewardsJson(options);
79
+ } catch (error) {
80
+ console.error(`Local check error: ${error.message}`);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ if (!local.present) return;
85
+ try {
86
+ const result = await checkDeclaration({
87
+ options,
88
+ auth,
89
+ buildId,
90
+ rewardsJson: local.value,
91
+ });
92
+ printDeclaration(result, { prefix: "Local check: " });
93
+ if (!result.ok) process.exitCode = 1;
94
+ } 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}).`);
97
+ }
98
+ }
99
+
100
+ export async function rewardsCommand(options) {
101
+ const action = String(options.positional?.[0] || "check");
102
+ if (options.help) {
103
+ printRewardsHelp();
104
+ return;
105
+ }
106
+ const auth = await ensureAuth(options);
107
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
108
+ if (action === "check") {
109
+ const local = await readWorkspaceRewardsJson(options);
110
+ const result = await checkDeclaration({
111
+ options,
112
+ auth,
113
+ buildId,
114
+ rewardsJson: local.present ? local.value : undefined,
115
+ });
116
+ if (options.json) {
117
+ console.log(JSON.stringify({ ...result, source: local.present ? "workspace" : "saved" }, null, 2));
118
+ } else {
119
+ console.log(
120
+ local.present
121
+ ? `Checked ${REWARDS_FILE} from this workspace against the question sheet on file.`
122
+ : `No ${REWARDS_FILE} in this workspace; checked the saved version instead.`,
123
+ );
124
+ printDeclaration(result);
125
+ }
126
+ if (!result.ok) process.exitCode = 1;
127
+ return;
128
+ }
129
+ if (action === "sheet") {
130
+ if (options.show) {
131
+ const result = await requestJson({
132
+ url: `${options.apiUrl}/cli/build/${buildId}/rewards/sheet`,
133
+ authToken: auth.token,
134
+ timeoutMs: options.timeoutMs,
135
+ });
136
+ 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.");
138
+ else {
139
+ const rules = Object.entries(result.sheet.rules || {});
140
+ console.log(`Question sheet on file: ${rules.length} rule(s).`);
141
+ for (const [id, entry] of rules) {
142
+ 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)`);
145
+ }
146
+ }
147
+ return;
148
+ }
149
+ const file = options.positional?.[1];
150
+ if (!file) throw new Error("Usage: lumine rewards sheet <file.json> | --show");
151
+ await assertAuthScope({ options, auth, scope: "build:write" });
152
+ let sheet;
153
+ try {
154
+ sheet = JSON.parse(await fs.readFile(path.resolve(file), "utf8"));
155
+ } catch (error) {
156
+ throw new Error(`Could not read ${file}: ${error?.message || error}`);
157
+ }
158
+ const result = await requestJson({
159
+ url: `${options.apiUrl}/cli/build/${buildId}/rewards/sheet`,
160
+ method: "PUT",
161
+ authToken: auth.token,
162
+ timeoutMs: options.timeoutMs,
163
+ body: { sheet },
164
+ });
165
+ if (options.json) console.log(JSON.stringify(result, null, 2));
166
+ 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.`);
168
+ printDeclaration(result);
169
+ }
170
+ if (!result.ok) process.exitCode = 1;
171
+ return;
172
+ }
173
+ printRewardsHelp();
174
+ throw new Error(`Unknown rewards action: ${action}`);
175
+ }
176
+
177
+ function printRewardsHelp() {
178
+ console.log(`Usage:
179
+ lumine rewards check Validate rewards.json (workspace or saved) against the question sheet on file
180
+ lumine rewards sheet <file.json> Upload the private question sheet ({ rules: { <ruleId>: { questions?, sets? } } })
181
+ lumine rewards sheet --show Summarize the sheet on file (never prints answer keys)
182
+
183
+ rewards.json (project root) declares the economy the reviewer approves:
184
+ { "dailyXP", "dailyCoins", "userDailyXP", "userDailyCoins", "lifetimeXP", "lifetimeCoins", "userDailyClaims"?,
185
+ "rules": [{ "id", "title", "xp", "coins", "verifier": "numeric-quiz" | "completion",
186
+ "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent" }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
187
+ Questions and answer keys never go in project files; they belong in the sheet.`);
188
+ }
package/lib/sdk.js CHANGED
@@ -27,6 +27,11 @@ export const SDK_CLI_METHODS = {
27
27
  "subjects.getSubject": { path: "api/content/subject", scopes: ["content:read"] },
28
28
  "subjects.getSubjectComments": { path: "api/content/subject-comments", scopes: ["content:read"] },
29
29
  "subjectComments.list": { path: "api/content/subject-comment-list", scopes: ["content:read"] },
30
+ "subjects.create": { path: "api/content/subject/create", scopes: ["content:write"], write: true },
31
+ "subjects.edit": { path: "api/content/subject/edit", scopes: ["content:write"], write: true },
32
+ "subjects.getWriteStatus": { path: "api/content/write-status", scopes: ["content:read"] },
33
+ "subjectComments.create": { path: "api/content/comment/create", scopes: ["content:write"], write: true },
34
+ "subjectComments.edit": { path: "api/content/comment/edit", scopes: ["content:write"], write: true },
30
35
  "profileComments.getProfileComments": { path: "api/content/profile-comments", scopes: ["content:read"] },
31
36
  "profileComments.getProfileCommentIds": { path: "api/content/profile-comment-ids", scopes: ["content:read"] },
32
37
  "profileComments.getCommentsByIds": { path: "api/content/profile-comments-by-ids", scopes: ["content:read"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.71",
3
+ "version": "0.2.76",
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-09T02:13:12.499Z
3
+ Version: 1.42.0
4
+ Updated: 2026-09-12
5
+ Generated: 2026-09-12T07:41:17.555Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -31,7 +31,8 @@ Generated: 2026-09-09T02:13:12.499Z
31
31
  - 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
32
  - 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
33
  - 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
- - Lumine agents prepare private numeric quiz rules and budgets with prepare_reward_rules (CLI: POST /build/:buildId/rewards/prepare with { config }). Creators are kids and teens: show a simple earning summary, approval status and Send for review; do not ask them to fill in technical forms. Every code or rule update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate and publishes without reward permission; adding it back requires a fresh approval. Other protected SDKs keep their own gates. Keep protected SDK calls explicit in project source. Existing approved live rewards continue while a draft waits; approvals never publish automatically.
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.
35
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
37
 
37
38
  ## Token Scopes
@@ -580,13 +581,15 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
580
581
  - Each operation slice is { cooldownSeconds, availableAt, retryAfterSeconds }.
581
582
  - serverNow is unix seconds so progress bars ignore client clock skew.
582
583
  - Pass subjectId/commentId to include per-target edit cooldowns.
583
- - async create({ title, description }) | scopes: content:write
584
- - Returns: { subject, writeStatus }
585
- - Creates a normal site subject (title + description only in v1).
584
+ - async create({ title, description, attachment }) | scopes: content:write
585
+ - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, userId, username, ... }, writeStatus }
586
+ - Creates a normal site subject. title up to 200 characters, description up to 20,000.
587
+ - attachment: { runtimeFileId } names a file the viewer uploaded through Twinkle.files.uploadGenerated (its asset id); the server verifies it is this viewer’s upload for this app, copies it into the site’s attachment storage and stores it as the subject’s real attachment (cover). Images get a thumbUrl from the site optimizer shortly after.
586
588
  - Site-wide durable cooldown: 600s between creates. 429 includes writeStatus.
587
- - async edit({ subjectId, title, description }) | scopes: content:write
589
+ - async edit({ subjectId, title, description, attachment }) | scopes: content:write
588
590
  - Returns: { subject, writeStatus }
589
591
  - Own subjects only (userId === uploader). Never uses moderator edit rights.
592
+ - attachment: { runtimeFileId } replaces the subject attachment with one of the viewer’s uploads; attachment: null removes it; omit to leave it unchanged.
590
593
  - Per-subject edit cooldown: 10s.
591
594
 
592
595
  ### Twinkle.aiCards
@@ -674,13 +677,14 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
674
677
  - For subject-poster books that include poster replies, use author: subjectPoster, includeReplies: true, and replyScope: ownThread so the poster's replies to other people do not become pages.
675
678
  - Supports cursor-based pagination. Pass cursor from the previous response to load more.
676
679
  - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 }); const subjectId = pickedSubject.id; const page = await Twinkle.subjectComments.list(subjectId, { sortBy: 'oldest', author: 'subjectPoster', includeReplies: true, replyScope: 'ownThread', limit: 50 });
677
- - async create({ subjectId, content }) | scopes: content:write
680
+ - async create({ subjectId, content, attachment }) | scopes: content:write
678
681
  - Returns: { comment, writeStatus }
679
- - Adds a top-level subject comment (book page). Own subject only.
682
+ - Adds a top-level subject comment (book page). Own subject only. content up to 10,000 characters (longer text is cut at 10,000, so split chapters yourself).
683
+ - attachment: { runtimeFileId } attaches one of the viewer’s Twinkle.files.uploadGenerated uploads to the comment as a real attachment.
680
684
  - Site-wide durable cooldown: 20s between comment creates. 429 includes writeStatus.
681
685
  - async edit({ commentId, content }) | scopes: content:write
682
686
  - Returns: { comment, writeStatus }
683
- - Own comments only. Per-comment edit cooldown: 10s.
687
+ - Own comments only. content up to 10,000 characters. Per-comment edit cooldown: 10s.
684
688
 
685
689
  ### Twinkle.profileComments
686
690
  - async getProfileComments({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
@@ -1006,14 +1010,27 @@ world.updatePresence({ x, y, z, facing });
1006
1010
 
1007
1011
  ### Twinkle.rewards
1008
1012
  - await Twinkle.rewards.getStatus() | scopes: rewards:claim
1009
- - Returns: { mode: "live", dayKey, rules, history, balances: { xp, coins } } | { mode: "preview", rules: [], history: [], message }
1010
- - Read canonical earning rules (without answer keys), today’s receipts and balances. Drafts return preview mode. Unapproved or revoked published releases return an error.
1013
+ - 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
+ - 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.
1016
+ - 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
+ - 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.
1011
1018
  - await Twinkle.rewards.start({ ruleId }) | scopes: rewards:claim
1012
- - Returns: { mode: "live", challengeId, questions: [{ prompt }], reward: { xp, coins }, attemptsRemaining, expiresAt }
1013
- - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts.
1014
- - await Twinkle.rewards.claim({ challengeId, answers: [number] }) | scopes: rewards:claim
1015
- - Returns: { awarded: false, attemptsRemaining } | { awarded: true, duplicate, receipt, balances: { xp, coins } }
1016
- - Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically.
1019
+ - 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.
1021
+ - 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
+ - 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
+ - await Twinkle.rewards.claim({ challengeId, answers?: [number] }) | scopes: rewards:claim
1024
+ - 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.
1026
+ - 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
+ - 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
+ - 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.
1029
+ - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1030
+ - 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 }
1031
+ - 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 in Korea, 'week' the last 7 Korean days, 'all' (default) every day since approval. limit defaults to 20, max 100.
1032
+ - 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.
1033
+ - Drafts and previews return mode 'preview' with no entries. Use Twinkle.leaderboards for app-defined scores; use this for real XP and Coins earned.
1017
1034
 
1018
1035
  ## Examples
1019
1036
 
@@ -1045,6 +1045,141 @@ mutation when a caller needs the same retry identity across processes. The CLI
1045
1045
  generates a fresh key for every mutation invocation; if a mutation fails, its
1046
1046
  JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
1047
1047
 
1048
+ ### Build XP/Coin reward approvals (any time; also a full-daily-review duty)
1049
+
1050
+ The creator's Lumine designs the rewards and writes them into the app. The
1051
+ app declares its economy in `rewards.json` at the project root (rule ids,
1052
+ titles, XP, Coins, tries, retry share, budgets); quiz rules get their questions
1053
+ and answer keys from a private question sheet the creator's Lumine uploads with
1054
+ `lumine rewards sheet <file.json>` (never a project file: published source is
1055
+ readable by every player). **Send for review** freezes the code and proposes
1056
+ `rewards.json` merged with the sheet. Approval is Mikey's decision: read the
1057
+ frozen code, check that the amounts are right and that the app cannot be
1058
+ farmed, change anything that is wrong, approve. These commands need no daily
1059
+ run and can be used whenever a request arrives (the reviewer also receives a
1060
+ DM card per request).
1061
+
1062
+ ```bash
1063
+ lumine admin reward-review list --json # pending (default)
1064
+ lumine admin reward-review list --status approved --json
1065
+ lumine admin reward-review list --status all --cursor 40 --json
1066
+ lumine admin reward-review show 2 --json # summary + file sizes + proposed rules
1067
+ lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json
1068
+ lumine admin reward-review approve 2 --json # approve exactly what the app proposed
1069
+ lumine admin reward-review approve 2 --config rules.json \
1070
+ --reason "Halved the stage amounts" --json # approve with changes
1071
+ lumine admin reward-review reject 2 --reason "Rewards fire on game over; nothing is earned" --json
1072
+ lumine admin reward-review revoke 2 --reason "Farmable; pausing until redesigned" --json
1073
+ ```
1074
+
1075
+ `show --dir` writes the exact reviewed snapshot (private files, including the
1076
+ app's own `rewards.json`) so the agent can read it like a pulled workspace. The
1077
+ result's `config` is the proposal: the declared economy with the sheet's
1078
+ questions merged in. It also carries `detectedRuleIds` (a heuristic scan of
1079
+ `start({ ruleId })` calls), `isLatest`/`isLive`, the published version,
1080
+ lifetime totals and what this review has already paid out.
1081
+
1082
+ Review questions to settle with Mikey before approving:
1083
+
1084
+ - Is the reward tied to real play or learning, or does it fire on trivial or
1085
+ losing moments (a timer, a game over, the first minute of play)?
1086
+ - Completion rules (`verifier: "completion"`) prove nothing but elapsed time:
1087
+ the app calls `start` when an activity begins and `claim` when it ends, and
1088
+ the server only checks `minSeconds`, once per learner per day, and the
1089
+ budgets. Read the code for where those calls sit, and keep the amounts and
1090
+ `userDailyXP` small enough that a player scripting the calls would not
1091
+ matter. Arcade Typing's stage clears are the reference: up to 10,000 XP a
1092
+ day across twelve stages.
1093
+ - Quiz rules: fixed questions reachable in seconds are farmable; dated sets
1094
+ 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.
1096
+ - Do the rule IDs in `rewards.json` match what the code starts? Unknown IDs
1097
+ simply never pay.
1098
+ - Are the amounts and the per-user, per-app and lifetime budgets conservative
1099
+ for what the app actually asks of people?
1100
+
1101
+ `rules.json` (what `--config` takes, and what the app's `rewards.json` plus
1102
+ sheet compose into):
1103
+
1104
+ ```json
1105
+ {
1106
+ "dailyXP": 2000000, "dailyCoins": 0,
1107
+ "userDailyXP": 10000, "userDailyCoins": 0,
1108
+ "lifetimeXP": 200000000, "lifetimeCoins": 0,
1109
+ "rules": [
1110
+ { "id": "stage-1", "title": "Clear Stage 1", "xp": 300, "coins": 0,
1111
+ "verifier": "completion", "minSeconds": 20 },
1112
+ { "id": "e1-daily", "title": "Elementary 1 · Daily bounty", "xp": 50000, "coins": 1000,
1113
+ "verifier": "numeric-quiz", "maxAttempts": null, "retry": { "xpPercent": 50, "coinsPercent": 0 },
1114
+ "progression": "until-earned",
1115
+ "sets": [{ "key": "e1-01", "questions": [{ "prompt": "...", "answer": 4, "hint": "...", "guide": { "explanation": "..." } }] }] }
1116
+ ]
1117
+ }
1118
+ ```
1119
+
1120
+ Rule fields (all server-enforced, none inferred from app code):
1121
+
1122
+ - `verifier`: `numeric-quiz` (server-checked numeric answers) or `completion`
1123
+ (a finished activity; `minSeconds` is the only proof).
1124
+ - `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
1126
+ (`"progression": "until-earned"`): ordered sets with optional `key`; the
1127
+ first set nobody earned before today is up, an unsolved set is never
1128
+ replaced, and a set earned today stays up for the rest of that day.
1129
+ - `retry`: `{ "xpPercent": 50, "coinsPercent": 0 }` — what a correct answer pays
1130
+ after a wrong one, as a share of the rule's amounts (rounded down). Absent:
1131
+ every correct answer pays the full amounts.
1132
+ - `maxAttempts`: wrong answers allowed per challenge; `null` = unlimited until
1133
+ Korean midnight (wrong answers are paced two seconds apart). Absent: 3.
1134
+ - Per question `hint` (public from the start, ≤ 300 chars) and `guide` (a JSON
1135
+ object ≤ 6,000 chars the app renders as the after-answer lesson). The server
1136
+ releases a guide only after the learner's first answer.
1137
+ - Top-level `userDailyClaims`: receipts one learner may earn per Korean day
1138
+ across all rules. `1` is "one bounty a day".
1139
+
1140
+ Math Lab's economy (Mikey, 2026-09-12): twelve level rules, one per grade per
1141
+ day, elementary 50,000 XP + 1,000 Coins, middle 70,000 + 5,000, high
1142
+ 100,000 + 10,000; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
1143
+ `userDailyClaims` 1; until-earned sets authored from the Korean curriculum.
1144
+ Arcade Typing (Mikey, 2026-09-12): XP for clearing campaign stages, up to
1145
+ 10,000 XP per learner per day, no Coins. Platform ceilings: 100,000 XP /
1146
+ 10,000 Coins per rule and per learner per day, 10,000,000 XP / 1,000,000 Coins
1147
+ per app per day, 1,000,000,000 XP / 100,000,000 Coins per app lifetime.
1148
+
1149
+ Approval freezes these rules with the reviewed snapshot; an approval without at
1150
+ least one rule is refused. Rejection and revocation require a `--reason` the
1151
+ creator reads verbatim in their workspace. Approval never publishes: the creator
1152
+ publishes the approved version themselves, and a later code save needs a new
1153
+ request. Never approve without reading the code; never approve a request whose
1154
+ `isLatest` is false.
1155
+
1156
+ ### Reward activity report (standing duty, every full daily review; added 2026-09-12)
1157
+
1158
+ Completion rewards (Arcade Typing's stage clears) prove nothing but elapsed
1159
+ time, so the run reads the shape of the week's claims instead of trusting them:
1160
+
1161
+ ```bash
1162
+ lumine admin reward-activity --json # last 7 Korean days, every app
1163
+ lumine admin reward-activity --days 14 --build 333 --json
1164
+ ```
1165
+
1166
+ Read-only, no run lease. The result lists every app that paid (claims,
1167
+ earners, XP, Coins) and the flagged player-days, worst first:
1168
+
1169
+ - `fast`: a completion claim within 5 s of the rule's `minSeconds` — a human
1170
+ who types the stage lands well above the minimum; a script lands on it;
1171
+ - `burst`: 3+ claims within 10 minutes;
1172
+ - `sweep`: 75 %+ of an app's completion rules earned within 30 minutes (3+ claims);
1173
+ - `cap` / `daily-max`: at the per-learner day cap; at it on 3+ days of the window;
1174
+ - `guessing`: a quiz challenge with 15+ wrong answers (unlimited-tries rules).
1175
+
1176
+ Put the app totals and every flagged row in the daily report verbatim. A
1177
+ single `cap` day is a good player having a good day; `fast` + `sweep` +
1178
+ `burst` together on one player is the script signature. None of it is proof:
1179
+ open the player with `admin identity inspect` before proposing anything, and
1180
+ propose to Mikey (revoke the app's rule, or a bucket ban) rather than acting.
1181
+ Never revoke an approval from a daily run.
1182
+
1048
1183
  ## Private carry-over todos
1049
1184
 
1050
1185
  ```bash
@@ -2374,6 +2509,43 @@ not that all channel history was reviewed. Deleted messages and hidden
2374
2509
  attachments remain hidden. Group-channel browsing, `--all`, and `--days` are
2375
2510
  not supported. Never start an entire daily run just to investigate one reply.
2376
2511
 
2512
+ ### API worker memory report (same phase, every full daily review; added 2026-09-12)
2513
+
2514
+ On 2026-09-12 both primary API workers hit their 256 MiB V8 old-space cap at
2515
+ the same moment after ~14.6 h and aborted; core dumps then pinned both vCPUs
2516
+ and the site was down about five minutes. Since then the cluster primary
2517
+ recycles one worker politely when its heap stays above 85 % of its limit
2518
+ (immediately above 95 %), never both at once, and the unit sets `LimitCORE=0`.
2519
+ The daily run reports on that behaviour so Mikey can decide the next step
2520
+ (heap cap, retention fix, host budget).
2521
+
2522
+ Run on the primary over management SSH (standing permission covers it):
2523
+
2524
+ ```bash
2525
+ ssh api-primary.twinkle.network 'cd /home/ec2-user/server && bash scripts/twinkle-api-service.sh memory-daily'
2526
+ ```
2527
+
2528
+ Read every line, and put these in the daily report verbatim:
2529
+
2530
+ - each `worker slot=… uptime=… core_limit=… heap_pct=… heap_high_water_pct=…`
2531
+ line: worker age, current heap as a share of its cap, and the highest share
2532
+ it reached; `core_limit` must read `0` on a post-2026-09-12 generation;
2533
+ - the `worker events (24h) heap_recycles=… heap_high_warnings=… pressure_recycles=…
2534
+ operator_recycles=… service_restarts=… oom_aborts=… unexpected_worker_exits=…`
2535
+ line;
2536
+ - the `day-over-day … worker_heap=…` delta.
2537
+
2538
+ Escalate in the report (a todo, and a note for Mikey) when any of these hold:
2539
+ `oom_aborts` > 0, `service_restarts` > 0, `unexpected_worker_exits` > 0, any
2540
+ worker `heap_high_water_pct` ≥ 95, or `heap_recycles` ≥ 4 in a day (the
2541
+ recycler is masking growth faster than expected). A worker at 85–90 % with a
2542
+ few recycles a day is the designed steady state, not an incident; report the
2543
+ numbers and move on. Do not raise the cap, change guard thresholds, or take a
2544
+ heap snapshot as part of the run: a snapshot inflates the worker to ~6× its
2545
+ heap and the cgroup guard kills it (observed 2026-09-12); the facility now
2546
+ refuses without that headroom. The retention investigation uses the
2547
+ `[runtime-memory] allocation-profile` lines in `twinkle-api.out.log` instead.
2548
+
2377
2549
  ### API runtime-log review (same phase, every full daily review)
2378
2550
 
2379
2551
  The bot-conduct review also owns a bounded production API log review. Bot
@@ -3099,8 +3271,17 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
3099
3271
  comments, recommendations, wordle, reflections, dailyTasks, aiChat,
3100
3272
  lumineBuildChat, buildsEdited, buildsPlayed) for the current window vs the
3101
3273
  equal-length previous window, each as `{ current, previous, delta }`.
3102
- Presence, build edits, and build plays come from durable action/version/view
3103
- events, not mutable `lastActive`/`updatedAt` snapshots. Zero/Ciel are excluded
3274
+ `activeUsers` is the union of every timestamp-windowed surface: a member who
3275
+ logged an action (search, logout, entering Chat), wrote or recommended a
3276
+ Subject or comment, sent a chat message, shared a reflection, talked to
3277
+ Lumine, or saved a Build in the window, counted once however many surfaces
3278
+ they touched. It is therefore never smaller than subjects, comments,
3279
+ recommendations, reflections, lumineBuildChat or buildsEdited, and a member
3280
+ active in both windows counts in both. The calendar-bucket surfaces (wordle,
3281
+ dailyTasks, aiChat) and buildsPlayed are NOT part of that union, so
3282
+ `activeUsers` can be smaller than those. Presence, build edits, and build
3283
+ plays come from durable action/version/view events, not mutable
3284
+ `lastActive`/`updatedAt` snapshots. Zero/Ciel are excluded
3104
3285
  from authored surfaces. Wordle, daily tasks, and AI chat use the equal
3105
3286
  calendar-bucket ranges in `dayWindow`; those can begin before the exact
3106
3287
  timestamp window but always compare the same number of days. This is the