@stage5/lumine 0.3.34 → 0.3.35

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.
@@ -23,6 +23,7 @@ export function runAdmin(
23
23
  args,
24
24
  { outputFile, extraEnv, retryRead = false, spawnImpl = spawnSync } = {},
25
25
  ) {
26
+ const startedAt = performance.now();
26
27
  const attemptFile = outputFile
27
28
  ? `${outputFile}.attempt-${randomUUID()}.json`
28
29
  : null;
@@ -78,6 +79,7 @@ export function runAdmin(
78
79
  featured: ["list", "history"],
79
80
  sponsor: ["applications"],
80
81
  "bot-output": [null],
82
+ "math-lab": ["queue"],
81
83
  subjects: ["candidates"],
82
84
  subject: ["get"],
83
85
  };
@@ -96,6 +98,7 @@ export function runAdmin(
96
98
  const retried = runAdmin(args, { outputFile, extraEnv, spawnImpl });
97
99
  return {
98
100
  ...retried,
101
+ durationMs: Math.round(performance.now() - startedAt),
99
102
  attempts: 2,
100
103
  recovered: retried.ok,
101
104
  firstError:
@@ -104,6 +107,7 @@ export function runAdmin(
104
107
  }
105
108
  return {
106
109
  ok,
110
+ durationMs: Math.round(performance.now() - startedAt),
107
111
  attempts: 1,
108
112
  status: result.status,
109
113
  json: error ? { ok: false, status: "unconfirmed", error } : parsed,
@@ -159,6 +163,7 @@ export function renderBotOutput({ chatMessages, comments }, file) {
159
163
  }
160
164
 
161
165
  export async function dailyRunGather(options) {
166
+ const startedAt = performance.now();
162
167
  const dir = ensureDir(
163
168
  path.resolve(options.adminOutputDir || options.adminDir || "."),
164
169
  );
@@ -172,6 +177,7 @@ export async function dailyRunGather(options) {
172
177
  file,
173
178
  ok: res.ok,
174
179
  attempts: res.attempts,
180
+ durationMs: res.durationMs,
175
181
  recovered: Boolean(res.recovered),
176
182
  ...(res.ok ? {} : { error: res.json?.error || res.stderr }),
177
183
  });
@@ -180,6 +186,7 @@ export async function dailyRunGather(options) {
180
186
  step("energy-budget", ["energy-budget"], "energy-budget.json");
181
187
  step("news", ["news"], "news.json");
182
188
  step("brief", ["brief"], "brief.json");
189
+ step("math-lab-queue", ["math-lab", "queue"], "math-lab-queue.json");
183
190
  step("ai-costs-monthly", ["ai-costs", "monthly"], "ai-monthly.json");
184
191
  step("ai-costs-day", ["ai-costs", "day", yesterday], "ai-day.json");
185
192
  step("media-costs", ["media-costs", "monthly"], "media.json");
@@ -239,6 +246,8 @@ export async function dailyRunGather(options) {
239
246
  if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
240
247
 
241
248
  const summary = {
249
+ collectedAt: new Date().toISOString(),
250
+ durationMs: Math.round(performance.now() - startedAt),
242
251
  dir,
243
252
  yesterdayUtc: yesterday,
244
253
  botOutput: {
@@ -377,13 +386,19 @@ fetch('daily-management-report.md',{cache:'no-store'}).then(r=>{if(!r.ok)throw n
377
386
  export function waitForReportServer(child, timeoutMs = 10000) {
378
387
  return new Promise((resolve, reject) => {
379
388
  let buf = "";
389
+ let stderr = "";
380
390
  let settled = false;
391
+ const clean = value => String(value || "")
392
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
393
+ .replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(-2048);
394
+ const diagnostic = message => new Error(`${message}${clean(stderr) ? ` ${clean(stderr)}` : ""}`);
381
395
  const finish = (error, url) => {
382
396
  if (settled) return;
383
397
  settled = true;
384
398
  clearTimeout(timer);
385
399
  child.stdout.removeListener("data", onData);
386
- child.removeListener("exit", onExit);
400
+ child.stderr?.removeListener("data", onStderr);
401
+ child.removeListener("close", onClose);
387
402
  child.removeListener("error", onError);
388
403
  if (error) {
389
404
  child.kill();
@@ -399,29 +414,36 @@ export function waitForReportServer(child, timeoutMs = 10000) {
399
414
  if (!buf.includes("\n")) return;
400
415
  try {
401
416
  const parsed = JSON.parse(buf.split("\n")[0]);
417
+ if (parsed.ok === false) {
418
+ const reason = clean(parsed.error?.message || parsed.message || parsed.error);
419
+ return finish(diagnostic(`Report server failed to start${reason ? `: ${reason}` : "."}`));
420
+ }
402
421
  const url = new URL(parsed.data?.url);
403
422
  if (
404
423
  parsed.ok !== true ||
405
424
  parsed.status !== "success" ||
406
425
  url.protocol !== "http:" ||
407
- url.hostname !== "127.0.0.1"
426
+ url.hostname !== "127.0.0.1" || url.username || url.password
408
427
  )
409
428
  throw new Error("invalid receipt");
410
429
  finish(null, url.href);
411
430
  } catch {
412
- finish(new Error("Report server returned an invalid startup receipt."));
431
+ finish(diagnostic("Report server returned an invalid startup receipt."));
413
432
  }
414
433
  };
415
- const onExit = (code) =>
416
- finish(new Error(`Report server exited (${code}) before it was ready.`));
434
+ const onStderr = chunk => { stderr = (stderr + chunk).slice(-8192); };
435
+ // close follows drained stdio; exit can arrive before the error text.
436
+ const onClose = (code, signal) =>
437
+ finish(diagnostic(`Report server exited (${signal || code}) before it was ready.`));
417
438
  const onError = (error) => finish(error);
418
439
  const timer = setTimeout(
419
440
  () =>
420
- finish(new Error("Report server did not start before its deadline.")),
441
+ finish(diagnostic("Report server did not start before its deadline.")),
421
442
  timeoutMs,
422
443
  );
423
444
  child.stdout.on("data", onData);
424
- child.once("exit", onExit);
445
+ child.stderr?.on("data", onStderr);
446
+ child.once("close", onClose);
425
447
  child.once("error", onError);
426
448
  });
427
449
  }
@@ -488,10 +510,13 @@ export async function reportServe(
488
510
  "--json",
489
511
  "--no-update-check",
490
512
  ],
491
- { detached: true, stdio: ["ignore", "pipe", "ignore"] },
513
+ { detached: true, stdio: ["ignore", "pipe", "pipe"] },
492
514
  );
493
515
  const url = await waitForReportServer(child);
494
516
  child.stdout.destroy();
517
+ // Keep draining diagnostics without retaining them or keeping the CLI up.
518
+ child.stderr?.resume();
519
+ child.stderr?.unref?.();
495
520
  child.unref();
496
521
  const opened = options.adminOpen
497
522
  ? openImpl("open", ["-a", "Google Chrome", url])
package/lib/admin.js CHANGED
@@ -3973,6 +3973,9 @@ export function parseAdminOperation(options) {
3973
3973
  },
3974
3974
  );
3975
3975
  }
3976
+ if (namespace === "math-lab" && action === "queue") {
3977
+ return readOperation("math-lab.queue", "/cli/admin/math-lab/queue");
3978
+ }
3976
3979
  if (namespace === "builds" && action === "review") {
3977
3980
  const parsed = parseAdminCommentTarget({ target, explicitType: "build" });
3978
3981
  if (parsed.type !== "build") {
@@ -7330,6 +7333,14 @@ function printAiBucketRuleResult({ operation, data }) {
7330
7333
 
7331
7334
  function printAdminResult({ operation, result }) {
7332
7335
  const data = result?.data || {};
7336
+ if (operation.name === "math-lab.queue") {
7337
+ console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
7338
+ for (const grade of data.grades || []) console.log(grade.available
7339
+ ? `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7340
+ : `${grade.ruleId}: queue unavailable`);
7341
+ if (!data.complete) console.log("Queue coverage is incomplete; unavailable grades are not empty queues.");
7342
+ return;
7343
+ }
7333
7344
  if (printChatSafetyResult({ operation, data })) return;
7334
7345
  const chatReportLines = formatChatReportResult({ operation, data });
7335
7346
  if (chatReportLines) {
package/lib/commands.js CHANGED
@@ -2479,6 +2479,7 @@ export function parseArgs(args) {
2479
2479
  "foreground",
2480
2480
  "open",
2481
2481
  "dryRun",
2482
+ "preflight",
2482
2483
  "confirmEmailRule",
2483
2484
  "includePrefix",
2484
2485
  "allowSharedNetwork",
@@ -2802,6 +2803,8 @@ export function parseArgs(args) {
2802
2803
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
2803
2804
  adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2804
2805
  adminFile: raw.file ? String(raw.file) : "",
2806
+ rewardPreflight: Boolean(raw.preflight),
2807
+ rewardApprovalFile: raw.approvalFile ? String(raw.approvalFile) : "",
2805
2808
  // admin craft revise --item <id|name> [--why] [--emoji] [--category]
2806
2809
  // (kept raw: a flag given without a text value is refused in
2807
2810
  // admin-craft.js rather than sent as the string "true")
@@ -3255,6 +3258,7 @@ export function printHelp() {
3255
3258
  lumine admin chat-reports export <report-id> | --hold <hold-id> --out <new-dir> [--json] (evidence package + SHA-256 manifest)
3256
3259
  lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
3257
3260
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
3261
+ lumine admin math-lab queue [--json] (published sets remaining per grade, all-time claims, refill flags)
3258
3262
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
3259
3263
  lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
3260
3264
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
package/lib/rewards.js CHANGED
@@ -18,6 +18,15 @@ import { resolveRequiredBuildIdOrSelected } from "./commands.js";
18
18
 
19
19
  const REWARDS_FILE = "rewards.json";
20
20
 
21
+ export function reviewApprovalFromReceipt(receipt, buildId) {
22
+ if (!receipt || receipt.ready !== true || receipt.buildId !== buildId ||
23
+ !Number.isSafeInteger(receipt.sourceVersionId) || receipt.sourceVersionId <= 0 ||
24
+ !["sourceHash", "sheetHash", "configHash"].every(key => /^[a-f0-9]{64}$/.test(receipt[key] || ""))) {
25
+ throw new Error("Use the complete, ready preflight JSON for this Build as --approval-file.");
26
+ }
27
+ return Object.fromEntries(["buildId", "sourceVersionId", "sourceHash", "sheetHash", "configHash"].map(key => [key, receipt[key]]));
28
+ }
29
+
21
30
  async function readWorkspaceRewardsJson(options) {
22
31
  const localProject = await findLocalProjectMetadata(
23
32
  path.resolve(options.dir || process.cwd()),
@@ -208,13 +217,39 @@ export async function rewardsCommand(options) {
208
217
  return;
209
218
  }
210
219
  if (action === "review") {
220
+ if (options.rewardPreflight && options.rewardApprovalFile)
221
+ throw new Error("Choose --preflight or --approval-file, not both.");
222
+ let receipt;
223
+ if (options.rewardApprovalFile) {
224
+ receipt = JSON.parse(await fs.readFile(path.resolve(options.rewardApprovalFile), "utf8"));
225
+ } else {
226
+ receipt = await requestJson({
227
+ url: `${options.apiUrl}/build/${buildId}/rewards/review-preflight`,
228
+ authToken: auth.token, timeoutMs: options.timeoutMs,
229
+ });
230
+ }
231
+ if (options.rewardPreflight) {
232
+ if (options.json) console.log(JSON.stringify(receipt, null, 2));
233
+ else {
234
+ console.log(`Build ${buildId}, saved v${receipt.savedVersion} (artifact ${receipt.sourceVersionId}).`);
235
+ console.log(receipt.publicationBehavior);
236
+ console.log(`Source: ${receipt.sourceHash}\nQuestion sheet: ${receipt.sheetHash}\nReward configuration: ${receipt.configHash}`);
237
+ for (const error of receipt.errors || []) console.log(` - ${error}`);
238
+ console.log("Save this preflight with --json, review the candidate, then pass --approval-file <receipt.json> after approval.");
239
+ }
240
+ if (!receipt.ready) process.exitCode = 1;
241
+ return;
242
+ }
243
+ const expectedApproval = reviewApprovalFromReceipt(receipt, buildId);
244
+ if (receipt.willPublishImmediately && !options.rewardApprovalFile)
245
+ throw new Error("This owner's reward review publishes immediately. Run `lumine rewards review --preflight --json`, approve that exact candidate, then use `--approval-file <receipt.json>`.");
211
246
  await assertAuthScope({ options, auth, scope: "build:write" });
212
247
  const result = await requestJson({
213
248
  url: `${options.apiUrl}/build/${buildId}/rewards/reviews`,
214
249
  method: "POST",
215
250
  authToken: auth.token,
216
251
  timeoutMs: options.timeoutMs,
217
- body: {},
252
+ body: { expectedApproval },
218
253
  });
219
254
  if (options.json) {
220
255
  console.log(JSON.stringify(result, null, 2));
@@ -222,10 +257,12 @@ export async function rewardsCommand(options) {
222
257
  }
223
258
  const reviewId = result?.policy?.latestReviewId;
224
259
  console.log(
225
- `Sent Build ${buildId} for reward review${reviewId ? ` (request #${reviewId})` : ""}. State: ${result?.state || "unknown"}.`,
260
+ `Reward review for Build ${buildId}${reviewId ? ` (request #${reviewId})` : ""}. Server state: ${result?.state || "unknown"}.`,
226
261
  );
227
262
  console.log(
228
- "The saved version and the question sheet on file are frozen for the reviewer. Saving again closes this request; send it again after the save.",
263
+ receipt.willPublishImmediately
264
+ ? "The server attempted automatic approval and publication. Check the returned state and published artifact before reporting it live."
265
+ : "The approved candidate is frozen for the reviewer. Saving again closes this request.",
229
266
  );
230
267
  return;
231
268
  }
@@ -239,6 +276,8 @@ function printRewardsHelp() {
239
276
  lumine rewards sheet <file.json> Upload the private question sheet ({ rules: { <ruleId>: { questions?, sets? } } })
240
277
  lumine rewards sheet --show Summarize the sheet on file (never prints answer keys)
241
278
  lumine rewards review Send the saved version (with the sheet on file) for XP/Coin review, like "Send for review" on the website
279
+ lumine rewards review --preflight --json Read exact version/source/sheet/config hashes and publication behavior; no mutation
280
+ lumine rewards review --approval-file <receipt.json> Request only the exact approved preflight candidate; owner auto-approval publishes immediately
242
281
 
243
282
  rewards.json (project root) declares the economy the reviewer approves:
244
283
  { "userDailyXP", "userDailyCoins", "userDailyClaims"?,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.34",
3
+ "version": "0.3.35",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -622,6 +622,10 @@ additions are not the live inventory. At two or fewer remaining, prepare a
622
622
  refill to at least ten, with complete interactive guides, and track it until
623
623
  approved publication. Report one or zero remaining prominently. Details are
624
624
  in the linked guide's **Daily queue monitoring and refill** section.
625
+ Read this inventory with `lumine admin math-lab queue --json`. Its receipt
626
+ binds the published artifact and live reward review, includes all twelve
627
+ grades, and reports incomplete coverage explicitly. Today’s cleared set stays
628
+ current until UTC midnight, but no longer counts as unearned inventory.
625
629
  This is not part of Featured-only or newspaper-only work and is not a new
626
630
  scheduler, delegated API scope, or automatic extension of admin permissions.
627
631
  Use the expressly authorized owner Build workflow for Math Lab; retain the
@@ -878,6 +882,8 @@ type StandalonePost = {
878
882
  updatedAt: null;
879
883
  title: string | null;
880
884
  question: string | null;
885
+ grade?: "Pass" | "Fail" | "Masterpiece" | null; // Daily Reflection only
886
+ masterpieceType?: "heart" | "mind" | "heart_and_mind" | null;
881
887
  content: string | null;
882
888
  explanation: string | null;
883
889
  imagePath: string | null;
@@ -1152,7 +1158,7 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
1152
1158
  ```
1153
1159
 
1154
1160
  These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
1155
- - **`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.
1161
+ - **`gather`** saves each read-only report into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. 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.
1156
1162
  - **`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.
1157
1163
  - **`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.
1158
1164
  - **`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.
@@ -1479,9 +1485,8 @@ Rule fields (all server-enforced, none inferred from app code):
1479
1485
  - Top-level `userDailyClaims`: receipts one learner may earn per site day
1480
1486
  across all rules. `1` is "one bounty a day".
1481
1487
 
1482
- Math Lab's economy (Mikey, 2026-09-12): twelve level rules, one per grade per
1483
- day, elementary 50,000 XP + 1,000 Coins, middle 70,000 + 5,000, high
1484
- 100,000 + 10,000; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
1488
+ Math Lab's economy (Mikey, equal pay 2026-10-04): twelve level rules, one per grade per
1489
+ day, all paying 70,000 XP + 5,000 Coins; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
1485
1490
  `userDailyClaims` 1; until-earned sets authored from the Korean curriculum.
1486
1491
  Arcade Typing (Mikey, 2026-09-12): XP for clearing campaign stages, up to
1487
1492
  10,000 XP per learner per day, no Coins. Platform ceilings: 100,000 XP /
@@ -1497,6 +1502,18 @@ also closes itself on that save). Rejection and revocation require a
1497
1502
  needs a new request. Never approve without reading the code; never approve a
1498
1503
  request whose `isLatest` is false.
1499
1504
 
1505
+ Mikey-owned apps automatically attempt approval and publication when the owner
1506
+ requests reward review. Before that write, run
1507
+ `lumine rewards review --preflight --json` and retain its complete receipt.
1508
+ It identifies the saved version, artifact ID, source hash, private sheet hash
1509
+ and effective reward configuration hash, and states the publication behavior.
1510
+ After Mikey approves that exact candidate, use
1511
+ `lumine rewards review --approval-file <receipt.json>`. The API checks those
1512
+ values under the build lock before freezing content; a change returns
1513
+ `build_reward_approval_stale`. There is no silent fallback to an unbound request.
1514
+ Saving a new preflight is read-only and is not approval. These commands require
1515
+ the matching API and CLI release.
1516
+
1500
1517
  `propose <id> --dir <edited> --config rules.json [--reason]` sends the edited
1501
1518
  directory (text files only; dotfiles and tool folders skipped) as the
1502
1519
  reviewer's proposal: the review moves to `changes_offered`, the creator's card
@@ -2480,6 +2497,12 @@ Inspection never silently bypasses secret semantics. Until the selected bot is
2480
2497
  the author or has canonically responded/revealed, secret values and comments
2481
2498
  remain unavailable.
2482
2499
 
2500
+ Daily Reflection post reads and recommendation candidates include the stored
2501
+ `grade` and `masterpieceType` (nullable), independently of XP and recommendations.
2502
+ An absent grade is unknown. Verify `grade === "Masterpiece"` before placing one
2503
+ of Mikey's reflections anywhere on the newspaper front page; lower or missing
2504
+ grades can be considered for inside pages. The CLI does not infer a grade.
2505
+
2483
2506
  ## Subject and Featured mutations
2484
2507
 
2485
2508
  ```bash