@stage5/lumine 0.3.3 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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-30T02:34:09.690Z
5
+ Generated: 2026-09-30T09:55:20.738Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -551,6 +551,26 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
551
551
  - Only one music generation per viewer may run at a time (code ai_music_generation_in_progress). A retry with the same requestId returns the finished result without paying again (replayed: true), or code music_in_progress while it is still being made.
552
552
  - Generation usually takes one to three minutes; the SDK timeout defaults to 600000ms. Show progress UI while waiting.
553
553
  - Example: const song = await Twinkle.ai.generateMusic({ prompt: 'Warm lo-fi hip hop with dusty drums, a mellow Rhodes and rain in the background', instrumental: true }); audio.src = song.url;
554
+ - async generateSpeech({ text, voice, language, requestId, timeoutMs } = {}) | scopes: none
555
+ - Returns: { success, asset, url, mimeType, durationSeconds, alignment, text, voice, model, requestId, replayed?, aiUsagePolicy }
556
+ - Generate spoken audio with Eleven v4 and save it in the viewer's Twinkle.files. New generations use AI Battery.
557
+ - Signed-in viewers only. Each new generation uses AI Battery; show this before the viewer starts voice interaction.
558
+ - text: 1–2,000 characters. voice: warm (default), bright, or calm. language: optional ISO language code such as en.
559
+ - The MP3 is saved in the viewer's Twinkle.files and counts toward storage. Replay the saved URL to listen again without another AI request.
560
+ - alignment contains provider character timing for subtitles or animation. Respect Twinkle tab mute and browser playback controls.
561
+ - Reuse requestId after a lost response. Completed audio replays the saved asset and settles the same billing receipt without regeneration or duplicate charges.
562
+ - Only one speech or transcription request per viewer runs at a time. SDK timeout defaults to 180000ms.
563
+ - Example: const speech = await Twinkle.ai.generateSpeech({ text: 'Welcome to our club!', voice: 'warm', language: 'en' }); audio.src = speech.url;
564
+ - async transcribeAudio({ audioBase64, language, requestId, timeoutMs } = {}) | scopes: none
565
+ - Returns: { success, text, words: [{ text, start, end }], language, durationSeconds, model, requestId, replayed?, aiUsagePolicy }
566
+ - Recognize a short recording with ElevenLabs Scribe v2. New recognition requests use AI Battery.
567
+ - Signed-in viewers only. New recognition requests use AI Battery. Each successful response includes the canonical aiUsagePolicy.
568
+ - audioBase64 is plain base64 of a mono 16-bit PCM WAV at 8–48 kHz, between 0.2 and 30 seconds. The server validates the actual duration.
569
+ - Request microphone permission only after a viewer action, show a recording indicator, and stop microphone tracks when recording ends or the app closes.
570
+ - Audio is sent to the recognition provider without saving a Twinkle recording asset. Let the viewer review or correct the transcript before using it as their message.
571
+ - Reuse requestId after a lost response. Completed transcripts replay without recognition or duplicate charges. Word recognition is not a pronunciation score.
572
+ - Only one speech or transcription request per viewer runs at a time. SDK timeout defaults to 180000ms.
573
+ - Example: const transcript = await Twinkle.ai.transcribeAudio({ audioBase64: wavBase64, language: 'en' }); input.value = transcript.text;
554
574
  - onImageGenerationStatus(listener) | scopes: none
555
575
  - Returns: unsubscribe function
556
576
  - Subscribe to real-time image generation status events forwarded into the build iframe.
@@ -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,