@stage5/lumine 0.3.30 → 0.3.32

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.
@@ -43,6 +43,31 @@ export function receiptToKeep(operation, result) {
43
43
  note: report.reviewNote ?? null,
44
44
  });
45
45
  }
46
+ // AI bucket changes answer with rule matches (emails, IPs) and member
47
+ // names; the receipt keeps the decision: ids, modes and counts.
48
+ case "ai-bucket.restrict":
49
+ case "ai-bucket.rule.add":
50
+ case "ai-bucket.rule.disable": {
51
+ const ruleIds = (rules) =>
52
+ (rules || []).map((rule) => ({
53
+ id: rule.id ?? null,
54
+ kind: rule.kind ?? null,
55
+ scope: rule.scope ?? null,
56
+ effectiveRestriction: rule.effectiveRestriction ?? null,
57
+ }));
58
+ return keep({
59
+ bucketId: data.bucket?.id ?? null,
60
+ restriction: data.bucket?.restriction?.mode ?? null,
61
+ previousRestriction: data.previous?.mode ?? null,
62
+ addedRules: ruleIds(data.addedRules),
63
+ disabledRules: ruleIds(data.disabledRule ? [data.disabledRule] : []),
64
+ kickedAccountCount: (data.kickedUserIds || []).length,
65
+ releasedAccountCount: (data.releasedAccounts || []).length,
66
+ ruleCount: data.ruleCount ?? null,
67
+ memberCount: data.memberCount ?? null,
68
+ verified: data.verified === true,
69
+ });
70
+ }
46
71
  default:
47
72
  // Bridge Builder writes answer with the whole crew view: members' who-
48
73
  // are-you answers, parents' addresses and questions, held parent emails.
package/lib/admin.js CHANGED
@@ -12,6 +12,11 @@ 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 { commitThumbnailImage, readThumbnailFile } from "./thumbnail.js";
16
+ import {
17
+ THUMBNAIL_CONTENT_TYPE_BY_EXTENSION,
18
+ THUMBNAIL_MAX_FILE_SIZE_BYTES,
19
+ } from "./constants.js";
15
20
  import { receiptToKeep } from "./admin-receipts.js";
16
21
  import { prepareAdminMutationIntent } from "./admin-request-intents.js";
17
22
  import { formatBytes, parseStorageSizeBytes } from "./util.js";
@@ -202,7 +207,7 @@ const REVIEW_REQUEST_LABELS = {
202
207
  cardcraft: "AI Card crafting",
203
208
  };
204
209
  const REVIEW_REQUEST_USAGE =
205
- "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.";
210
+ "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>] [--title <new app name>] [--thumbnail <image|workspace>]. A bare id works with --type.";
206
211
 
