@stage5/lumine 0.3.81 → 0.3.83

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.
@@ -13,6 +13,9 @@ import path from "node:path";
13
13
  // at once; a dry run (validation only) unless --confirm;
14
14
  // re-importing a folder retires that paper's earlier items
15
15
  // status --item <id> --to live|retired|checked
16
+ // revise --file <revisions.json> in-place text/passage/visual/review fixes to
17
+ // live items: same item id, a new content version;
18
+ // a dry run unless --confirm; audited
16
19
  // today-withdraw --user <id> (--keys <k,...> | --file <withdraw.json>) --reason <text>
17
20
  // withdraw that member's unserved queued Today's
18
21
  // Challenge questions (preview unless --confirm; audited)
@@ -22,7 +25,7 @@ import path from "node:path";
22
25
  // <itemId>.png renders of picture-option items
23
26
  // (panels are drawn by the web, not stored)
24
27
  export const ARENA_USAGE =
25
- "Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
28
+ "Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | revise --file <revisions.json> [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
26
29
  const MAX_ITEMS = 300;
27
30
 
28
31
  function validationError(message) {
@@ -256,6 +259,32 @@ export function buildArenaOperation({ action, options }) {
256
259
  timeoutMs: 120000,
257
260
  };
258
261
  }
262
+ if (action === "revise") {
263
+ if (!options.adminFile) throw validationError("Use revise --file <revisions.json> [--confirm].");
264
+ const file = path.resolve(options.adminFile);
265
+ let parsed;
266
+ try {
267
+ if (fs.statSync(file).size > 5_000_000) throw validationError("The revision file must be under 5 MB.");
268
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
269
+ } catch (error) {
270
+ if (error.code === "CLI_ADMIN_CLI_VALIDATION") throw error;
271
+ throw validationError(`Cannot read the revision file ${file}: ${error.message}`);
272
+ }
273
+ const items = Array.isArray(parsed) ? parsed : parsed?.items;
274
+ if (!Array.isArray(items) || !items.length || items.length > MAX_ITEMS) {
275
+ throw validationError(`A revision file holds 1 to ${MAX_ITEMS} revisions: an array, or {reason, items:[...]}.`);
276
+ }
277
+ const confirm = confirmed(options);
278
+ return {
279
+ name: confirm ? "arena.revise" : "arena.revise.preview",
280
+ method: "POST",
281
+ path: "/cli/admin/arena/items/revise",
282
+ body: { items, ...(typeof parsed?.reason === "string" ? { reason: parsed.reason } : {}), dryRun: !confirm },
283
+ mutates: confirm,
284
+ requiresRun: false,
285
+ timeoutMs: 120000,
286
+ };
287
+ }
259
288
  if (action === "status") {
260
289
  const id = Number(options.adminArenaItem);
261
290
  const to = String(options.adminArenaTo || "");
@@ -337,6 +366,16 @@ export function formatArenaResult({ operation, data }) {
337
366
  due.length ? `${due.length} type(s) due for a refill.` : "No standalone practice refill due; check paper-queue for waiting exams.",
338
367
  ];
339
368
  }
