@stage5/lumine 0.2.95 → 0.2.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,15 @@ Mikey reviews with `lumine admin storage list`, `show <user>`,
61
61
  or grants directly with `lumine admin storage grant <user> --size <size>`.
62
62
  Approvals are capped at 2 GB and never lower a creator below the default.
63
63
 
64
+ Bridge Builder meetup quest crews (the website's /achievements/bridge-builder
65
+ page) are reviewed with `lumine admin meetup list [--status review|active|completed|all]`,
66
+ `show <crewId>` (includes a one-hour link to the proof video),
67
+ `approve-plan <crewId> [--note <text>]`, `send-back <crewId> --note <text>`
68
+ (sends back whatever is waiting: the video, else the plan) and
69
+ `approve <crewId> --attended <userId,userId,...>`, which unlocks the
70
+ achievement for every member ticked. At least 3 attendees from 3 different
71
+ branches are required.
72
+
64
73
  For team projects, Lumine mirrors the website workspace flow: choosing or
65
74
  pulling the owner's main project creates or reuses your contribution branch and
66
75
  checks out that branch locally. Saves go to your branch, so the project owner
package/lib/admin.js CHANGED
@@ -724,7 +724,8 @@ export async function adminCommand(options) {
724
724
  : {}),
725
725
  ...(requestId ? { "x-lumine-idempotency-key": requestId } : {}),
726
726
  },
727
- timeoutMs: options.timeoutMs,
727
+ // Long operations (reward-bank seed) carry their own timeout.
728
+ timeoutMs: operation.timeoutMs || options.timeoutMs,
728
729
  signal,
729
730
  });
730
731
  const transformResult = (rawResult) =>
@@ -1924,6 +1925,148 @@ export function parseAdminOperation(options) {
1924
1925
  );
1925
1926
  }
1926
1927
 
1928
+ if (MEETUP_NAMESPACES.has(namespace)) {
1929
+ // Bridge Builder meetup quest crews (achievement type 'meetup'): the same
1930
+ // reviews as the admin controls on the website quest page. Mikey's own
1931
+ // decision, any time, never in a daily run.
1932
+ const usage =
1933
+ "Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
1934
+ const note = String(options.note || "").trim();
1935
+ if (note.length > 1000) {
1936
+ throw cliValidationError("--note must be at most 1000 characters.");
1937
+ }
1938
+ if (action === "story" || action === "stories") {
1939
+ // A crew's Bridge Builder story page: Mikey's final approval (the
1940
+ // admin bar on the story page does the same). Publishing copies only
1941
+ // the photos/clips every tagged member's parent allowed.
1942
+ const storyUsage =
1943
+ "Usage: lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text>.";
1944
+ const storyAction = target || "list";
1945
+ if (storyAction === "list") {
1946
+ return readOperation("meetup.story.list", "/cli/admin/meetup-quest/stories", {
1947
+ requiresRun: false,
1948
+ });
1949
+ }
1950
+ if (!["show", "approve", "send-back", "unpublish"].includes(storyAction)) {
1951
+ throw cliValidationError(storyUsage);
1952
+ }
1953
+ const crewId = parseRequiredInteger(extra, "Crew ID", 1);
1954
+ if (storyAction === "show") {
1955
+ return readOperation(
1956
+ "meetup.story.show",
1957
+ `/cli/admin/meetup-quest/stories/by-crew/${crewId}`,
1958
+ { requiresRun: false },
1959
+ );
1960
+ }
1961
+ if (storyAction !== "approve" && !note) {
1962
+ throw cliValidationError(
1963
+ `lumine admin meetup story ${storyAction} <crewId> needs --note <text> (the crew sees it).`,
1964
+ );
1965
+ }
1966
+ return writeOperation(
1967
+ `meetup.story.${storyAction}`,
1968
+ "POST",
1969
+ `/cli/admin/meetup-quest/stories/by-crew/${crewId}/${storyAction}`,
1970
+ storyAction === "approve"
1971
+ ? { note, announce: !options.adminNoAnnounce }
1972
+ : { note },
1973
+ { requiresRun: false },
1974
+ );
1975
+ }
1976
+ if (!action || action === "list") {
1977
+ return readOperation(
1978
+ "meetup.list",
1979
+ withQuery("/cli/admin/meetup-quest/crews", {
1980
+ status: parseChoice(
1981
+ options.adminStatus || "review",
1982
+ "--status",
1983
+ ["review", "active", "completed", "all"],
1984
+ ),
1985
+ }),
1986
+ { requiresRun: false },
1987
+ );
1988
+ }
1989
+ if (action === "show" || action === "get") {
1990
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
1991
+ return readOperation(
1992
+ "meetup.show",
1993
+ `/cli/admin/meetup-quest/crews/${crewId}`,
1994
+ { requiresRun: false },
1995
+ );
1996
+ }
1997
+ if (action === "approve-plan") {
1998
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
1999
+ return writeOperation(
2000
+ "meetup.approve-plan",
2001
+ "POST",
2002
+ `/cli/admin/meetup-quest/crews/${crewId}/approve-plan`,
2003
+ { note },
2004
+ { requiresRun: false },
2005
+ );
2006
+ }
2007
+ if (action === "send-back") {
2008
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2009
+ if (!note) {
2010
+ throw cliValidationError(
2011
+ "lumine admin meetup send-back <crewId> needs --note <what to change> (the crew sees it).",
2012
+ );
2013
+ }
2014
+ return writeOperation(
2015
+ "meetup.send-back",
2016
+ "POST",
2017
+ `/cli/admin/meetup-quest/crews/${crewId}/send-back`,
2018
+ { note },
2019
+ { requiresRun: false },
2020
+ );
2021
+ }
2022
+ if (action === "approve") {
2023
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2024
+ const attendedUserIds = String(options.adminAttended || "")
2025
+ .split(",")
2026
+ .map((part) => part.trim())
2027
+ .filter(Boolean)
2028
+ .map((part) => parseRequiredInteger(part, "--attended user ID", 1));
2029
+ if (!attendedUserIds.length) {
2030
+ throw cliValidationError(
2031
+ "lumine admin meetup approve <crewId> needs --attended <userId,userId,...> (the members who showed up in the video).",
2032
+ );
2033
+ }
2034
+ return writeOperation(
2035
+ "meetup.approve",
2036
+ "POST",
2037
+ `/cli/admin/meetup-quest/crews/${crewId}/approve`,
2038
+ { attendedUserIds: [...new Set(attendedUserIds)], note },
2039
+ { requiresRun: false },
2040
+ );
2041
+ }
2042
+ if (action === "slot") {
2043
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2044
+ if (options.adminConfirm === "") {
2045
+ throw cliValidationError(
2046
+ "lumine admin meetup slot <crewId> needs --confirm <slotIndex> (0 is the first offered slot; see `show`).",
2047
+ );
2048
+ }
2049
+ return writeOperation(
2050
+ "meetup.slot",
2051
+ "POST",
2052
+ `/cli/admin/meetup-quest/crews/${crewId}/slot`,
2053
+ { slotIndex: parseRequiredInteger(options.adminConfirm, "--confirm", 0, 9) },
2054
+ { requiresRun: false },
2055
+ );
2056
+ }
2057
+ if (action === "resend-email") {
2058
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2059
+ return writeOperation(
2060
+ "meetup.resend-email",
2061
+ "POST",
2062
+ `/cli/admin/meetup-quest/crews/${crewId}/resend-email`,
2063
+ {},
2064
+ { requiresRun: false },
2065
+ );
2066
+ }
2067
+ throw cliValidationError(usage);
2068
+ }
2069
+
1927
2070
  if (STORAGE_LIMIT_NAMESPACES.has(namespace)) {
1928
2071
  // Lumine file storage (Twinkle.files) unlocks: per-creator approvals past
1929
2072
  // the default quota. Mikey's own decision, any time, never in a daily run.
@@ -2133,6 +2276,45 @@ export function parseAdminOperation(options) {
2133
2276
  );
2134
2277
  }
