@stage5/lumine 0.3.31 → 0.3.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/commands.js CHANGED
@@ -2478,7 +2478,14 @@ export function parseArgs(args) {
2478
2478
  "show",
2479
2479
  "foreground",
2480
2480
  "open",
2481
+ "dryRun",
2482
+ "confirmEmailRule",
2483
+ "includePrefix",
2484
+ "allowSharedNetwork",
2481
2485
  ]);
2486
+ // A flag that may stand alone (true) or carry a value: `--confirm` confirms
2487
+ // an AI bucket change; `meetup slot <crewId> --confirm <slotIndex>` picks a slot.
2488
+ const optionalValueFlags = new Set(["confirm"]);
2482
2489
 
2483
2490
  for (let i = 0; i < rest.length; i += 1) {
2484
2491
  const arg = rest[i];
@@ -2499,6 +2506,12 @@ export function parseArgs(args) {
2499
2506
  : inlineValue;
2500
2507
  } else if (booleanFlags.has(camelKey)) {
2501
2508
  raw[camelKey] = true;
2509
+ } else if (
2510
+ optionalValueFlags.has(camelKey) &&
2511
+ // a following flag (`--json`, `-h`) is never taken as the value
2512
+ (rest[i + 1] === undefined || rest[i + 1].startsWith("-"))
2513
+ ) {
2514
+ raw[camelKey] = true;
2502
2515
  } else {
2503
2516
  const nextValue = rest[i + 1];
2504
2517
  if (
@@ -2732,6 +2745,13 @@ export function parseArgs(args) {
2732
2745
  : "",
2733
2746
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2734
2747
  adminDeviceKey: raw.deviceKey ? String(raw.deviceKey) : "",
2748
+ adminRuleId: raw.ruleId ? String(raw.ruleId) : "",
2749
+ adminIp: raw.ip ? String(raw.ip) : "",
2750
+ adminMessage: raw.message === undefined ? undefined : String(raw.message),
2751
+ adminDryRun: Boolean(raw.dryRun),
2752
+ adminConfirmEmailRule: Boolean(raw.confirmEmailRule),
2753
+ adminIncludePrefix: Boolean(raw.includePrefix),
2754
+ adminAllowSharedNetwork: Boolean(raw.allowSharedNetwork),
2735
2755
  adminLabel: raw.label ? String(raw.label) : "",
2736
2756
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2737
2757
  adminUser: raw.user ? String(raw.user) : "",
@@ -2782,10 +2802,20 @@ export function parseArgs(args) {
2782
2802
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2783
2803
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2784
2804
  adminFile: raw.file ? String(raw.file) : "",
2805
+ // admin craft revise --item <id|name> [--why] [--emoji] [--category]
2806
+ // (kept raw: a flag given without a text value is refused in
2807
+ // admin-craft.js rather than sent as the string "true")
2808
+ adminCraftItem: raw.item,
2809
+ adminCraftWhy: raw.why,
2810
+ adminCraftEmoji: raw.emoji,
2811
+ adminCraftCategory: raw.category,
2812
+ adminCraftPair: raw.pair,
2785
2813
  adminPort: raw.port ? String(raw.port) : "",
2786
2814
  adminForeground: Boolean(raw.foreground),
2787
2815
  adminOpen: Boolean(raw.open),
2788
2816
  adminConfigFile: raw.config ? String(raw.config) : "",
2817
+ // admin review propose --thumbnail <image|workspace>
2818
+ adminThumbnail: raw.thumbnail ? String(raw.thumbnail) : "",
2789
2819
  adminReviewedBuildVersion: raw.reviewedVersion
2790
2820
  ? String(raw.reviewedVersion)
2791
2821
  : "",
@@ -3177,6 +3207,15 @@ export function printHelp() {
3177
3207
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
3178
3208
  lumine admin ai-bucket device block-signup --bucket-id <id> --user <source-user-id> --device-key <inspection-key> --user-ids <reviewed-device-users> --note <reason> [--json]
3179
3209
  lumine admin ai-bucket note set --bucket-id <id> --note <text> [--json]
3210
+ lumine admin ai-bucket inspect --bucket-id <id> --reason <management reason> [--include-private-evidence] [--json]
3211
+ lumine admin ai-bucket restrict --bucket-id <id> --mode none|signup|full [--message <text>] [--allow-shared-network] (--dry-run | --confirm) [--json]
3212
+ lumine admin ai-bucket rule add --bucket-id <id> --type user --user <id> [--note <text>] (--dry-run | --confirm) [--json]
3213
+ lumine admin ai-bucket rule add --bucket-id <id> --type email --email <address> [--note <text>] (--dry-run | --confirm-email-rule --confirm) [--json]
3214
+ lumine admin ai-bucket rule add --bucket-id <id> --type ip --ip <address> --scope signup|full [--include-prefix] [--allow-shared-network] [--note <text>] (--dry-run | --confirm) [--json]
3215
+ lumine admin ai-bucket rule add --bucket-id <id> --type device --user <source-user-id> --device-key <inspect-key> --scope signup|full --user-ids <reviewed-device-users> --note <reason> (--dry-run | --confirm) [--json]
3216
+ lumine admin ai-bucket rule disable --bucket-id <id> --rule-id <id> [--note <text>] (--dry-run | --confirm) [--json]
3217
+ (every real change needs --confirm; --dry-run shows exactly what would change and writes nothing;
3218
+ a shared network (academy, school) or an unmeasured prefix is refused unless --allow-shared-network, only on Mikey's explicit word)
3180
3219
  lumine admin ai-email-policy get --email <address> [--json]
3181
3220
  lumine admin ai-email-policy set --email <address> --mode <automatic|separate_accounts> --note <text> [--json]
3182
3221
  lumine admin daily-run start [--scope full|featured|newspaper] [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
@@ -3202,6 +3241,7 @@ export function printHelp() {
3202
3241
  lumine admin english import <verdicts.json> --pass a|b | adjudicate <file> | add-questions <file> | relevel <file> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> | retag <questionId> --skill <code> (English skill tags and bank upkeep for Grammarbles; content written by Claude Code agents)
3203
3242
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3204
3243
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3244
+ lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
3205
3245
  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>]
3206
3246
  lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | request-changes <crewId> --note <text> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch "<name>" [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3207
3247
  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)
@@ -3216,7 +3256,7 @@ export function printHelp() {
3216
3256
  lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
3217
3257
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
3218
3258
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
3219
- lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--json]
3259
+ lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
3220
3260
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
3221
3261
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
3222
3262
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
package/lib/thumbnail.js CHANGED
@@ -134,6 +134,27 @@ export async function thumbnailSet(options) {
134
134
  if (!filePath) {
135
135
  throw new Error("Usage: lumine thumbnail set <file> (jpg, png, or webp)");
136
136
  }
137
+ const { buffer, contentType } = await readThumbnailFile(filePath);
138
+
139
+ const { buildId, auth, build } = await resolveThumbnailContext(options);
140
+ const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
141
+ if (!proceed) return;
142
+
143
+ const { thumbnailUrl } = await commitThumbnailImage({
144
+ options,
145
+ auth,
146
+ buildId,
147
+ buffer,
148
+ contentType,
149
+ });
150
+ console.log(`Thumbnail set for Build #${buildId}:`);
151
+ console.log(` ${thumbnailUrl}`);
152
+ console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
153
+ }
154
+
155
+ // A local jpg/png/webp checked against the thumbnail limits, ready for
156
+ // commitThumbnailImage (also used by `lumine admin review propose --thumbnail`).
157
+ export async function readThumbnailFile(filePath) {
137
158
  const absolutePath = path.resolve(filePath);
138
159
  const extension = path.extname(absolutePath).toLowerCase();
139
160
  const contentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension];
@@ -159,21 +180,7 @@ export async function thumbnailSet(options) {
159
180
  `Thumbnail file is too big (${formatBytes(buffer.length)}; max ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}).`,
160
181
  );
161
182
  }
162
-
163
- const { buildId, auth, build } = await resolveThumbnailContext(options);
164
- const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
165
- if (!proceed) return;
166
-
167
- const { thumbnailUrl } = await commitThumbnailImage({
168
- options,
169
- auth,
170
- buildId,
171
- buffer,
172
- contentType,
173
- });
174
- console.log(`Thumbnail set for Build #${buildId}:`);
175
- console.log(` ${thumbnailUrl}`);
176
- console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
183
+ return { buffer, contentType };
177
184
  }
178
185
 
179
186
  function decodeImageDataUrl(dataUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.31",
3
+ "version": "0.3.33",
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.64.3
4
- Updated: 2026-10-10
5
- Generated: 2026-10-10T06:47:00.045Z
3
+ Version: 1.65.1
4
+ Updated: 2026-10-13
5
+ Generated: 2026-10-12T17:14:00.482Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -36,7 +36,7 @@ Generated: 2026-10-10T06:47:00.045Z
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; an award larger than the learner's remaining room today pays what fits, XP and Coins clamped separately; a capped award still counts as claimed, including a one-time maxLifetimeClaims reward, and its receipt carries cappedFrom) 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
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
- - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); 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), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), 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), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
39
+ - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); 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), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), 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), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Twinkle.craft apps also have craft-target-v1 (the learner's run of merges reaches today's target in this app's universe; every merge is a known pair and every result is one the learner made through Twinkle.craft.merge), craft-par-v1 (the same run reaches it in at most par merges) and craft-discovery-v1 (the oldest World First of the learner's that no rule has paid yet; each World First pays once ever; optional minWordLevel basic|elementary|intermediate|advanced|epic makes tiers: either every craft-discovery-v1 rule has one or none does; each World First pays under exactly one tier, the highest configured tier at or below its Word Master level, so a series per tier can pay rarer words more and the order of claims never matters; an item whose level is still pending pays under no tier yet). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
40
40
 
