@stage5/lumine 0.3.31 → 0.3.33

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,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";
@@ -39,6 +44,7 @@ import {
39
44
  printOwnerTrace,
40
45
  } from "./admin-owner-trace.js";
41
46
  import { buildRolesOperation, printRoles } from "./admin-roles.js";
47
+ import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
42
48
  import { formatChatReportResult } from "./admin-chat-reports.js";
43
49
  import { formatIdentityDeviceUsage } from "./admin-identity-inspection.js";
44
50
  import {
@@ -202,7 +208,7 @@ const REVIEW_REQUEST_LABELS = {
202
208
  cardcraft: "AI Card crafting",
203
209
  };
204
210
  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.";
211
+ "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
212
 
207
213
  function parseReviewRequestType(value) {
208
214
  const key = String(value || "")
@@ -945,6 +951,20 @@ function buildRewardProposalOperation(reviewId, options) {
945
951
  );
946
952
  }
947
953
  const files = readRewardProposalDirectory(options.dir);
954
+ // --title proposes a new app name; --thumbnail <file> a new thumbnail (set
955
+ // on your proposal workspace copy first), or --thumbnail workspace to offer
956
+ // the one already set on that copy (e.g. from the website).
957
+ const title = String(options.title || "").trim();
958
+ if (title.length > 100) {
959
+ throw cliValidationError("--title must be at most 100 characters.");
960
+ }
961
+ const thumbnail = String(options.adminThumbnail || "").trim();
962
+ // Checked before signing in or opening the copy; its hash makes a retry
963
+ // with a different picture a new request rather than a replay.
964
+ const thumbnailSha256 =
965
+ thumbnail && thumbnail !== "workspace"
966
+ ? hashProposalThumbnailFile(thumbnail)
967
+ : "";
948
968
  return writeOperation(
949
969
  "reward-review.propose",
950
970
  "POST",
@@ -953,11 +973,78 @@ function buildRewardProposalOperation(reviewId, options) {
953
973
  files,
954
974
  config: readRewardConfigFile(options.adminConfigFile),
955
975
  reason,
976
+ ...(title ? { title } : {}),
977
+ ...(thumbnail ? { thumbnailFrom: "workspace" } : {}),
978
+ ...(thumbnailSha256 ? { thumbnailSha256 } : {}),
979
+ },
980
+ {
981
+ requiresRun: false,
982
+ reviewId,
983
+ fileCount: files.length,
984
+ ...(thumbnail && thumbnail !== "workspace"
985
+ ? {
986
+ prepare: ({ options: runOptions, auth }) =>
987
+ setProposalWorkspaceThumbnail({
988
+ options: runOptions,
989
+ auth,
990
+ reviewId,
991
+ filePath: thumbnail,
992
+ }),
993
+ }
994
+ : {}),
956
995
  },
957
- { requiresRun: false, reviewId, fileCount: files.length },
958
996
  );
959
997
  }
960
998
 
