@stage5/lumine 0.3.80 → 0.3.81

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,13 +13,16 @@ 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
+ // today-withdraw --user <id> (--keys <k,...> | --file <withdraw.json>) --reason <text>
17
+ // withdraw that member's unserved queued Today's
18
+ // Challenge questions (preview unless --confirm; audited)
16
19
  // picture-audit [--items <ids> | --cursor <id>] [--limit 1-20] [--renders <dir>]
17
20
  // advisory JEV image review list of live picture
18
21
  // items; never retires or edits. --renders holds
19
22
  // <itemId>.png renders of picture-option items
20
23
  // (panels are drawn by the web, not stored)
21
24
  export const ARENA_USAGE =
22
- "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] | picture-audit [--items <id,id,...> | --cursor <id>] [--limit <1-20>] [--renders <dir of <itemId>.png>] [--output <review-list.json>] [--json]";
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]";
23
26
  const MAX_ITEMS = 300;
24
27
 
25
28
  function validationError(message) {
@@ -95,6 +98,58 @@ function readRenders(dir, itemIds) {
95
98
  return renders;
96
99
  }
97
100
 
101
+ const TODAY_WITHDRAW_USAGE =
102
+ 'Use today-withdraw --user <member id> (--keys <key,key,...> | --file <withdraw.json>) --reason "<why>" [--confirm].';
103
+ const TODAY_WITHDRAW_MAX_KEYS = 100;
104
+
105
+ // A withdrawal file is an array of keys, or an object with `keys` (or
106
+ // `authorKeys`, or `items` of {key|authorKey}), an optional `userId` that must
107
+ // match --user, and an optional `reason` used when --reason is absent.
108
+ function readWithdrawFile(file, userId) {
109
+ const resolved = path.resolve(file);
110
+ let parsed;
111
+ try {
112
+ if (fs.statSync(resolved).size > 200_000) throw validationError("The withdrawal file must be under 200 KB.");
113
+ parsed = JSON.parse(fs.readFileSync(resolved, "utf8"));
114
+ } catch (error) {
115
+ if (error.code === "CLI_ADMIN_CLI_VALIDATION") throw error;
116
+ throw validationError(`Cannot read the withdrawal file ${resolved}: ${error.message}`);
117
+ }
118
+ const object = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
119
+ if (object?.userId !== undefined && Number(object.userId) !== userId) {
120
+ throw validationError(`The withdrawal file is for member ${object.userId}, not --user ${userId}.`);
121
+ }
122
+ const list = Array.isArray(parsed) ? parsed : object?.keys || object?.authorKeys || object?.items;
123
+ if (!Array.isArray(list)) throw validationError("The withdrawal file needs a list of question keys (an array, or {keys:[...]}).");
124
+ const keys = list.map((entry) => (typeof entry === "string" ? entry : entry?.authorKey ?? entry?.key));
125
+ return { keys, reason: typeof object?.reason === "string" ? object.reason : "" };
126
+ }
127
+
128
+ function buildTodayWithdraw(options) {
129
+ const userId = Number(options.adminUser);
130
+ if (!Number.isSafeInteger(userId) || userId <= 0) throw validationError(TODAY_WITHDRAW_USAGE);
131
+ const keyList = String(options.adminArenaKeys || "");
132
+ if (Boolean(keyList) === Boolean(options.adminFile)) throw validationError(`Pass exactly one of --keys or --file. ${TODAY_WITHDRAW_USAGE}`);
133
+ const fromFile = options.adminFile ? readWithdrawFile(options.adminFile, userId) : null;
134
+ const keys = fromFile ? fromFile.keys : keyList.split(",").map((key) => key.trim()).filter(Boolean);
135
+ if (!keys.length || keys.length > TODAY_WITHDRAW_MAX_KEYS ||
136
+ keys.some((key) => typeof key !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(key))) {
137
+ throw validationError(`Withdraw 1-${TODAY_WITHDRAW_MAX_KEYS} question keys (letters, digits, _ and -).`);
138
+ }
139
+ if (new Set(keys).size !== keys.length) throw validationError("Each question key may appear once.");
140
+ const reason = String(options.adminReason || fromFile?.reason || "").trim();
141
+ if (reason.length < 10 || reason.length > 500) throw validationError(`Give a 10-500 character --reason. ${TODAY_WITHDRAW_USAGE}`);
142
+ const confirm = confirmed(options);
143
+ return {
144
+ name: confirm ? "arena.today.withdraw" : "arena.today.withdraw.preview",
145
+ method: "POST",
146
+ path: "/cli/admin/arena/today/withdraw",
147
+ body: { userId, keys, reason, dryRun: !confirm },
148
+ mutates: confirm,
149
+ requiresRun: false,
150
+ };
151
+ }
152
+
98
153
  export function buildArenaOperation({ action, options }) {
99
154
  if (action === "picture-audit") {
100
155
  const limit = Number(options.adminLimit ?? 10);
@@ -146,6 +201,7 @@ export function buildArenaOperation({ action, options }) {
146
201
  const confirm = confirmed(options);
147
202
  return { name: confirm ? "arena.today.import" : "arena.today.preview", method: "POST", path: "/cli/admin/arena/today/import", body: { ...payload, dryRun: !confirm }, mutates: confirm, requiresRun: false };
148
203
  }
204
+ if (action === "today-withdraw") return buildTodayWithdraw(options);
149
205
  if (action === "marking") {
150
206
  const cursor = Number(options.adminCursor || 0);
151
207
  const limit = Number(options.adminLimit ?? 100);
@@ -245,6 +301,16 @@ export function formatArenaResult({ operation, data }) {
245
301
  if (operation.name === "arena.today.preview" || operation.name === "arena.today.import") return [
246
302
  `${data?.dryRun ? "Dry run" : "Saved"}: ${data?.questions?.length || 0} reviewed personal questions.${data?.dryRun ? " Add --confirm to make new questions available." : ""}`,
247
303
  ];
304
+ if (operation.name === "arena.today.withdraw.preview" || operation.name === "arena.today.withdraw") {
305
+ const withdrawn = data?.withdrawn || [];
306
+ const skipped = data?.skipped || [];
307
+ return [
308
+ `${data?.dryRun ? "Dry run: would withdraw" : "Withdrew"} ${withdrawn.length} queued question(s) for member ${data?.userId}${withdrawn.length ? `: ${withdrawn.map((q) => q.key).join(", ")}` : ""}.`,
309
+ ...skipped.map((s) => `Skipped ${s.key}: ${s.reason} (${s.detail})`),
310
+ `Ready at preferences v${data?.revision ?? "-"}: ${data?.readyBefore ?? 0} before, ${data?.readyAfter ?? 0} after${data?.refillDue ? `; refill to ${data?.target}` : ""}.`,
311
+ ...(data?.dryRun && withdrawn.length ? ["Add --confirm to withdraw them."] : []),
312
+ ];
313
+ }
248
314
  if (operation.name === "arena.marking") {
249
315
  const entries = data?.entries || [];
250
316
  return [
package/lib/commands.js CHANGED
@@ -2726,6 +2726,7 @@ export function parseArgs(args) {
2726
2726
  adminArenaTo: raw.to ? String(raw.to) : "",
2727
2727
  adminArenaRenders: raw.renders ? String(raw.renders) : "",
2728
2728
  adminArenaItems: raw.items ? String(raw.items) : "",
2729
+ adminArenaKeys: raw.keys ? String(raw.keys) : "",
2729
2730
  // lumine admin jev-images (admin-jev-images.js)
2730
2731
  adminJevArea: raw.area ? String(raw.area) : "",
2731
2732
  adminJevKey: raw.key ? String(raw.key) : "",
@@ -3351,6 +3352,7 @@ export function printHelp() {
3351
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)
3352
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)
3353
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)
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)
3354
3356
  lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3355
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)
3356
3358
  lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.80",
3
+ "version": "0.3.81",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5695,6 +5695,44 @@ Bridge Builder qualifies from QR check-ins: 7 different people, or one meetup a
5695
5695
  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
5696
 
5697
5697
 
5698
+ ## Arena Today's Challenge personal queues
5699
+
5700
+ Each member who chose Today's Challenge preferences is served the oldest
5701
+ `ready`, never-shown question written for their current preference revision.
5702
+ Questions are prepared editorially (see the repository's
5703
+ `agent-guides/lumine-admin.md`, Today's Challenge personal refills, for the
5704
+ content rules, including the 11-03 no-answer-revealing-options and real-test
5705
+ coverage rules).
5706
+
5707
+ ```
5708
+ lumine admin arena today-queue [--cursor <id>] [--limit <1-100>] --json
5709
+ lumine admin arena today-member --user <id> --json
5710
+ lumine admin arena today-import --file <reviewed-questions.json> [--confirm] --json
5711
+ lumine admin arena today-withdraw --user <id> (--keys <key,key,...> | --file <withdraw.json>) --reason "<why>" [--confirm] --json
5712
+ ```
5713
+
5714
+ `today-import` validates without publishing; `--confirm` publishes. Keys and
5715
+ content are immutable and duplicate questions are rejected.
5716
+
5717
+ `today-withdraw` removes that member's unserved queued questions that failed
5718
+ review, so corrected replacements are not served behind them. Without
5719
+ `--confirm` it is a preview. It withdraws only `ready` questions never assigned
5720
+ to the member; a question shown today or on an earlier day is skipped as
5721
+ `served`, and keys are looked up only in that member's queue (another member's
5722
+ key reads as `not_found`). Already-withdrawn keys are skipped, so a repeat is a
5723
+ no-op, and a retried request replays its original receipt. Each confirmed run is
5724
+ recorded in the administrator audit log (operator, time, member, keys, reason),
5725
+ and each withdrawn question keeps that record in its review evidence. The result
5726
+ lists `withdrawn` and `skipped` keys (`not_found`, `already_withdrawn`, `served`,
5727
+ `not_ready`) with `readyBefore`/`readyAfter` at the current preference revision.
5728
+ Withdrawn questions are never served or counted as ready. Their keys stay
5729
+ reserved and the identical question cannot be imported again; import the
5730
+ corrected version under a new key (it may keep the same passage and prompt).
5731
+ A withdrawal file is a JSON array of keys, or `{ "userId": <id>, "keys": [...],
5732
+ "reason": "..." }` (`userId` must match `--user`; `--reason` overrides the
5733
+ file's reason). Re-read `today-member` afterwards and refill if the member is
5734
+ due.
5735
+
5698
5736
  ## Nonogram picture collection
5699
5737
 
5700
5738
  Nonogram's `nonogram-picture-v1` proof uses a private, reviewed picture bank.