@stage5/lumine 0.2.91 → 0.2.94

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/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 { formatBytes, parseStorageSizeBytes } from "./util.js";
15
16
  import {
16
17
  extractNewsClaim,
17
18
  readAdminJsonFile,
@@ -141,6 +142,238 @@ const REWARD_REVIEW_STATUSES = ["pending", "approved", "all"];
141
142
  const CHAT_REPORT_STATUSES = ["open", "reviewing", "resolved", "dismissed"];
142
143
  const CHAT_REPORT_LIST_STATUSES = ["pending", ...CHAT_REPORT_STATUSES, "all"];
143
144
  const REWARD_REVIEW_DECISIONS = ["approve", "reject", "revoke"];
145
+ // `lumine admin review`: one queue for every Build unlock (XP/Coin rewards,
146
+ // project room, Lumine file storage, AI Card crafting). The per-type commands
147
+ // (reward-review, cardcraft-review, storage) are thin aliases of it.
148
+ const REVIEW_REQUEST_TYPES = [
149
+ "rewards",
150
+ "project-limit",
151
+ "storage-limit",
152
+ "cardcraft",
153
+ ];
154
+ const REVIEW_REQUEST_TYPE_ALIASES = {
155
+ rewards: "rewards",
156
+ reward: "rewards",
157
+ "reward-review": "rewards",
158
+ "project-limit": "project-limit",
159
+ project: "project-limit",
160
+ room: "project-limit",
161
+ "storage-limit": "storage-limit",
162
+ storage: "storage-limit",
163
+ cardcraft: "cardcraft",
164
+ "card-craft": "cardcraft",
165
+ crafting: "cardcraft",
166
+ };
167
+ const REVIEW_REQUEST_LIST_STATUSES = [
168
+ "queue",
169
+ "pending",
170
+ "open",
171
+ "approved",
172
+ "rejected",
173
+ "all",
174
+ ];
175
+ const REVIEW_REQUEST_LABELS = {
176
+ rewards: "XP & Coin rewards",
177
+ "project-limit": "Project room",
178
+ "storage-limit": "Lumine file storage",
179
+ cardcraft: "AI Card crafting",
180
+ };
181
+ const REVIEW_REQUEST_USAGE =
182
+ "Usage: lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <type:id> [--reason <text>] | revoke <type:id> --reason <text> | propose rewards:<id> --dir <edited-snapshot> --config <rules.json> [--reason <text>]. A bare id works with --type.";
183
+
184
+ function parseReviewRequestType(value) {
185
+ const key = String(value || "")
186
+ .trim()
187
+ .toLowerCase();
188
+ const type = REVIEW_REQUEST_TYPE_ALIASES[key];
189
+ if (!type) {
190
+ throw cliValidationError(
191
+ `Unknown request type "${key}". Use rewards, project, storage or cardcraft.`,
192
+ );
193
+ }
194
+ return type;
195
+ }
196
+
197
+ // "storage:12", "rewards:70", or a bare id with --type (or a fixed type).
198
+ function parseReviewRequestRef(target, { type: fixedType, typeOption } = {}) {
199
+ const raw = String(target || "").trim();
200
+ const match = /^([a-z-]+):(\d+)$/i.exec(raw);
201
+ if (match) {
202
+ const type = parseReviewRequestType(match[1]);
203
+ if (fixedType && type !== fixedType) {
204
+ throw cliValidationError(
205
+ `This command handles ${REVIEW_REQUEST_LABELS[fixedType]} requests only.`,
206
+ );
207
+ }
208
+ return {
209
+ type,
210
+ id: parseRequiredInteger(match[2], "Request ID", 1),
211
+ };
212
+ }
213
+ const type = fixedType || (typeOption ? parseReviewRequestType(typeOption) : "");
214
+ if (!type) {
215
+ throw cliValidationError(
216
+ "Name the request as <type>:<id> (e.g. storage:12) or pass --type.",
217
+ );
218
+ }
219
+ return { type, id: parseRequiredInteger(raw, "Request ID", 1) };
220
+ }
221
+
222
+ // Builds the operation for list/show/approve/reject/revoke. `fixedType` is set
223
+ // by the per-type aliases; `listStatuses` keeps their own --status choices.
224
+ function buildReviewRequestOperation({
225
+ action,
226
+ target,
227
+ options,
228
+ fixedType = "",
229
+ command = "lumine admin review",
230
+ listStatuses = REVIEW_REQUEST_LIST_STATUSES,
231
+ }) {
232
+ const reason = String(options.adminReason || "").trim();
233
+ if (reason.length > 1000) {
234
+ throw cliValidationError("--reason must be at most 1000 characters.");
235
+ }
236
+ if (!action || action === "list") {
237
+ return readOperation(
238
+ "review.list",
239
+ withQuery("/cli/admin/reviews", {
240
+ status: parseChoice(
241
+ options.adminStatus || "pending",
242
+ "--status",
243
+ listStatuses,
244
+ ),
245
+ type:
246
+ fixedType ||
247
+ (options.adminType
248
+ ? String(options.adminType)
249
+ .split(",")
250
+ .map((value) => parseReviewRequestType(value))
251
+ .join(",")
252
+ : ""),
253
+ cursor: options.adminCursor
254
+ ? /^\d+(\.\d\.\d+)?$/.test(String(options.adminCursor))
255
+ ? String(options.adminCursor)
256
+ : (() => {
257
+ throw cliValidationError(
258
+ "--cursor must be the value a previous list printed.",
259
+ );
260
+ })()
261
+ : "",
262
+ }),
263
+ { requiresRun: false, reviewType: fixedType || null },
264
+ );
265
+ }
266
+ const ref = parseReviewRequestRef(target, {
267
+ type: fixedType,
268
+ typeOption: options.adminType,
269
+ });
270
+ if (action === "show" || action === "get") {
271
+ const snapshotDir = String(options.dir || "").trim();
272
+ if (options.dir && !snapshotDir) {
273
+ throw cliValidationError("Pass a new or empty directory with --dir <path>.");
274
+ }
275
+ if (snapshotDir && ref.type !== "rewards") {
276
+ throw cliValidationError("--dir writes a reward request's frozen source.");
277
+ }
278
+ return readOperation(
279
+ "review.show",
280
+ withQuery(`/cli/admin/reviews/${ref.type}/${ref.id}`, {
281
+ // Reward source is listed with sizes only unless --dir asks for it.
282
+ files: snapshotDir ? "1" : "0",
283
+ }),
284
+ {
285
+ requiresRun: false,
286
+ reviewType: ref.type,
287
+ reviewId: ref.id,
288
+ snapshotDir,
289
+ },
290
+ );
291
+ }
292
+ if (!REWARD_REVIEW_DECISIONS.includes(action)) {
293
+ return null;
294
+ }
295
+ if (action === "revoke" && !["rewards", "cardcraft"].includes(ref.type)) {
296
+ throw cliValidationError(
297
+ `${REVIEW_REQUEST_LABELS[ref.type]} approvals are not revoked; change the limit instead${ref.type === "storage-limit" ? " (lumine admin storage grant)" : ""}.`,
298
+ );
299
+ }
300
+ if (
301
+ ["rewards", "cardcraft"].includes(ref.type) &&
302
+ action !== "approve" &&
303
+ !reason
304
+ ) {
305
+ throw cliValidationError(
306
+ `${command} ${action} <id> needs --reason <text> the creator will read.`,
307
+ );
308
+ }
309
+ const body = { decision: action, reason };
310
+ if (options.adminConfigFile) {
311
+ if (ref.type !== "rewards") {
312
+ throw cliValidationError(
313
+ "--config (earning rules) is only used with XP & Coin reward requests.",
314
+ );
315
+ }
316
+ if (action !== "approve") {
317
+ throw cliValidationError(
318
+ "--config is only used with approve; rejections and revocations take --reason.",
319
+ );
320
+ }
321
+ // Without --config the app's own proposal (rewards.json + sheet, frozen
322
+ // in the request) is approved as it stands; --config replaces it.
323
+ body.config = readRewardConfigFile(options.adminConfigFile);
324
+ }
325
+ if (options.storageSize) {
326
+ if (ref.type !== "storage-limit") {
327
+ throw cliValidationError(
328
+ "--size is only used with Lumine file storage requests.",
329
+ );
330
+ }
331
+ if (action !== "approve") {
332
+ throw cliValidationError("--size is only used with approve.");
333
+ }
334
+ body.sizeBytes = parseAdminStorageSize(options.storageSize);
335
+ }
336
+ return writeOperation(
337
+ "review.decide",
338
+ "POST",
339
+ `/cli/admin/reviews/${ref.type}/${ref.id}`,
340
+ body,
341
+ {
342
+ requiresRun: false,
343
+ reviewType: ref.type,
344
+ reviewId: ref.id,
345
+ decision: action,
346
+ },
347
+ );
348
+ }
349
+
350
+ // A reviewer counter-proposal for an XP & Coin reward request: the edited copy
351
+ // of the snapshot (from `show --dir`) plus the rules the creator's acceptance
352
+ // publishes.
353
+ function buildRewardProposalOperation(reviewId, options) {
354
+ const reason = String(options.adminReason || "").trim();
355
+ if (reason.length > 1000) {
356
+ throw cliValidationError("--reason must be at most 1000 characters.");
357
+ }
358
+ if (!options.adminConfigFile) {
359
+ throw cliValidationError(
360
+ "lumine admin reward-review propose <id> needs --config <rules.json> (the rules the creator's acceptance publishes) and --dir <edited snapshot>.",
361
+ );
362
+ }
363
+ const files = readRewardProposalDirectory(options.dir);
364
+ return writeOperation(
365
+ "reward-review.propose",
366
+ "POST",
367
+ `/cli/admin/reward-reviews/${reviewId}/propose`,
368
+ {
369
+ files,
370
+ config: readRewardConfigFile(options.adminConfigFile),
371
+ reason,
372
+ },
373
+ { requiresRun: false, reviewId, fileCount: files.length },
374
+ );
375
+ }
376
+
144
377
  const REWARD_PROPOSAL_MAX_FILES = 500;
145
378
  const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
146
379
  const REWARD_PROPOSAL_SKIPPED_DIRS = new Set([
@@ -633,7 +866,11 @@ export async function adminCommand(options) {
633
866
  if (operation.name === "daily-run.start") {
634
867
  assertAdminTodoHandoffResult(result, operation.body.scope);
635
868
  }
636
- if (operation.name === "reward-review.show" && operation.snapshotDir) {
869
+ if (
870
+ (operation.name === "reward-review.show" ||
871
+ operation.name === "review.show") &&
872
+ operation.snapshotDir
873
+ ) {
637
874
  // The snapshot leaves the JSON result and lands on disk, where the agent
638
875
  // reads it like any pulled workspace; the result keeps sizes and paths.
639
876
  const review = result?.data?.review || {};
@@ -1597,106 +1834,125 @@ export function parseAdminOperation(options) {
1597
1834
  }
1598
1835
  }
1599
1836
 
1837
+ if (namespace === "review" || namespace === "reviews") {
1838
+ // Every Build unlock in one queue. Mikey's own decisions: any time, never
1839
+ // part of a delegated Zero/Ciel daily run.
1840
+ if (action === "propose") {
1841
+ const ref = parseReviewRequestRef(target, {
1842
+ typeOption: options.adminType,
1843
+ });
1844
+ if (ref.type !== "rewards") {
1845
+ throw cliValidationError(
1846
+ "Only XP & Coin reward requests take a proposed copy.",
1847
+ );
1848
+ }
1849
+ return buildRewardProposalOperation(ref.id, options);
1850
+ }
1851
+ const operation = buildReviewRequestOperation({ action, target, options });
1852
+ if (operation) return operation;
1853
+ throw cliValidationError(REVIEW_REQUEST_USAGE);
1854
+ }
1855
+
1600
1856
  if (
1601
1857
  namespace === "reward-review" ||
1602
1858
  namespace === "reward-reviews" ||
1603
1859
  namespace === "rewards"
1604
1860
  ) {
1605
- // Build XP/Coin reward approvals are the administrator's own decision and
1606
- // never part of a delegated Zero/Ciel daily run: they can happen any time.
1607
- const snapshotDir = String(options.dir || "").trim();
1608
- if (!action || action === "list") {
1609
- return readOperation(
1610
- "reward-review.list",
1611
- withQuery("/cli/admin/reward-reviews", {
1612
- status: parseChoice(
1613
- options.adminStatus || "pending",
1614
- "--status",
1615
- REWARD_REVIEW_STATUSES,
1616
- ),
1617
- beforeId: options.adminCursor
1618
- ? parseRequiredInteger(options.adminCursor, "--cursor", 1)
1619
- : "",
1620
- }),
1621
- { requiresRun: false },
1861
+ // Alias of `lumine admin review ... rewards:<id>`; propose stays here.
1862
+ if (action === "propose") {
1863
+ return buildRewardProposalOperation(
1864
+ parseRequiredInteger(target, "Reward review ID", 1),
1865
+ options,
1622
1866
  );
1623
1867
  }
1868
+ const operation = buildReviewRequestOperation({
1869
+ action,
1870
+ target,
1871
+ options,
1872
+ fixedType: "rewards",
1873
+ command: "lumine admin reward-review",
1874
+ listStatuses: REWARD_REVIEW_STATUSES,
1875
+ });
1876
+ if (operation) return operation;
1877
+ throw cliValidationError(
1878
+ "Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | propose <id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
1879
+ );
1880
+ }
1881
+
1882
+ if (
1883
+ namespace === "cardcraft-review" ||
1884
+ namespace === "cardcraft-reviews" ||
1885
+ namespace === "cardcraft"
1886
+ ) {
1887
+ // Alias of `lumine admin review ... cardcraft:<id>`.
1888
+ const operation = buildReviewRequestOperation({
1889
+ action,
1890
+ target,
1891
+ options,
1892
+ fixedType: "cardcraft",
1893
+ command: "lumine admin cardcraft-review",
1894
+ listStatuses: REWARD_REVIEW_STATUSES,
1895
+ });
1896
+ if (operation) return operation;
1897
+ throw cliValidationError(
1898
+ "Usage: lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve <id> [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
1899
+ );
1900
+ }
1901
+
1902
+ if (STORAGE_LIMIT_NAMESPACES.has(namespace)) {
1903
+ // Lumine file storage (Twinkle.files) unlocks: per-creator approvals past
1904
+ // the default quota. Mikey's own decision, any time, never in a daily run.
1905
+ const usage =
1906
+ "Usage: 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>].";
1907
+ const reason = String(options.adminReason || "").trim();
1908
+ if (reason.length > 1000) {
1909
+ throw cliValidationError("--reason must be at most 1000 characters.");
1910
+ }
1911
+ if (!action || action === "list") {
1912
+ // Alias of `lumine admin review list --type storage`.
1913
+ return buildReviewRequestOperation({
1914
+ action,
1915
+ target,
1916
+ options,
1917
+ fixedType: "storage-limit",
1918
+ command: "lumine admin storage",
1919
+ listStatuses: ["pending", "approved", "rejected", "all"],
1920
+ });
1921
+ }
1624
1922
  if (action === "show" || action === "get") {
1625
- const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1626
- if (options.dir && !snapshotDir) {
1627
- throw cliValidationError(
1628
- "Pass a new or empty directory with --dir <path>.",
1629
- );
1630
- }
1923
+ const user = parseStorageTargetUser(target);
1631
1924
  return readOperation(
1632
- "reward-review.show",
1633
- withQuery(`/cli/admin/reward-reviews/${reviewId}`, {
1634
- // Without --dir the source is listed with sizes only; with --dir the
1635
- // full snapshot is fetched and written locally for reading.
1636
- files: snapshotDir ? "1" : "0",
1637
- }),
1638
- { requiresRun: false, reviewId, snapshotDir },
1925
+ "storage.show",
1926
+ `/cli/admin/storage-limits/users/${encodeURIComponent(user)}`,
1927
+ { requiresRun: false },
1639
1928
  );
1640
1929
  }
1641
- if (action === "propose") {
1642
- // Offer an edited copy of the snapshot as the condition of approval:
1643
- // the creator accepts (which publishes it) or declines (which rejects).
1644
- const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1645
- const reason = String(options.adminReason || "").trim();
1646
- if (reason.length > 1000) {
1647
- throw cliValidationError("--reason must be at most 1000 characters.");
1648
- }
1649
- if (!options.adminConfigFile) {
1650
- throw cliValidationError(
1651
- "lumine admin reward-review propose <id> needs --config <rules.json> (the rules the creator's acceptance publishes) and --dir <edited snapshot>.",
1652
- );
1653
- }
1654
- const files = readRewardProposalDirectory(options.dir);
1655
- return writeOperation(
1656
- "reward-review.propose",
1657
- "POST",
1658
- `/cli/admin/reward-reviews/${reviewId}/propose`,
1659
- {
1660
- files,
1661
- config: readRewardConfigFile(options.adminConfigFile),
1662
- reason,
1663
- },
1664
- { requiresRun: false, reviewId, fileCount: files.length },
1665
- );
1930
+ if (action === "approve" || action === "reject") {
1931
+ // Alias of `lumine admin review approve|reject storage:<id>`.
1932
+ return buildReviewRequestOperation({
1933
+ action,
1934
+ target,
1935
+ options,
1936
+ fixedType: "storage-limit",
1937
+ command: "lumine admin storage",
1938
+ });
1666
1939
  }
1667
- if (REWARD_REVIEW_DECISIONS.includes(action)) {
1668
- const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1669
- const reason = String(options.adminReason || "").trim();
1670
- if (action !== "approve" && !reason) {
1940
+ if (action === "grant" || action === "set") {
1941
+ const user = parseStorageTargetUser(target);
1942
+ if (!options.storageSize) {
1671
1943
  throw cliValidationError(
1672
- `lumine admin reward-review ${action} <id> needs --reason <text> the creator will read.`,
1673
- );
1674
- }
1675
- if (reason.length > 1000) {
1676
- throw cliValidationError("--reason must be at most 1000 characters.");
1677
- }
1678
- const body = { decision: action, reason };
1679
- if (action === "approve") {
1680
- // Without --config the app's own proposal (rewards.json + sheet, frozen
1681
- // in the request) is approved as it stands; --config replaces it.
1682
- if (options.adminConfigFile)
1683
- body.config = readRewardConfigFile(options.adminConfigFile);
1684
- } else if (options.adminConfigFile) {
1685
- throw cliValidationError(
1686
- "--config is only used with approve; rejections and revocations take --reason.",
1944
+ "lumine admin storage grant <user> needs --size <500MB|1GB|2GB|n MB>.",
1687
1945
  );
1688
1946
  }
1689
1947
  return writeOperation(
1690
- "reward-review.decide",
1691
- "POST",
1692
- `/cli/admin/reward-reviews/${reviewId}`,
1693
- body,
1694
- { requiresRun: false, reviewId, decision: action },
1948
+ "storage.grant",
1949
+ "PUT",
1950
+ `/cli/admin/storage-limits/users/${encodeURIComponent(user)}`,
1951
+ { sizeBytes: parseAdminStorageSize(options.storageSize), reason },
1952
+ { requiresRun: false },
1695
1953
  );
1696
1954
  }
1697
- throw cliValidationError(
1698
- "Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | propose <id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
1699
- );
1955
+ throw cliValidationError(usage);
1700
1956
  }
1701
1957
 
1702
1958
  if (namespace === "chat-reports" || namespace === "chat-report") {
@@ -4339,6 +4595,254 @@ function printChatSafetyResult({ operation, data }) {
4339
4595
  return false;
4340
4596
  }
4341
4597
 
4598
+ function formatCardCraftReviewLine(review) {
4599
+ return `#${review.id} · ${review.status}${review.isLive ? " (live)" : ""} · Build ${review.buildId}${review.appTitle ? ` "${review.appTitle}"` : ""}${review.ownerUsername ? ` by ${review.ownerUsername}` : ""}${review.reason ? ` · ${review.reason}` : ""}`;
4600
+ }
4601
+
4602
+ function printCardCraftReviewResult({ operation, data }) {
4603
+ if (operation.name === "cardcraft-review.list") {
4604
+ const reviews = Array.isArray(data.reviews) ? data.reviews : [];
4605
+ console.log(
4606
+ `${reviews.length} card crafting review(s) (${data.filter || "pending"}):`,
4607
+ );
4608
+ for (const review of reviews)
4609
+ console.log(` ${formatCardCraftReviewLine(review)}`);
4610
+ if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
4611
+ return;
4612
+ }
4613
+ const review = data.review || {};
4614
+ if (operation.name === "cardcraft-review.decide") {
4615
+ console.log(
4616
+ `Decision recorded: ${operation.decision} → #${review.id} is ${review.status}.`,
4617
+ );
4618
+ if (review.status === "approved")
4619
+ console.log(
4620
+ "Crafting in the published app now uses this recipe (no republish needed).",
4621
+ );
4622
+ if (review.status === "revoked")
4623
+ console.log(
4624
+ "New crafts stopped. Existing assets stay with their cards.",
4625
+ );
4626
+ return;
4627
+ }
4628
+ console.log(formatCardCraftReviewLine(review));
4629
+ if (review.craftedCount !== undefined)
4630
+ console.log(`Assets crafted under this recipe: ${review.craftedCount}`);
4631
+ for (const problem of review.problems || [])
4632
+ console.log(` problem: ${problem}`);
4633
+ const declaration = review.declaration || {};
4634
+ if (declaration.guidance) console.log(`Guidance: ${declaration.guidance}`);
4635
+ for (const kind of declaration.kinds || []) {
4636
+ console.log(`Kind ${kind.id} "${kind.label}"${kind.description ? ` — ${kind.description}` : ""}`);
4637
+ for (const [name, param] of Object.entries(kind.params || {})) {
4638
+ const shape =
4639
+ param.type === "enum"
4640
+ ? `one of ${param.values.join(", ")}`
4641
+ : param.type === "integer" || param.type === "number"
4642
+ ? `${param.type} ${param.min}-${param.max}${param.scaleWithTier ? " (scales with colour)" : ""}`
4643
+ : param.type === "string"
4644
+ ? `text up to ${param.maxLength}`
4645
+ : param.type;
4646
+ console.log(` ${name}: ${shape}`);
4647
+ }
4648
+ }
4649
+ const colours = ["", "blue", "pink", "orange", "magenta", "gold", "black"];
4650
+ for (const level of ["1", "2", "3", "4", "5", "6"]) {
4651
+ const kinds = (declaration.tiers || {})[level];
4652
+ console.log(
4653
+ ` ${level} ${colours[Number(level)]}: ${kinds?.length ? kinds.join(", ") : "not craftable"}`,
4654
+ );
4655
+ }
4656
+ for (const event of review.events || [])
4657
+ console.log(
4658
+ ` ${new Date(event.createdAt * 1000).toISOString()} ${event.action} by ${event.actorId}${event.reason ? `: ${event.reason}` : ""}`,
4659
+ );
4660
+ }
4661
+
4662
+ const STORAGE_LIMIT_NAMESPACES = new Set([
4663
+ "storage",
4664
+ "storage-limit",
4665
+ "storage-limits",
4666
+ "storage-request",
4667
+ "storage-requests",
4668
+ ]);
4669
+
4670
+ function parseStorageTargetUser(value) {
4671
+ const user = String(value || "")
4672
+ .trim()
4673
+ .replace(/^@/, "");
4674
+ if (!user || !/^[\w.-]+$/.test(user)) {
4675
+ throw cliValidationError("Pass a user id or username.");
4676
+ }
4677
+ return user;
4678
+ }
4679
+
4680
+ function parseAdminStorageSize(value) {
4681
+ try {
4682
+ return parseStorageSizeBytes(value);
4683
+ } catch (error) {
4684
+ throw cliValidationError(error.message);
4685
+ }
4686
+ }
4687
+
4688
+ function formatStorageRequestLine(request) {
4689
+ const approved = request.approvedMaxRuntimeFileStorageBytes
4690
+ ? ` → ${formatBytes(request.approvedMaxRuntimeFileStorageBytes)}`
4691
+ : "";
4692
+ return `#${request.requestId} · ${request.status} · ${request.username || `user ${request.userId}`} (${request.userId}) asks ${formatBytes(request.requestedMaxRuntimeFileStorageBytes)}${approved} · using ${formatBytes(request.currentUsageBytes)} at request${request.reason ? ` · "${request.reason}"` : ""}${request.reviewReason ? ` · review: ${request.reviewReason}` : ""}`;
4693
+ }
4694
+
4695
+ function printStorageLimitResult({ operation, data, result }) {
4696
+ if (operation.name === "storage.list") {
4697
+ const requests = Array.isArray(data.requests) ? data.requests : [];
4698
+ console.log(
4699
+ `${requests.length} storage request(s) (${data.filter || "pending"}):`,
4700
+ );
4701
+ for (const request of requests)
4702
+ console.log(` ${formatStorageRequestLine(request)}`);
4703
+ if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
4704
+ return;
4705
+ }
4706
+ if (operation.name === "storage.show") {
4707
+ const storage = data.storage || {};
4708
+ console.log(
4709
+ `${data.user?.username || "user"} (${data.user?.id}): ${formatBytes(storage.runtimeFileStorageBytes)} used of ${formatBytes(storage.maxRuntimeFileStorageBytes)} across ${Number(storage.runtimeFileCount || 0)} file(s) (default ${formatBytes(storage.defaultMaxRuntimeFileStorageBytes)}, cap ${formatBytes(storage.maxApprovableRuntimeFileStorageBytes)}).`,
4710
+ );
4711
+ if (data.override) {
4712
+ console.log(
4713
+ `Approved override: ${formatBytes(data.override.maxRuntimeFileStorageBytes)} by user ${data.override.approvedByUserId}${data.override.reason ? ` · ${data.override.reason}` : ""}`,
4714
+ );
4715
+ }
4716
+ for (const request of data.recentRequests || [])
4717
+ console.log(` ${formatStorageRequestLine(request)}`);
4718
+ return;
4719
+ }
4720
+ const limit = formatBytes(data.maxRuntimeFileStorageBytes);
4721
+ if (operation.name === "storage.decide") {
4722
+ const request = data.request || {};
4723
+ console.log(
4724
+ result?.changed === false
4725
+ ? `Storage request #${request.requestId} was already ${request.status}. ${request.username || "The creator"}'s limit is ${limit}.`
4726
+ : `Storage request #${request.requestId} ${request.status}. ${request.username || "The creator"}'s Lumine file storage limit is now ${limit}.`,
4727
+ );
4728
+ return;
4729
+ }
4730
+ if (operation.name === "storage.grant") {
4731
+ console.log(
4732
+ result?.changed === false
4733
+ ? `${data.user?.username || "The user"} (${data.user?.id}) already has ${limit} of Lumine file storage.`
4734
+ : `${data.user?.username || "The user"} (${data.user?.id}) now has ${limit} of Lumine file storage.${data.request ? ` Closed pending request #${data.request.requestId} as approved.` : ""}`,
4735
+ );
4736
+ }
4737
+ }
4738
+
4739
+ function formatReviewAge(seconds) {
4740
+ const at = Number(seconds || 0);
4741
+ if (!at) return "";
4742
+ const minutes = Math.max(0, Math.round((Date.now() / 1000 - at) / 60));
4743
+ if (minutes < 60) return `${minutes}m ago`;
4744
+ const hours = Math.round(minutes / 60);
4745
+ if (hours < 48) return `${hours}h ago`;
4746
+ return `${Math.round(hours / 24)}d ago`;
4747
+ }
4748
+
4749
+ function formatReviewItemLine(item) {
4750
+ const where =
4751
+ item.appTitle || (item.type === "storage-limit" ? "all their Builds" : "");
4752
+ return [
4753
+ `${item.ref} · ${item.status}${item.details?.isLive ? " (live)" : ""}`,
4754
+ REVIEW_REQUEST_LABELS[item.type] || item.type,
4755
+ `${where ? `${where} · ` : ""}by ${item.requesterUsername || "user"} (${item.requesterId})`,
4756
+ item.summary,
4757
+ item.reason ? `"${item.reason}"` : "",
4758
+ item.reviewReason && item.status !== "pending" ? `note: ${item.reviewReason}` : "",
4759
+ formatReviewAge(item.createdAt),
4760
+ ]
4761
+ .filter(Boolean)
4762
+ .join(" · ");
4763
+ }
4764
+
4765
+ function printReviewEvents(events) {
4766
+ for (const event of events || [])
4767
+ console.log(
4768
+ ` ${new Date(Number(event.createdAt || 0) * 1000).toISOString()} ${event.action} by ${event.actorId}${event.reason ? `: ${event.reason}` : ""}`,
4769
+ );
4770
+ }
4771
+
4772
+ function printReviewRequestResult({ operation, data, result }) {
4773
+ if (operation.name === "review.list") {
4774
+ const items = Array.isArray(data.items) ? data.items : [];
4775
+ const scope = operation.reviewType
4776
+ ? REVIEW_REQUEST_LABELS[operation.reviewType]
4777
+ : "Build";
4778
+ console.log(`${items.length} ${scope} request(s) (${data.filter || "pending"}):`);
4779
+ for (const item of items) console.log(` ${formatReviewItemLine(item)}`);
4780
+ if (data.nextCursor) console.log(`More: --cursor ${data.nextCursor}`);
4781
+ if (items.some((item) => item.decisions?.includes("approve")))
4782
+ console.log(
4783
+ "Decide: lumine admin review approve|reject <ref> (storage: --size; rewards: --config; cardcraft/rewards reject needs --reason).",
4784
+ );
4785
+ return;
4786
+ }
4787
+ const type = operation.reviewType;
4788
+ // Rewards and card crafting keep their detailed printers.
4789
+ if (type === "rewards") {
4790
+ printRewardReviewResult({
4791
+ operation: {
4792
+ ...operation,
4793
+ name:
4794
+ operation.name === "review.decide"
4795
+ ? "reward-review.decide"
4796
+ : "reward-review.show",
4797
+ },
4798
+ data,
4799
+ });
4800
+ return;
4801
+ }
4802
+ if (type === "cardcraft") {
4803
+ printCardCraftReviewResult({
4804
+ operation: {
4805
+ ...operation,
4806
+ name:
4807
+ operation.name === "review.decide"
4808
+ ? "cardcraft-review.decide"
4809
+ : "cardcraft-review.show",
4810
+ },
4811
+ data,
4812
+ });
4813
+ return;
4814
+ }
4815
+ if (operation.name === "review.decide" && type === "storage-limit") {
4816
+ printStorageLimitResult({
4817
+ operation: { ...operation, name: "storage.decide" },
4818
+ data,
4819
+ result,
4820
+ });
4821
+ return;
4822
+ }
4823
+ if (data.item) console.log(formatReviewItemLine(data.item));
4824
+ if (operation.name === "review.decide") {
4825
+ console.log(
4826
+ result?.changed === false
4827
+ ? `Request ${data.item?.ref || ""} was already ${data.item?.status || data.request?.status}.`
4828
+ : `Decision recorded: ${operation.decision}.${type === "project-limit" && operation.decision === "approve" ? " Main and all its branches have the room now." : ""}`,
4829
+ );
4830
+ return;
4831
+ }
4832
+ if (type === "storage-limit" && data.storage) {
4833
+ const storage = data.storage;
4834
+ console.log(
4835
+ `Now: ${formatBytes(storage.runtimeFileStorageBytes)} used of ${formatBytes(storage.maxRuntimeFileStorageBytes)} across ${Number(storage.runtimeFileCount || 0)} file(s) (default ${formatBytes(storage.defaultMaxRuntimeFileStorageBytes)}, cap ${formatBytes(storage.maxApprovableRuntimeFileStorageBytes)}).`,
4836
+ );
4837
+ }
4838
+ if (type === "project-limit" && data.currentLimits) {
4839
+ console.log(
4840
+ `Now: ${data.currentLimits.maxFilesPerProject} files, ${formatBytes(data.currentLimits.maxProjectBytes)} (Main; every branch inherits it).`,
4841
+ );
4842
+ }
4843
+ printReviewEvents(data.events);
4844
+ }
4845
+
4342
4846
  function printAdminResult({ operation, result }) {
4343
4847
  const data = result?.data || {};
4344
4848
  if (printChatSafetyResult({ operation, data })) return;
@@ -4346,10 +4850,22 @@ function printAdminResult({ operation, result }) {
4346
4850
  printRewardActivity(data);
4347
4851
  return;
4348
4852
  }
4853
+ if (operation.name.startsWith("review.")) {
4854
+ printReviewRequestResult({ operation, data, result });
4855
+ return;
4856
+ }
4349
4857
  if (operation.name.startsWith("reward-review.")) {
4350
4858
  printRewardReviewResult({ operation, data });
4351
4859
  return;
4352
4860
  }
4861
+ if (operation.name.startsWith("cardcraft-review.")) {
4862
+ printCardCraftReviewResult({ operation, data });
4863
+ return;
4864
+ }
4865
+ if (operation.name.startsWith("storage.")) {
4866
+ printStorageLimitResult({ operation, data, result });
4867
+ return;
4868
+ }
4353
4869
  if (operation.name === "runtime.evidence") {
4354
4870
  const evidence = data.evidence || {};
4355
4871
  console.log(