2135
2278
 
2279
+ if (namespace === "reward-bank" || namespace === "reward-banks") {
2280
+ // Seed questions for an approved app's reward question bank (generated-quiz
2281
+ // rules). coverage is read-only; seed writes ONE missing question per call
2282
+ // (lowest step first) as the daily run's Zero/Ciel identity and takes as
2283
+ // long as two GPT-6 Sol calls, so it gets its own timeout. The daily
2284
+ // website management run calls it in a bounded loop (Mikey, 2026-09-28).
2285
+ const usage =
2286
+ "Usage: lumine admin reward-bank coverage <buildId> [--max-step 3] | seed <buildId> [--max-step 3].";
2287
+ const buildId = parseRequiredInteger(
2288
+ target || options.adminBuild,
2289
+ "Build ID",
2290
+ 1,
2291
+ );
2292
+ const maxStep = options.adminMaxStep
2293
+ ? parseRequiredInteger(options.adminMaxStep, "--max-step", 1)
2294
+ : 3;
2295
+ if (maxStep > 10) throw cliValidationError("--max-step must be at most 10.");
2296
+ if (action === "coverage" || action === "status") {
2297
+ return readOperation(
2298
+ "reward-bank.coverage",
2299
+ withQuery("/cli/admin/reward-bank/coverage", {
2300
+ buildId: String(buildId),
2301
+ maxStep: String(maxStep),
2302
+ }),
2303
+ { requiresRun: false },
2304
+ );
2305
+ }
2306
+ if (action === "seed") {
2307
+ return writeOperation(
2308
+ "reward-bank.seed",
2309
+ "POST",
2310
+ "/cli/admin/reward-bank/seed",
2311
+ { buildId, maxStep },
2312
+ { timeoutMs: 240000 },
2313
+ );
2314
+ }
2315
+ throw cliValidationError(usage);
2316
+ }
2317
+
2136
2318
  if (namespace === "reward-activity" || namespace === "reward-telemetry") {
2137
2319
  // Read-only claim telemetry for the daily run: no run lease, no mutation.
2138
2320
  const date = options.adminDate
@@ -4684,6 +4866,167 @@ function printCardCraftReviewResult({ operation, data }) {
4684
4866
  );
4685
4867
  }
4686
4868
 