41
41
  ## AI decision design
42
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.
@@ -1517,6 +1517,7 @@ world.updatePresence({ x, y, z, facing });
1517
1517
  - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
1518
1518
  - A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction; every receipt counts, including one capped by the daily budget (cappedFrom). build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
1519
1519
  - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
1520
+ - For craft-target-v1 and craft-par-v1, pass completionToken as JSON.stringify({ v: 1, merges: [[a, b], ...] }): the item ids of the run's successful merges in order, starting from the starters (at most 300 merges, 32 KB). The server replays it against this app's known pairs, needs today's target (Twinkle.craft.getState().today) to appear, every result to be in the learner's collection, and at least one second per merge since start; it refuses an unknown pair, an item the run had not made yet, an over-par run (par rule) or a run on another day. For craft-discovery-v1, pass nothing: the server pays the oldest unpaid World First (receipt slot craft:<itemId>) and puts its name on the bounty story ("discovered <item>"). With tiers, a rule pays only World Firsts of its own tier (refused with a plain reason when the learner has none).
1520
1521
  - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1521
1522
  - Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
1522
1523
  - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
@@ -1652,6 +1653,36 @@ spawnPet(asset.kind, asset.params, asset.palette, asset.tier.effects);
1652
1653
  - The asset's name is fixed: it is shown on the card page and cannot be renamed. To let owners name their pet, keep an in-game nickname in state (for example state.nickname, trimmed and length-limited by your app) and show it in the game. After a sale the new owner can set their own nickname the same way.
