@stage5/lumine 0.3.19 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,11 +64,17 @@ Approvals are capped at 2 GB and never lower a creator below the default.
64
64
  Bridge Builder meetup quest crews (the website's /achievements/bridge-builder
65
65
  page) are reviewed with `lumine admin meetup list [--status review|active|completed|all]`,
66
66
  `show <crewId>` (includes a one-hour link to the proof video),
67
+ `approve-crew <crewId>`, `approve-grownup <crewId>`,
68
+ `request-changes <crewId> --note <text>`,
67
69
  `approve-plan <crewId> [--note <text>]`, `send-back <crewId> --note <text>`
68
70
  (sends back whatever is waiting: the video, else the plan) and
69
71
  `approve <crewId> --attended <userId,userId,...>`, which unlocks the
70
- achievement for every member ticked. At least 3 attendees from 3 different
71
- branches are required.
72
+ achievement for every member ticked once the server confirms the requirements.
73
+ `request-changes` holds the current crew or grown-up approval step, shows the
74
+ staff note to every member on the crew page and chat tracker, and announces it
75
+ in the crew chat. Members can request review again after making the changes;
76
+ staff still need to approve the step. Use `info` only for private questions to
77
+ one member about their identity.
72
78
 
73
79
  For team projects, Lumine mirrors the website workspace flow: choosing or
74
80
  pulling the owner's main project creates or reuses your contribution branch and
@@ -231,10 +237,17 @@ audio, and MIDI data (`.mid`/`.midi`; playback still needs an app-side parser
231
237
  or synth); `lumine assets list` prints your uploads and refreshes
232
238
  `.twinkle/assets.json`.
233
239
 
234
- `lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>` creates
240
+ **Nano Banana migration (0.3.21):** `--model nano-banana` now selects
241
+ Nano Banana 2.1, the fast tier. In CLI 0.3.20 and earlier it selected Pro.
242
+ To preserve Pro output in an existing script, change the option to
243
+ `--model nano-banana-pro` before upgrading. Existing installed CLIs keep
244
+ using Pro through the server's legacy model alias. This CLI release must be
245
+ published after the API update that accepts the new model IDs.
246
+
247
+ `lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>` creates
235
248
  an AI-generated image asset instead of uploading one. `--model` is required
236
249
  (Flare = fast generation; Sunburst = precise editing; gpt-image-2 = original
237
- model; nano-banana = Gemini). Generation spends your Twinkle AI Battery, so the
250
+ model; nano-banana = Gemini Nano Banana 2.1, nano-banana-pro = Gemini Nano Banana Pro). Generation spends your Twinkle AI Battery, so the
238
251
  CLI shows the estimated cost and asks for confirmation first — non-interactive
239
252
  runs must pass
240
253
  `--yes` to consent. `--quality low|medium|high|xhigh|max` applies to GPT Image
