@stage5/lumine 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lumine.js CHANGED
@@ -2,11 +2,15 @@
2
2
  import { main } from "../lib/commands.js";
3
3
  import { formatAdminJsonError, isAdminJsonInvocation } from "../lib/admin.js";
4
4
 
5
- main().catch((error) => {
5
+ try {
6
+ // Node exits nonzero when top-level await cannot finish, instead of silently
7
+ // treating an unfinished command with no remaining handles as exit 0.
8
+ await main();
9
+ } catch (error) {
6
10
  if (isAdminJsonInvocation(process.argv.slice(2))) {
7
11
  console.log(JSON.stringify(formatAdminJsonError(error)));
8
12
  } else {
9
13
  console.error(`lumine: ${error?.message || error}`);
10
14
  }
11
15
  process.exitCode = 1;
12
- });
16
+ }
@@ -3,6 +3,11 @@ import path from "path";
3
3
  import http from "http";
4
4
  import { spawn, spawnSync } from "child_process";
5
5
  import { fileURLToPath } from "url";
6
+ import { randomUUID } from "node:crypto";
7
+ import { adminWorkDirectory } from "./admin-work-directory.js";
8
+ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
9
+ import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
10
+ export { dailyRunProgress };
6
11
 
7
12
  // Multi-step conveniences for the full daily management run. Each one runs
8
13
  // the ordinary `lumine admin` commands as child processes, so pagination,
@@ -14,10 +19,16 @@ const MARKED_ASSET = fileURLToPath(
14
19
  new URL("./report-assets/marked.min.js", import.meta.url),
15
20
  );
16
21
 