1653
1654
  - expectedRevision is the stateRevision you last read; a mismatch fails with code card_craft_state_conflict (details.stateRevision), and a card the viewer no longer owns fails with card_craft_not_owner.
1654
1655
 
1656
+ ### Twinkle.craft
1657
+ - await Twinkle.craft.getState() | scopes: craft:read
1658
+ - Returns: { starters: [item], counts: { mine, world }, today: { day, target: item | null, par: number | null, done: boolean }, reason?: "sign_in_required" }
1659
+ - item is { id, name, emoji, category, wordLevel: "basic" | "elementary" | "intermediate" | "advanced" | "epic" | null, isStarter }. wordLevel is Word Master's level of the name's rarest word (null while it is being looked up); show it by name, never as a number.
1660
+ - Starters are always in the player's hands and are not in list(). counts.mine is the player's collection without starters; counts.world is every item in this universe.
1661
+ - today is Today's Star: the same target for everyone in this app on this site day (UTC), reachable from the starters in par merges or fewer (par is the server's best plan, an upper bound). target is null while the universe is too young. done is true once the player has the target.
1662
+ - Signed-out viewers get the same shape, empty: starters [], counts 0, today with target null, plus reason: "sign_in_required". counts.world may be up to a minute old.
1663
+ - await Twinkle.craft.merge({ a, b, requestId? }) | scopes: craft:write
1664
+ - Returns: { status: "ok" | "fizzle", item: item | null, why: string | null, newForMe: boolean, worldFirst: boolean, newPair: boolean, aiUsagePolicy? }
1665
+ - a and b are item ids the player has (starters or their collection); the order does not matter and an item can merge with itself. A known pair answers at once and costs nothing. A new pair asks the AI (usually a few seconds; the SDK waits up to 120 s), costs the player a little AI Energy (newPair: true) and is then known for everyone. fizzle means the pair makes nothing; it is stored too, so it is also free afterwards.
1666
+ - why is one true sentence explaining the result ("Lava cools in water and hardens into rock."): show it, it is the learning part. worldFirst: true means this player is the first in this universe to make the item; their name is on it for good.
1667
+ - Limits: 300 new pairs per player per day (known pairs are unlimited), 20,000 items per collection, and about 1 merge a second. Errors carry code: craft_item_not_owned, craft_item_not_found, craft_in_progress (someone else is discovering the same pair; retry shortly), craft_daily_limit, craft_collection_full, craft_universe_full, craft_generation_failed / craft_generation_timeout (the AI was unavailable or too slow and the pair stays new; try again), craft_check_unavailable (HTTP 503: Twinkle's safety check is briefly down; nothing was stored and the pair stays new, so try again in a minute), craft_app_not_found / craft_forbidden (the app was deleted or the viewer can no longer open it), craft_throttled, and the AI Energy codes of Twinkle.ai.
1668
+ - Pass the same requestId when retrying after a lost reply. Keep a run's successful merges as [a, b] ids for Twinkle.rewards.claim (craft-target-v1 / craft-par-v1).
1669
+ - A result that is a starter (Water + Water = Water) is not added to the collection: newForMe is false. An error can carry aiUsagePolicy when an earlier AI attempt already used Energy.
1670
+ - Items can be taken down by Twinkle moderators: a retired item leaves the player's collection (list, item, getState counts) and its pairs answer fizzle. Treat ids you cached as possibly gone: a merge with one returns craft_item_not_found.
1671
+ - Example: const result = await Twinkle.craft.merge({ a: water.id, b: fire.id });
1672
+ if (result.status === 'ok') showCard(result.item, result.why, result.worldFirst);
1673
+ - await Twinkle.craft.list({ cursor?, limit? }) | scopes: craft:read
1674
+ - Returns: { items: [{ id, name, emoji, category, wordLevel, firstAt, isWorldFirst }], cursor: number | null }
1675
+ - The player's collection in this app, oldest item id first; limit 1-200 (default 100). firstAt is when this player got the item; isWorldFirst is true for items they discovered first in the universe.
1676
+ - await Twinkle.craft.item({ id }) | scopes: craft:read
1677
+ - Returns: { item: { id, name, emoji, category, wordLevel, isStarter, why, firstDiscoverer: { id, username } | null, firstAt, holders, recipes: [[{ id, name, emoji }, { id, name, emoji }]] } | null }
1678
+ - The discovery card of an item the player has (null otherwise, so undiscovered items never leak). why is the explanation from the merge that first made it, holders how many players have it, recipes up to five known pairs that make it whose inputs the player has.
1679
+ - await Twinkle.craft.journey({ day? }) | scopes: craft:read
1680
+ - Returns: { day, journey: { target: item, par, steps: [{ a, b, result }] } | null, reason?: "no_target" | "reach_target_first" | "sign_in_required" }
1681
+ - The server's par plan for a day (YYYY-MM-DD, default today), each step { id, name, emoji } items. For today it opens only after the player made the target today (reason reach_target_first before that); past days are open too, except a past day whose target is today's target (same rule as today).
1682
+ - await Twinkle.craft.rankings() | scopes: craft:read
1683
+ - Returns: { allTime: { top: [{ rank, userId, username, profilePicUrl, worldFirsts }], me: { rank: number | null, worldFirsts } }, month: { month, top, me: { rank: number | null, worldFirsts } } }
1684
+ - World First rankings in this app: the top 30 discoverers all time and this month (UTC), and the viewer's own rank (null with no World Firsts). The top lists may be up to a minute old. Signed-out viewers get empty top lists and me: { rank: null, worldFirsts: 0 }.
1685
+
1655
1686
  ### Twinkle.network
1656
1687
  - async getHome({}) | scopes: sharedDb:read
1657
1688
  - Returns: { name, buildId, url, communities, counts }
@@ -131,7 +131,8 @@ canonical bucket. Use it to distinguish quota bookkeeping from moderation;
131
131
  the note itself changes no access, ban, or identity rules.
132
132
 
133
133
  The commands above are quota bookkeeping only. They cannot ban accounts, block signup,
134
- add IP/device/risk-key rules, or infer an account family. Identification
134
+ add IP/device/risk-key rules, or infer an account family (the explicit
135
+ restricted-bucket commands below can, on Mikey's instruction). Identification
135
136
  remains a human/LLM evidence judgment and must be explicitly requested by
136
137
  Mikey; routine administrator runs still escalate suspected alternate accounts
137
138
  and never auto-enforce. `accounts add` and `note set` still accept only an
@@ -159,6 +160,85 @@ raw device ID. The private audit records the request before evidence is read
159
160
  and records successful completion separately. Routine reviews still never
160
161
  auto-enforce; this action requires Mikey's explicit instruction for the device.
161
162
 
163
+ ### Restricted buckets: inspect, rules and restrictions
164
+
165
+ These are the operator path for buckets with a restriction (and for adding
166
+ email, IP and device rules to any bucket). They use the same shared operations
167
+ as the Management page, so each change has the same side effects there
168
+ (current-day usage re-attribution, ban cache, member notification and kicks,
169
+ live quota updates). Use them only on Mikey's explicit instruction for that
170
+ bucket, rule or restriction.
171
+
172
+ ```bash
173
+ lumine admin ai-bucket inspect --bucket-id 4 --reason "todo 79 review" --json
174
+ lumine admin ai-bucket restrict --bucket-id 4 --mode signup --dry-run
175
+ lumine admin ai-bucket restrict --bucket-id 4 --mode signup --confirm
176
+ lumine admin ai-bucket rule add --bucket-id 4 --type user --user 18559 --dry-run
177
+ lumine admin ai-bucket rule add --bucket-id 4 --type user --user 18559 --confirm
178
+ lumine admin ai-bucket rule add --bucket-id 4 --type email \
179
+ --email family@example.com --confirm-email-rule --confirm
180
+ lumine admin ai-bucket rule add --bucket-id 4 --type ip --ip 58.151.12.59 \
181
+ --scope signup [--include-prefix] --confirm
182
+ lumine admin ai-bucket rule add --bucket-id 4 --type device --user 18559 \
183
+ --device-key 0123456789abcdef --scope signup --user-ids 18559,20001 \
184
+ --note "Mikey approved this reviewed device" --confirm
185
+ lumine admin ai-bucket rule disable --bucket-id 4 --rule-id 77 --dry-run
186
+ ```
187
+
188
+ `inspect` works for unrestricted and restricted buckets. It returns the
189
+ canonical bucket (label, note, restriction `none|signup|full`, message), every
190
+ active rule (id, kind, scope, effective restriction, match), the canonical
191
+ members, every account a full restriction would reach, and for each member
192
+ (first 25) their devices (inspection keys, never raw IDs) and network
193
+ addresses with how many accounts share each. A device or address used by many
194
+ accounts (an academy, a school) is counted, marked shared and never listed or
195
+ treated as family evidence. Addresses and email-rule addresses (in evidence, rules and rule notes)
196
+ are masked unless `--include-private-evidence`; every other output, and the
197
+ audit, keeps them masked (with a keyed hash in the audit). `--reason` is required; the API commits an
198
+ access receipt before reading evidence.
199
+
200
+ `--dry-run` on `restrict`, `rule add` and `rule disable` is a read: it returns
201
+ the current and resulting restriction, the accounts newly affected or released,
202
+ the accounts whose sessions a full restriction drops, admin conflicts and any
203
+ blocker, and changes nothing. Device, IP and email previews read private
204
+ evidence, so they record an access receipt; the others record nothing.
205
+
206
+ A real run is audited (request receipt with the idempotency key and payload
207
+ fingerprint, then the completed result) and returns the bucket read back from
208
+ the database and checked (`verified: true`); the CLI refuses a result that
209
+ does not confirm the requested state. A failure after the change started is
210
+ recorded as failed; a replay with the same idempotency key returns the stored
211
+ result instead of running again.
212
+
213
+ - `restrict` always needs `--confirm`. `signup` blocks only new accounts:
214
+ existing logins and existing bans (`users.banned`) stay as they are. `full`
215
+ drops members' sessions and is refused while any member is an admin. `none`
216
+ lifts the restriction; each member gets its effective ban state pushed again.
217
+ Without `--message` the current message is kept. The preview and the
218
+ result list every IP, prefix and device rule whose effective restriction
219
+ changes, with how many accounts it reaches (exact IPs from site activity;
220
+ prefixes and devices from a year of AI usage, a lower bound). A rule that
221
+ gets stronger on a shared network (more than 8 accounts) or whose reach
222
+ can't be measured refuses the change unless `--allow-shared-network` is
223
+ passed; use it only on Mikey's explicit word. The CLI prints the override.
224
+ Saving a full bucket again (even only a new message) re-kicks its members,
225
+ as on the Management page; the preview lists them.
226
+ - `rule add` / `rule disable` always need `--confirm` too: on an unrestricted
227
+ bucket a rule still pools AI Energy, on a restricted one it changes who is
228
+ blocked. On a fully restricted bucket an account or email rule re-kicks every
229
+ account the bucket reaches (as on the Management page); the preview lists them.
230
+ Email rules cover every account with that verified address and also need
231
+ `--confirm-email-rule`. IP and device rules need `--scope`; IP prefix rules
232
+ (`--include-prefix`, the whole /24 or /64) are opt-in, their reach measured
233
+ as above. A shared exact address or prefix, or an unmeasured prefix under a
234
+ full restriction, is refused unless `--allow-shared-network`. An address or
235
+ prefix rule held by another bucket is refused, never moved. Device rules
236
+ keep the `device block-signup` guards: the reviewed `--user-ids` must equal
237
+ the accounts seen on the device, all inside the bucket; a device more than
238
+ 9 accounts used is shared (counted, not listed, refused). Disabling an exact
239
+ IP rule leaves its prefix rules active (the preview names them).
240
+ `rule disable` keeps `--note` in the audit receipt only.
241
+
162
242
  ### Shared verified-email policies
163
243
 
164
244
  A verified address proves that an account can use that mailbox; it does not
@@ -1320,10 +1400,19 @@ lumine admin reward-review approve 2 --config rules.json \
1320
1400
  lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json # then edit that directory…
1321
1401
  lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 \
1322
1402
  --config rules.json --reason "Moved the claim after the stage clears" --json # …and offer it
1403
+ lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 --config rules.json \
1404
+ --title "Word Forge" --thumbnail cover.png --json # …also offering a new name and thumbnail
1323
1405
  lumine admin reward-review reject 2 --reason "Rewards fire on game over; nothing is earned" --json
1324
1406
  lumine admin reward-review revoke 2 --reason "Farmable; pausing until redesigned" --json
1325
1407
  ```
1326
1408
 
1409
+ A proposal can also offer the app a new name (`--title`, at most 100
1410
+ characters) and a new thumbnail (`--thumbnail <jpg|png|webp>`, uploaded to your
1411
+ proposal workspace copy first; `--thumbnail workspace` offers the one already
1412
+ set on that copy, e.g. from the website). Both are frozen with the offer and
1413
+ applied together with the files and rules when the creator accepts. An offer
1414
+ may change only the name or thumbnail; the files can then stay as submitted.
1415
+
1327
1416
  `show --dir` writes the exact reviewed snapshot (private files, including the
1328
1417
  app's own `rewards.json`) so the agent can read it like a pulled workspace. The
1329
1418
  result's `config` is the proposal: the declared economy with the sheet's
@@ -1503,6 +1592,99 @@ lumine admin roles --min-level 3 # only the admin types' holders
1503
1592
  lumine admin roles --min-level 0 --limit 200 --json
1504
1593
  ```
1505
1594
 
1595
+ ### Twinkle.craft universes: seed, audit, revise (any time; added 2026-10-12)
1596
+
1597
+ An app using `Twinkle.craft` (Infinity Craft, build 1499) has its own
1598
+ universe of items and pairs. Players' new pairs are answered by the AI on
1599
+ their own Energy; we seed and curate the rest with Claude Code agents, not
1600
+ API calls (Mikey 10-12): an initial seed, then expansion and a daily audit,
1601
+ Math Lab style. No daily run is needed. `seed` and `revise` are dry runs
1602
+ unless `--confirm` is given.
1603
+
1604
+ ```bash
1605
+ lumine admin craft stats --build 1499 [--limit 50] # sizes, frontier, today's star, newest AI answers, kept flagged names
1606
+ lumine admin craft seed --build 1499 --file pairs.json # ALWAYS first: what would be added, skipped, rejected, warned
1607
+ lumine admin craft seed --build 1499 --file pairs.json --confirm # only after reading every warning
1608
+ lumine admin craft revise --build 1499 --item Obsidian --why "Lava that cools very fast turns into glassy rock." --confirm
1609
+ ```
1610
+
1611
+ - `pairs.json` is up to 500 pairs, in order: `[{ "a": "Water", "b": "Fire",
1612
+ "result": { "name": "Steam", "emoji": "♨️", "category": "science", "why":
1613
+ "Fire heats water until it turns into vapour." } }]` (or `{ "pairs": [...] }`).
1614
+ `a` and `b` are item names: starters (Water, Fire, Earth, Wind, Tree,
1615
+ Nature), items already in the universe, or results earlier in the same file.
1616
+ - Every result passes the same checks as AI answers: 1-3 English words of
1617
+ letters (32 characters), exactly one emoji, a category from nature, weather,
1618
+ earth, space, life, body, science, energy, materials, food, tools, places,
1619
+ ideas, and one plain-text why of at most 140 characters (no links or
1620
+ markup). No AI or JEV call runs, so the content is ours to get right: real,
1621
+ common English nouns or ideas, no people, characters or brands, child-safe,
1622
+ and a true why that teaches the link.
1623
+ - **Always dry-run a seed first and read every warning before `--confirm`.**
1624
+ A warning means a word of the name is on Word Master's censored list; the
1625
+ pair is still written on `--confirm`, so drop or rename any pair whose name
1626
+ could read rudely before confirming.
1627
+ - A pair that already has an answer is skipped, never overwritten. Each
1628
+ chunk of 100 is one audited transaction (`craft.seed`); a re-run is safe.
1629
+ Word levels (Word Master's basic..epic) are looked up as for player items.
1630
+ - Seeded items have no World First: the first player to make one claims it
1631
+ (name on the item, rankings, the discovery reward). Seeded pairs count for
1632
+ Today's Star like any answered pair and never count toward the admin's
1633
+ daily new-pair limit.
1634
+ - `stats` lists the newest AI-made pairs (`source: ai`, newest first, `--limit`
1635
+ 1-100, default 20) with each result's name, emoji, category, word level and
1636
+ why. Audit them daily; fix a wrong or weak explanation with `revise`.
1637
+ - `stats` also lists **kept flagged names**: AI items JEV kept although a word
1638
+ matched the censored list (name, matched word, JEV's score, when). Read
1639
+ each one as a child would; `retire` any that reads rudely.
1640
+ - `revise` changes an item's `--why` (the explanation on its card: the pair
1641
+ that first made it), `--emoji` or `--category`, through the same checks. The
1642
+ name never changes: players hold it. Audited as `craft.revise`.
1643
+ - Expansion: `stats` shows the frontier (items no pair uses yet, and untried
1644
+ pairs); seed pairs that use frontier items so Today's Star has fresh targets.
1645
+
1646
+ Take-down and repair (added 2026-10-13):
1647
+
1648
+ ```bash
1649
+ lumine admin craft retire --build 1499 --item "<name or id>" --reason "Not suitable for children" # dry run
1650
+ lumine admin craft retire --build 1499 --item "<name or id>" --reason "Not suitable for children" --confirm
1651
+ lumine admin craft unfizzle --build 1499 [--since 2026-10-13T00:00:00Z] [--pair Water+Fire] # dry run
1652
+ lumine admin craft unfizzle --build 1499 --since 2026-10-13T00:00:00Z --confirm
1653
+ ```
1654
+
1655
+ - `retire` takes a bad item down everywhere: it leaves collections, item
1656
+ cards, rankings, journeys and the daily target (today's star is re-picked);
1657
+ the pairs that make it fizzle and no AI answer or seed can make it again;
1658
+ its unpaid World First no longer pays (a paid one stays paid); its name
1659
+ leaves bounty story lines. Who, when and why are stored on the item and in
1660
+ the admin audit (`craft.retire`). Starters cannot be retired. There is no
1661
+ un-retire.
1662
+ - Word Master's censored list (`content_words_censored`) is a Wordle filter,
1663
+ not a moderation list, so a hit never decides anything on its own. A name
1664
+ with a listed word (whole words only, simple inflections both ways: -s,
1665
+ -es, -ed, -ing; hyphens count as spaces) is always judged by JEV as a
1666
+ whole, told which word to look at, and must clear a stricter bar. Crystal
1667
+ Ball, Sperm Whale, Pork Loin or Blue-Footed Booby pass when JEV finds them
1668
+ innocent; a rude reading is regenerated once, then the pair fizzles.
1669
+ Only JEV itself judges such a name: if JEV is down and its fallback model
1670
+ answers, the merge takes the outage path below (the answer is kept and
1671
+ re-checked once JEV is back). There is no allowlist. For seeds a hit is a
1672
+ **warning** in the report (printed as `warning …` lines, and in `warnings`
1673
+ with `--json`), never a rejection.
1674
+ - During a safety-check outage players get `craft_check_unavailable`. A
1675
+ pair's answer is paid for once and re-checked free for 24 h from when the
1676
+ model wrote it; after that a new try generates fresh. (If JEV later judges
1677
+ the kept answer low, the one normal regeneration is billed as usual.) Each
1678
+ server pauses new runs for a minute after an outage (per pair, or
1679
+ everywhere after three); the pause never stops the free re-check of a kept
1680
+ answer or a run that has already paid.
1681
+ - `unfizzle` repairs pairs that fizzled because the safety check itself
1682
+ failed (an outage before 2026-10-13; a check outage now fails the merge
1683
+ without storing anything). It resets only fizzles whose stored checks all
1684
+ errored, never a real low verdict or a censored word; the next player to
1685
+ try the pair asks again. `--since` and `--pair` narrow it; up to 5,000
1686
+ fizzles are scanned per run.
1687
+
1506
1688
  ### Reward question-bank seeding (every full daily review; added 2026-09-28)
1507
1689
 
1508
1690
  `generated-quiz` rules fill a question bank on the server: a question is