@stage5/lumine 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin-chat-reports.js +129 -0
- package/lib/admin-owner-trace.js +64 -2
- package/lib/admin-profile.js +138 -0
- package/lib/admin-receipts.js +45 -0
- package/lib/admin.js +331 -13
- package/lib/commands.js +10 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +6 -6
- package/sdk/LUMINE_ADMIN.md +38 -4
|
@@ -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
|
+
}
|
package/lib/admin-owner-trace.js
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
// no daily run needed. The server keeps 30 days.
|
|
5
5
|
|
|
6
6
|
export const OWNER_TRACE_USAGE =
|
|
7
|
-
"Usage: lumine admin owner-trace [--since 2h|30m|3d|<iso>|<unix>] [--type route,feed-open,scroll-anchor,content-scroll,error,...] [--path <substring>] [--limit 300] [--json].";
|
|
7
|
+
"Usage: lumine admin owner-trace [--since 2h|30m|3d|<iso>|<unix>] [--type route,feed-open,scroll-anchor,content-scroll,error,...] [--perf] [--path <substring>] [--limit 300] [--json].";
|
|
8
|
+
|
|
9
|
+
// --perf: only the performance events (tap-to-page timeline, slow API
|
|
10
|
+
// requests, main-thread stalls, page load).
|
|
11
|
+
export const OWNER_TRACE_PERF_TYPES = [
|
|
12
|
+
"nav-timing",
|
|
13
|
+
"slow-request",
|
|
14
|
+
"stall",
|
|
15
|
+
"page-load",
|
|
16
|
+
];
|
|
8
17
|
|
|
9
18
|
const MAX_LIMIT = 2000;
|
|
10
19
|
|
|
@@ -41,7 +50,16 @@ export function buildOwnerTraceOperation(options) {
|
|
|
41
50
|
const query = new URLSearchParams();
|
|
42
51
|
query.set("since", normalizeSince(options.adminSince));
|
|
43
52
|
query.set("limit", String(limit));
|
|
44
|
-
|
|
53
|
+
const types = String(options.adminType || "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((type) => type.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (options.adminPerf) {
|
|
58
|
+
for (const type of OWNER_TRACE_PERF_TYPES) {
|
|
59
|
+
if (!types.includes(type)) types.push(type);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (types.length) query.set("type", types.join(","));
|
|
45
63
|
if (options.adminPath) query.set("path", String(options.adminPath));
|
|
46
64
|
return {
|
|
47
65
|
name: "owner-trace.read",
|
|
@@ -107,6 +125,27 @@ export function describeOwnerTraceEvent(event) {
|
|
|
107
125
|
return `${data.e || "?"} ${pairs(data, ["e"])}`.trim();
|
|
108
126
|
case "content-scroll":
|
|
109
127
|
return `${data.phase} ${data.ct}:${data.id} scrollTop=${data.st} height=${data.sh} viewport=${data.vh}`;
|
|
128
|
+
case "nav-timing":
|
|
129
|
+
return describeNavTiming(data);
|
|
130
|
+
case "slow-request": {
|
|
131
|
+
const extra = [
|
|
132
|
+
data.q ? `queued ${data.q}ms` : "",
|
|
133
|
+
data.tries ? `${data.tries} tries` : "",
|
|
134
|
+
].filter(Boolean);
|
|
135
|
+
return `${data.m || "?"} ${data.api || "?"} → ${data.status ?? "?"} in ${data.ms}ms${extra.length ? ` (${extra.join(", ")})` : ""}`;
|
|
136
|
+
}
|
|
137
|
+
case "stall":
|
|
138
|
+
return `main thread blocked ${data.ms}ms${data.n > 1 ? ` (${data.n} stalls, ${data.total}ms blocked over ${data.span}ms)` : ""}`;
|
|
139
|
+
case "page-load": {
|
|
140
|
+
const parts = [
|
|
141
|
+
data.type || "",
|
|
142
|
+
data.ttfb !== undefined ? `ttfb ${data.ttfb}` : "",
|
|
143
|
+
data.dcl !== undefined ? `domcontentloaded ${data.dcl}` : "",
|
|
144
|
+
data.load !== undefined ? `load ${data.load}` : "",
|
|
145
|
+
data.route !== undefined ? `first route ${data.route}` : "",
|
|
146
|
+
].filter(Boolean);
|
|
147
|
+
return `${parts.join(" · ")} ms${data.cached ? " (document from cache)" : ""}`;
|
|
148
|
+
}
|
|
110
149
|
case "error":
|
|
111
150
|
case "rejection":
|
|
112
151
|
return `${data.msg || "(no message)"}${data.src ? ` (${data.src}:${data.line}:${data.col})` : ""}${data.status ? ` HTTP ${data.status}` : ""}`;
|
|
@@ -115,6 +154,29 @@ export function describeOwnerTraceEvent(event) {
|
|
|
115
154
|
}
|
|
116
155
|
}
|
|
117
156
|
|
|
157
|
+
// "tap→page 4210ms · PUSH via nav · url +90 · gate +4180 (probe timeout,
|
|
158
|
+
// waited 4100ms) · commit +4210 · ready +4600": offsets are ms from the tap.
|
|
159
|
+
function describeNavTiming(data) {
|
|
160
|
+
const end = data.ready ?? data.commit;
|
|
161
|
+
const head =
|
|
162
|
+
end !== undefined
|
|
163
|
+
? `${data.via === "url" || data.via === "pop" ? "start" : "tap"}→${data.ready !== undefined ? "ready" : "page"} ${end}ms`
|
|
164
|
+
: `cut after ${data.total}ms`;
|
|
165
|
+
const steps = [
|
|
166
|
+
data.url !== undefined ? `url +${data.url}` : "",
|
|
167
|
+
data.gate !== undefined
|
|
168
|
+
? `gate +${data.gate}${data.probe ? ` (probe ${data.probe}${data.probeMs ? `, waited ${data.probeMs}ms` : ""})` : ""}`
|
|
169
|
+
: "",
|
|
170
|
+
data.commit !== undefined ? `commit +${data.commit}` : "",
|
|
171
|
+
data.ready !== undefined ? `ready +${data.ready}` : "",
|
|
172
|
+
].filter(Boolean);
|
|
173
|
+
const tail = [
|
|
174
|
+
data.result ? `gate ${data.result}` : "",
|
|
175
|
+
data.cut ? `cut by ${data.cut}` : "",
|
|
176
|
+
].filter(Boolean);
|
|
177
|
+
return [head, `${data.nav || "?"} via ${data.via || "?"}`, ...steps, ...tail].join(" · ");
|
|
178
|
+
}
|
|
179
|
+
|
|
118
180
|
export function formatOwnerTrace(data) {
|
|
119
181
|
const events = Array.isArray(data?.events) ? data.events : [];
|
|
120
182
|
const lines = [];
|
|
@@ -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,45 @@
|
|
|
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
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
}
|
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";
|
|
@@ -3627,6 +3873,11 @@ export function parseAdminOperation(options) {
|
|
|
3627
3873
|
);
|
|
3628
3874
|
}
|
|
3629
3875
|
|
|
3876
|
+
// A member's public profile for review (admin-profile.js); a daily-run read.
|
|
3877
|
+
if (namespace === "profile") {
|
|
3878
|
+
return buildProfileShowOperation({ action, target, extra });
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3630
3881
|
if (namespace === "notable" && action === "status" && !extra) {
|
|
3631
3882
|
const rawTarget = String(target || "").trim();
|
|
3632
3883
|
if (!rawTarget) {
|
|
@@ -5861,6 +6112,39 @@ function printEvidence(evidence) {
|
|
|
5861
6112
|
if (evidence.note) console.log(evidence.note);
|
|
5862
6113
|
}
|
|
5863
6114
|
|
|
6115
|
+
const TEACHER_REVIEW_LABELS = {
|
|
6116
|
+
unreviewed: "NEW: flagged, not reviewed",
|
|
6117
|
+
changed: "CHANGED since review",
|
|
6118
|
+
reviewed: "reviewed, unchanged",
|
|
6119
|
+
clean: "no flags",
|
|
6120
|
+
};
|
|
6121
|
+
|
|
6122
|
+
export function formatTeacherAuditSummary(data) {
|
|
6123
|
+
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)" : ""}`;
|
|
6124
|
+
}
|
|
6125
|
+
|
|
6126
|
+
export function formatTeacherReviewLine(teacher) {
|
|
6127
|
+
const a = teacher.account || {};
|
|
6128
|
+
const r = teacher.review;
|
|
6129
|
+
const status = TEACHER_REVIEW_LABELS[teacher.reviewStatus] || teacher.reviewStatus || "?";
|
|
6130
|
+
const reviewed = r
|
|
6131
|
+
? ` · last review ${r.decision} ${approvalDate(r.reviewedAt)} by ${r.reviewedBy}${r.note ? `: "${r.note}"` : ""}`
|
|
6132
|
+
: "";
|
|
6133
|
+
const flags = (teacher.flags || []).length ? ` · flags ${teacher.flags.join(", ")}` : "";
|
|
6134
|
+
return `[${status}] ${a.username || "?"} (#${a.userId})${flags}${reviewed}`;
|
|
6135
|
+
}
|
|
6136
|
+
|
|
6137
|
+
// One audit entry: reviewed-unchanged teachers are one line (already judged);
|
|
6138
|
+
// the rest show what changed, the evidence and the fingerprint a review sends.
|
|
6139
|
+
function printTeacherEntry(teacher, { full = false } = {}) {
|
|
6140
|
+
console.log(formatTeacherReviewLine(teacher));
|
|
6141
|
+
if (teacher.reviewStatus === "reviewed" && !full) return;
|
|
6142
|
+
for (const change of teacher.review?.changes || []) console.log(` changed: ${change}`);
|
|
6143
|
+
if (teacher.evidenceGap) console.log(` cannot be reviewed now: ${teacher.evidenceGap}`);
|
|
6144
|
+
printEvidence(teacher);
|
|
6145
|
+
if (teacher.fingerprint) console.log(`Fingerprint: ${teacher.fingerprint}`);
|
|
6146
|
+
}
|
|
6147
|
+
|
|
5864
6148
|
function printApprovalResult({ operation, data }) {
|
|
5865
6149
|
if (operation.name === "approvals.list") {
|
|
5866
6150
|
const items = data.items || [];
|
|
@@ -5887,12 +6171,35 @@ function printApprovalResult({ operation, data }) {
|
|
|
5887
6171
|
return;
|
|
5888
6172
|
}
|
|
5889
6173
|
if (operation.name === "teachers.audit") {
|
|
5890
|
-
console.log(
|
|
5891
|
-
`Audited ${data.audited || 0} approved teachers · ${data.flagged || 0} with flags (riskiest first).`,
|
|
5892
|
-
);
|
|
6174
|
+
console.log(formatTeacherAuditSummary(data));
|
|
5893
6175
|
for (const teacher of data.teachers || []) {
|
|
5894
6176
|
console.log("");
|
|
5895
|
-
|
|
6177
|
+
printTeacherEntry(teacher);
|
|
6178
|
+
}
|
|
6179
|
+
if (data.nextCursor) console.log(`\nMore teachers: --cursor ${data.nextCursor}, or --all for every page.`);
|
|
6180
|
+
return;
|
|
6181
|
+
}
|
|
6182
|
+
if (operation.name === "teachers.review") {
|
|
6183
|
+
console.log(
|
|
6184
|
+
`Reviewed #${data.teacherUserId} as ${data.decision} (fingerprint ${String(data.fingerprint || "").slice(0, 12)}; flags ${(data.flags || []).join(", ") || "none"}). Note kept: ${data.note}`,
|
|
6185
|
+
);
|
|
6186
|
+
if (data.revoked) {
|
|
6187
|
+
console.log(
|
|
6188
|
+
`Teacher status removed (achievements ${(data.revoked.removedAchievementIds || []).join(", ") || "-"}; request #${data.revoked.approvalItemId ?? "-"} marked rejected).`,
|
|
6189
|
+
);
|
|
6190
|
+
}
|
|
6191
|
+
return;
|
|
6192
|
+
}
|
|
6193
|
+
if (operation.name === "teachers.review_flagged") {
|
|
6194
|
+
console.log(
|
|
6195
|
+
`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}`,
|
|
6196
|
+
);
|
|
6197
|
+
for (const r of data.reviewed || []) {
|
|
6198
|
+
console.log(` - ${r.username || "?"} (#${r.teacherUserId}) · was ${r.previousStatus}`);
|
|
6199
|
+
}
|
|
6200
|
+
for (const s of data.skipped || []) console.log(` skipped #${s.userId}: ${s.reason}`);
|
|
6201
|
+
if ((data.changedSinceYouLooked || []).length) {
|
|
6202
|
+
console.log(` Changed since the audit above (not reviewed; audit again): ${data.changedSinceYouLooked.map((id) => `#${id}`).join(", ")}`);
|
|
5896
6203
|
}
|
|
5897
6204
|
return;
|
|
5898
6205
|
}
|
|
@@ -6098,6 +6405,15 @@ function printRewardBankReport(data) {
|
|
|
6098
6405
|
function printAdminResult({ operation, result }) {
|
|
6099
6406
|
const data = result?.data || {};
|
|
6100
6407
|
if (printChatSafetyResult({ operation, data })) return;
|
|
6408
|
+
const chatReportLines = formatChatReportResult({ operation, data });
|
|
6409
|
+
if (chatReportLines) {
|
|
6410
|
+
for (const line of chatReportLines) console.log(line);
|
|
6411
|
+
return;
|
|
6412
|
+
}
|
|
6413
|
+
if (operation.name === "profile.show") {
|
|
6414
|
+
for (const line of formatProfileReview(data)) console.log(line);
|
|
6415
|
+
return;
|
|
6416
|
+
}
|
|
6101
6417
|
if (operation.name === "reward-activity.report") {
|
|
6102
6418
|
printRewardActivity(data);
|
|
6103
6419
|
return;
|
|
@@ -6348,7 +6664,9 @@ function printAdminResult({ operation, result }) {
|
|
|
6348
6664
|
console.log(`Checkpoint: ${data.batch.checkpointPath}`);
|
|
6349
6665
|
return;
|
|
6350
6666
|
}
|
|
6351
|
-
|
|
6667
|
+
// A daily-run report; other operations' `report` (chat reports) are
|
|
6668
|
+
// printed above and must never reach this branch.
|
|
6669
|
+
if (data.report?.run) {
|
|
6352
6670
|
const report = data.report;
|
|
6353
6671
|
const historical = report.basis?.kind === "historical_reconstruction";
|
|
6354
6672
|
console.log(
|
package/lib/commands.js
CHANGED
|
@@ -2381,8 +2381,10 @@ export function parseArgs(args) {
|
|
|
2381
2381
|
"all",
|
|
2382
2382
|
"resume",
|
|
2383
2383
|
"sinceRun",
|
|
2384
|
+
"perf",
|
|
2384
2385
|
"includeLegacy",
|
|
2385
2386
|
"includePrivateEvidence",
|
|
2387
|
+
"allFlagged",
|
|
2386
2388
|
"reviewed",
|
|
2387
2389
|
"noReviewLoop",
|
|
2388
2390
|
"acceptAgreement",
|
|
@@ -2497,12 +2499,16 @@ export function parseArgs(args) {
|
|
|
2497
2499
|
adminSinceRun: Boolean(raw.sinceRun),
|
|
2498
2500
|
adminSince: raw.since ? String(raw.since) : "",
|
|
2499
2501
|
adminPath: raw.path ? String(raw.path) : "",
|
|
2502
|
+
adminPerf: Boolean(raw.perf),
|
|
2500
2503
|
adminIncludeLegacy: Boolean(raw.includeLegacy),
|
|
2501
2504
|
adminIncludePrivateEvidence: parseBoolean(
|
|
2502
2505
|
raw.includePrivateEvidence,
|
|
2503
2506
|
false,
|
|
2504
2507
|
),
|
|
2505
2508
|
adminAll: Boolean(raw.all),
|
|
2509
|
+
adminAllFlagged: Boolean(raw.allFlagged),
|
|
2510
|
+
adminFingerprint: raw.fingerprint ? String(raw.fingerprint) : "",
|
|
2511
|
+
adminExpect: raw.expect ? String(raw.expect) : "",
|
|
2506
2512
|
adminResume: Boolean(raw.resume),
|
|
2507
2513
|
adminCheckpoint: raw.checkpoint ? String(raw.checkpoint) : "",
|
|
2508
2514
|
adminOutput: raw.output ? String(raw.output) : "",
|
|
@@ -3028,7 +3034,8 @@ export function printHelp() {
|
|
|
3028
3034
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
3029
3035
|
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>
|
|
3030
3036
|
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)
|
|
3031
|
-
lumine admin teachers audit [--limit <1-100>] | revoke <userId> --reason <text> (approved teachers,
|
|
3037
|
+
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)
|
|
3038
|
+
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)
|
|
3032
3039
|
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)
|
|
3033
3040
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
3034
3041
|
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
@@ -3081,7 +3088,7 @@ export function printHelp() {
|
|
|
3081
3088
|
lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
|
|
3082
3089
|
lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
|
|
3083
3090
|
lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
|
|
3084
|
-
lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors; kept 30 days)
|
|
3091
|
+
lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load; --perf shows only those; kept 30 days)
|
|
3085
3092
|
lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
|
|
3086
3093
|
lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
|
|
3087
3094
|
lumine admin comment post --draft-id <id> [--json]
|
|
@@ -3107,6 +3114,7 @@ export function printHelp() {
|
|
|
3107
3114
|
lumine admin news claim [--date YYYY-MM-DD] [--output <claim.json>] [--scaffold <editorial.json>] [--json]
|
|
3108
3115
|
lumine admin news validate --claim <claim.json> --file <editorial.json> [--json]
|
|
3109
3116
|
lumine admin news submit --claim <claim.json> --file <editorial.json> [--model <name>] [--json]
|
|
3117
|
+
lumine admin profile show <user-id|username> [--json] (public profile for review: rank, bio, counts, recent posts; daily-run read)
|
|
3110
3118
|
lumine admin notable status <user-id|username> [--json]
|
|
3111
3119
|
lumine admin notable add <user-id|username> --note <text> [--json]
|
|
3112
3120
|
lumine admin notable remove <user-id|username> --note <text> [--json]
|
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-06T10:12:09.371Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -1527,19 +1527,19 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1527
1527
|
- Requires an open challenge of the learner's own (build_reward_challenge_not_open, HTTP 409 otherwise). Limit: 120 characters (build_reward_answer_text_invalid, 400), rate limited to a burst of 4 then one read every 5 seconds (build_reward_throttled, 429). build_reward_answer_reader_unavailable (503) means the reader is down: ask the learner to type just the number.
|
|
1528
1528
|
- Drafts and owner previews return mode 'preview' and need no open challenge.
|
|
1529
1529
|
- await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
|
|
1530
|
-
- Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures
|
|
1530
|
+
- Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures?, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", firstTry, firstSolve, failersBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
|
|
1531
1531
|
- Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
|
|
1532
1532
|
- Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
|
|
1533
|
-
- puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers.
|
|
1534
|
-
- tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later.
|
|
1533
|
+
- puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers, sent only while the puzzle is still unsolved (absent on defeated puzzles, so a solver's own misses cannot be worked out).
|
|
1534
|
+
- tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later. firstTry is true when the solver got it on the first try; which try it took and the solver's own wrong answers are never shown to other players, so never reconstruct them.
|
|
1535
1535
|
- No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
|
|
1536
1536
|
- await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
|
|
1537
|
-
- Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins,
|
|
1537
|
+
- Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, firstTry, story: { tier, firstTry, failersBefore, daysStanding } | null, puzzle: { rating, solvers, failers } | null, vault: { rating, solvers, failers, failures } | null, rematchOpen }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
|
|
1538
1538
|
- Browse confirmed solves of retired until-earned quiz bounties, newest first.
|
|
1539
1539
|
- A solve appears only after the next site reset (UTC midnight, 9 AM Korea). Today’s solves, unsolved sets, standing quizzes and dated quizzes are excluded. The server decides retirement; client dates cannot unlock content. Zero-reward correct solves are included.
|
|
1540
1540
|
- One entry per solve receipt, with firstSolver identifying the first receipt for that rule and set across approved versions. limit defaults to 20, max 50; pass nextCursor unchanged for older solves and omit it when changing ruleId. Pages may have fewer entries when old question sheets cannot be recovered; continue while nextCursor is present.
|
|
1541
1541
|
- No daily-claim limit applies to reading. Requires the current approved published runtime grant; previews return an empty timeline. Never awards or changes balances. Open a receipt with getArchivedProblem to load the original question and guide.
|
|
1542
|
-
- Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', the solver
|
|
1542
|
+
- Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', firstTry: whether the solver got it on the first try, never which try, how many other players had missed it, days it stood); null for receipts older than solve logging. puzzle is the official challenge rating and counts now (no total wrong answers: it is solved, and that total would give away the solver's own misses); vault is the puzzle's living practice stats (its own rating, practice solvers, players whose first practice answer missed, all wrong practice answers), or null before anyone practised it. Counts only: players who missed are never named.
|
|
1543
1543
|
- rematchOpen is true on the entry that is today's daily rematch while the viewer can still answer it for pay: label it and open it as the rematch, not as a lesson. Daily-rematch receipts are never timeline entries.
|
|
1544
1544
|
- await Twinkle.rewards.getArchivedProblem({ receiptId, withGuide?: true }) | scopes: rewards:claim
|
|
1545
1545
|
- Returns: { mode: "live", entry: <same solve entry as getTimeline, with rematchOpen>, questions: [{ prompt, hint?, guide? }], rematchOpen } | { mode: "preview", entry: null, questions: [], message }
|
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;
|
|
@@ -1378,6 +1387,7 @@ else's is a no-op and the upload route refuses them); the server keeps 30 days.
|
|
|
1378
1387
|
lumine admin owner-trace --since 30m # readable timeline, oldest first
|
|
1379
1388
|
lumine admin owner-trace --since 2026-10-06T09:00 --type route,feed-open
|
|
1380
1389
|
lumine admin owner-trace --path /comments/123 --json # raw rows
|
|
1390
|
+
lumine admin owner-trace --since 1h --perf # performance events only
|
|
1381
1391
|
```
|
|
1382
1392
|
|
|
1383
1393
|
Event types: `session` (version, viewport, platform, PWA or tab), `route`
|
|
@@ -1390,6 +1400,17 @@ later), `error`/`rejection` (short message and source file), `visibility`,
|
|
|
1390
1400
|
`pagehide`, `trace-dropped`. Times are the phone's clock in this machine's
|
|
1391
1401
|
time zone. Ids and paths only, never message text or other members' content.
|
|
1392
1402
|
|
|
1403
|
+
Performance events (`--perf` shows only these): `nav-timing` (one per
|
|
1404
|
+
navigation: ms from the tap to the URL change, the deploy-probe gate release
|
|
1405
|
+
with the probe's outcome current/armed/timeout/failed/throttled/backoff/pending
|
|
1406
|
+
and how long the tap waited for it, the route commit and, on content pages,
|
|
1407
|
+
content-ready; `cut by next-nav` means another navigation started before this
|
|
1408
|
+
one committed), `slow-request` (API requests over 1 s: method, path pattern
|
|
1409
|
+
with query keys only, status, ms, scheduler queue time), `stall` (main-thread
|
|
1410
|
+
gaps over 200 ms while the page is visible, bursts within 1 s coalesced) and
|
|
1411
|
+
`page-load` (once per load: TTFB, DOMContentLoaded, load, first route ready,
|
|
1412
|
+
ms since navigation start).
|
|
1413
|
+
|
|
1393
1414
|
### Reward question-bank seeding (every full daily review; added 2026-09-28)
|
|
1394
1415
|
|
|
1395
1416
|
`generated-quiz` rules fill a question bank on the server: a question is
|
|
@@ -3282,6 +3303,7 @@ lumine admin brief --days 3 --json
|
|
|
3282
3303
|
lumine admin ai-costs monthly --json
|
|
3283
3304
|
lumine admin media-costs monthly --json
|
|
3284
3305
|
lumine admin notable status Stealth --json
|
|
3306
|
+
lumine admin profile show Stealth # public profile for the candidate check
|
|
3285
3307
|
lumine admin notable add 12647 --note "Top authored-activity kid of the window: 11 subjects, 61 comments." --json
|
|
3286
3308
|
lumine admin notable add Minecrarft_guy --note "Helped three new builders debug their projects and gave detailed feedback on five posts." --json
|
|
3287
3309
|
```
|
|
@@ -3858,8 +3880,9 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
|
|
|
3858
3880
|
re-read its canonical members. This duty applies even when no group crossed
|
|
3859
3881
|
three cards, because the new enforcement prevents already-bucketed siblings
|
|
3860
3882
|
from producing an over-limit row.
|
|
3861
|
-
- `notableCandidates` — kids (never bots, staff `userType`s,
|
|
3862
|
-
|
|
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
|
|
3863
3886
|
`isNewUser` marking window-new signups. Use it to find the overlooked and
|
|
3864
3887
|
rising users the editorial priorities exist for, and propose additions to
|
|
3865
3888
|
Mikey's Notable Users list in the report. When Mikey approves additions,
|
|
@@ -3876,6 +3899,14 @@ farm-signal sections added that day; AI Card summon watch added 2026-08-24):
|
|
|
3876
3899
|
canonical writer and returns only the resolved public account identity,
|
|
3877
3900
|
current membership, and the roster rationale/timestamps when present; it
|
|
3878
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.
|
|
3879
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.
|
|
3880
3911
|
**Always pass `--note`** with a concrete one-or-two-sentence record of what
|
|
3881
3912
|
made them notable — real numbers and specifics from the brief window, not
|
|
@@ -4033,6 +4064,9 @@ type InsightsBrief = Success<{
|
|
|
4033
4064
|
isNewUser: boolean;
|
|
4034
4065
|
lastActive: number | null;
|
|
4035
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;
|
|
4036
4070
|
teachers: {
|
|
4037
4071
|
genuinelyInterested: TeacherInsight[];
|
|
4038
4072
|
workOnly: TeacherInsight[];
|