@stage5/lumine 0.3.13 → 0.3.16

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.
@@ -0,0 +1,160 @@
1
+ // `lumine admin owner-trace`: the owner's client trace from Mikey's own
2
+ // devices (routes, Home feed card opens, scroll-anchor restores, content-page
3
+ // scroll positions, window errors), read when he reports a UI issue. Read-only;
4
+ // no daily run needed. The server keeps 30 days.
5
+
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].";
8
+
9
+ const MAX_LIMIT = 2000;
10
+
11
+ function validationError(message) {
12
+ const error = new Error(message);
13
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
14
+ return error;
15
+ }
16
+
17
+ function normalizeSince(value) {
18
+ const raw = String(value || "").trim();
19
+ if (!raw) return "2h";
20
+ if (/^\d+\s*[smhd]$/i.test(raw) || /^\d{9,14}$/.test(raw)) {
21
+ return raw.replace(/\s+/g, "");
22
+ }
23
+ const parsed = Date.parse(raw);
24
+ if (!Number.isFinite(parsed)) {
25
+ throw validationError(
26
+ "--since must look like 30m, 2h, 3d, an ISO date or a unix time.",
27
+ );
28
+ }
29
+ return String(Math.floor(parsed / 1000));
30
+ }
31
+
32
+ export function buildOwnerTraceOperation(options) {
33
+ const limitRaw = options.adminLimit;
34
+ let limit = 300;
35
+ if (limitRaw !== null && limitRaw !== undefined && limitRaw !== "") {
36
+ limit = Number(String(limitRaw).trim());
37
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
38
+ throw validationError(`--limit must be an integer 1-${MAX_LIMIT}.`);
39
+ }
40
+ }
41
+ const query = new URLSearchParams();
42
+ query.set("since", normalizeSince(options.adminSince));
43
+ query.set("limit", String(limit));
44
+ if (options.adminType) query.set("type", String(options.adminType));
45
+ if (options.adminPath) query.set("path", String(options.adminPath));
46
+ return {
47
+ name: "owner-trace.read",
48
+ method: "GET",
49
+ path: `/cli/admin/owner-trace?${query.toString()}`,
50
+ body: undefined,
51
+ mutates: false,
52
+ requiresRun: false,
53
+ };
54
+ }
55
+
56
+ function pad(value, size) {
57
+ return String(value).padStart(size, "0");
58
+ }
59
+
60
+ function formatClock(ms) {
61
+ const date = new Date(ms);
62
+ return `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}.${pad(date.getMilliseconds(), 3)}`;
63
+ }
64
+
65
+ function formatDay(ms) {
66
+ const date = new Date(ms);
67
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
68
+ }
69
+
70
+ function pairs(data, skip = []) {
71
+ return Object.entries(data || {})
72
+ .filter(([key, value]) => !skip.includes(key) && value !== undefined && value !== null && value !== "")
73
+ .map(([key, value]) => `${key}=${typeof value === "object" ? JSON.stringify(value) : value}`)
74
+ .join(" ");
75
+ }
76
+
77
+ function describeSession(data = {}, path = "") {
78
+ const parts = [
79
+ data.version ? `v${data.version}` : "",
80
+ data.vw && data.vh ? `${data.vw}x${data.vh}${data.dpr ? ` @${data.dpr}x` : ""}` : "",
81
+ data.vvh ? `visual ${data.vvh}h` : "",
82
+ data.platform || "",
83
+ data.mobile ? "mobile" : "",
84
+ data.standalone ? "standalone (PWA)" : "browser tab",
85
+ data.touch ? `touch ${data.touch}` : "",
86
+ ].filter(Boolean);
87
+ if (path) parts.push(`at ${path}`);
88
+ return `${parts.join(" · ")}${data.ua ? `\n ua: ${data.ua}` : ""}`;
89
+ }
90
+
91
+ export function describeOwnerTraceEvent(event) {
92
+ const data = event.data || {};
93
+ switch (event.type) {
94
+ case "route": {
95
+ const feed = data.feedNav
96
+ ? ` · feedNav ${data.feedNav.ct}:${data.feedNav.id}${data.feedNav.action ? ` ${data.feedNav.action}` : ""}`
97
+ : "";
98
+ const intent = data.feedIntent ? ` · intent ${data.feedIntent}` : "";
99
+ const otherKeys = (data.stateKeys || []).filter(
100
+ (key) => key !== "homeFeedNavigation" && key !== "homeFeedActionIntent",
101
+ );
102
+ return `${data.nav || "?"}${data.from ? ` from ${data.from}` : ""}${feed}${intent}${otherKeys.length ? ` · state ${otherKeys.join(",")}` : ""}${data.hash ? ` · ${data.hash}` : ""}`;
103
+ }
104
+ case "feed-open":
105
+ return `${data.action || "open"} ${data.ct}:${data.id}${data.via ? ` via ${data.via}` : ""}${data.el ? ` (${data.el})` : ""}${data.to ? ` → ${data.to}` : ""}${data.pos ? ` · card #${data.pos}` : ""}${data.surface && data.surface !== "home" ? ` · ${data.surface}` : ""}`;
106
+ case "scroll-anchor":
107
+ return `${data.e || "?"} ${pairs(data, ["e"])}`.trim();
108
+ case "content-scroll":
109
+ return `${data.phase} ${data.ct}:${data.id} scrollTop=${data.st} height=${data.sh} viewport=${data.vh}`;
110
+ case "error":
111
+ case "rejection":
112
+ return `${data.msg || "(no message)"}${data.src ? ` (${data.src}:${data.line}:${data.col})` : ""}${data.status ? ` HTTP ${data.status}` : ""}`;
113
+ default:
114
+ return pairs(data);
115
+ }
116
+ }
117
+
118
+ export function formatOwnerTrace(data) {
119
+ const events = Array.isArray(data?.events) ? data.events : [];
120
+ const lines = [];
121
+ const sessions = new Set(events.map((event) => event.sessionId));
122
+ const since = Number(data?.since || 0);
123
+ lines.push(
124
+ `Owner trace since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ? " · older events cut off (raise --limit or narrow --since)" : ""}`,
125
+ );
126
+ if (!events.length) {
127
+ lines.push("Nothing recorded in this window.");
128
+ return lines;
129
+ }
130
+ let day = "";
131
+ let session = "";
132
+ for (const event of events) {
133
+ const ms = Number(event.clientTime) || Number(event.serverTime) * 1000;
134
+ const eventDay = formatDay(ms);
135
+ if (eventDay !== day) {
136
+ day = eventDay;
137
+ lines.push(`── ${day}`);
138
+ }
139
+ if (event.sessionId !== session) {
140
+ session = event.sessionId;
141
+ if (event.type !== "session") {
142
+ lines.push(` · session ${String(session).slice(0, 8)} (continued)`);
143
+ }
144
+ }
145
+ if (event.type === "session") {
146
+ lines.push(
147
+ ` ${formatClock(ms)} ── session ${String(session).slice(0, 8)} · ${describeSession(event.data, event.path)}`,
148
+ );
149
+ continue;
150
+ }
151
+ lines.push(
152
+ ` ${formatClock(ms)} ${event.type.padEnd(14)} ${event.path || "-"} ${describeOwnerTraceEvent(event)}`.trimEnd(),
153
+ );
154
+ }
155
+ return lines;
156
+ }
157
+
158
+ export function printOwnerTrace(data) {
159
+ for (const line of formatOwnerTrace(data)) console.log(line);
160
+ }
package/lib/admin.js CHANGED
@@ -33,6 +33,10 @@ import {
33
33
  runManagedBuildReview,
34
34
  } from "./build-review.js";
