@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 CHANGED
@@ -96,8 +96,11 @@ run `lumine suggestions <build-url-or-id>` to see their open suggestion inbox.
96
96
  The inbox prints canonical follow-up commands for merging or replacing Main and
97
97
  for applying the exact frozen thumbnail shown in a suggestion. The same actions
98
98
  are also available directly as `lumine suggestions merge <id>`,
99
- `lumine suggestions replace-main <id>`, and
100
- `lumine suggestions adopt-thumbnail <id>`. Large inboxes are cursor-paginated;
99
+ `lumine suggestions replace-main <id>`,
100
+ `lumine suggestions adopt-thumbnail <id>`, and
101
+ `lumine suggestions adopt-title <id>`. To pass on a branch, thumbnail or name
102
+ suggestion, run `lumine suggestions decline <id>`; the chat card shows it was
103
+ declined for both sides and it leaves the inbox. Large inboxes are cursor-paginated;
101
104
  the CLI prints the exact `--cursor` command for the next page.
102
105
 
103
106
  Use `lumine explore` to list public open-source Build apps that can be used as
@@ -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
- return readOperation(
373
+ const operation = readOperation(
362
374
  "teachers.audit",
363
375
  withQuery("/cli/admin/teachers/audit", {
364
- limit: options.limit
365
- ? parseRequiredInteger(options.limit, "--limit", 1, 200)
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 (100 teachers took
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
- mutationIntent?.confirm(result);
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
- printEvidence(teacher);
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
- if (data.report) {
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, riskiest first; revoke removes the status cleanly)
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 \`adopt-title\`; do not infer an action from stale
242
- local branch state.
241
+ \`adopt-thumbnail\`, \`adopt-title\` or \`decline\`; do not infer an action
242
+ from stale local branch state. Decline only when the user asks to pass on
243
+ that suggestion.
243
244
 
244
245
  ## Assets (Runtime Media)
245
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-10-06T10:12:09.371Z
5
+ Generated: 2026-10-07T07:52:06.040Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -257,6 +257,7 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
257
257
  - title/body are accepted as aliases for label/summary.
258
258
  - target is accepted as a backwards-compatible alias for launchTarget.
259
259
  - Twinkle applies app API rate limits, a stricter notification emit rate limit, and existing notification mutes.
260
+ - Twinkle shows the acting member's username right before `summary` in the notification row, so write `summary` as a predicate ('posted in Lobby', 'responded to the Twinkle Newspaper'), never starting with the member's name (otherwise it reads 'Liki Liki responded…').
260
261
  - Example: await Twinkle.notifications.notifySubscribers('room.message', { targetKey: 'room:lobby', eventKey: 'room.message.created', label: 'Room messages', summary: 'posted in Lobby', launchTarget: { view: 'room', roomId: 'lobby', messageId } });
261
262
  - async getSubjectUpdateSubscription(subjectId) | scopes: notifications:read
262
263
  - Returns: { subscription }
@@ -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` lists approved teachers, riskiest first.
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, or users already
3874
- on the Notable Users list) ranked by authored activity in the window, with
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[];