@stage5/lumine 0.3.18 → 0.3.20

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
@@ -96,8 +96,11 @@ run `lumine suggestions <build-url-or-id>` to see their open suggestion inbox.
96
96
  The inbox prints canonical follow-up commands for merging or replacing Main and
97
97
  for applying the exact frozen thumbnail shown in a suggestion. The same actions
98
98
  are also available directly as `lumine suggestions merge <id>`,
99
- `lumine suggestions replace-main <id>`, and
100
- `lumine suggestions adopt-thumbnail <id>`. Large inboxes are cursor-paginated;
99
+ `lumine suggestions replace-main <id>`,
100
+ `lumine suggestions adopt-thumbnail <id>`, and
101
+ `lumine suggestions adopt-title <id>`. To pass on a branch, thumbnail or name
102
+ suggestion, run `lumine suggestions decline <id>`; the chat card shows it was
103
+ declined for both sides and it leaves the inbox. Large inboxes are cursor-paginated;
101
104
  the CLI prints the exact `--cursor` command for the next page.
102
105
 
103
106
  Use `lumine explore` to list public open-source Build apps that can be used as
@@ -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({
@@ -40,6 +44,33 @@ export function receiptToKeep(operation, result) {
40
44
  });
41
45
  }
42
46
  default:
47
+ // Bridge Builder writes answer with the whole crew view: members' who-
48
+ // are-you answers, parents' addresses and questions, held parent emails.
49
+ // Keep the decision only (ids, statuses, counts), never the crew.
50
+ if (String(operation?.name || "").startsWith("meetup.")) {
51
+ const crew = data.crew || {};
52
+ return keep({
53
+ crewId: crew.crewId ?? data.result?.crewId ?? null,
54
+ crewStatus: crew.status ?? null,
55
+ currentStep: crew.progress?.currentStep ?? null,
56
+ result: scalarsOf(data.result),
57
+ ...(data.slot ? { slot: scalarsOf(data.slot) } : {}),
58
+ ...(data.unlocks ? { unlocks: scalarsOf(data.unlocks) } : {}),
59
+ });
60
+ }
43
61
  return result;
44
62
  }
45
63
  }
64
+
65
+ // Numbers, booleans, short status words and arrays of ids: what proves a
66
+ // decision without copying anyone's text.
67
+ function scalarsOf(value) {
68
+ if (!value || typeof value !== "object") return null;
69
+ const out = {};
70
+ for (const [key, item] of Object.entries(value)) {
71
+ if (typeof item === "number" || typeof item === "boolean" || item === null) out[key] = item;
72
+ else if (typeof item === "string" && /^[\w:.-]{0,40}$/.test(item)) out[key] = item;
73
+ else if (Array.isArray(item) && item.every((x) => typeof x === "number")) out[key] = item;
74
+ }
75
+ return out;
76
+ }
@@ -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>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
2598
+ "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>.";
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.");
@@ -2748,6 +2760,29 @@ export function parseAdminOperation(options) {
2748
2760
  { requiresRun: false },
2749
2761
  );
2750
2762
  }