35
35
  import { runAdminRuntimeLogWorkflow } from "./admin-runtime-logs.js";
36
+ import {
37
+ buildOwnerTraceOperation,
38
+ printOwnerTrace,
39
+ } from "./admin-owner-trace.js";
36
40
  import {
37
41
  dailyRunGather,
38
42
  dailyRunProgress,
@@ -281,6 +285,77 @@ function buildApprovalOperation({ action, target, options }) {
281
285
  throw cliValidationError(APPROVAL_USAGE);
282
286
  }
283
287
 
288
+ // English skill tags for the Grammarbles bank: import the verdicts two Claude
289
+ // Code agent passes wrote (no model is called), then review what they could
290
+ // not settle (Mikey 2026-10-06: bulk AI work runs on Claude Code agents).
291
+ const ENGLISH_USAGE =
292
+ "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] | retag <questionId> --skill <code> [--reason <text>]";
293
+ const ENGLISH_IMPORT_MAX = 500;
294
+
295
+ function buildEnglishOperation({ action, target, options }) {
296
+ const limitOf = (max, fallback) =>
297
+ options.adminLimit != null ? parseRequiredInteger(options.adminLimit, "--limit", 1, max) : fallback;
298
+ if (action === "import") {
299
+ const pass = String(options.adminPass || "");
300
+ if (!["a", "b"].includes(pass) || !target) throw cliValidationError(ENGLISH_USAGE);
301
+ const raw = readAdminJsonFile(target, "<verdicts.json>", { maxBytes: 4 * 1024 * 1024 });
302
+ const items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : null;
303
+ if (!items || !items.length || items.length > ENGLISH_IMPORT_MAX) {
304
+ throw cliValidationError(`The file must hold 1 to ${ENGLISH_IMPORT_MAX} verdicts (an array, or { items: [...] }).`);
305
+ }
306
+ return writeOperation("english.import", "POST", "/cli/admin/english/import", { pass, items }, { requiresRun: false, timeoutMs: 120000 });
307
+ }
308
+ // adjudicate / add-questions / relevel: one JSON file of up to 500 entries.
309
+ const BANK_FILES = {
310
+ adjudicate: ["english.adjudicate", "/cli/admin/english/adjudicate", "items"],
311
+ "add-questions": ["english.add_questions", "/cli/admin/english/questions/import", "questions"],
312
+ relevel: ["english.relevel", "/cli/admin/english/relevel", "items"],
313
+ };
314
+ if (BANK_FILES[action]) {
315
+ const [name, path, key] = BANK_FILES[action];
316
+ if (!target) throw cliValidationError(ENGLISH_USAGE);
317
+ const raw = readAdminJsonFile(target, "<file.json>", { maxBytes: 4 * 1024 * 1024 });
318
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.[key]) ? raw[key] : null;
319
+ if (!list || !list.length || list.length > ENGLISH_IMPORT_MAX) {
320
+ throw cliValidationError(`The file must hold 1 to ${ENGLISH_IMPORT_MAX} entries (an array, or { ${key}: [...] }).`);
321
+ }
322
+ return writeOperation(name, "POST", path, { [key]: list }, { requiresRun: false, timeoutMs: 180000 });
323
+ }
324
+ if (action === "summary") {
325
+ return readOperation("english.summary", "/cli/admin/english/tag-summary", { requiresRun: false });
326
+ }
327
+ // One learner's per-skill mastery and the sentences a report may use.
328
+ // Private learner data: a reason is required and every read is audited.
329
+ if (action === "mastery") {
330
+ const userId = String(target || "");
331
+ const reason = String(options.adminReason || "").trim();
332
+ if (!/^\d+$/.test(userId) || reason.length < 8 || reason.length > 500) throw cliValidationError(ENGLISH_USAGE);
333
+ // POST: the reason travels in the body, never in a URL that access logs keep.
334
+ // A read, not a write: writes save their full response as a receipt file,
335
+ // and a child's learning data must never be left on disk.
336
+ return { ...bodyReadOperation("english.mastery", "POST", "/cli/admin/english/mastery", { userId: Number(userId), reason }), requiresRun: false, timeoutMs: 60000 };
337
+ }
338
+ if (action === "backfill-distractors") {
339
+ return writeOperation("english.backfill_distractors", "POST", "/cli/admin/english/backfill-distractors", {}, { requiresRun: false, timeoutMs: 300000 });
340
+ }
341
+ if (action === "review" && !target) {
342
+ return readOperation("english.review", withQuery("/cli/admin/english/review", { limit: limitOf(100, 20) }), { requiresRun: false });
343
+ }
344
+ if (["accept", "retire", "retag"].includes(action)) {
345
+ const ref = String(target || "");
346
+ if (!/^\d+$/.test(ref)) throw cliValidationError(ENGLISH_USAGE);
347
+ if (action === "retag" && !options.adminSkill) throw cliValidationError(ENGLISH_USAGE);
348
+ return writeOperation(
349
+ "english.decide",
350
+ "POST",
351
+ `/cli/admin/english/review/${ref}`,
352
+ { action, skillCode: options.adminSkill || undefined, reason: String(options.adminReason || "").trim() || undefined },
353
+ { requiresRun: false },
354
+ );
355
+ }
356
+ throw cliValidationError(ENGLISH_USAGE);
357
+ }
358
+
284
359
  function buildTeacherOperation({ action, target, options }) {
285
360
  if (!action || action === "audit") {
286
361
  return readOperation(
@@ -1948,6 +2023,11 @@ export function parseAdminOperation(options) {
1948
2023
  const [namespace = "", action = "", target = "", extra = ""] =
1949
2024
  options.positional;
1950
2025
 
2026
+ // The owner's client trace (admin-owner-trace.js); read-only, no run.
2027
+ if (namespace === "owner-trace") {
2028
+ return buildOwnerTraceOperation(options);
2029
+ }
2030
+
1951
2031
  if (namespace === "correction" || namespace === "corrections") {
1952
2032
  if (!action || action === "status") {
1953
2033
  return readOperation(
@@ -2017,7 +2097,11 @@ export function parseAdminOperation(options) {
2017
2097
  `An identity-inspection reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
2018
2098
  );
2019
2099
  }
2020
- return writeOperation(
2100
+ // A read: writes keep their full response as a receipt file under
2101
+ // ./work/lumine-admin, and private identity evidence must never be left
2102
+ // on disk by default. The server still audits who looked, when and why
2103
+ // (one row per call). `--output <file>` saves a 0600 copy on purpose.
2104
+ return bodyReadOperation(
2021
2105
  "identity.inspect",
2022
2106
  "POST",
2023
2107
  "/cli/admin/identity/inspect",
@@ -2043,17 +2127,20 @@ export function parseAdminOperation(options) {
2043
2127
  `A network-evidence reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
2044
2128
  );
2045
2129
  }
2046
- return writeOperation(
2047
- "identity.network",
2048
- "POST",
2049
- "/cli/admin/identity/network",
2050
- {
2051
- target: networkTarget,
2052
- reason,
2053
- includePrivateEvidence: options.adminIncludePrivateEvidence === true,
2054
- },
2055
- { requiresRun: false },
2056
- );
2130
+ // A read for the same reason as identity inspect: no receipt on disk.
2131
+ return {
2132
+ ...bodyReadOperation(
2133
+ "identity.network",
2134
+ "POST",
2135
+ "/cli/admin/identity/network",
2136
+ {
2137
+ target: networkTarget,
2138
+ reason,
2139
+ includePrivateEvidence: options.adminIncludePrivateEvidence === true,
2140
+ },
2141
+ ),
2142
+ requiresRun: false,
2143
+ };
2057
2144
  }
2058
2145
 
2059
2146
  if (namespace === "economy" && action === "trace") {
@@ -2172,6 +2259,9 @@ export function parseAdminOperation(options) {
2172
2259
  // Mikey's decisions, any time; each comes with its evidence (approvals.ts).
2173
2260
  return buildApprovalOperation({ action, target, options });
2174
2261
  }
2262
+ if (namespace === "english") {
2263
+ return buildEnglishOperation({ action, target, options });
2264
+ }
2175
2265
  if (namespace === "teachers" || namespace === "teacher") {
2176
2266
  // Approved teachers: an audit any time, and a clean removal of the status.
2177
2267
  return buildTeacherOperation({ action, target, options });
@@ -5813,6 +5903,60 @@ function printApprovalResult({ operation, data }) {
5813
5903
  }
5814
5904
  }
5815
5905
 
5906
+ function printEnglishResult({ operation, data }) {
5907
+ if (operation.name === "english.import") {
5908
+ console.log(`Pass ${data.pass}: ${data.stored} stored of ${data.received} (${data.invalid} invalid, ${data.unknownQuestion} unknown questions) · ${data.decided} decided: ${data.auto} accepted automatically, ${data.review} for review (${Object.entries(data.reasons || {}).map(([k, v]) => `${k} ${v}`).join(", ") || "-"})`);
5909
+ return;
5910
+ }
5911
+ if (operation.name === "english.mastery") {
5912
+ console.log(`User #${data.userId} · last ${data.windowDays} days · ${data.responses} honest answers · ${data.skillsSeen} skills seen (mastery = ${Math.round(data.target * 100)}% right on the first try)`);
5913
+ const label = { fact: "facts only", focus: "NEXT FOCUS", gap: "CLEAR GAP", strength: "strength", "clear-strength": "CLEAR STRENGTH" };
5914
+ if (!data.reportable?.length) console.log("Nothing reportable yet: no skill has enough evidence.");
5915
+ for (const r of data.reportable || []) {
5916
+ console.log(` [${label[r.kind] || r.kind}] ${r.nameEn} · ${r.correct}/${r.responses} first try · ${r.items} questions · ${r.days} days · P(below) ${r.pBelow.toFixed(2)}`);
5917
+ console.log(` report may say: ${r.wordingEn}`);
5918
+ }
5919
+ if (data.practiceOnly?.length) console.log(` practice only (not enough evidence yet): ${data.practiceOnly.map((s) => `${s.nameEn} ${s.responses}`).join(" · ")}`);
5920
+ return;
5921
+ }
5922
+ if (operation.name === "english.backfill_distractors") {
5923
+ console.log(`Wrong-choice labels restored on ${data.filled} of ${data.candidates} agent-written questions.`);
5924
+ return;
5925
+ }
5926
+ if (operation.name === "english.adjudicate") {
5927
+ console.log(`Adjudicated ${data.received}: ${data.keep} kept · ${data.fix} fixed (new versions) · ${data.retire} retired · ${data.skipped?.length || 0} skipped${data.skipped?.length ? ` (${data.skipped.slice(0, 5).map((s) => `${s.ref}: ${s.why}`).join("; ")})` : ""}`);
5928
+ return;
5929
+ }
5930
+ if (operation.name === "english.add_questions") {
5931
+ console.log(`Added ${data.added} of ${data.received} questions · ${data.duplicate} duplicates skipped · ${data.invalid?.length || 0} invalid${data.invalid?.length ? ` (${data.invalid.slice(0, 5).map((s) => `#${s.index}: ${s.why}`).join("; ")})` : ""}`);
5932
+ return;
5933
+ }
5934
+ if (operation.name === "english.relevel") {
5935
+ console.log(`Re-levelled ${data.moved} of ${data.received} · ${data.unchanged} already in place · ${data.invalid} invalid`);
5936
+ return;
5937
+ }
5938
+ if (operation.name === "english.summary") {
5939
+ console.log(`${data.total} questions · passes: ${(data.passes || []).map((row) => `${row.model} ${row.items}`).join(" · ") || "none yet"}`);
5940
+ console.log(`Tag status: ${(data.byStatus || []).map((row) => `${row.status} ${row.items}`).join(" · ") || "none yet"}`);
5941
+ (data.bySkill || []).slice(0, 40).forEach((row) => console.log(` ${row.skillCode}: ${row.items}`));
5942
+ return;
5943
+ }
5944
+ if (operation.name === "english.review") {
5945
+ const items = data.items || [];
5946
+ if (!items.length) return console.log("Nothing waiting for review.");
5947
+ items.forEach((item) => {
5948
+ console.log(`\n#${item.itemRef} v${item.itemVersion} · ${item.skillCode} · ${item.cefr || "?"}`);
5949
+ console.log(` ${item.question}`);
5950
+ ["a", "b", "c", "d"].forEach((letter) => console.log(` ${letter === item.correct_choice ? "*" : " "} ${letter}) ${item[`choice_${letter}`]}`));
5951
+ (item.verdicts || []).forEach((v) => console.log(` ${v.model}: ${v.primarySkill}${v.ambiguous ? ` · AMBIGUOUS: ${v.ambiguityReason}` : ""}`));
5952
+ });
5953
+ return;
5954
+ }
5955
+ if (operation.name === "english.decide") {
5956
+ console.log(`#${data.ref} v${data.version}: ${data.action} · ${data.skillCode}${data.reason ? ` (${data.reason})` : ""}`);
5957
+ }
5958
+ }
5959
+
5816
5960
  function printReviewRequestResult({ operation, data, result }) {
5817
5961
  if (operation.name === "review.list") {
5818
5962
  const items = Array.isArray(data.items) ? data.items : [];
@@ -5962,6 +6106,10 @@ function printAdminResult({ operation, result }) {
5962
6106
  printRewardBankReport(data);
5963
6107
  return;
5964
6108
  }
6109
+ if (operation.name === "owner-trace.read") {
6110
+ printOwnerTrace(data);
6111
+ return;
6112
+ }
5965
6113
  if (operation.name.startsWith("reward-bank.")) {
5966
6114
  if (operation.name === "reward-bank.seed") {
5967
6115
  console.log(
@@ -5999,6 +6147,10 @@ function printAdminResult({ operation, result }) {
5999
6147
  printApprovalResult({ operation, data });
6000
6148
  return;
6001
6149
  }
6150
+ if (operation.name.startsWith("english.")) {
6151
+ printEnglishResult({ operation, data });
6152
+ return;
6153
+ }
6002
6154
  if (operation.name.startsWith("review.")) {
6003
6155
  printReviewRequestResult({ operation, data, result });
6004
6156
  return;
@@ -536,7 +536,7 @@ export async function runInteractionSteps({
536
536
  };
537
537
  }
538
538
 
539
- function findChromeExecutable(explicitPath = "") {
539
+ export function findChromeExecutable(explicitPath = "") {
540
540
  const candidates = [
541
541
  explicitPath,
542
542
  process.env.CHROME_PATH,
@@ -589,7 +589,7 @@ function delay(milliseconds) {
589
589
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
590
590
  }
591
591
 
592
- function createCdpPipe(child) {
592
+ export function createCdpPipe(child) {
593
593
  const commandPipe = child.stdio[3];
594
594
  const eventPipe = child.stdio[4];
595
595
  if (!commandPipe || !eventPipe) {
package/lib/commands.js CHANGED
@@ -2371,6 +2371,8 @@ export function parseArgs(args) {
2371
2371
  "json",
2372
2372
  "keepAssets",
2373
2373
  "noBrowser",
2374
+ "noSave",
2375
+ "poolEvents",
2374
2376
  "includeComments",
2375
2377
  "anyoneCanReward",
2376
2378
  "full",
@@ -2493,6 +2495,8 @@ export function parseArgs(args) {
2493
2495
  adminPostedAfter: raw.postedAfter ? String(raw.postedAfter) : "",
2494
2496
  adminApprove: raw.approve ? String(raw.approve) : "",
2495
2497
  adminSinceRun: Boolean(raw.sinceRun),
2498
+ adminSince: raw.since ? String(raw.since) : "",
2499
+ adminPath: raw.path ? String(raw.path) : "",
2496
2500
  adminIncludeLegacy: Boolean(raw.includeLegacy),
2497
2501
  adminIncludePrivateEvidence: parseBoolean(
2498
2502
  raw.includePrivateEvidence,
@@ -2546,6 +2550,10 @@ export function parseArgs(args) {
2546
2550
  sponsorOutcome: raw.outcome ? String(raw.outcome) : "",
2547
2551
  sponsorFailureReason: raw.reason ? String(raw.reason) : "",
2548
2552
  sponsorNoLeaseKeeper: Boolean(raw.noLeaseKeeper),
2553
+ sponsorPoolEvents: Boolean(raw.poolEvents),
2554
+ sponsorNotifyCommand: raw.notify ? String(raw.notify) : "",
2555
+ sponsorLoopMinutes: raw.minutes ?? "",
2556
+ sponsorNoSave: Boolean(raw.noSave),
2549
2557
  sponsorHoldEverySeconds: raw.holdEverySeconds,
2550
2558
  sponsorUpdateFile: raw.file ? String(raw.file) : "",
2551
2559
  sponsorUpdatePhase: raw.phase ? String(raw.phase) : "",
@@ -2606,6 +2614,9 @@ export function parseArgs(args) {
2606
2614
  draftId: raw.draftId ? String(raw.draftId) : "",
2607
2615
  twinkles: raw.twinkles ? String(raw.twinkles) : "",
2608
2616
  adminReason: raw.reason ? String(raw.reason) : "",
2617
+ adminSkill: raw.skill ? String(raw.skill) : "",
2618
+ adminPass: raw.pass ? String(raw.pass) : "",
2619
+ adminLimit: raw.limit === undefined ? null : raw.limit,
2609
2620
  storageSize: raw.size ? String(raw.size) : "",
2610
2621
  adminAttended: raw.attended ? String(raw.attended) : "",
2611
2622
  adminConfirm: raw.confirm === undefined ? "" : String(raw.confirm),
@@ -2917,7 +2928,8 @@ export function printHelp() {
2917
2928
  lumine sponsor agreement|apply|status|withdraw
2918
2929
  lumine sponsor capacity [--concurrency <n>] [--helpers <n>] [--daily-limit <n>] [--weekly-limit <n>]
2919
2930
  lumine sponsor duty start [--provider <codex|claude-code>] --model <name> --effort <level> [--service-tier <tier>]
2920
- lumine sponsor duty watch [--wait-ms <ms>] [--json]
2931
+ lumine sponsor duty watch [--wait-ms <ms>] [--pool-events] [--json]
2932
+ lumine sponsor duty watch-loop [--minutes <n>] [--pool-events] [--notify <command>] [--json]
2921
2933
  lumine sponsor duty pause|resume|stop
2922
2934
  lumine sponsor job status|pulse|begin <job-id>
2923
2935
  lumine sponsor job update <job-id> --file <path> [--phase <name>]
@@ -2930,6 +2942,7 @@ export function printHelp() {
2930
2942
  lumine sponsor job rename <job-id> <title>
2931
2943
  lumine sponsor job describe <job-id> <text>
2932
2944
  lumine sponsor job update <job-id> --file <path> --kind question
2945
+ lumine sponsor job preview <job-id> [--no-save] [--no-browser] [--wait-ms <ms>]
2933
2946
  lumine sponsor job complete <job-id> --summary <text>
2934
2947
  lumine sponsor job fail <job-id> --reason <text>
2935
2948
  lumine sponsor jobs
@@ -2988,8 +3001,8 @@ export function printHelp() {
2988
3001
  lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
2989
3002
  lumine doctor runtime-assets
2990
3003
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
2991
- lumine admin identity inspect <user-id|username> --reason <management-reason> [--include-private-evidence] [--json]
2992
- lumine admin identity network <user-id|username> --reason <management-reason> [--include-private-evidence] [--json] (addresses this account used; who shares the small ones)
3004
+ lumine admin identity inspect <user-id|username> --reason <management-reason> [--include-private-evidence] [--output <file>] [--json] (nothing kept on disk unless --output; that copy is 0600)
3005
+ lumine admin identity network <user-id|username> --reason <management-reason> [--include-private-evidence] [--output <file>] [--json] (addresses this account used; who shares the small ones)
2993
3006
  lumine admin economy trace <user-id|username> --reason <management-reason> [--days <1..30>] [--json]
2994
3007
  lumine admin rescue wordle-audit --reason <management-reason> [--days <1..30>] [--json]
2995
3008
  lumine admin ai-bucket create --label <name> --note <text> [--json]
@@ -3016,6 +3029,7 @@ export function printHelp() {
3016
3029
  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>
3017
3030
  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)
3018
3031
  lumine admin teachers audit [--limit <1-100>] | revoke <userId> --reason <text> (approved teachers, riskiest first; revoke removes the status cleanly)
3032
+ 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)
3019
3033
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3020
3034
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3021
3035
  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>]
@@ -3067,6 +3081,7 @@ export function printHelp() {
3067
3081
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3068
3082
  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)
3069
3083
  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)
3070
3085
  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]
3071
3086
  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]
3072
3087
  lumine admin comment post --draft-id <id> [--json]