@@ -246,7 +259,7 @@ actual battery usage also includes prompt and reference input.
246
259
  `lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
247
260
  thumbnail. `lumine thumbnail capture` screenshots the running app server-side
248
261
  and sets the result (add `--out <file>` to keep a local copy).
249
- `lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>`
262
+ `lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>`
250
263
  generates an AI image and sets it as the thumbnail (the image is also kept as a
251
264
  normal reusable asset); without a prompt the server composes one from the build
252
265
  title and description. Replacing an existing thumbnail asks for confirmation;
@@ -1,3 +1,5 @@
1
+ import { teacherBranchApplyReceipt } from "./admin-teacher-branches.js";
2
+
1
3
  // What a write keeps on disk as its receipt (admin-request-intents.js writes
2
4
  // it to the admin work directory). Most writes keep the API's response; these
3
5
  // return private evidence (a teacher's identity facts, a reported child's
@@ -29,6 +31,8 @@ export function receiptToKeep(operation, result) {
29
31
  })),
30
32
  skipped: (data.skipped || []).map((s) => ({ userId: s.userId ?? null, reason: s.reason ?? null })),
31
33
  });
34
+ case "teachers.branches.apply":
35
+ return keep(teacherBranchApplyReceipt(data));
32
36
  case "chat-reports.set": {
33
37
  const report = data.report || {};
34
38
  return keep({
@@ -0,0 +1,302 @@
1
+ // `lumine admin teachers branches …`: the teacher branch registry (2026-10-08).
2
+ // Which Twinkle branch each approved teacher works at, confirmed by Mikey
3
+ // through the academy networks teachers use, not the branch they typed when
4
+ // they applied. Mikey only, any time, no daily run. Networks are named by a
5
+ // short id and a masked address; a raw address never reaches the CLI.
6
+ //
7
+ // suggest academy networks found live, each with a suggested branch
8
+ // networks the confirmed networks
9
+ // confirm <networkId> --branch <name> [--branch <name>] (two or more: a shared building)
10
+ // unconfirm <networkId>
11
+ // set <userId|username> --branch <name> [--note] owner setting, beats networks
12
+ // clear <userId|username>
13
+ // apply recompute network branches from the confirmed networks
14
+ // list [--branch <name>|none]
15
+
16
+ export const TEACHER_BRANCHES_USAGE =
17
+ "Use: lumine admin teachers branches suggest | networks | confirm <networkId> --branch <name> [--branch <name> ...] [--note <text>] | unconfirm <networkId> | set <userId|username> --branch <name> [--note <text>] | clear <userId|username> | apply | list [--branch <name>|none] [--json]";
18
+
19
+ const BASE = "/cli/admin/teachers/branches";
20
+ // a live scan of a year of teachers' network use: the server stops at 75 s
21
+ const SCAN_TIMEOUT_MS = 150000;
22
+
23
+ function validationError(message) {
24
+ const error = new Error(message);
25
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
26
+ return error;
27
+ }
28
+
29
+ const read = (name, path, extra = {}) => ({
30
+ name,
31
+ method: "GET",
32
+ path,
33
+ body: undefined,
34
+ mutates: false,
35
+ requiresRun: false,
36
+ ...extra,
37
+ });
38
+ const write = (name, path, body, extra = {}) => ({
39
+ name,
40
+ method: "POST",
41
+ path,
42
+ body,
43
+ mutates: true,
44
+ requiresRun: false,
45
+ ...extra,
46
+ });
47
+
48
+ /** --branch (repeatable, or comma-separated) and --branches → names. */
49
+ export function parseBranchNames(raw) {
50
+ return [
51
+ ...new Set(
52
+ String(raw || "")
53
+ .split(",")
54
+ .map((name) => name.trim())
55
+ .filter(Boolean),
56
+ ),
57
+ ];
58
+ }
59
+
60
+ function networkIdOf(raw) {
61
+ const id = String(raw || "").trim().toLowerCase();
62
+ if (!/^[0-9a-f]{10}$/.test(id)) {
63
+ throw validationError(
64
+ "Name the network by the 10-character id `lumine admin teachers branches suggest` printed.",
65
+ );
66
+ }
67
+ return id;
68
+ }
69
+
70
+ function teacherRefOf(raw) {
71
+ const ref = String(raw || "").trim();
72
+ if (!ref) throw validationError(TEACHER_BRANCHES_USAGE);
73
+ return encodeURIComponent(ref);
74
+ }
75
+
76
+ export function buildTeacherBranchOperation({ sub, target, options }) {
77
+ const names = parseBranchNames(options.adminBranch);
78
+ const note = String(options.note || "").trim();
79
+ if (!sub || sub === "list") {
80
+ const branch = names.join(",");
81
+ return read(
82
+ "teachers.branches.list",
83
+ branch ? `${BASE}?branch=${encodeURIComponent(branch)}` : BASE,
84
+ );
85
+ }
86
+ if (sub === "suggest") {
87
+ return read("teachers.branches.suggest", `${BASE}/suggest`, {
88
+ timeoutMs: SCAN_TIMEOUT_MS,
89
+ });
90
+ }
91
+ if (sub === "networks") {
92
+ return read("teachers.branches.networks", `${BASE}/networks`);
93
+ }
94
+ if (sub === "confirm") {
95
+ const id = networkIdOf(target);
96
+ if (!names.length) {
97
+ throw validationError(
98
+ "confirm needs --branch <name> (repeat --branch, or --branches A,B, for a building branches share).",
99
+ );
100
+ }
101
+ return write(
102
+ "teachers.branches.confirm",
103
+ `${BASE}/networks/${id}`,
104
+ { branches: names, ...(note ? { note } : {}) },
105
+ { timeoutMs: SCAN_TIMEOUT_MS },
106
+ );
107
+ }
108
+ if (sub === "unconfirm") {
109
+ return write("teachers.branches.unconfirm", `${BASE}/networks/${networkIdOf(target)}/unconfirm`, {});
110
+ }
111
+ if (sub === "set") {
112
+ const ref = teacherRefOf(target);
113
+ if (names.length !== 1) throw validationError("set needs exactly one --branch <name>.");
114
+ return write("teachers.branches.set", `${BASE}/teacher/${ref}`, {
115
+ branch: names[0],
116
+ ...(note ? { note } : {}),
117
+ });
118
+ }
119
+ if (sub === "clear") {
120
+ return write("teachers.branches.clear", `${BASE}/teacher/${teacherRefOf(target)}/clear`, {});
121
+ }
122
+ if (sub === "apply") {
123
+ return write("teachers.branches.apply", `${BASE}/apply`, {}, { timeoutMs: SCAN_TIMEOUT_MS });
124
+ }
125
+ throw validationError(TEACHER_BRANCHES_USAGE);
126
+ }
127
+
128
+ /** What an apply keeps as its receipt: counts and the changes, not the lists. */
129
+ export function teacherBranchApplyReceipt(data = {}) {
130
+ return {
131
+ counts: data.counts || null,
132
+ changes: (data.changes || []).map((c) => ({
133
+ userId: c.userId ?? null,
134
+ from: c.from ?? null,
135
+ to: c.to ?? null,
136
+ ...(c.to ? { share: c.share ?? null, actions: c.actions ?? null, days: c.days ?? null } : {}),
137
+ })),
138
+ };
139
+ }
140
+
141
+ const day = (seconds) =>
142
+ Number(seconds) > 0 ? new Date(Number(seconds) * 1000).toISOString().slice(0, 10) : "-";
143
+ const GAP_LABELS = {
144
+ replica_busy: "replica busy",
145
+ time_limit: "read too slow",
146
+ time_budget: "out of time",
147
+ unreadable: "replica busy or too slow",
148
+ };
149
+ const gapText = (gaps) => {
150
+ const parts = Object.entries(gaps || {})
151
+ .filter(([, n]) => n > 0)
152
+ .map(([why, n]) => `${n} ${GAP_LABELS[why] || why}`);
153
+ return parts.length ? ` (${parts.join(", ")})` : "";
154
+ };
155
+ const evidence = (x) =>
156
+ x && x.actions != null ? ` · ${x.actions} actions on ${x.days ?? "?"} day(s)` : "";
157
+ const names = (branches) => (branches || []).map((b) => b.displayName || b).join(" + ");
158
+
159
+ function printSuggest(data) {
160
+ console.log(
161
+ `Academy networks (${data.academyMinAccounts}+ accounts in ${data.windowDays} days) used by the ${data.teachers} approved teachers: ${(data.networks || []).length}.`,
162
+ );
163
+ if (data.incomplete) {
164
+ console.log(
165
+ `Incomplete: ${data.teachersNotRead} teacher(s) not read${gapText(data.teachersNotReadBy)}, ${data.addressesNotCounted} address(es) not counted${gapText(data.addressesNotCountedBy)}. Run it again for the rest.`,
166
+ );
167
+ }
168
+ for (const n of data.networks || []) {
169
+ console.log("");
170
+ console.log(
171
+ `${n.networkId} ${n.address} · ${n.accounts} accounts · ${n.teachers} teachers · ${n.teacherActions} teacher actions · ${day(n.firstAt)} → ${day(n.lastAt)}`,
172
+ );
173
+ const suggestion = n.suggested
174
+ ? n.suggested.displayName
175
+ : n.tiedWith?.length
176
+ ? `tie: ${names(n.tiedWith)}`
177
+ : "none (no official branch typed)";
178
+ console.log(
179
+ ` suggested: ${suggestion}${n.confirmed ? ` · CONFIRMED: ${names(n.confirmed.branches)}${n.confirmed.note ? ` (${n.confirmed.note})` : ""}` : ""}`,
180
+ );
181
+ console.log(` typed: ${(n.typed || []).map((t) => `${t.branch} ${t.teachers}`).join(", ")}`);
182
+ console.log(
183
+ ` top: ${(n.topTeachers || []).map((t) => `${t.username || "?"} (#${t.userId}${t.typedBranch ? `, typed "${t.typedBranch}"` : ""}) ${t.actions}`).join("; ")}`,
184
+ );
185
+ }
186
+ if ((data.confirmedNotSeen || []).length) {
187
+ console.log("\nConfirmed but not seen as an academy network this time:");
188
+ for (const n of data.confirmedNotSeen) console.log(` ${n.networkId} ${n.address} → ${names(n.branches)}`);
189
+ }
190
+ console.log(
191
+ `\nOfficial branches: ${(data.officialBranches || []).map((b) => b.displayName).join(", ")}`,
192
+ );
193
+ console.log(
194
+ "Confirm: lumine admin teachers branches confirm <networkId> --branch <name> (repeat --branch for a building branches share), then: lumine admin teachers branches apply",
195
+ );
196
+ }
197
+
198
+ function printList(data) {
199
+ const counts = Object.entries(data.counts || {})
200
+ .sort((a, b) => b[1] - a[1])
201
+ .map(([branch, n]) => `${branch} ${n}`)
202
+ .join(", ");
203
+ console.log(`${data.total} approved teachers: ${counts || "-"}`);
204
+ let current = null;
205
+ for (const t of data.teachers || []) {
206
+ const label = t.branch?.displayName || "No confirmed branch";
207
+ if (label !== current) {
208
+ current = label;
209
+ console.log(`\n${label}`);
210
+ }
211
+ const source = t.branch
212
+ ? t.branch.source === "owner"
213
+ ? " · set by owner"
214
+ : ` · network ${t.branch.share ?? "?"}%${evidence(t.branch)}`
215
+ : "";
216
+ console.log(
217
+ ` ${t.username} (#${t.userId})${source}${t.typedBranch ? ` · typed "${t.typedBranch}"` : ""} · last active ${day(t.lastActive)}`,
218
+ );
219
+ }
220
+ }
221
+
222
+ function printApply(data) {
223
+ const c = data.counts || {};
224
+ console.log(
225
+ `Applied ${c.confirmedNetworks} confirmed network(s) to ${c.teachers} teachers: ${c.assigned} assigned, ${c.ambiguous} ambiguous, ${c.lowEvidence ?? 0} low evidence (under ${c.minDays ?? "?"} days), ${c.unknown} unknown, ${c.ownerSet} set by owner${c.notRead ? `, ${c.notRead} not read${gapText(c.notReadBy)} (unchanged; run again)` : ""}. ${c.changed} changed; ${c.differsFromTyped} differ from what they typed${c.removedNotTeachers ? `; ${c.removedNotTeachers} former teacher row(s) removed` : ""}.`,
226
+ );
227
+ if ((data.changes || []).length) {
228
+ console.log("\nChanged:");
229
+ for (const ch of data.changes) {
230
+ console.log(
231
+ ` ${ch.username} (#${ch.userId}): ${ch.from || "none"} → ${ch.to || "none"}${ch.to ? ` (${ch.share}%${evidence(ch)})` : ""}`,
232
+ );
233
+ }
234
+ }
235
+ if ((data.ambiguous || []).length) {
236
+ console.log("\nAmbiguous (set one with `teachers branches set <user> --branch <name>`):");
237
+ for (const a of data.ambiguous) {
238
+ console.log(
239
+ ` ${a.username} (#${a.userId}) · ${a.candidates.join(" or ")} · ${a.split}${a.typedBranch ? ` · typed "${a.typedBranch}"` : ""} · ${a.reason}`,
240
+ );
241
+ }
242
+ }
243
+ if ((data.lowEvidence || []).length) {
244
+ console.log(
245
+ `\nLow evidence, not assigned (fewer than ${data.counts?.minDays ?? "?"} days on the branch: a visit, or a new teacher):`,
246
+ );
247
+ for (const l of data.lowEvidence) {
248
+ console.log(
249
+ ` ${l.username} (#${l.userId}) · ${l.branch} ${l.share}%${evidence(l)}${l.typedBranch ? ` · typed "${l.typedBranch}"` : ""}`,
250
+ );
251
+ }
252
+ }
253
+ if ((data.differsFromTyped || []).length) {
254
+ console.log("\nAssigned a branch other than the one they typed:");
255
+ for (const d of data.differsFromTyped) {
256
+ console.log(` ${d.username} (#${d.userId}) · typed "${d.typedBranch}" · network ${d.branch} ${d.share}%${evidence(d)}`);
257
+ }
258
+ }
259
+ const unknown = Object.entries(data.unknownReasons || {});
260
+ if (unknown.length) console.log(`\nUnknown: ${unknown.map(([why, n]) => `${n} ${why}`).join("; ")}`);
261
+ }
262
+
263
+ export function printTeacherBranchResult({ operation, data }) {
264
+ switch (operation.name) {
265
+ case "teachers.branches.suggest":
266
+ return printSuggest(data);
267
+ case "teachers.branches.list":
268
+ return printList(data);
269
+ case "teachers.branches.apply":
270
+ return printApply(data);
271
+ case "teachers.branches.networks": {
272
+ const networks = data.networks || [];
273
+ if (!networks.length) return console.log("No confirmed networks yet: lumine admin teachers branches suggest");
274
+ for (const n of networks) {
275
+ console.log(
276
+ `${n.networkId} ${n.address} → ${names(n.branches)}${n.shared ? " (shared building)" : ""} · confirmed ${day(n.confirmedAt)}${n.note ? ` · ${n.note}` : ""}`,
277
+ );
278
+ }
279
+ return undefined;
280
+ }
281
+ case "teachers.branches.confirm":
282
+ return console.log(
283
+ `Confirmed ${data.networkId} (${data.address}) as ${names(data.branches)}${data.shared ? " (a shared building: each teacher's typed branch decides there)" : ""}${data.previous?.length ? `; was ${data.previous.join(" + ")}` : ""}. Next: ${data.next}`,
284
+ );
285
+ case "teachers.branches.unconfirm":
286
+ return console.log(
287
+ `Unconfirmed ${data.networkId} (${data.address}; was ${(data.removedBranches || []).join(" + ")}). Next: ${data.next}`,
288
+ );
289
+ case "teachers.branches.set":
290
+ return console.log(
291
+ `${data.username} (#${data.userId}) is now ${data.branch?.displayName} (set by owner; networks no longer change it)${data.previous ? `; was ${data.previous.branchKey} (${data.previous.source})` : ""}.`,
292
+ );
293
+ case "teachers.branches.clear":
294
+ return console.log(
295
+ data.previous
296
+ ? `Cleared #${data.userId}${data.username ? ` (${data.username})` : ""}: was ${data.previous.branchKey} (${data.previous.source}). ${data.next}`
297
+ : `#${data.userId} had no branch.`,
298
+ );
299
+ default:
300
+ return console.log(JSON.stringify(data, null, 2));
301
+ }
302
+ }
package/lib/admin.js CHANGED
@@ -39,6 +39,10 @@ import {
39
39
  printOwnerTrace,
40
40
  } from "./admin-owner-trace.js";
