@stage5/lumine 0.3.68 → 0.3.70

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 | paper-queue [--cursor <id>] [--limit <1-100>] | 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,26 @@ 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 === "paper-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.paper.queue", method: "GET", path: `/cli/admin/arena/paper-queue?cursor=${cursor}&limit=${limit}`, mutates: false, requiresRun: false };
71
+ }
53
72
  if (action === "today-queue") {
54
73
  const cursor = Number(options.adminCursor || 0);
55
74
  const limit = Number(options.adminLimit ?? 50);
@@ -92,7 +111,11 @@ export function buildArenaOperation({ action, options }) {
92
111
  };
93
112
  }
94
113
  if (action === "import") {
95
- const items = readPaper(options.adminDir);
114
+ const additive = Boolean(options.adminArenaAdditive);
115
+ if ((options.adminFile && options.adminDir) || (options.adminFile && !additive)) {
116
+ throw validationError("Use --dir for a complete paper, or --additive with exactly one of --file or --dir for a refill.");
117
+ }
118
+ const items = options.adminFile ? readAdditiveItems(options.adminFile) : readPaper(options.adminDir);
96
119
  const confirm = confirmed(options);
97
120
  return {
98
121
  name: confirm ? "arena.import" : "arena.import.preview",
@@ -103,8 +126,9 @@ export function buildArenaOperation({ action, options }) {
103
126
  live: Boolean(options.adminArenaLive),
104
127
  dryRun: !confirm,
105
128
  ...(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)),
129
+ // Full papers retain their replacement semantics. Additive refills
130
+ // preserve the existing bank without retiring another paper's items.
131
+ ...(!additive ? { paper: path.basename(path.resolve(options.adminDir)) } : {}),
108
132
  },
109
133
  mutates: confirm,
110
134
  requiresRun: false,
@@ -133,6 +157,11 @@ export function buildArenaOperation({ action, options }) {
133
157
  }
134
158
 
135
159
  export function formatArenaResult({ operation, data }) {
160
+ if (operation.name === "arena.paper.queue") return [
161
+ `Checked ${data?.scannedMembers ?? 0} members; ${data?.entries?.length ?? 0} exam tracks are waiting for fresh papers on this page.`,
162
+ ...(data?.entries || []).map(row => `${row.username} (${row.userId}): ${row.examKey} ${row.difficulty}/${row.length}${row.reason === "solo_mastery_paper" ? " — solo mastery ready" : ""}`),
163
+ data?.nextCursor ? `Continue: lumine admin arena paper-queue --cursor ${data.nextCursor} --json` : "End of waiting-member queue.",
164
+ ];
136
165
  if (operation.name === "arena.today.queue") return [
137
166
  ...(data?.members || []).map(m => `#${m.userId} ${m.username}: ${m.ready} questions ready${m.refillDue ? `; refill to ${m.target}` : ""} (preferences v${m.revision})`),
138
167
  data?.nextCursor ? `Continue: lumine admin arena today-queue --cursor ${data.nextCursor} --json` : "End of Today’s Q queue.",
@@ -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,8 @@ 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";
12
+ import { collectDiagnosticPages, diagnosticCollectionSummary } from "./admin-diagnostic-collection.js";
11
13
  export { dailyRunProgress };
12
14
 
13
15
  // Multi-step conveniences for the full daily management run. Each one runs
@@ -22,7 +24,7 @@ const MARKED_ASSET = fileURLToPath(
22
24
 
23
25
  export function runAdmin(
24
26
  args,
25
- { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync } = {},
27
+ { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync, timeoutMs } = {},
26
28
  ) {
27
29
  const startedAt = performance.now();
28
30
  const attemptFile = outputFile
@@ -33,6 +35,7 @@ export function runAdmin(
33
35
  const result = spawnImpl(process.execPath, [LUMINE_BIN, ...full], {
34
36
  encoding: "utf8",
35
37
  maxBuffer: 256 * 1024 * 1024,
38
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
36
39
  env: { ...process.env, ...(extraEnv || {}) },
37
40
  });
38
41
  let parsed = null;
@@ -84,6 +87,9 @@ export function runAdmin(
84
87
  sponsor: ["applications"],
85
88
  "bot-output": [null],
86
89
  "math-lab": ["queue"],
90
+ arena: ["queue", "today-queue", "paper-queue"],
91
+ "word-games": ["status"],
92
+ nonogram: ["status"],
87
93
  subjects: ["candidates"],
88
94
  subject: ["get"],
89
95
  };
@@ -98,8 +104,9 @@ export function runAdmin(
98
104
  result.error ||
99
105
  result.signal ||
100
106
  parsed?.error?.code === "lumine_http_invalid_response";
101
- if (!ok && retryRead && readOnly && transient) {
102
- const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl });
107
+ const remainingMs = timeoutMs == null ? undefined : Math.floor(timeoutMs - (performance.now() - startedAt));
108
+ if (!ok && retryRead && readOnly && transient && (remainingMs == null || remainingMs > 0)) {
109
+ const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl, timeoutMs: remainingMs });
103
110
  return {
104
111
  ...retried,
105
112
  durationMs: Math.round(performance.now() - startedAt),
@@ -166,16 +173,24 @@ export function renderBotOutput({ chatMessages, comments }, file) {
166
173
  fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
167
174
  }
168
175
 
169
- export async function dailyRunGather(options, { run = runAdmin } = {}) {
176
+ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {}) {
170
177
  const startedAt = performance.now();
171
178
  const dir = ensureDir(
172
179
  path.resolve(options.adminOutputDir || options.adminDir || "."),
173
180
  );
174
- const yesterday = options.adminDate || utcDayKey(-1);
181
+ const collection = createDailyCollection(dir, options, { now, clock });
182
+ const yesterday = collection.costDate;
183
+ // Resume keeps the original observation boundary; no page slides its window
184
+ // forward while the operator works through a long diagnostic queue.
185
+ const diagnosticUntil = Math.floor(Date.parse(collection.startedAt) / 1000);
175
186
  const steps = [];
187
+ const failLast = (error) => {
188
+ collection.invalidate(steps.at(-1).name);
189
+ Object.assign(steps.at(-1), { ok: false, error });
190
+ };
191
+ const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|owner-wake|bot-output)/;
176
192
  const step = (name, args, file) => {
177
- const outputFile = path.join(dir, file);
178
- const res = run(args, { outputFile, retryRead: true });
193
+ const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
179
194
  steps.push({
180
195
  name,
181
196
  file,
@@ -183,14 +198,72 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
183
198
  attempts: res.attempts,
184
199
  durationMs: res.durationMs,
185
200
  recovered: Boolean(res.recovered),
186
- ...(res.ok ? {} : { error: res.json?.error || res.stderr }),
201
+ capturedAt: res.capturedAt || null,
202
+ reused: Boolean(res.reused),
203
+ ...(res.deferred ? { deferred: true, reason: res.reason } : res.ok ? {} : { error: res.json?.error || res.stderr }),
187
204
  });
188
205
  return res;
189
206
  };
190
207
  step("energy-budget", ["energy-budget"], "energy-budget.json");
191
208
  step("news", ["news"], "news.json");
192
209
  step("brief", ["brief"], "brief.json");
210
+ step("todos", ["todo", "list"], "todos.json");
211
+ step("escalations", ["escalation", "list"], "escalations.json");
212
+ const paperQueue = { complete: false, pages: 0, waitingTracks: 0 };
213
+ let paperCursor = 0;
214
+ for (;;) {
215
+ paperQueue.pages++;
216
+ const res = step(`arena-paper-page-${paperQueue.pages}`,
217
+ ["arena", "paper-queue", "--cursor", String(paperCursor), "--limit", "100"],
218
+ `arena-paper-queue-${paperQueue.pages}.json`);
219
+ if (!res.ok) break;
220
+ const data = res.json?.data;
221
+ const next = data?.nextCursor;
222
+ if (data?.demandBasis !== "waiting-published-papers-v1" || !Array.isArray(data?.entries) ||
223
+ !(next === null || (Number.isSafeInteger(next) && next > paperCursor))) {
224
+ failLast("Invalid or non-advancing Arena paper-waiting page.");
225
+ break;
226
+ }
227
+ paperQueue.waitingTracks += data.entries.length;
228
+ if (next === null) { paperQueue.complete = true; break; }
229
+ if (paperQueue.pages >= 100) {
230
+ failLast("Arena paper-waiting queue reached its 100-page bound.");
231
+ break;
232
+ }
233
+ paperCursor = next;
234
+ }
193
235
  step("math-lab-queue", ["math-lab", "queue"], "math-lab-queue.json");
236
+ step("arena-queue", ["arena", "queue"], "arena-queue.json");
237
+ step("word-games-status", ["word-games", "status"], "word-games-status.json");
238
+ step("nonogram-status", ["nonogram", "status"], "nonogram-status.json");
239
+ const personalQueue = { complete: false, pages: 0, members: 0 };
240
+ let personalCursor = 0;
241
+ const personalCursors = new Set();
242
+ for (;;) {
243
+ personalQueue.pages++;
244
+ const res = step(`arena-today-page-${personalQueue.pages}`,
245
+ ["arena", "today-queue", "--cursor", String(personalCursor), "--limit", "100"],
246
+ `arena-today-queue-${personalQueue.pages}.json`);
247
+ if (!res.ok) break;
248
+ const data = res.json?.data;
249
+ const rawNext = data?.nextCursor;
250
+ const next = Number(rawNext || 0);
251
+ if (!Array.isArray(data?.members) || !Object.hasOwn(data, "nextCursor") ||
252
+ !(rawNext === null || (typeof rawNext === "number" && Number.isSafeInteger(rawNext)) || (typeof rawNext === "string" && /^\d+$/.test(rawNext))) ||
253
+ !Number.isSafeInteger(next) || next < 0) {
254
+ failLast("Invalid Arena personal queue page.");
255
+ break;
256
+ }
257
+ personalQueue.members += data.members.length;
258
+ if (!next) { personalQueue.complete = true; break; }
259
+ if (personalCursors.has(next) || personalQueue.pages >= 100) {
260
+ collection.invalidate(steps.at(-1).name);
261
+ steps.push({ name: "arena-today-pagination", ok: false, error: "Arena personal queue repeated a cursor or reached its 100-page bound." });
262
+ break;
263
+ }
264
+ personalCursors.add(next);
265
+ personalCursor = next;
266
+ }
194
267
  step("ai-costs-monthly", ["ai-costs", "monthly"], "ai-monthly.json");
195
268
  step("ai-costs-day", ["ai-costs", "day", yesterday], "ai-day.json");
196
269
  step("media-costs", ["media-costs", "monthly"], "media.json");
@@ -199,7 +272,17 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
199
272
  ["reward-activity", "--date", yesterday, "--days", "7"],
200
273
  "reward-activity.json",
201
274
  );
202
- step("reward-diagnostics", ["reward-diagnostics", "--days", "1"], "reward-diagnostics.json");
275
+ const rewardDiagnostics = collectDiagnosticPages({ name: "reward-diagnostics",
276
+ command: ["reward-diagnostics", "--days", "1"], since: diagnosticUntil - 86400,
277
+ until: diagnosticUntil, step, failLast });
278
+ writeAdminJsonFile(path.join(dir, "reward-diagnostics-summary.json"), diagnosticCollectionSummary(rewardDiagnostics), { privateFile: true });
279
+ steps.push({ name: "reward-diagnostics-summary", file: "reward-diagnostics-summary.json", ok: true });
280
+ const jevUntil = Math.floor(diagnosticUntil / 86400) * 86400;
281
+ const jevReview = collectDiagnosticPages({ name: "jev-review", jevReview: true,
282
+ command: ["jev-routing", "review", "--since", String(jevUntil - 86400), "--limit", "20"],
283
+ since: jevUntil - 86400, until: jevUntil, step, failLast });
284
+ writeAdminJsonFile(path.join(dir, "jev-review-summary.json"), diagnosticCollectionSummary(jevReview), { privateFile: true });
285
+ steps.push({ name: "jev-review-summary", file: "jev-review-summary.json", ok: true });
203
286
  // One pending queue covers every Build approval type. Exhaust its cursor;
204
287
  // an empty/malformed receipt must not look like an empty queue.
205
288
  const buildReviews = { complete: false, pages: 0, items: 0 };
@@ -222,10 +305,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
222
305
  !(data.nextCursor === null ||
223
306
  (typeof data.nextCursor === "string" && /^\d+(\.\d\.\d+)?$/.test(data.nextCursor)))
224
307
  ) {
225
- Object.assign(steps[steps.length - 1], {
226
- ok: false,
227
- error: "Invalid pending Build review page: expected filter, items and nextCursor.",
228
- });
308
+ failLast("Invalid pending Build review page: expected filter, items and nextCursor.");
229
309
  break;
230
310
  }
231
311
  buildReviews.items += data.items.length;
@@ -234,6 +314,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
234
314
  break;
235
315
  }
236
316
  if (reviewCursors.has(data.nextCursor) || buildReviews.pages >= 100) {
317
+ collection.invalidate(steps.at(-1).name);
237
318
  steps.push({
238
319
  name: "build-reviews-pagination",
239
320
  ok: false,
@@ -249,20 +330,22 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
249
330
  // management approvals (birthdates, teacher signups, meetups) with their
250
331
  // flags: a required report section (agent-guides/lumine-admin.md)
251
332
  step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
252
- step("todos", ["todo", "list"], "todos.json");
253
333
  step("personal-followups", ["followup", "list"], "personal-followups.json");
254
- step("escalations", ["escalation", "list"], "escalations.json");
255
- const wake = step("owner-wake", ["owner-trace", "--since", "24h", "--wake", "--limit", "2000"], "owner-wake.json");
334
+ const wake = collectDiagnosticPages({ name: "owner-wake", ownerTrace: true,
335
+ command: ["owner-trace", "--since", String(diagnosticUntil - 86400), "--wake", "--limit", "2000"],
336
+ since: diagnosticUntil - 86400, until: diagnosticUntil, step, failLast });
256
337
  // A phone tab can live longer than the daily window. Retain its version,
257
338
  // browser and device context instead of silently calling it unknown.
258
- const wakeSessions = step("owner-wake-sessions", ["owner-trace", "--since", "30d", "--type", "session", "--limit", "2000"], "owner-wake-sessions.json");
339
+ const wakeSessions = collectDiagnosticPages({ name: "owner-wake-sessions", ownerTrace: true,
340
+ command: ["owner-trace", "--since", String(diagnosticUntil - 30 * 86400), "--type", "session", "--limit", "2000"],
341
+ since: diagnosticUntil - 30 * 86400, until: diagnosticUntil, step, failLast });
259
342
  const ownerWake = summarizeOwnerViewport(
260
- wake.ok ? wake.json.data : null,
261
- wakeSessions.ok ? wakeSessions.json.data : null,
343
+ wake,
344
+ wakeSessions,
262
345
  );
263
346
  ownerWake.collectionFailures = [
264
- ...(!wake.ok ? ["owner-wake"] : []),
265
- ...(!wakeSessions.ok ? ["owner-wake-sessions"] : []),
347
+ ...(!wake.complete ? ["owner-wake"] : []),
348
+ ...(!wakeSessions.complete ? ["owner-wake-sessions"] : []),
266
349
  ];
267
350
  if (ownerWake.collectionFailures.length) {
268
351
  ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
@@ -284,6 +367,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
284
367
  let cursor = "";
285
368
  let pages = 0;
286
369
  let botOk = true;
370
+ const botCursors = new Set();
287
371
  for (;;) {
288
372
  pages += 1;
289
373
  const file = `bot-output-${pages}.json`;
@@ -297,12 +381,18 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
297
381
  break;
298
382
  }
299
383
  const data = res.json?.data || {};
384
+ if (!Array.isArray(data.chatMessages) || !Array.isArray(data.comments) || typeof data.pagination?.exhausted !== "boolean") {
385
+ botOk = false;
386
+ failLast("Invalid bot-output page: rows and explicit exhaustion are required.");
387
+ break;
388
+ }
300
389
  chatMessages.push(...(data.chatMessages || []));
301
390
  comments.push(...(data.comments || []));
302
391
  const p = data.pagination || {};
303
392
  if (p.exhausted && !data.chatTruncated && !data.commentsTruncated) break;
304
393
  cursor = p.nextCursor || "";
305
- if (!cursor || pages > 50) {
394
+ if (!cursor || botCursors.has(cursor) || pages >= 50) {
395
+ collection.invalidate(steps.at(-1).name);
306
396
  botOk = false;
307
397
  steps.push({
308
398
  name: "bot-output",
@@ -311,6 +401,7 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
311
401
  });
312
402
  break;
313
403
  }
404
+ botCursors.add(cursor);
314
405
  }
315
406
  if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
316
407
 
@@ -319,12 +410,20 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
319
410
  durationMs: Math.round(performance.now() - startedAt),
320
411
  dir,
321
412
  yesterdayUtc: yesterday,
413
+ costDateUtc: yesterday,
414
+ collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase, warnings: collection.warnings },
415
+ personalQueue,
416
+ paperQueue,
322
417
  buildReviews,
418
+ rewardDiagnostics: diagnosticCollectionSummary(rewardDiagnostics),
419
+ jevReview: diagnosticCollectionSummary(jevReview),
323
420
  ownerWake: {
324
421
  coverage: ownerWake.coverage,
325
422
  truncated: ownerWake.truncated,
326
423
  largeGapCycles: ownerWake.largeGapCycles,
327
424
  reportFile: path.join(dir, "owner-wake-summary.md"),
425
+ collection: diagnosticCollectionSummary(wake),
426
+ sessionCollection: diagnosticCollectionSummary(wakeSessions),
328
427
  },
329
428
  botOutput: {
330
429
  complete: botOk,
@@ -334,7 +433,8 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
334
433
  readFile: botOk ? path.join(dir, "bot-chats.txt") : null,
335
434
  },
336
435
  steps,
337
- failed: steps.filter((s) => !s.ok).map((s) => s.name),
436
+ failed: steps.filter((s) => !s.ok && !s.deferred).map((s) => s.name),
437
+ deferred: steps.filter((s) => s.deferred).map((s) => ({ name: s.name, reason: s.reason })),
338
438
  };
339
439
  fs.writeFileSync(
340
440
  path.join(dir, "gather.json"),
@@ -351,8 +451,8 @@ export async function dailyRunGather(options, { run = runAdmin } = {}) {
351
451
  { privateFile: true },
352
452
  );
353
453
  return {
354
- ok: summary.failed.length === 0,
355
- status: summary.failed.length ? "partial_failure" : "success",
454
+ ok: summary.failed.length === 0 && summary.deferred.length === 0,
455
+ status: summary.failed.length ? "partial_failure" : summary.deferred.length ? "deferred" : "success",
356
456
  data: summary,
357
457
  };
358
458
  }
@@ -450,10 +550,37 @@ nav{position:fixed;top:0;left:0;bottom:0;width:250px;overflow:auto;background:va
450
550
  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
551
  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
552
  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}
553
+ .relay-actions{border:1px solid var(--line);border-radius:12px;padding:16px;margin:20px 0;background:var(--nav)}
554
+ .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}
555
+ .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}
556
+ .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}
557
+ .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}
558
+ .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
559
  @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
560
  </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
561
  <script src="marked.min.js"></script><script>
456
562
  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.'});
563
+ document.getElementById('content').addEventListener('click',async event=>{
564
+ const button=event.target.closest('button[data-copy-prompt]');
565
+ if(!button||button.disabled)return;
566
+ const group=button.closest('.relay-actions');
567
+ const source=document.getElementById(button.dataset.copyPrompt);
568
+ const status=group?.querySelector('[role=status]');
569
+ if(!group||!status||!source?.matches('textarea.relay-prompt-source')||!group.contains(source))return;
570
+ const buttons=Array.from(group.querySelectorAll('button[data-copy-prompt]'));
571
+ buttons.forEach(b=>{b.disabled=true});
572
+ try{
573
+ await navigator.clipboard.writeText(source.value);
574
+ status.textContent=button.textContent.replace(/^Copy: /,'')+' copied. Paste it into the agent chat you choose.';
575
+ }catch{
576
+ source.hidden=false;
577
+ source.focus();
578
+ source.select();
579
+ status.textContent='Automatic copy is unavailable. The prompt is selected below; copy it with your keyboard.';
580
+ }finally{
581
+ buttons.forEach(b=>{b.disabled=false});
582
+ }
583
+ });
457
584
  </script></body></html>`;
458
585
  }
459
586
 
@@ -0,0 +1,106 @@
1
+ // Fixed-window keyset collection. Every page stays a separate acknowledged
2
+ // source; aggregates are conveniences, never proof that the source was read.
3
+ function jevPosition(cursor, since, until) {
4
+ try {
5
+ if (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor)) return null;
6
+ const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
7
+ if (value.version !== 1 || value.kind !== "jev-routing-review" ||
8
+ value.since !== since || value.until !== until || value.includeSynthetic !== false ||
9
+ !Number.isSafeInteger(value.createdAt) || value.createdAt < since || value.createdAt >= until ||
10
+ !Number.isSafeInteger(value.id) || value.id <= 0) return null;
11
+ return value;
12
+ } catch { return null; }
13
+ }
14
+
15
+ export function collectDiagnosticPages({ name, command, since, until, ownerTrace = false, jevReview = false, step, failLast }) {
16
+ const events = [];
17
+ const ids = new Set();
18
+ const cursors = new Set();
19
+ const sourceFiles = [];
20
+ let cursor = null;
21
+ let pages = 0;
22
+ let duplicateRows = 0;
23
+ let complete = false;
24
+ let deferred = false;
25
+ let failure = null;
26
+ const pageLimit = ownerTrace ? 20 : 100;
27
+ for (;;) {
28
+ pages++;
29
+ const args = [...command, "--until", String(until)];
30
+ if (cursor !== null) args.push("--cursor", String(cursor));
31
+ const file = `${name}${pages === 1 ? "" : `-${pages}`}.json`;
32
+ const result = step(`${name}${pages === 1 ? "" : `-page-${pages}`}`, args, file);
33
+ sourceFiles.push(file);
34
+ if (!result.ok) {
35
+ deferred = Boolean(result.deferred);
36
+ failure = deferred ? result.reason : "source-read-failed";
37
+ break;
38
+ }
39
+ const data = result.json?.data;
40
+ const next = data?.nextCursor;
41
+ const rows = jevReview ? data?.items : data?.events;
42
+ const window = jevReview ? data?.collectionWindow : data;
43
+ const validCursor = next === null || (jevReview ? Boolean(jevPosition(next, since, until)) : ownerTrace
44
+ ? typeof next === "string" && /^\d+:\d+$/.test(next)
45
+ : Number.isSafeInteger(next) && next > 0 && (cursor === null || next < cursor));
46
+ if (!Array.isArray(rows) || window?.since !== since || window?.until !== until ||
47
+ !validCursor || data.coverage === "unavailable" ||
48
+ (jevReview && (window.includeSynthetic !== false || data.after !== cursor ||
49
+ data.hasMore !== (next !== null) || data.coverage?.scope !== "page" ||
50
+ data.coverage?.status !== (next === null ? "exhausted" : "partial"))) ||
51
+ (ownerTrace && data.truncated !== (next !== null))) {
52
+ failure = "Invalid diagnostic page or fixed-window coverage; deploy the matching API before collecting.";
53
+ failLast(failure);
54
+ break;
55
+ }
56
+ const idOf = row => jevReview ? row.eventId : row.id;
57
+ if (rows.some(row => !Number.isSafeInteger(idOf(row)) || idOf(row) <= 0)) {
58
+ failure = "Diagnostic page contains an invalid event ID.";
59
+ failLast(failure);
60
+ break;
61
+ }
62
+ if (jevReview && cursor !== null && next !== null) {
63
+ const old = jevPosition(cursor, since, until), current = jevPosition(next, since, until);
64
+ if (!(current.createdAt > old.createdAt || current.createdAt === old.createdAt && current.id > old.id)) {
65
+ failure = "JEV review cursor did not advance.";
66
+ failLast(failure);
67
+ break;
68
+ }
69
+ }
70
+ if (ownerTrace && cursor !== null && next !== null) {
71
+ const [oldTime, oldId] = cursor.split(":").map(Number);
72
+ const [time, id] = next.split(":").map(Number);
73
+ if (!(time < oldTime || time === oldTime && id < oldId)) {
74
+ failure = "Owner trace cursor did not advance.";
75
+ failLast(failure);
76
+ break;
77
+ }
78
+ }
79
+ for (const row of rows) {
80
+ if (ids.has(idOf(row))) { duplicateRows++; continue; }
81
+ ids.add(idOf(row));
82
+ events.push(row);
83
+ }
84
+ if (next === null) { complete = true; break; }
85
+ if (cursors.has(next) || pages >= pageLimit) {
86
+ failure = `Diagnostic pagination repeated a cursor or reached its ${pageLimit}-page bound.`;
87
+ failLast(failure);
88
+ break;
89
+ }
90
+ cursors.add(next);
91
+ cursor = next;
92
+ }
93
+ if (ownerTrace) events.sort((a, b) => a.serverTime - b.serverTime || a.id - b.id);
94
+ return { since, until, complete, pages, rows: events.length, duplicateRows,
95
+ sourceFiles, deferred, failure, events, truncated: !complete };
96
+ }
97
+
98
+ export function diagnosticCollectionSummary(collection) {
99
+ const { events, ...summary } = collection;
100
+ const outcomes = {};
101
+ for (const row of events) {
102
+ const key = `${row.source || row.type || row.route || "unknown"}:${row.operation || ""}:${row.outcome || row.selectedSource || ""}`;
103
+ outcomes[key] = (outcomes[key] || 0) + 1;
104
+ }
105
+ return { ...summary, outcomes, note: "Metadata only. Read the source pages; this summary does not acknowledge review or prove member outcomes." };
106
+ }