@stage5/lumine 0.3.68 → 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] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--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,6 +49,19 @@ 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 }) {
53
66
  if (action === "today-queue") {
54
67
  const cursor = Number(options.adminCursor || 0);
@@ -92,7 +105,11 @@ export function buildArenaOperation({ action, options }) {
92
105
  };
93
106
  }
94
107
  if (action === "import") {
95
- 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);
96
113
  const confirm = confirmed(options);
97
114
  return {
98
115
  name: confirm ? "arena.import" : "arena.import.preview",
@@ -103,8 +120,9 @@ export function buildArenaOperation({ action, options }) {
103
120
  live: Boolean(options.adminArenaLive),
104
121
  dryRun: !confirm,
105
122
  ...(options.adminArenaAudio ? { audioPrefix: options.adminArenaAudio } : {}),
106
- // the folder name is the paper; re-importing it retires its earlier copy
107
- 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)) } : {}),
108
126
  },
109
127
  mutates: confirm,
110
128
  requiresRun: false,
@@ -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.
@@ -284,6 +326,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
284
326
  let cursor = "";
285
327
  let pages = 0;
286
328
  let botOk = true;
329
+ const botCursors = new Set();
287
330
  for (;;) {
288
331
  pages += 1;
289
332
  const file = `bot-output-${pages}.json`;
@@ -297,12 +340,18 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
297
340
  break;
298
341
  }
299
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
+ }
300
348
  chatMessages.push(...(data.chatMessages || []));
301
349
  comments.push(...(data.comments || []));
302
350
  const p = data.pagination || {};
303
351
  if (p.exhausted && !data.chatTruncated && !data.commentsTruncated) break;
304
352
  cursor = p.nextCursor || "";
305
- if (!cursor || pages > 50) {
353
+ if (!cursor || botCursors.has(cursor) || pages >= 50) {
354
+ collection.invalidate(steps.at(-1).name);
306
355
  botOk = false;
307
356
  steps.push({
308
357
  name: "bot-output",
@@ -311,6 +360,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
311
360
  });
312
361
  break;
313
362
  }
363
+ botCursors.add(cursor);
314
364
  }
315
365
  if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
316
366
 
@@ -319,6 +369,9 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
319
369
  durationMs: Math.round(performance.now() - startedAt),
320
370
  dir,
321
371
  yesterdayUtc: yesterday,
372
+ costDateUtc: yesterday,
373
+ collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase, warnings: collection.warnings },
374
+ personalQueue,
322
375
  buildReviews,
323
376
  ownerWake: {
324
377
  coverage: ownerWake.coverage,
@@ -334,7 +387,8 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
334
387
  readFile: botOk ? path.join(dir, "bot-chats.txt") : null,
335
388
  },
336
389
  steps,
337
- 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 })),
338
392
  };
339
393
  fs.writeFileSync(
340
394
  path.join(dir, "gather.json"),
@@ -351,8 +405,8 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
351
405
  { privateFile: true },
352
406
  );
353
407
  return {
354
- ok: summary.failed.length === 0,
355
- 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",
356
410
  data: summary,
357
411
  };
358
412
  }
@@ -450,10 +504,37 @@ nav{position:fixed;top:0;left:0;bottom:0;width:250px;overflow:auto;background:va
450
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)}
451
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}
452
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}
453
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}}
454
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>
455
515
  <script src="marked.min.js"></script><script>
456
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
+ });
457
538
  </script></body></html>`;
458
539
  }
459
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: {
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,
@@ -3339,7 +3345,7 @@ export function printHelp() {
3339
3345
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
3340
3346
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
3341
3347
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
3342
- 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)
3343
3349
  lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] [--json] (reading, canonical effort counts, pending duties)
3344
3350
  lumine admin featured candidates [--days 7] [--after <iso>] [--json] (recent, provably never-Featured Subjects with comment counts)
3345
3351
  lumine admin report serve --file <report.md> [--port <n>] [--open] [--json] (loopback report page with section nav)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.68",
3
+ "version": "0.3.69",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {