@stage5/lumine 0.3.26 → 0.3.28

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.
@@ -0,0 +1,75 @@
1
+ // `lumine admin roles`: every user type with its management level and who
2
+ // holds it. Read-only; no daily run needed. A member's level is the higher of
3
+ // their user type's level and what their achievement points earn; points stop
4
+ // below admin, so the admin holders listed here are everyone at that level.
5
+
6
+ export const ROLES_USAGE =
7
+ "Usage: lumine admin roles [--min-level 1] [--limit 50] [--json].";
8
+
9
+ function validationError(message) {
10
+ const error = new Error(message);
11
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
12
+ return error;
13
+ }
14
+
15
+ function optionalInteger(value, flag, min, max) {
16
+ if (value === null || value === undefined || value === "") return null;
17
+ const number = Number(String(value).trim());
18
+ if (!Number.isSafeInteger(number) || number < min || number > max) {
19
+ throw validationError(`${flag} must be an integer ${min}-${max}.`);
20
+ }
21
+ return number;
22
+ }
23
+
24
+ export function buildRolesOperation(options) {
25
+ const query = new URLSearchParams();
26
+ const minLevel = optionalInteger(options.adminMinLevel, "--min-level", 0, 10);
27
+ const limit = optionalInteger(options.adminLimit, "--limit", 1, 500);
28
+ if (minLevel !== null) query.set("minLevel", String(minLevel));
29
+ if (limit !== null) query.set("limit", String(limit));
30
+ const qs = query.toString();
31
+ return {
32
+ name: "roles.read",
33
+ method: "GET",
34
+ path: `/cli/admin/roles${qs ? `?${qs}` : ""}`,
35
+ body: undefined,
36
+ mutates: false,
37
+ requiresRun: false,
38
+ };
39
+ }
40
+
41
+ function day(seconds) {
42
+ return seconds ? new Date(seconds * 1000).toISOString().slice(0, 10) : "never";
43
+ }
44
+
45
+ export function formatRoles(data) {
46
+ const lines = [];
47
+ const points = (data.fromAchievementPoints || [])
48
+ .map((p) => `level ${p.managementLevel} from ${p.minAchievementPoints} AP`)
49
+ .join(", ");
50
+ lines.push(
51
+ `Admin pages and lumine admin need management level ${data.adminLevel}+. A member's level is the higher of their user type's and their achievement points' (${points || "none"}; points never pass level ${data.maxLevelFromAchievementPoints}).`,
52
+ );
53
+ for (const type of data.types || []) {
54
+ lines.push("");
55
+ lines.push(`${type.label}: level ${type.managementLevel}, ${type.holders} holder${type.holders === 1 ? "" : "s"}`);
56
+ if (!type.members) continue;
57
+ for (const m of type.members) {
58
+ lines.push(
59
+ ` #${m.userId} ${m.username}${m.realName ? ` (${m.realName})` : ""}${m.isOwner ? " [owner]" : ""} · last active ${day(m.lastActive)}`,
60
+ );
61
+ }
62
+ if (type.members.length < type.holders) {
63
+ lines.push(` … ${type.holders - type.members.length} more (raise --limit)`);
64
+ }
65
+ }
66
+ if (data.minLevel > 0) {
67
+ lines.push("");
68
+ lines.push(`Members listed for level ${data.minLevel}+ types; --min-level 0 lists every type's holders.`);
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+
73
+ export function printRoles(data) {
74
+ console.log(formatRoles(data));
75
+ }
package/lib/admin.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  buildOwnerTraceOperation,
39
39
  printOwnerTrace,
40
40
  } from "./admin-owner-trace.js";
41
+ import { buildRolesOperation, printRoles } from "./admin-roles.js";
41
42
  import { formatChatReportResult } from "./admin-chat-reports.js";
42
43
  import { formatIdentityDeviceUsage } from "./admin-identity-inspection.js";
43
44
  import {
@@ -241,6 +242,75 @@ function parseReviewRequestRef(target, { type: fixedType, typeOption } = {}) {
241
242
  return { type, id: parseRequiredInteger(raw, "Request ID", 1) };
242
243
  }
243
244
 
245
+
246
+ const NOTABLE_SCORING_ACTIONS = ["suggestions", "review", "profile", "refresh", "dismiss", "note"];
247
+ const NOTABLE_ARCHETYPES = ["builder", "adapter", "thinker", "community"];
248
+ const NOTABLE_POLARITIES = ["positive", "negative", "neutral"];
249
+
250
+ function requireNotableTarget(target, usage) {
251
+ const text = String(target || "").trim();
252
+ if (!text) throw cliValidationError(usage);
253
+ return text;
254
+ }
255
+
256
+ function buildNotableScoringOperation({ action, target, options }) {
257
+ if (action === "suggestions") {
258
+ const archetype = String(options.notableArchetype || "").trim();
259
+ if (archetype && !NOTABLE_ARCHETYPES.includes(archetype)) {
260
+ throw cliValidationError(`--archetype is one of: ${NOTABLE_ARCHETYPES.join(", ")}.`);
261
+ }
262
+ return readOperation(
263
+ "notable.suggestions",
264
+ withQuery("/cli/admin/notable/suggestions", {
265
+ archetype,
266
+ limit: options.notableLimit
267
+ ? parseRequiredInteger(options.notableLimit, "--limit", 1, 100)
268
+ : "",
269
+ }),
270
+ { requiresRun: false },
271
+ );
272
+ }
273
+ if (action === "review") {
274
+ return readOperation("notable.review", "/cli/admin/notable/review", { requiresRun: false });
275
+ }
276
+ if (action === "profile") {
277
+ const who = requireNotableTarget(target, "Usage: lumine admin notable profile <userId|username>.");
278
+ return readOperation("notable.profile", `/cli/admin/notable/profile/${encodeURIComponent(who)}`, { requiresRun: false });
279
+ }
280
+ if (action === "refresh") {
281
+ // scoring takes ~20 s on production: the 20 s default would time out and a
282
+ // retry would ask for a second refresh (the API refuses that, busy)
283
+ return writeOperation("notable.refresh", "POST", "/cli/admin/notable/refresh", {}, { requiresRun: false, timeoutMs: 120000 });
284
+ }
285
+ if (action === "dismiss") {
286
+ const who = requireNotableTarget(target, "Usage: lumine admin notable dismiss <userId|username> --note <why>.");
287
+ const note = String(options.note || "").trim();
288
+ if (!note) throw cliValidationError("Say why with --note <text> (kept, and shown if they are suggested again).");
289
+ return writeOperation("notable.dismiss", "POST", "/cli/admin/notable/dismiss", { target: who, note }, { requiresRun: false });
290
+ }
291
+ // note
292
+ const who = requireNotableTarget(
293
+ target,
294
+ "Usage: lumine admin notable note <userId|username> --polarity positive|negative|neutral --trait <word> --note <what you saw> [--evidence <link>].",
295
+ );
296
+ const polarity = String(options.notablePolarity || "").trim();
297
+ if (!NOTABLE_POLARITIES.includes(polarity)) {
298
+ throw cliValidationError(`--polarity is one of: ${NOTABLE_POLARITIES.join(", ")}.`);
299
+ }
300
+ const trait = String(options.notableTrait || "").trim();
301
+ const note = String(options.note || "").trim();
302
+ if (!trait || !note) {
303
+ throw cliValidationError("A character note needs --trait <word> and --note <what you saw>.");
304
+ }
305
+ return writeOperation(
306
+ "notable.note",
307
+ "POST",
308
+ "/cli/admin/notable/notes",
309
+ { target: who, polarity, trait, note, evidenceUrl: String(options.notableEvidence || "").trim() },
310
+ { requiresRun: false },
311
+ );
312
+ }
313
+
244
314
  // Management approvals (dob/mentor/meetup) and the teacher audit.
245
315
  const APPROVAL_USAGE =
246
316
  "Use: lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup] | show <id> | approve <id> [--reason <text>] | reject <id> [--reason <text>] | supermod <id> [--reason <text>]";
@@ -300,7 +370,7 @@ function buildApprovalOperation({ action, target, options }) {
300
370
  // Code agent passes wrote (no model is called), then review what they could
301
371
  // not settle (Mikey 2026-10-06: bulk AI work runs on Claude Code agents).
302
372
  const ENGLISH_USAGE =
303
- "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] | retag <questionId> --skill <code> [--reason <text>]";
373
+ "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] | retag <questionId> --skill <code> [--reason <text>] | build-candidates [--after <questionId>] [--limit <n>] | build-import <verdicts.json> | check-candidates [--after <questionId>] [--limit <n>] | check-import <verdicts.json> | check-broken [--after <questionId>] [--limit <n>]";
304
374
  const ENGLISH_IMPORT_MAX = 500;
305
375
 
306
376
  function buildEnglishOperation({ action, target, options }) {
@@ -335,6 +405,41 @@ function buildEnglishOperation({ action, target, options }) {
335
405
  if (action === "summary") {
336
406
  return readOperation("english.summary", "/cli/admin/english/tag-summary", { requiresRun: false });
337
407
  }
408
+ // Grammar Quest "Build it" (Mikey 2026-10-10): the sentences still waiting
409
+ // for the agent pass's chunking verdict, and importing its verdicts.
410
+ if (action === "build-candidates") {
411
+ // the parser leaves an omitted --after as ""
412
+ const after = options.adminAfter ? parseRequiredInteger(options.adminAfter, "--after", 0, 2147483647) : 0;
413
+ return readOperation("english.build_candidates", withQuery("/cli/admin/english/build-chunks/candidates", { after, limit: limitOf(1000, 200) }), { requiresRun: false });
414
+ }
415
+ if (action === "build-import") {
416
+ if (!target) throw cliValidationError(ENGLISH_USAGE);
417
+ const raw = readAdminJsonFile(target, "<verdicts.json>", { maxBytes: 4 * 1024 * 1024 });
418
+ const items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : null;
419
+ if (!items || !items.length || items.length > ENGLISH_IMPORT_MAX) {
420
+ throw cliValidationError(`The file must hold 1 to ${ENGLISH_IMPORT_MAX} verdicts (an array, or { items: [...] }).`);
421
+ }
422
+ return writeOperation("english.build_import", "POST", "/cli/admin/english/build-chunks/import", { items }, { requiresRun: false, timeoutMs: 180000 });
423
+ }
424
+ // Grammar Quest item checks (Mikey 2026-10-10): every item as each question
425
+ // type shows it, still waiting for the agent pass's verdict; importing its
426
+ // verdicts; and the items it found broken, for Mikey to decide on.
427
+ if (action === "check-candidates" || action === "check-broken") {
428
+ // the parser leaves an omitted --after as ""
429
+ const after = options.adminAfter ? parseRequiredInteger(options.adminAfter, "--after", 0, 2147483647) : 0;
430
+ return action === "check-candidates"
431
+ ? readOperation("english.check_candidates", withQuery("/cli/admin/english/item-checks/candidates", { after, limit: limitOf(1000, 200) }), { requiresRun: false })
432
+ : readOperation("english.check_broken", withQuery("/cli/admin/english/item-checks/broken", { after, limit: limitOf(500, 100) }), { requiresRun: false });
433
+ }
434
+ if (action === "check-import") {
435
+ if (!target) throw cliValidationError(ENGLISH_USAGE);
436
+ const raw = readAdminJsonFile(target, "<verdicts.json>", { maxBytes: 4 * 1024 * 1024 });
437
+ const items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : null;
438
+ if (!items || !items.length || items.length > ENGLISH_IMPORT_MAX) {
439
+ throw cliValidationError(`The file must hold 1 to ${ENGLISH_IMPORT_MAX} verdicts (an array, or { items: [...] }).`);
440
+ }
441
+ return writeOperation("english.check_import", "POST", "/cli/admin/english/item-checks/import", { items }, { requiresRun: false, timeoutMs: 300000 });
442
+ }
338
443
  // One learner's per-skill mastery and the sentences a report may use.
339
444
  // Private learner data: a reason is required and every read is audited.
340
445
  if (action === "mastery") {
@@ -2286,6 +2391,10 @@ export function parseAdminOperation(options) {
2286
2391
  if (namespace === "owner-trace") {
2287
2392
  return buildOwnerTraceOperation(options);
2288
2393
  }
2394
+ // Who holds which management level (admin-roles.js); read-only, no run.
2395
+ if (namespace === "roles") {
2396
+ return buildRolesOperation(options);
2397
+ }
2289
2398
 
2290
2399
  if (namespace === "correction" || namespace === "corrections") {
2291
2400
  if (!action || action === "status") {
@@ -2611,7 +2720,7 @@ export function parseAdminOperation(options) {
2611
2720
  // reviews as the admin controls on the website quest page. Mikey's own
2612
2721
  // decision, any time, never in a daily run.
2613
2722
  const usage =
2614
- "Usage: 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|show|approve|reject|merge] | 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>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
2723
+ "Usage: 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|show|approve|reject|merge] | 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>.";
2615
2724
  const note = String(options.note || "").trim();
2616
2725
  if (note.length > 1000) {
2617
2726
  throw cliValidationError("--note must be at most 1000 characters.");
@@ -2799,6 +2908,30 @@ export function parseAdminOperation(options) {
2799
2908
  { requiresRun: false },
2800
2909
  );
2801
2910
  }
2911
+ if (action === "set-branch") {
2912
+ // Staff correct a member's branch (Mikey 2026-10-10) instead of a
2913
+ // database write: an official branch (verified at once), a name in the
2914
+ // branch registry, or "Not a Twinkle student". Former students get the
2915
+ // branch they went to. The note is private to admins; nobody is messaged.
2916
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2917
+ const memberId = parseRequiredInteger(extra, "Member user ID", 1);
2918
+ const branch = String(options.adminBranch || "").trim();
2919
+ if (!branch) {
2920
+ throw cliValidationError(
2921
+ 'lumine admin meetup set-branch <crewId> <userId> needs --branch "<official branch>" (or --branch "Not a Twinkle student").',
2922
+ );
2923
+ }
2924
+ if (branch.includes(",")) {
2925
+ throw cliValidationError("set-branch takes exactly one --branch.");
2926
+ }
2927
+ return writeOperation(
2928
+ "meetup.set-branch",
2929
+ "POST",
2930
+ `/cli/admin/meetup-quest/crews/${crewId}/members/${memberId}/branch`,
2931
+ { branch, note },
2932
+ { requiresRun: false },
2933
+ );
2934
+ }
2802
2935
  if (action === "request-changes") {
2803
2936
  const crewId = parseRequiredInteger(target, "Crew ID", 1);
2804
2937
  if (!note) throw cliValidationError("request-changes needs --note <what the whole crew must change>.");
@@ -3930,6 +4063,12 @@ export function parseAdminOperation(options) {
3930
4063
  );
3931
4064
  }
3932
4065
 
4066
+ // Notable suggestions ranked by the site, the review of fading notables,
4067
+ // and the daily run's character notes (Mikey 2026-10-10).
4068
+ if (namespace === "notable" && NOTABLE_SCORING_ACTIONS.includes(action) && !extra) {
4069
+ return buildNotableScoringOperation({ action, target, options });
4070
+ }
4071
+
3933
4072
  if (namespace === "notable" && ["add", "remove"].includes(action) && !extra) {
3934
4073
  const rawTarget = String(target || "").trim();
3935
4074
  if (!rawTarget) {
@@ -5877,6 +6016,12 @@ function printMeetupCrew(crew) {
5877
6016
  ` Who-are-you check #${check.checkId} · ${check.username} (${check.userId}) · crew #${check.crewId} · ${check.status}${check.askNote ? ` · asked: "${check.askNote}"` : ""} · ${answer}${check.reviewedBy ? ` · ${check.status} by ${check.reviewedBy}` : ""}${check.staffNote ? ` · note: ${check.staffNote}` : ""}`,
5878
6017
  );
5879
6018
  }
6019
+ // staff's branch corrections (admins only), newest first
6020
+ for (const change of crew.branchChanges || []) {
6021
+ console.log(
6022
+ ` Branch set by ${change.actorUsername || "staff"} ${shortDate(change.createdAt)} · ${change.username} (${change.userId}): "${change.oldBranch || "none"}" -> "${change.newBranch}"${change.note ? ` · private note: ${change.note}` : ""}`,
6023
+ );
6024
+ }
5880
6025
  for (const parent of crew.parentContacts || []) {
5881
6026
  console.log(
5882
6027
  ` Parent of ${parent.childUsername}: ${parent.status}${parent.verifiedGuardian ? " (guardian on file)" : parent.staffVerified ? " (staff verified)" : " (child-typed address: NOT verified)"} · ${parent.email}${parent.question ? ` · asks: "${parent.question}"` : ""}`,
@@ -6033,6 +6178,16 @@ function printMeetupResult({ operation, data, result }) {
6033
6178
  if (data.crew) printMeetupCrew(data.crew);
6034
6179
  return;
6035
6180
  }
6181
+ if (operation.name === "meetup.set-branch") {
6182
+ const r = data.result || {};
6183
+ console.log(
6184
+ result?.changed === false
6185
+ ? `Member ${r.userId} on crew #${r.crewId} already has branch "${r.branch}". Nothing changed.`
6186
+ : `Member ${r.userId} on crew #${r.crewId}: branch "${r.oldBranch || "none"}" -> "${r.branch}"${r.branchVerified ? " (verified)" : r.branchStatus === "pending" ? " (waits for its branch decision)" : ""}.`,
6187
+ );
6188
+ if (data.crew) printMeetupCrew(data.crew);
6189
+ return;
6190
+ }
6036
6191
  if (operation.name === "meetup.show" || operation.name === "meetup.parent-reply") {
6037
6192
  printMeetupCrew(data.crew || {});
6038
6193
  return;
@@ -6302,6 +6457,71 @@ function printTeacherEntry(teacher, { full = false } = {}) {
6302
6457
  if (teacher.fingerprint) console.log(`Fingerprint: ${teacher.fingerprint}`);
6303
6458
  }
6304
6459
 
6460
+ function notableScoreLine(member) {
6461
+ const s = member.scores || {};
6462
+ const trend = member.trend
6463
+ ? ` (week: ${["builder", "adapter", "thinker", "community"].map((a) => `${a[0]}${member.trend[a] >= 0 ? "+" : ""}${Math.round(member.trend[a])}`).join(" ")})`
6464
+ : "";
6465
+ return `builder ${Math.round(s.builder || 0)} · adapter ${Math.round(s.adapter || 0)} · thinker ${Math.round(s.thinker || 0)} · community ${Math.round(s.community || 0)}${trend}`;
6466
+ }
6467
+
6468
+ function printNotableMember(member, prefix = "") {
6469
+ console.log(`${prefix}${member.username} (#${member.userId}) · best: ${member.bestArchetype || "-"} · active ${member.activeDays} of 60 days`);
6470
+ console.log(` ${notableScoreLine(member)}`);
6471
+ for (const archetype of ["builder", "adapter", "thinker", "community"]) {
6472
+ const facts = (member.facts || {})[archetype] || [];
6473
+ if (facts.length) console.log(` ${archetype}: ${facts.join("; ")}`);
6474
+ }
6475
+ for (const note of member.characterNotes || []) {
6476
+ console.log(` character [${note.polarity}] ${note.trait}: ${note.note}${note.evidenceUrl ? ` ${note.evidenceUrl}` : ""}`);
6477
+ }
6478
+ if (member.resurfaced) {
6479
+ console.log(` suggested again: dismissed before ("${member.resurfaced.note}"), score has since risen`);
6480
+ }
6481
+ }
6482
+
6483
+ function printNotableScoringResult({ operation, data }) {
6484
+ if (operation.name === "notable.refresh") {
6485
+ if (data.busy) {
6486
+ console.log("A refresh is already running; its scores will be there when it finishes (check with notable suggestions).");
6487
+ return;
6488
+ }
6489
+ console.log(`Scored ${data.scored} active members; ${data.qualified} qualify (active on 8+ of the last 60 days).`);
6490
+ return;
6491
+ }
6492
+ if (operation.name === "notable.suggestions") {
6493
+ const when = data.computedAt ? new Date(data.computedAt * 1000).toISOString().slice(0, 16).replace("T", " ") : "never";
6494
+ console.log(`Notable suggestions${data.archetype ? ` (${data.archetype})` : ""} · scores from ${when} UTC · ${data.qualified} qualified of ${data.scored} scored`);
6495
+ if (!data.suggestions.length) console.log("No suggestions. Run lumine admin notable refresh first if the scores are old.");
6496
+ for (const member of data.suggestions) printNotableMember(member, `${member.rank}. `);
6497
+ console.log("Facts, not verdicts. Read their posts before proposing; Mikey decides (lumine admin notable add | dismiss).");
6498
+ return;
6499
+ }
6500
+ if (operation.name === "notable.review") {
6501
+ console.log(`Current notables to review (best score under ${data.fadingBelow}, or inactive): ${data.notables.length}`);
6502
+ for (const member of data.notables) {
6503
+ printNotableMember(member, "- ");
6504
+ console.log(` why listed: ${member.reason}`);
6505
+ }
6506
+ return;
6507
+ }
6508
+ if (operation.name === "notable.profile") {
6509
+ if (!data) return console.log("No such member.");
6510
+ printNotableMember(data, data.isNotable ? "[notable] " : "");
6511
+ if (!data.scored) console.log(" not scored (not active in the last 60 days, or not eligible)");
6512
+ return;
6513
+ }
6514
+ if (operation.name === "notable.dismiss") {
6515
+ console.log(`Dismissed ${data.username} (#${data.userId}): "${data.note}". Suggested again only after a clear rise (best score now ${data.bestScoreAtDismissal}).`);
6516
+ return;
6517
+ }
6518
+ console.log(
6519
+ data.duplicate
6520
+ ? `Already noted today: character note #${data.id} for ${data.username} (#${data.userId}).`
6521
+ : `Character note #${data.id} recorded for ${data.username} (#${data.userId}): [${data.polarity}] ${data.trait}.`,
6522
+ );
6523
+ }
6524
+
6305
6525
  function printApprovalResult({ operation, data }) {
6306
6526
  if (operation.name === "approvals.list") {
6307
6527
  const items = data.items || [];
@@ -6399,6 +6619,16 @@ function printEnglishResult({ operation, data }) {
6399
6619
  console.log(`Re-levelled ${data.moved} of ${data.received} · ${data.unchanged} already in place · ${data.invalid} invalid`);
6400
6620
  return;
6401
6621
  }
6622
+ if (operation.name === "english.build_candidates") {
6623
+ console.log(`${(data.items || []).length} sentences waiting · ${data.checked} checked so far${data.next ? ` · next page: --after ${data.next}` : ""}`);
6624
+ (data.items || []).forEach((item) => console.log(` #${item.questionId} v${item.version} · ${item.skill} · ${item.sentence}`));
6625
+ return;
6626
+ }
6627
+ if (operation.name === "english.build_import") {
6628
+ console.log(`Stored ${data.stored}: ${data.ok} ok · ${data.ambiguous} ambiguous · ${data.unsuitable} unsuitable · ${(data.rejected || []).length} rejected`);
6629
+ (data.rejected || []).forEach((r) => console.log(` #${r.questionId}: ${r.reason}`));
6630
+ return;
6631
+ }
6402
6632
  if (operation.name === "english.summary") {
6403
6633
  console.log(`${data.total} questions · passes: ${(data.passes || []).map((row) => `${row.model} ${row.items}`).join(" · ") || "none yet"}`);
6404
6634
  console.log(`Tag status: ${(data.byStatus || []).map((row) => `${row.status} ${row.items}`).join(" · ") || "none yet"}`);
@@ -6583,6 +6813,10 @@ function printAdminResult({ operation, result }) {
6583
6813
  printOwnerTrace(data);
6584
6814
  return;
6585
6815
  }
6816
+ if (operation.name === "roles.read") {
6817
+ printRoles(data);
6818
+ return;
6819
+ }
6586
6820
  if (operation.name.startsWith("reward-bank.")) {
6587
6821
  if (operation.name === "reward-bank.seed") {
6588
6822
  console.log(
@@ -6620,6 +6854,10 @@ function printAdminResult({ operation, result }) {
6620
6854
  printTeacherBranchResult({ operation, data });
6621
6855
  return;
6622
6856
  }
6857
+ if (["notable.suggestions", "notable.review", "notable.profile", "notable.refresh", "notable.dismiss", "notable.note"].includes(operation.name)) {
6858
+ printNotableScoringResult({ operation, data });
6859
+ return;
6860
+ }
6623
6861
  if (operation.name.startsWith("approvals.") || operation.name.startsWith("teachers.")) {
6624
6862
  printApprovalResult({ operation, data });
6625
6863
  return;
package/lib/commands.js CHANGED
@@ -2623,6 +2623,7 @@ export function parseArgs(args) {
2623
2623
  adminSince: raw.since ? String(raw.since) : "",
2624
2624
  adminPath: raw.path ? String(raw.path) : "",
2625
2625
  adminPerf: Boolean(raw.perf),
2626
+ adminMinLevel: raw.minLevel === undefined ? null : raw.minLevel,
2626
2627
  adminIncludeLegacy: Boolean(raw.includeLegacy),
2627
2628
  adminIncludePrivateEvidence: parseBoolean(
2628
2629
  raw.includePrivateEvidence,
@@ -2738,6 +2739,12 @@ export function parseArgs(args) {
2738
2739
  adminEmail: raw.email ? String(raw.email) : "",
2739
2740
  adminMode: raw.mode ? String(raw.mode) : "",
2740
2741
  adminType: raw.type ? String(raw.type) : "",
2742
+ // lumine admin notable suggestions|note (raw values; validated by the builder)
2743
+ notableArchetype: raw.archetype ? String(raw.archetype) : "",
2744
+ notablePolarity: raw.polarity ? String(raw.polarity) : "",
2745
+ notableTrait: raw.trait ? String(raw.trait) : "",
2746
+ notableEvidence: raw.evidence ? String(raw.evidence) : "",
2747
+ notableLimit: raw.limit ? String(raw.limit) : "",
2741
2748
  adminKind: raw.kind ? String(raw.kind) : "",
2742
2749
  adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
2743
2750
  adminUnviewed: Boolean(raw.unviewed),
@@ -3193,7 +3200,7 @@ export function printHelp() {
3193
3200
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3194
3201
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3195
3202
  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>]
3196
- 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>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3203
+ 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)
3197
3204
  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)
3198
3205
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3199
3206
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
@@ -3242,6 +3249,7 @@ export function printHelp() {
3242
3249
  lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
3243
3250
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3244
3251
  lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load; --perf shows only those; kept 30 days)
3252
+ lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3245
3253
  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]
3246
3254
  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]
3247
3255
  lumine admin comment post --draft-id <id> [--json]
@@ -3271,6 +3279,12 @@ export function printHelp() {
3271
3279
  lumine admin notable status <user-id|username> [--json]
3272
3280
  lumine admin notable add <user-id|username> --note <text> [--json]
3273
3281
  lumine admin notable remove <user-id|username> --note <text> [--json]
3282
+ lumine admin notable suggestions [--archetype builder|adapter|thinker|community] [--limit <1-100>] [--json] (ranked by the site, with the facts behind each)
3283
+ lumine admin notable review [--json] (current notables whose signals faded or who went inactive)
3284
+ lumine admin notable profile <user-id|username> [--json] (one member's scores, facts, trend and character notes)
3285
+ lumine admin notable refresh [--json] (recompute every active member's scores; the daily run does this first)
3286
+ lumine admin notable dismiss <user-id|username> --note <why> [--json] (Mikey declined; suggested again only after a clear rise)
3287
+ lumine admin notable note <user-id|username> --polarity positive|negative|neutral --trait <word> --note <what you saw> [--evidence <twin-kle.com link>] [--json]
3274
3288
  lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3275
3289
 
3276
3290
  Examples:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
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.1
4
- Updated: 2026-10-09
5
- Generated: 2026-10-09T12:57:05.120Z
3
+ Version: 1.64.3
4
+ Updated: 2026-10-10
5
+ Generated: 2026-10-10T06:47:00.045Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -33,10 +33,10 @@ Generated: 2026-10-09T12:57:05.120Z
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' | '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.
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; 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), 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). 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.
@@ -128,7 +128,7 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
128
128
  - Returns: { success, url }
129
129
  - Open a recognized Twinkle content page in the parent app from a viewer click or tap.
130
130
  - Call this directly from a user click or tap handler; calls without an active user action are rejected.
131
- - The trusted parent displays the canonical destination and requires viewer confirmation before navigating.
131
+ - The trusted parent checks the destination and opens it right away, with no extra confirmation step.
132
132
  - Only recognized Twinkle content URLs are accepted. The parent preserves its current signed-in origin when opening the content.
133
133
  - Use navigate() for routes inside the current Build and openContent() for Twinkle subjects, comments, apps, profiles, and other content pages.
134
134
  - Example: await Twinkle.app.openContent('https://www.twin-kle.com/subjects/432');
@@ -514,6 +514,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
514
514
  - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
515
515
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
516
516
  - The server validates the final shape; OpenAI/xAI routes can retry malformed output, while Anthropic routes (claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
517
+ - If the model declines a request, the call rejects with code ai_declined (HTTP 422). The tokens it used are charged to AI Energy like a finished call. Show the viewer a short message so they can rephrase; do not automatically retry the same prompt.
517
518
  - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 'number' }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
518
519
  - onChatStatus(listener) | scopes: none
519
520
  - Returns: unsubscribe function
@@ -594,8 +595,8 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
594
595
  - The character route also accepts text or message fields for compatibility, but generated apps should use content.
595
596
  - The server keeps the latest 16 valid character history entries.
596
597
  - Pass onText/onStatus for streaming dialogue. Omit callbacks for non-streaming dialogue where the promise resolves with the final response.
597
- - Inside Build character chat, thinkingMode low uses Lite Mode: Zero and Ciel both use GPT-6 Luna with reasoning disabled; confirmed provider usage consumes the viewer's AI Energy and is usually cheaper than High.
598
- - Inside Build character chat, thinkingMode medium uses the same normal chat model routing: Zero and Ciel both use GPT-6 Luna with reasoning disabled and normal AI Energy.
598
+ - Inside Build character chat, thinkingMode low uses Lite Mode: Zero uses GPT-6 Luna with reasoning disabled and Ciel uses Claude Haiku 5.5 with low thinking; confirmed provider usage consumes the viewer's AI Energy and is usually cheaper than High.
599
+ - Inside Build character chat, thinkingMode medium uses the same normal chat model routing: Zero uses GPT-6 Luna with reasoning disabled and Ciel uses Claude Haiku 5.5 with low thinking, with normal AI Energy. If Anthropic is unavailable, Ciel's reply falls back to GPT-6 Luna.
599
600
  - Inside Build character chat, thinkingMode high uses Think Hard chat routing and high AI Energy: Zero uses Grok 4.7 with high reasoning and Ciel uses Claude Opus 5.5 with high thinking.
600
601
  - When AI Energy is empty, Low, Medium, and High all reject before new provider work; there is no free fallback mode.
601
602
  - Pass roomContext as a short shared scene transcript so Zero and Ciel can know what happened in the same room.
@@ -1492,13 +1493,13 @@ world.updatePresence({ x, y, z, facing });
1492
1493
  - Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
1493
1494
  - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
1494
1495
  - Reconcile a durable local reward outbox after a lost claim reply: awarded confirms the exact payment; pending means no receipt yet for a current unexpired challenge, so retry the same challengeId. expired or not_found confirms no paid receipt and no claim possible for that ID under the current release. Never refund app items just because getStatus history omitted an older claim or a network request failed. Preview has no durable paid receipts; keep it separate from live recovery.
1495
- - await Twinkle.rewards.start({ ruleId, levelIndex? }) | scopes: rewards:claim
1496
+ - await Twinkle.rewards.start({ ruleId, levelIndex?, completionToken? }) | scopes: rewards:claim
1496
1497
  - Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1497
1498
  - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
1498
1499
  - Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
1499
1500
  - For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
1500
1501
  - For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
1501
- - For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today.
1502
+ - For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today.
1502
1503
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1503
1504
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1504
1505
  - Verify a bounded batch of inputs for an approved server-simulated climb.
@@ -1507,14 +1508,15 @@ world.updatePresence({ x, y, z, facing });
1507
1508
  - On respawn or a return from another world, begin a fresh run at the canonical spawn via start. Preserve other worlds and gameplay. Do not submit positions, scores, secret keys, or a client completion flag. Preview tokens can never be redeemed in the published app.
1508
1509
  - For breadface-v1, breadface-v2 and breadface-v3, send 1–1,000 chronological [dt, inputBits] frames (dt > 0 and <= 0.033). Bits are left=1, right=2, jumpHeld=4, jumpQueued=8, fireHeld=16. The server replays the registered frozen game; only a legitimate goal and any rule-specific bonus qualify. Honor maxFrames and retain unacknowledged inputs for a bounded retry.
1509
1510
  - For study-record-v1, send record { work, learning, nextStep? } and one stable requestId (16–64 ASCII letters, numbers, hyphens or underscores, e.g. crypto.randomUUID()). The combined record is at most 4,000 UTF-8 bytes. Show that each check uses AI Energy before sending; accept, revise and uncertain decisions all incur measured check cost. Reuse the requestId for a lost response; do not poll in a loop. An accepted, billed review returns completion.completed and a bound token; use that token with claim. A preview charges for the check but never awards real XP/Coins. Keep the record and show completion.message on a refusal or uncertain result.
1510
- - await Twinkle.rewards.claim({ challengeId, answers?: [number], completionToken?: string }) | scopes: rewards:claim
1511
- - Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry }, questions: [{ prompt, hint?, guide? }], balances: { xp, coins } }
1512
- - Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. The receipt’s xp/coins are what was actually paid: the full amounts on a first try, the retry share after a wrong answer (attempt > 1). Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically. Under retry.paidAttempts a correct answer past that attempt returns awarded: true with a zero receipt: solved, not paid.
1511
+ - await Twinkle.rewards.claim({ challengeId, answers?: [number], completionToken?: string, subject?: { kind?: 'song' | 'item', title, path?, imageUrl? } }) | scopes: rewards:claim
1512
+ - Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry, cappedFrom?: { xp, coins } }, questions: [{ prompt, hint?, guide? }], balances: { xp, coins } }
1513
+ - Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. The receipt’s xp/coins are what was actually paid: the full amounts on a first try, the retry share after a wrong answer (attempt > 1), clamped to the learner's remaining userDailyXP/userDailyCoins room today (each currency on its own). A clamped receipt carries cappedFrom: { xp, coins }, the amounts the rule would have paid; the rule still counts as earned today and the rest cannot be claimed later. build_reward_budget_reached (HTTP 409) means nothing would be paid: both rooms are used up, or the rule pays only a currency whose room is used up. This includes one-time rules: a capped award on a maxLifetimeClaims rule also pays only what fits and uses up that lifetime claim (e.g. a one-time 5,000 XP reward claimed at 9,999/10,000 XP pays 1 XP and is done). Use cappedFrom to explain it, e.g. "+1 XP (today's limit; this reward was 5,000 XP)". Always show receipt.xp/receipt.coins, never the rule's amounts, as what the learner got, and hide a zero part (a receipt can pay 0 XP and some Coins, or the reverse): show "+50 Coins", not "+0 XP · +50 Coins". Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically. Under retry.paidAttempts a correct answer past that attempt returns awarded: true with a zero receipt: solved, not paid.
1513
1514
  - Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
1514
1515
  - A wrong answer within two seconds of the previous one is refused with build_reward_throttled (HTTP 429) and does not count; wait for the person to try again rather than retry-looping.
1516
+ - Optional subject says what the reward was for; it is shown on the public bounty story on Home while the app is public (e.g. "listened to Night Drive"). title is plain text up to 80 characters, path is this app's share-link segment (the one you pass to Twinkle.app.getShareUrl, letters, digits and dashes), imageUrl a Twinkle-hosted image. A malformed subject is refused with build_reward_subject_invalid (HTTP 400); an older server without subjects ignores it. Proof-backed rules that read the item on the server (Groove Lab songs) replace it with the stored title, link, cover and creator.
1515
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.
1516
- - 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. build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
1517
- - 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 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.
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
+ - 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.
1518
1520
  - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1519
1521
  - 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 }
1520
1522
  - 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.
@@ -1592,10 +1594,10 @@ world.updatePresence({ x, y, z, facing });
1592
1594
  - attempts counts today's wrong rematch answers; reward is the first-try amount and retryReward the amount for paid retries (up to paidAttempts); later correct answers count as solved but pay nothing.
1593
1595
  - reason 'already-studied' means the viewer opened that puzzle's guide or explanations before. reason 'no-puzzle' (entry null) when no retired puzzle qualifies today or today's stored pick can no longer be read. The day's first call stores the day's pick, chosen among the strongest candidates with a server-only seed so it cannot be predicted.
1594
1596
  - await Twinkle.rewards.claimRematch({ answer?: number, answers?: [number], text?: string }) | scopes: rewards:claim
1595
- - Returns: { mode: "live", awarded: false, correct: false, counted: true, challengeId, attempts, attemptsRemaining, nextReward: { xp, coins }, read?, practice, vault } | { mode: "live", awarded: true, duplicate: false, correct: true, counted: true, read?, receipt: { id, challengeId, ruleId, xp, coins, attempt, firstTry, createdAt }, balances: { xp, coins }, practice, vault } | { mode: "live", awarded: true, duplicate: true, receipt, balances } | { mode: "live", awarded: false, correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", awarded: false, counted: false, message }
1597
+ - Returns: { mode: "live", awarded: false, correct: false, counted: true, challengeId, attempts, attemptsRemaining, nextReward: { xp, coins }, read?, practice, vault } | { mode: "live", awarded: true, duplicate: false, correct: true, counted: true, read?, receipt: { id, challengeId, ruleId, xp, coins, attempt, firstTry, createdAt, cappedFrom?: { xp, coins } }, balances: { xp, coins }, practice, vault } | { mode: "live", awarded: true, duplicate: true, receipt, balances } | { mode: "live", awarded: false, correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", awarded: false, counted: false, message }
1596
1598
  - Answer today's daily rematch. A correct answer writes a real receipt (ledger, budgets, Home feed story, leaderboard).
1597
1599
  - Same answer check and tolerance as claim; text is read by the same answer reader as interpretAnswer (unreadable text uses no try). First try pays the rule's full amounts, paid retries the retry share, later correct answers pay nothing. One rematch receipt per player per site day; a repeat call after it returns the same receipt (duplicate: true).
1598
- - Does not use the daily bounty count (userDailyClaims) but counts toward userDailyXP/userDailyCoins (build_reward_budget errors as for claim). Each answer also counts as the player's Vault answer for that puzzle (practice rating, Vault stats). Errors: build_reward_rematch_ineligible (409), build_reward_rematch_unavailable (404/409), build_reward_rematch_day_changed (409, reload getRematch), build_reward_throttled (429; wait between wrong answers).
1600
+ - Does not use the daily bounty count (userDailyClaims) but counts toward userDailyXP/userDailyCoins (clamped to the room left, with cappedFrom and build_reward_budget_reached as for claim; show receipt.xp/coins). Each answer also counts as the player's Vault answer for that puzzle (practice rating, Vault stats). Errors: build_reward_rematch_ineligible (409), build_reward_rematch_unavailable (404/409), build_reward_rematch_day_changed (409, reload getRematch), build_reward_throttled (429; wait between wrong answers).
1599
1601
  - await Twinkle.rewards.getBankStatus({ ruleId }) | scopes: rewards:claim
1600
1602
  - Returns: { mode, ruleId, dayKey, startStep, steps: [{ step, reward: { xp, coins } }], topics: [{ key, title, step, questionsAtStep }], today: { xp, coins, claims }, budgets: { userDailyXP, userDailyCoins } }
1601
1603
  - Read a generated-quiz rule's ladder: what each step pays, the learner's step on every topic, and today's earnings.
@@ -1614,11 +1616,11 @@ world.updatePresence({ x, y, z, facing });
1614
1616
  - 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.
1615
1617
  - The question joins the shared bank: every other learner at that step gets it before anything new is written.
1616
1618
  - await Twinkle.rewards.claimBankQuestion({ challengeId, answer: number }) | scopes: rewards:claim
1617
- - Returns: { correct, awarded, duplicate?, budgetReached?, receipt: { id, questionId, step, xp, coins, attempt, firstTry } | null, balances?, ladder?: { step, previousStep }, question: { ..., guide?, answer? }, attempts, attemptsRemaining, stats }
1619
+ - Returns: { correct, awarded, duplicate?, budgetReached?, receipt: { id, questionId, step, xp, coins, attempt, firstTry, cappedFrom?: { xp, coins } } | null, balances?, ladder?: { step, previousStep }, question: { ..., guide?, answer? }, attempts, attemptsRemaining, stats }
1618
1620
  - Answer a bank question. The first answer moves the ladder; a correct answer pays the question's step share.
1619
1621
  - 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).
1620
1622
  - 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.
1621
- - 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.
1623
+ - A payment larger than the learner's remaining userDailyXP/userDailyCoins room today pays what fits (receipt.xp/coins, with cappedFrom: { xp, coins } the step share it would have paid); show the receipt amounts. budgetReached: true means the solve counted but nothing fit: today's room is used up. In preview mode nothing is paid and previewReward shows what the live app would pay.
1622
1624
  - await Twinkle.rewards.reportBankQuestion({ questionId, reason }) | scopes: rewards:claim
1623
1625
  - Returns: { reported: true, duplicate, hidden }
1624
1626
  - Report a wrong or unclear bank question the learner has seen.
@@ -1490,6 +1490,19 @@ gaps over 200 ms while the page is visible, bursts within 1 s coalesced) and
1490
1490
  `page-load` (once per load: TTFB, DOMContentLoaded, load, first route ready,
1491
1491
  ms since navigation start).
1492
1492
 
1493
+ ### Who holds which management level (any time, read-only; added 2026-10-10)
1494
+
1495
+ Answer "who can see this?" or "is level N a teacher?" from this command, not
1496
+ from a raw production query. A member's management level is the higher of
1497
+ their user type's level and what their achievement points earn. Points never
1498
+ pass level 2, so every admin (level 3+) is a holder of a listed user type.
1499
+
1500
+ ```bash
1501
+ lumine admin roles # every type; holders listed for level 1+ types
1502
+ lumine admin roles --min-level 3 # only the admin types' holders
1503
+ lumine admin roles --min-level 0 --limit 200 --json
1504
+ ```
1505
+
1493
1506
  ### Reward question-bank seeding (every full daily review; added 2026-09-28)
1494
1507
 
1495
1508
  `generated-quiz` rules fill a question bank on the server: a question is
@@ -3436,6 +3449,33 @@ never opens a new conversation. The message is audited and idempotent, reopens
3436
3449
  the existing DM canonically, and leaves the child's unread pointer untouched.
3437
3450
  A full-run report that skipped the conduct review is incomplete.
3438
3451
 
3452
+ ## Notable suggestions (the site ranks, Mikey decides; added 2026-10-10)
3453
+
3454
+ ```bash
3455
+ lumine admin notable refresh # recompute every active member's scores (~20 s, reader)
3456
+ lumine admin notable suggestions --json # ranked, with scores, weekly trend and the facts behind them
3457
+ lumine admin notable suggestions --archetype thinker --limit 15
3458
+ lumine admin notable review # current notables whose signals faded, or who went inactive
3459
+ lumine admin notable profile Chu # one member: scores, facts, trend, character notes
3460
+ lumine admin notable note Chu --polarity positive --trait "helps others" \
3461
+ --note "Explained a puzzle step by step to a younger member." --evidence https://www.twin-kle.com/comments/123
3462
+ lumine admin notable dismiss Chu --note "not yet" # Mikey declined; suggested again only after a clear rise
3463
+ ```
3464
+
3465
+ Four kinds of valuable person (Mikey's standard). Each kind is ranked separately, and a member is notable for any one:
3466
+ - **builder:** building days, builds improved over 3+ days, distinct players, other kids earning in their builds, merged contributions, readers of their chapters.
3467
+ - **adapter:** activity kinds used, tools new to them, apps earned in, unfamiliar Bounties apps tried.
3468
+ - **thinker:** puzzles others had failed that they solved, first-ever solves, solves after misses, hardest puzzle rating, grammar skills mastered, thoughtful daily answers, Grammarbles level.
3469
+ - **community:** distinct people replying to them, distinct people recommending their posts (bots excluded), meetups attended, crews founded, explanation votes.
3470
+
3471
+ How the scores work:
3472
+ - Each signal becomes a percentile among active members.
3473
+ - An archetype score is the mean of its two strongest signals, so a specialist doesn't need everything.
3474
+ - Members qualify with 8+ active days in the last 60; consistency qualifies, it never ranks.
3475
+ - Teacher rewards are not used.
3476
+
3477
+ Character is not scored. The daily run records what it notices, with a link to the post, through `notable note`. Notes are observations; nothing excludes anyone automatically, and Mikey decides. The same suggestions are on the management page (Notable Users → Suggested).
3478
+
3439
3479
  ## Daily brief (management insights)
3440
3480
 
3441
3481
  ```bash
@@ -3713,6 +3753,19 @@ patterns. Keep the two explicit denominators separate: unchanged stops / all
3713
3753
  budget stops, and unchanged stops / completed manual runs in the same cohort.
3714
3754
  Do not divide by usage reservations or assume busy-refusal counts measure waits.
3715
3755
 
3756
+ Each day also carries `nothingSaved`: the Energy stops that saved nothing,
3757
+ split by cause (`byCause`): `not_started` (no paid work round ran: too big
3758
+ for the model, or one round did not fit), `read_only`, `edit_failed`,
3759
+ `cut_off`, `after_landing` (the apply-only landing round ran and still saved
3760
+ nothing), `early_hand_off`, `other`, and `unattributed` (days before the split
3761
+ shipped). `paidUnchangedRatio` is the nothing-saved rate among stops that paid
3762
+ for a work round; it is meaningful only from the release day of the cause
3763
+ split (before it `not_started` is 0, so it just repeats `unchangedRatio`).
3764
+ `landingRounds` (granted/saved) counts the last-round landings that may use
3765
+ the hand-off reserve, and `resumes` (started /
3766
+ fellBackToFresh) counts Continues that resumed the stopped run's provider
3767
+ thread. Headline the paid rate and the largest cause beside the 30% flag.
3768
+
3716
3769
  Inspect the stop cases' observed starting budget, recorded work/handoff turns,
3717
3770
  remaining Energy and final-reservation spend. Missing lineage is unknown, not
3718
3771
  zero work or zero cost; final-reservation cost can exclude earlier planning
@@ -5030,11 +5083,14 @@ For total JEV cost, include `dailyAiCosts.byProviderModel` for TypeSafe/JEV acro
5030
5083
 
5031
5084
  ## Bridge Builder crew reviews (no daily run required)
5032
5085
 
5086
+ On the website, Zero and Ciel acting for an admin can make these staff decisions too (who-are-you checks, branch corrections, crew, plan and branch reviews), through the same routes the admin's own taps use and only after the admin's yes on the approval card (Mikey, 2026-10-10). A member's Zero or Ciel still cannot.
5087
+
5033
5088
  - `lumine admin meetup list` shows steps waiting for staff. Use `list --status active` to include crews making requested changes.
5034
5089
  - `lumine admin meetup show <crewId>` shows canonical progress, approval status, shared feedback, and private staff evidence.
5035
5090
  - `lumine admin meetup approve-crew <crewId>` and `approve-grownup <crewId>` approve the corresponding ready step.
5036
5091
  - `lumine admin meetup request-changes <crewId> --note <text>` holds the current crew or grown-up step, displays the note to **every crew member**, and announces it in the crew chat. The note is required (maximum 1,000 characters). Members use **Request review again** after making changes; this alerts staff and returns the step to the queue without approving it.
5037
5092
  - `lumine admin meetup send-back <crewId> --note <text>` is for a pending plan or video. Those steps keep their existing revise-and-submit flow.
5038
5093
  - `lumine admin meetup info <crewId> <userId> --decision ask|accept|ask-again|withdraw` is a **private identity check**. Its question and answer are visible only to the named member and staff. Use `request-changes`, not `info`, for instructions the whole crew needs to see.
5094
+ - `lumine admin meetup set-branch <crewId> <userId> --branch <name> [--note <text>]` corrects a member's branch instead of a database write. `--branch` must be an official branch (a registry alias such as `Busan` resolves to its official name, `Haeundae`), a name still pending in the branch registry, or `"Not a Twinkle student"`. Former Twinkle students get the branch they went to. An official branch is verified at once; a pending name waits for its branch decision. The crew's progress, tier and staff alerts follow the new branch; approvals already given stay. The same branch again changes nothing. The private note and the change history are shown only to admins (`meetup show`, the crew page's staff panel); nobody is messaged. The command refuses members who left and crews that are not active.
5039
5095
 
5040
5096
  Every decision requires Mikey's authorization for that crew and action. Mutations are audited and idempotent; `--output <path>` is an explicit request to retain the canonical JSON. General write receipts omit private crew details.