@stage5/lumine 0.3.9 → 0.3.11

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.
@@ -71,6 +71,7 @@ export function runAdmin(
71
71
  "media-costs": ["monthly"],
72
72
  "reward-activity": [null],
73
73
  "reward-review": ["list"],
74
+ approvals: ["list"],
74
75
  todo: ["list"],
75
76
  escalation: ["list"],
76
77
  featured: ["list", "history"],
@@ -187,6 +188,9 @@ export async function dailyRunGather(options) {
187
188
  "reward-activity.json",
188
189
  );
189
190
  step("reward-review-pending", ["reward-review", "list"], "rr-pending.json");
191
+ // management approvals (birthdates, teacher signups, meetups) with their
192
+ // flags: a required report section (agent-guides/lumine-admin.md)
193
+ step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
190
194
  step("todos", ["todo", "list"], "todos.json");
191
195
  step("escalations", ["escalation", "list"], "escalations.json");
192
196
  step("featured-list", ["featured", "list"], "featured-start.json");
package/lib/admin.js CHANGED
@@ -226,6 +226,91 @@ function parseReviewRequestRef(target, { type: fixedType, typeOption } = {}) {
226
226
  return { type, id: parseRequiredInteger(raw, "Request ID", 1) };
227
227
  }
228
228
 
229
+ // Management approvals (dob/mentor/meetup) and the teacher audit.
230
+ const APPROVAL_USAGE =
231
+ "Use: lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>]";
232
+ const TEACHER_USAGE =
233
+ "Use: lumine admin teachers audit [--limit <1-100>] | revoke <userId> --reason <text>";
234
+
235
+ function parseApprovalId(target) {
236
+ const raw = String(target || "").replace(/^approval:/, "");
237
+ if (!/^\d+$/.test(raw)) throw cliValidationError(APPROVAL_USAGE);
238
+ return Number(raw);
239
+ }
240
+
241
+ function buildApprovalOperation({ action, target, options }) {
242
+ const reason = String(options.adminReason || "").trim();
243
+ if (!action || action === "list") {
244
+ return readOperation(
245
+ "approvals.list",
246
+ withQuery("/cli/admin/approvals", {
247
+ status: parseChoice(options.adminStatus || "pending", "--status", [
248
+ "pending",
249
+ "approved",
250
+ "rejected",
251
+ "all",
252
+ ]),
253
+ type: options.adminType
254
+ ? parseChoice(String(options.adminType), "--type", [
255
+ "dob",
256
+ "mentor",
257
+ "meetup",
258
+ ])
259
+ : "",
260
+ }),
261
+ { requiresRun: false },
262
+ );
263
+ }
264
+ const id = parseApprovalId(target);
265
+ if (action === "show" || action === "get") {
266
+ return readOperation("approvals.show", `/cli/admin/approvals/${id}`, {
267
+ requiresRun: false,
268
+ });
269
+ }
270
+ if (action === "approve" || action === "reject") {
271
+ return writeOperation(
272
+ "approvals.decide",
273
+ "POST",
274
+ `/cli/admin/approvals/${id}`,
275
+ { decision: action, reason },
276
+ { requiresRun: false },
277
+ );
278
+ }
279
+ throw cliValidationError(APPROVAL_USAGE);
280
+ }
281
+
282
+ function buildTeacherOperation({ action, target, options }) {
283
+ if (!action || action === "audit") {
284
+ return readOperation(
285
+ "teachers.audit",
286
+ withQuery("/cli/admin/teachers/audit", {
287
+ limit: options.limit
288
+ ? parseRequiredInteger(options.limit, "--limit", 1, 200)
289
+ : "",
290
+ }),
291
+ { requiresRun: false },
292
+ );
293
+ }
294
+ if (action === "revoke" || action === "remove") {
295
+ const userId = String(target || "");
296
+ if (!/^\d+$/.test(userId)) throw cliValidationError(TEACHER_USAGE);
297
+ const reason = String(options.adminReason || "").trim();
298
+ if (!reason) {
299
+ throw cliValidationError(
300
+ "lumine admin teachers revoke <userId> needs --reason <text> (kept on the request's record).",
301
+ );
302
+ }
303
+ return writeOperation(
304
+ "teachers.revoke",
305
+ "POST",
306
+ `/cli/admin/teachers/${userId}/revoke`,
307
+ { reason },
308
+ { requiresRun: false },
309
+ );
310
+ }
311
+ throw cliValidationError(TEACHER_USAGE);
312
+ }
313
+
229
314
  // Builds the operation for list/show/approve/reject/revoke. `fixedType` is set
230
315
  // by the per-type aliases; `listStatuses` keeps their own --status choices.
231
316
  function buildReviewRequestOperation({
@@ -718,7 +803,12 @@ export async function adminCommand(options) {
718
803
  throw noActiveRunError();
719
804
  }
720
805
  runId = Number(selectedRun?.id || 0);
721
- if (!runId && !correctionSessionId && !commentSessionId) {
806
+ if (
807
+ !runId &&
808
+ !correctionSessionId &&
809
+ !commentSessionId &&
810
+ !operation.runOptional
811
+ ) {
722
812
  if (operation.correctionEligible) {
723
813
  const error = new Error(
724
814
  `Start a correction session first: lumine admin correction start ${operation.correctionCommentId}.`,
@@ -2042,6 +2132,16 @@ export function parseAdminOperation(options) {
2042
2132
  }
2043
2133
  }
2044
2134
 
2135
+ if (namespace === "approvals" || namespace === "approval") {
2136
+ // Management approvals: birthdates, teacher signups (mentor), meetups.
2137
+ // Mikey's decisions, any time; each comes with its evidence (approvals.ts).
2138
+ return buildApprovalOperation({ action, target, options });
2139
+ }
2140
+ if (namespace === "teachers" || namespace === "teacher") {
2141
+ // Approved teachers: an audit any time, and a clean removal of the status.
2142
+ return buildTeacherOperation({ action, target, options });
2143
+ }
2144
+
2045
2145
  if (namespace === "review" || namespace === "reviews") {
2046
2146
  // Every Build unlock in one queue. Mikey's own decisions: any time, never
2047
2147
  // part of a delegated Zero/Ciel daily run.
@@ -2578,7 +2678,7 @@ export function parseAdminOperation(options) {
2578
2678
  // long as two GPT-6.1 Sol calls, so it gets its own timeout. The daily
2579
2679
  // website management run calls it in a bounded loop (Mikey, 2026-09-28).
2580
2680
  const usage =
2581
- "Usage: lumine admin reward-bank coverage <buildId> [--max-step 3] | seed <buildId> [--max-step 3] | report <buildId> [--days 1].";
2681
+ "Usage: lumine admin reward-bank coverage <buildId> [--max-step 3] | seed <buildId> [--max-step 3] [--identity zero|ciel|auto] | report <buildId> [--days 1].";
2582
2682
  const buildId = parseRequiredInteger(
2583
2683
  target || options.adminBuild,
2584
2684
  "Build ID",
@@ -2613,12 +2713,21 @@ export function parseAdminOperation(options) {
2613
2713
  );
2614
2714
  }
2615
2715
  if (action === "seed") {
2716
+ // Inside a daily run it seeds as that run's identity. Without one the
2717
+ // owner can seed whenever they like (Mikey, 2026-10-04): the server
2718
+ // checks the account and acts as --identity zero|ciel (default: today's
2719
+ // scheduled identity), billed to that identity's AI Energy as before.
2720
+ const identity = options.adminIdentity
2721
+ ? String(options.adminIdentity).toLowerCase()
2722
+ : "";
2723
+ if (identity && !["zero", "ciel", "auto"].includes(identity))
2724
+ throw cliValidationError("--identity must be zero, ciel or auto.");
2616
2725
  return writeOperation(
2617
2726
  "reward-bank.seed",
2618
2727
  "POST",
2619
2728
  "/cli/admin/reward-bank/seed",
2620
- { buildId, maxStep },
2621
- { timeoutMs: 240000 },
2729
+ { buildId, maxStep, ...(identity ? { identity } : {}) },
2730
+ { timeoutMs: 240000, runOptional: true },
2622
2731
  );
2623
2732
  }
2624
2733
  throw cliValidationError(usage);
@@ -5264,7 +5373,7 @@ function printMeetupCrew(crew) {
5264
5373
  const plan = crew.plan || {};
5265
5374
  if (plan.status && plan.status !== "none") {
5266
5375
  console.log(
5267
- ` Plan (${plan.status}): ${plan.date} in ${plan.area}: ${plan.activity}${plan.note ? ` · note: ${plan.note}` : ""}`,
5376
+ ` Plan (${plan.status}): ${plan.date}${plan.time ? ` at ${plan.time}` : ""} in ${plan.area}: ${plan.activity}${plan.note ? ` · note: ${plan.note}` : ""}`,
5268
5377
  );
5269
5378
  }
5270
5379
  if (crew.planApprovedBy?.username) {
@@ -5390,7 +5499,7 @@ function printMeetupResult({ operation, data, result }) {
5390
5499
  }
5391
5500
  if (crews.some((crew) => crew.waitingFor)) {
5392
5501
  console.log(
5393
- "Review: lumine admin meetup show <crewId>, then approve-plan | send-back --note | approve --attended <ids>.",
5502
+ "Review: lumine admin meetup show <crewId>, then approve-crew | approve-plan | approve-grownup | send-back --note | approve --attended <ids> (classroom plans: approve at the headmaster desk with a room and times).",
5394
5503
  );
5395
5504
  }
5396
5505
  return;
@@ -5548,6 +5657,86 @@ function printReviewEvents(events) {
5548
5657
  );
5549
5658
  }
5550
5659
 
5660
+ const APPROVAL_LABELS = { dob: "birthdate", mentor: "teacher", meetup: "meetup" };
5661
+ const approvalDate = (seconds) =>
5662
+ seconds ? new Date(Number(seconds) * 1000).toISOString().slice(0, 10) : "?";
5663
+
5664
+ function printEvidence(evidence) {
5665
+ if (!evidence) return;
5666
+ const a = evidence.account || {};
5667
+ console.log(
5668
+ `Account: ${a.username} (#${a.userId}) · real name ${a.realName || "-"} · joined ${approvalDate(a.joinedAt)} · last active ${approvalDate(a.lastActive)} · age ${a.age ?? "not given"} · email ${a.verifiedEmail || "-"}`,
5669
+ );
5670
+ if (evidence.request) {
5671
+ const r = evidence.request;
5672
+ console.log(
5673
+ `Request: ${[r.branchName && `branch ${r.branchName}`, r.className && `class ${r.className}`, r.realName && `name ${r.realName}`, r.email && `email ${r.email}`, r.dob && `birthdate ${r.dob}`].filter(Boolean).join(" · ") || "-"} · sent ${approvalDate(r.requestedAt)}`,
5674
+ );
5675
+ }
5676
+ console.log(
5677
+ `Invited by: ${evidence.invitedBy ? `${evidence.invitedBy.username || "#" + evidence.invitedBy.userId} (${evidence.invitedBy.source || "invite"})` : "nobody (passphrase or other)"}`,
5678
+ );
5679
+ const act = evidence.activity || {};
5680
+ console.log(
5681
+ `Activity: ${act.comments || 0} comments (${act.commentsLast30Days || 0} in 30 days) · ${act.chatMessages || 0} chat messages`,
5682
+ );
5683
+ const linked = evidence.linkedAccounts || [];
5684
+ console.log(
5685
+ `Linked accounts: ${linked.length}${evidence.linkedEvidenceTruncated ? " (evidence truncated)" : ""}`,
5686
+ );
5687
+ for (const l of linked.slice(0, 15)) {
5688
+ console.log(
5689
+ ` - ${l.username} (#${l.userId}) via ${(l.linkedBy || []).join("+")}${l.sharedDeviceCount ? ` (${l.sharedDeviceCount} device)` : ""} · age ${l.age ?? "?"} · ${l.isTeacherOrStaff ? "teacher/staff" : "member"} · joined ${approvalDate(l.joinedAt)}${l.banned ? " · BANNED" : ""}`,
5690
+ );
5691
+ }
5692
+ console.log(
5693
+ `Flags: ${(evidence.flags || []).length ? evidence.flags.join(", ") : "none"}`,
5694
+ );
5695
+ if (evidence.note) console.log(evidence.note);
5696
+ }
5697
+
5698
+ function printApprovalResult({ operation, data }) {
5699
+ if (operation.name === "approvals.list") {
5700
+ const items = data.items || [];
5701
+ if (!items.length) return console.log("No approval requests.");
5702
+ for (const item of items) {
5703
+ console.log(
5704
+ `${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId}) · ${approvalDate(item.requestedAt)}${item.flags ? ` · flags: ${item.flags.length ? item.flags.join(", ") : "none"}` : ""}`,
5705
+ );
5706
+ }
5707
+ return;
5708
+ }
5709
+ if (operation.name === "approvals.show") {
5710
+ const item = data.item || {};
5711
+ console.log(
5712
+ `${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId})`,
5713
+ );
5714
+ printEvidence(data.evidence);
5715
+ return;
5716
+ }
5717
+ if (operation.name === "approvals.decide") {
5718
+ console.log(
5719
+ `${data.ref} ${data.status}: ${APPROVAL_LABELS[data.type] || data.type} request from ${data.requesterUsername} (#${data.requesterId}).`,
5720
+ );
5721
+ return;
5722
+ }
5723
+ if (operation.name === "teachers.audit") {
5724
+ console.log(
5725
+ `Audited ${data.audited || 0} approved teachers · ${data.flagged || 0} with flags (riskiest first).`,
5726
+ );
5727
+ for (const teacher of data.teachers || []) {
5728
+ console.log("");
5729
+ printEvidence(teacher);
5730
+ }
5731
+ return;
5732
+ }
5733
+ if (operation.name === "teachers.revoke") {
5734
+ console.log(
5735
+ `Teacher status removed from #${data.userId} (achievements ${(data.removedAchievementIds || []).join(", ")}; request #${data.approvalItemId ?? "-"} marked rejected). Reason kept: ${data.reason}`,
5736
+ );
5737
+ }
5738
+ }
5739
+
5551
5740
  function printReviewRequestResult({ operation, data, result }) {
5552
5741
  if (operation.name === "review.list") {
5553
5742
  const items = Array.isArray(data.items) ? data.items : [];
@@ -5718,6 +5907,10 @@ function printAdminResult({ operation, result }) {
5718
5907
  }
5719
5908
  return;
5720
5909
  }
5910
+ if (operation.name.startsWith("approvals.") || operation.name.startsWith("teachers.")) {
5911
+ printApprovalResult({ operation, data });
5912
+ return;
5913
+ }
5721
5914
  if (operation.name.startsWith("review.")) {
5722
5915
  printReviewRequestResult({ operation, data, result });
5723
5916
  return;
package/lib/commands.js CHANGED
@@ -3013,6 +3013,8 @@ export function printHelp() {
3013
3013
  lumine admin sponsor integrity get <case-id> [--json]
3014
3014
  lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
3015
3015
  lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject|revoke <type:id> [--reason <text>] | propose rewards:<id> --dir <edited-snapshot> --config <rules.json>
3016
+ lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] (management approvals with evidence: birthdates, teacher signups, meetups)
3017
+ lumine admin teachers audit [--limit <1-100>] | revoke <userId> --reason <text> (approved teachers, riskiest first; revoke removes the status cleanly)
3016
3018
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3017
3019
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3018
3020
  lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
@@ -3062,7 +3064,7 @@ export function printHelp() {
3062
3064
  lumine admin post skip-batch --target-file <json-or-lines> [--reason <text>] [--checkpoint <file> [--resume]] [--json]
3063
3065
  lumine admin post reward <target> [--type subject|comment|aiStory|dailyReflection] --twinkles 3 [--json]
3064
3066
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3065
- lumine admin reward-bank seed <buildId> [--max-step 3] [--json] (daily run: writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy)
3067
+ lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
3066
3068
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3067
3069
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3068
3070
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
package/lib/rewards.js CHANGED
@@ -71,6 +71,8 @@ function printDeclaration(result, { prefix = "" } = {}) {
71
71
  ? `completion · at least ${rule.minSeconds || 0}s`
72
72
  : rule.verifier === "generated-quiz"
73
73
  ? "question bank · server-written, double-checked questions on a step ladder"
74
+ : rule.verifier === "archive-rematch"
75
+ ? "daily rematch · one retired puzzle a day for players who never met it"
74
76
  : `quiz · ${rule.questionSets} set(s)${rule.progression ? ` · ${rule.progression}` : ""}${rule.standingQuestions ? ` · ${rule.standingQuestions} standing` : ""}`;
75
77
  console.log(
76
78
  `${prefix} ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins · ${what}`,
@@ -241,11 +243,13 @@ function printRewardsHelp() {
241
243
  rewards.json (project root) declares the economy the reviewer approves:
242
244
  { "userDailyXP", "userDailyCoins", "userDailyClaims"?,
243
245
  "rules": [{ "id", "title", "howTo"? (how to earn it, in the player's words), "category"? (section header, up to 40 characters, e.g. "Bring friends"),
244
- "series"? (lowercase key for repeat rules like friend-1/2/3; they must share category, verifier and amounts), "xp", "coins", "verifier": "numeric-quiz" | "completion" | "generated-quiz",
246
+ "series"? (lowercase key for repeat rules like friend-1/2/3; they must share category, verifier and amounts), "xp", "coins", "verifier": "numeric-quiz" | "completion" | "generated-quiz" | "archive-rematch",
245
247
  "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz),
246
248
  "bank"? (generated-quiz): { "steps": [percent, ...] rising to 100, "startStep"?, "instructions", "topics": [{ "key", "title", "description" }] } }] }
247
249
  The Earn page shows rules under their category headers and each series as one row ("up to 3 a day").
248
250
  Questions and answer keys never go in project files; they belong in the sheet.
251
+ archive-rematch (at most one) needs no sheet either: each day the server picks one retired
252
+ until-earned puzzle for players who never met it and checks the answer against its frozen sheet.
249
253
  generated-quiz rules need no sheet: the server writes each question for a topic at the learner's step
250
254
  (GPT-6.1 Sol, billed to that learner's AI Energy), checks it with a second independent solve, and pays
251
255
  the step's percent of xp/coins. The reviewer approves the ladder, topics and instructions.`);
package/lib/sdk.js CHANGED
@@ -227,8 +227,76 @@ export const SDK_CLI_METHODS = {
227
227
  "rewards.getArchivedProblem": {
228
228
  path: "api/rewards/archived-problem", special: "rewards", operation: "archived-problem",
229
229
  scopes: ["rewards:claim"], readOnly: true,
230
+ writeWhen: (args) => args.withGuide === true,
231
+ // withGuide marks the puzzle as studied for this viewer (unrated practice,
232
+ // no paid rematch of it later).
233
+ mapArgs: (args) => ({
234
+ receiptId: args.receiptId,
235
+ ...(args.withGuide === true ? { withGuide: true } : {}),
236
+ }),
237
+ },
238
+ // Puzzle vault (retired until-earned puzzles): practice for a practice
239
+ // rating and solver explanations. Pays nothing; the server checks answers
240
+ // against the frozen sheet and never returns them.
241
+ "rewards.practiceCheck": {
242
+ path: "api/rewards/practice-check", special: "rewards", operation: "practice-check",
243
+ scopes: ["rewards:claim"], write: true,
244
+ mapArgs: (args) => ({
245
+ receiptId: args.receiptId,
246
+ ...(args.answer !== undefined ? { answer: args.answer } : {}),
247
+ ...(args.answers !== undefined ? { answers: args.answers } : {}),
248
+ ...(args.text !== undefined ? { text: args.text } : {}),
249
+ }),
250
+ },
251
+ "rewards.getPracticeBoard": {
252
+ path: "api/rewards/practice-board", special: "rewards", operation: "practice-board",
253
+ scopes: ["rewards:claim"], readOnly: true,
254
+ mapArgs: () => ({}),
255
+ },
256
+ "rewards.getRelatedArchive": {
257
+ path: "api/rewards/related-archive", special: "rewards", operation: "related-archive",
258
+ scopes: ["rewards:claim"], readOnly: true,
259
+ mapArgs: (args) =>
260
+ args.receiptId !== undefined ? { receiptId: args.receiptId } : { ruleId: args.ruleId },
261
+ },
262
+ "rewards.listExplanations": {
263
+ path: "api/rewards/explanations", special: "rewards", operation: "explanations",
264
+ // Reading explanations marks the puzzle as studied for this account.
265
+ scopes: ["rewards:claim"], write: true,
230
266
  mapArgs: (args) => ({ receiptId: args.receiptId }),
231
267
  },
268
+ "rewards.postExplanation": {
269
+ path: "api/rewards/explanation-post", special: "rewards", operation: "explanation-post",
270
+ scopes: ["rewards:claim"], write: true,
271
+ mapArgs: (args) => ({ receiptId: args.receiptId, text: args.text }),
272
+ },
273
+ "rewards.voteExplanation": {
274
+ path: "api/rewards/explanation-vote", special: "rewards", operation: "explanation-vote",
275
+ scopes: ["rewards:claim"], write: true,
276
+ mapArgs: (args) => ({ id: args.id, vote: args.vote }),
277
+ },
278
+ "rewards.deleteExplanation": {
279
+ path: "api/rewards/explanation-delete", special: "rewards", operation: "explanation-delete",
280
+ scopes: ["rewards:claim"], write: true,
281
+ mapArgs: (args) => ({ id: args.id }),
282
+ },
283
+ // Daily rematch: both calls write (the day's pick, a paid answer), so both
284
+ // stay behind --allow-write.
285
+ "rewards.getRematch": {
286
+ // Not read-only: the day's first call stores the day's pick.
287
+ path: "api/rewards/rematch", special: "rewards", operation: "rematch",
288
+ scopes: ["rewards:claim"], write: true,
289
+ mapArgs: () => ({}),
290
+ },
291
+ "rewards.claimRematch": {
292
+ path: "api/rewards/rematch-claim", special: "rewards", operation: "rematch-claim",
293
+ scopes: ["rewards:claim"], write: true,
294
+ mapArgs: (args) => ({
295
+ ...(args.answer !== undefined ? { answer: args.answer } : {}),
296
+ ...(args.answers !== undefined ? { answers: args.answers } : {}),
297
+ ...(args.text !== undefined ? { text: args.text } : {}),
298
+ }),
299
+ },
232
300
  "rewards.start": {
233
301
  path: "api/rewards/start",
234
302
  special: "rewards",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-10-03T09:02:05.836Z
5
+ Generated: 2026-10-04T06:59:28.249Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -34,7 +34,7 @@ Generated: 2026-10-03T09:02:05.836Z
34
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.
35
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.
36
36
  - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. 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. 'generated-quiz' pays from a question bank the server fills itself: for one declared topic at the learner's step of the ladder, the server writes a question with GPT-6.1 Sol (billed to that learner's AI Energy), solves it again independently and keeps it only when both answers agree; every learner who has not seen a question gets it before anything new is written. `xp`/`coins` are the top step's amounts and each step pays its `steps` percent of them (then the usual retry share). Two first-try answers in a row climb a step; two misses in a row step down. Learners may earn many bank questions a day, bounded by userDailyXP/userDailyCoins. The reviewer approves the ladder, amounts, topics and instructions, not each question; learner reports and solve rates hide bad questions. Use the rewards bank methods (getBankStatus, startBankQuestion, generateBankQuestion, claimBankQuestion, reportBankQuestion); start and claim refuse bank rules.
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. 'generated-quiz' pays from a question bank the server fills itself: for one declared topic at the learner's step of the ladder, the server writes a question with GPT-6.1 Sol (billed to that learner's AI Energy), solves it again independently and keeps it only when both answers agree; every learner who has not seen a question gets it before anything new is written. `xp`/`coins` are the top step's amounts and each step pays its `steps` percent of them (then the usual retry share). Two first-try answers in a row climb a step; two misses in a row step down. Learners may earn many bank questions a day, bounded by userDailyXP/userDailyCoins. The reviewer approves the ladder, amounts, topics and instructions, not each question; learner reports and solve rates hide bad questions. Use the rewards bank methods (getBankStatus, startBankQuestion, generateBankQuestion, claimBankQuestion, reportBankQuestion); start and claim refuse bank rules. 'archive-rematch' is the daily rematch: one retired until-earned puzzle of the app a day, chosen by the server (celebrated, high-rated solves first; never repeated within 60 days) and answered from its frozen approved sheet. Declare at most one, with no questions, sets, progression, series, minSeconds or completionProof, e.g. { "id": "rematch-daily", "title": "Daily rematch", "xp": 20000, "coins": 1000, "verifier": "archive-rematch", "retry": { "xpPercent": 50, "coinsPercent": 0, "paidAttempts": 3 }, "maxAttempts": null }. Only players who never met that puzzle (no official receipt, wrong answer or challenge while it was live, no Vault practice) may answer it; its receipt does not use userDailyClaims but counts toward userDailyXP/userDailyCoins. Use getRematch and claimRematch; start and claim refuse it.
38
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.
39
39
  - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
40
40
 
@@ -1534,16 +1534,66 @@ world.updatePresence({ x, y, z, facing });
1534
1534
  - tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later.
1535
1535
  - No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
1536
1536
  - await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
1537
- - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
1537
+ - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt, story: { tier, attempt, failersBefore, failuresBefore, daysStanding } | null, puzzle: { rating, solvers, failers, failures } | null, vault: { rating, solvers, failers, failures } | null, rematchOpen }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
1538
1538
  - Browse confirmed solves of retired until-earned quiz bounties, newest first.
1539
1539
  - A solve appears only after the next site reset (UTC midnight, 9 AM Korea). Today’s solves, unsolved sets, standing quizzes and dated quizzes are excluded. The server decides retirement; client dates cannot unlock content. Zero-reward correct solves are included.
1540
1540
  - One entry per solve receipt, with firstSolver identifying the first receipt for that rule and set across approved versions. limit defaults to 20, max 50; pass nextCursor unchanged for older solves and omit it when changing ruleId. Pages may have fewer entries when old question sheets cannot be recovered; continue while nextCursor is present.
1541
1541
  - No daily-claim limit applies to reading. Requires the current approved published runtime grant; previews return an empty timeline. Never awards or changes balances. Open a receipt with getArchivedProblem to load the original question and guide.
1542
- - await Twinkle.rewards.getArchivedProblem({ receiptId }) | scopes: rewards:claim
1543
- - Returns: { mode: "live", entry: <same solve entry as getTimeline>, questions: [{ prompt, hint?, guide? }] } | { mode: "preview", entry: null, questions: [], message }
1542
+ - Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', the solver's attempt, how many other players had missed it and how often, days it stood); null for receipts older than solve logging. puzzle is the official challenge rating and counts now; vault is the puzzle's living practice stats (its own rating, practice solvers, players whose first practice answer missed, all wrong practice answers), or null before anyone practised it. Counts only: players who missed are never named.
1543
+ - rematchOpen is true on the entry that is today's daily rematch while the viewer can still answer it for pay: label it and open it as the rematch, not as a lesson. Daily-rematch receipts are never timeline entries.
1544
+ - await Twinkle.rewards.getArchivedProblem({ receiptId, withGuide?: true }) | scopes: rewards:claim
1545
+ - Returns: { mode: "live", entry: <same solve entry as getTimeline, with rematchOpen>, questions: [{ prompt, hint?, guide? }], rematchOpen } | { mode: "preview", entry: null, questions: [], message }
1544
1546
  - Read a retired bounty’s original questions and guides from the approval attached to its solve receipt.
1545
1547
  - Use a receiptId returned by getTimeline. Both reads independently check retirement and app ownership; guessing an active or other app’s receipt cannot reveal its questions or guides. Returns build_reward_archive_unavailable (404) if unavailable.
1546
1548
  - Uses that receipt’s frozen approved sheet, never today’s edited question or the mutable draft. Answer keys and tolerances are never returned. Render the question first and offer Reveal guide for learning, without a reward-claim button.
1549
+ - entry carries the same story, puzzle and vault museum fields as getTimeline.
1550
+ - Guides are returned only with withGuide: true. Asking for the guide counts as having studied the puzzle: the viewer's Vault practice of it is unrated and it can never be their paid daily rematch. Offer the guide behind an explicit Reveal step and say so. withGuide on today's rematch puzzle is refused (build_reward_rematch_open, 409) while the viewer can still answer it for pay.
1551
+ - await Twinkle.rewards.practiceCheck({ receiptId, answer?: number, answers?: [number], text?: string }) | scopes: rewards:claim
1552
+ - Returns: { mode: "live", correct, counted: true, firstAttempt, attempts, solvedAt: number | null, rated, read?: { understood: true, value }, practice: { rating, solves, rank: number | null }, vault: { rating, solvers, failers, failures } } | { mode: "live", correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", correct: false, counted: false, practice: null, vault: null, message }
1553
+ - Try a retired puzzle again (the Vault): checks an answer against the frozen sheet for a practice rating, never XP or Coins.
1554
+ - receiptId is any receipt of a retired set (from getTimeline). The answer is compared with the same tolerance as claims; the answer key is never returned. Send answer for a one-question set, answers (in order) for several, or the learner's raw text for a one-question set: text goes through the same answer reader as interpretAnswer (puzzle text for format only, same rate bucket). Unreadable text returns counted: false and uses no attempt; ask the learner to type just the number.
1555
+ - Practice rating: the first answer a player gives on a puzzle is rated once (correct = win, wrong = loss) against the puzzle's Vault rating, and both ratings move. Later answers only count attempts; a later correct answer still marks the puzzle solved (solvedAt) and opens postExplanation. Players who earned or missed the set officially, had a challenge on its rule while it was live, or studied it (opened its guide or explanations) may practise it but are not rated (rated: false). The official challenge rating is never changed by practice.
1556
+ - Pays nothing. Rate limited per learner (build_reward_throttled, 429). Errors: build_reward_archive_unavailable (404) for a receipt that is not a retired puzzle, build_reward_practice_answers_invalid (400).
1557
+ - Today's daily rematch puzzle cannot be practised by a viewer who may still answer it as the rematch (build_reward_rematch_open, 409); practising any puzzle in the Vault makes the viewer ineligible for it as a future rematch.
1558
+ - await Twinkle.rewards.getPracticeBoard() | scopes: rewards:claim
1559
+ - Returns: { mode: "live", top: [{ rank, userId, username, profilePicUrl, rating, solves }], me: { rating, solves, rank: number | null } | null, hardest: [{ receiptId, ruleId, setKey, title, vault: { rating, solvers, failers, failures } }], solved: ["ruleId:setKey"] } | { mode: "preview", top: [], me: null, hardest: [], solved: [], message }
1560
+ - Vault practice standings: top practice-rated players, the viewer's own standing, the toughest retired puzzles and which ones the viewer solved in practice.
1561
+ - top lists the 10 highest practice ratings among players with at least one rated answer; profilePicUrl is an absolute https URL or null. me.rank is null until the viewer has a rated answer. hardest is up to 5 retired puzzles with the highest Vault rating that three or more players missed on their first practice answer. solved lists the viewer's practice solves only; official solves are in getTimeline.
1562
+ - await Twinkle.rewards.getRelatedArchive({ ruleId } | { receiptId }) | scopes: rewards:claim
1563
+ - Returns: { mode: "live", entries: [<getTimeline entry>] } | { mode: "preview", entries: [], message }
1564
+ - Up to 3 retired puzzles that share an idea with today's puzzle (after answering it) or with a retired puzzle.
1565
+ - Idea tags come from each set's questions[0].guide.ideaTags (1-3 kebab-case slugs). The current release's copy of a set wins, else the frozen sheet. Ordered by shared tags, then newest; one entry per retired set (its earliest receipt).
1566
+ - With ruleId: today's set of that until-earned rule. It would be a hint, so it opens only once the viewer has a receipt for today's set or a challenge on it with a wrong attempt; otherwise build_reward_related_locked (409). With receiptId: no gate; the source puzzle is excluded.
1567
+ - Rate limited (build_reward_throttled, 429). Daily-rematch receipts are never related entries.
1568
+ - await Twinkle.rewards.listExplanations({ receiptId }) | scopes: rewards:claim
1569
+ - Returns: { mode: "live", entries: [{ id, userId, username, profilePicUrl, text, votes, myVote: 0 | 1, createdAt, updatedAt, mine, pinned }], canModerate } | { mode: "preview", entries: [], message }
1570
+ - Solvers' explanations of a retired puzzle, most helpful first.
1571
+ - Up to 50, ordered by votes then age. pinned marks the top entry when it has two or more votes. text is user content: escape it when rendering (never innerHTML). Only retired puzzles have explanations (build_reward_archive_unavailable, 404 otherwise).
1572
+ - Refused with build_reward_rematch_open (409) for today's rematch puzzle while the viewer may still answer it for pay.
1573
+ - Reading explanations counts as having studied the puzzle (unrated practice, never that viewer's paid rematch). canModerate is true for the app's owner and Twinkle admins, who may delete any explanation. Rate limited (build_reward_throttled, 429).
1574
+ - await Twinkle.rewards.postExplanation({ receiptId, text }) | scopes: rewards:claim
1575
+ - Returns: { mode: "live", entry: <listExplanations entry> } | { mode: "preview", entry: null, message }
1576
+ - Post or edit the viewer's explanation of a retired puzzle they solved.
1577
+ - Only players who solved that set officially (any receipt) or in practice may post (build_reward_explanation_not_solver, 403). 20-1200 characters after trimming (build_reward_explanation_invalid, 400). One per player per puzzle: posting again replaces the text and keeps its votes. Pays nothing; rate limited.
1578
+ - await Twinkle.rewards.voteExplanation({ id, vote: 1 | 0 }) | scopes: rewards:claim
1579
+ - Returns: { mode: "live", id, votes, myVote } | { mode: "preview", id: null, votes: 0, myVote: 0, message }
1580
+ - Mark another player's explanation helpful (1) or take that back (0).
1581
+ - One vote per player per explanation; repeating a vote changes nothing. Voting for your own explanation is refused (build_reward_explanation_own_vote, 403).
1582
+ - await Twinkle.rewards.deleteExplanation({ id }) | scopes: rewards:claim
1583
+ - Returns: { mode: "live", id, deleted: true } | { mode: "preview", id: null, deleted: false, message }
1584
+ - Delete the viewer's own explanation, or any explanation as the app's owner or a Twinkle admin.
1585
+ - Own explanations, or any explanation for the app's owner and Twinkle admins (listExplanations.canModerate); otherwise build_reward_explanation_not_owner (403). Its votes are removed with it.
1586
+ - await Twinkle.rewards.getRematch() | scopes: rewards:claim
1587
+ - Returns: { mode: "live", dayKey, ruleId, entry: <getTimeline entry with story, puzzle, vault>, prompt, hint?, questionCount, eligible, reason?: "already-attempted" | "already-practised" | "already-studied" | "already-earned", attempts, earned: { id, challengeId, ruleId, setKey, dayKey, xp, coins, attempt, createdAt } | null, reward: { xp, coins }, retryReward: { xp, coins }, paidAttempts } | { mode: "live", entry: null, prompt: null, eligible: false, reason: "no-puzzle", ... } | { mode: "preview", entry: null, prompt: null, eligible: false, attempts: 0, earned: null, message }
1588
+ - Today's daily rematch: one retired puzzle, whether the viewer may answer it for XP/Coins, and what it pays.
1589
+ - Needs an approved archive-rematch rule (build_reward_rematch_unavailable, 404). The server picks one retired until-earned puzzle per site day for everyone. Only the prompt and hint are returned (never the answer or guide). eligible is false with a reason for players who met the puzzle officially, practised it in the Vault, or already earned today's rematch (earned holds that receipt). Ineligible players may practise it in the Vault (no pay) once they cannot answer it for pay.
1590
+ - attempts counts today's wrong rematch answers; reward is the first-try amount and retryReward the amount for paid retries (up to paidAttempts); later correct answers count as solved but pay nothing.
1591
+ - reason 'already-studied' means the viewer opened that puzzle's guide or explanations before. reason 'no-puzzle' (entry null) when no retired puzzle qualifies today or today's stored pick can no longer be read. The day's first call stores the day's pick, chosen among the strongest candidates with a server-only seed so it cannot be predicted.
1592
+ - await Twinkle.rewards.claimRematch({ answer?: number, answers?: [number], text?: string }) | scopes: rewards:claim
1593
+ - Returns: { mode: "live", awarded: false, correct: false, counted: true, challengeId, attempts, attemptsRemaining, nextReward: { xp, coins }, read?, practice, vault } | { mode: "live", awarded: true, duplicate: false, correct: true, counted: true, read?, receipt: { id, challengeId, ruleId, xp, coins, attempt, firstTry, createdAt }, balances: { xp, coins }, practice, vault } | { mode: "live", awarded: true, duplicate: true, receipt, balances } | { mode: "live", awarded: false, correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", awarded: false, counted: false, message }
1594
+ - Answer today's daily rematch. A correct answer writes a real receipt (ledger, budgets, Home feed story, leaderboard).
1595
+ - Same answer check and tolerance as claim; text is read by the same answer reader as interpretAnswer (unreadable text uses no try). First try pays the rule's full amounts, paid retries the retry share, later correct answers pay nothing. One rematch receipt per player per site day; a repeat call after it returns the same receipt (duplicate: true).
1596
+ - Does not use the daily bounty count (userDailyClaims) but counts toward userDailyXP/userDailyCoins (build_reward_budget errors as for claim). Each answer also counts as the player's Vault answer for that puzzle (practice rating, Vault stats). Errors: build_reward_rematch_ineligible (409), build_reward_rematch_unavailable (404/409), build_reward_rematch_day_changed (409, reload getRematch), build_reward_throttled (429; wait between wrong answers).
1547
1597
  - await Twinkle.rewards.getBankStatus({ ruleId }) | scopes: rewards:claim
1548
1598
  - Returns: { mode, ruleId, dayKey, startStep, steps: [{ step, reward: { xp, coins } }], topics: [{ key, title, step, questionsAtStep }], today: { xp, coins, claims }, budgets: { userDailyXP, userDailyCoins } }
1549
1599
  - Read a generated-quiz rule's ladder: what each step pays, the learner's step on every topic, and today's earnings.
@@ -1160,6 +1160,27 @@ lumine admin review approve rewards:70 --config rules.json --json
1160
1160
  lumine admin review propose rewards:70 --dir /private/tmp/reward-review-70 --config rules.json --json
1161
1161
  ```
1162
1162
 
1163
+ ### Management approvals and the teacher audit (any time; also a full-daily-review duty; added 2026-10-04)
1164
+
1165
+ Birthdate, teacher-signup (`mentor`) and meetup-achievement requests, each with its evidence: the account, the request, who invited them, accounts linked by verified email, exact device or a manual identity bucket (with each one's age and role), activity, and flags. These are Mikey-only and need no daily run.
1166
+
1167
+ ```bash
1168
+ lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup]
1169
+ lumine admin approvals show <id>
1170
+ lumine admin approvals approve|reject <id> [--reason <text>]
1171
+ lumine admin teachers audit [--limit 1-100]
1172
+ lumine admin teachers revoke <userId> --reason <text>
1173
+ ```
1174
+
1175
+ - Flags are pointers, not verdicts: a school device can link strangers. Judge each case, recommend, and act only on Mikey's decision.
1176
+ - `teachers audit` lists approved teachers, riskiest first.
1177
+ - `teachers revoke` removes a teacher status cleanly:
1178
+ - the mentor achievement, plus the teenager/adult achievements the teacher approval gave unless a separately approved birthdate supports them;
1179
+ - the teacher title and the public "unlocked" posts;
1180
+ - the moderation powers that came with the level, by recounting points.
1181
+ - Revoke marks the request rejected, keeps who, when and why, and refuses sage/founder holders.
1182
+ - The daily-run procedure is in `agent-guides/lumine-admin.md`.
1183
+
1163
1184
  ### Build XP/Coin reward approvals (any time; also a full-daily-review duty)
1164
1185
 
1165
1186
  The creator's Lumine designs the rewards and writes them into the app. The