369
+ if (operation.name === "arena.revise.preview" || operation.name === "arena.revise") {
370
+ const revised = data?.revised || [];
371
+ const unchanged = data?.unchanged || [];
372
+ return [
373
+ `${data?.dryRun ? "Dry run: would revise" : "Revised"} ${revised.length} item(s)${unchanged.length ? `; ${unchanged.length} already as written` : ""}.`,
374
+ ...revised.map((r) => `Item ${r.itemId}${r.paper ? ` (${r.paper} #${r.number})` : r.number ? ` (#${r.number})` : ""}: v${r.fromVersion} -> v${r.toVersion}, ${r.changed.join(", ")}${r.fromPassageVersion !== r.toPassageVersion ? `; passage ${r.passageId} v${r.fromPassageVersion} -> v${r.toPassageVersion}` : ""}`),
375
+ ...unchanged.map((r) => `Item ${r.itemId}: ${r.status === "already_applied" ? "already revised" : "no change"} (v${r.version})`),
376
+ ...(data?.dryRun && revised.length ? ["Add --confirm to publish these versions."] : []),
377
+ ];
378
+ }
340
379
  if (operation.name === "arena.import.preview") {
341
380
  return [`Dry run: ${data?.wouldImport ?? 0} item(s) pass validation. Add --confirm to import.`];
342
381
  }
@@ -20,6 +20,46 @@ function invalid(message) {
20
20
  return error;
21
21
  }
22
22
 
23
+ // Resolve once before pagination so every page uses the same canonical board.
24
+ export function resolveCurrentFeaturedHistory({ operation, receipt, all }) {
25
+ const subjects = receipt?.data?.subjects;
26
+ if (
27
+ receipt?.ok !== true || receipt.status !== "success" ||
28
+ !Array.isArray(subjects) || receipt.data.count !== subjects.length ||
29
+ subjects.some((subject) => !Number.isSafeInteger(subject?.id) || subject.id < 1)
30
+ ) {
31
+ throw invalid("Could not read the canonical current Featured board; history was not requested.");
32
+ }
33
+ const subjectIds = subjects.map((subject) => subject.id);
34
+ if (new Set(subjectIds).size !== subjectIds.length) {
35
+ throw invalid("The current Featured board contains duplicate subject IDs; history was not requested.");
36
+ }
37
+ if (subjectIds.length > 20_000) {
38
+ throw invalid("Featured history accepts at most 20000 subject IDs per CLI scan.");
39
+ }
40
+ if (subjectIds.length > FEATURED_HISTORY_BATCH_SIZE && !all) {
41
+ throw invalid("The current Featured board has more than 100 subjects; pass --all to batch history reads.");
42
+ }
43
+ const url = new URL(operation.path, "https://lumine.invalid");
44
+ url.searchParams.set("subjectIds", subjectIds.join(","));
45
+ return {
46
+ ...operation,
47
+ path: `${url.pathname}${url.search}`,
48
+ pagination: {
49
+ ...operation.pagination,
50
+ summaryKeys: [...operation.pagination.summaryKeys, "subjectSelection"],
51
+ filters: { subjectIds },
52
+ },
53
+ featuredHistorySelection: {
54
+ source: "current-featured-board",
55
+ subjectIds,
56
+ message: subjectIds.length
57
+ ? `Defaulted to the current Featured board (${subjectIds.length} subjects).`
58
+ : "Defaulted to the current Featured board, which is empty; no history was requested.",
59
+ },
60
+ };
61
+ }
62
+
23
63
  export async function runBatchedFeaturedHistory(args) {
24
64
  const { options, operation, runId } = args;
25
65
  if (options.adminCursor) {
@@ -135,6 +175,9 @@ export async function runBatchedFeaturedHistory(args) {
135
175
  ? firstCoverage
136
176
  : { complete: false, startedAt: null },
137
177
  subjects: results.flatMap((result) => result.data.subjects),
178
+ ...(operation.featuredHistorySelection
179
+ ? { subjectSelection: operation.featuredHistorySelection }
180
+ : {}),
138
181
  pagination: {
139
182
  nextCursor: null,
140
183
  hasMore: false,
package/lib/admin.js CHANGED
@@ -86,6 +86,7 @@ import {
86
86
  } from "./admin-featured.js";
87
87
  import {
88
88
  FEATURED_HISTORY_BATCH_SIZE,
89
+ resolveCurrentFeaturedHistory,
89
90
  runBatchedFeaturedHistory,
90
91
  } from "./admin-featured-history.js";
91
92
 
@@ -1379,7 +1380,7 @@ export async function adminCommand(options) {
1379
1380
  if (result && result.ok === false) process.exitCode = 1;
1380
1381
  return result;
1381
1382
  }
1382
- const operation = parseAdminOperation(options);
1383
+ let operation = parseAdminOperation(options);
1383
1384
  if (operation.name === "news.validate") {
1384
1385
  const validation = validateNewsEditorial({
1385
1386
  claim: operation.claim,
@@ -1607,15 +1608,54 @@ export async function adminCommand(options) {
1607
1608
  }
1608
1609
  return receipt;
1609
1610
  };
1610
- const transformResult = (rawResult) =>
1611
- transformAdminResult({
1611
+ if (operation.featuredHistoryCurrentBoard) {
1612
+ // The empty-board path skips pagination, but must preserve its files too.
1613
+ if (options.adminAll && options.adminCheckpoint && options.adminOutput &&
1614
+ path.resolve(options.adminCheckpoint) === path.resolve(options.adminOutput)) {
1615
+ throw cliValidationError("The pagination checkpoint and result output must use different files.");
1616
+ }
1617
+ if (options.adminAll && options.adminCursor) {
1618
+ throw cliValidationError("Use --resume with the scan checkpoint instead of combining --all with --cursor.");
1619
+ }
1620
+ if (options.adminResume && !options.adminAll) {
1621
+ throw cliValidationError("--resume requires --all or post skip-batch.");
1622
+ }
1623
+ operation = resolveCurrentFeaturedHistory({
1624
+ operation,
1625
+ receipt: await fetchOperation("/cli/admin/subjects/featured"),
1626
+ all: options.adminAll,
1627
+ });
1628
+ if (!operation.featuredHistorySelection.subjectIds.length && (options.adminResume || options.adminCursor)) {
1629
+ throw cliValidationError("The current Featured board is empty; it cannot supply the subject IDs for a previous history scan. Use explicit --subject-ids for a fresh scan or cursor continuation.");
1630
+ }
1631
+ }
1632
+ const transformResult = (rawResult) => {
1633
+ const transformed = transformAdminResult({
1612
1634
  operation,
1613
1635
  result: rawResult,
1614
1636
  options,
1615
1637
  recommendationContentTypes,
1616
1638
  viewFilter,
1617
1639
  });
1618
- if (operation.name === "spending.report") {
1640
+ return operation.featuredHistorySelection && transformed.ok
1641
+ ? { ...transformed, data: { ...transformed.data, subjectSelection: operation.featuredHistorySelection } }
1642
+ : transformed;
1643
+ };
1644
+ if (operation.featuredHistorySelection?.subjectIds.length === 0) {
1645
+ // An empty board is canonical; there is no valid nonempty history query.
1646
+ // Do not invent audit coverage or a history snapshot for this case.
1647
+ result = {
1648
+ ok: true,
1649
+ status: "success",
1650
+ data: {
1651
+ subjectSelection: operation.featuredHistorySelection,
1652
+ subjects: [],
1653
+ events: [],
1654
+ coverage: null,
1655
+ pagination: { hasMore: false, exhausted: true, nextCursor: null },
1656
+ },
1657
+ };
1658
+ } else if (operation.name === "spending.report") {
1619
1659
  result = await completeSpendingReport(await fetchOperation(), options);
1620
1660
  if (result.status === "partial") process.exitCode = 1;
1621
1661
  } else if (operation.featuredWorkflow) {
@@ -4395,10 +4435,10 @@ export function parseAdminOperation(options) {
4395
4435
  return readOperation("featured.list", "/cli/admin/subjects/featured");
4396
4436
  }
4397
4437
  if (action === "history") {
4398
- const subjectIds = parseFeaturedSubjectIds(
4399
- options.adminIds,
4400
- "--subject-ids",
4401
- );
4438
+ const currentBoard = !String(options.adminIds || "").trim();
4439
+ const subjectIds = currentBoard
4440
+ ? []
4441
+ : parseFeaturedSubjectIds(options.adminIds, "--subject-ids");
4402
4442
  if (subjectIds.length > 20_000) {
4403
4443
  throw cliValidationError(
4404
4444
  "Featured history accepts at most 20000 subject IDs per CLI scan.",
@@ -4420,6 +4460,7 @@ export function parseAdminOperation(options) {
4420
4460
  limit: options.limit,
4421
4461
  }),
4422
4462
  {
4463
+ ...(currentBoard ? { featuredHistoryCurrentBoard: true } : {}),
4423
4464
  pagination: {
4424
4465
  collectionKey: "events",
4425
4466
  summaryKeys: ["coverage", "subjects"],
@@ -8442,6 +8483,10 @@ function printAdminResult({ operation, result }) {
8442
8483
  }
8443
8484
 
8444
8485
  function printFeaturedHistorySummary(data) {
8486
+ if (data.subjectSelection) {
8487
+ console.log(data.subjectSelection.message);
8488
+ if (data.subjectSelection.subjectIds.length === 0) return;
8489
+ }
8445
8490
  const coverage = data.coverage || {};
8446
8491
  console.log(
8447
8492
  coverage.complete
package/lib/commands.js CHANGED
@@ -3351,7 +3351,7 @@ export function printHelp() {
3351
3351
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3352
3352
  lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | revise --build <id> --pair <pairId|a+b> --why <text> [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
3353
3353
  lumine admin sliding-block status | add --file <levels.json> [--confirm] (sliding-block-v1 campaign refills, RushHour: dry run without --confirm; the server solves each board and appends it)
3354
- lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm | picture-audit [--items <id,id,...> | --cursor <id>] [--limit 1-20] [--renders <dir of <itemId>.png>] [--output <review-list.json>] (Arena's mock-exam bank; picture-audit is an advisory JEV image review list that never retires or edits items; import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
3354
+ lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm | revise --file <revisions.json> [--confirm] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit 1-20] [--renders <dir of <itemId>.png>] [--output <review-list.json>] (Arena's mock-exam bank; picture-audit is an advisory JEV image review list that never retires or edits items; import and revise are validation-only dry runs without --confirm; revise fixes a live item in place as a new content version; upload audio first with twinkle-api scripts/arena-upload-media.ts)
3355
3355
  lumine admin arena today-queue [--cursor <id>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] | today-withdraw --user <id> (--keys <key,...> | --file <withdraw.json>) --reason <text> [--confirm] (Today's Challenge personal queues; import and withdraw are dry runs without --confirm; withdraw removes only unserved queued questions and is audited)
3356
3356
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3357
3357
  lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
@@ -3389,7 +3389,7 @@ export function printHelp() {
3389
3389
  lumine admin subject creator set-made-by-poster <subject-id> [--json]
3390
3390
  lumine admin subject feature|unfeature <subject-id> [--json]
3391
3391
  lumine admin featured list [--unviewed|--viewed] [--json]
3392
- lumine admin featured history --subject-ids <id,id,...> [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3392
+ lumine admin featured history [--subject-ids <id,id,...>] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
3393
3393
  lumine admin featured add --subject-ids <id,id,...> --posted-after <ISO-8601-or-Unix-time> [--json]
3394
3394
  lumine admin featured reorder --subject-ids <id,id,...> [--json]
3395
3395
  lumine admin featured rotate --remove-subject-ids <id,id,...> --add-subject-ids <id,id,...> [--posted-after <timestamp>] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.81",
3
+ "version": "0.3.83",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2671,6 +2671,7 @@ lumine admin subject creator set-made-by-poster 123 --json
2671
2671
  lumine admin subject feature 123 --json
2672
2672
  lumine admin subject unfeature 123 --json
2673
2673
  lumine admin featured list --json
2674
+ lumine admin featured history --all --json # defaults to current Featured board
2674
2675
  lumine admin featured history --subject-ids 50,40 --all --json
2675
2676
  lumine admin featured add --subject-ids 50,40 \
2676
2677
  --posted-after 2026-08-27T00:00:00+07:00 --json
@@ -2789,6 +2790,8 @@ unknown/deleted IDs, missing current members, non-subject rows, and more than
2789
2790
  100 subjects. Permanent pins and editorial ordering policy are deliberately not
2790
2791
  hardcoded.
2791
2792
 
2793
+ When `lumine admin featured history` omits `--subject-ids`, the CLI reads the current Featured board once and requests history for those IDs in board order. Human output explicitly says that it defaulted to the current board; JSON and `--output` include `data.subjectSelection` with `source: "current-featured-board"`, `subjectIds`, and the same message. An empty board succeeds with empty subjects/events and `coverage: null`, explicitly stating that no history was requested. A failed or malformed board read fails rather than being treated as empty. Explicit `--subject-ids` keeps its existing behavior and skips the board read. Pagination retains the resolved IDs for the entire scan. A no-ID `--all --resume` requires the same board IDs in the same order; a changed board is rejected by checkpoint scope validation. If the board changes, use the previous result's explicit `subjectIds` for `--cursor`, or start a fresh `--all` scan with those IDs and a new checkpoint. An empty board cannot resume a prior scan or supply IDs for its cursor.
2794
+
2792
2795
  `featured history` is the compact canonical evidence path. It returns only
2793
2796
  coverage metadata, per-subject lifetime summaries, and paginated mutation
2794
2797
  events; it does not repeat full audit before/after board snapshots. An event of
@@ -5695,6 +5698,71 @@ Bridge Builder qualifies from QR check-ins: 7 different people, or one meetup a
5695
5698
  Every decision requires Mikey's authorization for that crew and action. Mutations are audited and idempotent; `--output <path>` is an explicit request to retain the canonical JSON. General write receipts omit private crew details.
5696
5699
 
5697
5700
 
5701
+ ## Arena timed-bank item revisions
5702
+
5703
+ Reviewed text, option and visual fixes to a published paper go live in place, without
5704
+ re-importing the paper:
5705
+
5706
+ ```
5707
+ lumine admin arena revise --file <revisions.json> --json
5708
+ lumine admin arena revise --file <revisions.json> --confirm --json
5709
+ ```
5710
+
5711
+ The file is `{ "reason": "...", "items": [ { "itemId": <live id>,
5712
+ "expectedVersion": <version you read>, "paper": "<edition>", "number": <n>,
5713
+ "content": { "stem", "body", "choices", "answer", "accepted",
5714
+ "transformation", "review", "visual", "passage" (any subset) }, "reason": "...", "review":
5715
+ "<options-only check + blind solve evidence>" } ] }` (or a bare array). `paper`
5716
+ and `number` are optional guards; a per-item `reason` overrides the file's.
5717
+ Without `--confirm` it is a dry run.
5718
+
5719
+ - The item keeps its id, so exposures, question identity, paper completeness
5720
+ and passed/solo exclusions are unchanged. The old content is archived as its
5721
+ version and the item gets the next version. Finished papers, open sittings,
5722
+ marking, review cards and drills keep the version they froze. A cover or
5723
+ challenge prepared before the revision starts on the new version.
5724
+ - Stem, body, choices, key (`answer`, `accepted`, `transformation`), review,
5725
+ visual and an existing unrecorded text passage can change. Set membership,
5726
+ type, number, mode, rubric and recordings stay fixed. Changing choices, key,
5727
+ passage or visual requires the full rewritten `content.review`. Its explanation
5728
+ is non-empty text; optional `why` notes are an array of strings, one per choice.
5729
+ Option reasoning may instead be included in the full explanation. The same
5730
+ field types apply to a supplied Korean translation.
5731
+ - To change a passage, send `content.passage` and top-level
5732
+ `expectedPassageVersion` for **every live/checked item sharing that passage**,
5733
+ each with its own expected item version and review evidence. All siblings must
5734
+ carry the identical new text. The entire set is validated, and the old passage
5735
+ and item versions are archived in the same transaction. Old sittings and drills
5736
+ keep their original text; new starts use the revised item and passage together.
5737
+ A passage also used by a retired/draft item is refused, because its unchanged
5738
+ version must retain its old text.
5739
+ Use `visual: null` to remove a visual; supplied diagrams/charts must validate.
5740
+ - The result is checked with the import validators together with the rest of
5741
+ its set (choice count, key index, `why` lines, evidence spans). A version
5742
+ other than `expectedVersion`, a retired item, an essay/spoken task or any
5743
+ invalid result refuses the whole file; nothing is written.
5744
+ - Confirmed runs are audited (`arena.items.revise`: operator, time, item ids,
5745
+ reasons). Each item records the revision (versions, reason, evidence,
5746
+ audit id) in its authoring. A retried request replays its receipt;
5747
+ re-running an applied file reports `already applied` and writes nothing.
5748
+ - The new version starts its own calibration from the item's level, as on
5749
+ import; the old version's calibration stays on record.
5750
+
5751
+ ### Ordered timed-review explanations
5752
+
5753
+ Authored timed items may include `review.steps`: 1–8 ordered `{text, scene?}`
5754
+ pairs. `text` is 1–1600 characters; the whole sequence is bounded to 40,000
5755
+ characters. Keep `review.explanation` as the full readable fallback. The review
5756
+ screen and Zero/Ciel use these exact step numbers. A translated `review.ko`
5757
+ must provide the same number of aligned steps when explicit steps are used.
5758
+
5759
+ Scenes use the existing `cards`, `bars` or `passage` contract (caption up to
5760
+ 300 characters; 1–12 parts with text up to 1600 characters; bars need a finite
5761
+ value from 0 to 1,000,000), or the validated `diagram` contract with a required
5762
+ accessible `alt` (up to 200 characters). No executable markup or figure-plan
5763
+ placeholders. Render and review the actual scenes at phone size before import.
5764
+ Older text-only items retain the sentence-based step behavior.
5765
+
5698
5766
  ## Arena Today's Challenge personal queues
5699
5767
 
5700
5768
  Each member who chose Today's Challenge preferences is served the oldest