4869
+ const MEETUP_NAMESPACES = new Set([
4870
+ "meetup",
4871
+ "meetups",
4872
+ "meetup-quest",
4873
+ "bridge-builder",
4874
+ ]);
4875
+
4876
+ function formatMeetupMembers(members) {
4877
+ return (members || [])
4878
+ .map(
4879
+ (member) =>
4880
+ `${member.username || `user ${member.userId}`} (${member.userId}, ${member.branch || "no branch"}${member.parentOk === undefined ? "" : member.parentOk ? ", parent OK" : ", parent not yet"}${member.attended ? ", attended" : ""})`,
4881
+ )
4882
+ .join("; ");
4883
+ }
4884
+
4885
+ function printMeetupCrew(crew) {
4886
+ const progress = crew.progress || {};
4887
+ console.log(
4888
+ `Crew #${crew.crewId}${crew.name ? ` "${crew.name}"` : ""} · ${crew.status}${crew.isOpen === false ? " · invite only" : ""} · step: ${progress.currentStep || "done"}${progress.blocking ? ` · ${progress.blocking}` : ""}`,
4889
+ );
4890
+ if (crew.about) console.log(` About: ${crew.about}`);
4891
+ for (const step of progress.steps || []) {
4892
+ console.log(` ${step.number}. ${step.label} [${step.state}] ${step.detail}`);
4893
+ }
4894
+ console.log(` Members: ${formatMeetupMembers(crew.members)}`);
4895
+ if (Array.isArray(crew.invites) && crew.invites.length) {
4896
+ console.log(
4897
+ ` Pending invites: ${crew.invites.map((invite) => `${invite.username} (${invite.userId}, invite ${invite.inviteId})`).join("; ")}`,
4898
+ );
4899
+ }
4900
+ if (crew.adult?.kind) {
4901
+ console.log(
4902
+ ` Adult coming: ${crew.adult.kind === "teacher" ? "Twinkle teacher" : "parent"}: ${crew.adult.name}`,
4903
+ );
4904
+ }
4905
+ const plan = crew.plan || {};
4906
+ if (plan.status && plan.status !== "none") {
4907
+ console.log(
4908
+ ` Plan (${plan.status}): ${plan.date} in ${plan.area}: ${plan.activity}${plan.note ? ` · note: ${plan.note}` : ""}`,
4909
+ );
4910
+ }
4911
+ if (crew.planApprovedBy?.username) {
4912
+ console.log(` Plan approved by ${crew.planApprovedBy.username}`);
4913
+ }
4914
+ const venue = crew.venue || {};
4915
+ if (venue.status && venue.status !== "none") {
4916
+ const slots = (venue.slots || []).map(
4917
+ (slot, index) => `[${index}] ${slot.date} ${slot.start}-${slot.end}`,
4918
+ );
4919
+ console.log(
4920
+ ` Venue (${venue.status}): ${venue.room ? `${venue.branch} · ${venue.room}` : "no classroom (meets elsewhere with the grown-up)"}${slots.length ? ` · slots ${slots.join(", ")}` : ""}${venue.confirmedSlot ? ` · SET: ${venue.confirmedSlot.date} ${venue.confirmedSlot.start}-${venue.confirmedSlot.end}` : ""}`,
4921
+ );
4922
+ }
4923
+ const video = crew.video || {};
4924
+ if (video.status && video.status !== "none") {
4925
+ console.log(
4926
+ ` Video (${video.status}): ${video.fileName}${video.note ? ` · note: ${video.note}` : ""}`,
4927
+ );
4928
+ if (video.url) console.log(` Watch (link expires in 1 hour): ${video.url}`);
4929
+ }
4930
+ }
4931
+
4932
+ function printMeetupResult({ operation, data, result }) {
4933
+ if (operation.name === "meetup.story.list") {
4934
+ const stories = Array.isArray(data.stories) ? data.stories : [];
4935
+ console.log(`${stories.length} Bridge Builder stor${stories.length === 1 ? "y" : "ies"} waiting for approval:`);
4936
+ for (const story of stories) {
4937
+ console.log(
4938
+ ` crew #${story.crewId} · "${story.title}" · ${story.crewName} (${(story.people || []).join(", ")}) · ${story.itemsPublic} public / ${story.itemsPrivate} private`,
4939
+ );
4940
+ }
4941
+ if (stories.length) console.log("Approve: lumine admin meetup story approve <crewId> (or send-back <crewId> --note).");
4942
+ return;
4943
+ }
4944
+ if (operation.name === "meetup.story.show") {
4945
+ const story = data.story || {};
4946
+ const items = [...(story.photos || []), ...(story.clips || [])];
4947
+ console.log(`Story #${story.storyId} (crew #${story.crewId}) · ${story.status} · "${story.title}"`);
4948
+ console.log(` People: ${(story.people || []).map((p) => `${p.username} (${p.branch})`).join(", ")}`);
4949
+ for (const consent of story.review?.consents || []) {
4950
+ const person = (story.people || []).find((p) => p.userId === consent.userId);
4951
+ console.log(` Parent of ${person?.username || consent.userId}: ${consent.state}`);
4952
+ }
4953
+ for (const item of items) {
4954
+ const gate = item.gate || {};
4955
+ console.log(
4956
+ ` ${item.kind} #${item.id}: ${gate.publishable ? "PUBLIC" : `private (${gate.reason || "live"})`}${item.caption ? ` · ${item.caption}` : ""}${item.url ? ` · ${item.url}` : ""}`,
4957
+ );
4958
+ }
4959
+ if ((story.review?.nameWarnings || []).length) {
4960
+ console.log(` Possible real names: ${story.review.nameWarnings.join(", ")}`);
4961
+ }
4962
+ if (story.body) console.log(`\n${story.body}\n`);
4963
+ return;
4964
+ }
4965
+ if (operation.name.startsWith("meetup.story.")) {
4966
+ const r = data.result || {};
4967
+ if (operation.name === "meetup.story.approve") {
4968
+ console.log(
4969
+ `Published story #${r.storyId} (crew #${r.crewId}): ${(r.publicMediaIds || []).length} public, ${(r.privateMediaIds || []).length} private.${r.announcementSubjectId ? ` Announcement: subject #${r.announcementSubjectId}.` : ""}${(r.failed || []).length ? ` FAILED to process: ${r.failed.map((f) => `#${f.mediaId} ${f.error}`).join("; ")}.` : ""}`,
4970
+ );
4971
+ } else {
4972
+ console.log(`${operation.name === "meetup.story.unpublish" ? "Unpublished" : "Sent back"} story #${r.storyId} (crew #${r.crewId}).`);
4973
+ }
4974
+ return;
4975
+ }
4976
+ if (operation.name === "meetup.list") {
4977
+ const crews = Array.isArray(data.crews) ? data.crews : [];
4978
+ console.log(`${crews.length} meetup crew(s) (${data.filter || "review"}):`);
4979
+ for (const crew of crews) {
4980
+ console.log(
4981
+ ` #${crew.crewId} · ${crew.status}${crew.waitingFor ? ` · waiting: ${crew.waitingFor}` : ""} · founder ${crew.founderUsername || "?"} · ${formatMeetupMembers(crew.members)}${crew.blocking ? ` · ${crew.blocking}` : ""}`,
4982
+ );
4983
+ }
4984
+ if (crews.some((crew) => crew.waitingFor)) {
4985
+ console.log(
4986
+ "Review: lumine admin meetup show <crewId>, then approve-plan | send-back --note | approve --attended <ids>.",
4987
+ );
4988
+ }
4989
+ return;
4990
+ }
4991
+ if (operation.name === "meetup.show") {
4992
+ printMeetupCrew(data.crew || {});
4993
+ return;
4994
+ }
4995
+ if (operation.name === "meetup.slot") {
4996
+ console.log(
4997
+ `Slot set for crew #${data.crew?.crewId}: ${data.slot?.date} ${data.slot?.start}-${data.slot?.end}.`,
4998
+ );
4999
+ if (data.crew) printMeetupCrew(data.crew);
5000
+ return;
5001
+ }
5002
+ if (operation.name === "meetup.resend-email") {
5003
+ const email = data.email || {};
5004
+ console.log(
5005
+ email.sent
5006
+ ? `Coordinator ${email.kind} email ${email.mode === "sent" ? "sent" : `built (${email.mode})`} to ${email.to}.`
5007
+ : `No email sent: ${email.reason || "unknown"}.`,
5008
+ );
5009
+ return;
5010
+ }
5011
+ const stage = data.result?.stage === "video" ? "video" : "plan";
5012
+ if (operation.name === "meetup.approve-plan") {
5013
+ console.log(
5014
+ result?.changed === false
5015
+ ? `Crew #${data.result?.crewId}'s plan was already approved.`
5016
+ : `Approved crew #${data.result?.crewId}'s plan. They can meet up and film it now.`,
5017
+ );
5018
+ } else if (operation.name === "meetup.send-back") {
5019
+ console.log(`Sent crew #${data.result?.crewId}'s ${stage} back with your note.`);
5020
+ } else if (operation.name === "meetup.approve") {
5021
+ const unlocked = data.unlocks?.unlockedUserIds || [];
5022
+ const failed = data.unlocks?.failed || [];
5023
+ console.log(
5024
+ `${result?.changed === false ? "Already approved" : "Approved"} crew #${data.result?.crewId}: unlocked for user(s) ${unlocked.join(", ") || "none"}.${failed.length ? ` Unlock FAILED for ${failed.join(", ")}; run the same command again.` : ""}`,
5025
+ );
5026
+ }
5027
+ if (data.crew) printMeetupCrew(data.crew);
5028
+ }
5029
+
4687
5030
  const STORAGE_LIMIT_NAMESPACES = new Set([
4688
5031
  "storage",
4689
5032
  "storage-limit",
@@ -4875,6 +5218,27 @@ function printAdminResult({ operation, result }) {
4875
5218
  printRewardActivity(data);
4876
5219
  return;
4877
5220
  }
5221
+ if (operation.name.startsWith("reward-bank.")) {
5222
+ if (operation.name === "reward-bank.seed") {
5223
+ console.log(
5224
+ data.seeded
5225
+ ? `Seeded question #${data.seeded.questionId}: ${data.seeded.ruleId} · ${data.seeded.topicKey} · step ${data.seeded.step}.`
5226
+ : "Every slot is already seeded.",
5227
+ );
5228
+ }
5229
+ const coverage = data.coverage || data;
5230
+ console.log(
5231
+ `Coverage: ${Number(coverage.filled || 0)} / ${Number(coverage.total || 0)} topic-step slots have a question${data.maxStep ? ` (steps 1-${data.maxStep})` : ""}.`,
5232
+ );
5233
+ if (data.missingByStep && Object.keys(data.missingByStep).length) {
5234
+ console.log(
5235
+ `Missing by step: ${Object.entries(data.missingByStep)
5236
+ .map(([step, count]) => `step ${step}: ${count}`)
5237
+ .join(", ")}.`,
5238
+ );
5239
+ }
5240
+ return;
5241
+ }
4878
5242
  if (operation.name.startsWith("review.")) {
4879
5243
  printReviewRequestResult({ operation, data, result });
4880
5244
  return;
@@ -4891,6 +5255,10 @@ function printAdminResult({ operation, result }) {
4891
5255
  printStorageLimitResult({ operation, data, result });
4892
5256
  return;
4893
5257
  }
5258
+ if (operation.name.startsWith("meetup.")) {
5259
+ printMeetupResult({ operation, data, result });
5260
+ return;
5261
+ }
4894
5262
  if (operation.name === "runtime.evidence") {
4895
5263
  const evidence = data.evidence || {};
4896
5264
  console.log(
package/lib/commands.js CHANGED
@@ -2355,6 +2355,7 @@ export function parseArgs(args) {
2355
2355
  const raw = {};
2356
2356
  const positional = [];
2357
2357
  const booleanFlags = new Set([
2358
+ "noAnnounce",
2358
2359
  "noLeaseKeeper",
2359
2360
  "noOpen",
2360
2361
  "open",
@@ -2604,12 +2605,16 @@ export function parseArgs(args) {
2604
2605
  twinkles: raw.twinkles ? String(raw.twinkles) : "",
2605
2606
  adminReason: raw.reason ? String(raw.reason) : "",
2606
2607
  storageSize: raw.size ? String(raw.size) : "",
2608
+ adminAttended: raw.attended ? String(raw.attended) : "",
2609
+ adminConfirm: raw.confirm === undefined ? "" : String(raw.confirm),
2610
+ adminNoAnnounce: Boolean(raw.noAnnounce),
2607
2611
  adminContextLimit: raw.limit === undefined ? 20 : Number(raw.limit),
2608
2612
  adminRun: raw.run ? String(raw.run) : "",
2609
2613
  adminTarget: raw.target ? String(raw.target) : "",
2610
2614
  adminActions: raw.actions ? String(raw.actions) : "",
2611
2615
  adminDate: raw.date ? String(raw.date) : "",
2612
2616
  adminDays: raw.days ? String(raw.days) : "",
2617
+ adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
2613
2618
  adminBuild: raw.build ? String(raw.build) : "",
2614
2619
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2615
2620
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
@@ -3008,6 +3013,8 @@ export function printHelp() {
3008
3013
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3009
3014
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3010
3015
  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>]
3016
+ lumine admin meetup list [--status review|active|completed|all] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3017
+ lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
3011
3018
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3012
3019
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
3013
3020
  lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
@@ -3050,6 +3057,8 @@ export function printHelp() {
3050
3057
  lumine admin post skip <target> [--type comment|aiStory|dailyReflection] [--reason <text>] [--json]
3051
3058
  lumine admin post skip-batch --target-file <json-or-lines> [--reason <text>] [--checkpoint <file> [--resume]] [--json]
3052
3059
  lumine admin post reward <target> [--type subject|comment|aiStory|dailyReflection] --twinkles 3 [--json]
3060
+ lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3061
+ 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)
3053
3062
  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]
3054
3063
  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]
3055
3064
  lumine admin comment post --draft-id <id> [--json]
package/lib/constants.js CHANGED
@@ -48,6 +48,8 @@ export const BUILD_VENDOR_THREE_MODULE_IMPORT = `${BUILD_VENDOR_THREE_PREFIX}thr
48
48
  export const BUILD_VENDOR_THREE_WEBGPU_MODULE_IMPORT = `${BUILD_VENDOR_THREE_PREFIX}three.webgpu.min.js`;
49
49
  export const BUILD_VENDOR_THREE_TSL_MODULE_IMPORT = `${BUILD_VENDOR_THREE_PREFIX}three.tsl.min.js`;
50
50
  export const BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX = `${BUILD_VENDOR_THREE_PREFIX}addons/`;
51
+ export const BUILD_VENDOR_KATEX_VERSION = "0.18.9";
52
+ export const BUILD_VENDOR_KATEX_PREFIX = `/build/vendor/katex/${BUILD_VENDOR_KATEX_VERSION}/`;
51
53
  export const PROJECT_METADATA_DIR = ".twinkle";
52
54
  export const PROJECT_METADATA_FILE = "lumine-project.json";
53
55
  export const ASSETS_METADATA_FILE = "assets.json";
@@ -157,6 +159,8 @@ export const LUMINE_THREE_VENDOR_GUIDANCE = `- For Three.js, use the first-party
157
159
  - Keep one Three.js version throughout a project. If a project using ${BUILD_VENDOR_THREE_LEGACY_VERSION} needs current addons, migrate every Three.js import to ${BUILD_VENDOR_THREE_VERSION} in one coherent change; never mix the legacy core with current addons.
158
160
  - This vendor surface covers supported official Three.js modules, not arbitrary third-party Three.js packages. Do not paste library source into project files or use npm/CDN copies. Loader runtime assets are served too: point decoder/transcoder paths at the addon prefix (example: dracoLoader.setDecoderPath('${BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX}libs/draco/');).
159
161
  - Size both WebGLRenderer and EffectComposer from Twinkle.preview layout dimensions, coalesce resize work, and render with composer.render() when a composer owns the pass chain.`;
162
+ export const LUMINE_MATH_VENDOR_GUIDANCE = `- For math formulas, Twinkle serves KaTeX ${BUILD_VENDOR_KATEX_VERSION}: link <link rel="stylesheet" href="${BUILD_VENDOR_KATEX_PREFIX}katex.min.css"> (its fonts load from the same place), then import katex from '${BUILD_VENDOR_KATEX_PREFIX}katex.js' and call katex.render(tex, element, { displayMode, throwOnError: false }), or import renderMathInElement from '${BUILD_VENDOR_KATEX_PREFIX}contrib/auto-render.js' to typeset $...$ and $$...$$ inside existing text.
163
+ - Use it when an app shows fractions, roots, exponents, integrals, matrices or other formulas that plain text renders poorly; keep plain text for simple arithmetic. Do not paste KaTeX source into project files or use npm/CDN copies.`;
160
164
  export const LUMINE_AGENT_INSTRUCTIONS = `${LUMINE_AGENT_INSTRUCTIONS_MARKER}
161
165
  # Lumine Project Agent Guide
162
166
 
@@ -177,9 +181,12 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
177
181
  ## Workflow
178
182
 
179
183
  - When planning a new app or a substantial improvement, read the AI decision design
180
- guidance in ${SDK_REFERENCE_FILE}. Consider whether JEV through Twinkle.ai.decide
181
- would improve the requested experience, and choose ordinary code or a text model
182
- when those fit better. Make this judgment from the app's needs; do not wait for
184
+ guidance in ${SDK_REFERENCE_FILE} and decide explicitly whether JEV through
185
+ Twinkle.ai.decide improves the requested experience. JEV is free for viewers (it
186
+ never uses their AI Energy). Strong fits: an AI opponent or boss that reads the
187
+ player's habits and picks a counter-plan, NPCs that understand typed requests,
188
+ classifying submissions, and adaptive difficulty. Choose ordinary code or a text
189
+ model when those fit better. Make this judgment from the app's needs; do not wait for
183
190
  the creator to know the technology or add it automatically to every app.
184
191
  - Edit only project files in this workspace.
185
192
  - Keep /index.html or /index.htm as the entry file.
@@ -333,6 +340,7 @@ ${LUMINE_MOBILE_SELECTION_GUIDANCE}
333
340
  - 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.
334
341
  - 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.
335
342
  ${LUMINE_THREE_VENDOR_GUIDANCE}
343
+ ${LUMINE_MATH_VENDOR_GUIDANCE}
336
344
  - Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
337
345
  - Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
338
346
  ${LUMINE_WORLD_UPDATE_GUIDANCE}
@@ -350,6 +358,8 @@ ${LUMINE_WORLD_UPDATE_GUIDANCE}
350
358
 
351
359
  Report the changed files, any SDK methods used, the lumine save result, the
352
360
  build or branch id, and whether the result is published or unpublished changes.
361
+ Include one line on JEV: "JEV: used for <decision>" or "JEV: not used because
362
+ <reason>".
353
363
  `;
354
364
  export const LUMINE_REFERENCE_INSTRUCTIONS = `${LUMINE_REFERENCE_INSTRUCTIONS_MARKER}
355
365
  # Lumine Reference Guide
package/lib/rewards.js CHANGED
@@ -69,7 +69,9 @@ function printDeclaration(result, { prefix = "" } = {}) {
69
69
  const what =
70
70
  rule.verifier === "completion"
71
71
  ? `completion · at least ${rule.minSeconds || 0}s`
72
- : `quiz · ${rule.questionSets} set(s)${rule.progression ? ` · ${rule.progression}` : ""}${rule.standingQuestions ? ` · ${rule.standingQuestions} standing` : ""}`;
72
+ : rule.verifier === "generated-quiz"
73
+ ? "question bank · server-written, double-checked questions on a step ladder"
74
+ : `quiz · ${rule.questionSets} set(s)${rule.progression ? ` · ${rule.progression}` : ""}${rule.standingQuestions ? ` · ${rule.standingQuestions} standing` : ""}`;
73
75
  console.log(
74
76
  `${prefix} ${rule.id}: ${rule.title} · ${rule.xp} XP + ${rule.coins} Coins · ${what}`,
75
77
  );
@@ -239,8 +241,12 @@ function printRewardsHelp() {
239
241
  rewards.json (project root) declares the economy the reviewer approves:
240
242
  { "userDailyXP", "userDailyCoins", "userDailyClaims"?,
241
243
  "rules": [{ "id", "title", "howTo"? (how to earn it, in the player's words), "category"? (section header, up to 40 characters, e.g. "Bring friends"),
242
- "series"? (lowercase key for repeat rules like friend-1/2/3; they must share category, verifier and amounts), "xp", "coins", "verifier": "numeric-quiz" | "completion",
243
- "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
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",
245
+ "maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz),
246
+ "bank"? (generated-quiz): { "steps": [percent, ...] rising to 100, "startStep"?, "instructions", "topics": [{ "key", "title", "description" }] } }] }
244
247
  The Earn page shows rules under their category headers and each series as one row ("up to 3 a day").
245
- Questions and answer keys never go in project files; they belong in the sheet.`);
248
+ Questions and answer keys never go in project files; they belong in the sheet.
249
+ generated-quiz rules need no sheet: the server writes each question for a topic at the learner's step
250
+ (GPT-6 Sol, billed to that learner's AI Energy), checks it with a second independent solve, and pays
251
+ the step's percent of xp/coins. The reviewer approves the ladder, topics and instructions.`);
246
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.95",
3
+ "version": "0.2.97",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.61.0
3
+ Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-09-27T17:21:08.257Z
5
+ Generated: 2026-09-28T08:22:20.133Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -33,13 +33,13 @@ Generated: 2026-09-27T17:21:08.257Z
33
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.
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
- - 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', 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.
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 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.
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
 
41
41
  ## AI decision design
42
- - When planning a new app or an improvement, consider whether model-based judgments would materially improve the requested experience. Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state. Potential uses include interpreting a player's request to an NPC, choosing among legal game actions, classifying user content, ranking supplied candidates, and adapting an activity to evidence about the learner. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
42
+ - Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state, and it is free for viewers: decisions are covered by Twinkle and never use their AI Energy. Whenever an app makes a judgment that ordinary code can only approximate, consider JEV first. Proven patterns: an opponent or boss coach that reads the player's habits (favourite lane, repeated trick, cheese move) and picks a counter-plan from a fixed list, as in Red Square's boss and Open Football's opponent coach; NPCs that understand what a player types and choose an action; classifying or scoring user submissions; ranking supplied candidates; and picking the next exercise or difficulty from evidence about the learner. Any app with an AI opponent, NPC, or adaptive difficulty should decide explicitly whether JEV improves it. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
43
43
  - Choose the mechanism by the work: use ordinary code for known rules, arithmetic, physics, legal moves, storage, and permissions; use ai.decide for contextual choices, yes/no probabilities, and scores; use ai.chat or ai.generateObject for generated dialogue, explanations, open-ended content, and multi-step reasoning. JEV does not generate text, browse the web, inspect images, or supply missing app data. Combine these tools when it benefits the actual request, without silently turning every decision into a more expensive text-model call.
44
44
  - Before adopting JEV, identify the concrete decision, the context available at that moment, the allowed outcomes, and why the added latency is worthwhile. Use your model judgment to make that choice. Do not substitute regex or keyword matching for understanding human-language intent. Explain the user-visible benefit simply; the creator does not need to select a provider, enter an API key, or design question schemas.
45
45
  - Send related independent questions about the same state together in one ai.decide request (up to 32). Write complete instructions and distinct option or level descriptions. Include an other/uncertain option when the choices might not cover the input. A second call is useful only when it needs newly obtained context or choices that depend on the first result; answers in one batch do not see each other.
@@ -1064,6 +1064,122 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1064
1064
  - Others get 403 minecraft_news_forbidden.
1065
1065
  - Example: await Twinkle.minecraft.deleteNews({ id: post.id });
1066
1066
 
1067
+ ### Twinkle.town
1068
+ - async getTown() | scopes: sharedDb:read
1069
+ - Returns: { town, clock: { now, townMinute, day, weekday, kstTime }, places: [{ id, name, kind, district, door: [x, z], y, built, spots }], housing: { homes, waitingList }, residents: [publicCharacter], gazette: [{ id, townMinute, text }], goods, myCharacterId, cursor }
1070
+ - Load Starhaven Life's shared town: places (Minecraft coordinates), the homes board, every resident's public state and the Town Gazette.
1071
+ - Every resident is a real member's character living on its own every 15 minutes on the real KST clock (school mornings, work afternoons, rent on Mondays).
1072
+ - publicCharacter = { characterId, name, look, pet, home, place, spot, move: { points, depart, arrive } | null, activity: { id, label, icon, place, start, until, with, withName } | null, mood: { emotion, icon, label }, title, job, knownFor, guided }. start/until/depart/arrive are town minutes (clock.townMinute is now).
1073
+ - Pass cursor to getChanges.
1074
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1075
+ - Example: const town = await Twinkle.town.getTown(); let cursor = town.cursor; setInterval(async () => { const ch = await Twinkle.town.getChanges({ since: cursor }); cursor = ch.cursor; /* move ch.residents */ }, 25000);
1076
+ - async getChanges({ since }) | scopes: sharedDb:read
1077
+ - Returns: { clock, events: [{ id, townMinute, type, salience, characterId, withCharacterId, text, mine }], residents: [publicCharacter], newDiaryLines, hasMore, cursor }
1078
+ - Poll what changed since a cursor: public town events, events about your own character, and residents whose state changed.
1079
+ - Poll every 20-30 seconds; always continue from the returned cursor. hasMore: true means call again right away.
1080
+ - Also marks the viewer as online (their character earns full pay while its guardian is around).
1081
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1082
+ - async getMyCharacter() | scopes: sharedDb:read
1083
+ - Returns: { character: { characterId, name, status, profile, petName, public, life, movingIn } | null, guardian: { priorities, direction, focus, civicCard, optIns, aspiration }, home, waitingListPosition, control, guidedActions, clock } | { character: null, create: { suggestedNames, suggestedPetNames, petSpecies, likeTags, axes, nameCheck } }
1084
+ - The guardian's view of the viewer's own character (needs, feelings, wishes, worries, skills, job, plan, money, friends, proposals waiting for an answer), or the options for creating one.
1085
+ - life is the server's canonical state; never compute needs, money or relationships in the app.
1086
+ - life.proposals are big life changes the character asks its guardian first (for example asking someone to be best friends): answer with answerProposal; silence for 24 hours lets the character decide.
1087
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1088
+ - async createCharacter({ name, petName, petSpecies, personality: { neat, outgoing, active, playful, kind }, likes, dislike, look: { skin, hair, shirt, pants, hood }, homeId }) | scopes: sharedDb:write
1089
+ - Returns: { characterId, name, petName, welcomeBucks, housing: { homeId, weeklyRent, rentDueMinute } | { waitingListPosition, error? } }
1090
+ - Create the viewer's one character in Starhaven (safety-checked names, 200 welcome Bucks) and optionally claim a vacant home.
1091
+ - Names are 2-16 letters. Suggested names (getMyCharacter().create) always pass; any other name needs a clear safety check and may be refused (400 town_name_not_allowed) or be unavailable (503: offer the suggestions).
1092
+ - personality values are -2..2; likes: 3 different interest tags; dislike: 1 other tag; look colours are 0xRRGGBB numbers.
1093
+ - Without a free home the character lodges in the Fivefold guest rooms and joins the waiting list.
1094
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1095
+ - async setPriorities({ priorities }) | scopes: sharedDb:write
1096
+ - Returns: { priorities }
1097
+ - Set the guardian's 8 priority sliders (-2..2): saving, pets, work, social, learning, fun, tidy, civic.
1098
+ - Priorities tilt what the character chooses; they never force it (characters have free will).
1099
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1100
+ - async setDirections({ text }) | scopes: sharedDb:write
1101
+ - Returns: { ok: true, text, understoodAs, weights, neighbour } | { ok: false, reason }
1102
+ - Give the character plain-words guidance (up to 200 characters, English). JEV checks it is safe and kind, then shows back how it was understood.
1103
+ - Show understoodAs to the guardian. ok: false keeps the old guidance (unsafe wording, too many changes today (3 a day), or guidance reading paused).
1104
+ - Directions can only raise time with a neighbour, never avoid or target anyone. They are private to the owner.
1105
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1106
+ - async setOptIns({ dontVoteForMe, voteForMe, spendLimit, savingsFloor, visitors, workOffline, tendPets }) | scopes: sharedDb:write
1107
+ - Returns: { optIns }
1108
+ - Guardian opt-ins: JEV votes for the character by default (dontVoteForMe: true opts out), daily spend limit (0-500 Bucks, default 50), savings floor, visitors (friends | residents | nobody).
1109
+ - visitors: nobody also stops the character hosting a moved-out friend.
1110
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1111
+ - async setFocus({ skills }) | scopes: sharedDb:write
1112
+ - Returns: { focus }
1113
+ - Pick up to 2 Focus skills; the character spends more time on them and grows them faster.
1114
+ - Scholarship grows only from the owner's real learning on Twinkle and cannot be a Focus.
1115
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1116
+ - async setCivicCard({ focus, tax, change }) | scopes: sharedDb:write
1117
+ - Returns: { civicCard }
1118
+ - The 3-question civic card the autopilot vote reads (focus: fun | learning | pets | saving | civic; tax: lower | keep | higher; change: new | keep).
1119
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1120
+ - async confirmAspiration({ aspirationId }) | scopes: sharedDb:write
1121
+ - Returns: { aspiration: { id, label, confirmed } }
1122
+ - Confirm the life goal the character proposed (life.aspirationProposed) or choose another; milestones give titles only.
1123
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1124
+ - async takeControl({ leaseId, immediate }) | scopes: sharedDb:write
1125
+ - Returns: { leaseId, expiresAt, heartbeatMs, renewed }
1126
+ - Guide the character directly. Returns a lease; call takeControl({ leaseId }) about every 60 s to keep it.
1127
+ - The lease lapses after 3 missed heartbeats (stop renewing when the tab is hidden) and the character goes back to living on its own. immediate: true stops the current activity now instead of at its end.
1128
+ - 409 town_control_elsewhere: another window is guiding this character.
1129
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1130
+ - async releaseControl({ leaseId }) | scopes: sharedDb:write
1131
+ - Returns: { released, livingOnAutopilot }
1132
+ - "Let them live": hand the character back to its own free will.
1133
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1134
+ - async act({ leaseId, operationId, activity } | { leaseId, operationId, outing, withCharacterId }) | scopes: sharedDb:write
1135
+ - Returns: { operationId, status: queued | applied | rejected, result }
1136
+ - While guiding, tell the character what to do next: an activity (from getMyCharacter().guidedActions.activities) or an outing with another resident.
1137
+ - The server checks it is possible (hours, weather, money, the other resident being free) and applies it within a few seconds; watch getChanges/getMyCharacter for the result. Reuse the same operationId when retrying.
1138
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1139
+ - async answerProposal({ proposalId, answer, operationId }) | scopes: sharedDb:write
1140
+ - Returns: { status, result }
1141
+ - Answer a big-life-change question the character asked its guardian: yes, no, or decide (let them decide).
1142
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1143
+ - async getDiary({ since, limit }) | scopes: sharedDb:read
1144
+ - Returns: { since, moments, decisions: [{ text, by: jev | code, at }], lines, numbers: { earned, shifts, meals, sleptHours, friendsSeen, skillUps } }
1145
+ - "While you were away": the top moments, the decisions made for you (and whether JEV or the code made them), and the numbers since a time (ms).
1146
+ - Text comes from fixed English templates; "(quiet stretch)" marks time the town caught up after a pause.
1147
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1148
+ - async getElections() | scopes: sharedDb:read
1149
+ - Returns: { offices, elections: [{ id, office, officeLabel, status, votingOpensAt, closesAt, candidates, result, myBallot }], pledges, mottos, minEligibleVoters }
1150
+ - Town offices and elections (Town Chief, Headmaster, Market Warden, Pet Warden; 14-day terms).
1151
+ - Results show counts only (secret ballot), including how many were autopilot votes.
1152
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1153
+ - async vote({ electionId, approve }) | scopes: sharedDb:write
1154
+ - Returns: { electionId, approve, abstain }
1155
+ - Approval vote: approve any number of candidates (characterIds). One ballot per account; voting again replaces it until the close.
1156
+ - Voters: account 30+ days old, living in Starhaven 7+ days, active 3 of the last 14 days (403 town_not_eligible otherwise).
1157
+ - Without a manual vote, JEV votes for the character at the close unless the guardian opted out (setOptIns({ dontVoteForMe: true })).
1158
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1159
+ - async declareCandidacy({ electionId, pledges, motto }) | scopes: sharedDb:write
1160
+ - Returns: { electionId, pledges, motto }
1161
+ - Stand for office during the candidacy days: exactly 3 pledges from the list and an optional templated motto id (no free text).
1162
+ - Only the owner can declare, never the character.
1163
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1164
+ - async getHousing() | scopes: sharedDb:read
1165
+ - Returns: { homes: [{ homeId, plot, district, name, type, readyMade, weeklyRent, vacant, residentName }], waitingList: [{ position, name, since, movedOut }], mine: { home, rentJar, bucks, waitingListPosition, inStorage } | null, rentDay, graceDays }
1166
+ - The homes board (10 Harbor Row + 10 Lantern Hill plots), the public waiting list and the viewer's rent status.
1167
+ - Rent is weekly in Bucks, due Monday 00:00 KST, with 3 days of grace; a character that can't pay moves out gently (belongings to storage) and gets the next home it can cover from the waiting list.
1168
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1169
+ - async claimHome({ homeId }) | scopes: sharedDb:write
1170
+ - Returns: { homeId, weeklyRent, rentDueMinute, rentDueAt }
1171
+ - Claim a vacant home, first come first served. The first rent is due on a Monday at least 3 days away.
1172
+ - 409 town_home_taken when someone else got there first.
1173
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1174
+ - async joinWaitingList() | scopes: sharedDb:write
1175
+ - Returns: { waitingListPosition }
1176
+ - Join the public housing waiting list.
1177
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1178
+ - async leaveWaitingList() | scopes: sharedDb:write
1179
+ - Returns: { waitingListPosition: null }
1180
+ - Leave the housing waiting list.
1181
+ - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
1182
+
1067
1183
  ### Twinkle.leaderboards
1068
1184
  - async get({ boardKey = 'default', limit, cursor } = {}) | scopes: none
1069
1185
  - Returns: { entries: [{ rank, id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt }], scores, cursor, hasMore, personalBest: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null }
@@ -1388,6 +1504,33 @@ world.updatePresence({ x, y, z, facing });
1388
1504
  - Read a retired bounty’s original questions and guides from the approval attached to its solve receipt.
1389
1505
  - 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.
1390
1506
  - 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.
1507
+ - await Twinkle.rewards.getBankStatus({ ruleId }) | scopes: rewards:claim
1508
+ - Returns: { mode, ruleId, dayKey, startStep, steps: [{ step, reward: { xp, coins } }], topics: [{ key, title, step, questionsAtStep }], today: { xp, coins, claims }, budgets: { userDailyXP, userDailyCoins } }
1509
+ - Read a generated-quiz rule's ladder: what each step pays, the learner's step on every topic, and today's earnings.
1510
+ - Read on load and after each claim; never compute amounts or steps in the app. Drafts return mode 'preview' from the owner's own rewards.json.
1511
+ - await Twinkle.rewards.startBankQuestion({ ruleId, topicKey, skip? }) | scopes: rewards:claim
1512
+ - Returns: { needsQuestion: true, step, reward } | { challengeId, step, question: { id, topicKey, step, prompt, hint?, guide?, answer? }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, solved, reward, retryReward, stats: { learners, solved, firstSolver: { id, username, solvedAt } | null } }
1513
+ - Serve the learner's open question on a topic, else the next question at their step that they have not seen.
1514
+ - needsQuestion: true means no unseen question exists at the learner's step: offer to write one with generateBankQuestion, saying it uses their AI Energy.
1515
+ - The prompt and guide use LaTeX between $...$ / $$...$$; render them with the Twinkle-served KaTeX (see the vendor guidance). The answer appears only after the learner solves it or runs out of tries; the guide after their first answer.
1516
+ - skip: true gives up on the open question (it counts as a miss on the ladder) and serves the next one.
1517
+ - await Twinkle.rewards.generateBankQuestion({ ruleId, topicKey, onProgress?: ({ stage, phase, percent, round? }) => void }) | scopes: rewards:claim
1518
+ - Returns: Same as startBankQuestion with the new question, or { needsQuestion: false } when an unseen question already exists (call startBankQuestion).
1519
+ - Have the server write, double-check and serve a new question at the learner's step. Uses the learner's AI Energy.
1520
+ - Show a progress bar from onProgress: stage is 'pending', 'writing', 'checking' (the independent solve), 'saving' or 'done'; percent follows the server's real phases and the model's streamed output, never goes backwards, and reaches 100 only when the question is served. Silent model thinking inside a phase cannot be measured, so the bar eases forward there rather than guessing.
1521
+ - Takes as long as two GPT-6 Sol calls (tens of seconds): show progress and keep one request in flight. Both calls are billed to the signed-in learner's AI Energy; an empty battery rejects before any work.
1522
+ - build_reward_bank_unverified (503) means two tries did not produce a question whose answers agreed; nothing is stored. build_reward_bank_generation_limit (429) caps questions one learner writes per day.
1523
+ - The question joins the shared bank: every other learner at that step gets it before anything new is written.
1524
+ - await Twinkle.rewards.claimBankQuestion({ challengeId, answer: number }) | scopes: rewards:claim
1525
+ - Returns: { correct, awarded, duplicate?, budgetReached?, receipt: { id, questionId, step, xp, coins, attempt, firstTry } | null, balances?, ladder?: { step, previousStep }, question: { ..., guide?, answer? }, attempts, attemptsRemaining, stats }
1526
+ - Answer a bank question. The first answer moves the ladder; a correct answer pays the question's step share.
1527
+ - Send one number (convert a typed fraction yourself). A wrong answer within two seconds of the previous one is refused with build_reward_throttled (429).
1528
+ - ladder appears on the learner's first answer to a question and gives their new step on that topic. Retrying a solved challengeId returns duplicate: true with the original receipt.
1529
+ - budgetReached: true means the solve counted but today's userDailyXP/userDailyCoins is used up. In preview mode nothing is paid and previewReward shows what the live app would pay.
1530
+ - await Twinkle.rewards.reportBankQuestion({ questionId, reason }) | scopes: rewards:claim
1531
+ - Returns: { reported: true, duplicate, hidden }
1532
+ - Report a wrong or unclear bank question the learner has seen.
1533
+ - One report per learner per question; three reports hide it from everyone. Show a small 'report this problem' action next to the question.
1391
1534
 
1392
1535
  ### Twinkle.cardCraft
1393
1536
  - await Twinkle.cardCraft.getStatus() | scopes: cardCraft:read
@@ -1293,6 +1293,39 @@ those exact files and does not depend on keeping the review copy. Re-offers
1293
1293
  reuse the media; a failed transaction cleans up its copied objects. Declining
1294
1294
  leaves the offered media as unused uploads in the creator's library.
1295
1295
 
1296
+ ### Reward question-bank seeding (every full daily review; added 2026-09-28)
1297
+
1298
+ `generated-quiz` rules fill a question bank on the server: a question is
1299
+ written for one declared topic at one ladder step (GPT-6 Sol) and kept only
1300
+ when an independent second solve agrees. Learners write missing questions
1301
+ themselves on their own AI Energy; seeding writes the gentlest steps ahead of
1302
+ them so the first learner on a unit never waits or pays.
1303
+
1304
+ ```bash
1305
+ lumine admin reward-bank coverage <buildId> [--max-step 3] [--json] # read-only, no run
1306
+ lumine admin reward-bank seed <buildId> [--max-step 3] [--json] # delegated full run
1307
+ ```
1308
+
1309
+ - `seed` writes ONE question per call into the first missing slot, lowest
1310
+ step first across every topic of every generated-quiz rule, up to
1311
+ `--max-step` (default 3, at most 10). A slot is a (rule, topic, step) with
1312
+ no active live question. The call takes as long as two model calls
1313
+ (about 20–40 s; the CLI allows 4 minutes) and returns `seeded`
1314
+ (`questionId`, `ruleId`, `topicKey`, `step`) or `complete: true`, plus
1315
+ `coverage` (`filled` / `total`).
1316
+ - It needs the `reward:bank-seed` scope, which only full delegated runs carry.
1317
+ The question is written as the run identity (Zero or Ciel) and billed to
1318
+ that account's own AI Energy. It never pays anyone and opens no challenge.
1319
+ - It refuses unless the app's rewards are approved and published
1320
+ (`CLI_ADMIN_BUILD_REWARD_BANK_NOT_LIVE`), and refuses to store a question
1321
+ if the approval changed while it was being written
1322
+ (`CLI_ADMIN_BUILD_REWARD_BANK_REVIEW_CHANGED`: run it again).
1323
+ `CLI_ADMIN_BUILD_REWARD_BANK_UNVERIFIED` means the two solves disagreed
1324
+ twice; nothing was stored.
1325
+ - Which apps to seed, how many steps and how many per run are Mikey's
1326
+ decisions, recorded in the agent guide (Math Studio #2655: steps 1–3, at
1327
+ most 20 per run).
1328
+
1296
1329
  ### Reward activity report (standing duty, every full daily review; added 2026-09-12)
1297
1330
 
1298
1331
  Completion rewards (Arcade Typing's stage clears) prove nothing but elapsed