17
- function runAdmin(args, { outputFile, extraEnv } = {}) {
18
- const full = ["admin", ...args, "--json"];
19
- if (outputFile) full.push("--output", outputFile);
20
- const result = spawnSync(process.execPath, [LUMINE_BIN, ...full], {
22
+ export function runAdmin(
23
+ args,
24
+ { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync } = {},
25
+ ) {
26
+ const attemptFile = outputFile
27
+ ? `${outputFile}.attempt-${randomUUID()}.json`
28
+ : null;
29
+ const full = ["admin", ...args, "--json", "--no-update-check"];
30
+ if (attemptFile) full.push("--output", attemptFile);
31
+ const result = spawnImpl(process.execPath, [LUMINE_BIN, ...full], {
21
32
  encoding: "utf8",
22
33
  maxBuffer: 256 * 1024 * 1024,
23
34
  env: { ...process.env, ...(extraEnv || {}) },
@@ -28,10 +39,72 @@ function runAdmin(args, { outputFile, extraEnv } = {}) {
28
39
  } catch {
29
40
  parsed = null;
30
41
  }
42
+ let error = null;
43
+ if (!parsed || typeof parsed !== "object" || typeof parsed.ok !== "boolean" || typeof parsed.status !== "string") {
44
+ error = {
45
+ code: "CLI_ADMIN_OUTPUT_INVALID",
46
+ message: "The child did not return a valid admin JSON receipt.",
47
+ };
48
+ } else if (attemptFile && result.status === 0 && parsed.ok === true) {
49
+ try {
50
+ const saved = JSON.parse(fs.readFileSync(attemptFile, "utf8"));
51
+ if (JSON.stringify(saved) !== JSON.stringify(parsed))
52
+ throw new Error("mismatch");
53
+ if (parsed.data?.scan?.outputPath === attemptFile)
54
+ parsed.data.scan.outputPath = outputFile;
55
+ writeAdminJsonFile(outputFile, parsed, { privateFile: true });
56
+ fs.unlinkSync(attemptFile);
57
+ } catch {
58
+ error = {
59
+ code: "CLI_ADMIN_OUTPUT_MISSING",
60
+ message: "The child did not save the matching JSON receipt.",
61
+ };
62
+ }
63
+ }
64
+ const ok =
65
+ result.status === 0 && parsed?.ok === true && !error && !result.error;
66
+ const reads = {
67
+ "energy-budget": [null],
68
+ news: [null],
69
+ brief: [null],
70
+ "ai-costs": ["monthly", "day"],
71
+ "media-costs": ["monthly"],
72
+ "reward-activity": [null],
73
+ "reward-review": ["list"],
74
+ todo: ["list"],
75
+ escalation: ["list"],
76
+ featured: ["list", "history"],
77
+ sponsor: ["applications"],
78
+ "bot-output": [null],
79
+ subjects: ["candidates"],
80
+ subject: ["get"],
81
+ };
82
+ const subcommand = args[1]?.startsWith("--") ? null : (args[1] ?? null);
83
+ const readOnly =
84
+ reads[args[0]]?.includes(subcommand) &&
85
+ (args[0] !== "sponsor" || args[2] === "list");
86
+ // Only explicitly opted-in read collections retry an unconfirmed result.
87
+ // Mutation recovery keeps its original idempotency key and lease instead.
88
+ const transient =
89
+ error ||
90
+ result.error ||
91
+ result.signal ||
92
+ parsed?.error?.code === "lumine_http_invalid_response";
93
+ if (!ok && retryRead && readOnly && transient) {
94
+ const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl });
95
+ return {
96
+ ...retried,
97
+ attempts: 2,
98
+ recovered: retried.ok,
99
+ firstError:
100
+ error || parsed?.error || String(result.error || result.signal),
101
+ };
102
+ }
31
103
  return {
32
- ok: result.status === 0 && parsed?.ok !== false,
104
+ ok,
105
+ attempts: 1,
33
106
  status: result.status,
34
- json: parsed,
107
+ json: error ? { ok: false, status: "unconfirmed", error } : parsed,
35
108
  stderr: String(result.stderr || "").slice(-2000),
36
109
  };
37
110
  }
@@ -91,11 +164,13 @@ export async function dailyRunGather(options) {
91
164
  const steps = [];
92
165
  const step = (name, args, file) => {
93
166
  const outputFile = path.join(dir, file);
94
- const res = runAdmin(args, { outputFile });
167
+ const res = runAdmin(args, { outputFile, retryRead: true });
95
168
  steps.push({
96
169
  name,
97
170
  file,
98
171
  ok: res.ok,
172
+ attempts: res.attempts,
173
+ recovered: Boolean(res.recovered),
99
174
  ...(res.ok ? {} : { error: res.json?.error || res.stderr }),
100
175
  });
101
176
  return res;
@@ -175,6 +250,15 @@ export async function dailyRunGather(options) {
175
250
  JSON.stringify(summary, null, 2),
176
251
  { mode: 0o600 },
177
252
  );
253
+ // Collection never acknowledges reading or completes an editorial duty.
254
+ const reviewStateFile = path.join(dir, "review-state.json");
255
+ const previousReviewState = fs.existsSync(reviewStateFile)
256
+ ? readAdminJsonFile(reviewStateFile, "the previous review state") : {};
257
+ writeAdminJsonFile(
258
+ reviewStateFile,
259
+ createDailyReviewState(dir, summary, previousReviewState),
260
+ { privateFile: true },
261
+ );
178
262
  return {
179
263
  ok: summary.failed.length === 0,
180
264
  status: summary.failed.length ? "partial_failure" : "success",
@@ -188,20 +272,26 @@ export async function featuredCandidates(options) {
188
272
  const days = Math.max(1, Math.min(30, Number(options.adminDays || 7)));
189
273
  const after = options.adminAfter || bangkokCutoff(days);
190
274
  const scratch = ensureDir(
191
- path.resolve(options.adminOutputDir || fs.mkdtempSync(path.join(process.env.TMPDIR || "/tmp", "lumine-featured-"))),
275
+ path.resolve(
276
+ options.adminOutputDir ||
277
+ fs.mkdtempSync(path.join(adminWorkDirectory(), "featured-")),
278
+ ),
192
279
  );
193
280
  const subjects = runAdmin(
194
281
  ["subjects", "candidates", "--after", after, "--all"],
195
- { outputFile: path.join(scratch, "subjects.json") },
282
+ { outputFile: path.join(scratch, "subjects.json"), retryRead: true },
196
283
  );
197
284
  if (!subjects.ok) throw new Error(`subjects candidates failed: ${JSON.stringify(subjects.json?.error || subjects.stderr)}`);
198
- const board = runAdmin(["featured", "list"]);
285
+ const board = runAdmin(["featured", "list"], { retryRead: true });
199
286
  if (!board.ok) throw new Error("featured list failed");
200
287
  const onBoard = new Set((board.json.data.subjects || []).map((s) => s.id));
201
288
  const recent = (subjects.json.data.subjects || []).filter((s) => !onBoard.has(s.id));
202
289
  const ids = recent.map((s) => s.id);
203
290
  const history = ids.length
204
- ? runAdmin(["featured", "history", "--subject-ids", ids.join(","), "--all"])
291
+ ? runAdmin(
292
+ ["featured", "history", "--subject-ids", ids.join(","), "--all"],
293
+ { retryRead: true },
294
+ )
205
295
  : { ok: true, json: { data: { subjects: [], coverage: {} } } };
206
296
  if (!history.ok) throw new Error("featured history failed");
207
297
  const never = new Map(
@@ -209,7 +299,14 @@ export async function featuredCandidates(options) {
209
299
  );
210
300
  const candidates = [];
211
301
  for (const s of recent) {
212
- const detail = runAdmin(["subject", "get", String(s.id), "--include-comments"]);
302
+ const detail = runAdmin(
303
+ ["subject", "get", String(s.id), "--include-comments"],
304
+ { retryRead: true },
305
+ );
306
+ if (!detail.ok)
307
+ throw new Error(
308
+ `Subject ${s.id} context is unavailable; candidate review is incomplete.`,
309
+ );
213
310
  const subject = detail.json?.data?.subject || {};
214
311
  const humanComments = (subject.comments || []).filter(
215
312
  (c) => !c.isNotification && ![7587, 8411].includes(Number(c.author?.id)),
@@ -226,6 +323,9 @@ export async function featuredCandidates(options) {
226
323
  hasAttachment: Boolean(s.attachment),
227
324
  description: String(s.description || "").slice(0, 300),
228
325
  commentsIncluded: Boolean(subject.commentsIncluded),
326
+ commentsTruncated: Boolean(subject.commentsTruncated),
327
+ commentCountsComplete:
328
+ Boolean(subject.commentsIncluded) && !subject.commentsTruncated,
229
329
  humanComments: humanComments.length,
230
330
  botComments: (subject.comments || []).filter((c) =>
231
331
  [7587, 8411].includes(Number(c.author?.id)),
@@ -262,13 +362,68 @@ code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.9em}pre
262
362
  @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}}
263
363
  </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>
264
364
  <script src="marked.min.js"></script><script>
265
- fetch('daily-management-report.md',{cache:'no-store'}).then(r=>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)}})});
365
+ 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.'});
266
366
  </script></body></html>`;
267
367
  }
268
368
 
269
369
  // Serves the report Markdown as a browsable page on loopback. Only the page,
270
370
  // its script and the Markdown are reachable; the evidence folder is not.
271
- export async function reportServe(options) {
371
+ export function waitForReportServer(child, timeoutMs = 10000) {
372
+ return new Promise((resolve, reject) => {
373
+ let buf = "";
374
+ let settled = false;
375
+ const finish = (error, url) => {
376
+ if (settled) return;
377
+ settled = true;
378
+ clearTimeout(timer);
379
+ child.stdout.removeListener("data", onData);
380
+ child.removeListener("exit", onExit);
381
+ child.removeListener("error", onError);
382
+ if (error) {
383
+ child.kill();
384
+ reject(error);
385
+ } else resolve(url);
386
+ };
387
+ const onData = (chunk) => {
388
+ buf += chunk;
389
+ if (buf.length > 65536)
390
+ return finish(
391
+ new Error("Report server returned an invalid startup receipt."),
392
+ );
393
+ if (!buf.includes("\n")) return;
394
+ try {
395
+ const parsed = JSON.parse(buf.split("\n")[0]);
396
+ const url = new URL(parsed.data?.url);
397
+ if (
398
+ parsed.ok !== true ||
399
+ parsed.status !== "success" ||
400
+ url.protocol !== "http:" ||
401
+ url.hostname !== "127.0.0.1"
402
+ )
403
+ throw new Error("invalid receipt");
404
+ finish(null, url.href);
405
+ } catch {
406
+ finish(new Error("Report server returned an invalid startup receipt."));
407
+ }
408
+ };
409
+ const onExit = (code) =>
410
+ finish(new Error(`Report server exited (${code}) before it was ready.`));
411
+ const onError = (error) => finish(error);
412
+ const timer = setTimeout(
413
+ () =>
414
+ finish(new Error("Report server did not start before its deadline.")),
415
+ timeoutMs,
416
+ );
417
+ child.stdout.on("data", onData);
418
+ child.once("exit", onExit);
419
+ child.once("error", onError);
420
+ });
421
+ }
422
+
423
+ export async function reportServe(
424
+ options,
425
+ { spawnImpl = spawn, openImpl = spawnSync } = {},
426
+ ) {
272
427
  const file = path.resolve(options.adminFile || "daily-management-report.md");
273
428
  if (!fs.existsSync(file)) throw new Error(`Report not found: ${file}`);
274
429
  const title =
@@ -295,48 +450,65 @@ export async function reportServe(options) {
295
450
  res.writeHead(404).end("not found");
296
451
  return;
297
452
  }
298
- const { type, body } = route();
299
- res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
300
- res.end(body);
453
+ try {
454
+ const { type, body } = route();
455
+ res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
456
+ res.end(body);
457
+ } catch {
458
+ res.writeHead(503).end("report source unavailable");
459
+ }
460
+ });
461
+ await new Promise((resolve, reject) => {
462
+ server.once("error", reject);
463
+ server.listen(port, "127.0.0.1", resolve);
301
464
  });
302
- await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
303
465
  const url = `http://127.0.0.1:${server.address().port}/`;
304
466
  console.log(JSON.stringify({ ok: true, status: "success", data: { url, file } }));
305
467
  return new Promise(() => {});
306
468
  }
307
469
  // Detach a foreground copy of this command so the page outlives the CLI.
308
- const child = spawn(
470
+ const child = spawnImpl(
309
471
  process.execPath,
310
- [LUMINE_BIN, "admin", "report", "serve", "--file", file, "--port", String(port), "--foreground"],
472
+ [
473
+ LUMINE_BIN,
474
+ "admin",
475
+ "report",
476
+ "serve",
477
+ "--file",
478
+ file,
479
+ "--port",
480
+ String(port),
481
+ "--foreground",
482
+ "--json",
483
+ "--no-update-check",
484
+ ],
311
485
  { detached: true, stdio: ["ignore", "pipe", "ignore"] },
312
486
  );
313
- const url = await new Promise((resolve, reject) => {
314
- let buf = "";
315
- const timer = setTimeout(() => reject(new Error("report server did not start")), 10000);
316
- child.stdout.on("data", (chunk) => {
317
- buf += chunk;
318
- const line = buf.split("\n")[0];
319
- try {
320
- const parsed = JSON.parse(line);
321
- clearTimeout(timer);
322
- resolve(parsed.data.url);
323
- } catch {
324
- /* wait for the full line */
325
- }
326
- });
327
- child.on("exit", (code) => {
328
- clearTimeout(timer);
329
- reject(new Error(`report server exited (${code})`));
330
- });
331
- });
487
+ const url = await waitForReportServer(child);
332
488
  child.stdout.destroy();
333
489
  child.unref();
334
- if (options.adminOpen) {
335
- spawnSync("open", ["-a", "Google Chrome", url]);
336
- }
490
+ const opened = options.adminOpen
491
+ ? openImpl("open", ["-a", "Google Chrome", url])
492
+ : null;
337
493
  return {
338
494
  ok: true,
339
495
  status: "success",
340
- data: { url, file, pid: child.pid, opened: Boolean(options.adminOpen) },
496
+ data: {
497
+ url,
498
+ file,
499
+ pid: child.pid,
500
+ opened: opened?.status === 0,
501
+ ...(opened && opened.status !== 0
502
+ ? {
503
+ openError:
504
+ "Chrome did not accept the open request; the report server is available.",
505
+ }
506
+ : {}),
507
+ browserVerification: {
508
+ render: "pending",
509
+ navigation: "pending",
510
+ source: "pending",
511
+ },
512
+ },
341
513
  };
342
514
  }
@@ -0,0 +1,191 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
5
+
6
+ function fingerprint(dir, file) {
7
+ const absolute = path.resolve(dir, file);
8
+ const relative = path.relative(dir, absolute);
9
+ if (
10
+ !relative ||
11
+ relative.startsWith(`..${path.sep}`) ||
12
+ path.isAbsolute(relative)
13
+ ) {
14
+ throw new Error("Duty evidence must name a file inside the run directory.");
15
+ }
16
+ const bytes = fs.readFileSync(absolute);
17
+ return {
18
+ file: relative,
19
+ bytes: bytes.length,
20
+ sha256: createHash("sha256").update(bytes).digest("hex"),
21
+ };
22
+ }
23
+
24
+ export function createDailyReviewState(dir, gather, previous = {}) {
25
+ if (previous.protocol != null && previous.protocol !== 1) throw new Error("Unsupported previous duty evidence protocol.");
26
+ const sources = gather.steps
27
+ .filter((step) => step.file)
28
+ .map((step) => ({
29
+ ...(step.ok ? fingerprint(dir, step.file) : { file: step.file }),
30
+ fetched: step.ok,
31
+ read: false,
32
+ }));
33
+ if (gather.botOutput.complete)
34
+ sources.push({
35
+ ...fingerprint(dir, "bot-chats.txt"),
36
+ fetched: true,
37
+ read: false,
38
+ });
39
+ for (const source of previous.sources || []) {
40
+ if (!sources.some((current) => current.file === source.file)) sources.push({ ...source, retained: true, read: false });
41
+ }
42
+ return {
43
+ protocol: 1,
44
+ scope: previous.scope || "collection",
45
+ sources,
46
+ requiredDuties: [...new Set(["effort-levels", "report-browser", ...(previous.requiredDuties || [])])],
47
+ 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.",
48
+ };
49
+ }
50
+
51
+ // Local operator acknowledgments are separate from canonical server mutations.
52
+ // Hashes bind an acknowledgment to the bytes read; they cannot prove reading.
53
+ export function summarizeDailyProgress(dir, state, evidence = {}) {
54
+ if (
55
+ state.protocol !== 1 ||
56
+ (evidence.protocol != null && evidence.protocol !== 1)
57
+ )
58
+ throw new Error("Unsupported duty evidence protocol.");
59
+ const acknowledgments = evidence.reading || [];
60
+ const sources = (state.sources || []).map((source) => {
61
+ if (!source.fetched)
62
+ return { ...source, read: false, status: "unavailable" };
63
+ let current;
64
+ try {
65
+ current = fingerprint(dir, source.file);
66
+ } catch {
67
+ return { ...source, read: false, status: "unavailable" };
68
+ }
69
+ const acknowledgment = acknowledgments.find(
70
+ (item) =>
71
+ item.file === source.file &&
72
+ item.sha256 === current.sha256 &&
73
+ item.bytes === current.bytes &&
74
+ Number.isFinite(Date.parse(item.readAt)) &&
75
+ item.complete === true,
76
+ );
77
+ return {
78
+ ...source,
79
+ ...current,
80
+ read: Boolean(acknowledgment),
81
+ status: acknowledgment ? "read" : "fetched",
82
+ readAt: acknowledgment?.readAt || null,
83
+ };
84
+ });
85
+ const verifyReference = (reference) => {
86
+ if (!reference?.file || !reference.sha256) return false;
87
+ try {
88
+ const current = fingerprint(dir, reference.file);
89
+ return (
90
+ current.sha256 === reference.sha256 && current.bytes === reference.bytes
91
+ );
92
+ } catch {
93
+ return false;
94
+ }
95
+ };
96
+ let canonicalRun = null;
97
+ let effortAssignments = null;
98
+ if (verifyReference(evidence.runReport)) {
99
+ const receipt = readAdminJsonFile(
100
+ path.resolve(dir, evidence.runReport.file),
101
+ "the run report",
102
+ );
103
+ const report =
104
+ receipt.ok === true && receipt.status === "success"
105
+ ? receipt.data?.report
106
+ : null;
107
+ if (
108
+ report &&
109
+ Number(report.run?.id) === Number(evidence.runId) &&
110
+ Array.isArray(report.mutations?.byAction)
111
+ ) {
112
+ canonicalRun = { id: report.run.id, status: report.run.status };
113
+ // Absence is zero only in a confirmed complete report, never in a failed fetch.
114
+ const row = report.mutations.byAction.find(
115
+ (item) => item.action === "subject.effort.set",
116
+ );
117
+ effortAssignments = row || {
118
+ action: "subject.effort.set",
119
+ attempts: 0,
120
+ completed: 0,
121
+ changed: 0,
122
+ failed: 0,
123
+ pending: 0,
124
+ };
125
+ }
126
+ }
127
+ const required = [
128
+ ...new Set([
129
+ ...(state.requiredDuties || []),
130
+ ...(evidence.requiredDuties || []),
131
+ ]),
132
+ ];
133
+ const duties = required.map((id) => {
134
+ const duty = (evidence.duties || []).find((item) => item.id === id);
135
+ const confirmed =
136
+ duty?.status === "completed" &&
137
+ duty.evidence?.length &&
138
+ duty.evidence.every(verifyReference) &&
139
+ (id !== "effort-levels" || effortAssignments !== null);
140
+ return {
141
+ id,
142
+ status: confirmed
143
+ ? "completed"
144
+ : duty?.status === "unavailable"
145
+ ? "unavailable"
146
+ : "pending",
147
+ note: duty?.note || null,
148
+ };
149
+ });
150
+ const incomplete = duties
151
+ .filter((item) => item.status !== "completed")
152
+ .map((item) => item.id);
153
+ const complete =
154
+ sources.every((item) => item.read) && incomplete.length === 0;
155
+ return {
156
+ scope: state.scope || "declared-duties",
157
+ coverageBasis: "declared sources and duties only",
158
+ canonicalRun,
159
+ effortAssignments,
160
+ sources,
161
+ duties,
162
+ complete,
163
+ reading: {
164
+ fetched: sources.filter((item) => item.fetched).length,
165
+ read: sources.filter((item) => item.read).length,
166
+ pending: sources.filter((item) => !item.read).map((item) => item.file),
167
+ },
168
+ incompleteDuties: incomplete,
169
+ note: "Run lease completion, reading acknowledgments, effort mutations, and browser verification are independent. A completed lease does not close pending review duties.",
170
+ };
171
+ }
172
+
173
+ export async function dailyRunProgress(options) {
174
+ const dir = path.resolve(options.adminDir || options.adminOutputDir || ".");
175
+ const state = readAdminJsonFile(
176
+ path.join(dir, "review-state.json"),
177
+ "the review state",
178
+ );
179
+ const evidence = options.adminFile
180
+ ? readAdminJsonFile(path.resolve(options.adminFile), "the duty evidence")
181
+ : {};
182
+ const progress = summarizeDailyProgress(dir, state, evidence);
183
+ writeAdminJsonFile(path.join(dir, "progress.json"), progress, {
184
+ privateFile: true,
185
+ });
186
+ return {
187
+ ok: true,
188
+ status: progress.complete ? "success" : "pending",
189
+ data: progress,
190
+ };
191
+ }
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { adminWorkDirectory } from "./admin-work-directory.js";
5
+ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
6
+
7
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
8
+
9
+ // Persist before sending. A killed process or missing HTTP receipt reuses the
10
+ // same request key in the same API/account/run/session and exact operation.
11
+ // Only a canonical receipt closes it; there is no automatic write retry.
12
+ export function prepareAdminMutationIntent({
13
+ operation,
14
+ apiUrl,
15
+ authority,
16
+ idempotencyKey,
17
+ directory = path.join(adminWorkDirectory(), "requests"),
18
+ }) {
19
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
20
+ fs.chmodSync(directory, 0o700);
21
+ const fingerprint = digest(
22
+ JSON.stringify({
23
+ apiUrl: String(apiUrl).replace(/\/$/, ""),
24
+ authority,
25
+ method: operation.method,
26
+ path: operation.path,
27
+ body: operation.body,
28
+ }),
29
+ );
30
+ const suffix = idempotencyKey ? `-${digest(idempotencyKey)}` : "";
31
+ const file = path.join(directory, `${fingerprint}${suffix}.pending.json`);
32
+ let intent;
33
+ try {
34
+ const fd = fs.openSync(file, "wx", 0o600);
35
+ try {
36
+ intent = {
37
+ kind: "lumine-admin-mutation-intent",
38
+ fingerprint,
39
+ operation: operation.name,
40
+ requestId: idempotencyKey || `cli:${randomUUID()}`,
41
+ createdAt: new Date().toISOString(),
42
+ };
43
+ fs.writeFileSync(fd, JSON.stringify(intent));
44
+ fs.fsyncSync(fd);
45
+ } finally {
46
+ fs.closeSync(fd);
47
+ }
48
+ } catch (error) {
49
+ if (error.code !== "EEXIST") throw error;
50
+ intent = readAdminJsonFile(file, "the pending mutation intent");
51
+ if (
52
+ intent.kind !== "lumine-admin-mutation-intent" ||
53
+ intent.fingerprint !== fingerprint ||
54
+ !/^[A-Za-z0-9._:-]{8,80}$/.test(intent.requestId || "")
55
+ ) {
56
+ throw new Error(
57
+ `The pending mutation intent is unconfirmed: ${file}. Inspect its original request key before retrying.`,
58
+ );
59
+ }
60
+ }
61
+ const receiptPath = path.join(
62
+ directory,
63
+ `${digest(intent.requestId)}.receipt.json`,
64
+ );
65
+ return {
66
+ requestId: intent.requestId,
67
+ confirm(receipt) {
68
+ if (receipt?.ok !== true || typeof receipt.status !== "string") {
69
+ const error = new Error(
70
+ "The API did not return a canonical admin receipt. The mutation outcome is unconfirmed.",
71
+ );
72
+ error.code = "CLI_ADMIN_RECEIPT_INVALID";
73
+ throw error;
74
+ }
75
+ writeAdminJsonFile(receiptPath, receipt, { privateFile: true });
76
+ },
77
+ delivered() {
78
+ fs.rmSync(file, { force: true });
79
+ },
80
+ file,
81
+ receiptPath,
82
+ };
83
+ }
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
 
20
20
  import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
21
21
  import { requestJson } from "./http.js";
22
+ import { adminWorkDirectory } from "./admin-work-directory.js";
22
23
 
23
24
  const SESSION_SCHEMA_VERSION = 1;
24
25
  const MAX_SESSION_BYTES = 2 * 1024 * 1024;
@@ -127,7 +128,7 @@ function startIntentPath(options) {
127
128
  "runtime-log-review-start-intent.json",
128
129
  );
129
130
  }
130
- // Shared temp directories are per host, not per operator login; key the
131
+ // Persistent fallback files are also keyed by operator account; key the
131
132
  // fallback to this account so two operator accounts never replay each
132
133
  // other's start key. Two shells of the same account still share it, which
133
134
  // is why a replayed start rotates the lease server-side.
@@ -136,7 +137,7 @@ function startIntentPath(options) {
136
137
  .digest("hex")
137
138
  .slice(0, 16);
138
139
  return path.join(
139
- os.tmpdir(),
140
+ adminWorkDirectory(),
140
141
  `lumine-admin-runtime-log-review-start-intent-${account}.json`,
141
142
  );
142
143
  }
@@ -178,7 +179,10 @@ function createSession({ options, apiUrl, review }) {
178
179
  ),
179
180
  )
180
181
  : mkdtempSync(
181
- path.join(os.tmpdir(), `lumine-admin-runtime-log-review-${review.id}-`),
182
+ path.join(
183
+ adminWorkDirectory(),
184
+ `lumine-admin-runtime-log-review-${review.id}-`,
185
+ ),
182
186
  );
183
187
  chmodSync(outputDirectory, 0o700);
184
188
  const sessionPath = path.resolve(
@@ -0,0 +1,11 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // Recovery keys, leases and evidence survive OS temp cleanup. Explicit output
5
+ // directories, checkpoints and review-session paths still take priority.
6
+ export function adminWorkDirectory() {
7
+ const directory = path.resolve(process.cwd(), "work", "lumine-admin");
8
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
9
+ fs.chmodSync(directory, 0o700);
10
+ return directory;
11
+ }
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
3
  chmodSync,
4
+ constants,
5
+ copyFileSync,
4
6
  closeSync,
5
7
  createReadStream,
6
8
  fchmodSync,
@@ -12,6 +14,7 @@ import {
12
14
  readFileSync,
13
15
  readSync,
14
16
  renameSync,
17
+ statSync,
15
18
  truncateSync,
16
19
  unlinkSync,
17
20
  writeFileSync,
@@ -22,6 +25,7 @@ import readline from "node:readline";
22
25
 
23
26
  import { writeAdminJsonFile, readAdminJsonFile } from "./admin-news.js";
24
27
  import { requestJson } from "./http.js";
28
+ import { adminWorkDirectory } from "./admin-work-directory.js";
25
29
 
26
30
  const MAX_BATCH_TARGET_FILE_BYTES = 2 * 1024 * 1024;
27
31
  const MAX_BATCH_TARGETS = 20_000;
@@ -80,13 +84,14 @@ function defaultCheckpointPath({
80
84
  runId,
81
85
  operationName,
82
86
  operationFingerprint = "",
87
+ directory = adminWorkDirectory(),
83
88
  }) {
84
89
  const safeName = String(operationName).replace(/[^a-z0-9_.-]+/gi, "-");
85
90
  const fingerprintSuffix = operationFingerprint
86
91
  ? `-${operationFingerprint}`
87
92
  : "";
88
93
  return path.join(
89
- os.tmpdir(),
94
+ directory,
90
95
  `lumine-admin-run-${runId}-${safeName}${fingerprintSuffix}.json`,
91
96
  );
92
97
  }
@@ -116,10 +121,91 @@ function resolveAutomaticCheckpointPath({
116
121
  operationName: operation.name,
117
122
  });
118
123
  if (pathEntryExists(legacyPath)) return legacyPath;
124
+ // Read old OS-temp checkpoints once, then resume only from durable files.
125
+ // The normal scan validates the saved scope and spool hash before use.
126
+ for (const fingerprint of [operationFingerprint, ""]) {
127
+ const old = defaultCheckpointPath({
128
+ runId,
129
+ operationName: operation.name,
130
+ operationFingerprint: fingerprint,
131
+ directory: os.tmpdir(),
132
+ });
133
+ if (pathEntryExists(old)) {
134
+ migrateLegacyCheckpoint({ old, keyedPath, operationFingerprint });
135
+ return keyedPath;
136
+ }
137
+ }
119
138
  }
120
139
  return keyedPath;
121
140
  }
122
141
 
142
+ export function migrateLegacyCheckpoint({
143
+ old,
144
+ keyedPath,
145
+ operationFingerprint,
146
+ }) {
147
+ const lock = acquireCheckpointLock(keyedPath, operationFingerprint);
148
+ let copiedSpool = null;
149
+ let savedCheckpoint = false;
150
+ const assertSourceIdle = () => {
151
+ if (
152
+ (pathEntryExists(`${old}.lock`) &&
153
+ !checkpointLockOwnerIsDead(`${old}.lock`)) ||
154
+ pathEntryExists(`${old}.lock.reclaim`)
155
+ ) {
156
+ throw validationError(
157
+ "The legacy checkpoint still has a scan lock. Finish its owner before migrating it.",
158
+ );
159
+ }
160
+ };
161
+ try {
162
+ // A concurrent resumer may already have completed this migration.
163
+ if (pathEntryExists(keyedPath)) return;
164
+ assertSourceIdle();
165
+ const before = statSync(old);
166
+ const saved = readAdminJsonFile(old, "the legacy checkpoint", {
167
+ maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
168
+ });
169
+ if (saved.spoolPath) {
170
+ if (
171
+ !candidateSpoolBelongsToCheckpoint({
172
+ checkpointPath: old,
173
+ spoolPath: saved.spoolPath,
174
+ })
175
+ )
176
+ throw validationError(
177
+ "The legacy candidate spool does not belong to its checkpoint.",
178
+ );
179
+ copiedSpool = candidateSpoolPath(keyedPath);
180
+ copyFileSync(saved.spoolPath, copiedSpool, constants.COPYFILE_EXCL);
181
+ chmodSync(copiedSpool, 0o600);
182
+ saved.spoolPath = copiedSpool;
183
+ }
184
+ assertSourceIdle();
185
+ const after = statSync(old);
186
+ if (
187
+ before.ino !== after.ino ||
188
+ before.size !== after.size ||
189
+ before.mtimeMs !== after.mtimeMs
190
+ ) {
191
+ throw validationError(
192
+ "The legacy checkpoint changed during migration. Retry after its owner finishes.",
193
+ );
194
+ }
195
+ writeAdminJsonFile(keyedPath, saved, { privateFile: true });
196
+ savedCheckpoint = true;
197
+ } finally {
198
+ if (copiedSpool && !savedCheckpoint) {
199
+ try {
200
+ unlinkSync(copiedSpool);
201
+ } catch {
202
+ /* preserve the original migration error */
203
+ }
204
+ }
205
+ releaseCheckpointLock(lock);
206
+ }
207
+ }
208
+
123
209
  function pathEntryExists(filePath) {
124
210
  try {
125
211
  lstatSync(filePath);
@@ -209,27 +295,7 @@ function removeDeadCheckpointLock(lockPath) {
209
295
  // replacement lock created after another process removed the stale one.
210
296
  linkSync(lockPath, reclaimPath);
211
297
  ownsReclaim = true;
212
- const fileState = lstatSync(reclaimPath);
213
- if (!fileState.isFile() || fileState.isSymbolicLink()) return false;
214
- if (fileState.size > 16 * 1024) return false;
215
- const state = JSON.parse(readFileSync(reclaimPath, "utf8"));
216
- if (
217
- state?.kind !== "admin-pagination-lock" ||
218
- !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
219
- String(state?.token || ""),
220
- ) ||
221
- !/^[a-f0-9]{64}$/i.test(String(state?.operationFingerprint || ""))
222
- ) {
223
- return false;
224
- }
225
- const pid = Number(state?.pid);
226
- if (!Number.isSafeInteger(pid) || pid <= 0) return false;
227
- try {
228
- process.kill(pid, 0);
229
- return false;
230
- } catch (error) {
231
- if (error?.code !== "ESRCH") return false;
232
- }
298
+ if (!checkpointLockOwnerIsDead(reclaimPath)) return false;
233
299
  const claimedState = lstatSync(reclaimPath);
234
300
  const currentState = lstatSync(lockPath);
235
301
  if (
@@ -253,6 +319,34 @@ function removeDeadCheckpointLock(lockPath) {
253
319
  }
254
320
  }
255
321
 
322
+ // Read-only liveness proof also permits migration from a crashed legacy owner
323
+ // without editing its old temporary files. Unknown/live owners still stop it.
324
+ function checkpointLockOwnerIsDead(lockPath) {
325
+ try {
326
+ const fileState = lstatSync(lockPath);
327
+ if (!fileState.isFile() || fileState.isSymbolicLink() || fileState.size > 16 * 1024)
328
+ return false;
329
+ const state = JSON.parse(readFileSync(lockPath, "utf8"));
330
+ if (
331
+ state?.kind !== "admin-pagination-lock" ||
332
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
333
+ String(state?.token || ""),
334
+ ) ||
335
+ !/^[a-f0-9]{64}$/i.test(String(state?.operationFingerprint || ""))
336
+ ) return false;
337
+ const pid = Number(state.pid);
338
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
339
+ try {
340
+ process.kill(pid, 0);
341
+ return false;
342
+ } catch (error) {
343
+ return error?.code === "ESRCH";
344
+ }
345
+ } catch {
346
+ return false;
347
+ }
348
+ }
349
+
256
350
  export function releaseCheckpointLock(lock) {
257
351
  try {
258
352
  const fileState = lstatSync(lock.lockPath);
package/lib/admin.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import path from "node:path";
13
13
  import { assertAuthScope, resolveAuth } from "./auth.js";
14
14
  import { requestJson } from "./http.js";
15
+ import { prepareAdminMutationIntent } from "./admin-request-intents.js";
15
16
  import { formatBytes, parseStorageSizeBytes } from "./util.js";
16
17
  import {
17
18
  extractNewsClaim,
@@ -34,6 +35,7 @@ import {
34
35
  import { runAdminRuntimeLogWorkflow } from "./admin-runtime-logs.js";
35
36
  import {
36
37
  dailyRunGather,
38
+ dailyRunProgress,
37
39
  featuredCandidates,
38
40
  reportServe,
39
41
  } from "./admin-daily.js";
@@ -591,11 +593,13 @@ export async function adminCommand(options) {
591
593
  const composite =
592
594
  namespace === "daily-run" && action === "gather"
593
595
  ? dailyRunGather
594
- : namespace === "featured" && action === "candidates"
595
- ? featuredCandidates
596
- : namespace === "report" && action === "serve"
597
- ? reportServe
598
- : null;
596
+ : namespace === "daily-run" && action === "progress"
597
+ ? dailyRunProgress
598
+ : namespace === "featured" && action === "candidates"
599
+ ? featuredCandidates
600
+ : namespace === "report" && action === "serve"
601
+ ? reportServe
602
+ : null;
599
603
  if (composite) {
600
604
  const result = await composite(options);
601
605
  if (options.adminOutput) {
@@ -631,7 +635,7 @@ export async function adminCommand(options) {
631
635
  ? parseRecommendationContentTypes(options.adminContentTypes)
632
636
  : null;
633
637
  const auth = await resolveAuth(options);
634
- await assertAuthScope({
638
+ const authSession = await assertAuthScope({
635
639
  options,
636
640
  auth,
637
641
  scope: operation.mutates ? "build:write" : "build:read",
@@ -755,12 +759,31 @@ export async function adminCommand(options) {
755
759
  }
756
760
  }
757
761
  }
762
+ const mutationIntent =
763
+ operation.mutates &&
764
+ !operation.featuredWorkflow &&
765
+ operation.name !== "post.skip-batch" &&
766
+ !operation.name.startsWith("runtime-logs.")
767
+ ? prepareAdminMutationIntent({
768
+ operation,
769
+ apiUrl: options.apiUrl,
770
+ authority: {
771
+ account: canonicalAdminAccount(authSession),
772
+ runId,
773
+ correctionSessionId,
774
+ commentSessionId,
775
+ },
776
+ idempotencyKey: options.idempotencyKey,
777
+ })
778
+ : null;
758
779
  const requestId =
759
- options.idempotencyKey || (operation.mutates ? `cli:${randomUUID()}` : "");
780
+ mutationIntent?.requestId ||
781
+ options.idempotencyKey ||
782
+ (operation.mutates ? `cli:${randomUUID()}` : "");
760
783
  let result;
761
784
  try {
762
- const fetchOperation = async (requestPath = operation.path, signal) =>
763
- requestJson({
785
+ const fetchOperation = async (requestPath = operation.path, signal) => {
786
+ const receipt = await requestJson({
764
787
  method: operation.method,
765
788
  url: `${options.apiUrl}${requestPath}`,
766
789
  authToken: auth.token,
@@ -784,6 +807,18 @@ export async function adminCommand(options) {
784
807
  timeoutMs: operation.timeoutMs || options.timeoutMs,
785
808
  signal,
786
809
  });
810
+ if (
811
+ typeof receipt.ok !== "boolean" ||
812
+ typeof receipt.status !== "string"
813
+ ) {
814
+ const error = new Error(
815
+ "The API did not return a valid admin receipt. The request outcome is unconfirmed.",
816
+ );
817
+ error.code = "CLI_ADMIN_RECEIPT_INVALID";
818
+ throw error;
819
+ }
820
+ return receipt;
821
+ };
787
822
  const transformResult = (rawResult) =>
788
823
  transformAdminResult({
789
824
  operation,
@@ -859,6 +894,7 @@ export async function adminCommand(options) {
859
894
  }
860
895
  result = transformResult(await fetchOperation());
861
896
  }
897
+ mutationIntent?.confirm(result);
862
898
  } catch (error) {
863
899
  if (operation.name === "runtime.evidence" && error.status === 404) {
864
900
  error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
@@ -1003,7 +1039,19 @@ export async function adminCommand(options) {
1003
1039
  data: { ...(result.data || {}), artifacts },
1004
1040
  };
1005
1041
  }
1006
- return finishAdminOutput({ options, operation, result });
1042
+ const output = await finishAdminOutput({ options, operation, result });
1043
+ mutationIntent?.delivered();
1044
+ return output;
1045
+ }
1046
+
1047
+ function canonicalAdminAccount(session) {
1048
+ const userId = Number(session?.userId);
1049
+ if (!Number.isSafeInteger(userId) || userId <= 0) {
1050
+ throw cliValidationError(
1051
+ "The API did not confirm the signed-in account. The mutation was not sent.",
1052
+ );
1053
+ }
1054
+ return userId;
1007
1055
  }
1008
1056
 
1009
1057
  function transformAdminResult({
package/lib/auth.js CHANGED
@@ -198,6 +198,7 @@ export async function assertAuthScope({ options, auth, scope }) {
198
198
  `Saved login is missing ${scope}. Run \`lumine login\` again to grant it.`,
199
199
  );
200
200
  }
201
+ return session;
201
202
  }
202
203
 
203
204
  export function isMissingLoginError(error) {
@@ -8,11 +8,11 @@ import {
8
8
  statSync,
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
- import os from "node:os";
12
11
  import path from "node:path";
13
12
 
14
13
  import { requestJson } from "./http.js";
15
14
  import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
15
+ import { adminWorkDirectory } from "./admin-work-directory.js";
16
16
 
17
17
  const DEFAULT_REVIEW_WAIT_MS = 10_000;
18
18
  const MAX_REVIEW_WAIT_MS = 45_000;
@@ -961,10 +961,10 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
961
961
  outputDir = mkdtempSync(path.join(outputRoot, `build-${buildId}-review-`));
962
962
  } else {
963
963
  outputDir = mkdtempSync(
964
- path.join(os.tmpdir(), `lumine-build-${buildId}-review-`),
964
+ path.join(adminWorkDirectory(), `build-${buildId}-review-`),
965
965
  );
966
966
  }
967
- const profileDir = mkdtempSync(path.join(os.tmpdir(), "lumine-chrome-profile-"));
967
+ const profileDir = mkdtempSync(path.join(outputDir, "browser-profile-"));
968
968
  const screenshotPath = path.join(outputDir, "runtime.png");
969
969
  const receiptPath = path.join(outputDir, "review.json");
970
970
  const appUrl = `${String(options.siteUrl).replace(/\/$/, "")}/app/${buildId}`;
package/lib/commands.js CHANGED
@@ -2503,6 +2503,7 @@ export function parseArgs(args) {
2503
2503
  adminCheckpoint: raw.checkpoint ? String(raw.checkpoint) : "",
2504
2504
  adminOutput: raw.output ? String(raw.output) : "",
2505
2505
  adminOutputDir: raw.outputDir ? String(raw.outputDir) : "",
2506
+ adminDir: raw.dir ? String(raw.dir) : "",
2506
2507
  adminClaimFile: raw.claim ? String(raw.claim) : "",
2507
2508
  adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
2508
2509
  adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
@@ -3032,6 +3033,7 @@ export function printHelp() {
3032
3033
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
3033
3034
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
3034
3035
  lumine admin daily-run gather --output-dir <dir> [--date YYYY-MM-DD] [--json] (all read-only daily reports + bot-output pages + bot-chats.txt)
3036
+ lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] [--json] (reading, canonical effort counts, pending duties)
3035
3037
  lumine admin featured candidates [--days 7] [--after <iso>] [--json] (recent, provably never-Featured Subjects with comment counts)
3036
3038
  lumine admin report serve --file <report.md> [--port <n>] [--open] [--json] (loopback report page with section nav)
3037
3039
  lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
package/lib/http.js CHANGED
@@ -18,7 +18,7 @@ export async function requestJson({
18
18
  headers,
19
19
  signal,
20
20
  });
21
- const data = parseJson(text);
21
+ let data = parseJson(text);
22
22
  if (!response.ok) {
23
23
  const error = new Error(
24
24
  data?.error?.message ||
@@ -30,7 +30,22 @@ export async function requestJson({
30
30
  error.data = data;
31
31
  throw error;
32
32
  }
33
- return data || {};
33
+ // parseJson intentionally wraps non-JSON error text for HTTP failures.
34
+ // A successful response must contain actual JSON, never that fallback.
35
+ try {
36
+ data = JSON.parse(text);
37
+ } catch {
38
+ data = null;
39
+ }
40
+ if (!data || typeof data !== "object") {
41
+ const error = new Error(
42
+ "The API returned no valid JSON response. The request outcome is unconfirmed.",
43
+ );
44
+ error.code = "lumine_http_invalid_response";
45
+ error.status = response.status;
46
+ throw error;
47
+ }
48
+ return data;
34
49
  }
35
50
 
36
51
  export async function probeUrl({ url, authToken, timeoutMs, signal }) {
package/lib/sdk.js CHANGED
@@ -214,6 +214,16 @@ export const SDK_CLI_METHODS = {
214
214
  scopes: ["rewards:claim"], readOnly: true,
215
215
  mapArgs: (args) => ({ ruleId: args.ruleId, cursor: args.cursor, limit: args.limit }),
216
216
  },
217
+ "rewards.interpretAnswer": {
218
+ path: "api/rewards/interpret-answer", special: "rewards", operation: "interpret-answer",
219
+ scopes: ["rewards:claim"], readOnly: true,
220
+ mapArgs: (args) => ({ challengeId: args.challengeId, text: args.text }),
221
+ },
222
+ "rewards.getChallengeBoard": {
223
+ path: "api/rewards/challenge-board", special: "rewards", operation: "challenge-board",
224
+ scopes: ["rewards:claim"], readOnly: true,
225
+ mapArgs: () => ({}),
226
+ },
217
227
  "rewards.getArchivedProblem": {
218
228
  path: "api/rewards/archived-problem", special: "rewards", operation: "archived-problem",
219
229
  scopes: ["rewards:claim"], readOnly: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-09-30T09:55:20.738Z
5
+ Generated: 2026-10-03T04:47:01.454Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -81,8 +81,9 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
81
81
 
82
82
  ### Twinkle.viewer
83
83
  - async get() | scopes: none
84
- - Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest }
84
+ - Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest, ageTier }
85
85
  - Cached; use refresh() to re-fetch.
86
+ - ageTier is 'kid', 'teen' or 'adult', taken from the viewer's Teenager and Adult achievements (granted once an admin approves their birthdate). Guests and viewers without either achievement are 'kid'. It is a display and default-selection hint computed by the Twinkle page; do not treat it as a server-side permission.
86
87
  - async refresh() | scopes: none
87
88
  - Returns: Viewer info
88
89
  - Forces a fresh fetch from the parent.
@@ -1515,6 +1516,21 @@ world.updatePresence({ x, y, z, facing });
1515
1516
  - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
1516
1517
  - available says which boards this app's approved rules can pay: show a Coins board only when available.coins is true (an app whose rules pay XP only has no Coins standings). me is the signed-in viewer's own standing even when they fall outside the page, or null when they earned nothing in the period.
1517
1518
  - Drafts and previews return mode 'preview' with no entries. Use Twinkle.leaderboards for app-defined scores; use this for real XP and Coins earned.
1519
+ - await Twinkle.rewards.interpretAnswer({ challengeId, text }) | scopes: rewards:claim
1520
+ - Returns: { mode: "live" | "preview", understood: boolean, value: number | null, source: "plain" | "model" }
1521
+ - Read a learner's free-form answer text into the single number they meant, so they can answer in any format.
1522
+ - For numeric-quiz bounties. Call it only when the typed text is not already a plain number or fraction (check that locally); a plain number returns source 'plain' with no model call. Then pass the returned value to claim({ challengeId, answers: [value] }). A small model reads the text and the platform pays for it, so the learner's AI Energy is never charged.
1523
+ - understood: false means the text had no single clear number (several alternatives, no number, or a calculation). Tell the learner in friendly words to type just the number. This is not a wrong answer and does not use a try.
1524
+ - The reader is given the puzzle text only to learn the requested answer format (unit, fraction/decimal/percent, which of several numbers answers the question), never the answer key, and never calculates or converts units: it only reads what was written, and the number it returns must be one the learner actually typed. Typed expressions like 5+8 are intentionally not evaluated.
1525
+ - Requires an open challenge of the learner's own (build_reward_challenge_not_open, HTTP 409 otherwise). Limit: 120 characters (build_reward_answer_text_invalid, 400), rate limited to a burst of 4 then one read every 5 seconds (build_reward_throttled, 429). build_reward_answer_reader_unavailable (503) means the reader is down: ask the learner to type just the number.
1526
+ - Drafts and owner previews return mode 'preview' and need no open challenge.
1527
+ - await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
1528
+ - Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", attempt, firstSolve, failersBefore, failuresBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
1529
+ - Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
1530
+ - Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
1531
+ - puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers.
1532
+ - tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later.
1533
+ - No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
1518
1534
  - await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
1519
1535
  - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
1520
1536
  - Browse confirmed solves of retired until-earned quiz bounties, newest first.
@@ -1022,14 +1022,50 @@ each swap again. Never omit this section from a full-run report.
1022
1022
 
1023
1023
  ```bash
1024
1024
  lumine admin daily-run gather --output-dir <dir> [--date YYYY-MM-DD] --json
1025
+ lumine admin daily-run progress --dir <dir> [--file <duty-evidence.json>] --json
1025
1026
  lumine admin featured candidates [--days 7] [--after <iso>] --json
1026
1027
  lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [--open] --json
1027
1028
  ```
1028
1029
 
1029
1030
  These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
1030
- - **`gather`** saves each read-only report into `<dir>`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. It needs the active run for run-scoped reads.
1031
- - **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts and whether Mikey has viewed it. Eligibility only: ranking stays editorial.
1032
- - **`report serve`** detaches a loopback server that answers only `/`, `marked.min.js` and the report Markdown. `--open` opens Chrome.
1031
+ - **`gather`** saves each read-only report into `<dir>`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. It also creates `review-state.json`: every source is fetched, not read. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
1032
+ - **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
1033
+ - **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts, explicit comment truncation/completeness, and whether Mikey has viewed it. Counts from truncated context are partial. An unavailable Subject context fails the collection. Eligibility only: ranking stays editorial.
1034
+ - **`report serve`** detaches a loopback server that answers only `/`, `marked.min.js` and the report Markdown. `--open` opens Chrome and reports whether the OS accepted the request. Render, navigation and original-source verification remain pending until actually checked. Startup failures stop only the child this command started.
1035
+
1036
+ ### Durable recovery and explicit duty evidence (CLI 0.3.5)
1037
+
1038
+ Default checkpoints, mutation intents, runtime-log evidence and Build reviews live
1039
+ under `work/lumine-admin` in the invocation directory, with private permissions.
1040
+ Explicit persistent output/checkpoint paths take priority. Older automatic
1041
+ checkpoints in OS temp storage are copied with their candidate spools to persistent
1042
+ storage before resuming; the existing scope/hash checks still apply.
1043
+
1044
+ Generic mutations persist their exact operation/account/run/session key before
1045
+ sending. A missing receipt leaves that key pending; rerunning the exact command
1046
+ reuses it instead of issuing a fresh mutation. Changed bodies or authorities get
1047
+ different keys. A canonical receipt is archived privately before printing, and
1048
+ the pending pointer closes only after successful output. Existing Featured,
1049
+ batch-skip and runtime-log workflows retain their own recovery contracts.
1050
+
1051
+ `duty-evidence.json` is local operator evidence, for example:
1052
+
1053
+ ```json
1054
+ {
1055
+ "protocol": 1,
1056
+ "runId": 107,
1057
+ "runReport": { "file": "run-report.json", "bytes": 123, "sha256": "<full digest>" },
1058
+ "requiredDuties": ["effort-levels", "report-browser", "football-coach"],
1059
+ "reading": [{ "file": "bot-chats.txt", "bytes": 456, "sha256": "<full digest>", "complete": true, "readAt": "2026-10-01T00:00:00Z" }],
1060
+ "duties": [{ "id": "effort-levels", "status": "completed", "evidence": [{ "file": "run-report.json", "bytes": 123, "sha256": "<full digest>" }], "note": "All candidates considered; preserve higher-authority levels." }]
1061
+ }
1062
+ ```
1063
+
1064
+ Use actual byte counts/digests and only acknowledge work performed. Every evidence
1065
+ reference stays inside the run directory. Changed files invalidate reading
1066
+ acknowledgments. Effort totals use the canonical report's `subject.effort.set`
1067
+ audit counts, including failed attempts separately; unavailable receipts remain
1068
+ unknown. Pending browser duties need genuine version-bound interaction evidence.
1033
1069
 
1034
1070
  ### Full management report in Chrome
1035
1071
 
@@ -1039,7 +1075,7 @@ Do this as part of finishing the authorized run; a Markdown path in chat alone
1039
1075
  is insufficient, and no additional confirmation is needed to open the report.
1040
1076
 
1041
1077
  1. Save the complete report as
1042
- `/private/tmp/twinkle-daily-YYYY-MM-DD/daily-management-report.md`, using the
1078
+ `/Users/mikey/Developer/twinkle/work/daily-management-YYYY-MM-DD/daily-management-report.md`, using the
1043
1079
  run's Bangkok date. Include every required reporting section, coverage gap,
1044
1080
  pending decision and carryover. Reflect later owner decisions accurately.
1045
1081
  2. Render the entire Markdown into a readable HTML page with section links,