999
+ // Puts a proposed thumbnail on the reviewer's proposal workspace copy (opened
1000
+ // on first use), the copy the offer's thumbnailFrom 'workspace' reads.
1001
+ async function setProposalWorkspaceThumbnail({
1002
+ options,
1003
+ auth,
1004
+ reviewId,
1005
+ filePath,
1006
+ }) {
1007
+ const { buffer, contentType } = await readThumbnailFile(filePath);
1008
+ const receipt = await requestJson({
1009
+ method: "POST",
1010
+ url: `${options.apiUrl}/cli/admin/reward-reviews/${reviewId}/workspace`,
1011
+ authToken: auth.token,
1012
+ body: {},
1013
+ headers: { "x-lumine-idempotency-key": `cli:${randomUUID()}` },
1014
+ timeoutMs: options.timeoutMs,
1015
+ });
1016
+ const buildId = Number(receipt?.data?.workspace?.buildId || 0);
1017
+ if (!receipt?.ok || !buildId) {
1018
+ throw new Error(
1019
+ receipt?.error || "Could not open the proposal workspace copy.",
1020
+ );
1021
+ }
1022
+ await commitThumbnailImage({ options, auth, buildId, buffer, contentType });
1023
+ // stderr: --json output stays one parseable receipt.
1024
+ console.error(`Proposed thumbnail set on workspace copy (Build #${buildId}).`);
1025
+ }
1026
+
1027
+ function hashProposalThumbnailFile(filePath) {
1028
+ const extension = path.extname(filePath).toLowerCase();
1029
+ if (!THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension]) {
1030
+ throw cliValidationError(
1031
+ `--thumbnail must be a ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")} file, or "workspace".`,
1032
+ );
1033
+ }
1034
+ let buffer;
1035
+ try {
1036
+ buffer = readFileSync(path.resolve(filePath));
1037
+ } catch {
1038
+ throw cliValidationError(`Thumbnail file does not exist: ${filePath}`);
1039
+ }
1040
+ if (!buffer.length || buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
1041
+ throw cliValidationError(
1042
+ `Thumbnail file must be between 1 byte and ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}.`,
1043
+ );
1044
+ }
1045
+ return createHash("sha256").update(buffer).digest("hex");
1046
+ }
1047
+
961
1048
  const REWARD_PROPOSAL_MAX_FILES = 500;
962
1049
  const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
