@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 +5 -2
- package/lib/admin-receipts.js +31 -0
- package/lib/admin-teacher-branches.js +302 -0
- package/lib/admin.js +111 -10
- package/lib/api.js +18 -0
- package/lib/commands.js +65 -4
- package/lib/constants.js +3 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +2 -1
- package/sdk/LUMINE_ADMIN.md +41 -0
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>`,
|
|
100
|
-
`lumine suggestions adopt-thumbnail <id
|
|
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
|
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({
|
|
@@ -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
|
-
|
|
5745
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
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 \`
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Version: 1.63.0
|
|
4
4
|
Updated: 2026-09-28
|
|
5
|
-
Generated: 2026-10-
|
|
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 }
|
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
|