@stage5/lumine 0.3.17 → 0.3.19
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-chat-reports.js +129 -0
- package/lib/admin-profile.js +138 -0
- package/lib/admin-receipts.js +72 -0
- package/lib/admin.js +378 -16
- package/lib/api.js +18 -0
- package/lib/commands.js +60 -3
- package/lib/constants.js +3 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +2 -1
- package/sdk/LUMINE_ADMIN.md +26 -4
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
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Human output for `lumine admin chat-reports list|show|set`. The generic
|
|
2
|
+
// printer used to fall through to the daily-run report branch for any
|
|
3
|
+
// `data.report` and crash on `report.run.id`; these formatters own the
|
|
4
|
+
// chat-report shapes (controllers/chat/model/messageReports.ts).
|
|
5
|
+
|
|
6
|
+
function formatTime(seconds) {
|
|
7
|
+
const value = Number(seconds || 0);
|
|
8
|
+
if (!value) return "unknown time";
|
|
9
|
+
return `${new Date(value * 1000).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function who(person) {
|
|
13
|
+
if (!person) return "unknown";
|
|
14
|
+
const id = Number(person.id || person.userId || 0);
|
|
15
|
+
if (person.username) return id ? `${person.username} (#${id})` : person.username;
|
|
16
|
+
return id ? `user #${id}` : "unknown";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function indent(text, prefix) {
|
|
20
|
+
return String(text ?? "")
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => `${prefix}${line}`)
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatMessageLine(message, marker = " ") {
|
|
27
|
+
const author = message.username || (message.userId ? `user #${message.userId}` : "unknown");
|
|
28
|
+
const content = String(message.content ?? "").trim() || "(no text)";
|
|
29
|
+
const [first, ...rest] = content.split("\n");
|
|
30
|
+
const head = `${marker} ${formatTime(message.timeStamp)} ${author}: ${first}`;
|
|
31
|
+
return rest.length ? `${head}\n${indent(rest.join("\n"), " ")}` : head;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function where(report) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
if (report.channelKind) parts.push(report.channelKind);
|
|
37
|
+
if (report.channelId) parts.push(`channel ${report.channelId}`);
|
|
38
|
+
if (report.subchannelId) parts.push(`subchannel ${report.subchannelId}`);
|
|
39
|
+
const channelName = report.message?.channelName;
|
|
40
|
+
return `${parts.join(" ")}${channelName ? ` "${channelName}"` : ""}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatChatReportLine(report) {
|
|
44
|
+
const preview = String(report.message?.content ?? "")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
const short = preview.length > 80 ? `${preview.slice(0, 79)}…` : preview;
|
|
48
|
+
return `#${report.id} ${String(report.status || "open").toUpperCase()} · ${report.reasonLabel || report.reason || "no reason"} · ${who(report.reporter)} → ${who(report.reported)} · ${formatTime(report.createdAt)}${Number(report.reportsOfThisMessage) > 1 ? ` · ${report.reportsOfThisMessage} reports of this message` : ""}${short ? `\n "${short}"` : ""}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatChatReportList(data = {}) {
|
|
52
|
+
const reports = Array.isArray(data.reports) ? data.reports : [];
|
|
53
|
+
const filter = data.filter ? `${data.filter} ` : "";
|
|
54
|
+
const lines = [
|
|
55
|
+
`${reports.length} ${filter}chat report(s)${reports.length ? ":" : "."}`,
|
|
56
|
+
];
|
|
57
|
+
for (const report of reports) lines.push(` ${formatChatReportLine(report)}`);
|
|
58
|
+
if (data.nextCursor) {
|
|
59
|
+
const status =
|
|
60
|
+
data.filter && data.filter !== "pending" ? ` --status ${data.filter}` : "";
|
|
61
|
+
lines.push(
|
|
62
|
+
`More: lumine admin chat-reports list${status} --cursor ${data.nextCursor}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (reports.length) {
|
|
66
|
+
lines.push("Open one with: lumine admin chat-reports show <id>");
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatChatReport(report = {}) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
lines.push(
|
|
74
|
+
`Chat report #${report.id}: ${String(report.status || "open").toUpperCase()} · filed ${formatTime(report.createdAt)}${report.ownerNotifiedAt ? ` · Mikey notified ${formatTime(report.ownerNotifiedAt)}` : ""}`,
|
|
75
|
+
);
|
|
76
|
+
lines.push(`Reporter → reported: ${who(report.reporter)} → ${who(report.reported)}`);
|
|
77
|
+
lines.push(
|
|
78
|
+
`Reason: ${report.reasonLabel || report.reason || "none"}${report.reason && report.reasonLabel && report.reason !== report.reasonLabel ? ` (${report.reason})` : ""}`,
|
|
79
|
+
);
|
|
80
|
+
if (report.note) lines.push(`Reporter's note: ${report.note}`);
|
|
81
|
+
const place = where(report);
|
|
82
|
+
if (place) lines.push(`Where: ${place}`);
|
|
83
|
+
if (Number(report.reportsOfThisMessage) > 1) {
|
|
84
|
+
lines.push(`This message has ${report.reportsOfThisMessage} reports.`);
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(report.safetyHoldIds) && report.safetyHoldIds.length) {
|
|
87
|
+
lines.push(`Safety hold(s): #${report.safetyHoldIds.join(", #")}`);
|
|
88
|
+
}
|
|
89
|
+
if (report.reviewedAt || report.reviewNote) {
|
|
90
|
+
lines.push(
|
|
91
|
+
`Review: ${formatTime(report.reviewedAt)}${report.reviewedByUserId ? ` by user #${report.reviewedByUserId}` : ""}${report.reviewNote ? ` — ${report.reviewNote}` : ""}`,
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
lines.push("Review: not reviewed yet.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const message = report.message;
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push(`Reported message${report.messageId ? ` #${report.messageId}` : ""}:`);
|
|
100
|
+
lines.push(message ? formatMessageLine(message, ">") : " (no snapshot kept)");
|
|
101
|
+
|
|
102
|
+
const before = Array.isArray(report.context?.before) ? report.context.before : [];
|
|
103
|
+
const after = Array.isArray(report.context?.after) ? report.context.after : [];
|
|
104
|
+
if (!report.context) {
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push("Context: none kept.");
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push(`Context before (${before.length}):`);
|
|
111
|
+
if (!before.length) lines.push(" (none)");
|
|
112
|
+
for (const item of before) lines.push(formatMessageLine(item));
|
|
113
|
+
lines.push(`Context after (${after.length}):`);
|
|
114
|
+
if (!after.length) lines.push(" (none)");
|
|
115
|
+
for (const item of after) lines.push(formatMessageLine(item));
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Returns the lines for a chat-report operation, or null when the operation
|
|
120
|
+
// is not one of these (holds/export/suspend have their own printer).
|
|
121
|
+
export function formatChatReportResult({ operation, data }) {
|
|
122
|
+
if (operation?.name === "chat-reports.list") return formatChatReportList(data);
|
|
123
|
+
if (operation?.name === "chat-reports.show" || operation?.name === "chat-reports.set") {
|
|
124
|
+
const lines = data?.report ? formatChatReport(data.report) : ["No report returned."];
|
|
125
|
+
if (operation.name === "chat-reports.set") lines.unshift("Recorded.");
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// `lumine admin profile show <user-id|username>`: a member's public profile as
|
|
2
|
+
// a reviewer reads it (identity, rank and titles, bio, counts, and their
|
|
3
|
+
// latest public subjects, comments, builds, AI stories and shared
|
|
4
|
+
// reflections), for the daily run's Notable Users candidate check without a
|
|
5
|
+
// browser. Read-only; a daily-run read like `brief` (content:read). The API
|
|
6
|
+
// serves it as a signed-out visitor would see the profile: no emails.
|
|
7
|
+
|
|
8
|
+
export const PROFILE_USAGE =
|
|
9
|
+
"Usage: lumine admin profile show <user-id|username> [--json].";
|
|
10
|
+
|
|
11
|
+
const SITE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
function validationError(message) {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildProfileShowOperation({ action, target, extra }) {
|
|
20
|
+
const rawTarget = String(target || "").trim();
|
|
21
|
+
if (action !== "show" || !rawTarget || extra) {
|
|
22
|
+
throw validationError(PROFILE_USAGE);
|
|
23
|
+
}
|
|
24
|
+
const query = new URLSearchParams();
|
|
25
|
+
if (/^\d+$/.test(rawTarget)) {
|
|
26
|
+
const userId = Number(rawTarget);
|
|
27
|
+
if (!Number.isSafeInteger(userId) || userId < 1) {
|
|
28
|
+
throw validationError("The user ID must be a positive integer.");
|
|
29
|
+
}
|
|
30
|
+
query.set("userId", String(userId));
|
|
31
|
+
} else {
|
|
32
|
+
query.set("username", rawTarget.replace(/^@/, ""));
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
name: "profile.show",
|
|
36
|
+
method: "GET",
|
|
37
|
+
path: `/cli/admin/profile?${query.toString()}`,
|
|
38
|
+
body: undefined,
|
|
39
|
+
mutates: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function day(seconds) {
|
|
44
|
+
const value = Number(seconds || 0);
|
|
45
|
+
return value ? new Date(value * 1000).toISOString().slice(0, 10) : "unknown";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ago(seconds, now) {
|
|
49
|
+
const value = Number(seconds || 0);
|
|
50
|
+
if (!value) return "";
|
|
51
|
+
const days = Math.floor((now - value * 1000) / SITE_DAY_MS);
|
|
52
|
+
if (days <= 0) return " (under a day ago)";
|
|
53
|
+
return ` (${days} day${days === 1 ? "" : "s"} ago)`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function section(lines, heading, items, describe) {
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push(`${heading} (${items.length}):`);
|
|
59
|
+
if (!items.length) {
|
|
60
|
+
lines.push(" (none)");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const [head, ...rest] = describe(item);
|
|
65
|
+
lines.push(` ${head}`);
|
|
66
|
+
for (const line of rest) if (line) lines.push(` ${line}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function describeCommentRoot(item) {
|
|
71
|
+
if (item.subjectTitle) return `"${item.subjectTitle}"`;
|
|
72
|
+
if (item.rootType === "user") {
|
|
73
|
+
return item.rootId ? `user #${item.rootId}'s profile` : "a profile";
|
|
74
|
+
}
|
|
75
|
+
return `${item.rootType || "post"}${item.rootId ? ` ${item.rootId}` : ""}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatProfileReview(data = {}, { now = Date.now() } = {}) {
|
|
79
|
+
const profile = data.profile || {};
|
|
80
|
+
const counts = data.counts || {};
|
|
81
|
+
const recent = data.recent || {};
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push(
|
|
84
|
+
`${profile.username || "(no username)"} (#${profile.id})${profile.realName ? ` · ${profile.realName}` : ""}${profile.isNotable ? " · on Notable Users" : ""}`,
|
|
85
|
+
);
|
|
86
|
+
if (profile.url) lines.push(profile.url);
|
|
87
|
+
lines.push(
|
|
88
|
+
`Joined ${day(profile.joinedAt)}${ago(profile.joinedAt, now)} · last active ${day(profile.lastActive)}${ago(profile.lastActive, now)}`,
|
|
89
|
+
);
|
|
90
|
+
const standing = [
|
|
91
|
+
profile.rank ? `rank #${profile.rank}` : "",
|
|
92
|
+
`${Number(profile.twinkleXP || 0).toLocaleString("en-US")} XP`,
|
|
93
|
+
profile.xpThisMonth
|
|
94
|
+
? `${Number(profile.xpThisMonth).toLocaleString("en-US")} XP this month`
|
|
95
|
+
: "",
|
|
96
|
+
`${Number(profile.achievementPoints || 0).toLocaleString("en-US")} AP`,
|
|
97
|
+
profile.userType ? `type ${profile.userType}` : "",
|
|
98
|
+
profile.title ? `title "${profile.title}"` : "",
|
|
99
|
+
].filter(Boolean);
|
|
100
|
+
lines.push(standing.join(" · "));
|
|
101
|
+
if (Array.isArray(profile.achievements) && profile.achievements.length) {
|
|
102
|
+
lines.push(`Achievements: ${profile.achievements.join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
if (profile.isNotable && profile.notableReason) {
|
|
105
|
+
lines.push(`Notable because: ${profile.notableReason}`);
|
|
106
|
+
}
|
|
107
|
+
if (profile.statusMsg) lines.push(`Status: ${profile.statusMsg}`);
|
|
108
|
+
if (profile.greeting) lines.push(`Greeting: ${profile.greeting}`);
|
|
109
|
+
if (Array.isArray(profile.bio) && profile.bio.length) {
|
|
110
|
+
lines.push(`Bio: ${profile.bio.join(" / ")}`);
|
|
111
|
+
}
|
|
112
|
+
const links = [profile.website, profile.youtubeUrl].filter(Boolean);
|
|
113
|
+
if (links.length) lines.push(`Links: ${links.join(" · ")}`);
|
|
114
|
+
lines.push(
|
|
115
|
+
`Counts: ${counts.subjects ?? 0} subjects · ${counts.comments ?? 0} comments · ${counts.aiStories ?? 0} AI stories · ${counts.builds ?? 0} public builds · ${counts.sharedReflections ?? 0} shared reflections${profile.pictures ? ` · ${profile.pictures} profile pictures` : ""}`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
section(lines, "Recent subjects", recent.subjects || [], (item) => [
|
|
119
|
+
`${day(item.createdAt)} ${item.title || "(untitled)"} ${item.url}`,
|
|
120
|
+
item.excerpt,
|
|
121
|
+
]);
|
|
122
|
+
section(lines, "Recent comments", recent.comments || [], (item) => [
|
|
123
|
+
`${day(item.createdAt)} on ${describeCommentRoot(item)} ${item.url}`,
|
|
124
|
+
item.excerpt,
|
|
125
|
+
]);
|
|
126
|
+
section(lines, "Recent public builds", recent.builds || [], (item) => [
|
|
127
|
+
`${day(item.publishedAt || item.updatedAt)} ${item.title || "(untitled)"} ${item.url}`,
|
|
128
|
+
item.excerpt,
|
|
129
|
+
]);
|
|
130
|
+
section(lines, "Recent AI stories", recent.aiStories || [], (item) => [
|
|
131
|
+
`${day(item.createdAt)} ${item.title || "(no topic)"} ${item.url}`,
|
|
132
|
+
]);
|
|
133
|
+
section(lines, "Recent shared reflections", recent.reflections || [], (item) => [
|
|
134
|
+
`${day(item.createdAt)} ${item.url}${item.question ? ` Q: ${item.question}` : ""}`,
|
|
135
|
+
item.excerpt ? `"${item.excerpt}"` : "",
|
|
136
|
+
]);
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// What a write keeps on disk as its receipt (admin-request-intents.js writes
|
|
2
|
+
// it to the admin work directory). Most writes keep the API's response; these
|
|
3
|
+
// return private evidence (a teacher's identity facts, a reported child's
|
|
4
|
+
// chat), which is printed but never persisted: the receipt keeps only what
|
|
5
|
+
// proves the decision (Mikey 2026-10-07, after the identity-inspect fix).
|
|
6
|
+
export function receiptToKeep(operation, result) {
|
|
7
|
+
if (!result || typeof result !== "object") return result;
|
|
8
|
+
const data = result.data || {};
|
|
9
|
+
const keep = (fields) => ({ ok: result.ok, status: result.status, changed: result.changed, data: fields });
|
|
10
|
+
switch (operation?.name) {
|
|
11
|
+
case "teachers.review":
|
|
12
|
+
return keep({
|
|
13
|
+
userId: data.teacherUserId ?? null,
|
|
14
|
+
decision: data.decision ?? null,
|
|
15
|
+
fingerprint: data.fingerprint ?? null,
|
|
16
|
+
reviewedAt: data.reviewedAt ?? null,
|
|
17
|
+
note: data.note ?? null,
|
|
18
|
+
reviewId: data.reviewId ?? null,
|
|
19
|
+
revoked: Boolean(data.revoked),
|
|
20
|
+
});
|
|
21
|
+
case "teachers.review_flagged":
|
|
22
|
+
return keep({
|
|
23
|
+
decision: data.decision ?? null,
|
|
24
|
+
note: data.note ?? null,
|
|
25
|
+
reviewed: (data.reviewed || []).map((r) => ({
|
|
26
|
+
userId: r.teacherUserId ?? null,
|
|
27
|
+
fingerprint: r.fingerprint ?? null,
|
|
28
|
+
reviewedAt: r.reviewedAt ?? null,
|
|
29
|
+
})),
|
|
30
|
+
skipped: (data.skipped || []).map((s) => ({ userId: s.userId ?? null, reason: s.reason ?? null })),
|
|
31
|
+
});
|
|
32
|
+
case "chat-reports.set": {
|
|
33
|
+
const report = data.report || {};
|
|
34
|
+
return keep({
|
|
35
|
+
reportId: report.id ?? null,
|
|
36
|
+
status: report.status ?? null,
|
|
37
|
+
reviewedAt: report.reviewedAt ?? null,
|
|
38
|
+
reviewedByUserId: report.reviewedByUserId ?? null,
|
|
39
|
+
note: report.reviewNote ?? null,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
default:
|
|
43
|
+
// Bridge Builder writes answer with the whole crew view: members' who-
|
|
44
|
+
// are-you answers, parents' addresses and questions, held parent emails.
|
|
45
|
+
// Keep the decision only (ids, statuses, counts), never the crew.
|
|
46
|
+
if (String(operation?.name || "").startsWith("meetup.")) {
|
|
47
|
+
const crew = data.crew || {};
|
|
48
|
+
return keep({
|
|
49
|
+
crewId: crew.crewId ?? data.result?.crewId ?? null,
|
|
50
|
+
crewStatus: crew.status ?? null,
|
|
51
|
+
currentStep: crew.progress?.currentStep ?? null,
|
|
52
|
+
result: scalarsOf(data.result),
|
|
53
|
+
...(data.slot ? { slot: scalarsOf(data.slot) } : {}),
|
|
54
|
+
...(data.unlocks ? { unlocks: scalarsOf(data.unlocks) } : {}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Numbers, booleans, short status words and arrays of ids: what proves a
|
|
62
|
+
// decision without copying anyone's text.
|
|
63
|
+
function scalarsOf(value) {
|
|
64
|
+
if (!value || typeof value !== "object") return null;
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [key, item] of Object.entries(value)) {
|
|
67
|
+
if (typeof item === "number" || typeof item === "boolean" || item === null) out[key] = item;
|
|
68
|
+
else if (typeof item === "string" && /^[\w:.-]{0,40}$/.test(item)) out[key] = item;
|
|
69
|
+
else if (Array.isArray(item) && item.every((x) => typeof x === "number")) out[key] = item;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
package/lib/admin.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
14
14
|
import { requestJson } from "./http.js";
|
|
15
|
+
import { receiptToKeep } from "./admin-receipts.js";
|
|
15
16
|
import { prepareAdminMutationIntent } from "./admin-request-intents.js";
|
|
16
17
|
import { formatBytes, parseStorageSizeBytes } from "./util.js";
|
|
17
18
|
import {
|
|
@@ -37,6 +38,11 @@ import {
|
|
|
37
38
|
buildOwnerTraceOperation,
|
|
38
39
|
printOwnerTrace,
|
|
39
40
|
} from "./admin-owner-trace.js";
|
|
41
|
+
import { formatChatReportResult } from "./admin-chat-reports.js";
|
|
42
|
+
import {
|
|
43
|
+
buildProfileShowOperation,
|
|
44
|
+
formatProfileReview,
|
|
45
|
+
} from "./admin-profile.js";
|
|
40
46
|
import {
|
|
41
47
|
dailyRunGather,
|
|
42
48
|
dailyRunProgress,
|
|
@@ -234,7 +240,7 @@ function parseReviewRequestRef(target, { type: fixedType, typeOption } = {}) {
|
|
|
234
240
|
const APPROVAL_USAGE =
|
|
235
241
|
"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>]";
|
|
236
242
|
const TEACHER_USAGE =
|
|
237
|
-
"Use: lumine admin teachers audit [--limit <1-100>] | revoke <userId> --reason <text>";
|
|
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>";
|
|
238
244
|
|
|
239
245
|
function parseApprovalId(target) {
|
|
240
246
|
const raw = String(target || "").replace(/^approval:/, "");
|
|
@@ -356,19 +362,28 @@ function buildEnglishOperation({ action, target, options }) {
|
|
|
356
362
|
throw cliValidationError(ENGLISH_USAGE);
|
|
357
363
|
}
|
|
358
364
|
|
|
365
|
+
// One audit page stays well under proxy timeouts (100 teachers took over
|
|
366
|
+
// 20 s on 2026-10-05); `--all` pages through every teacher.
|
|
367
|
+
const TEACHER_AUDIT_PAGE = 50;
|
|
368
|
+
// the API's per-request bulk review bound (approvals.ts BULK_REVIEW_MAX)
|
|
369
|
+
const TEACHER_REVIEW_BATCH = 25;
|
|
370
|
+
|
|
359
371
|
function buildTeacherOperation({ action, target, options }) {
|
|
360
372
|
if (!action || action === "audit") {
|
|
361
|
-
|
|
373
|
+
const operation = readOperation(
|
|
362
374
|
"teachers.audit",
|
|
363
375
|
withQuery("/cli/admin/teachers/audit", {
|
|
364
|
-
limit: options.
|
|
365
|
-
?
|
|
366
|
-
:
|
|
376
|
+
limit: options.adminAll
|
|
377
|
+
? TEACHER_AUDIT_PAGE
|
|
378
|
+
: options.limit
|
|
379
|
+
? parseRequiredInteger(options.limit, "--limit", 1, 100)
|
|
380
|
+
: "",
|
|
381
|
+
cursor: options.adminAll ? "" : options.adminCursor || "",
|
|
367
382
|
}),
|
|
368
|
-
// Linked-account networks make large audits slow
|
|
369
|
-
// over 20 s on 2026-10-05), so this read gets its own timeout.
|
|
383
|
+
// Linked-account networks make large audits slow, so this read gets its own timeout.
|
|
370
384
|
{ requiresRun: false, timeoutMs: 120000 },
|
|
371
385
|
);
|
|
386
|
+
return options.adminAll ? { ...operation, teacherWorkflow: "audit-all" } : operation;
|
|
372
387
|
}
|
|
373
388
|
if (action === "revoke" || action === "remove") {
|
|
374
389
|
const userId = String(target || "");
|
|
@@ -387,9 +402,232 @@ function buildTeacherOperation({ action, target, options }) {
|
|
|
387
402
|
{ requiresRun: false },
|
|
388
403
|
);
|
|
389
404
|
}
|
|
405
|
+
// The owner's review of an audited teacher, kept with the fingerprint of the
|
|
406
|
+
// facts he was shown: the server records nothing if they changed since.
|
|
407
|
+
if (action === "review") {
|
|
408
|
+
const note = String(options.note || "").trim();
|
|
409
|
+
if (!note) {
|
|
410
|
+
throw cliValidationError(
|
|
411
|
+
"lumine admin teachers review needs --note <text> (kept with the review).",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (options.adminAllFlagged) {
|
|
415
|
+
if (target || options.adminDecision || options.adminFingerprint) {
|
|
416
|
+
throw cliValidationError(
|
|
417
|
+
"--all-flagged reviews the --expect teachers as legit; leave out <userId>, --decision and --fingerprint.",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
...writeOperation(
|
|
422
|
+
"teachers.review_flagged",
|
|
423
|
+
"POST",
|
|
424
|
+
"/cli/admin/teachers/review-flagged",
|
|
425
|
+
{ note },
|
|
426
|
+
{ requiresRun: false, timeoutMs: 120000 },
|
|
427
|
+
),
|
|
428
|
+
teacherWorkflow: "review-flagged",
|
|
429
|
+
expect: parseTeacherExpect(options.adminExpect),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const userId = String(target || "");
|
|
433
|
+
if (!/^\d+$/.test(userId)) throw cliValidationError(TEACHER_USAGE);
|
|
434
|
+
const decision = parseChoice(options.adminDecision || "", "--decision", [
|
|
435
|
+
"legit",
|
|
436
|
+
"revoke",
|
|
437
|
+
]);
|
|
438
|
+
const fingerprint = String(options.adminFingerprint || "").trim().toLowerCase();
|
|
439
|
+
if (fingerprint && !/^[0-9a-f]{64}$/.test(fingerprint)) {
|
|
440
|
+
throw cliValidationError("--fingerprint is the 64-character fingerprint the teacher audit printed.");
|
|
441
|
+
}
|
|
442
|
+
// a revoke acts on facts Mikey read, never on an entry printed and acted on in one step
|
|
443
|
+
if (decision === "revoke" && !fingerprint) {
|
|
444
|
+
throw cliValidationError(
|
|
445
|
+
"--decision revoke needs --fingerprint <fp> from the audit entry Mikey decided on.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const operation = writeOperation(
|
|
449
|
+
"teachers.review",
|
|
450
|
+
"POST",
|
|
451
|
+
`/cli/admin/teachers/${userId}/review`,
|
|
452
|
+
{ decision, note, ...(fingerprint ? { fingerprint } : {}) },
|
|
453
|
+
{ requiresRun: false, timeoutMs: 60000 },
|
|
454
|
+
);
|
|
455
|
+
// without --fingerprint: read and show the entry first, then send its fingerprint
|
|
456
|
+
return fingerprint ? operation : { ...operation, teacherWorkflow: "review-one", userId: Number(userId) };
|
|
457
|
+
}
|
|
390
458
|
throw cliValidationError(TEACHER_USAGE);
|
|
391
459
|
}
|
|
392
460
|
|
|
461
|
+
/**
|
|
462
|
+
* --expect for `review --all-flagged`: the teachers Mikey decided on, as
|
|
463
|
+
* `id` or `id:<fingerprint prefix, 8+ hex>` (the prefix pins the facts he
|
|
464
|
+
* read, so a flag that moved since is caught even for an expected id).
|
|
465
|
+
*/
|
|
466
|
+
export function parseTeacherExpect(raw) {
|
|
467
|
+
const text = String(raw || "").trim();
|
|
468
|
+
if (!text) {
|
|
469
|
+
throw cliValidationError(
|
|
470
|
+
"--all-flagged needs --expect <id[:fp],...>: the teachers Mikey decided on (from the audit he read). Only those are recorded.",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const expect = new Map();
|
|
474
|
+
for (const part of text.split(",").map((p) => p.trim()).filter(Boolean)) {
|
|
475
|
+
const match = part.match(/^(\d+)(?::([0-9a-fA-F]{8,64}))?$/);
|
|
476
|
+
if (!match) throw cliValidationError(`--expect entry "${part}" is not <id> or <id>:<fingerprint prefix>.`);
|
|
477
|
+
expect.set(Number(match[1]), match[2] ? match[2].toLowerCase() : "");
|
|
478
|
+
}
|
|
479
|
+
if (!expect.size) throw cliValidationError("--expect lists no teachers.");
|
|
480
|
+
return Object.fromEntries(expect);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Split the audit's new/changed teachers by --expect: what is sent, and what is not and why. */
|
|
484
|
+
export function planExpectedReview(shown, expect) {
|
|
485
|
+
const send = [];
|
|
486
|
+
const notSent = [];
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
for (const t of shown) {
|
|
489
|
+
seen.add(t.userId);
|
|
490
|
+
if (!(String(t.userId) in expect)) {
|
|
491
|
+
notSent.push({ userId: t.userId, reason: "new or changed now, but not in --expect: not reviewed" });
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const prefix = expect[String(t.userId)];
|
|
495
|
+
if (prefix && !t.fingerprint.startsWith(prefix)) {
|
|
496
|
+
notSent.push({ userId: t.userId, reason: "changed since the fingerprint in --expect" });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
send.push(t);
|
|
500
|
+
}
|
|
501
|
+
for (const id of Object.keys(expect)) {
|
|
502
|
+
if (!seen.has(Number(id))) notSent.push({ userId: Number(id), reason: "in --expect, but not new or changed in this audit" });
|
|
503
|
+
}
|
|
504
|
+
return { send, notSent };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const TEACHER_STATUS_ORDER = { unreviewed: 0, changed: 1, reviewed: 2, clean: 3 };
|
|
508
|
+
|
|
509
|
+
/** Audit pages merged into one audit, in the server's owner order. */
|
|
510
|
+
export function mergeTeacherAuditPages(pages) {
|
|
511
|
+
const teachers = pages.flatMap((page) => page.teachers || []);
|
|
512
|
+
teachers.sort(
|
|
513
|
+
(a, b) =>
|
|
514
|
+
(TEACHER_STATUS_ORDER[a.reviewStatus] ?? 9) - (TEACHER_STATUS_ORDER[b.reviewStatus] ?? 9) ||
|
|
515
|
+
(b.flags || []).length - (a.flags || []).length ||
|
|
516
|
+
Number(b.approvedAt) - Number(a.approvedAt),
|
|
517
|
+
);
|
|
518
|
+
const sum = (key) => pages.reduce((total, page) => total + Number(page[key] || 0), 0);
|
|
519
|
+
return {
|
|
520
|
+
teachers,
|
|
521
|
+
audited: sum("audited"),
|
|
522
|
+
flagged: sum("flagged"),
|
|
523
|
+
newlyFlagged: sum("newlyFlagged"),
|
|
524
|
+
changedSinceReview: sum("changedSinceReview"),
|
|
525
|
+
reviewedUnchanged: sum("reviewedUnchanged"),
|
|
526
|
+
failedUserIds: pages.flatMap((page) => page.failedUserIds || []),
|
|
527
|
+
nextCursor: null,
|
|
528
|
+
pages: pages.length,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** The teachers an --all-flagged review sends: those the audit showed as new or changed. */
|
|
533
|
+
export function teachersToReview(teachers) {
|
|
534
|
+
return (teachers || [])
|
|
535
|
+
.filter((t) => t.reviewStatus === "unreviewed" || t.reviewStatus === "changed")
|
|
536
|
+
.map((t) => ({ userId: Number(t.account?.userId), fingerprint: t.fingerprint }))
|
|
537
|
+
.filter((t) => t.userId > 0 && /^[0-9a-f]{64}$/.test(String(t.fingerprint || "")));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const unwrapAdminData = (receipt) => {
|
|
541
|
+
if (!receipt || receipt.ok !== true) {
|
|
542
|
+
const error = new Error(receipt?.error?.message || "The API did not confirm the teacher audit request.");
|
|
543
|
+
error.code = receipt?.error?.code || "CLI_ADMIN_RECEIPT_INVALID";
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
return receipt.data || {};
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Multi-request teacher commands: `audit --all` (every page), `review <id>`
|
|
551
|
+
* (show the entry, then send its fingerprint) and `review --all-flagged`
|
|
552
|
+
* (page the audit, print it, then send what it showed as new or changed, in
|
|
553
|
+
* bounded batches).
|
|
554
|
+
*/
|
|
555
|
+
export async function runTeacherWorkflow({ options, operation, authToken, requestId, request = requestJson, print = !options.json }) {
|
|
556
|
+
const call = (method, requestPath, body, key) =>
|
|
557
|
+
request({
|
|
558
|
+
method,
|
|
559
|
+
url: `${options.apiUrl}${requestPath}`,
|
|
560
|
+
authToken,
|
|
561
|
+
body,
|
|
562
|
+
headers: key ? { "x-lumine-idempotency-key": key } : {},
|
|
563
|
+
timeoutMs: Math.max(120000, Number(options.timeoutMs) || 0),
|
|
564
|
+
}).then(unwrapAdminData);
|
|
565
|
+
const auditAll = async () => {
|
|
566
|
+
const pages = [];
|
|
567
|
+
let cursor = "";
|
|
568
|
+
for (let i = 0; i < 200; i += 1) {
|
|
569
|
+
const page = await call("GET", withQuery("/cli/admin/teachers/audit", { limit: TEACHER_AUDIT_PAGE, cursor }));
|
|
570
|
+
pages.push(page);
|
|
571
|
+
if (!page.nextCursor) return mergeTeacherAuditPages(pages);
|
|
572
|
+
cursor = page.nextCursor;
|
|
573
|
+
}
|
|
574
|
+
throw new Error("The teacher audit did not finish paging; nothing was reviewed.");
|
|
575
|
+
};
|
|
576
|
+
if (operation.teacherWorkflow === "audit-all") {
|
|
577
|
+
return { ok: true, status: "success", data: await auditAll() };
|
|
578
|
+
}
|
|
579
|
+
if (operation.teacherWorkflow === "review-one") {
|
|
580
|
+
const { teacher } = await call("GET", `/cli/admin/teachers/${operation.userId}/audit`);
|
|
581
|
+
if (print) {
|
|
582
|
+
console.log("Reviewing this audit entry:");
|
|
583
|
+
printTeacherEntry(teacher, { full: true });
|
|
584
|
+
console.log("");
|
|
585
|
+
}
|
|
586
|
+
if (teacher?.evidenceGap) {
|
|
587
|
+
throw cliValidationError(`Not reviewed: ${teacher.evidenceGap}. Try again later.`);
|
|
588
|
+
}
|
|
589
|
+
const data = await call("POST", operation.path, { ...operation.body, fingerprint: teacher.fingerprint }, requestId);
|
|
590
|
+
// the entry was printed above; it is never returned (the receipt stays minimal)
|
|
591
|
+
return { ok: true, status: "success", data };
|
|
592
|
+
}
|
|
593
|
+
if (operation.teacherWorkflow === "review-flagged") {
|
|
594
|
+
const audit = await auditAll();
|
|
595
|
+
if (print) {
|
|
596
|
+
printApprovalResult({ operation: { name: "teachers.audit" }, data: audit });
|
|
597
|
+
console.log("");
|
|
598
|
+
}
|
|
599
|
+
const { send: shown, notSent } = planExpectedReview(teachersToReview(audit.teachers), operation.expect || {});
|
|
600
|
+
const total = { reviewed: [], skipped: [...notSent], changedSinceYouLooked: [] };
|
|
601
|
+
for (let i = 0; i < shown.length; i += TEACHER_REVIEW_BATCH) {
|
|
602
|
+
const batch = shown.slice(i, i + TEACHER_REVIEW_BATCH);
|
|
603
|
+
let data;
|
|
604
|
+
try {
|
|
605
|
+
data = await call("POST", operation.path, { note: operation.body.note, teachers: batch }, `${requestId}:${i / TEACHER_REVIEW_BATCH}`);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
console.error(`Stopped at batch ${i / TEACHER_REVIEW_BATCH + 1}: ${total.reviewed.length} reviewed before it. ${error.message}`);
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
total.reviewed.push(...(data.reviewed || []));
|
|
611
|
+
total.skipped.push(...(data.skipped || []));
|
|
612
|
+
total.changedSinceYouLooked.push(...(data.changedSinceYouLooked || []));
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
ok: true,
|
|
616
|
+
status: "success",
|
|
617
|
+
data: {
|
|
618
|
+
decision: "legit",
|
|
619
|
+
note: operation.body.note,
|
|
620
|
+
...total,
|
|
621
|
+
reviewedCount: total.reviewed.length,
|
|
622
|
+
sent: shown.length,
|
|
623
|
+
audited: audit.audited,
|
|
624
|
+
flagged: audit.flagged,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
throw new Error(`Unknown teacher workflow ${operation.teacherWorkflow}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
393
631
|
// Builds the operation for list/show/approve/reject/revoke. `fixedType` is set
|
|
394
632
|
// by the per-type aliases; `listStatuses` keeps their own --status choices.
|
|
395
633
|
function buildReviewRequestOperation({
|
|
@@ -1021,6 +1259,13 @@ export async function adminCommand(options) {
|
|
|
1021
1259
|
runId,
|
|
1022
1260
|
parseTarget: parseRecommendationTarget,
|
|
1023
1261
|
});
|
|
1262
|
+
} else if (operation.teacherWorkflow) {
|
|
1263
|
+
result = await runTeacherWorkflow({
|
|
1264
|
+
options,
|
|
1265
|
+
operation,
|
|
1266
|
+
authToken: auth.token,
|
|
1267
|
+
requestId,
|
|
1268
|
+
});
|
|
1024
1269
|
} else if (operation.name.startsWith("runtime-logs.")) {
|
|
1025
1270
|
result = await runAdminRuntimeLogWorkflow({
|
|
1026
1271
|
options,
|
|
@@ -1068,7 +1313,8 @@ export async function adminCommand(options) {
|
|
|
1068
1313
|
}
|
|
1069
1314
|
result = transformResult(await fetchOperation());
|
|
1070
1315
|
}
|
|
1071
|
-
|
|
1316
|
+
// private evidence is printed, never kept in the receipt file
|
|
1317
|
+
mutationIntent?.confirm(receiptToKeep(operation, result));
|
|
1072
1318
|
} catch (error) {
|
|
1073
1319
|
if (operation.name === "runtime.evidence" && error.status === 404) {
|
|
1074
1320
|
error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
|
|
@@ -2337,7 +2583,7 @@ export function parseAdminOperation(options) {
|
|
|
2337
2583
|
// reviews as the admin controls on the website quest page. Mikey's own
|
|
2338
2584
|
// decision, any time, never in a daily run.
|
|
2339
2585
|
const usage =
|
|
2340
|
-
"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>.";
|
|
2586
|
+
"Usage: lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list|show|approve|reject|merge] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId>.";
|
|
2341
2587
|
const note = String(options.note || "").trim();
|
|
2342
2588
|
if (note.length > 1000) {
|
|
2343
2589
|
throw cliValidationError("--note must be at most 1000 characters.");
|
|
@@ -2502,6 +2748,29 @@ export function parseAdminOperation(options) {
|
|
|
2502
2748
|
{ requiresRun: false },
|
|
2503
2749
|
);
|
|
2504
2750
|
}
|
|
2751
|
+
if (action === "info") {
|
|
2752
|
+
// Staff's "who are you" check on a member (ruled 2026-10-07): the member
|
|
2753
|
+
// gives their teacher's name and class name (or how they know the crew)
|
|
2754
|
+
// on the crew page; the crew waits until you accept. Nobody is contacted.
|
|
2755
|
+
const crewId = parseRequiredInteger(target, "Crew ID", 1);
|
|
2756
|
+
const memberId = parseRequiredInteger(extra, "Member user ID", 1);
|
|
2757
|
+
const decision = parseChoice(options.adminDecision || "", "--decision", [
|
|
2758
|
+
"ask",
|
|
2759
|
+
"accept",
|
|
2760
|
+
"ask-again",
|
|
2761
|
+
"withdraw",
|
|
2762
|
+
]);
|
|
2763
|
+
if (decision === "ask-again" && !note) {
|
|
2764
|
+
throw cliValidationError("--decision ask-again needs --note <what to tell the member>.");
|
|
2765
|
+
}
|
|
2766
|
+
return writeOperation(
|
|
2767
|
+
"meetup.info",
|
|
2768
|
+
"POST",
|
|
2769
|
+
`/cli/admin/meetup-quest/crews/${crewId}/members/${memberId}/info-check`,
|
|
2770
|
+
{ action: decision === "ask" ? "request" : decision, note },
|
|
2771
|
+
{ requiresRun: false },
|
|
2772
|
+
);
|
|
2773
|
+
}
|
|
2505
2774
|
if (action === "approve-crew" || action === "approve-grownup") {
|
|
2506
2775
|
// Every step passes through the owner: the crew (members + branches) and
|
|
2507
2776
|
// the grown-up step (parents' yes + the named adult).
|
|
@@ -3627,6 +3896,11 @@ export function parseAdminOperation(options) {
|
|
|
3627
3896
|
);
|
|
3628
3897
|
}
|
|
3629
3898
|
|
|
3899
|
+
// A member's public profile for review (admin-profile.js); a daily-run read.
|
|
3900
|
+
if (namespace === "profile") {
|
|
3901
|
+
return buildProfileShowOperation({ action, target, extra });
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3630
3904
|
if (namespace === "notable" && action === "status" && !extra) {
|
|
3631
3905
|
const rawTarget = String(target || "").trim();
|
|
3632
3906
|
if (!rawTarget) {
|
|
@@ -5465,7 +5739,7 @@ function formatMeetupMembers(members) {
|
|
|
5465
5739
|
return (members || [])
|
|
5466
5740
|
.map(
|
|
5467
5741
|
(member) =>
|
|
5468
|
-
`${member.username || `user ${member.userId}`} (${member.userId}, ${member.branch || "no branch"}${member.parentOk === undefined ? "" : member.parentOk ? ", parent OK" : ", parent not yet"}${member.attended ? ", attended" : ""})`,
|
|
5742
|
+
`${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" : ""})`,
|
|
5469
5743
|
)
|
|
5470
5744
|
.join("; ");
|
|
5471
5745
|
}
|
|
@@ -5480,6 +5754,17 @@ function printMeetupCrew(crew) {
|
|
|
5480
5754
|
console.log(` ${step.number}. ${step.label} [${step.state}] ${step.detail}`);
|
|
5481
5755
|
}
|
|
5482
5756
|
console.log(` Members: ${formatMeetupMembers(crew.members)}`);
|
|
5757
|
+
// staff's "who are you" checks on these members (any crew), newest first
|
|
5758
|
+
for (const check of crew.memberChecks || []) {
|
|
5759
|
+
const answer = check.teacherName
|
|
5760
|
+
? `teacher "${check.teacherName}", class "${check.className}"`
|
|
5761
|
+
: check.relationship
|
|
5762
|
+
? `how they know the crew: "${check.relationship}"`
|
|
5763
|
+
: "no answer yet";
|
|
5764
|
+
console.log(
|
|
5765
|
+
` 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}` : ""}`,
|
|
5766
|
+
);
|
|
5767
|
+
}
|
|
5483
5768
|
for (const parent of crew.parentContacts || []) {
|
|
5484
5769
|
console.log(
|
|
5485
5770
|
` 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}"` : ""}`,
|
|
@@ -5624,11 +5909,21 @@ function printMeetupResult({ operation, data, result }) {
|
|
|
5624
5909
|
}
|
|
5625
5910
|
if (crews.some((crew) => crew.waitingFor)) {
|
|
5626
5911
|
console.log(
|
|
5627
|
-
"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).",
|
|
5912
|
+
"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).",
|
|
5628
5913
|
);
|
|
5629
5914
|
}
|
|
5630
5915
|
return;
|
|
5631
5916
|
}
|
|
5917
|
+
if (operation.name === "meetup.info") {
|
|
5918
|
+
const status = data.result?.status;
|
|
5919
|
+
console.log(
|
|
5920
|
+
result?.changed === false
|
|
5921
|
+
? `Member ${data.result?.userId} already has an open question (${status}).`
|
|
5922
|
+
: `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}.`,
|
|
5923
|
+
);
|
|
5924
|
+
if (data.crew) printMeetupCrew(data.crew);
|
|
5925
|
+
return;
|
|
5926
|
+
}
|
|
5632
5927
|
if (operation.name === "meetup.show" || operation.name === "meetup.parent-reply") {
|
|
5633
5928
|
printMeetupCrew(data.crew || {});
|
|
5634
5929
|
return;
|
|
@@ -5861,6 +6156,39 @@ function printEvidence(evidence) {
|
|
|
5861
6156
|
if (evidence.note) console.log(evidence.note);
|
|
5862
6157
|
}
|
|
5863
6158
|
|
|
6159
|
+
const TEACHER_REVIEW_LABELS = {
|
|
6160
|
+
unreviewed: "NEW: flagged, not reviewed",
|
|
6161
|
+
changed: "CHANGED since review",
|
|
6162
|
+
reviewed: "reviewed, unchanged",
|
|
6163
|
+
clean: "no flags",
|
|
6164
|
+
};
|
|
6165
|
+
|
|
6166
|
+
export function formatTeacherAuditSummary(data) {
|
|
6167
|
+
return `Audited ${data.audited || 0} approved teachers · ${data.flagged || 0} flagged: ${data.newlyFlagged || 0} new, ${data.changedSinceReview || 0} changed since review, ${data.reviewedUnchanged || 0} reviewed and unchanged${data.failedUserIds?.length ? ` · evidence failed for ${data.failedUserIds.map((id) => `#${id}`).join(", ")}` : ""}${data.moreTeachersNotRead ? " · older teachers not read (raise --limit)" : ""}`;
|
|
6168
|
+
}
|
|
6169
|
+
|
|
6170
|
+
export function formatTeacherReviewLine(teacher) {
|
|
6171
|
+
const a = teacher.account || {};
|
|
6172
|
+
const r = teacher.review;
|
|
6173
|
+
const status = TEACHER_REVIEW_LABELS[teacher.reviewStatus] || teacher.reviewStatus || "?";
|
|
6174
|
+
const reviewed = r
|
|
6175
|
+
? ` · last review ${r.decision} ${approvalDate(r.reviewedAt)} by ${r.reviewedBy}${r.note ? `: "${r.note}"` : ""}`
|
|
6176
|
+
: "";
|
|
6177
|
+
const flags = (teacher.flags || []).length ? ` · flags ${teacher.flags.join(", ")}` : "";
|
|
6178
|
+
return `[${status}] ${a.username || "?"} (#${a.userId})${flags}${reviewed}`;
|
|
6179
|
+
}
|
|
6180
|
+
|
|
6181
|
+
// One audit entry: reviewed-unchanged teachers are one line (already judged);
|
|
6182
|
+
// the rest show what changed, the evidence and the fingerprint a review sends.
|
|
6183
|
+
function printTeacherEntry(teacher, { full = false } = {}) {
|
|
6184
|
+
console.log(formatTeacherReviewLine(teacher));
|
|
6185
|
+
if (teacher.reviewStatus === "reviewed" && !full) return;
|
|
6186
|
+
for (const change of teacher.review?.changes || []) console.log(` changed: ${change}`);
|
|
6187
|
+
if (teacher.evidenceGap) console.log(` cannot be reviewed now: ${teacher.evidenceGap}`);
|
|
6188
|
+
printEvidence(teacher);
|
|
6189
|
+
if (teacher.fingerprint) console.log(`Fingerprint: ${teacher.fingerprint}`);
|
|
6190
|
+
}
|
|
6191
|
+
|
|
5864
6192
|
function printApprovalResult({ operation, data }) {
|
|
5865
6193
|
if (operation.name === "approvals.list") {
|
|
5866
6194
|
const items = data.items || [];
|
|
@@ -5887,12 +6215,35 @@ function printApprovalResult({ operation, data }) {
|
|
|
5887
6215
|
return;
|
|
5888
6216
|
}
|
|
5889
6217
|
if (operation.name === "teachers.audit") {
|
|
5890
|
-
console.log(
|
|
5891
|
-
`Audited ${data.audited || 0} approved teachers · ${data.flagged || 0} with flags (riskiest first).`,
|
|
5892
|
-
);
|
|
6218
|
+
console.log(formatTeacherAuditSummary(data));
|
|
5893
6219
|
for (const teacher of data.teachers || []) {
|
|
5894
6220
|
console.log("");
|
|
5895
|
-
|
|
6221
|
+
printTeacherEntry(teacher);
|
|
6222
|
+
}
|
|
6223
|
+
if (data.nextCursor) console.log(`\nMore teachers: --cursor ${data.nextCursor}, or --all for every page.`);
|
|
6224
|
+
return;
|
|
6225
|
+
}
|
|
6226
|
+
if (operation.name === "teachers.review") {
|
|
6227
|
+
console.log(
|
|
6228
|
+
`Reviewed #${data.teacherUserId} as ${data.decision} (fingerprint ${String(data.fingerprint || "").slice(0, 12)}; flags ${(data.flags || []).join(", ") || "none"}). Note kept: ${data.note}`,
|
|
6229
|
+
);
|
|
6230
|
+
if (data.revoked) {
|
|
6231
|
+
console.log(
|
|
6232
|
+
`Teacher status removed (achievements ${(data.revoked.removedAchievementIds || []).join(", ") || "-"}; request #${data.revoked.approvalItemId ?? "-"} marked rejected).`,
|
|
6233
|
+
);
|
|
6234
|
+
}
|
|
6235
|
+
return;
|
|
6236
|
+
}
|
|
6237
|
+
if (operation.name === "teachers.review_flagged") {
|
|
6238
|
+
console.log(
|
|
6239
|
+
`Reviewed ${data.reviewedCount || 0} of the ${data.sent || 0} expected new or changed flagged teacher(s) shown above as legit (${data.flagged || 0} flagged, ${data.audited || 0} audited). Note: ${data.note}`,
|
|
6240
|
+
);
|
|
6241
|
+
for (const r of data.reviewed || []) {
|
|
6242
|
+
console.log(` - ${r.username || "?"} (#${r.teacherUserId}) · was ${r.previousStatus}`);
|
|
6243
|
+
}
|
|
6244
|
+
for (const s of data.skipped || []) console.log(` skipped #${s.userId}: ${s.reason}`);
|
|
6245
|
+
if ((data.changedSinceYouLooked || []).length) {
|
|
6246
|
+
console.log(` Changed since the audit above (not reviewed; audit again): ${data.changedSinceYouLooked.map((id) => `#${id}`).join(", ")}`);
|
|
5896
6247
|
}
|
|
5897
6248
|
return;
|
|
5898
6249
|
}
|
|
@@ -6098,6 +6449,15 @@ function printRewardBankReport(data) {
|
|
|
6098
6449
|
function printAdminResult({ operation, result }) {
|
|
6099
6450
|
const data = result?.data || {};
|
|
6100
6451
|
if (printChatSafetyResult({ operation, data })) return;
|
|
6452
|
+
const chatReportLines = formatChatReportResult({ operation, data });
|
|
6453
|
+
if (chatReportLines) {
|
|
6454
|
+
for (const line of chatReportLines) console.log(line);
|
|
6455
|
+
return;
|
|
6456
|
+
}
|
|
6457
|
+
if (operation.name === "profile.show") {
|
|
6458
|
+
for (const line of formatProfileReview(data)) console.log(line);
|
|
6459
|
+
return;
|
|
6460
|
+
}
|
|
6101
6461
|
if (operation.name === "reward-activity.report") {
|
|
6102
6462
|
printRewardActivity(data);
|
|
6103
6463
|
return;
|
|
@@ -6348,7 +6708,9 @@ function printAdminResult({ operation, result }) {
|
|
|
6348
6708
|
console.log(`Checkpoint: ${data.batch.checkpointPath}`);
|
|
6349
6709
|
return;
|
|
6350
6710
|
}
|
|
6351
|
-
|
|
6711
|
+
// A daily-run report; other operations' `report` (chat reports) are
|
|
6712
|
+
// printed above and must never reach this branch.
|
|
6713
|
+
if (data.report?.run) {
|
|
6352
6714
|
const report = data.report;
|
|
6353
6715
|
const historical = report.basis?.kind === "historical_reconstruction";
|
|
6354
6716
|
console.log(
|
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(
|
|
@@ -2384,6 +2433,7 @@ export function parseArgs(args) {
|
|
|
2384
2433
|
"perf",
|
|
2385
2434
|
"includeLegacy",
|
|
2386
2435
|
"includePrivateEvidence",
|
|
2436
|
+
"allFlagged",
|
|
2387
2437
|
"reviewed",
|
|
2388
2438
|
"noReviewLoop",
|
|
2389
2439
|
"acceptAgreement",
|
|
@@ -2428,6 +2478,7 @@ export function parseArgs(args) {
|
|
|
2428
2478
|
"replace-main",
|
|
2429
2479
|
"adopt-thumbnail",
|
|
2430
2480
|
"adopt-title",
|
|
2481
|
+
"decline",
|
|
2431
2482
|
]);
|
|
2432
2483
|
const suggestionAction =
|
|
2433
2484
|
command === "suggest"
|
|
@@ -2505,6 +2556,9 @@ export function parseArgs(args) {
|
|
|
2505
2556
|
false,
|
|
2506
2557
|
),
|
|
2507
2558
|
adminAll: Boolean(raw.all),
|
|
2559
|
+
adminAllFlagged: Boolean(raw.allFlagged),
|
|
2560
|
+
adminFingerprint: raw.fingerprint ? String(raw.fingerprint) : "",
|
|
2561
|
+
adminExpect: raw.expect ? String(raw.expect) : "",
|
|
2508
2562
|
adminResume: Boolean(raw.resume),
|
|
2509
2563
|
adminCheckpoint: raw.checkpoint ? String(raw.checkpoint) : "",
|
|
2510
2564
|
adminOutput: raw.output ? String(raw.output) : "",
|
|
@@ -2966,6 +3020,7 @@ export function printHelp() {
|
|
|
2966
3020
|
lumine suggestions replace-main <suggestion-id> [--build <id>]
|
|
2967
3021
|
lumine suggestions adopt-thumbnail <suggestion-id> [--build <id>] [--yes]
|
|
2968
3022
|
lumine suggestions adopt-title <suggestion-id> [--build <id>] [--yes]
|
|
3023
|
+
lumine suggestions decline <suggestion-id> [--build <id>] [--yes]
|
|
2969
3024
|
lumine explore [search terms]
|
|
2970
3025
|
lumine select [twinkle-build-url]
|
|
2971
3026
|
lumine pull [twinkle-build-url]
|
|
@@ -3030,12 +3085,13 @@ export function printHelp() {
|
|
|
3030
3085
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
3031
3086
|
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>
|
|
3032
3087
|
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)
|
|
3033
|
-
lumine admin teachers audit [--limit <1-100>] | revoke <userId> --reason <text> (approved teachers,
|
|
3088
|
+
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)
|
|
3089
|
+
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)
|
|
3034
3090
|
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)
|
|
3035
3091
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
3036
3092
|
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
3037
3093
|
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>]
|
|
3038
|
-
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)
|
|
3094
|
+
lumine admin meetup list [--status review|active|completed|all] | approve-crew <crewId> | approve-grownup <crewId> | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | parent-reply <crewId> <memberUserId> --note <text> | funnel [--days <1-90>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | show <crewId> | approve-plan <crewId> [--note <text>] | send-back <crewId> --note <text> | approve <crewId> --attended <userId,userId,...> [--note <text>] | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | slot <crewId> --confirm <slotIndex> | resend-email <crewId> (Bridge Builder meetup quest)
|
|
3039
3095
|
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)
|
|
3040
3096
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
3041
3097
|
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
|
@@ -3109,6 +3165,7 @@ export function printHelp() {
|
|
|
3109
3165
|
lumine admin news claim [--date YYYY-MM-DD] [--output <claim.json>] [--scaffold <editorial.json>] [--json]
|
|
3110
3166
|
lumine admin news validate --claim <claim.json> --file <editorial.json> [--json]
|
|
3111
3167
|
lumine admin news submit --claim <claim.json> --file <editorial.json> [--model <name>] [--json]
|
|
3168
|
+
lumine admin profile show <user-id|username> [--json] (public profile for review: rank, bio, counts, recent posts; daily-run read)
|
|
3112
3169
|
lumine admin notable status <user-id|username> [--json]
|
|
3113
3170
|
lumine admin notable add <user-id|username> --note <text> [--json]
|
|
3114
3171
|
lumine admin notable remove <user-id|username> --note <text> [--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
|
@@ -1174,8 +1174,10 @@ Birthdate, teacher-signup (`mentor`) and meetup-achievement requests, each with
|
|
|
1174
1174
|
lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup]
|
|
1175
1175
|
lumine admin approvals show <id>
|
|
1176
1176
|
lumine admin approvals approve|reject <id> [--reason <text>]
|
|
1177
|
-
lumine admin teachers audit [--limit 1-100]
|
|
1177
|
+
lumine admin teachers audit [--limit 1-100] [--cursor <c>] [--all]
|
|
1178
1178
|
lumine admin teachers revoke <userId> --reason <text>
|
|
1179
|
+
lumine admin teachers review <userId> --decision legit|revoke --note <text> [--fingerprint <fp>] # revoke requires --fingerprint
|
|
1180
|
+
lumine admin teachers review --all-flagged --expect <id[:fp],...> --note <text>
|
|
1179
1181
|
lumine admin identity network <userId|username> --reason <text> [--include-private-evidence]
|
|
1180
1182
|
```
|
|
1181
1183
|
|
|
@@ -1191,7 +1193,14 @@ lumine admin identity network <userId|username> --reason <text> [--include-priva
|
|
|
1191
1193
|
- `linked_account_is_minor`, `own_birthdate_is_minor`, `new_account_with_linked_accounts`, `real_name_differs_from_profile`, `banned_account_in_family`.
|
|
1192
1194
|
|
|
1193
1195
|
- Flags are pointers, not verdicts: a school device can link strangers. Judge each case, recommend, and act only on Mikey's decision.
|
|
1194
|
-
- `teachers audit`
|
|
1196
|
+
- `teachers audit` reads approved teachers a page at a time (newest approval first, up to 100 per page; `--cursor` continues, `--all` pages through every teacher). Each page lists, in this order: flagged and never reviewed (`NEW`), reviewed but materially changed since (`CHANGED`, each change listed), reviewed and unchanged (one line, no evidence), then teachers with no flags. `--all` merges the pages in that order. Counts: `audited`, `flagged`, `newlyFlagged`, `changedSinceReview`, `reviewedUnchanged`. Each entry carries its `fingerprint`.
|
|
1197
|
+
- `teachers review` records Mikey's review (added 2026-10-07) of the facts he was shown. The fingerprint covers the flag set and the linked accounts (who, linked how, and whether each has a recorded age under 14 or under 18). Activity, last-active time, addresses and shared-device counts are not part of it. **Accounts that only share a network with the teacher count only when they change a network flag** (`shares_small_network_with_…`): a new sibling on a home address that already raised the member flag does not reopen a review, by design, because network sharers churn. The latest review per teacher wins; a teacher reappears when those facts change, or when a teacher reviewed as `revoke` holds the status again.
|
|
1198
|
+
- `review <userId>` reads and prints that teacher's current entry, then sends its fingerprint; `--fingerprint <fp>` sends the one from the audit Mikey read instead. If the facts changed since, nothing is recorded (409: read it again).
|
|
1199
|
+
- `--decision legit` keeps the teacher. `--decision revoke` **requires `--fingerprint`** from the entry Mikey decided on, then runs the same clean removal as `teachers revoke` (the note is its reason) and records the review.
|
|
1200
|
+
- `--all-flagged --expect <id[:fp],...>` pages the whole audit, prints it, and reviews as legit, with one note, only the `--expect` teachers that it shows as new or changed. Give each as `id:<fingerprint prefix>` (8+ hex from the audit Mikey read) to pin the facts he judged. Anything else is listed and not reviewed: new or changed but not expected; expected but its fingerprint moved; expected but not new or changed now. The server also skips, and lists, any whose facts changed between the printed audit and the write, whose last review was a revoke, or whose evidence is incomplete. Sent in batches of 25.
|
|
1201
|
+
- The write receipts these leave on disk hold only the decision (teacher id, decision, fingerprint, time, note); the evidence is printed, never saved.
|
|
1202
|
+
- Incomplete evidence is never compared or recorded: unread network evidence, or a linked-account family truncated at the identity inspection's cap. Such a teacher keeps showing until the evidence reads in full.
|
|
1203
|
+
- Record a review only on Mikey's decision for those accounts.
|
|
1195
1204
|
- `teachers revoke` removes a teacher status cleanly:
|
|
1196
1205
|
- the mentor achievement, plus the teenager/adult achievements the teacher approval gave unless a separately approved birthdate supports them;
|
|
1197
1206
|
- the teacher title and the public "unlocked" posts;
|
|
@@ -3294,6 +3303,7 @@ lumine admin brief --days 3 --json
|
|
|
3294
3303
|
lumine admin ai-costs monthly --json
|
|
3295
3304
|
lumine admin media-costs monthly --json
|
|
3296
3305
|
lumine admin notable status Stealth --json
|
|
3306
|
+
lumine admin profile show Stealth # public profile for the candidate check
|
|
3297
3307
|
lumine admin notable add 12647 --note "Top authored-activity kid of the window: 11 subjects, 61 comments." --json
|
|
3298
3308
|
lumine admin notable add Minecrarft_guy --note "Helped three new builders debug their projects and gave detailed feedback on five posts." --json
|
|
3299
3309
|
```
|
|
@@ -3870,8 +3880,9 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
|
|
|
3870
3880
|
re-read its canonical members. This duty applies even when no group crossed
|
|
3871
3881
|
three cards, because the new enforcement prevents already-bucketed siblings
|
|
3872
3882
|
from producing an over-limit row.
|
|
3873
|
-
- `notableCandidates` — kids (never bots, staff `userType`s,
|
|
3874
|
-
|
|
3883
|
+
- `notableCandidates` — kids (never bots, staff `userType`s, approved teachers
|
|
3884
|
+
— anyone holding the mentor or sage achievement — or users already on the
|
|
3885
|
+
Notable Users list) ranked by authored activity in the window, with
|
|
3875
3886
|
`isNewUser` marking window-new signups. Use it to find the overlooked and
|
|
3876
3887
|
rising users the editorial priorities exist for, and propose additions to
|
|
3877
3888
|
Mikey's Notable Users list in the report. When Mikey approves additions,
|
|
@@ -3888,6 +3899,14 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
|
|
|
3888
3899
|
canonical writer and returns only the resolved public account identity,
|
|
3889
3900
|
current membership, and the roster rationale/timestamps when present; it
|
|
3890
3901
|
does not expose the private roster fields.
|
|
3902
|
+
Before proposing a candidate, read their profile with
|
|
3903
|
+
`lumine admin profile show <userId|username> [--json]` (a daily-run read,
|
|
3904
|
+
like `brief`): username, real name as the public profile shows it, join and
|
|
3905
|
+
last-active dates, rank/XP/title/achievements, status and bio lines, counts
|
|
3906
|
+
(subjects, comments, AI stories, public builds, shared reflections) and
|
|
3907
|
+
their latest ten of each with dates, excerpts and links. It serves the
|
|
3908
|
+
profile as a signed-out visitor sees it: no emails, no secret answers, no
|
|
3909
|
+
unshared reflections, and only comments on public surfaces.
|
|
3891
3910
|
When Mikey authorizes removal, use `lumine admin notable remove <userId|username> --note "<why removed>" --json`. It is run-independent, transactionally audited as `notable.remove`, verifies canonical absence, and is idempotent. Never use SQL to work around a missing CLI verb. Mikey is the administrator, not a Notable candidate; do not include him in blanket roster additions.
|
|
3892
3911
|
**Always pass `--note`** with a concrete one-or-two-sentence record of what
|
|
3893
3912
|
made them notable — real numbers and specifics from the brief window, not
|
|
@@ -4045,6 +4064,9 @@ type InsightsBrief = Success<{
|
|
|
4045
4064
|
isNewUser: boolean;
|
|
4046
4065
|
lastActive: number | null;
|
|
4047
4066
|
}>;
|
|
4067
|
+
// Approved teachers that would otherwise have ranked into the list; counts
|
|
4068
|
+
// only those met while filling it, not every active teacher.
|
|
4069
|
+
notableCandidateTeachersExcluded: number;
|
|
4048
4070
|
teachers: {
|
|
4049
4071
|
genuinelyInterested: TeacherInsight[];
|
|
4050
4072
|
workOnly: TeacherInsight[];
|