963
1050
  const REWARD_PROPOSAL_SKIPPED_DIRS = new Set([
@@ -1364,6 +1451,12 @@ export async function adminCommand(options) {
1364
1451
  (operation.mutates ? `cli:${randomUUID()}` : "");
1365
1452
  let result;
1366
1453
  try {
1454
+ // A step that must land before the request (e.g. a proposed thumbnail
1455
+ // uploaded to the reviewer's workspace copy); its failure is reported
1456
+ // like the request's own.
1457
+ if (typeof operation.prepare === "function") {
1458
+ await operation.prepare({ options, auth });
1459
+ }
1367
1460
  const fetchOperation = async (requestPath = operation.path, signal) => {
1368
1461
  const receipt = await requestJson({
1369
1462
  method: operation.method,
@@ -1576,6 +1669,9 @@ export async function adminCommand(options) {
1576
1669
  if (operation.name === "ai-email-policy.set") {
1577
1670
  assertAiEmailPolicySetResult({ operation, result });
1578
1671
  }
1672
+ if (AI_BUCKET_CHANGE_OPERATIONS.has(operation.name)) {
1673
+ assertAiBucketChangeResult({ operation, result });
1674
+ }
1579
1675
  if (operation.name === "daily-run.start") {
1580
1676
  assertAdminTodoHandoffResult(result, operation.body.scope);
1581
1677
  }
@@ -2045,6 +2141,42 @@ export function assertAiEmailPolicySetResult({ operation, result }) {
2045
2141
  throw error;
2046
2142
  }
2047
2143
 
2144
+ const AI_BUCKET_CHANGE_OPERATIONS = new Set([
2145
+ "ai-bucket.restrict",
2146
+ "ai-bucket.rule.add",
2147
+ "ai-bucket.rule.disable",
2148
+ ]);
2149
+
2150
+ // A bucket change is done only when the API re-read the canonical bucket and
2151
+ // confirmed it (verified), and a restriction reads back as the requested mode.
2152
+ export function assertAiBucketChangeResult({ operation, result }) {
2153
+ const data = result?.data || {};
2154
+ const requestedMode =
2155
+ operation?.name === "ai-bucket.restrict" ? operation.body?.mode : null;
2156
+ if (
2157
+ result?.ok === true &&
2158
+ data.verified === true &&
2159
+ Number(data.bucket?.id) > 0 &&
2160
+ (!requestedMode || data.bucket?.restriction?.mode === requestedMode)
2161
+ ) {
2162
+ return;
2163
+ }
2164
+ const error = new Error(
2165
+ "The API did not confirm the canonical AI bucket state after this change. Inspect the bucket before retrying.",
2166
+ );
2167
+ error.code = "LUMINE_ADMIN_AI_BUCKET_NOT_VERIFIED";
2168
+ error.data = {
2169
+ ok: false,
2170
+ status: "partial_failure",
2171
+ error: {
2172
+ code: error.code,
2173
+ message: error.message,
2174
+ details: { requestedMode, canonical: result?.data || null },
2175
+ },
2176
+ };
2177
+ throw error;
2178
+ }
2179
+
2048
2180
  const RECOMMENDATION_CONTENT_TYPES = new Map([
2049
2181
  ["comment", "comment"],
2050
2182
  ["aistory", "aiStory"],
@@ -2452,6 +2584,10 @@ export function parseAdminOperation(options) {
2452
2584
  if (namespace === "roles") {
2453
2585
  return buildRolesOperation(options);
2454
2586
  }
2587
+ // Twinkle.craft universe curation (admin-craft.js); no run.
2588
+ if (namespace === "craft") {
2589
+ return buildCraftOperation({ action, options });
2590
+ }
2455
2591
 
2456
2592
  if (namespace === "correction" || namespace === "corrections") {
2457
2593
  if (!action || action === "status") {
@@ -2660,6 +2796,78 @@ export function parseAdminOperation(options) {
2660
2796
  },
2661
2797
  );
2662
2798
  }
2799
+ if (action === "inspect") {
2800
+ const reason = String(options.adminReason || "").trim();
2801
+ if (!reason) {
2802
+ throw cliValidationError(
2803
+ "Say why with --reason <management reason>; the inspection is audited.",
2804
+ );
2805
+ }
2806
+ if (reason.length > MAX_IDENTITY_INSPECTION_REASON_LENGTH) {
2807
+ throw cliValidationError(
2808
+ `An inspection reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
2809
+ );
2810
+ }
2811
+ // A read like identity inspect: private evidence is printed, never
2812
+ // kept on disk; the server records who looked, when and why.
2813
+ return bodyReadOperation(
2814
+ "ai-bucket.inspect",
2815
+ "POST",
2816
+ `/cli/admin/ai-buckets/${bucketId}/inspect`,
2817
+ {
2818
+ reason,
2819
+ includePrivateEvidence: options.adminIncludePrivateEvidence === true,
2820
+ },
2821
+ );
2822
+ }
2823
+ if (action === "restrict") {
2824
+ const mode = String(options.adminMode || "");
2825
+ if (!["none", "signup", "full"].includes(mode)) {
2826
+ throw cliValidationError("Pass --mode none, signup or full.");
2827
+ }
2828
+ const body = { mode };
2829
+ if (options.adminMessage !== undefined) {
2830
+ const message = String(options.adminMessage).trim();
2831
+ if (message.length > 500) {
2832
+ throw cliValidationError("--message must be at most 500 characters.");
2833
+ }
2834
+ body.message = message;
2835
+ }
2836
+ return aiBucketChangeOperation({
2837
+ options,
2838
+ name: "ai-bucket.restrict",
2839
+ method: "PUT",
2840
+ path: `/cli/admin/ai-buckets/${bucketId}/restriction`,
2841
+ body,
2842
+ // a restriction changes only on an explicit instruction
2843
+ confirmRequired: true,
2844
+ });
2845
+ }
2846
+ if (action === "rule" && target === "add") {
2847
+ return aiBucketChangeOperation({
2848
+ options,
2849
+ name: "ai-bucket.rule.add",
2850
+ method: "POST",
2851
+ path: `/cli/admin/ai-buckets/${bucketId}/rules`,
2852
+ body: parseAiBucketRuleBody(options),
2853
+ confirmRequired: true,
2854
+ });
2855
+ }
2856
+ if (action === "rule" && target === "disable") {
2857
+ const ruleId = parseRequiredInteger(options.adminRuleId, "--rule-id", 1);
2858
+ const note = String(options.note || "").trim();
2859
+ if (note.length > 180) {
2860
+ throw cliValidationError("--note must be at most 180 characters.");
2861
+ }
2862
+ return aiBucketChangeOperation({
2863
+ options,
2864
+ name: "ai-bucket.rule.disable",
2865
+ method: "POST",
2866
+ path: `/cli/admin/ai-buckets/${bucketId}/rules/${ruleId}/disable`,
2867
+ body: note ? { note } : {},
2868
+ confirmRequired: true,
2869
+ });
2870
+ }
2663
2871
  if (action === "note" && target === "set") {
2664
2872
  return writeOperation(
2665
2873
  "ai-bucket.note.set",
@@ -2748,7 +2956,7 @@ export function parseAdminOperation(options) {
2748
2956
  });
2749
2957
  if (operation) return operation;
2750
2958
  throw cliValidationError(
2751
- "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>.",
2959
+ "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>.",
2752
2960
  );
2753
2961
  }
2754
2962
 
@@ -5085,6 +5293,108 @@ export function assertAdminTodoHandoffResult(result, expectedRunScope = null) {
5085
5293
  return handoff;
5086
5294
  }
5087
5295
 
5296
+ // --dry-run is a read (no receipt, nothing changes); a real run is a write
5297
+ // that needs --confirm, checked here and again by the server.
5298
+ function aiBucketChangeOperation({ options, name, method, path, body, confirmRequired }) {
5299
+ // --allow-shared-network lets a rule reach a shared network (an academy,
5300
+ // a school); it is sent, and printed, only when given
5301
+ if (options.adminAllowSharedNetwork) {
5302
+ if (!["ai-bucket.restrict", "ai-bucket.rule.add"].includes(name)) {
5303
+ throw cliValidationError("--allow-shared-network applies only to restrict and rule add.");
5304
+ }
5305
+ body = { ...body, allowSharedNetwork: true };
5306
+ }
5307
+ if (options.adminDryRun) {
5308
+ return bodyReadOperation(`${name}.preview`, method, path, {
5309
+ ...body,
5310
+ dryRun: true,
5311
+ });
5312
+ }
5313
+ const confirm = options.adminConfirm;
5314
+ if (confirm !== "" && confirm !== "true") {
5315
+ throw cliValidationError("Pass --confirm on its own, without a value.");
5316
+ }
5317
+ if (confirmRequired && confirm !== "true") {
5318
+ throw cliValidationError(
5319
+ "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.",
5320
+ );
5321
+ }
5322
+ return writeOperation(name, method, path, {
5323
+ ...body,
5324
+ ...(confirm === "true" ? { confirm: true } : {}),
5325
+ });
5326
+ }
5327
+
5328
+ function parseAiBucketRuleBody(options) {
5329
+ const type = String(options.adminType || "");
5330
+ const note = String(options.note || "").trim();
5331
+ if (note.length > 180) {
5332
+ throw cliValidationError("--note must be at most 180 characters.");
5333
+ }
5334
+ const withNote = (body) => (note ? { ...body, note } : body);
5335
+ const scope = () => {
5336
+ const value = String(options.adminScope || "");
5337
+ if (!["signup", "full"].includes(value)) {
5338
+ throw cliValidationError(
5339
+ "Pass --scope signup or full for an IP or device rule.",
5340
+ );
5341
+ }
5342
+ return value;
5343
+ };
5344
+ if (type === "user") {
5345
+ return withNote({
5346
+ type,
5347
+ userId: parseRequiredInteger(options.adminUser, "--user", 1),
5348
+ });
5349
+ }
5350
+ if (type === "email") {
5351
+ const email = String(options.adminEmail || "").trim().toLowerCase();
5352
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
5353
+ throw cliValidationError("Pass a valid --email <address>.");
5354
+ }
5355
+ if (!options.adminDryRun && !options.adminConfirmEmailRule) {
5356
+ throw cliValidationError(
5357
+ "An email rule covers every account with that verified address. Pass --confirm-email-rule (run --dry-run first to see them).",
5358
+ );
5359
+ }
5360
+ return withNote({
5361
+ type,
5362
+ email,
5363
+ ...(options.adminConfirmEmailRule ? { confirmEmailRule: true } : {}),
5364
+ });
5365
+ }
5366
+ if (type === "ip") {
5367
+ const ip = String(options.adminIp || "").trim();
5368
+ if (!ip) throw cliValidationError("Pass --ip <address>.");
5369
+ return withNote({
5370
+ type,
5371
+ ip,
5372
+ scope: scope(),
5373
+ ...(options.adminIncludePrefix ? { includePrefix: true } : {}),
5374
+ });
5375
+ }
5376
+ if (type === "device") {
5377
+ if (!/^[a-f0-9]{16}$/.test(options.adminDeviceKey || "")) {
5378
+ throw cliValidationError(
5379
+ "Pass --device-key with the exact key from ai-bucket inspect or identity inspect.",
5380
+ );
5381
+ }
5382
+ if (!options.adminDryRun && !note) {
5383
+ throw cliValidationError("Say why with --note <reason> for a device rule.");
5384
+ }
5385
+ return withNote({
5386
+ type,
5387
+ sourceUserId: parseRequiredInteger(options.adminUser, "--user", 1),
5388
+ deviceKey: options.adminDeviceKey,
5389
+ scope: scope(),
5390
+ ...(options.adminUserIds || !options.adminDryRun
5391
+ ? { expectedUserIds: parseAiBucketUserIds(options.adminUserIds) }
5392
+ : {}),
5393
+ });
5394
+ }
5395
+ throw cliValidationError("Pass --type user, email, ip or device.");
5396
+ }
5397
+
5088
5398
  function readOperation(name, path, extra = {}) {
5089
5399
  return {
5090
5400
  name,
@@ -5769,6 +6079,10 @@ function printRewardReviewResult({ operation, data }) {
5769
6079
  );
5770
6080
  for (const file of review.proposal.changedFiles || [])
5771
6081
  console.log(` ${file.status}: ${file.path}`);
6082
+ if (review.proposal.title)
6083
+ console.log(` new name: ${review.proposal.title}`);
6084
+ if (review.proposal.thumbnailUrl)
6085
+ console.log(` new thumbnail: ${review.proposal.thumbnailUrl}`);
5772
6086
  }
5773
6087
  if (review.status === "rejected" && review.declinedByCreator) {
5774
6088
  console.log(
@@ -6846,6 +7160,174 @@ function printRewardBankReport(data) {
6846
7160
  }
6847
7161
  }
6848
7162
 
7163
+ function aiBucketRestrictionLabel(restriction) {
7164
+ const mode = restriction?.mode || "none";
7165
+ const label = mode === "none" ? "unrestricted" : mode === "signup" ? "signup-only" : "full";
7166
+ return restriction?.message ? `${label} — "${restriction.message}"` : label;
7167
+ }
7168
+
7169
+ function formatAiBucketAccounts(accounts) {
7170
+ return (accounts || []).length
7171
+ ? accounts.map((a) => `${a.username || "?"} (#${a.userId})`).join(", ")
7172
+ : "none";
7173
+ }
7174
+
7175
+ function printAiBucketNetworkRuleChanges(changes) {
7176
+ if (!changes?.length) return;
7177
+ console.log("Network/device rules whose restriction changes:");
7178
+ for (const c of changes) {
7179
+ const r = c.reach;
7180
+ const reach = !c.strengthens
7181
+ ? "loosens"
7182
+ : !r?.measured
7183
+ ? "reach NOT MEASURED"
7184
+ : `${r.accountCount}${r.lowerBound ? "+" : ""} account(s)${r.sharedNetwork ? " — SHARED NETWORK" : ""}`;
7185
+ console.log(` #${c.ruleId} ${c.kind} ${c.match}: ${c.from} → ${c.to} · ${reach}`);
7186
+ }
7187
+ }
7188
+
7189
+ function printAiBucketBlockers(blockers, overrideGiven) {
7190
+ for (const blocker of blockers || []) {
7191
+ if (blocker.overridable && overrideGiven) {
7192
+ console.log(`OVERRIDDEN by --allow-shared-network (${blocker.code}): ${blocker.message}`);
7193
+ } else {
7194
+ console.log(`BLOCKED (${blocker.code}): ${blocker.message}${blocker.overridable ? " Only --allow-shared-network, on Mikey's explicit word, lifts this." : ""}`);
7195
+ }
7196
+ }
7197
+ }
7198
+
7199
+ function printAiBucketRules(rules, indent = " ") {
7200
+ for (const rule of rules || []) {
7201
+ console.log(
7202
+ `${indent}#${rule.id} ${rule.kind} ${rule.match} · scope ${rule.scope} · effective ${rule.effectiveRestriction}${rule.note ? ` · ${rule.note}` : ""}`,
7203
+ );
7204
+ }
7205
+ }
7206
+
7207
+ // The bucket commands of todo 79 (inspect, restrict, rule add/disable and
7208
+ // their --dry-run previews). Returns false for the older ai-bucket commands.
7209
+ function printAiBucketRuleResult({ operation, data }) {
7210
+ const name = operation.name;
7211
+ const head = (bucket) =>
7212
+ `AI bucket #${bucket.id} (${bucket.label}): ${aiBucketRestrictionLabel(bucket.restriction)}`;
7213
+ if (name === "ai-bucket.inspect") {
7214
+ console.log(`${head(data.bucket)}; ${data.rules.length} active rule(s), ${data.memberCount} member account(s).`);
7215
+ if (data.bucket.note) console.log(`Note: ${data.bucket.note}`);
7216
+ console.log("Rules:");
7217
+ printAiBucketRules(data.rules);
7218
+ console.log(
7219
+ `Members: ${(data.members || []).map((m) => `${m.username || "?"} (#${m.userId}${m.viaEmailRule ? ", via email rule" : ""})`).join(", ") || "none"}`,
7220
+ );
7221
+ for (const account of data.evidence?.accounts || []) {
7222
+ console.log(`Evidence for ${account.username || "?"} (#${account.userId})${account.canonicalBucketId && account.canonicalBucketId !== data.bucket.id ? ` · resolves to bucket #${account.canonicalBucketId}` : ""}:`);
7223
+ if (!account.devices.length) console.log(" Devices: none recorded");
7224
+ for (const device of account.devices) {
7225
+ const rule = device.ruleId ? ` · rule #${device.ruleId}` : "";
7226
+ if (device.sharedDevice) {
7227
+ console.log(` Device ${device.key}: shared by ${device.accountCount} accounts (not listed; not family evidence)${rule}`);
7228
+ continue;
7229
+ }
7230
+ const others = device.otherAccounts.length
7231
+ ? device.otherAccounts.map((a) => `${a.username || "?"} (#${a.userId})${a.inBucket ? "" : " OUTSIDE bucket"}`).join(", ")
7232
+ : "no other account";
7233
+ console.log(` Device ${device.key}: ${device.accountCount} account(s); others: ${others}${device.accountsTruncated ? " (more not listed)" : ""}${device.status !== "complete" ? ` · ${device.status}` : ""}${rule}`);
7234
+ }
7235
+ printNetwork(account.network, " ");
7236
+ const ruled = (account.network?.addresses || []).filter((a) => a.ruleId);
7237
+ if (ruled.length) {
7238
+ console.log(` IP rules matching these addresses: ${ruled.map((a) => `#${a.ruleId} [${a.addressId}]`).join(", ")}`);
7239
+ }
7240
+ }
7241
+ if (data.evidence?.membersNotInspected) {
7242
+ console.log(`Evidence covers the first ${data.evidence.membersInspected} members; ${data.evidence.membersNotInspected} not inspected.`);
7243
+ }
7244
+ console.log(data.interpretation);
7245
+ return true;
7246
+ }
7247
+ if (name === "ai-bucket.restrict.preview") {
7248
+ const p = data.preview;
7249
+ console.log(`DRY RUN ${head(data.bucket)} → ${aiBucketRestrictionLabel(p.to)}${p.changed ? "" : " (no change)"}.`);
7250
+ console.log(`Accounts it reaches: ${formatAiBucketAccounts(p.memberAccounts)}`);
7251
+ console.log(`Sessions dropped: ${p.sessionsKickedUserIds.length ? p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${p.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
7252
+ if (p.fullBlockLiftedUserIds.length) console.log(`Full block lifted for: ${p.fullBlockLiftedUserIds.map((id) => `#${id}`).join(", ")} (unless another restriction still applies).`);
7253
+ if (p.adminConflictUserIds.length) console.log(`BLOCKED: admin account(s) ${p.adminConflictUserIds.map((id) => `#${id}`).join(", ")} cannot be fully restricted.`);
7254
+ printAiBucketNetworkRuleChanges(p.networkRuleChanges);
7255
+ printAiBucketBlockers(p.blockers, data.sharedNetworkOverride);
7256
+ console.log("Nothing was changed. Run again with --confirm to apply.");
7257
+ return true;
7258
+ }
7259
+ if (name === "ai-bucket.restrict") {
7260
+ console.log(`${head(data.bucket)} (was ${aiBucketRestrictionLabel(data.previous)}); verified.`);
7261
+ printAiBucketNetworkRuleChanges(data.networkRuleChanges);
7262
+ if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — a shared network or unmeasured rule is now restricted.");
7263
+ console.log(`Sessions dropped: ${data.kickedUserIds.length ? data.kickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${data.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
7264
+ return true;
7265
+ }
7266
+ if (name === "ai-bucket.rule.add.preview") {
7267
+ console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
7268
+ if (data.device) {
7269
+ const d = data.device;
7270
+ if (d.sharedDevice) {
7271
+ console.log(`Shared device: ${d.observedAccountCount} accounts (${d.outsideBucketCount} outside this bucket); not listed, not family evidence.`);
7272
+ } else {
7273
+ 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}.`);
7274
+ if (d.outsideBucketUserIds.length) console.log(`Outside this bucket: ${d.outsideBucketUserIds.map((id) => `#${id}`).join(", ")}`);
7275
+ }
7276
+ if (d.existingRuleId) console.log(`Existing rule #${d.existingRuleId} in bucket #${d.existingRuleBucketId}.`);
7277
+ if (!(data.blockers || []).length) console.log(`Pass the reviewed accounts with --user-ids ${d.observedUserIds.join(",")} on the real run.`);
7278
+ } else {
7279
+ const p = data.preview;
7280
+ const r = p.rule;
7281
+ 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}.`);
7282
+ if (p.existingRule) console.log(`Existing rule #${p.existingRule.ruleId} in bucket #${p.existingRule.bucketId} (${p.existingRule.bucketLabel}).`);
7283
+ if (p.affectedAccounts) console.log(`Accounts it names: ${formatAiBucketAccounts(p.affectedAccounts)}`);
7284
+ if (p.exactAddress) {
7285
+ const a = p.exactAddress;
7286
+ console.log(a.kind === "shared_network"
7287
+ ? `Address [${a.addressId}]: shared network, ${a.accountCount} accounts (not listed; not family evidence).`
7288
+ : `Address [${a.addressId}]: ${a.accountCount} account(s): ${formatAiBucketAccounts(a.accounts)}${a.olderHistoryNotRead ? " · older history not read" : ""}`);
7289
+ }
7290
+ if (p.prefixReach) {
7291
+ const r = p.prefixReach;
7292
+ console.log(r.measured
7293
+ ? `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).`
7294
+ : "Prefix: reach could not be measured.");
7295
+ }
7296
+ for (const existing of p.existingPrefixRules || []) {
7297
+ if (existing) console.log(`Existing prefix rule #${existing.ruleId} in bucket #${existing.bucketId} (${existing.scope}).`);
7298
+ }
7299
+ if (p.sessionsKickedUserIds.length) console.log(`Sessions dropped: ${p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ")}`);
7300
+ }
7301
+ printAiBucketBlockers(data.blockers || data.preview?.blockers || [], data.sharedNetworkOverride);
7302
+ console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
7303
+ return true;
7304
+ }
7305
+ if (name === "ai-bucket.rule.add") {
7306
+ console.log(`${head(data.bucket)}; rule saved; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
7307
+ printAiBucketRules(data.addedRules);
7308
+ if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — this rule reaches a shared network.");
7309
+ if (data.kickedUserIds?.length) console.log(`Sessions dropped: ${data.kickedUserIds.map((id) => `#${id}`).join(", ")}`);
7310
+ return true;
7311
+ }
7312
+ if (name === "ai-bucket.rule.disable.preview") {
7313
+ const p = data.preview;
7314
+ console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
7315
+ printAiBucketRules([p.rule]);
7316
+ console.log(p.alreadyDisabled ? "Already disabled." : `Released accounts: ${formatAiBucketAccounts(p.releasedAccounts)}${p.liftsRestriction ? " (each keeps any other restriction that still applies)" : ""}.`);
7317
+ if (p.prefixRulesRemainActive) {
7318
+ console.log(`${p.prefixNote}${p.companionPrefixRuleIds?.length ? ` Its prefix rules here: ${p.companionPrefixRuleIds.map((id) => `#${id}`).join(", ")}.` : ""}`);
7319
+ }
7320
+ console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
7321
+ return true;
7322
+ }
7323
+ if (name === "ai-bucket.rule.disable") {
7324
+ console.log(`${head(data.bucket)}; rule #${data.disabledRule.id} disabled; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
7325
+ console.log(`Released accounts: ${formatAiBucketAccounts(data.releasedAccounts)}`);
7326
+ return true;
7327
+ }
7328
+ return false;
7329
+ }
7330
+
6849
7331
  function printAdminResult({ operation, result }) {
6850
7332
  const data = result?.data || {};
6851
7333
  if (printChatSafetyResult({ operation, data })) return;
@@ -6858,6 +7340,9 @@ function printAdminResult({ operation, result }) {
6858
7340
  for (const line of formatProfileReview(data)) console.log(line);
6859
7341
  return;
6860
7342
  }
7343
+ if (operation.name.startsWith("ai-bucket.") && printAiBucketRuleResult({ operation, data })) {
7344
+ return;
7345
+ }
6861
7346
  if (operation.name === "reward-activity.report") {
6862
7347
  printRewardActivity(data);
6863
7348
  return;
@@ -6874,6 +7359,13 @@ function printAdminResult({ operation, result }) {
6874
7359
  printRoles(data);
6875
7360
  return;
6876
7361
  }
7362
+ if (operation.name.startsWith("craft.")) {
7363
+ const lines = formatCraftResult({ operation, data });
7364
+ if (lines) {
7365
+ for (const line of lines) console.log(line);
7366
+ return;
7367
+ }
7368
+ }
6877
7369
  if (operation.name.startsWith("reward-bank.")) {
6878
7370
  if (operation.name === "reward-bank.seed") {
6879
7371
  console.log(