@stage5/lumine 0.3.19 → 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/lib/admin-receipts.js +4 -0
- package/lib/admin-teacher-branches.js +302 -0
- package/lib/admin.js +64 -7
- package/lib/commands.js +12 -2
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +41 -0
package/lib/admin-receipts.js
CHANGED
|
@@ -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",
|
|
@@ -5744,6 +5756,46 @@ function formatMeetupMembers(members) {
|
|
|
5744
5756
|
.join("; ");
|
|
5745
5757
|
}
|
|
5746
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
|
+
|
|
5747
5799
|
function printMeetupCrew(crew) {
|
|
5748
5800
|
const progress = crew.progress || {};
|
|
5749
5801
|
console.log(
|
|
@@ -5775,11 +5827,8 @@ function printMeetupCrew(crew) {
|
|
|
5775
5827
|
` Pending invites: ${crew.invites.map((invite) => `${invite.username} (${invite.userId}, invite ${invite.inviteId})`).join("; ")}`,
|
|
5776
5828
|
);
|
|
5777
5829
|
}
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
` Adult coming: ${crew.adult.kind === "teacher" ? "Twinkle teacher" : "parent"}: ${crew.adult.name}`,
|
|
5781
|
-
);
|
|
5782
|
-
}
|
|
5830
|
+
const adultLine = meetupAdultLine(crew.adult);
|
|
5831
|
+
if (adultLine) console.log(adultLine);
|
|
5783
5832
|
const plan = crew.plan || {};
|
|
5784
5833
|
if (plan.status && plan.status !== "none") {
|
|
5785
5834
|
console.log(
|
|
@@ -6175,7 +6224,11 @@ export function formatTeacherReviewLine(teacher) {
|
|
|
6175
6224
|
? ` · last review ${r.decision} ${approvalDate(r.reviewedAt)} by ${r.reviewedBy}${r.note ? `: "${r.note}"` : ""}`
|
|
6176
6225
|
: "";
|
|
6177
6226
|
const flags = (teacher.flags || []).length ? ` · flags ${teacher.flags.join(", ")}` : "";
|
|
6178
|
-
|
|
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}`;
|
|
6179
6232
|
}
|
|
6180
6233
|
|
|
6181
6234
|
// One audit entry: reviewed-unchanged teachers are one line (already judged);
|
|
@@ -6503,6 +6556,10 @@ function printAdminResult({ operation, result }) {
|
|
|
6503
6556
|
);
|
|
6504
6557
|
return;
|
|
6505
6558
|
}
|
|
6559
|
+
if (operation.name.startsWith("teachers.branches.")) {
|
|
6560
|
+
printTeacherBranchResult({ operation, data });
|
|
6561
|
+
return;
|
|
6562
|
+
}
|
|
6506
6563
|
if (operation.name.startsWith("approvals.") || operation.name.startsWith("teachers.")) {
|
|
6507
6564
|
printApprovalResult({ operation, data });
|
|
6508
6565
|
return;
|
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] =
|
|
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
|
-
|
|
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) : "",
|
|
@@ -3086,6 +3095,7 @@ export function printHelp() {
|
|
|
3086
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>
|
|
3087
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)
|
|
3088
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)
|
|
3089
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)
|
|
3090
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)
|
|
3091
3101
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -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
|