41
41
  import { formatChatReportResult } from "./admin-chat-reports.js";
42
+ import {
43
+ buildTeacherBranchOperation,
44
+ printTeacherBranchResult,
45
+ } from "./admin-teacher-branches.js";
42
46
  import {
43
47
  buildProfileShowOperation,
44
48
  formatProfileReview,
@@ -240,7 +244,7 @@ function parseReviewRequestRef(target, { type: fixedType, typeOption } = {}) {
240
244
  const APPROVAL_USAGE =
241
245
  "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>]";
242
246
  const TEACHER_USAGE =
243
- "Use: lumine admin teachers audit [--limit <1-100>] [--cursor <c>] [--all] | revoke <userId> --reason <text> | review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>] (revoke needs it) | review --all-flagged --expect <id[:fp],...> --note <text>";
247
+ "Use: lumine admin teachers audit [--limit <1-100>] [--cursor <c>] [--all] | branches suggest|networks|confirm|unconfirm|set|clear|apply|list (`lumine admin teachers branches usage` prints each one) | revoke <userId> --reason <text> | review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>] (revoke needs it) | review --all-flagged --expect <id[:fp],...> --note <text>";
244
248
 
245
249
  function parseApprovalId(target) {
246
250
  const raw = String(target || "").replace(/^approval:/, "");
@@ -369,6 +373,14 @@ const TEACHER_AUDIT_PAGE = 50;
369
373
  const TEACHER_REVIEW_BATCH = 25;
370
374
 
371
375
  function buildTeacherOperation({ action, target, options }) {
376
+ // the teacher branch registry (admin-teacher-branches.js)
377
+ if (action === "branches" || action === "branch") {
378
+ return buildTeacherBranchOperation({
379
+ sub: target,
380
+ target: options.positional?.[3],
381
+ options,
382
+ });
383
+ }
372
384
  if (!action || action === "audit") {
373
385
  const operation = readOperation(
374
386
  "teachers.audit",
@@ -2583,7 +2595,7 @@ export function parseAdminOperation(options) {
2583
2595
  // reviews as the admin controls on the website quest page. Mikey's own
2584
2596
  // decision, any time, never in a daily run.
2585
2597
  const usage =
2586
- "Usage: lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | 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>.";
2598
+ "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>.";
2587
2599
  const note = String(options.note || "").trim();
2588
2600
  if (note.length > 1000) {
2589
2601
  throw cliValidationError("--note must be at most 1000 characters.");
@@ -2771,6 +2783,17 @@ export function parseAdminOperation(options) {
2771
2783
  { requiresRun: false },
2772
2784
  );
2773
2785
  }
2786
+ if (action === "request-changes") {
2787
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2788
+ if (!note) throw cliValidationError("request-changes needs --note <what the whole crew must change>.");
2789
+ return writeOperation(
2790
+ "meetup.request-changes",
2791
+ "POST",
2792
+ `/cli/admin/meetup-quest/crews/${crewId}/request-changes`,
2793
+ { note },
2794
+ { requiresRun: false },
2795
+ );
2796
+ }
2774
2797
  if (action === "approve-crew" || action === "approve-grownup") {
2775
2798
  // Every step passes through the owner: the crew (members + branches) and
2776
2799
  // the grown-up step (parents' yes + the named adult).
@@ -5744,6 +5767,46 @@ function formatMeetupMembers(members) {
5744
5767
  .join("; ");
5745
5768
  }
5746
5769
 
5770
+ // The crew's grown-up: a parent as the crew typed it; a Twinkle teacher is an
5771
+ // approved teacher account (username + id) with where their answer stands
5772
+ // (confirmed <date> / waiting for confirmation / declined); a classroom names
5773
+ // no person (Twinkle arranges it).
5774
+ function shortDate(seconds) {
5775
+ const value = Number(seconds || 0);
5776
+ return value > 0 ? new Date(value * 1000).toISOString().slice(0, 10) : "";
5777
+ }
5778
+
5779
+ export function meetupAdultLine(adult) {
5780
+ const request = adult?.request || null;
5781
+ if (adult?.display?.kind === "classroom") {
5782
+ return adult.display.status === "confirmed"
5783
+ ? ` Adult coming: classroom meetup, supervised at ${adult.display.name} (Twinkle arranges the grown-up)`
5784
+ : " Adult coming: classroom meetup, Twinkle arranges the grown-up (slot not confirmed yet)";
5785
+ }
5786
+ if (!adult?.kind) {
5787
+ return request?.status === "declined"
5788
+ ? ` Adult coming: not named yet (Twinkle teacher ${request.teacherUsername} (user ${request.teacherUserId}) declined${request.afterYes ? " after saying yes" : ""} ${shortDate(request.answeredAt)})`
5789
+ : "";
5790
+ }
5791
+ if (adult.kind !== "teacher") return ` Adult coming: parent: ${adult.name}`;
5792
+ if (adult.teacher?.userId) {
5793
+ const who = `Twinkle teacher ${adult.teacher.username} (user ${adult.teacher.userId}, approved teacher account)`;
5794
+ if (Number(adult.confirmedAt || 0) > 0) {
5795
+ return ` Adult coming: ${who}, confirmed ${shortDate(adult.confirmedAt)}`;
5796
+ }
5797
+ if (adult.display?.status === "waiting" || request?.status === "open") {
5798
+ return ` Adult coming: ${who}, waiting for confirmation${request?.sentAt ? ` (asked ${shortDate(request.sentAt)})` : ""}`;
5799
+ }
5800
+ if (adult.display?.status === "confirmed" || (!adult.display && !adult.teacherProblem)) {
5801
+ return ` Adult coming: ${who}`;
5802
+ }
5803
+ }
5804
+ // the API blanks `name` for a teacher that does not count; staff get what
5805
+ // is on file as `unconfirmedName`
5806
+ const typed = adult.unconfirmedName || adult.name;
5807
+ return ` Adult coming: Twinkle teacher NOT CONFIRMED${typed ? ` (on file: "${typed}")` : ""}: ${adult.teacherProblem || "no approved teacher account picked"}`;
5808
+ }
5809
+
5747
5810
  function printMeetupCrew(crew) {
5748
5811
  const progress = crew.progress || {};
5749
5812
  console.log(
@@ -5754,6 +5817,10 @@ function printMeetupCrew(crew) {
5754
5817
  console.log(` ${step.number}. ${step.label} [${step.state}] ${step.detail}`);
5755
5818
  }
5756
5819
  console.log(` Members: ${formatMeetupMembers(crew.members)}`);
5820
+ for (const [step, review] of Object.entries(crew.reviews || {})) {
5821
+ console.log(` ${step === "crew" ? "Crew" : "Parents and adult"} approval: ${review.status}${review.requestedAt ? ` (review requested ${shortDate(review.requestedAt)})` : ""}`);
5822
+ if (review.note) console.log(` Shared staff note (every crew member sees this): ${review.note}`);
5823
+ }
5757
5824
  // staff's "who are you" checks on these members (any crew), newest first
5758
5825
  for (const check of crew.memberChecks || []) {
5759
5826
  const answer = check.teacherName
@@ -5775,11 +5842,8 @@ function printMeetupCrew(crew) {
5775
5842
  ` Pending invites: ${crew.invites.map((invite) => `${invite.username} (${invite.userId}, invite ${invite.inviteId})`).join("; ")}`,
5776
5843
  );
5777
5844
  }
5778
- if (crew.adult?.kind) {
5779
- console.log(
5780
- ` Adult coming: ${crew.adult.kind === "teacher" ? "Twinkle teacher" : "parent"}: ${crew.adult.name}`,
5781
- );
5782
- }
5845
+ const adultLine = meetupAdultLine(crew.adult);
5846
+ if (adultLine) console.log(adultLine);
5783
5847
  const plan = crew.plan || {};
5784
5848
  if (plan.status && plan.status !== "none") {
5785
5849
  console.log(
@@ -6175,7 +6239,11 @@ export function formatTeacherReviewLine(teacher) {
6175
6239
  ? ` · last review ${r.decision} ${approvalDate(r.reviewedAt)} by ${r.reviewedBy}${r.note ? `: "${r.note}"` : ""}`
6176
6240
  : "";
6177
6241
  const flags = (teacher.flags || []).length ? ` · flags ${teacher.flags.join(", ")}` : "";
6178
- return `[${status}] ${a.username || "?"} (#${a.userId})${flags}${reviewed}`;
6242
+ // the owner-confirmed branch (teachers branches); not part of the review
6243
+ const branch = teacher.branch
6244
+ ? ` · ${teacher.branch.displayName}${teacher.branch.source === "owner" ? " (set by owner)" : ""}`
6245
+ : "";
6246
+ return `[${status}] ${a.username || "?"} (#${a.userId})${branch}${flags}${reviewed}`;
6179
6247
  }
6180
6248
 
6181
6249
  // One audit entry: reviewed-unchanged teachers are one line (already judged);
@@ -6503,6 +6571,10 @@ function printAdminResult({ operation, result }) {
6503
6571
  );
6504
6572
  return;
6505
6573
  }
6574
+ if (operation.name.startsWith("teachers.branches.")) {
6575
+ printTeacherBranchResult({ operation, data });
6576
+ return;
6577
+ }
6506
6578
  if (operation.name.startsWith("approvals.") || operation.name.startsWith("teachers.")) {
6507
6579
  printApprovalResult({ operation, data });
6508
6580
  return;
package/lib/assets.js CHANGED
@@ -66,7 +66,7 @@ export async function assetsCommand(options) {
66
66
  return;
67
67
  }
68
68
  throw new Error(
69
- 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes] | lumine assets storage | lumine assets request-storage [--size 500MB|1GB|2GB] [--reason "<what it is for>"]',
69
+ 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro> | lumine assets delete <assetId> | lumine assets prune [--yes] | lumine assets storage | lumine assets request-storage [--size 500MB|1GB|2GB] [--reason "<what it is for>"]',
70
70
  );
71
71
  }
72
72
 
@@ -156,7 +156,7 @@ export function resolveGenerateModel(options) {
156
156
  const model = GENERATE_MODEL_ALIASES[rawModel];
157
157
  if (!model) {
158
158
  throw new Error(
159
- "--model is required: gpt-image-2.5-flare (fast generation), gpt-image-2.5-sunburst (precise editing), gpt-image-2, or nano-banana. No default model is applied.",
159
+ "--model is required: gpt-image-2.5-flare (fast generation), gpt-image-2.5-sunburst (precise editing), gpt-image-2, nano-banana (Nano Banana 2.1) or nano-banana-pro. No default model is applied.",
160
160
  );
161
161
  }
162
162
  const rawQuality = String(options.quality || "").trim();
@@ -165,7 +165,7 @@ export function resolveGenerateModel(options) {
165
165
  }
166
166
  if (rawQuality && !model.startsWith("gpt-image-")) {
167
167
  throw new Error(
168
- "--quality only applies to GPT Image models; nano-banana has a single quality tier.",
168
+ "--quality only applies to GPT Image models; nano-banana and nano-banana-pro have a single quality tier.",
169
169
  );
170
170
  }
171
171
  if (model === "gpt-image-2" && ["xhigh", "max"].includes(rawQuality)) {
@@ -212,7 +212,7 @@ export async function assetsGenerate(options) {
212
212
  const prompt = String(options.positional[1] || "").trim();
213
213
  if (!prompt) {
214
214
  throw new Error(
215
- 'Usage: lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana> [--quality low|medium|high|xhigh|max] [--name <fileName>] [--yes]',
215
+ 'Usage: lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro> [--quality low|medium|high|xhigh|max] [--name <fileName>] [--yes]',
216
216
  );
217
217
  }
218
218
  const { model, quality } = resolveGenerateModel(options);
package/lib/commands.js CHANGED
@@ -2455,7 +2455,10 @@ export function parseArgs(args) {
2455
2455
  const [key, inlineValue] = arg.slice(2).split("=", 2);
2456
2456
  const camelKey = toCamelCase(key);
2457
2457
  if (inlineValue !== undefined) {
2458
- raw[camelKey] = inlineValue;
2458
+ raw[camelKey] =
2459
+ camelKey === "branch" && raw.branch !== undefined
2460
+ ? `${raw.branch},${inlineValue}`
2461
+ : inlineValue;
2459
2462
  } else if (booleanFlags.has(camelKey)) {
2460
2463
  raw[camelKey] = true;
2461
2464
  } else {
@@ -2467,7 +2470,11 @@ export function parseArgs(args) {
2467
2470
  ) {
2468
2471
  throw new Error(`Missing value for --${key}.`);
2469
2472
  }
2470
- raw[camelKey] = nextValue;
2473
+ // --branch repeats: `--branch Mokdong --branch TwinkleU`
2474
+ raw[camelKey] =
2475
+ camelKey === "branch" && raw.branch !== undefined
2476
+ ? `${raw.branch},${nextValue}`
2477
+ : nextValue;
2471
2478
  i += 1;
2472
2479
  }
2473
2480
  }
@@ -2671,6 +2678,8 @@ export function parseArgs(args) {
2671
2678
  twinkles: raw.twinkles ? String(raw.twinkles) : "",
2672
2679
  adminReason: raw.reason ? String(raw.reason) : "",
2673
2680
  adminSkill: raw.skill ? String(raw.skill) : "",
2681
+ // --branch may repeat (a building two branches share); --branches A,B too
2682
+ adminBranch: [raw.branch, raw.branches].filter(Boolean).join(","),
2674
2683
  adminPass: raw.pass ? String(raw.pass) : "",
2675
2684
  adminLimit: raw.limit === undefined ? null : raw.limit,
2676
2685
  storageSize: raw.size ? String(raw.size) : "",
@@ -2704,6 +2713,7 @@ export function parseArgs(args) {
2704
2713
  repeat: Math.min(Math.max(Math.floor(Number(raw.repeat) || 1), 1), 20),
2705
2714
  allowWrite: parseBoolean(raw.allowWrite, false),
2706
2715
  assumeYes: parseBoolean(raw.yes, false),
2716
+ show: parseBoolean(raw.show, false),
2707
2717
  sdkPath: raw.path ? String(raw.path) : "",
2708
2718
  sdkScopes: String(raw.scopes || "")
2709
2719
  .split(",")
@@ -3041,7 +3051,7 @@ export function printHelp() {
3041
3051
  lumine sdk call <namespace.method> [jsonArgs]
3042
3052
  lumine assets [list]
3043
3053
  lumine assets upload <file...>
3044
- lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
3054
+ lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3045
3055
  lumine assets delete <assetId>
3046
3056
  lumine assets prune [--yes]
3047
3057
  lumine assets storage [--json]
@@ -3055,7 +3065,7 @@ export function printHelp() {
3055
3065
  lumine cardcraft check|status|review
3056
3066
  lumine thumbnail set <file>
3057
3067
  lumine thumbnail capture [--out <file>]
3058
- lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
3068
+ lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>
3059
3069
  lumine doctor runtime-assets
3060
3070
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
3061
3071
  lumine admin identity inspect <user-id|username> --reason <management-reason> [--include-private-evidence] [--output <file>] [--json] (nothing kept on disk unless --output; that copy is 0600)
@@ -3086,12 +3096,13 @@ export function printHelp() {
3086
3096
  lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject|revoke <type:id> [--reason <text>] | propose rewards:<id> --dir <edited-snapshot> --config <rules.json>
3087
3097
  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>] (management approvals with evidence: birthdates, teacher signups, meetups)
3088
3098
  lumine admin teachers audit [--limit <1-100>] [--cursor <c>] [--all] | revoke <userId> --reason <text> (approved teachers, a page at a time or --all: new flags first, then changed since your review; revoke removes the status cleanly)
3099
+ lumine admin teachers branches suggest | networks | confirm <networkId> --branch <name> [--branch <name>] [--note <text>] | unconfirm <networkId> | set <userId|username> --branch <name> [--note <text>] | clear <userId|username> | apply | list [--branch <name>|none] (which branch each teacher works at, confirmed by you through the academy networks they use: suggest finds the networks, confirm names their branch (repeat --branch for a building branches share), apply gives teachers the branch most of their actions there point at and lists the unclear ones; set wins over networks)
3089
3100
  lumine admin teachers review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>, required for revoke] | review --all-flagged --expect <id[:fp],...> --note <text> (record your review of the facts shown; --all-flagged records only the --expect teachers; nothing is recorded if they changed since. A reviewed teacher comes back when its flags or linked accounts change; accounts sharing a network count only when they change a network flag)
3090
3101
  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)
3091
3102
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3092
3103
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3093
3104
  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>]
3094
- lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | 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)
3105
+ 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)
3095
3106
  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)
3096
3107
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3097
3108
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
@@ -3307,7 +3318,7 @@ Options:
3307
3318
  --json Print machine-readable output where supported
3308
3319
  --keep-assets Keep doctor probe assets instead of deleting them
3309
3320
  --no-browser Skip doctor browser probes
3310
- --model <model> Image model for generate: gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-2, or nano-banana (required)
3321
+ --model <model> Image model for generate: gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-2, nano-banana (Nano Banana 2.1) or nano-banana-pro (required)
3311
3322
  --quality <q> GPT Image quality: low, medium, high, xhigh, max (default high; xhigh/max require 2.5)
3312
3323
  --name <fileName> File name hint for a generated asset
3313
3324
  --out <path> With thumbnail capture: also save the capture locally
package/lib/constants.js CHANGED
@@ -21,8 +21,14 @@ export const GENERATE_MODEL_ALIASES = {
21
21
  flare: "gpt-image-2.5-flare",
22
22
  sunburst: "gpt-image-2.5-sunburst",
23
23
  "gpt-image-2": "gpt-image-2",
24
- "nano-banana": "gemini-3-pro-image-preview",
25
- "gemini-3-pro-image-preview": "gemini-3-pro-image-preview",
24
+ // Gemini by tier (the API's NANO_BANANA_MODEL / NANO_BANANA_PRO_MODEL):
25
+ // nano-banana = the fast tier (Nano Banana 2.1 since 2026-10-08), pro = Pro
26
+ "nano-banana": "gemini-nano-banana-2.1",
27
+ "nano-banana-2.1": "gemini-nano-banana-2.1",
28
+ "gemini-nano-banana-2.1": "gemini-nano-banana-2.1",
29
+ "nano-banana-pro": "gemini-3-pro-image",
30
+ "gemini-3-pro-image": "gemini-3-pro-image",
31
+ "gemini-3-pro-image-preview": "gemini-3-pro-image",
26
32
  };
27
33
  export const GENERATE_QUALITIES = new Set([
28
34
  "low",
@@ -278,10 +284,10 @@ lumine save --summary "Describe the change"
278
284
  replaces. Update code references, then delete or prune the old asset.
279
285
  - Prefer one small assets module (e.g. src/assets.js) mapping names to asset
280
286
  URLs over scattering URLs through the code.
281
- - \`lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>\` creates
287
+ - \`lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana|nano-banana-pro>\` creates
282
288
  an AI-generated image asset. --model is REQUIRED (no default): gpt-image-2 is
283
- best quality but slower and pricier; nano-banana (Gemini) is faster and
284
- cheaper. It spends the logged-in user's AI Battery, so it asks y/N with the
289
+ best quality but slower and pricier; nano-banana (Gemini Nano Banana 2.1) is
290
+ the fastest and cheapest; nano-banana-pro (Gemini Nano Banana Pro) sits between. It spends the logged-in user's AI Battery, so it asks y/N with the
285
291
  estimated cost first; non-interactive runs must pass --yes to consent.
286
292
  Optional: --quality low|medium|high (gpt-image-2 only, default high),
287
293
  --name <fileName>. The asset lands in .twinkle/${ASSETS_METADATA_FILE} like an upload.
@@ -313,7 +319,7 @@ lumine save --summary "Describe the change"
313
319
  build's thumbnail. Thumbnails display ~16:9; the image is uploaded uncropped.
314
320
  - \`lumine thumbnail capture\` screenshots the running app server-side and sets
315
321
  the result as the thumbnail (add --out <file> to also save it locally).
316
- - \`lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana>\`
322
+ - \`lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana|nano-banana-pro>\`
317
323
  generates an AI image and sets it as the thumbnail (also stored as a normal
318
324
  reusable asset). Same battery-cost consent rules as \`assets generate\`.
319
325
  - Replacing an existing thumbnail asks y/N; pass --yes for non-interactive runs.
package/lib/thumbnail.js CHANGED
@@ -42,7 +42,7 @@ export async function thumbnailCommand(options) {
42
42
  return;
43
43
  }
44
44
  throw new Error(
45
- 'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>',
45
+ 'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana|nano-banana-pro>',
46
46
  );
47
47
  }
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.19",
3
+ "version": "0.3.22",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-10-07T07:52:06.040Z
5
+ Generated: 2026-10-08T04:59:03.150Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -536,6 +536,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
536
536
  - referenceImageB64 may be a raw base64 string or a data:image/...;base64 URL.
537
537
  - Optional model: gpt-image-2.5-flare or gpt-image-2.5-sunburst. Without a model, OpenAI uses Flare for new images and Sunburst when a reference image or continuation is supplied. Explicit gpt-image-2 remains supported.
538
538
  - Quality accepts low, medium, high, xhigh, or max. xhigh and max require a GPT Image 2.5 model. Gemini has one quality tier.
539
+ - engine: 'gemini' uses Google's Nano Banana 2.1 (the fast Gemini image tier; it replaced the retired original Nano Banana on 2026-10-08).
539
540
  - GPT Image 2.5 battery spending uses actual image-model input and output token usage. The confirmation shows an image-output estimate; prompts and reference images use additional energy.
540
541
  - responseId and imageId are opaque continuation handles. Pass them back unchanged to edit a prior result; do not assume an OpenAI ID format. Existing GPT Image 2 continuations remain usable.
541
542
  - Example: const result = await Twinkle.ai.generateImage({ prompt: 'Create a fashion guide portrait for this face with flattering colors and outfit ideas', referenceImageB64, quality: 'high', onStatus: (status) => console.log(status.stage) });
@@ -1208,6 +1208,47 @@ lumine admin identity network <userId|username> --reason <text> [--include-priva
1208
1208
  - Revoke marks the request rejected, keeps who, when and why, and refuses sage/founder holders.
1209
1209
  - The daily-run procedure is in `agent-guides/lumine-admin.md`.
1210
1210
 
1211
+ ### Teacher branches (Mikey only, any time; added 2026-10-08)
1212
+
1213
+ Which Twinkle branch each approved teacher works at, confirmed by Mikey through the academy networks teachers use. The branch a teacher typed when they applied is only a hint: many typed nothing, two branches, "Substitute", or an alias.
1214
+
1215
+ ```bash
1216
+ lumine admin teachers branches suggest
1217
+ lumine admin teachers branches networks
1218
+ lumine admin teachers branches confirm <networkId> --branch <name> [--branch <name> ...] [--note <text>]
1219
+ lumine admin teachers branches unconfirm <networkId>
1220
+ lumine admin teachers branches set <userId|username> --branch <name> [--note <text>]
1221
+ lumine admin teachers branches clear <userId|username>
1222
+ lumine admin teachers branches apply
1223
+ lumine admin teachers branches list [--branch <name>|none] [--json]
1224
+ ```
1225
+
1226
+ - `suggest` finds the **academy networks** live: an address (`users_actions`) that 15 or more accounts used in the last 365 days, among addresses approved teachers used at least 3 times. Each one shows:
1227
+ - a 10-character `networkId` and a masked address (`58.151.x.59`); raw addresses never leave the server;
1228
+ - its account and teacher counts, and when teachers used it (an academy that changed address shows as two networks with dates that meet);
1229
+ - what its teachers typed, the branch most of them typed (`suggested`; `tie` when two are level), and its top 5 teachers;
1230
+ - whether it is already `CONFIRMED`.
1231
+ The read uses the read replica only, never the primary, and is time-bounded (75 s). A busy replica, or a read that hits its cap, is reported as not read, with the reason; it is never an error. `Incomplete` means run it again.
1232
+ - `confirm` names the network's branch. The network id must come from a `suggest` of the last 7 days (the server remembers what suggest found, so a confirm does not rescan; otherwise run suggest again). Names are the official Bridge Builder branches or their aliases (`meetup_quest_branches`; "Twinkle U" = TwinkleU, 목동 = Mokdong). A branch that is not official yet is refused: make it official in `lumine admin meetup branches` first. Give **two or more `--branch`** (or `--branches A,B`) for a building branches share (Mikey 10-08: TwinkleU and Mokdong share one). Confirming again replaces the branches. `unconfirm` removes a network.
1233
+ - `apply` recomputes the branch of every approved teacher that Mikey has not set, from their last-365-day actions on confirmed networks only:
1234
+ - a single-branch network counts for its branch;
1235
+ - on a shared network the actions count for the teacher's typed branch when it is one of that network's branches; otherwise they count for the shared group;
1236
+ - the teacher gets the top branch when it is not tied, holds at least half the actions (with shared networks that include it), and no other branch has 10% or more;
1237
+ - and the branch has actions on at least **3 distinct days** (Korea time) and at least 3 actions. Fewer is **low evidence**, which may be a single meetup visit to another branch or a new teacher. Such a teacher is listed with the branch, actions and days, and gets no branch. The default is 3 days, pending Mikey's confirmation. `TEACHER_BRANCH_MIN_DAYS` on the API overrides it; 0 turns it off;
1238
+ - otherwise the teacher is **ambiguous** (two branches, a substitute, or mostly in a shared building with no matching typed branch) and is listed with the candidate branches. No actions on a confirmed network is **unknown**. Teachers who could not be read (replica busy, slow read, time budget) are left unchanged and counted as not read.
1239
+ Ambiguous, low-evidence and unknown teachers get no branch; an earlier network branch is removed. It reports:
1240
+ - counts: assigned / ambiguous / low evidence / unknown / set by owner / not read, with reasons;
1241
+ - every change, with share, actions and days;
1242
+ - the ambiguous and low-evidence lists;
1243
+ - teachers given a branch other than the one they typed. Run it after each confirm or unconfirm.
1244
+ - `set` is Mikey's own setting for one teacher and always wins: `apply` never changes it. `clear` removes any branch (the next `apply` may give a network branch).
1245
+ - `list` shows every approved teacher grouped by branch, with the source (`set by owner`, or `network NN% · N actions on N day(s)`), what they typed and when last active. `--branch none` lists those without one.
1246
+ - Only approved teachers ever show a branch. Revoking a teacher deletes their branch, and other features (`loadTeacherBranches`) never return a branch for an account that is no longer a teacher.
1247
+ - Little Star is official (Mikey 10-08). "Littlestar" and 리틀스타 resolve to it.
1248
+ - `teachers audit` shows each teacher's confirmed branch on its line. The branch is not part of the review fingerprint, so a branch change never marks a reviewed teacher changed.
1249
+ - Writes are audited server-side. The local receipts hold the network id, masked address and branches; an `apply` receipt keeps only the counts and the changes.
1250
+ - After it goes live: `suggest`, Mikey confirms the networks, then `apply`, then settle the ambiguous teachers with `set`.
1251
+
1211
1252
  ### Build XP/Coin reward approvals (any time; also a full-daily-review duty)
1212
1253
 
1213
1254
  The creator's Lumine designs the rewards and writes them into the app. The
@@ -4886,3 +4927,14 @@ After the Study review migration is deployed, the full daily website run also re
4886
4927
  Study's registered `study-record-v1` checks use learner AI Energy for accepted, revise and uncertain results. Preview checks use real AI Energy but cannot award XP/Coins. The agreed proposal pays 2,000 XP + 500 Coins at most once per learner per UTC day. Breadface's `breadface-v1` uses deterministic server physics, not a model. Both creator proposals must be accepted to publish; inspect the canonical review state and acceptance/rejection events during follow-up.
4887
4928
 
4888
4929
  For total JEV cost, include `dailyAiCosts.byProviderModel` for TypeSafe/JEV across both AI Energy and system-covered ledgers. Report serving/shadow/audit operations separately as above; the Build SDK's system-covered/failed calls use `jev_decision`, while ordinary viewer SDK checks are already counted by the AI Energy ledger. Do not add the Study report's known-cost sum on top of the canonical overall AI-cost total: it is a diagnostic breakdown of those same calls.
4930
+
4931
+ ## Bridge Builder crew reviews (no daily run required)
4932
+
4933
+ - `lumine admin meetup list` shows steps waiting for staff. Use `list --status active` to include crews making requested changes.
4934
+ - `lumine admin meetup show <crewId>` shows canonical progress, approval status, shared feedback, and private staff evidence.
4935
+ - `lumine admin meetup approve-crew <crewId>` and `approve-grownup <crewId>` approve the corresponding ready step.
4936
+ - `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.
4937
+ - `lumine admin meetup send-back <crewId> --note <text>` is for a pending plan or video. Those steps keep their existing revise-and-submit flow.
4938
+ - `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.
4939
+
4940
+ 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.