@stage5/lumine 0.3.67 → 0.3.69

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.
@@ -14,7 +14,7 @@ import path from "node:path";
14
14
  // re-importing a folder retires that paper's earlier items
15
15
  // status --item <id> --to live|retired|checked
16
16
  export const ARENA_USAGE =
17
- "Usage: lumine admin arena queue | marking [--cursor <id>] [--limit <1-200>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] [--json]";
17
+ "Usage: lumine admin arena queue | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] [--json]";
18
18
  const MAX_ITEMS = 300;
19
19
 
20
20
  function validationError(message) {
@@ -49,7 +49,39 @@ function readPaper(dir) {
49
49
  .sort((a, b) => a.number - b.number);
50
50
  }
51
51
 
52
+ function readAdditiveItems(file) {
53
+ const resolved = path.resolve(file);
54
+ if (fs.statSync(resolved).size > 5_000_000) {
55
+ throw validationError("The additive import file must be under 5 MB.");
56
+ }
57
+ const items = JSON.parse(fs.readFileSync(resolved, "utf8"));
58
+ if (!Array.isArray(items) || !items.length || items.length > MAX_ITEMS ||
59
+ items.some((item) => !item || typeof item !== "object" || Array.isArray(item))) {
60
+ throw validationError(`An additive import file must contain an array of 1 to ${MAX_ITEMS} reviewed items.`);
61
+ }
62
+ return items;
63
+ }
64
+
52
65
  export function buildArenaOperation({ action, options }) {
66
+ if (action === "today-queue") {
67
+ const cursor = Number(options.adminCursor || 0);
68
+ const limit = Number(options.adminLimit ?? 50);
69
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw validationError("Use a non-negative --cursor and --limit from 1 to 100.");
70
+ return { name: "arena.today.queue", method: "GET", path: `/cli/admin/arena/today/queue?cursor=${cursor}&limit=${limit}`, mutates: false, requiresRun: false };
71
+ }
72
+ if (action === "today-member") {
73
+ const id = Number(options.adminUser);
74
+ if (!Number.isSafeInteger(id) || id <= 0) throw validationError("Use today-member --user <member id>.");
75
+ return { name: "arena.today.member", method: "GET", path: `/cli/admin/arena/today/member/${id}`, mutates: false, requiresRun: false };
76
+ }
77
+ if (action === "today-import") {
78
+ if (!options.adminFile) throw validationError("Use today-import --file <reviewed-questions.json> [--confirm].");
79
+ const file = path.resolve(options.adminFile);
80
+ if (fs.statSync(file).size > 1000000) throw validationError("The import file must be under 1 MB.");
81
+ const payload = JSON.parse(fs.readFileSync(file, "utf8"));
82
+ const confirm = confirmed(options);
83
+ return { name: confirm ? "arena.today.import" : "arena.today.preview", method: "POST", path: "/cli/admin/arena/today/import", body: { ...payload, dryRun: !confirm }, mutates: confirm, requiresRun: false };
84
+ }
53
85
  if (action === "marking") {
54
86
  const cursor = Number(options.adminCursor || 0);
55
87
  const limit = Number(options.adminLimit ?? 100);
@@ -73,7 +105,11 @@ export function buildArenaOperation({ action, options }) {
73
105
  };
74
106
  }
75
107
  if (action === "import") {
76
- const items = readPaper(options.adminDir);
108
+ const additive = Boolean(options.adminArenaAdditive);
109
+ if ((options.adminFile && options.adminDir) || (options.adminFile && !additive)) {
110
+ throw validationError("Use --dir for a complete paper, or --additive with exactly one of --file or --dir for a refill.");
111
+ }
112
+ const items = options.adminFile ? readAdditiveItems(options.adminFile) : readPaper(options.adminDir);
77
113
  const confirm = confirmed(options);
78
114
  return {
79
115
  name: confirm ? "arena.import" : "arena.import.preview",
@@ -84,8 +120,9 @@ export function buildArenaOperation({ action, options }) {
84
120
  live: Boolean(options.adminArenaLive),
85
121
  dryRun: !confirm,
86
122
  ...(options.adminArenaAudio ? { audioPrefix: options.adminArenaAudio } : {}),
87
- // the folder name is the paper; re-importing it retires its earlier copy
88
- paper: path.basename(path.resolve(options.adminDir)),
123
+ // Full papers retain their replacement semantics. Additive refills
124
+ // preserve the existing bank without retiring another paper's items.
125
+ ...(!additive ? { paper: path.basename(path.resolve(options.adminDir)) } : {}),
89
126
  },
90
127
  mutates: confirm,
91
128
  requiresRun: false,
@@ -114,6 +151,14 @@ export function buildArenaOperation({ action, options }) {
114
151
  }
115
152
 
116
153
  export function formatArenaResult({ operation, data }) {
154
+ if (operation.name === "arena.today.queue") return [
155
+ ...(data?.members || []).map(m => `#${m.userId} ${m.username}: ${m.ready} questions ready${m.refillDue ? `; refill to ${m.target}` : ""} (preferences v${m.revision})`),
156
+ data?.nextCursor ? `Continue: lumine admin arena today-queue --cursor ${data.nextCursor} --json` : "End of Today’s Q queue.",
157
+ ];
158
+ if (operation.name === "arena.today.member") return [JSON.stringify(data, null, 2)];
159
+ if (operation.name === "arena.today.preview" || operation.name === "arena.today.import") return [
160
+ `${data?.dryRun ? "Dry run" : "Saved"}: ${data?.questions?.length || 0} reviewed personal questions.${data?.dryRun ? " Add --confirm to make new questions available." : ""}`,
161
+ ];
117
162
  if (operation.name === "arena.marking") {
118
163
  const entries = data?.entries || [];
119
164
  return [
@@ -74,6 +74,12 @@ export function buildBuildPlansOperation({ action, options }) {
74
74
  throw validationError(BUILD_PLANS_USAGE);
75
75
  }
76
76
 
77
+ function formatStepCoverage(row) {
78
+ if (!Number.isFinite(row.steps)) return ""; // Older API response.
79
+ if (row.stepsPassed == null) return `; steps ?/${row.steps} verified (no step grading yet)`;
80
+ return `; steps ${row.stepsPassed}/${row.steps} verified, ${row.stepsUnverified ?? 0} unverified, ${row.stepsNeedWork ?? 0} need work`;
81
+ }
82
+
77
83
  function formatRow(row) {
78
84
  const ratio = row.energyRatio == null ? "?" : `${row.energyRatio}x`;
79
85
  const milestone =
@@ -86,7 +92,7 @@ function formatRow(row) {
86
92
  : row.wouldStall
87
93
  ? ` would stall (${row.wouldStall})`
88
94
  : "";
89
- return `Build ${row.buildId}: milestone ${milestone} (${row.milestonesDone} done), ${row.runs} runs, Energy ${ratio} of estimate, ${row.evaluations ?? "?"} gradings, non-pass streak ${row.consecutiveNonPass ?? "?"}, ${row.passed}/${row.criteria} passing, ${row.unverified} unverified${state};${coverage}${row.flags?.length ? ` [${row.flags.join(", ")}]` : ""}`;
95
+ return `Build ${row.buildId}: milestone ${milestone} (${row.milestonesDone} done), ${row.runs} runs, Energy ${ratio} of estimate, ${row.evaluations ?? "?"} gradings, non-pass streak ${row.consecutiveNonPass ?? "?"}, ${row.passed}/${row.criteria} passing, ${row.unverified} unverified${formatStepCoverage(row)}${state};${coverage}${row.flags?.length ? ` [${row.flags.join(", ")}]` : ""}`;
90
96
  }
91
97
 
92
98
  export function formatBuildPlansResult({ operation, data }) {
@@ -110,7 +116,7 @@ export function formatBuildPlansResult({ operation, data }) {
110
116
  for (const result of data?.results || []) {
111
117
  lines.push(
112
118
  result?.ok
113
- ? `${result.replayed ? "(already rechecked by this request) " : ""}Build ${result.buildId}: milestone ${result.milestoneIndex + 1} ${result.verdict}, ${result.passed}/${result.criteria} passing, ${result.unverified} unverified${result.milestoneCompleted ? ", milestone completed" : ""}${result.stalled ? `, stalled (${result.stalled.reason})` : ""}${result.fileCoverage ? `; files ${result.fileCoverage.shownFiles}/${result.fileCoverage.totalFiles} + ${result.fileCoverage.toolFilesRead || 0} read in ${result.fileCoverage.toolRounds || 0} rounds` : ""}${result.usage ? `; cost ${result.usage.energyUnits} Energy units (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens), not charged to the creator` : ""}`
119
+ ? `${result.replayed ? "(already rechecked by this request) " : ""}Build ${result.buildId}: milestone ${result.milestoneIndex + 1} ${result.verdict}, ${result.passed}/${result.criteria} passing, ${result.unverified} unverified${formatStepCoverage(result)}${result.milestoneCompleted ? ", milestone completed" : ""}${result.stalled ? `, stalled (${result.stalled.reason})` : ""}${result.fileCoverage ? `; files ${result.fileCoverage.shownFiles}/${result.fileCoverage.totalFiles} + ${result.fileCoverage.toolFilesRead || 0} read in ${result.fileCoverage.toolRounds || 0} rounds` : ""}${result.usage ? `; cost ${result.usage.energyUnits} Energy units (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens), not charged to the creator` : ""}`
114
120
  : `Build ${result?.buildId}: not rechecked (${result?.reason === "run_in_progress" ? "a Lumine run is in progress; try again later" : result?.reason || "unknown"})`,
115
121
  );
116
122
  }
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+
5
+ function invalid(message) {
6
+ const error = new Error(message);
7
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
8
+ throw error;
9
+ }
10
+
11
+ const sha = (value) => createHash("sha256").update(value).digest("hex");
12
+
13
+ // Resuming reuses explicitly dated, byte-verified receipts. It does not assert
14
+ // that old observations are fresh, read, or an editorial duty is complete.
15
+ export function createDailyCollection(dir, options, { now = Date.now, clock = () => performance.now() } = {}) {
16
+ const started = clock();
17
+ const today = new Date(now()).toISOString().slice(0, 10);
18
+ const costDate = options.adminCostDate || options.adminDate || new Date(now() - 86400000).toISOString().slice(0, 10);
19
+ if (options.adminCostDate && options.adminDate && options.adminCostDate !== options.adminDate)
20
+ invalid("--cost-date and legacy --date must identify the same closed UTC cost day.");
21
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(costDate) || !Number.isFinite(Date.parse(`${costDate}T00:00:00Z`)) ||
22
+ new Date(`${costDate}T00:00:00Z`).toISOString().slice(0, 10) !== costDate || costDate >= today)
23
+ invalid("Use --cost-date YYYY-MM-DD for a completed UTC day, not today's run date.");
24
+ const phase = options.adminPhase || "all";
25
+ if (!["all", "priority", "evidence"].includes(phase)) invalid("--phase must be priority, evidence, or all.");
26
+ const maxSteps = Number(options.adminMaxSteps === "" || options.adminMaxSteps == null ? 200 : options.adminMaxSteps);
27
+ const maxSeconds = Number(options.adminMaxSeconds === "" || options.adminMaxSeconds == null ? 900 : options.adminMaxSeconds);
28
+ if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 1000 ||
29
+ !Number.isInteger(maxSeconds) || maxSeconds < 1 || maxSeconds > 14400)
30
+ invalid("Use --max-steps 1–1000 and --max-seconds 1–14400.");
31
+ const file = path.join(dir, "gather-checkpoint.json");
32
+ let checkpoint = { protocol: 1, collectionId: randomUUID(), startedAt: new Date(now()).toISOString(), costDate, entries: {} };
33
+ if (options.adminResume) {
34
+ if (!fs.existsSync(file)) invalid("No gather checkpoint exists in this directory; run without --resume first.");
35
+ checkpoint = JSON.parse(fs.readFileSync(file, "utf8"));
36
+ if (checkpoint.protocol !== 1 || checkpoint.costDate !== costDate || !checkpoint.entries || typeof checkpoint.entries !== "object")
37
+ invalid("The checkpoint belongs to a different cost day or format. Use its --cost-date or start a new directory.");
38
+ }
39
+ const save = () => {
40
+ const temporary = `${file}.${process.pid}.next`;
41
+ fs.writeFileSync(temporary, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
42
+ fs.renameSync(temporary, file);
43
+ };
44
+ save();
45
+ let executed = 0;
46
+ return {
47
+ costDate, phase, collectionId: checkpoint.collectionId, startedAt: checkpoint.startedAt,
48
+ warnings: options.adminDate ? ["--date is a legacy alias for --cost-date (a completed UTC day), not the management run date."] : [],
49
+ invalidate(name) {
50
+ if (checkpoint.entries[name]) { checkpoint.entries[name].ok = false; save(); }
51
+ },
52
+ collect(name, args, outputName, stage, run) {
53
+ const outputFile = path.join(dir, outputName);
54
+ const key = sha(JSON.stringify(args));
55
+ const cached = checkpoint.entries[name];
56
+ if (options.adminResume && cached?.ok && cached.requestHash === key && cached.file === outputName) {
57
+ try {
58
+ const bytes = fs.readFileSync(outputFile);
59
+ const json = JSON.parse(bytes);
60
+ if (sha(bytes) === cached.sha256 && json.ok === true && typeof json.status === "string")
61
+ return { ok: true, json, attempts: 0, durationMs: 0, capturedAt: cached.capturedAt, reused: true };
62
+ } catch { /* Missing/changed bytes require a fresh read, never a cached success. */ }
63
+ }
64
+ const remainingMs = maxSeconds * 1000 - (clock() - started);
65
+ const reason = phase !== "all" && phase !== stage ? `outside-${phase}-phase`
66
+ : executed >= maxSteps ? "step-budget" : remainingMs <= 0 ? "time-budget" : null;
67
+ if (reason) return { ok: false, deferred: true, reason, attempts: 0, durationMs: 0 };
68
+ executed++;
69
+ let result;
70
+ try { result = run(args, { outputFile, retryRead: true, timeoutMs: Math.max(1, Math.min(120000, Math.floor(remainingMs))) }); }
71
+ catch (error) { result = { ok: false, attempts: 1, json: { error: { message: String(error.message || error) } } }; }
72
+ const capturedAt = new Date(now()).toISOString();
73
+ let digest;
74
+ if (result.ok) {
75
+ try {
76
+ const bytes = fs.readFileSync(outputFile);
77
+ if (JSON.stringify(JSON.parse(bytes)) !== JSON.stringify(result.json)) throw new Error("receipt mismatch");
78
+ digest = sha(bytes);
79
+ } catch {
80
+ result = { ...result, ok: false, json: { error: { code: "CLI_DAILY_RECEIPT_INVALID", message: "Successful collection did not save its matching receipt." } } };
81
+ }
82
+ }
83
+ checkpoint.entries[name] = { ok: result.ok, requestHash: key, file: outputName, capturedAt, ...(digest ? { sha256: digest } : {}) };
84
+ save();
85
+ return { ...result, capturedAt, reused: false };
86
+ },
87
+ };
88
+ }
@@ -8,6 +8,7 @@ import { adminWorkDirectory } from "./admin-work-directory.js";
8
8
  import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
9
9
  import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
10
10
  import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
11
+ import { createDailyCollection } from "./admin-daily-collection.js";
11
12
  export { dailyRunProgress };
12
13
 
13
14
  // Multi-step conveniences for the full daily management run. Each one runs
@@ -22,7 +23,7 @@ const MARKED_ASSET = fileURLToPath(
22
23
 
23
24
  export function runAdmin(
24
25
  args,
25
- { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync } = {},
26
+ { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync, timeoutMs } = {},
26
27
  ) {
27
28
  const startedAt = performance.now();
28
29
  const attemptFile = outputFile
@@ -33,6 +34,7 @@ export function runAdmin(
33
34
  const result = spawnImpl(process.execPath, [LUMINE_BIN, ...full], {
34
35
  encoding: "utf8",
35
36
  maxBuffer: 256 * 1024 * 1024,
37
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
36
38
  env: { ...process.env, ...(extraEnv || {}) },
37
39
  });
38
40
  let parsed = null;
@@ -84,6 +86,9 @@ export function runAdmin(
84
86
  sponsor: ["applications"],
85
87
  "bot-output": [null],
86
88
  "math-lab": ["queue"],
89
+ arena: ["queue", "today-queue"],
90
+ "word-games": ["status"],
91
+ nonogram: ["status"],
87
92
  subjects: ["candidates"],
88
93
  subject: ["get"],
89
94
  };
@@ -98,8 +103,9 @@ export function runAdmin(
98
103
  result.error ||
99
104
  result.signal ||
100
105
  parsed?.error?.code === "lumine_http_invalid_response";
101
- if (!ok && retryRead && readOnly && transient) {
102
- const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl });
106
+ const remainingMs = timeoutMs == null ? undefined : Math.floor(timeoutMs - (performance.now() - startedAt));
107
+ if (!ok && retryRead && readOnly && transient && (remainingMs == null || remainingMs > 0)) {
108
+ const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl, timeoutMs: remainingMs });
103
109
  return {
104
110
  ...retried,
105
111
  durationMs: Math.round(performance.now() - startedAt),
@@ -166,16 +172,21 @@ export function renderBotOutput({ chatMessages, comments }, file) {
166
172
  fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
167
173
  }
168
174
 
169
- export async function dailyRunGather(options, { run = runAdmin } = {}) {
175
+ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {}) {
170
176
  const startedAt = performance.now();
171
177
  const dir = ensureDir(
172
178
  path.resolve(options.adminOutputDir || options.adminDir || "."),
173
179
  );
174
- const yesterday = options.adminDate || utcDayKey(-1);
180
+ const collection = createDailyCollection(dir, options, { now, clock });
181
+ const yesterday = collection.costDate;
175
182
  const steps = [];
183
+ const failLast = (error) => {
184
+ collection.invalidate(steps.at(-1).name);
185
+ Object.assign(steps.at(-1), { ok: false, error });
186
+ };
187
+ const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|owner-wake|bot-output)/;
176
188
  const step = (name, args, file) => {
177
- const outputFile = path.join(dir, file);
178
- const res = run(args, { outputFile, retryRead: true });
189
+ const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
179
190
  steps.push({
180
191
  name,
181
192
  file,
@@ -183,14 +194,49 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
183
194
  attempts: res.attempts,
184
195
  durationMs: res.durationMs,
185
196
  recovered: Boolean(res.recovered),
186
- ...(res.ok ? {} : { error: res.json?.error || res.stderr }),
197
+ capturedAt: res.capturedAt || null,
198
+ reused: Boolean(res.reused),
199
+ ...(res.deferred ? { deferred: true, reason: res.reason } : res.ok ? {} : { error: res.json?.error || res.stderr }),
187
200
  });
188
201
  return res;
189
202
  };
190
203
  step("energy-budget", ["energy-budget"], "energy-budget.json");
191
204
  step("news", ["news"], "news.json");
192
205
  step("brief", ["brief"], "brief.json");
206
+ step("todos", ["todo", "list"], "todos.json");
207
+ step("escalations", ["escalation", "list"], "escalations.json");
193
208
  step("math-lab-queue", ["math-lab", "queue"], "math-lab-queue.json");
209
+ step("arena-queue", ["arena", "queue"], "arena-queue.json");
210
+ step("word-games-status", ["word-games", "status"], "word-games-status.json");
211
+ step("nonogram-status", ["nonogram", "status"], "nonogram-status.json");
212
+ const personalQueue = { complete: false, pages: 0, members: 0 };
213
+ let personalCursor = 0;
214
+ const personalCursors = new Set();
215
+ for (;;) {
216
+ personalQueue.pages++;
217
+ const res = step(`arena-today-page-${personalQueue.pages}`,
218
+ ["arena", "today-queue", "--cursor", String(personalCursor), "--limit", "100"],
219
+ `arena-today-queue-${personalQueue.pages}.json`);
220
+ if (!res.ok) break;
221
+ const data = res.json?.data;
222
+ const rawNext = data?.nextCursor;
223
+ const next = Number(rawNext || 0);
224
+ if (!Array.isArray(data?.members) || !Object.hasOwn(data, "nextCursor") ||
225
+ !(rawNext === null || (typeof rawNext === "number" && Number.isSafeInteger(rawNext)) || (typeof rawNext === "string" && /^\d+$/.test(rawNext))) ||
226
+ !Number.isSafeInteger(next) || next < 0) {
227
+ failLast("Invalid Arena personal queue page.");
228
+ break;
229
+ }
230
+ personalQueue.members += data.members.length;
231
+ if (!next) { personalQueue.complete = true; break; }
232
+ if (personalCursors.has(next) || personalQueue.pages >= 100) {
233
+ collection.invalidate(steps.at(-1).name);
234
+ steps.push({ name: "arena-today-pagination", ok: false, error: "Arena personal queue repeated a cursor or reached its 100-page bound." });
235
+ break;
236
+ }
237
+ personalCursors.add(next);
238
+ personalCursor = next;
239
+ }
194
240
  step("ai-costs-monthly", ["ai-costs", "monthly"], "ai-monthly.json");
195
241
  step("ai-costs-day", ["ai-costs", "day", yesterday], "ai-day.json");
196
242
  step("media-costs", ["media-costs", "monthly"], "media.json");
@@ -222,10 +268,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
222
268
  !(data.nextCursor === null ||
223
269
  (typeof data.nextCursor === "string" && /^\d+(\.\d\.\d+)?$/.test(data.nextCursor)))
224
270
  ) {
225
- Object.assign(steps[steps.length - 1], {
226
- ok: false,
227
- error: "Invalid pending Build review page: expected filter, items and nextCursor.",
228
- });
271
+ failLast("Invalid pending Build review page: expected filter, items and nextCursor.");
229
272
  break;
230
273
  }
231
274
  buildReviews.items += data.items.length;
@@ -234,6 +277,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
234
277
  break;
235
278
  }
236
279
  if (reviewCursors.has(data.nextCursor) || buildReviews.pages >= 100) {
280
+ collection.invalidate(steps.at(-1).name);
237
281
  steps.push({
238
282
  name: "build-reviews-pagination",
239
283
  ok: false,
@@ -249,9 +293,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
249
293
  // management approvals (birthdates, teacher signups, meetups) with their
250
294
  // flags: a required report section (agent-guides/lumine-admin.md)
251
295
  step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
252
- step("todos", ["todo", "list"], "todos.json");
253
296
  step("personal-followups", ["followup", "list"], "personal-followups.json");
254
- step("escalations", ["escalation", "list"], "escalations.json");
255
297
  const wake = step("owner-wake", ["owner-trace", "--since", "24h", "--wake", "--limit", "2000"], "owner-wake.json");
256
298
  // A phone tab can live longer than the daily window. Retain its version,
257
299
  // browser and device context instead of silently calling it unknown.
@@ -264,7 +306,10 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
264
306
  ...(!wake.ok ? ["owner-wake"] : []),
265
307
  ...(!wakeSessions.ok ? ["owner-wake-sessions"] : []),
266
308
  ];
267
- if (ownerWake.collectionFailures.length) ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
309
+ if (ownerWake.collectionFailures.length) {
310
+ ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
311
+ ownerWake.detail.coverage = ownerWake.detail.samples ? "partial" : "unavailable";
312
+ }
268
313
  writeAdminJsonFile(path.join(dir, "owner-wake-summary.json"), ownerWake, { privateFile: true });
269
314
  fs.writeFileSync(path.join(dir, "owner-wake-summary.md"), formatOwnerViewportReport(ownerWake), { mode: 0o600 });
270
315
  steps.push({ name: "owner-wake-summary", file: "owner-wake-summary.json", ok: true });
@@ -281,6 +326,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
281
326
  let cursor = "";
282
327
  let pages = 0;
283
328
  let botOk = true;
329
+ const botCursors = new Set();
284
330
  for (;;) {
285
331
  pages += 1;
286
332
  const file = `bot-output-${pages}.json`;
@@ -294,12 +340,18 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
294
340
  break;
295
341
  }
296
342
  const data = res.json?.data || {};
343
+ if (!Array.isArray(data.chatMessages) || !Array.isArray(data.comments) || typeof data.pagination?.exhausted !== "boolean") {
344
+ botOk = false;
345
+ failLast("Invalid bot-output page: rows and explicit exhaustion are required.");
346
+ break;
347
+ }
297
348
  chatMessages.push(...(data.chatMessages || []));
298
349
  comments.push(...(data.comments || []));
299
350
  const p = data.pagination || {};
300
351
  if (p.exhausted && !data.chatTruncated && !data.commentsTruncated) break;
301
352
  cursor = p.nextCursor || "";
302
- if (!cursor || pages > 50) {
353
+ if (!cursor || botCursors.has(cursor) || pages >= 50) {
354
+ collection.invalidate(steps.at(-1).name);
303
355
  botOk = false;
304
356
  steps.push({
305
357
  name: "bot-output",
@@ -308,6 +360,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
308
360
  });
309
361
  break;
310
362
  }
363
+ botCursors.add(cursor);
311
364
  }
312
365
  if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
313
366
 
@@ -316,6 +369,9 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
316
369
  durationMs: Math.round(performance.now() - startedAt),
317
370
  dir,
318
371
  yesterdayUtc: yesterday,
372
+ costDateUtc: yesterday,
373
+ collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase, warnings: collection.warnings },
374
+ personalQueue,
319
375
  buildReviews,
320
376
  ownerWake: {
321
377
  coverage: ownerWake.coverage,
@@ -331,7 +387,8 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
331
387
  readFile: botOk ? path.join(dir, "bot-chats.txt") : null,
332
388
  },
333
389
  steps,
334
- failed: steps.filter((s) => !s.ok).map((s) => s.name),
390
+ failed: steps.filter((s) => !s.ok && !s.deferred).map((s) => s.name),
391
+ deferred: steps.filter((s) => s.deferred).map((s) => ({ name: s.name, reason: s.reason })),
335
392
  };
336
393
  fs.writeFileSync(
337
394
  path.join(dir, "gather.json"),
@@ -348,8 +405,8 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
348
405
  { privateFile: true },
349
406
  );
350
407
  return {
351
- ok: summary.failed.length === 0,
352
- status: summary.failed.length ? "partial_failure" : "success",
408
+ ok: summary.failed.length === 0 && summary.deferred.length === 0,
409
+ status: summary.failed.length ? "partial_failure" : summary.deferred.length ? "deferred" : "success",
353
410
  data: summary,
354
411
  };
355
412
  }
@@ -447,10 +504,37 @@ nav{position:fixed;top:0;left:0;bottom:0;width:250px;overflow:auto;background:va
447
504
  nav h4{margin:0 0 10px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}nav a{display:block;padding:4px 6px;border-radius:6px;color:var(--fg);text-decoration:none;font-size:13px}nav a:hover{background:var(--line)}
448
505
  main{margin-left:250px;padding:28px 40px;max-width:1100px}a{color:var(--link)}table{border-collapse:collapse;margin:10px 0;display:block;overflow-x:auto}th,td{border:1px solid var(--line);padding:6px 9px;text-align:left;vertical-align:top}
449
506
  code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.9em}pre{background:var(--code);padding:12px;border-radius:8px;overflow:auto}h2{border-bottom:1px solid var(--line);padding-bottom:4px;margin-top:34px}
507
+ .relay-actions{border:1px solid var(--line);border-radius:12px;padding:16px;margin:20px 0;background:var(--nav)}
508
+ .relay-actions h3{margin:0 0 8px}.relay-actions p{margin:8px 0}.relay-buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr));gap:8px}
509
+ .relay-actions button{font:inherit;font-size:1rem;text-align:left;min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);cursor:pointer;overflow-wrap:anywhere}
510
+ .relay-actions button:hover{border-color:var(--link)}.relay-actions button:focus-visible{outline:2px solid var(--link);outline-offset:2px}.relay-actions button:disabled{cursor:wait;opacity:.65}
511
+ .relay-actions .relay-master{grid-column:1/-1;font-weight:650;border-color:var(--link);color:var(--link)}.relay-actions [role=status]{min-height:1.55em;color:var(--muted);font-size:1rem}
512
+ .relay-prompt-source{width:100%;min-height:180px;padding:12px;font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:8px}
450
513
  @media (max-width:760px){nav{position:static;width:auto;height:auto;border-right:0;border-bottom:1px solid var(--line)}main{margin-left:0;padding:16px}}
451
514
  </style></head><body><nav><h4>Sections</h4><div id="toc"></div><h4 style="margin-top:18px">Source</h4><a href="source.html">Markdown</a></nav><main id="content">Loading…</main>
452
515
  <script src="marked.min.js"></script><script>
453
516
  fetch('daily-management-report.md',{cache:'no-store'}).then(r=>{if(!r.ok)throw new Error('Report source unavailable');return r.text()}).then(md=>{const el=document.getElementById('content');el.innerHTML=marked.parse(md);const toc=document.getElementById('toc');let i=0;el.querySelectorAll('h1,h2').forEach(h=>{h.id='s'+(i++);if(h.tagName==='H2'){const a=document.createElement('a');a.href='#'+h.id;a.textContent=h.textContent;toc.appendChild(a)}})}).catch(()=>{document.getElementById('content').textContent='The report could not load. Open Markdown under Source, or reload this page.'});
517
+ document.getElementById('content').addEventListener('click',async event=>{
518
+ const button=event.target.closest('button[data-copy-prompt]');
519
+ if(!button||button.disabled)return;
520
+ const group=button.closest('.relay-actions');
521
+ const source=document.getElementById(button.dataset.copyPrompt);
522
+ const status=group?.querySelector('[role=status]');
523
+ if(!group||!status||!source?.matches('textarea.relay-prompt-source')||!group.contains(source))return;
524
+ const buttons=Array.from(group.querySelectorAll('button[data-copy-prompt]'));
525
+ buttons.forEach(b=>{b.disabled=true});
526
+ try{
527
+ await navigator.clipboard.writeText(source.value);
528
+ status.textContent=button.textContent.replace(/^Copy: /,'')+' copied. Paste it into the agent chat you choose.';
529
+ }catch{
530
+ source.hidden=false;
531
+ source.focus();
532
+ source.select();
533
+ status.textContent='Automatic copy is unavailable. The prompt is selected below; copy it with your keyboard.';
534
+ }finally{
535
+ buttons.forEach(b=>{b.disabled=false});
536
+ }
537
+ });
454
538
  </script></body></html>`;
455
539
  }
456
540
 
@@ -28,6 +28,8 @@ export function createDailyReviewState(dir, gather, previous = {}) {
28
28
  .map((step) => ({
29
29
  ...(step.ok ? fingerprint(dir, step.file) : { file: step.file }),
30
30
  fetched: step.ok,
31
+ ...(step.deferred ? { deferred: true, reason: step.reason } : {}),
32
+ ...(step.capturedAt ? { capturedAt: step.capturedAt, reused: Boolean(step.reused) } : {}),
31
33
  read: false,
32
34
  }));
33
35
  if (gather.botOutput.complete)
@@ -42,6 +44,10 @@ export function createDailyReviewState(dir, gather, previous = {}) {
42
44
  if (gather.buildReviews && (
43
45
  source.file === "rr-pending.json" || /^build-reviews-\d+\.json$/.test(source.file)
44
46
  )) continue;
47
+ if (gather.personalQueue && /^arena-today-queue-\d+\.json$/.test(source.file)) continue;
48
+ if (gather.botOutput && (
49
+ source.file === "bot-chats.txt" || /^bot-output-\d+\.json$/.test(source.file)
50
+ )) continue;
45
51
  if (!sources.some((current) => current.file === source.file)) sources.push({ ...source, retained: true, read: false });
46
52
  }
47
53
  return {
@@ -49,6 +55,7 @@ export function createDailyReviewState(dir, gather, previous = {}) {
49
55
  scope: previous.scope || "collection",
50
56
  sources,
51
57
  collectionFailures: gather.failed || [],
58
+ collectionDeferred: gather.deferred || [],
52
59
  requiredDuties: [...new Set(["effort-levels", "report-browser", ...(previous.requiredDuties || [])])],
53
60
  note: "Collection is not a full review. Add every duty required by the run scope and its carryovers. Reading and browser checks require explicit evidence acknowledgments.",
54
61
  };
@@ -157,8 +164,9 @@ export function summarizeDailyProgress(dir, state, evidence = {}) {
157
164
  .filter((item) => item.status !== "completed")
158
165
  .map((item) => item.id);
159
166
  const collectionFailures = state.collectionFailures || [];
167
+ const collectionDeferred = state.collectionDeferred || [];
160
168
  const complete =
161
- collectionFailures.length === 0 && sources.every((item) => item.read) && incomplete.length === 0;
169
+ collectionFailures.length === 0 && collectionDeferred.length === 0 && sources.every((item) => item.read) && incomplete.length === 0;
162
170
  return {
163
171
  scope: state.scope || "declared-duties",
164
172
  coverageBasis: "declared sources and duties only",
@@ -166,6 +174,7 @@ export function summarizeDailyProgress(dir, state, evidence = {}) {
166
174
  effortAssignments,
167
175
  sources,
168
176
  collectionFailures,
177
+ collectionDeferred,
169
178
  duties,
170
179
  complete,
171
180
  reading: {
@@ -201,6 +201,9 @@ function describeViewport(data) {
201
201
  `nav bottom ${nav.bottom ?? "?"} (${nav.position || "absent"})`,
202
202
  `inset ${data.inset ?? "?"} focus ${focus.tag || "none"}${focus.type ? `:${focus.type}` : ""}`,
203
203
  `scroll ${data.root?.scrollTop ?? "?"}`,
204
+ isPlainObject(data.signals) ? `signals ${Object.entries(data.signals).map(([name, count]) => `${name}:${count}`).join(",")}` : "",
205
+ isPlainObject(data.layout) ? `detail ${data.layout.omitted || `v${data.layout.v}; body top ${data.layout.body?.top ?? "?"}; ancestor effects ${Array.isArray(data.layout.ancestors) ? data.layout.ancestors.length : "?"}`}` : "",
206
+ isPlainObject(data.activity) ? `changes ${data.activity.changes ?? "?"}/${data.activity.limit ?? "?"}; skipped events ${data.activity.skippedEvents ?? "?"}` : "",
204
207
  data._trimmed ? `trimmed ${JSON.stringify(data._trimmed)}` : "",
205
208
  ].filter(Boolean).join(" · ");
206
209
  }
@@ -33,7 +33,9 @@ export function summarizeOwnerViewport(data, sessionData = data) {
33
33
  }
34
34
  cycle.samples += 1;
35
35
  if (d.phase === "background") cycle.backgroundSamples += 1;
36
- cycle.lastScheduledMs = Math.max(cycle.lastScheduledMs, Number(d.scheduledMs) || 0);
36
+ // Activity samples can occur minutes later; only a scheduled baseline
37
+ // establishes that the report actually observed the 20-second follow-up.
38
+ if (d.phase === "settle") cycle.lastScheduledMs = Math.max(cycle.lastScheduledMs, Number(d.scheduledMs) || 0);
37
39
  if (d.phase !== "settle" || d.scheduledMs < 1000 || d.visibility !== "visible") continue;
38
40
  cycle.settledSamples += 1;
39
41
  const nav = d.nav;
@@ -81,6 +83,7 @@ export function summarizeOwnerViewport(data, sessionData = data) {
81
83
  truncated: Boolean(data?.truncated), sessionHeadersTruncated: Boolean(sessionData?.truncated),
82
84
  viewportEvents, usableSamples, unavailableSamples: unavailable, droppedEvents: dropped,
83
85
  wakeProfiles: events.filter(e => e.type === "wake").length,
86
+ detail: summarizeViewportDetail(events, usableSamples, partial),
84
87
  clients, clientsWithoutGeometry,
85
88
  cycles: rows, cyclesMissingHeaders: missingHeaders,
86
89
  cyclesReaching20s: rows.filter(c => c.lastScheduledMs >= 20_000).length,
@@ -92,6 +95,92 @@ export function summarizeOwnerViewport(data, sessionData = data) {
92
95
  };
93
96
  }
94
97
 
98
+ function summarizeViewportDetail(events, usableSamples, partial) {
99
+ const rows = events.filter(e => e.type === "wake-viewport" && e.data?.v === 1 &&
100
+ !e.data.error && !e.data._trimmed && e.data.window && e.data.root && e.data.focus);
101
+ const detailed = rows.filter(e => e.data.layout?.v === 2 && !e.data.layout.omitted);
102
+ const omitted = rows.filter(e => e.data.layout?.omitted).length;
103
+ const limitedAncestors = detailed.filter(e => e.data.layout.ancestorLimit).length;
104
+ const capped = new Set();
105
+ const groups = new Map();
106
+ const examples = [];
107
+ let firstTouchPairs = 0, firstTouchChanges = 0, boundaryChanges = 0, ancestorSamples = 0;
108
+ const addExample = (event, kind, extra) => {
109
+ if (examples.filter(example => example.kind === kind).length < 3) examples.push({ kind, sessionId: event.sessionId,
110
+ cycle: event.data.cycle, seq: event.seq, clientTime: event.clientTime, path: event.path, ...extra });
111
+ };
112
+ for (const event of rows) {
113
+ const d = event.data, key = `${event.sessionId}:${d.cycle}`;
114
+ const limit = Number(d.activity?.limit);
115
+ if ((limit > 0 && Number(d.activity?.changes) >= limit) || Number(d.activity?.skippedEvents) > 0) capped.add(key);
116
+ if (d.layout?.v !== 2 || d.layout.omitted) continue;
117
+ if (Array.isArray(d.layout.ancestors) && d.layout.ancestors.length) {
118
+ ancestorSamples++;
119
+ addExample(event, "ancestor-layout-effects", { ancestors: d.layout.ancestors });
120
+ }
121
+ if (!groups.has(key)) groups.set(key, []);
122
+ groups.get(key).push(event);
123
+ }
124
+ for (const group of groups.values()) {
125
+ // Within one client session sequence order survives wall-clock changes.
126
+ group.sort((a, b) => a.seq - b.seq || a.clientTime - b.clientTime);
127
+ let previous = null, firstTouch = null;
128
+ for (const event of group) {
129
+ const d = event.data;
130
+ if (d.signals?.["first-touch"]) firstTouch = event;
131
+ if (d.signals?.["after-first-touch"] && firstTouch) {
132
+ const delta = compareBoundaries(firstTouch, event);
133
+ // A tap can navigate or focus a keyboard; neither is a same-page repair.
134
+ if (delta && d.elapsedMs - firstTouch.data.elapsedMs <= 2000) {
135
+ firstTouchPairs++;
136
+ if (delta.maximum >= 40) {
137
+ firstTouchChanges++;
138
+ addExample(event, "first-touch-boundary-change", { beforeSeq: firstTouch.seq, ...delta });
139
+ }
140
+ }
141
+ firstTouch = null;
142
+ }
143
+ if (previous) {
144
+ const delta = compareBoundaries(previous, event);
145
+ if (delta && delta.maximum >= 40) {
146
+ boundaryChanges++;
147
+ addExample(event, "boundary-change", { beforeSeq: previous.seq, signals: d.signals, ...delta });
148
+ }
149
+ }
150
+ previous = event;
151
+ }
152
+ }
153
+ return {
154
+ coverage: !detailed.length ? "unavailable" : partial || limitedAncestors || detailed.length < usableSamples ? "partial" : "observed",
155
+ samples: detailed.length, omittedSamples: omitted, limitedAncestorSamples: limitedAncestors, activityCappedCycles: capped.size,
156
+ firstTouchPairs, firstTouchChanges, boundaryChanges, ancestorSamples, examples,
157
+ note: "Boundary changes (>=40px) and ancestor CSS effects are comparison evidence, not confirmed gaps or repairs. A first touch can naturally move browser controls. Unchanged DOM measurements cannot rule out a visible rendering fault. Activity capture is bounded; no samples or a reached cap cannot establish healthy behavior.",
158
+ };
159
+ }
160
+
161
+ function compareBoundaries(before, after) {
162
+ const a = before.data, b = after.data;
163
+ if (![a.elapsedMs, b.elapsedMs, a.window.width, b.window.width].every(Number.isFinite) ||
164
+ before.path !== after.path || a.window.width !== b.window.width ||
165
+ a.layout.orientation !== b.layout.orientation || b.elapsedMs <= a.elapsedMs) return null;
166
+ for (const d of [a, b]) {
167
+ if (d.visibility !== "visible" || d.inset !== 0 || d.focus.editable ||
168
+ ["input", "textarea", "iframe"].includes(d.focus.tag) ||
169
+ !Number.isFinite(d.visual?.scale) || Math.abs(d.visual.scale - 1) > 0.01 ||
170
+ d.nav?.position !== "fixed" || parseFloat(d.nav.bottomStyle) !== 0 ||
171
+ d.nav.height <= 0 || d.nav.display === "none" || d.nav.visibility === "hidden") return null;
172
+ if (![d.window.height, d.root.height, d.visual.height, d.visual.offsetTop, d.nav.bottom].every(Number.isFinite)) return null;
173
+ }
174
+ const changes = {
175
+ window: b.window.height - a.window.height,
176
+ root: b.root.height - a.root.height,
177
+ visual: b.visual.height - a.visual.height,
178
+ visualTop: b.visual.offsetTop - a.visual.offsetTop,
179
+ navBottom: b.nav.bottom - a.nav.bottom,
180
+ };
181
+ return { changes, maximum: Math.max(...Object.values(changes).map(Math.abs)) };
182
+ }
183
+
95
184
  export function formatOwnerViewportReport(summary) {
96
185
  const lines = [
97
186
  "# Owner wake viewport follow-up", "",
@@ -100,6 +189,9 @@ export function formatOwnerViewportReport(summary) {
100
189
  `Collection failures: ${summary.collectionFailures?.join(", ") || "none"}.`, "",
101
190
  `Large-gap candidates: ${summary.largeGapCycles} cycles; ${summary.repeatedLargeGapCycles} had settled bottom-nav gaps >=100px at least 500ms apart in actual time, no focused input, no app keyboard inset and scale 1.`, "",
102
191
  `Missing/trimmed geometry: ${summary.unavailableSamples}; clients without usable geometry: ${summary.clientsWithoutGeometry}; reported dropped events: ${summary.droppedEvents}; cycles without session context: ${summary.cyclesMissingHeaders}; truncated trace: ${summary.truncated}; truncated session history: ${summary.sessionHeadersTruncated}.`, "",
192
+ `Expanded diagnostics: **${summary.detail?.coverage || "unavailable"}**; ${summary.detail?.samples || 0} detailed samples, ${summary.detail?.omittedSamples || 0} payload-budget omissions, ${summary.detail?.limitedAncestorSamples || 0} bounded ancestor scans, ${summary.detail?.activityCappedCycles || 0} cycles reached the activity cap.`, "",
193
+ `Comparison leads: ${summary.detail?.boundaryChanges || 0} boundary changes >=40px; ${summary.detail?.firstTouchPairs || 0} comparable first-touch pairs (${summary.detail?.firstTouchChanges || 0} changed >=40px); ${summary.detail?.ancestorSamples || 0} samples with ancestor layout effects. These are not diagnoses. See detail.examples in owner-wake-summary.json and the corresponding raw sequence numbers.`, "",
194
+ summary.detail?.note || "Expanded telemetry has not been observed in this window.", "",
103
195
  summary.note, "",
104
196
  "Read owner-wake.json and owner-wake-sessions.json alongside this summary. Compare affected and ordinary cycles within the same client/browser version. Distinguish viewport height changes, viewport panning and nav displacement; correlate the wake performance profile and its upload network tag. Record the evidence and next action in the daily report even when coverage is unavailable.", "",
105
197
  "| Client | Platform | Cycle | Samples | Settled | Large gap | Max root gap | Max visual gap |",
package/lib/admin.js CHANGED
@@ -3013,6 +3013,20 @@ export function parseAdminOperation(options) {
3013
3013
  // Mikey's decisions, any time; each comes with its evidence (approvals.ts).
3014
3014
  return buildApprovalOperation({ action, target, options });
3015
3015
  }
3016
+ if (namespace === "dictionary") {
3017
+ if (action === "korean-status") return readOperation("dictionary.korean-status", "/cli/admin/dictionary/korean/status", { requiresRun: false });
3018
+ if (action === "korean-pending") return readOperation("dictionary.korean-pending", withQuery("/cli/admin/dictionary/korean/pending", {
3019
+ cursor: options.adminCursor || undefined,
3020
+ limit: parseRequiredInteger(options.adminLimit || 30, "--limit", 1, 100),
3021
+ }), { requiresRun: false });
3022
+ if (action === "korean-review" && target) {
3023
+ const raw = readAdminJsonFile(target, "<reviewed-definitions.json>", { maxBytes: 512 * 1024 });
3024
+ const items = Array.isArray(raw) ? raw : raw?.items;
3025
+ if (!Array.isArray(items) || !items.length || items.length > 100) throw cliValidationError("Review 1 to 100 Korean definitions, keeping exported word IDs, revisions and source hashes.");
3026
+ return writeOperation("dictionary.korean-review", "POST", "/cli/admin/dictionary/korean/review", { items, dryRun: Boolean(options.adminDryRun) }, { requiresRun: false, mutates: !options.adminDryRun, timeoutMs: 120000 });
3027
+ }
3028
+ throw cliValidationError("Use: lumine admin dictionary korean-status | korean-pending [--cursor <time:id>] [--limit <1-100>] | korean-review <reviewed-definitions.json> [--dry-run]");
3029
+ }
3016
3030
  if (namespace === "word-games") {
3017
3031
  if (action === "status") return readOperation("word-games.status", "/cli/admin/word-games/status", { requiresRun: false });
3018
3032
  if (action === "candidates") return readOperation("word-games.candidates", withQuery("/cli/admin/word-games/candidates", {
package/lib/commands.js CHANGED
@@ -2474,6 +2474,7 @@ export function parseArgs(args) {
2474
2474
  const booleanFlags = new Set([
2475
2475
  // lumine admin arena import --live
2476
2476
  "live",
2477
+ "additive",
2477
2478
  "remove",
2478
2479
  "fault",
2479
2480
  "noAnnounce",
@@ -2701,6 +2702,7 @@ export function parseArgs(args) {
2701
2702
  // lumine admin arena (admin-arena.js)
2702
2703
  adminArenaAudio: raw.audio ? String(raw.audio) : "",
2703
2704
  adminArenaLive: parseBoolean(raw.live, false),
2705
+ adminArenaAdditive: parseBoolean(raw.additive, false),
2704
2706
  adminArenaItem: raw.item ? String(raw.item) : "",
2705
2707
  adminArenaTo: raw.to ? String(raw.to) : "",
2706
2708
  adminClaimFile: raw.claim ? String(raw.claim) : "",
@@ -2851,6 +2853,10 @@ export function parseArgs(args) {
2851
2853
  adminTarget: raw.target ? String(raw.target) : "",
2852
2854
  adminActions: raw.actions ? String(raw.actions) : "",
2853
2855
  adminDate: raw.date ? String(raw.date) : "",
2856
+ adminCostDate: raw.costDate ? String(raw.costDate) : "",
2857
+ adminPhase: raw.phase ? String(raw.phase) : "",
2858
+ adminMaxSteps: raw.maxSteps ?? "",
2859
+ adminMaxSeconds: raw.maxSeconds ?? "",
2854
2860
  adminDays: raw.days ? String(raw.days) : "",
2855
2861
  adminSpendingMonth: raw.month,
2856
2862
  adminSpendingFrom: raw.from,
@@ -3310,6 +3316,7 @@ export function printHelp() {
3310
3316
  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)
3311
3317
  lumine admin teachers branches suggest | networks | confirm <networkId> --branch <name> [--branch <name>] [--note <text>] | unconfirm <networkId> | set <userId|username> --branch <name> [--note <text>] | clear <userId|username> | apply | list [--branch <name>|none] (which branch each teacher works at, confirmed by you through the academy networks they use: suggest finds the networks, confirm names their branch (repeat --branch for a building branches share), apply gives teachers the branch most of their actions there point at and lists the unclear ones; set wins over networks)
3312
3318
  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)
3319
+ lumine admin dictionary korean-status | korean-pending [--cursor <time:id>] [--limit <1-100>] | korean-review <reviewed-definitions.json> [--dry-run] (shared Korean word-bank definitions; daily review, no paid generation)
3313
3320
  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)
3314
3321
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
3315
3322
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
@@ -3338,7 +3345,7 @@ export function printHelp() {
3338
3345
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
3339
3346
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
3340
3347
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
3341
- lumine admin daily-run gather --output-dir <dir> [--date YYYY-MM-DD] [--json] (all read-only daily reports + bot-output pages + bot-chats.txt)
3348
+ lumine admin daily-run gather --output-dir <dir> [--cost-date YYYY-MM-DD] [--phase priority|evidence|all] [--max-steps 200] [--max-seconds 900] [--resume] [--json] (closed UTC cost day; resumable collection, never automatic review)
3342
3349
  lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] [--json] (reading, canonical effort counts, pending duties)
3343
3350
  lumine admin featured candidates [--days 7] [--after <iso>] [--json] (recent, provably never-Featured Subjects with comment counts)
3344
3351
  lumine admin report serve --file <report.md> [--port <n>] [--open] [--json] (loopback report page with section nav)
package/lib/sdk.js CHANGED
@@ -41,6 +41,9 @@ export const SDK_CLI_METHODS = {
41
41
  "wordMaster.reportIssue": { path: "api/content/word-master/telemetry", scopes: ["content:write"], write: true },
42
42
  "wordMaster.getDailyWords": { path: "api/content/word-master/words", scopes: ["content:read"] },
43
43
  "wordMaster.getWord": { path: "api/content/word-master/details", scopes: ["content:write"], write: true },
44
+ "wordMaster.lookupWord": { path: "api/content/word-master/dictionary", scopes: ["content:read"] },
45
+ "wordMaster.generateKoreanDefinition": { path: "api/content/word-master/korean-definition", scopes: ["content:write"], write: true },
46
+ "wordMaster.getKoreanDefinition": { path: "api/content/word-master/korean-definition-status", scopes: ["content:write"], write: true },
44
47
  "wordMaster.generateEntry": { path: "api/content/word-master/generate-entry", scopes: ["content:write"], write: true },
45
48
  "wordMaster.getEntry": { path: "api/content/word-master/entry-status", scopes: ["content:read"], write: true },
46
49
  "wordMaster.encounterWords": { path: "api/content/word-master/encounter", scopes: ["content:write"], write: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.67",
3
+ "version": "0.3.69",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
3
  Version: 1.73.0
4
- Updated: 2026-10-25
5
- Generated: 2026-10-25T00:59:10.277Z
4
+ Updated: 2026-10-27
5
+ Generated: 2026-10-26T22:37:17.385Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -828,6 +828,21 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
828
828
  - Returns: { word: { id, word, definition, example, wordLevel, canGenerate }, mode }
829
829
  - Open an existing Word Master word’s reviewed definition and example, and add it to the viewer’s book.
830
830
  - Pass wordId or the exact word. Reuses reviewed existing definitions and suitable public AI Card examples. Blank definition/example means missing. Adds this encounter to the book but awards nothing; this is a write for CLI/capture permission purposes. No viewer AI Battery is used.
831
+ - async lookupWord({ wordId, word }) | scopes: content:read
832
+ - Returns: { word: { id, word, wordLevel, definitions: [{ partOfSpeech, definition }], koreanDefinition, koreanDefinitionRevision, koreanDefinitionStatus } }
833
+ - Read an exact word from the shared dictionary without spending Battery or changing the viewer’s book.
834
+ - Pass wordId or an exact English headword (1–64 characters; internal spaces, apostrophes and hyphens supported). Only existing, uncensored word-bank entries are eligible; an unknown spelling returns dictionary_word_not_found (404). This does not create words or perform reverse Korean search.
835
+ - koreanDefinitionStatus is missing, generated (awaiting daily review), or reviewed. definitions contains up to 12 existing English meanings. Reads never generate or charge. Always use current canonical text: daily corrections replace earlier generated text.
836
+ - async generateKoreanDefinition({ wordId, requestId, useAiBattery }) | scopes: content:write
837
+ - Returns: { entry?: { id, wordId, requestId, status, failureCode }, word?, pending?, alreadyComplete?, aiUsagePolicy? }
838
+ - Fill a missing shared Korean definition using the searching member’s AI Battery.
839
+ - Before the intentional search, clearly disclose that a missing Korean definition uses the viewer’s AI Battery; pass useAiBattery:true. Do not generate on page load, resume, keystrokes or background refresh. Cached content costs no Battery. This is shared canonical dictionary content in BOTH preview and live, across all apps; it grants no XP, Coins, mastery or completion receipt.
840
+ - Persist a stable 16–64 character requestId before calling. One reservation per word prevents concurrent duplicate generations. pending:true means another request owns that reservation: use free lookupWord checks, then allow an explicit retry if it remains missing. Never invent a payment or definition from local state.
841
+ - entry.status is pending, generated, ready, failed or superseded. Recover unknown transport outcomes with getKoreanDefinition and the SAME requestId; retrying generate with that same ID is idempotent. A confirmed failed job may be deliberately retried with a new ID. Only successful saved definitions charge Battery, atomically with publication. At most 20 generation attempts per member per UTC day and one active generation per member.
842
+ - async getKoreanDefinition({ requestId }) | scopes: content:write
843
+ - Returns: { entry: { id, wordId, requestId, status, failureCode } | null, word?, aiUsagePolicy? }
844
+ - Recover a Korean-definition request after a lost response without another model call.
845
+ - Scoped to this build and viewer. A generated result is saved and charged once; status never launches another model call. entry:null means no job was found: an explicit retry must reuse the saved requestId. Return values include the current shared definition, including subsequent reviewer corrections.
831
846
  - async generateEntry({ wordId, requestId, useAiBattery }) | scopes: content:write
832
847
  - Returns: { entry?, word?, alreadyComplete?, aiUsagePolicy, mode }
833
848
  - Use viewer AI Battery to fill a missing meaning/example once, with a server-verified contribution receipt.
@@ -1653,6 +1653,27 @@ control contents. Normal wakes are retained for comparison. `--perf` also
1653
1653
  includes the geometry rows. The session's `viewportTrace: 1` marks support;
1654
1654
  older tabs do not acquire it until they load a version containing the change.
1655
1655
 
1656
+ The October 26 expanded recorder keeps that v1 event compatible and adds
1657
+ `layout.v: 2` (`viewportDetail: 2` on the session): body/root bounds, nav padding,
1658
+ orientation and bounded ancestor CSS-effect flags. Mobile load/return cycles
1659
+ also capture one first-touch/300ms pair and at most four debounced
1660
+ resize/scroll/orientation bursts. `signals` and `activity` retain event counts,
1661
+ the cap and skipped/pending events; optional context can explicitly report
1662
+ `omitted: "payload-budget"`. No page content, screenshot or automatic correction
1663
+ is included. Check `work/mobile-bottom-gap-20261026/telemetry/STATUS.md` for
1664
+ actual release status; use the workspace CLI for this expanded summary until
1665
+ an npm release containing it is recorded there.
1666
+
1667
+ The daily summary's `detail` reports expanded coverage, capped/omitted samples,
1668
+ boundary changes, comparable first-touch pairs and ancestor effects, with raw
1669
+ sequence numbers for follow-up. These are comparison leads, never proof of a
1670
+ rendering defect or repair. Normal toolbar movement changes heights too. A
1671
+ normal DOM rectangle cannot disprove a visible compositor-only gap, and reads
1672
+ may flush pending layout. Review new evidence and keep the original roughly
1673
+ 108px gap open until its cause and fix are established. Follow the owner-wake
1674
+ section in `agent-guides/lumine-admin.md`; do not restart the stopped physical
1675
+ phone/Simulator investigation without Mikey's instruction.
1676
+
1656
1677
  `daily-run gather` additionally saves `owner-wake.json`, the retained session
1657
1678
  context in `owner-wake-sessions.json`, and `owner-wake-summary.json` / `.md`.
1658
1679
  Include the coverage and follow-up in the daily report. A >=100px settled gap
@@ -5544,3 +5565,13 @@ Every full daily website-management run adds reviewed Word Master words across a
5544
5565
  ## Word Search monthly champions
5545
5566
 
5546
5567
  Word Search rankings come from server-verified solves, but every board comes from public inputs, so a script playing at a human-looking pace can still rank. A month's champion prize (word-search-champion-v1) opens only after review. From 07:00 UTC on the 2nd, run `lumine admin word-games champions --build <id>`. It lists the last three final months: the current candidate, whether the win is confirmed or collected, and each top-three player's best solves (score, seconds, words, gaps between words, gap variation (near 0 means machine-even spacing), misses, pauses, hints, best streak, days played). Confirm a believable human with `lumine admin word-games confirm <YYYY-MM> <userId> --build <id> --reason "<evidence>"`. Disqualify a scripted winner with `disqualify`; the next eligible player becomes the candidate, so review them too. Removed and fully banned accounts are skipped automatically. Both writes are audited, and neither works after the prize is collected. Until confirmation the winner's game shows that the win is being checked.
5568
+
5569
+ ## Shared Korean dictionary definitions
5570
+
5571
+ Every full management run includes this dedicated review, requested by Mikey October 27. A missing Korean definition is generated on an intentional member search using **that member's AI Battery**; saved definitions are shared and free to reuse. Preview and live apps use the same canonical word bank. These admin commands never invoke a paid model, charge a member, or grant rewards.
5572
+
5573
+ - `lumine admin dictionary korean-status --json`: current generated/reviewed/pending coverage, oldest pending time, outstanding jobs and recent failures.
5574
+ - `lumine admin dictionary korean-pending --limit 100 [--cursor <time:id>] --json`: full text, English reference meanings, exact IDs/revisions/source hashes. Continue through `nextCursor` until null. Read and assess every entry; collection alone is not review.
5575
+ - `lumine admin dictionary korean-review <reviewed.json> --dry-run --json`, then omit `--dry-run` to apply the same reviewed payload within authorized content-maintenance scope. Accepts an array or `{ "items": [...] }`, 1–100 entries, each `{ "wordId": 123, "word": "example", "expectedRevision": 1, "expectedSourceHash": "<exported SHA-256>", "koreanDefinition": "<checked Korean meaning>", "reason": "<what was checked or corrected>" }`. Keep exact exported identity/version fields; supply a reason even when accepting unchanged text. All entries commit together with a mutation audit. Conflicts require re-export and re-review, never blind retry with new revisions.
5576
+
5577
+ Assess common senses, part of speech and inflections, precision and natural Korean for learners. Use your own language judgment and source checks where needed; no paid batch-generation calls. Corrections update the shared word-bank field for all apps; successful reviews mark it reviewed and advance its revision. Future paid generation never overwrites existing text. The final run report includes reviewed/corrected/remaining counts, oldest waiting age, generation/Battery recovery failures, pagination coverage and carryovers. Re-read status after applying. Missing tables/endpoints mean the release is unavailable, not an empty healthy queue.