@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 +4 -0
- package/lib/admin.js +277 -86
- package/lib/commands.js +21 -2
- package/lib/constants.js +13 -4
- package/lib/rewards.js +46 -13
- package/lib/sdk.js +10 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +21 -10
- package/sdk/LUMINE_ADMIN.md +24 -11
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 {
|
|
35
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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({
|
|
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 =
|
|
413
|
-
operation.
|
|
414
|
-
|
|
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 =
|
|
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: {
|
|
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:
|
|
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"
|
|
492
|
+
...(serverError && typeof serverError === "object"
|
|
493
|
+
? serverError.details
|
|
494
|
+
: {}),
|
|
463
495
|
...error.featuredProgress,
|
|
464
|
-
retryInstruction:
|
|
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({
|
|
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
|
-
[
|
|
1102
|
-
|
|
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(
|
|
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)
|
|
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
|
|
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
|
-
|
|
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(
|
|
1959
|
+
throw cliValidationError(
|
|
1960
|
+
"Usage: featured comments scan|acknowledge|recommend|report --checkpoint <file>.",
|
|
1961
|
+
);
|
|
1903
1962
|
}
|
|
1904
|
-
if (
|
|
1905
|
-
|
|
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 {
|
|
1908
|
-
|
|
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)
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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 {
|
|
1922
|
-
|
|
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(
|
|
2031
|
+
throw cliValidationError(
|
|
2032
|
+
"Featured history accepts at most 20000 subject IDs per CLI scan.",
|
|
2033
|
+
);
|
|
1934
2034
|
}
|
|
1935
|
-
if (
|
|
1936
|
-
|
|
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" &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2222
|
-
|
|
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 ||
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
3739
|
+
// how many tries, and which site days (UTC) the dated sets cover.
|
|
3620
3740
|
export function formatRewardRuleLine(rule) {
|
|
3621
|
-
const parts = [
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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) =>
|
|
3637
|
-
|
|
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(
|
|
3648
|
-
|
|
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(
|
|
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(
|
|
3799
|
+
console.log(
|
|
3800
|
+
`Rule IDs found in source (heuristic): ${review.detectedRuleIds.join(", ") || "none"}`,
|
|
3801
|
+
);
|
|
3659
3802
|
}
|
|
3660
|
-
if (review.isLatest === false)
|
|
3661
|
-
|
|
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(
|
|
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(
|
|
3668
|
-
|
|
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(
|
|
3672
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 = [
|
|
3691
|
-
|
|
3692
|
-
|
|
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(
|
|
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(
|
|
3711
|
-
|
|
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(
|
|
3894
|
+
console.log(
|
|
3895
|
+
` ${recycle.id}: ${recycle.outcome}; under load: ${recycle.observedUnderLoad ?? "unknown"}.`,
|
|
3896
|
+
);
|
|
3714
3897
|
}
|
|
3715
|
-
console.log(
|
|
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(
|
|
3905
|
+
console.log(
|
|
3906
|
+
`${pair.remove.id} ${pair.remove.title} -> ${pair.add.id} ${pair.add.title}`,
|
|
3907
|
+
);
|
|
3721
3908
|
}
|
|
3722
|
-
console.log(
|
|
3723
|
-
|
|
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(`
|
|
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
|
-
|
|
22
|
-
|
|
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([
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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(
|
|
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 || [])
|
|
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)
|
|
96
|
-
|
|
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(
|
|
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)
|
|
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)
|
|
144
|
-
|
|
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)
|
|
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(
|
|
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
|
-
//
|
|
166
|
-
//
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-09-
|
|
5
|
-
Generated: 2026-09-
|
|
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
|
-
-
|
|
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
|
|
36
|
-
-
|
|
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
|
|
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
|
|
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
|
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -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,
|
|
492
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 #
|
|
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
|