2763
+ if (action === "info") {
2764
+ // Staff's "who are you" check on a member (ruled 2026-10-07): the member
2765
+ // gives their teacher's name and class name (or how they know the crew)
2766
+ // on the crew page; the crew waits until you accept. Nobody is contacted.
2767
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
2768
+ const memberId = parseRequiredInteger(extra, "Member user ID", 1);
2769
+ const decision = parseChoice(options.adminDecision || "", "--decision", [
2770
+ "ask",
2771
+ "accept",
2772
+ "ask-again",
2773
+ "withdraw",
2774
+ ]);
2775
+ if (decision === "ask-again" && !note) {
2776
+ throw cliValidationError("--decision ask-again needs --note <what to tell the member>.");
2777
+ }
2778
+ return writeOperation(
2779
+ "meetup.info",
2780
+ "POST",
2781
+ `/cli/admin/meetup-quest/crews/${crewId}/members/${memberId}/info-check`,
2782
+ { action: decision === "ask" ? "request" : decision, note },
2783
+ { requiresRun: false },
2784
+ );
2785
+ }
2751
2786
  if (action === "approve-crew" || action === "approve-grownup") {
2752
2787
  // Every step passes through the owner: the crew (members + branches) and
2753
2788
  // the grown-up step (parents' yes + the named adult).
@@ -5716,11 +5751,51 @@ function formatMeetupMembers(members) {
5716
5751
  return (members || [])
5717
5752
  .map(
5718
5753
  (member) =>
5719
- `${member.username || `user ${member.userId}`} (${member.userId}, ${member.branch || "no branch"}${member.parentOk === undefined ? "" : member.parentOk ? ", parent OK" : ", parent not yet"}${member.attended ? ", attended" : ""})`,
5754
+ `${member.username || `user ${member.userId}`} (${member.userId}, ${member.branch || "no branch"}${member.parentOk === undefined ? "" : member.parentOk ? ", parent OK" : ", parent not yet"}${member.attended ? ", attended" : ""}${member.infoCheck === "requested" ? ", details asked" : member.infoCheck === "answered" ? ", ANSWERED: review" : ""})`,
5720
5755
  )
5721
5756
  .join("; ");
5722
5757
  }
5723
5758
 
5759
+ // The crew's grown-up: a parent as the crew typed it; a Twinkle teacher is an
5760
+ // approved teacher account (username + id) with where their answer stands
5761
+ // (confirmed <date> / waiting for confirmation / declined); a classroom names
5762
+ // no person (Twinkle arranges it).
5763
+ function shortDate(seconds) {
5764
+ const value = Number(seconds || 0);
5765
+ return value > 0 ? new Date(value * 1000).toISOString().slice(0, 10) : "";
5766
+ }
5767
+
5768
+ export function meetupAdultLine(adult) {
5769
+ const request = adult?.request || null;
5770
+ if (adult?.display?.kind === "classroom") {
5771
+ return adult.display.status === "confirmed"
5772
+ ? ` Adult coming: classroom meetup, supervised at ${adult.display.name} (Twinkle arranges the grown-up)`
5773
+ : " Adult coming: classroom meetup, Twinkle arranges the grown-up (slot not confirmed yet)";
5774
+ }
5775
+ if (!adult?.kind) {
5776
+ return request?.status === "declined"
5777
+ ? ` Adult coming: not named yet (Twinkle teacher ${request.teacherUsername} (user ${request.teacherUserId}) declined${request.afterYes ? " after saying yes" : ""} ${shortDate(request.answeredAt)})`
5778
+ : "";
5779
+ }
5780
+ if (adult.kind !== "teacher") return ` Adult coming: parent: ${adult.name}`;
5781
+ if (adult.teacher?.userId) {
5782
+ const who = `Twinkle teacher ${adult.teacher.username} (user ${adult.teacher.userId}, approved teacher account)`;
5783
+ if (Number(adult.confirmedAt || 0) > 0) {
5784
+ return ` Adult coming: ${who}, confirmed ${shortDate(adult.confirmedAt)}`;
5785
+ }
5786
+ if (adult.display?.status === "waiting" || request?.status === "open") {
5787
+ return ` Adult coming: ${who}, waiting for confirmation${request?.sentAt ? ` (asked ${shortDate(request.sentAt)})` : ""}`;
5788
+ }
5789
+ if (adult.display?.status === "confirmed" || (!adult.display && !adult.teacherProblem)) {
5790
+ return ` Adult coming: ${who}`;
5791
+ }
5792
+ }
5793
+ // the API blanks `name` for a teacher that does not count; staff get what
5794
+ // is on file as `unconfirmedName`
5795
+ const typed = adult.unconfirmedName || adult.name;
5796
+ return ` Adult coming: Twinkle teacher NOT CONFIRMED${typed ? ` (on file: "${typed}")` : ""}: ${adult.teacherProblem || "no approved teacher account picked"}`;
5797
+ }
5798
+
5724
5799
  function printMeetupCrew(crew) {
5725
5800
  const progress = crew.progress || {};
5726
5801
  console.log(
@@ -5731,6 +5806,17 @@ function printMeetupCrew(crew) {
5731
5806
  console.log(` ${step.number}. ${step.label} [${step.state}] ${step.detail}`);
5732
5807
  }
5733
5808
  console.log(` Members: ${formatMeetupMembers(crew.members)}`);
5809
+ // staff's "who are you" checks on these members (any crew), newest first
5810
+ for (const check of crew.memberChecks || []) {
5811
+ const answer = check.teacherName
5812
+ ? `teacher "${check.teacherName}", class "${check.className}"`
5813
+ : check.relationship
5814
+ ? `how they know the crew: "${check.relationship}"`
5815
+ : "no answer yet";
5816
+ console.log(
5817
+ ` 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}` : ""}`,
5818
+ );
5819
+ }
5734
5820
  for (const parent of crew.parentContacts || []) {
5735
5821
  console.log(
5736
5822
  ` 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}"` : ""}`,
@@ -5741,11 +5827,8 @@ function printMeetupCrew(crew) {
5741
5827
  ` Pending invites: ${crew.invites.map((invite) => `${invite.username} (${invite.userId}, invite ${invite.inviteId})`).join("; ")}`,
5742
5828
  );
5743
5829
  }
5744
- if (crew.adult?.kind) {
5745
- console.log(
5746
- ` Adult coming: ${crew.adult.kind === "teacher" ? "Twinkle teacher" : "parent"}: ${crew.adult.name}`,
5747
- );
5748
- }
5830
+ const adultLine = meetupAdultLine(crew.adult);
5831
+ if (adultLine) console.log(adultLine);
5749
5832
  const plan = crew.plan || {};
5750
5833
  if (plan.status && plan.status !== "none") {
5751
5834
  console.log(
@@ -5875,11 +5958,21 @@ function printMeetupResult({ operation, data, result }) {
5875
5958
  }
5876
5959
  if (crews.some((crew) => crew.waitingFor)) {
5877
5960
  console.log(
5878
- "Review: lumine admin meetup show <crewId>, then approve-crew | approve-plan | approve-grownup | send-back --note | approve --attended <ids> (classroom plans: approve at the headmaster desk with a room and times).",
5961
+ "Review: lumine admin meetup show <crewId>, then approve-crew | approve-plan | approve-grownup | send-back --note | approve --attended <ids> | info <crewId> <userId> --decision accept|ask-again (a member's who-are-you answer) (classroom plans: approve at the headmaster desk with a room and times).",
5879
5962
  );
5880
5963
  }
5881
5964
  return;
5882
5965
  }
5966
+ if (operation.name === "meetup.info") {
5967
+ const status = data.result?.status;
5968
+ console.log(
5969
+ result?.changed === false
5970
+ ? `Member ${data.result?.userId} already has an open question (${status}).`
5971
+ : `Member ${data.result?.userId} on crew #${data.result?.crewId}: ${status === "requested" ? "asked (their crew page now asks for their teacher and class, or how they know the crew)" : status}.`,
5972
+ );
5973
+ if (data.crew) printMeetupCrew(data.crew);
5974
+ return;
5975
+ }
5883
5976
  if (operation.name === "meetup.show" || operation.name === "meetup.parent-reply") {
5884
5977
  printMeetupCrew(data.crew || {});
5885
5978
  return;
@@ -6131,7 +6224,11 @@ export function formatTeacherReviewLine(teacher) {
6131
6224
  ? ` · last review ${r.decision} ${approvalDate(r.reviewedAt)} by ${r.reviewedBy}${r.note ? `: "${r.note}"` : ""}`
6132
6225
  : "";
6133
6226
  const flags = (teacher.flags || []).length ? ` · flags ${teacher.flags.join(", ")}` : "";
6134
- return `[${status}] ${a.username || "?"} (#${a.userId})${flags}${reviewed}`;
6227
+ // the owner-confirmed branch (teachers branches); not part of the review
6228
+ const branch = teacher.branch
6229
+ ? ` · ${teacher.branch.displayName}${teacher.branch.source === "owner" ? " (set by owner)" : ""}`
6230
+ : "";
6231
+ return `[${status}] ${a.username || "?"} (#${a.userId})${branch}${flags}${reviewed}`;
6135
6232
  }
6136
6233
 
6137
6234
  // One audit entry: reviewed-unchanged teachers are one line (already judged);
@@ -6459,6 +6556,10 @@ function printAdminResult({ operation, result }) {
6459
6556
  );
6460
6557
  return;
6461
6558
  }
6559
+ if (operation.name.startsWith("teachers.branches.")) {
6560
+ printTeacherBranchResult({ operation, data });
6561
+ return;
6562
+ }
6462
6563
  if (operation.name.startsWith("approvals.") || operation.name.startsWith("teachers.")) {
6463
6564
  printApprovalResult({ operation, data });
6464
6565
  return;
package/lib/api.js CHANGED
@@ -530,6 +530,24 @@ export async function adoptBuildTitleSuggestion({
530
530
  });
531
531
  }
532
532
 
533
+ // The owner passing on one teammate suggestion (branch updates, thumbnail or
534
+ // name): the card settles as declined for both sides and leaves the inbox.
535
+ export async function declineBuildOwnerSuggestion({
536
+ options,
537
+ auth,
538
+ rootBuildId,
539
+ contributionBuildId,
540
+ suggestionMessageId,
541
+ }) {
542
+ return await requestJson({
543
+ method: "POST",
544
+ url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/decline-suggestion`,
545
+ authToken: auth.token,
546
+ body: { suggestionMessageId },
547
+ timeoutMs: options.timeoutMs,
548
+ });
549
+ }
550
+
533
551
  export async function mergeContributionIntoMain({
534
552
  options,
535
553
  auth,
package/lib/commands.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  import {
24
24
  adoptBuildThumbnailSuggestion,
25
25
  adoptBuildTitleSuggestion,
26
+ declineBuildOwnerSuggestion,
26
27
  createBuild,
27
28
  fetchAllRuntimeAssets,
28
29
  forkBuild,
@@ -764,11 +765,50 @@ export async function suggestions(options) {
764
765
  return;
765
766
  }
766
767
 
768
+ if (action === "decline") {
769
+ const description = describeSuggestionForDecline(suggestion);
770
+ if (!options.assumeYes) {
771
+ const confirmed = await confirmPrompt(
772
+ `Decline suggestion #${suggestionId} (${description}) on Build #${rootBuildId}? [y/N] `,
773
+ );
774
+ if (confirmed === null) {
775
+ console.log("Not a TTY — re-run with --yes to decline the suggestion.");
776
+ return;
777
+ }
778
+ if (!confirmed) {
779
+ console.log("Aborted. Suggestion left open.");
780
+ return;
781
+ }
782
+ }
783
+ await declineBuildOwnerSuggestion({
784
+ options,
785
+ auth,
786
+ rootBuildId,
787
+ contributionBuildId,
788
+ suggestionMessageId: suggestionId,
789
+ });
790
+ console.log(
791
+ `Declined suggestion #${suggestionId} (${description}) on Build #${rootBuildId}.`,
792
+ );
793
+ return;
794
+ }
795
+
767
796
  throw new Error(
768
- "Usage: lumine suggestions [build] | lumine suggestions merge|replace-main|adopt-thumbnail|adopt-title <suggestion-id> [--build <id>]",
797
+ "Usage: lumine suggestions [build] | lumine suggestions merge|replace-main|adopt-thumbnail|adopt-title|decline <suggestion-id> [--build <id>]",
769
798
  );
770
799
  }
771
800
 
801
+ function describeSuggestionForDecline(suggestion) {
802
+ if (suggestion.type === "title") {
803
+ return `name "${suggestion.suggestedTitle}"`;
804
+ }
805
+ if (suggestion.type === "thumbnail") return "thumbnail";
806
+ const branchNumber = Number(suggestion.branchNumber || 0);
807
+ return branchNumber
808
+ ? `updates from branch ${branchNumber}`
809
+ : `updates from branch #${suggestion.branchBuildId}`;
810
+ }
811
+
772
812
  async function resolveSuggestionRootBuildId(options, auth) {
773
813
  const requestedBuildId = options.buildIdFlag
774
814
  ? resolveRequiredBuildId(options.buildIdFlag)
@@ -827,6 +867,9 @@ export function printBuildSuggestions({
827
867
  console.log(
828
868
  ` replace: lumine suggestions replace-main ${suggestionId} --build ${rootBuildId}`,
829
869
  );
870
+ console.log(
871
+ ` decline: lumine suggestions decline ${suggestionId} --build ${rootBuildId}`,
872
+ );
830
873
  continue;
831
874
  }
832
875
  if (suggestion.type === "title") {
@@ -838,6 +881,9 @@ export function printBuildSuggestions({
838
881
  console.log(
839
882
  ` apply: lumine suggestions adopt-title ${suggestionId} --build ${rootBuildId}`,
840
883
  );
884
+ console.log(
885
+ ` decline: lumine suggestions decline ${suggestionId} --build ${rootBuildId}`,
886
+ );
841
887
  continue;
842
888
  }
843
889
  console.log(
@@ -850,6 +896,9 @@ export function printBuildSuggestions({
850
896
  console.log(
851
897
  ` apply: lumine suggestions adopt-thumbnail ${suggestionId} --build ${rootBuildId}`,
852
898
  );
899
+ console.log(
900
+ ` decline: lumine suggestions decline ${suggestionId} --build ${rootBuildId}`,
901
+ );
853
902
  }
854
903
  if (hasMore && nextCursor > 0) {
855
904
  console.log(
@@ -2406,7 +2455,10 @@ export function parseArgs(args) {
2406
2455
  const [key, inlineValue] = arg.slice(2).split("=", 2);
2407
2456
  const camelKey = toCamelCase(key);
2408
2457
  if (inlineValue !== undefined) {
2409
- raw[camelKey] = inlineValue;
2458
+ raw[camelKey] =
2459
+ camelKey === "branch" && raw.branch !== undefined
2460
+ ? `${raw.branch},${inlineValue}`
2461
+ : inlineValue;
2410
2462
  } else if (booleanFlags.has(camelKey)) {
2411
2463
  raw[camelKey] = true;
2412
2464
  } else {
@@ -2418,7 +2470,11 @@ export function parseArgs(args) {
2418
2470
  ) {
2419
2471
  throw new Error(`Missing value for --${key}.`);
2420
2472
  }
2421
- 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;
2422
2478
  i += 1;
2423
2479
  }
2424
2480
  }
@@ -2429,6 +2485,7 @@ export function parseArgs(args) {
2429
2485
  "replace-main",
2430
2486
  "adopt-thumbnail",
2431
2487
  "adopt-title",
2488
+ "decline",
2432
2489
  ]);
2433
2490
  const suggestionAction =
2434
2491
  command === "suggest"
@@ -2621,6 +2678,8 @@ export function parseArgs(args) {
2621
2678
  twinkles: raw.twinkles ? String(raw.twinkles) : "",
2622
2679
  adminReason: raw.reason ? String(raw.reason) : "",
2623
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(","),
2624
2683
  adminPass: raw.pass ? String(raw.pass) : "",
2625
2684
  adminLimit: raw.limit === undefined ? null : raw.limit,
2626
2685
  storageSize: raw.size ? String(raw.size) : "",
@@ -2970,6 +3029,7 @@ export function printHelp() {
2970
3029
  lumine suggestions replace-main <suggestion-id> [--build <id>]
2971
3030
  lumine suggestions adopt-thumbnail <suggestion-id> [--build <id>] [--yes]
2972
3031
  lumine suggestions adopt-title <suggestion-id> [--build <id>] [--yes]
3032
+ lumine suggestions decline <suggestion-id> [--build <id>] [--yes]
2973
3033
  lumine explore [search terms]
2974
3034
  lumine select [twinkle-build-url]
2975
3035
  lumine pull [twinkle-build-url]
@@ -3035,12 +3095,13 @@ export function printHelp() {
3035
3095
  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>
3036
3096
  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)
3037
3097
  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)
3098
+ 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)
3038
3099
  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)
3039
3100
  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)
3040
3101
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3041
3102
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3042
3103
  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>]
3043
- 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>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
3104
+ 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)
3044
3105
  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)
3045
3106
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3046
3107
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
package/lib/constants.js CHANGED
@@ -238,8 +238,9 @@ lumine save --summary "Describe the change"
238
238
  - On an owned canonical team project, \`lumine suggestions\` lists the owner's
239
239
  currently open branch, thumbnail and name suggestions. Act on the exact
240
240
  suggestion id it prints with \`lumine suggestions merge\`, \`replace-main\`,
241
- \`adopt-thumbnail\` or \`adopt-title\`; do not infer an action from stale
242
- local branch state.
241
+ \`adopt-thumbnail\`, \`adopt-title\` or \`decline\`; do not infer an action
242
+ from stale local branch state. Decline only when the user asks to pass on
243
+ that suggestion.
243
244
 
244
245
  ## Assets (Runtime Media)
245
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
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-06T10:12:09.371Z
5
+ Generated: 2026-10-07T07:52:06.040Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -257,6 +257,7 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
257
257
  - title/body are accepted as aliases for label/summary.
258
258
  - target is accepted as a backwards-compatible alias for launchTarget.
259
259
  - Twinkle applies app API rate limits, a stricter notification emit rate limit, and existing notification mutes.
260
+ - Twinkle shows the acting member's username right before `summary` in the notification row, so write `summary` as a predicate ('posted in Lobby', 'responded to the Twinkle Newspaper'), never starting with the member's name (otherwise it reads 'Liki Liki responded…').
260
261
  - Example: await Twinkle.notifications.notifySubscribers('room.message', { targetKey: 'room:lobby', eventKey: 'room.message.created', label: 'Room messages', summary: 'posted in Lobby', launchTarget: { view: 'room', roomId: 'lobby', messageId } });
261
262
  - async getSubjectUpdateSubscription(subjectId) | scopes: notifications:read
262
263
  - Returns: { subscription }
@@ -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