207
212
  function parseReviewRequestType(value) {
208
213
  const key = String(value || "")
@@ -370,7 +375,7 @@ function buildApprovalOperation({ action, target, options }) {
370
375
  // Code agent passes wrote (no model is called), then review what they could
371
376
  // not settle (Mikey 2026-10-06: bulk AI work runs on Claude Code agents).
372
377
  const ENGLISH_USAGE =
373
- "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] [--fault] | retag <questionId> --skill <code> [--reason <text>] | build-candidates [--after <questionId>] [--limit <n>] | build-import <verdicts.json> | check-candidates [--after <questionId>] [--limit <n>] | check-import <verdicts.json> | check-broken [--after <questionId>] [--limit <n>] | check-apply <fixes.json> | check-verify <checked.json> | check-follow-faulty [--after <next>] [--limit <n>]";
378
+ "Use: lumine admin english import <verdicts.json> --pass a|b | adjudicate <verdicts.json> | add-questions <questions.json> | relevel <moves.json> | mastery <userId> --reason <why> | backfill-distractors | summary | review [--limit <n>] | accept|retire <questionId> [--reason <text>] [--fault] | retag <questionId> --skill <code> [--reason <text>] | build-candidates [--after <questionId>] [--limit <n>] | build-import <verdicts.json> | check-candidates [--after <questionId>] [--limit <n>] | check-import <verdicts.json> | check-broken [--after <questionId>] [--limit <n>] | check-apply <fixes.json> | check-verify <checked.json> | check-follow-faulty [--after <next>] [--limit <n>] | lesson-candidates [--after <skillCode>] [--limit <n>] | lesson-import <lessons.json> | lesson-approve <approvals.json> | lessons [--status draft|approved]";
374
379
  const ENGLISH_IMPORT_MAX = 500;
375
380
 
376
381
  function buildEnglishOperation({ action, target, options }) {
@@ -452,6 +457,26 @@ function buildEnglishOperation({ action, target, options }) {
452
457
  if (after && !/^(?:[ufc]:)?\d+:\d+$/.test(after)) throw cliValidationError("--after takes the last reply's next (<phase:id:version>).");
453
458
  return writeOperation("english.check_follow_faulty", "POST", "/cli/admin/english/item-checks/follow-faulty", { after, limit: limitOf(200, 100) }, { requiresRun: false, timeoutMs: 300000 });
454
459
  }
460
+ // Grammar Quest lessons (Mikey 2026-10-11): what the writing pass draws on
461
+ // (each rule-first point with live sound questions on it), importing
462
+ // drafts, approving reviewed versions [{ skillCode, version }], the list
463
+ if (action === "lesson-candidates") {
464
+ const after = options.adminAfter ? String(options.adminAfter) : "";
465
+ return readOperation("english.lesson_candidates", withQuery("/cli/admin/english/lessons/candidates", { after, limit: limitOf(64, 20) }), { requiresRun: false });
466
+ }
467
+ if (action === "lesson-import" || action === "lesson-approve") {
468
+ if (!target) throw cliValidationError(ENGLISH_USAGE);
469
+ const raw = readAdminJsonFile(target, "<file.json>", { maxBytes: 2 * 1024 * 1024 });
470
+ const items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : null;
471
+ if (!items || !items.length || items.length > 100) throw cliValidationError("The file must hold 1 to 100 entries (an array, or { items: [...] }).");
472
+ const path = action === "lesson-import" ? "/cli/admin/english/lessons/import" : "/cli/admin/english/lessons/approve";
473
+ return writeOperation(action === "lesson-import" ? "english.lesson_import" : "english.lesson_approve", "POST", path, { items }, { requiresRun: false, timeoutMs: 180000 });
474
+ }
475
+ if (action === "lessons") {
476
+ const status = options.adminStatus || "";
477
+ if (status && !["draft", "approved"].includes(status)) throw cliValidationError("--status is draft or approved.");
478
+ return readOperation("english.lessons", withQuery("/cli/admin/english/lessons", status ? { status } : {}), { requiresRun: false });
479
+ }
455
480
  if (action === "check-apply") {
456
481
  if (!target) throw cliValidationError(ENGLISH_USAGE);
457
482
  const raw = readAdminJsonFile(target, "<fixes.json>", { maxBytes: 4 * 1024 * 1024 });
@@ -925,6 +950,20 @@ function buildRewardProposalOperation(reviewId, options) {
925
950
  );
926
951
  }
927
952
  const files = readRewardProposalDirectory(options.dir);
953
+ // --title proposes a new app name; --thumbnail <file> a new thumbnail (set
954
+ // on your proposal workspace copy first), or --thumbnail workspace to offer
955
+ // the one already set on that copy (e.g. from the website).
956
+ const title = String(options.title || "").trim();
957
+ if (title.length > 100) {
958
+ throw cliValidationError("--title must be at most 100 characters.");
959
+ }
960
+ const thumbnail = String(options.adminThumbnail || "").trim();
961
+ // Checked before signing in or opening the copy; its hash makes a retry
962
+ // with a different picture a new request rather than a replay.
963
+ const thumbnailSha256 =
964
+ thumbnail && thumbnail !== "workspace"
965
+ ? hashProposalThumbnailFile(thumbnail)
966
+ : "";
928
967
  return writeOperation(
929
968
  "reward-review.propose",
930
969
  "POST",
@@ -933,11 +972,78 @@ function buildRewardProposalOperation(reviewId, options) {
933
972
  files,
934
973
  config: readRewardConfigFile(options.adminConfigFile),
935
974
  reason,
975
+ ...(title ? { title } : {}),
976
+ ...(thumbnail ? { thumbnailFrom: "workspace" } : {}),
977
+ ...(thumbnailSha256 ? { thumbnailSha256 } : {}),
978
+ },
979
+ {
980
+ requiresRun: false,
981
+ reviewId,
982
+ fileCount: files.length,
983
+ ...(thumbnail && thumbnail !== "workspace"
984
+ ? {
985
+ prepare: ({ options: runOptions, auth }) =>
986
+ setProposalWorkspaceThumbnail({
987
+ options: runOptions,
988
+ auth,
989
+ reviewId,
990
+ filePath: thumbnail,
991
+ }),
992
+ }
993
+ : {}),
936
994
  },
937
- { requiresRun: false, reviewId, fileCount: files.length },
938
995
  );
939
996
  }
940
997
 
998
+ // Puts a proposed thumbnail on the reviewer's proposal workspace copy (opened
999
+ // on first use), the copy the offer's thumbnailFrom 'workspace' reads.
1000
+ async function setProposalWorkspaceThumbnail({
1001
+ options,
1002
+ auth,
1003
+ reviewId,
1004
+ filePath,
1005
+ }) {
1006
+ const { buffer, contentType } = await readThumbnailFile(filePath);
1007
+ const receipt = await requestJson({
1008
+ method: "POST",
1009
+ url: `${options.apiUrl}/cli/admin/reward-reviews/${reviewId}/workspace`,
1010
+ authToken: auth.token,
1011
+ body: {},
1012
+ headers: { "x-lumine-idempotency-key": `cli:${randomUUID()}` },
1013
+ timeoutMs: options.timeoutMs,
1014
+ });
1015
+ const buildId = Number(receipt?.data?.workspace?.buildId || 0);
1016
+ if (!receipt?.ok || !buildId) {
1017
+ throw new Error(
1018
+ receipt?.error || "Could not open the proposal workspace copy.",
1019
+ );
1020
+ }
1021
+ await commitThumbnailImage({ options, auth, buildId, buffer, contentType });
1022
+ // stderr: --json output stays one parseable receipt.
1023
+ console.error(`Proposed thumbnail set on workspace copy (Build #${buildId}).`);
1024
+ }
1025
+
1026
+ function hashProposalThumbnailFile(filePath) {
1027
+ const extension = path.extname(filePath).toLowerCase();
1028
+ if (!THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension]) {
1029
+ throw cliValidationError(
1030
+ `--thumbnail must be a ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")} file, or "workspace".`,
1031
+ );
1032
+ }
1033
+ let buffer;
1034
+ try {
1035
+ buffer = readFileSync(path.resolve(filePath));
1036
+ } catch {
1037
+ throw cliValidationError(`Thumbnail file does not exist: ${filePath}`);
1038
+ }
1039
+ if (!buffer.length || buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
1040
+ throw cliValidationError(
1041
+ `Thumbnail file must be between 1 byte and ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}.`,
1042
+ );
1043
+ }
1044
+ return createHash("sha256").update(buffer).digest("hex");
1045
+ }
1046
+
941
1047
  const REWARD_PROPOSAL_MAX_FILES = 500;
942
1048
  const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
943
1049
  const REWARD_PROPOSAL_SKIPPED_DIRS = new Set([
@@ -1344,6 +1450,12 @@ export async function adminCommand(options) {
1344
1450
  (operation.mutates ? `cli:${randomUUID()}` : "");
1345
1451
  let result;
1346
1452
  try {
1453
+ // A step that must land before the request (e.g. a proposed thumbnail
1454
+ // uploaded to the reviewer's workspace copy); its failure is reported
1455
+ // like the request's own.
1456
+ if (typeof operation.prepare === "function") {
1457
+ await operation.prepare({ options, auth });
1458
+ }
1347
1459
  const fetchOperation = async (requestPath = operation.path, signal) => {
1348
1460
  const receipt = await requestJson({
1349
1461
  method: operation.method,
@@ -1556,6 +1668,9 @@ export async function adminCommand(options) {
1556
1668
  if (operation.name === "ai-email-policy.set") {
1557
1669
  assertAiEmailPolicySetResult({ operation, result });
1558
1670
  }
1671
+ if (AI_BUCKET_CHANGE_OPERATIONS.has(operation.name)) {
1672
+ assertAiBucketChangeResult({ operation, result });
1673
+ }
1559
1674
  if (operation.name === "daily-run.start") {
1560
1675
  assertAdminTodoHandoffResult(result, operation.body.scope);
1561
1676
  }
@@ -2025,6 +2140,42 @@ export function assertAiEmailPolicySetResult({ operation, result }) {
2025
2140
  throw error;
2026
2141
  }
2027
2142
 
2143
+ const AI_BUCKET_CHANGE_OPERATIONS = new Set([
2144
+ "ai-bucket.restrict",
2145
+ "ai-bucket.rule.add",
2146
+ "ai-bucket.rule.disable",
2147
+ ]);
2148
+
2149
+ // A bucket change is done only when the API re-read the canonical bucket and
2150
+ // confirmed it (verified), and a restriction reads back as the requested mode.
2151
+ export function assertAiBucketChangeResult({ operation, result }) {
2152
+ const data = result?.data || {};
2153
+ const requestedMode =
2154
+ operation?.name === "ai-bucket.restrict" ? operation.body?.mode : null;
2155
+ if (
2156
+ result?.ok === true &&
2157
+ data.verified === true &&
2158
+ Number(data.bucket?.id) > 0 &&
2159
+ (!requestedMode || data.bucket?.restriction?.mode === requestedMode)
2160
+ ) {
2161
+ return;
2162
+ }
2163
+ const error = new Error(
2164
+ "The API did not confirm the canonical AI bucket state after this change. Inspect the bucket before retrying.",
2165
+ );
2166
+ error.code = "LUMINE_ADMIN_AI_BUCKET_NOT_VERIFIED";
2167
+ error.data = {
2168
+ ok: false,
2169
+ status: "partial_failure",
2170
+ error: {
2171
+ code: error.code,
2172
+ message: error.message,
2173
+ details: { requestedMode, canonical: result?.data || null },
2174
+ },
2175
+ };
2176
+ throw error;
2177
+ }
2178
+
2028
2179
  const RECOMMENDATION_CONTENT_TYPES = new Map([
2029
2180
  ["comment", "comment"],
2030
2181
  ["aistory", "aiStory"],
@@ -2640,6 +2791,78 @@ export function parseAdminOperation(options) {
2640
2791
  },
2641
2792
  );
2642
2793
  }
2794
+ if (action === "inspect") {
2795
+ const reason = String(options.adminReason || "").trim();
2796
+ if (!reason) {
2797
+ throw cliValidationError(
2798
+ "Say why with --reason <management reason>; the inspection is audited.",
2799
+ );
2800
+ }
2801
+ if (reason.length > MAX_IDENTITY_INSPECTION_REASON_LENGTH) {
2802
+ throw cliValidationError(
2803
+ `An inspection reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
2804
+ );
2805
+ }
2806
+ // A read like identity inspect: private evidence is printed, never
2807
+ // kept on disk; the server records who looked, when and why.
2808
+ return bodyReadOperation(
2809
+ "ai-bucket.inspect",
2810
+ "POST",
2811
+ `/cli/admin/ai-buckets/${bucketId}/inspect`,
2812
+ {
2813
+ reason,
2814
+ includePrivateEvidence: options.adminIncludePrivateEvidence === true,
2815
+ },
2816
+ );
2817
+ }
2818
+ if (action === "restrict") {
2819
+ const mode = String(options.adminMode || "");
2820
+ if (!["none", "signup", "full"].includes(mode)) {
2821
+ throw cliValidationError("Pass --mode none, signup or full.");
2822
+ }
2823
+ const body = { mode };
2824
+ if (options.adminMessage !== undefined) {
2825
+ const message = String(options.adminMessage).trim();
2826
+ if (message.length > 500) {
2827
+ throw cliValidationError("--message must be at most 500 characters.");
2828
+ }
2829
+ body.message = message;
2830
+ }
2831
+ return aiBucketChangeOperation({
2832
+ options,
2833
+ name: "ai-bucket.restrict",
2834
+ method: "PUT",
2835
+ path: `/cli/admin/ai-buckets/${bucketId}/restriction`,
2836
+ body,
2837
+ // a restriction changes only on an explicit instruction
2838
+ confirmRequired: true,
2839
+ });
2840
+ }
2841
+ if (action === "rule" && target === "add") {
2842
+ return aiBucketChangeOperation({
2843
+ options,
2844
+ name: "ai-bucket.rule.add",
2845
+ method: "POST",
2846
+ path: `/cli/admin/ai-buckets/${bucketId}/rules`,
2847
+ body: parseAiBucketRuleBody(options),
2848
+ confirmRequired: true,
2849
+ });
2850
+ }
2851
+ if (action === "rule" && target === "disable") {
2852
+ const ruleId = parseRequiredInteger(options.adminRuleId, "--rule-id", 1);
2853
+ const note = String(options.note || "").trim();
2854
+ if (note.length > 180) {
2855
+ throw cliValidationError("--note must be at most 180 characters.");
2856
+ }
2857
+ return aiBucketChangeOperation({
2858
+ options,
2859
+ name: "ai-bucket.rule.disable",
2860
+ method: "POST",
2861
+ path: `/cli/admin/ai-buckets/${bucketId}/rules/${ruleId}/disable`,
2862
+ body: note ? { note } : {},
2863
+ confirmRequired: true,
2864
+ });
2865
+ }
2643
2866
  if (action === "note" && target === "set") {
2644
2867
  return writeOperation(
2645
2868
  "ai-bucket.note.set",
@@ -2728,7 +2951,7 @@ export function parseAdminOperation(options) {
2728
2951
  });
2729
2952
  if (operation) return operation;
2730
2953
  throw cliValidationError(
2731
- "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>.",
2954
+ "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>] [--title <new app name>] [--thumbnail <image|workspace>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
2732
2955
  );
2733
2956
  }
2734
2957
 
@@ -5065,6 +5288,108 @@ export function assertAdminTodoHandoffResult(result, expectedRunScope = null) {
5065
5288
  return handoff;
5066
5289
  }
5067
5290
 
5291
+ // --dry-run is a read (no receipt, nothing changes); a real run is a write
5292
+ // that needs --confirm, checked here and again by the server.
5293
+ function aiBucketChangeOperation({ options, name, method, path, body, confirmRequired }) {
5294
+ // --allow-shared-network lets a rule reach a shared network (an academy,
5295
+ // a school); it is sent, and printed, only when given
5296
+ if (options.adminAllowSharedNetwork) {
5297
+ if (!["ai-bucket.restrict", "ai-bucket.rule.add"].includes(name)) {
5298
+ throw cliValidationError("--allow-shared-network applies only to restrict and rule add.");
5299
+ }
5300
+ body = { ...body, allowSharedNetwork: true };
5301
+ }
5302
+ if (options.adminDryRun) {
5303
+ return bodyReadOperation(`${name}.preview`, method, path, {
5304
+ ...body,
5305
+ dryRun: true,
5306
+ });
5307
+ }
5308
+ const confirm = options.adminConfirm;
5309
+ if (confirm !== "" && confirm !== "true") {
5310
+ throw cliValidationError("Pass --confirm on its own, without a value.");
5311
+ }
5312
+ if (confirmRequired && confirm !== "true") {
5313
+ throw cliValidationError(
5314
+ "Every bucket change needs --confirm (a rule changes who shares AI Energy, and on a restricted bucket who is blocked). Run it with --dry-run first to see what it changes.",
5315
+ );
5316
+ }
5317
+ return writeOperation(name, method, path, {
5318
+ ...body,
5319
+ ...(confirm === "true" ? { confirm: true } : {}),
5320
+ });
5321
+ }
5322
+
5323
+ function parseAiBucketRuleBody(options) {
5324
+ const type = String(options.adminType || "");
5325
+ const note = String(options.note || "").trim();
5326
+ if (note.length > 180) {
5327
+ throw cliValidationError("--note must be at most 180 characters.");
5328
+ }
5329
+ const withNote = (body) => (note ? { ...body, note } : body);
5330
+ const scope = () => {
5331
+ const value = String(options.adminScope || "");
5332
+ if (!["signup", "full"].includes(value)) {
5333
+ throw cliValidationError(
5334
+ "Pass --scope signup or full for an IP or device rule.",
5335
+ );
5336
+ }
5337
+ return value;
5338
+ };
5339
+ if (type === "user") {
5340
+ return withNote({
5341
+ type,
5342
+ userId: parseRequiredInteger(options.adminUser, "--user", 1),
5343
+ });
5344
+ }
5345
+ if (type === "email") {
5346
+ const email = String(options.adminEmail || "").trim().toLowerCase();
5347
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
5348
+ throw cliValidationError("Pass a valid --email <address>.");
5349
+ }
5350
+ if (!options.adminDryRun && !options.adminConfirmEmailRule) {
5351
+ throw cliValidationError(
5352
+ "An email rule covers every account with that verified address. Pass --confirm-email-rule (run --dry-run first to see them).",
5353
+ );
5354
+ }
5355
+ return withNote({
5356
+ type,
5357
+ email,
5358
+ ...(options.adminConfirmEmailRule ? { confirmEmailRule: true } : {}),
5359
+ });
5360
+ }
5361
+ if (type === "ip") {
5362
+ const ip = String(options.adminIp || "").trim();
5363
+ if (!ip) throw cliValidationError("Pass --ip <address>.");
5364
+ return withNote({
5365
+ type,
5366
+ ip,
5367
+ scope: scope(),
5368
+ ...(options.adminIncludePrefix ? { includePrefix: true } : {}),
5369
+ });
5370
+ }
5371
+ if (type === "device") {
5372
+ if (!/^[a-f0-9]{16}$/.test(options.adminDeviceKey || "")) {
5373
+ throw cliValidationError(
5374
+ "Pass --device-key with the exact key from ai-bucket inspect or identity inspect.",
5375
+ );
5376
+ }
5377
+ if (!options.adminDryRun && !note) {
5378
+ throw cliValidationError("Say why with --note <reason> for a device rule.");
5379
+ }
5380
+ return withNote({
5381
+ type,
5382
+ sourceUserId: parseRequiredInteger(options.adminUser, "--user", 1),
5383
+ deviceKey: options.adminDeviceKey,
5384
+ scope: scope(),
5385
+ ...(options.adminUserIds || !options.adminDryRun
5386
+ ? { expectedUserIds: parseAiBucketUserIds(options.adminUserIds) }
5387
+ : {}),
5388
+ });
5389
+ }
5390
+ throw cliValidationError("Pass --type user, email, ip or device.");
5391
+ }
5392
+
5068
5393
  function readOperation(name, path, extra = {}) {
5069
5394
  return {
5070
5395
  name,
@@ -5749,6 +6074,10 @@ function printRewardReviewResult({ operation, data }) {
5749
6074
  );
5750
6075
  for (const file of review.proposal.changedFiles || [])
5751
6076
  console.log(` ${file.status}: ${file.path}`);
6077
+ if (review.proposal.title)
6078
+ console.log(` new name: ${review.proposal.title}`);
6079
+ if (review.proposal.thumbnailUrl)
6080
+ console.log(` new thumbnail: ${review.proposal.thumbnailUrl}`);
5752
6081
  }
5753
6082
  if (review.status === "rejected" && review.declinedByCreator) {
5754
6083
  console.log(
@@ -6826,6 +7155,174 @@ function printRewardBankReport(data) {
6826
7155
  }
6827
7156
  }
6828
7157
 
7158
+ function aiBucketRestrictionLabel(restriction) {
7159
+ const mode = restriction?.mode || "none";
7160
+ const label = mode === "none" ? "unrestricted" : mode === "signup" ? "signup-only" : "full";
7161
+ return restriction?.message ? `${label} — "${restriction.message}"` : label;
7162
+ }
7163
+
7164
+ function formatAiBucketAccounts(accounts) {
7165
+ return (accounts || []).length
7166
+ ? accounts.map((a) => `${a.username || "?"} (#${a.userId})`).join(", ")
7167
+ : "none";
7168
+ }
7169
+
7170
+ function printAiBucketNetworkRuleChanges(changes) {
7171
+ if (!changes?.length) return;
7172
+ console.log("Network/device rules whose restriction changes:");
7173
+ for (const c of changes) {
7174
+ const r = c.reach;
7175
+ const reach = !c.strengthens
7176
+ ? "loosens"
7177
+ : !r?.measured
7178
+ ? "reach NOT MEASURED"
7179
+ : `${r.accountCount}${r.lowerBound ? "+" : ""} account(s)${r.sharedNetwork ? " — SHARED NETWORK" : ""}`;
7180
+ console.log(` #${c.ruleId} ${c.kind} ${c.match}: ${c.from} → ${c.to} · ${reach}`);
7181
+ }
7182
+ }
7183
+
7184
+ function printAiBucketBlockers(blockers, overrideGiven) {
7185
+ for (const blocker of blockers || []) {
7186
+ if (blocker.overridable && overrideGiven) {
7187
+ console.log(`OVERRIDDEN by --allow-shared-network (${blocker.code}): ${blocker.message}`);
7188
+ } else {
7189
+ console.log(`BLOCKED (${blocker.code}): ${blocker.message}${blocker.overridable ? " Only --allow-shared-network, on Mikey's explicit word, lifts this." : ""}`);
7190
+ }
7191
+ }
7192
+ }
7193
+
7194
+ function printAiBucketRules(rules, indent = " ") {
7195
+ for (const rule of rules || []) {
7196
+ console.log(
7197
+ `${indent}#${rule.id} ${rule.kind} ${rule.match} · scope ${rule.scope} · effective ${rule.effectiveRestriction}${rule.note ? ` · ${rule.note}` : ""}`,
7198
+ );
7199
+ }
7200
+ }
7201
+
7202
+ // The bucket commands of todo 79 (inspect, restrict, rule add/disable and
7203
+ // their --dry-run previews). Returns false for the older ai-bucket commands.
7204
+ function printAiBucketRuleResult({ operation, data }) {
7205
+ const name = operation.name;
7206
+ const head = (bucket) =>
7207
+ `AI bucket #${bucket.id} (${bucket.label}): ${aiBucketRestrictionLabel(bucket.restriction)}`;
7208
+ if (name === "ai-bucket.inspect") {
7209
+ console.log(`${head(data.bucket)}; ${data.rules.length} active rule(s), ${data.memberCount} member account(s).`);
7210
+ if (data.bucket.note) console.log(`Note: ${data.bucket.note}`);
7211
+ console.log("Rules:");
7212
+ printAiBucketRules(data.rules);
7213
+ console.log(
7214
+ `Members: ${(data.members || []).map((m) => `${m.username || "?"} (#${m.userId}${m.viaEmailRule ? ", via email rule" : ""})`).join(", ") || "none"}`,
7215
+ );
7216
+ for (const account of data.evidence?.accounts || []) {
7217
+ console.log(`Evidence for ${account.username || "?"} (#${account.userId})${account.canonicalBucketId && account.canonicalBucketId !== data.bucket.id ? ` · resolves to bucket #${account.canonicalBucketId}` : ""}:`);
7218
+ if (!account.devices.length) console.log(" Devices: none recorded");
7219
+ for (const device of account.devices) {
7220
+ const rule = device.ruleId ? ` · rule #${device.ruleId}` : "";
7221
+ if (device.sharedDevice) {
7222
+ console.log(` Device ${device.key}: shared by ${device.accountCount} accounts (not listed; not family evidence)${rule}`);
7223
+ continue;
7224
+ }
7225
+ const others = device.otherAccounts.length
7226
+ ? device.otherAccounts.map((a) => `${a.username || "?"} (#${a.userId})${a.inBucket ? "" : " OUTSIDE bucket"}`).join(", ")
7227
+ : "no other account";
7228
+ console.log(` Device ${device.key}: ${device.accountCount} account(s); others: ${others}${device.accountsTruncated ? " (more not listed)" : ""}${device.status !== "complete" ? ` · ${device.status}` : ""}${rule}`);
7229
+ }
7230
+ printNetwork(account.network, " ");
7231
+ const ruled = (account.network?.addresses || []).filter((a) => a.ruleId);
7232
+ if (ruled.length) {
7233
+ console.log(` IP rules matching these addresses: ${ruled.map((a) => `#${a.ruleId} [${a.addressId}]`).join(", ")}`);
7234
+ }
7235
+ }
7236
+ if (data.evidence?.membersNotInspected) {
7237
+ console.log(`Evidence covers the first ${data.evidence.membersInspected} members; ${data.evidence.membersNotInspected} not inspected.`);
7238
+ }
7239
+ console.log(data.interpretation);
7240
+ return true;
7241
+ }
7242
+ if (name === "ai-bucket.restrict.preview") {
7243
+ const p = data.preview;
7244
+ console.log(`DRY RUN ${head(data.bucket)} → ${aiBucketRestrictionLabel(p.to)}${p.changed ? "" : " (no change)"}.`);
7245
+ console.log(`Accounts it reaches: ${formatAiBucketAccounts(p.memberAccounts)}`);
7246
+ console.log(`Sessions dropped: ${p.sessionsKickedUserIds.length ? p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${p.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
7247
+ if (p.fullBlockLiftedUserIds.length) console.log(`Full block lifted for: ${p.fullBlockLiftedUserIds.map((id) => `#${id}`).join(", ")} (unless another restriction still applies).`);
7248
+ if (p.adminConflictUserIds.length) console.log(`BLOCKED: admin account(s) ${p.adminConflictUserIds.map((id) => `#${id}`).join(", ")} cannot be fully restricted.`);
7249
+ printAiBucketNetworkRuleChanges(p.networkRuleChanges);
7250
+ printAiBucketBlockers(p.blockers, data.sharedNetworkOverride);
7251
+ console.log("Nothing was changed. Run again with --confirm to apply.");
7252
+ return true;
7253
+ }
7254
+ if (name === "ai-bucket.restrict") {
7255
+ console.log(`${head(data.bucket)} (was ${aiBucketRestrictionLabel(data.previous)}); verified.`);
7256
+ printAiBucketNetworkRuleChanges(data.networkRuleChanges);
7257
+ if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — a shared network or unmeasured rule is now restricted.");
7258
+ console.log(`Sessions dropped: ${data.kickedUserIds.length ? data.kickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${data.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
7259
+ return true;
7260
+ }
7261
+ if (name === "ai-bucket.rule.add.preview") {
7262
+ console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
7263
+ if (data.device) {
7264
+ const d = data.device;
7265
+ if (d.sharedDevice) {
7266
+ console.log(`Shared device: ${d.observedAccountCount} accounts (${d.outsideBucketCount} outside this bucket); not listed, not family evidence.`);
7267
+ } else {
7268
+ console.log(`Device ${d.key} (${d.scope}) from #${d.sourceUserId}: seen on ${d.observedUserIds.map((id) => `#${id}`).join(", ") || "no account"} in ${d.lookbackDays} days; effective ${data.effectiveRestriction}.`);
7269
+ if (d.outsideBucketUserIds.length) console.log(`Outside this bucket: ${d.outsideBucketUserIds.map((id) => `#${id}`).join(", ")}`);
7270
+ }
7271
+ if (d.existingRuleId) console.log(`Existing rule #${d.existingRuleId} in bucket #${d.existingRuleBucketId}.`);
7272
+ if (!(data.blockers || []).length) console.log(`Pass the reviewed accounts with --user-ids ${d.observedUserIds.join(",")} on the real run.`);
7273
+ } else {
7274
+ const p = data.preview;
7275
+ const r = p.rule;
7276
+ console.log(`Rule: ${r.type} ${r.userId ? `#${r.userId}` : r.email || r.ip}${r.scope ? ` · scope ${r.scope}` : ""}${r.prefixRuleTypes?.length ? ` + prefix rules ${r.prefixRuleTypes.join(", ")}` : ""}; effective ${p.effectiveRestriction}.`);
7277
+ if (p.existingRule) console.log(`Existing rule #${p.existingRule.ruleId} in bucket #${p.existingRule.bucketId} (${p.existingRule.bucketLabel}).`);
7278
+ if (p.affectedAccounts) console.log(`Accounts it names: ${formatAiBucketAccounts(p.affectedAccounts)}`);
7279
+ if (p.exactAddress) {
7280
+ const a = p.exactAddress;
7281
+ console.log(a.kind === "shared_network"
7282
+ ? `Address [${a.addressId}]: shared network, ${a.accountCount} accounts (not listed; not family evidence).`
7283
+ : `Address [${a.addressId}]: ${a.accountCount} account(s): ${formatAiBucketAccounts(a.accounts)}${a.olderHistoryNotRead ? " · older history not read" : ""}`);
7284
+ }
7285
+ if (p.prefixReach) {
7286
+ const r = p.prefixReach;
7287
+ console.log(r.measured
7288
+ ? `Prefix: at least ${r.accountCount} account(s) used AI from it${r.sharedNetwork ? " — shared network" : ""}${r.olderHistoryNotRead ? " · older history not read" : ""} (accounts that never used AI there are not counted).`
7289
+ : "Prefix: reach could not be measured.");
7290
+ }
7291
+ for (const existing of p.existingPrefixRules || []) {
7292
+ if (existing) console.log(`Existing prefix rule #${existing.ruleId} in bucket #${existing.bucketId} (${existing.scope}).`);
7293
+ }
7294
+ if (p.sessionsKickedUserIds.length) console.log(`Sessions dropped: ${p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ")}`);
7295
+ }
7296
+ printAiBucketBlockers(data.blockers || data.preview?.blockers || [], data.sharedNetworkOverride);
7297
+ console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
7298
+ return true;
7299
+ }
7300
+ if (name === "ai-bucket.rule.add") {
7301
+ console.log(`${head(data.bucket)}; rule saved; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
7302
+ printAiBucketRules(data.addedRules);
7303
+ if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — this rule reaches a shared network.");
7304
+ if (data.kickedUserIds?.length) console.log(`Sessions dropped: ${data.kickedUserIds.map((id) => `#${id}`).join(", ")}`);
7305
+ return true;
7306
+ }
7307
+ if (name === "ai-bucket.rule.disable.preview") {
7308
+ const p = data.preview;
7309
+ console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
7310
+ printAiBucketRules([p.rule]);
7311
+ console.log(p.alreadyDisabled ? "Already disabled." : `Released accounts: ${formatAiBucketAccounts(p.releasedAccounts)}${p.liftsRestriction ? " (each keeps any other restriction that still applies)" : ""}.`);
7312
+ if (p.prefixRulesRemainActive) {
7313
+ console.log(`${p.prefixNote}${p.companionPrefixRuleIds?.length ? ` Its prefix rules here: ${p.companionPrefixRuleIds.map((id) => `#${id}`).join(", ")}.` : ""}`);
7314
+ }
7315
+ console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
7316
+ return true;
7317
+ }
7318
+ if (name === "ai-bucket.rule.disable") {
7319
+ console.log(`${head(data.bucket)}; rule #${data.disabledRule.id} disabled; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
7320
+ console.log(`Released accounts: ${formatAiBucketAccounts(data.releasedAccounts)}`);
7321
+ return true;
7322
+ }
7323
+ return false;
7324
+ }
7325
+
6829
7326
  function printAdminResult({ operation, result }) {
6830
7327
  const data = result?.data || {};
6831
7328
  if (printChatSafetyResult({ operation, data })) return;
@@ -6838,6 +7335,9 @@ function printAdminResult({ operation, result }) {
6838
7335
  for (const line of formatProfileReview(data)) console.log(line);
6839
7336
  return;
6840
7337
  }
7338
+ if (operation.name.startsWith("ai-bucket.") && printAiBucketRuleResult({ operation, data })) {
7339
+ return;
7340
+ }
6841
7341
  if (operation.name === "reward-activity.report") {
6842
7342
  printRewardActivity(data);
6843
7343
  return;
package/lib/commands.js CHANGED
@@ -2478,7 +2478,14 @@ export function parseArgs(args) {
2478
2478
  "show",
2479
2479
  "foreground",
2480
2480
  "open",
2481
+ "dryRun",
2482
+ "confirmEmailRule",
2483
+ "includePrefix",
2484
+ "allowSharedNetwork",
2481
2485
  ]);
2486
+ // A flag that may stand alone (true) or carry a value: `--confirm` confirms
2487
+ // an AI bucket change; `meetup slot <crewId> --confirm <slotIndex>` picks a slot.
2488
+ const optionalValueFlags = new Set(["confirm"]);
2482
2489
 
2483
2490
  for (let i = 0; i < rest.length; i += 1) {
2484
2491
  const arg = rest[i];
@@ -2499,6 +2506,12 @@ export function parseArgs(args) {
2499
2506
  : inlineValue;
2500
2507
  } else if (booleanFlags.has(camelKey)) {
2501
2508
  raw[camelKey] = true;
2509
+ } else if (
2510
+ optionalValueFlags.has(camelKey) &&
2511
+ // a following flag (`--json`, `-h`) is never taken as the value
2512
+ (rest[i + 1] === undefined || rest[i + 1].startsWith("-"))
2513
+ ) {
2514
+ raw[camelKey] = true;
2502
2515
  } else {
2503
2516
  const nextValue = rest[i + 1];
2504
2517
  if (
@@ -2732,6 +2745,13 @@ export function parseArgs(args) {
2732
2745
  : "",
2733
2746
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2734
2747
  adminDeviceKey: raw.deviceKey ? String(raw.deviceKey) : "",
2748
+ adminRuleId: raw.ruleId ? String(raw.ruleId) : "",
2749
+ adminIp: raw.ip ? String(raw.ip) : "",
2750
+ adminMessage: raw.message === undefined ? undefined : String(raw.message),
2751
+ adminDryRun: Boolean(raw.dryRun),
2752
+ adminConfirmEmailRule: Boolean(raw.confirmEmailRule),
2753
+ adminIncludePrefix: Boolean(raw.includePrefix),
2754
+ adminAllowSharedNetwork: Boolean(raw.allowSharedNetwork),
2735
2755
  adminLabel: raw.label ? String(raw.label) : "",
2736
2756
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2737
2757
  adminUser: raw.user ? String(raw.user) : "",
@@ -2786,6 +2806,8 @@ export function parseArgs(args) {
2786
2806
  adminForeground: Boolean(raw.foreground),
2787
2807
  adminOpen: Boolean(raw.open),
2788
2808
  adminConfigFile: raw.config ? String(raw.config) : "",
2809
+ // admin review propose --thumbnail <image|workspace>
2810
+ adminThumbnail: raw.thumbnail ? String(raw.thumbnail) : "",
2789
2811
  adminReviewedBuildVersion: raw.reviewedVersion
2790
2812
  ? String(raw.reviewedVersion)
2791
2813
  : "",
@@ -3177,6 +3199,15 @@ export function printHelp() {
3177
3199
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
3178
3200
  lumine admin ai-bucket device block-signup --bucket-id <id> --user <source-user-id> --device-key <inspection-key> --user-ids <reviewed-device-users> --note <reason> [--json]
3179
3201
  lumine admin ai-bucket note set --bucket-id <id> --note <text> [--json]
3202
+ lumine admin ai-bucket inspect --bucket-id <id> --reason <management reason> [--include-private-evidence] [--json]
3203
+ lumine admin ai-bucket restrict --bucket-id <id> --mode none|signup|full [--message <text>] [--allow-shared-network] (--dry-run | --confirm) [--json]
3204
+ lumine admin ai-bucket rule add --bucket-id <id> --type user --user <id> [--note <text>] (--dry-run | --confirm) [--json]
3205
+ lumine admin ai-bucket rule add --bucket-id <id> --type email --email <address> [--note <text>] (--dry-run | --confirm-email-rule --confirm) [--json]
3206
+ lumine admin ai-bucket rule add --bucket-id <id> --type ip --ip <address> --scope signup|full [--include-prefix] [--allow-shared-network] [--note <text>] (--dry-run | --confirm) [--json]
3207
+ lumine admin ai-bucket rule add --bucket-id <id> --type device --user <source-user-id> --device-key <inspect-key> --scope signup|full --user-ids <reviewed-device-users> --note <reason> (--dry-run | --confirm) [--json]
3208
+ lumine admin ai-bucket rule disable --bucket-id <id> --rule-id <id> [--note <text>] (--dry-run | --confirm) [--json]
3209
+ (every real change needs --confirm; --dry-run shows exactly what would change and writes nothing;
3210
+ a shared network (academy, school) or an unmeasured prefix is refused unless --allow-shared-network, only on Mikey's explicit word)
3180
3211
  lumine admin ai-email-policy get --email <address> [--json]
3181
3212
  lumine admin ai-email-policy set --email <address> --mode <automatic|separate_accounts> --note <text> [--json]
3182
3213
  lumine admin daily-run start [--scope full|featured|newspaper] [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
@@ -3216,7 +3247,7 @@ export function printHelp() {
3216
3247
  lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
3217
3248
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
3218
3249
  lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
3219
- lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--json]
3250
+ lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
3220
3251
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
3221
3252
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
3222
3253
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
package/lib/thumbnail.js CHANGED
@@ -134,6 +134,27 @@ export async function thumbnailSet(options) {
134
134
  if (!filePath) {
135
135
  throw new Error("Usage: lumine thumbnail set <file> (jpg, png, or webp)");
136
136
  }
137
+ const { buffer, contentType } = await readThumbnailFile(filePath);
138
+
139
+ const { buildId, auth, build } = await resolveThumbnailContext(options);
140
+ const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
141
+ if (!proceed) return;
142
+
143
+ const { thumbnailUrl } = await commitThumbnailImage({
144
+ options,
145
+ auth,
146
+ buildId,
147
+ buffer,
148
+ contentType,
149
+ });
150
+ console.log(`Thumbnail set for Build #${buildId}:`);
151
+ console.log(` ${thumbnailUrl}`);
152
+ console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
153
+ }
154
+
155
+ // A local jpg/png/webp checked against the thumbnail limits, ready for
156
+ // commitThumbnailImage (also used by `lumine admin review propose --thumbnail`).
157
+ export async function readThumbnailFile(filePath) {
137
158
  const absolutePath = path.resolve(filePath);
138
159
  const extension = path.extname(absolutePath).toLowerCase();
139
160
  const contentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension];
@@ -159,21 +180,7 @@ export async function thumbnailSet(options) {
159
180
  `Thumbnail file is too big (${formatBytes(buffer.length)}; max ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}).`,
160
181
  );
161
182
  }
162
-
163
- const { buildId, auth, build } = await resolveThumbnailContext(options);
164
- const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
165
- if (!proceed) return;
166
-
167
- const { thumbnailUrl } = await commitThumbnailImage({
168
- options,
169
- auth,
170
- buildId,
171
- buffer,
172
- contentType,
173
- });
174
- console.log(`Thumbnail set for Build #${buildId}:`);
175
- console.log(` ${thumbnailUrl}`);
176
- console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
183
+ return { buffer, contentType };
177
184
  }
178
185
 
179
186
  function decodeImageDataUrl(dataUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.30",
3
+ "version": "0.3.32",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -131,7 +131,8 @@ canonical bucket. Use it to distinguish quota bookkeeping from moderation;
131
131
  the note itself changes no access, ban, or identity rules.
132
132
 
133
133
  The commands above are quota bookkeeping only. They cannot ban accounts, block signup,
134
- add IP/device/risk-key rules, or infer an account family. Identification
134
+ add IP/device/risk-key rules, or infer an account family (the explicit
135
+ restricted-bucket commands below can, on Mikey's instruction). Identification
135
136
  remains a human/LLM evidence judgment and must be explicitly requested by
136
137
  Mikey; routine administrator runs still escalate suspected alternate accounts
137
138
  and never auto-enforce. `accounts add` and `note set` still accept only an
@@ -159,6 +160,85 @@ raw device ID. The private audit records the request before evidence is read
159
160
  and records successful completion separately. Routine reviews still never
160
161
  auto-enforce; this action requires Mikey's explicit instruction for the device.
161
162
 
163
+ ### Restricted buckets: inspect, rules and restrictions
164
+
165
+ These are the operator path for buckets with a restriction (and for adding
166
+ email, IP and device rules to any bucket). They use the same shared operations
167
+ as the Management page, so each change has the same side effects there
168
+ (current-day usage re-attribution, ban cache, member notification and kicks,
169
+ live quota updates). Use them only on Mikey's explicit instruction for that
170
+ bucket, rule or restriction.
171
+
172
+ ```bash
173
+ lumine admin ai-bucket inspect --bucket-id 4 --reason "todo 79 review" --json
174
+ lumine admin ai-bucket restrict --bucket-id 4 --mode signup --dry-run
175
+ lumine admin ai-bucket restrict --bucket-id 4 --mode signup --confirm
176
+ lumine admin ai-bucket rule add --bucket-id 4 --type user --user 18559 --dry-run
177
+ lumine admin ai-bucket rule add --bucket-id 4 --type user --user 18559 --confirm
178
+ lumine admin ai-bucket rule add --bucket-id 4 --type email \
179
+ --email family@example.com --confirm-email-rule --confirm
180
+ lumine admin ai-bucket rule add --bucket-id 4 --type ip --ip 58.151.12.59 \
181
+ --scope signup [--include-prefix] --confirm
182
+ lumine admin ai-bucket rule add --bucket-id 4 --type device --user 18559 \
183
+ --device-key 0123456789abcdef --scope signup --user-ids 18559,20001 \
184
+ --note "Mikey approved this reviewed device" --confirm
185
+ lumine admin ai-bucket rule disable --bucket-id 4 --rule-id 77 --dry-run
186
+ ```
187
+
188
+ `inspect` works for unrestricted and restricted buckets. It returns the
189
+ canonical bucket (label, note, restriction `none|signup|full`, message), every
190
+ active rule (id, kind, scope, effective restriction, match), the canonical
191
+ members, every account a full restriction would reach, and for each member
192
+ (first 25) their devices (inspection keys, never raw IDs) and network
193
+ addresses with how many accounts share each. A device or address used by many
194
+ accounts (an academy, a school) is counted, marked shared and never listed or
195
+ treated as family evidence. Addresses and email-rule addresses (in evidence, rules and rule notes)
196
+ are masked unless `--include-private-evidence`; every other output, and the
197
+ audit, keeps them masked (with a keyed hash in the audit). `--reason` is required; the API commits an
198
+ access receipt before reading evidence.
199
+
200
+ `--dry-run` on `restrict`, `rule add` and `rule disable` is a read: it returns
201
+ the current and resulting restriction, the accounts newly affected or released,
202
+ the accounts whose sessions a full restriction drops, admin conflicts and any
203
+ blocker, and changes nothing. Device, IP and email previews read private
204
+ evidence, so they record an access receipt; the others record nothing.
205
+
206
+ A real run is audited (request receipt with the idempotency key and payload
207
+ fingerprint, then the completed result) and returns the bucket read back from
208
+ the database and checked (`verified: true`); the CLI refuses a result that
209
+ does not confirm the requested state. A failure after the change started is
210
+ recorded as failed; a replay with the same idempotency key returns the stored
211
+ result instead of running again.
212
+
213
+ - `restrict` always needs `--confirm`. `signup` blocks only new accounts:
214
+ existing logins and existing bans (`users.banned`) stay as they are. `full`
215
+ drops members' sessions and is refused while any member is an admin. `none`
216
+ lifts the restriction; each member gets its effective ban state pushed again.
217
+ Without `--message` the current message is kept. The preview and the
218
+ result list every IP, prefix and device rule whose effective restriction
219
+ changes, with how many accounts it reaches (exact IPs from site activity;
220
+ prefixes and devices from a year of AI usage, a lower bound). A rule that
221
+ gets stronger on a shared network (more than 8 accounts) or whose reach
222
+ can't be measured refuses the change unless `--allow-shared-network` is
223
+ passed; use it only on Mikey's explicit word. The CLI prints the override.
224
+ Saving a full bucket again (even only a new message) re-kicks its members,
225
+ as on the Management page; the preview lists them.
226
+ - `rule add` / `rule disable` always need `--confirm` too: on an unrestricted
227
+ bucket a rule still pools AI Energy, on a restricted one it changes who is
228
+ blocked. On a fully restricted bucket an account or email rule re-kicks every
229
+ account the bucket reaches (as on the Management page); the preview lists them.
230
+ Email rules cover every account with that verified address and also need
231
+ `--confirm-email-rule`. IP and device rules need `--scope`; IP prefix rules
232
+ (`--include-prefix`, the whole /24 or /64) are opt-in, their reach measured
233
+ as above. A shared exact address or prefix, or an unmeasured prefix under a
234
+ full restriction, is refused unless `--allow-shared-network`. An address or
235
+ prefix rule held by another bucket is refused, never moved. Device rules
236
+ keep the `device block-signup` guards: the reviewed `--user-ids` must equal
237
+ the accounts seen on the device, all inside the bucket; a device more than
238
+ 9 accounts used is shared (counted, not listed, refused). Disabling an exact
239
+ IP rule leaves its prefix rules active (the preview names them).
240
+ `rule disable` keeps `--note` in the audit receipt only.
241
+
162
242
  ### Shared verified-email policies
163
243
 
164
244
  A verified address proves that an account can use that mailbox; it does not
@@ -1320,10 +1400,19 @@ lumine admin reward-review approve 2 --config rules.json \
1320
1400
  lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json # then edit that directory…
1321
1401
  lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 \
1322
1402
  --config rules.json --reason "Moved the claim after the stage clears" --json # …and offer it
1403
+ lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 --config rules.json \
1404
+ --title "Word Forge" --thumbnail cover.png --json # …also offering a new name and thumbnail
1323
1405
  lumine admin reward-review reject 2 --reason "Rewards fire on game over; nothing is earned" --json
1324
1406
  lumine admin reward-review revoke 2 --reason "Farmable; pausing until redesigned" --json
1325
1407
  ```
1326
1408
 
1409
+ A proposal can also offer the app a new name (`--title`, at most 100
1410
+ characters) and a new thumbnail (`--thumbnail <jpg|png|webp>`, uploaded to your
1411
+ proposal workspace copy first; `--thumbnail workspace` offers the one already
1412
+ set on that copy, e.g. from the website). Both are frozen with the offer and
1413
+ applied together with the files and rules when the creator accepts. An offer
1414
+ may change only the name or thumbnail; the files can then stay as submitted.
1415
+
1327
1416
  `show --dir` writes the exact reviewed snapshot (private files, including the
1328
1417
  app's own `rewards.json`) so the agent can read it like a pulled workspace. The
1329
1418
  result's `config` is the proposal: the declared economy with the sheet's