@stage5/lumine 0.2.41 → 0.2.43

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/README.md CHANGED
@@ -214,6 +214,8 @@ events.
214
214
 
215
215
  ```bash
216
216
  lumine admin identity list --json
217
+ lumine admin identity inspect Jay1216 \
218
+ --reason "Confirm account family before a quota-bucket change" --json
217
219
  lumine admin daily-run start --identity auto --comment-mode off --json
218
220
  lumine admin recommendations list --all --checkpoint recommendations.json --json
219
221
  lumine admin recommendations list --after 2026-08-14T00:00:00Z --all --json
@@ -248,6 +250,9 @@ lumine admin daily-run escalation add --target subject:123 \
248
250
  --note "Concrete privacy issue requiring owner review" --json
249
251
  lumine admin daily-run report --json
250
252
  lumine admin daily-run complete --json
253
+ lumine admin escalation list --status all --json
254
+ lumine admin escalation set 123 --status resolved \
255
+ --note "Final owner decision" --json
251
256
  ```
252
257
 
253
258
  Numeric recommendation targets default to subjects. Use `comment:<id>`,
@@ -280,7 +285,8 @@ resumed with the exact generated key.
280
285
  Subject and queue listings use opaque, stable snapshot cursors. `--all`
281
286
  follows them automatically, saves a checkpoint after every canonical page,
282
287
  and records completed queue coverage in the run audit; `--resume` continues
283
- the exact same request. Recommendation scans default to the previous completed
288
+ the exact same request. With `--all --json`, bounded progress goes to stderr so
289
+ stdout remains one pipe-safe JSON value. Recommendation scans default to the previous completed
284
290
  run's start boundary for at-least-once coverage. Use `--after` for an explicit
285
291
  timestamp or `--include-legacy`
286
292
  for an intentional all-history scan. Subject `--after` is inclusive and
@@ -293,6 +299,12 @@ the claim file. `daily-run report` summarizes confirmed mutations, completed
293
299
  queue coverage, explicitly recorded escalations, and the run brief before the
294
300
  run is completed.
295
301
 
302
+ Identity inspection, escalation dispositions, AI-bucket maintenance, and
303
+ approved Notable User additions are private operator bookkeeping and do not
304
+ require a delegated daily run. Identity inspection always requires an audited
305
+ `--reason`; raw email/DOB evidence additionally requires
306
+ `--include-private-evidence`. Routine briefs omit raw email identities.
307
+
296
308
  The complete run lifecycle, command contracts, nullable fields, Karma approval
297
309
  behavior, pagination semantics, secret-subject behavior, presence isolation,
298
310
  and retry rules are documented in
package/lib/admin-news.js CHANGED
@@ -3,8 +3,10 @@ import {
3
3
  mkdirSync,
4
4
  readFileSync,
5
5
  renameSync,
6
+ unlinkSync,
6
7
  writeFileSync,
7
8
  } from "node:fs";
9
+ import { randomUUID } from "node:crypto";
8
10
  import path from "node:path";
9
11
 
10
12
  const MAX_ADMIN_JSON_BYTES = 2 * 1024 * 1024;
@@ -17,9 +19,7 @@ function validationError(message) {
17
19
 
18
20
  function formatByteLimit(maxBytes) {
19
21
  const megabytes = maxBytes / (1024 * 1024);
20
- return Number.isInteger(megabytes)
21
- ? `${megabytes} MB`
22
- : `${maxBytes} bytes`;
22
+ return Number.isInteger(megabytes) ? `${megabytes} MB` : `${maxBytes} bytes`;
23
23
  }
24
24
 
25
25
  export function readAdminJsonFile(
@@ -36,7 +36,9 @@ export function readAdminJsonFile(
36
36
  throw validationError(`Could not read ${normalizedPath}.`);
37
37
  }
38
38
  if (Buffer.byteLength(contents, "utf8") > maxBytes) {
39
- throw validationError(`${label} must be under ${formatByteLimit(maxBytes)}.`);
39
+ throw validationError(
40
+ `${label} must be under ${formatByteLimit(maxBytes)}.`,
41
+ );
40
42
  }
41
43
  try {
42
44
  return JSON.parse(contents);
@@ -55,7 +57,10 @@ export function writeAdminJsonFile(
55
57
  throw validationError("An output file path is required.");
56
58
  }
57
59
  mkdirSync(path.dirname(resolved), { recursive: true });
58
- const temporary = `${resolved}.tmp-${process.pid}`;
60
+ // Checkpoints can contain private management evidence and often live in a
61
+ // shared temporary directory. Use an unguessable, exclusive staging file so
62
+ // another local account cannot pre-place a symlink at the temporary path.
63
+ const temporary = `${resolved}.tmp-${process.pid}-${randomUUID()}`;
59
64
  const contents = `${JSON.stringify(value, null, 2)}\n`;
60
65
  if (
61
66
  Number.isSafeInteger(maxBytes) &&
@@ -66,11 +71,21 @@ export function writeAdminJsonFile(
66
71
  `The output exceeds the ${formatByteLimit(maxBytes)} safety limit. Narrow the request before retrying.`,
67
72
  );
68
73
  }
69
- writeFileSync(temporary, contents, {
70
- encoding: "utf8",
71
- mode: privateFile ? 0o600 : 0o644,
72
- });
73
- renameSync(temporary, resolved);
74
+ try {
75
+ writeFileSync(temporary, contents, {
76
+ encoding: "utf8",
77
+ flag: "wx",
78
+ mode: privateFile ? 0o600 : 0o644,
79
+ });
80
+ renameSync(temporary, resolved);
81
+ } catch (error) {
82
+ try {
83
+ unlinkSync(temporary);
84
+ } catch {
85
+ // The exclusive create or successful rename may leave nothing to clean.
86
+ }
87
+ throw error;
88
+ }
74
89
  if (privateFile) chmodSync(resolved, 0o600);
75
90
  return resolved;
76
91
  }
@@ -83,7 +98,12 @@ export function extractNewsClaim(value) {
83
98
  const editionId = Number(claim.editionId || 0);
84
99
  const leaseToken = String(claim.leaseToken || "").trim();
85
100
  const events = Array.isArray(claim.events) ? claim.events : null;
86
- if (!Number.isSafeInteger(editionId) || editionId <= 0 || !leaseToken || !events) {
101
+ if (
102
+ !Number.isSafeInteger(editionId) ||
103
+ editionId <= 0 ||
104
+ !leaseToken ||
105
+ !events
106
+ ) {
87
107
  throw validationError(
88
108
  "The claim file is missing editionId, leaseToken, or canonical events.",
89
109
  );
@@ -114,7 +134,8 @@ export function createNewsEditorialScaffold(claimValue) {
114
134
  const frontIndex = claim.events.findIndex(
115
135
  (event) => String(event?.section || "") === "front",
116
136
  );
117
- const lead = frontIndex >= 0 ? scaffoldStory(claim.events[frontIndex], maximum) : null;
137
+ const lead =
138
+ frontIndex >= 0 ? scaffoldStory(claim.events[frontIndex], maximum) : null;
118
139
  const stories = claim.events
119
140
  .filter((_event, index) => index !== frontIndex)
120
141
  .map((event) => scaffoldStory(event, maximum));
@@ -150,7 +171,9 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
150
171
  const usedKeys = new Set();
151
172
  const maximum = Math.max(0, Number(claim.maxSourceQuoteLength || 360));
152
173
  const entries = [
153
- ...(editorial.lead ? [{ label: "lead", story: editorial.lead, lead: true }] : []),
174
+ ...(editorial.lead
175
+ ? [{ label: "lead", story: editorial.lead, lead: true }]
176
+ : []),
154
177
  ...editorial.stories.map((story, index) => ({
155
178
  label: `stories[${index}]`,
156
179
  story,
@@ -164,7 +187,8 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
164
187
  }
165
188
  const eventKey = String(story.eventKey || "").trim();
166
189
  const event = eventByKey.get(eventKey);
167
- if (!event) throw validationError(`${entry.label}.eventKey is not in the claim.`);
190
+ if (!event)
191
+ throw validationError(`${entry.label}.eventKey is not in the claim.`);
168
192
  if (usedKeys.has(eventKey)) {
169
193
  throw validationError(`${eventKey} is cited or covered more than once.`);
170
194
  }
@@ -174,14 +198,15 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
174
198
  }
175
199
  requireEditorialText(story.headline, `${entry.label}.headline`);
176
200
  requireEditorialText(story.summary, `${entry.label}.summary`);
177
- const quote = typeof story.sourceQuote === "string" ? story.sourceQuote : "";
201
+ const quote =
202
+ typeof story.sourceQuote === "string" ? story.sourceQuote : "";
178
203
  if (String(event.section || "") === "front") {
179
204
  const canonicalSummary = String(event.summary || "");
180
205
  const quoteIsValid = canonicalSummary
181
206
  ? Boolean(
182
207
  quote &&
183
- quote.length <= maximum &&
184
- canonicalSummary.includes(quote),
208
+ quote.length <= maximum &&
209
+ canonicalSummary.includes(quote),
185
210
  )
186
211
  : quote === "";
187
212
  if (!quoteIsValid) {
@@ -190,19 +215,27 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
190
215
  );
191
216
  }
192
217
  } else if (quote !== "") {
193
- throw validationError(`${entry.label}.sourceQuote must be empty outside the front section.`);
218
+ throw validationError(
219
+ `${entry.label}.sourceQuote must be empty outside the front section.`,
220
+ );
194
221
  }
195
222
  const covered = story.coveredEventKeys ?? [];
196
223
  if (!Array.isArray(covered)) {
197
- throw validationError(`${entry.label}.coveredEventKeys must be an array.`);
224
+ throw validationError(
225
+ `${entry.label}.coveredEventKeys must be an array.`,
226
+ );
198
227
  }
199
228
  for (const rawCoveredKey of covered) {
200
229
  const coveredKey = String(rawCoveredKey || "").trim();
201
230
  if (!eventByKey.has(coveredKey)) {
202
- throw validationError(`${entry.label} covers an eventKey not in the claim.`);
231
+ throw validationError(
232
+ `${entry.label} covers an eventKey not in the claim.`,
233
+ );
203
234
  }
204
235
  if (coveredKey === eventKey || usedKeys.has(coveredKey)) {
205
- throw validationError(`${coveredKey} is cited or covered more than once.`);
236
+ throw validationError(
237
+ `${coveredKey} is cited or covered more than once.`,
238
+ );
206
239
  }
207
240
  usedKeys.add(coveredKey);
208
241
  }
@@ -92,6 +92,7 @@ export async function runAutomaticPagination({
92
92
  fetchPage,
93
93
  transformPage,
94
94
  recordCoverage,
95
+ reportProgress = (message) => process.stderr.write(`${message}\n`),
95
96
  }) {
96
97
  if (!operation.pagination) {
97
98
  throw validationError("--all is supported only by paginated admin listings.");
@@ -152,6 +153,12 @@ export async function runAutomaticPagination({
152
153
  }
153
154
  state = { ...state, ...saved, resumed: true };
154
155
  }
156
+ const progressEnabled = options.json === true;
157
+ if (progressEnabled) {
158
+ reportProgress(
159
+ `Lumine admin ${operation.name}: ${state.resumed ? "resuming" : "starting"} canonical scan; checkpoint ${checkpointPath}.`,
160
+ );
161
+ }
155
162
  const pages = [];
156
163
  while (!state.exhausted) {
157
164
  if (state.pages >= MAX_AUTOMATIC_PAGES) {
@@ -230,6 +237,14 @@ export async function runAutomaticPagination({
230
237
  privateFile: true,
231
238
  maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
232
239
  });
240
+ if (
241
+ progressEnabled &&
242
+ (pages.length === 1 || state.pages % 10 === 0 || state.exhausted)
243
+ ) {
244
+ reportProgress(
245
+ `Lumine admin ${operation.name}: ${state.pages} page(s), ${state.scannedCount} row(s) scanned, ${state.items.length} candidate(s)${state.exhausted ? "; canonical snapshot exhausted." : "; continuing."}`,
246
+ );
247
+ }
233
248
  }
234
249
  const result = aggregatePageResult({
235
250
  operation,
package/lib/admin.js CHANGED
@@ -21,6 +21,8 @@ const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
21
21
  const MAX_COMPOSED_TEXT_FILE_BYTES = 64 * 1024;
22
22
  const MAX_COMPOSED_TEXT_LENGTH = 10_000;
23
23
  const MAX_NOTABLE_NOTE_LENGTH = 2_000;
24
+ const MAX_IDENTITY_INSPECTION_REASON_LENGTH = 500;
25
+ const MAX_ESCALATION_DECISION_NOTE_LENGTH = 2_000;
24
26
 
25
27
  // Operator-composed persona text (plain UTF-8, not JSON). The agent writes
26
28
  // the content in the bot's persona itself; the server never invokes
@@ -498,8 +500,12 @@ function adminOperationRequiresRun(operation) {
498
500
  "identity.list",
499
501
  "identity.status",
500
502
  "identity.use",
503
+ "identity.inspect",
501
504
  "daily-run.start",
502
505
  "daily-run.status",
506
+ "escalation.list",
507
+ "escalation.set",
508
+ "notable.add",
503
509
  ].includes(operation.name) && !operation.name.startsWith("ai-bucket.")
504
510
  );
505
511
  }
@@ -540,6 +546,35 @@ export function parseAdminOperation(options) {
540
546
  { identity: parseIdentity(target) },
541
547
  );
542
548
  }
549
+ if (action === "inspect") {
550
+ const inspectionTarget = String(target || "").trim();
551
+ const reason = String(options.adminReason || "").trim();
552
+ if (!inspectionTarget) {
553
+ throw cliValidationError(
554
+ "Usage: lumine admin identity inspect <userId|username> --reason <management reason> [--include-private-evidence].",
555
+ );
556
+ }
557
+ if (!reason) {
558
+ throw cliValidationError(
559
+ "Explain why private identity evidence is needed with --reason <management reason>.",
560
+ );
561
+ }
562
+ if (reason.length > MAX_IDENTITY_INSPECTION_REASON_LENGTH) {
563
+ throw cliValidationError(
564
+ `An identity-inspection reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
565
+ );
566
+ }
567
+ return writeOperation(
568
+ "identity.inspect",
569
+ "POST",
570
+ "/cli/admin/identity/inspect",
571
+ {
572
+ target: inspectionTarget,
573
+ reason,
574
+ includePrivateEvidence: options.adminIncludePrivateEvidence === true,
575
+ },
576
+ );
577
+ }
543
578
  }
544
579
 
545
580
  if (namespace === "ai-bucket" || namespace === "ai-buckets") {
@@ -639,6 +674,47 @@ export function parseAdminOperation(options) {
639
674
  }
640
675
  }
641
676
 
677
+ if (namespace === "escalation" || namespace === "escalations") {
678
+ if (!action || action === "list") {
679
+ const status = parseEscalationListStatus(options.adminStatus || "open");
680
+ return readOperation(
681
+ "escalation.list",
682
+ withQuery("/cli/admin/escalations", {
683
+ status,
684
+ limit: options.limit,
685
+ }),
686
+ );
687
+ }
688
+ if (action === "set") {
689
+ const escalationAuditId = parseRequiredInteger(
690
+ target,
691
+ "Escalation audit ID",
692
+ 1,
693
+ );
694
+ const status = parseEscalationStatus(options.adminStatus);
695
+ const note = String(options.note || "").trim();
696
+ if (!note) {
697
+ throw cliValidationError(
698
+ "Record the decision or next step with --note <text>.",
699
+ );
700
+ }
701
+ if (note.length > MAX_ESCALATION_DECISION_NOTE_LENGTH) {
702
+ throw cliValidationError(
703
+ `An escalation decision note must be at most ${MAX_ESCALATION_DECISION_NOTE_LENGTH} characters.`,
704
+ );
705
+ }
706
+ return writeOperation(
707
+ "escalation.set",
708
+ "PUT",
709
+ `/cli/admin/escalations/${escalationAuditId}`,
710
+ { status, note },
711
+ );
712
+ }
713
+ throw cliValidationError(
714
+ "Usage: lumine admin escalation list [--status open|acknowledged|resolved|all] | escalation set <auditId> --status <status> --note <decision>.",
715
+ );
716
+ }
717
+
642
718
  if (
643
719
  (namespace === "recommend-queue" && (!action || action === "list")) ||
644
720
  (namespace === "recommendations" && action === "list")
@@ -1164,7 +1240,7 @@ export function parseAdminOperation(options) {
1164
1240
  }
1165
1241
 
1166
1242
  throw cliValidationError(
1167
- "Usage: lumine admin identity|daily-run|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
1243
+ "Usage: lumine admin identity|daily-run|escalation|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
1168
1244
  );
1169
1245
  }
1170
1246
 
@@ -1543,6 +1619,30 @@ function parseCommentMode(value) {
1543
1619
  return mode;
1544
1620
  }
1545
1621
 
1622
+ function parseEscalationStatus(value) {
1623
+ const status = String(value || "")
1624
+ .trim()
1625
+ .toLowerCase();
1626
+ if (!["open", "acknowledged", "resolved"].includes(status)) {
1627
+ throw cliValidationError(
1628
+ "--status must be open, acknowledged, or resolved.",
1629
+ );
1630
+ }
1631
+ return status;
1632
+ }
1633
+
1634
+ function parseEscalationListStatus(value) {
1635
+ const status = String(value || "open")
1636
+ .trim()
1637
+ .toLowerCase();
1638
+ if (!["open", "acknowledged", "resolved", "all"].includes(status)) {
1639
+ throw cliValidationError(
1640
+ "--status must be open, acknowledged, resolved, or all.",
1641
+ );
1642
+ }
1643
+ return status;
1644
+ }
1645
+
1546
1646
  function parseOrderedIds(value) {
1547
1647
  const ids = String(value || "")
1548
1648
  .split(",")
@@ -1713,6 +1813,54 @@ function printAdminResult({ operation, result }) {
1713
1813
  console.log(`Notable-user candidates in this brief: ${notableCount}.`);
1714
1814
  return;
1715
1815
  }
1816
+ if (data.inspection) {
1817
+ const inspection = data.inspection;
1818
+ console.log(
1819
+ `Identity inspection for user #${inspection.targetUserId}: ${inspection.accounts?.length || 0} candidate account(s); oldest #${inspection.oldestAccount?.userId || "unknown"}.`,
1820
+ );
1821
+ if (inspection.manualBucket) {
1822
+ console.log(
1823
+ `AI bucket #${inspection.manualBucket.id} (${inspection.manualBucket.label}); ${inspection.manualBucket.memberCount} canonical member(s).`,
1824
+ );
1825
+ }
1826
+ for (const account of inspection.accounts || []) {
1827
+ console.log(
1828
+ ` #${account.userId} ${account.username || "(no username)"} — joined ${account.joinedAt || "unknown"}; ${account.relationBasis.join(", ") || "no relation evidence"}${account.hasDateOfBirth ? "; DOB on file" : "; no DOB on file"}.`,
1829
+ );
1830
+ if (account.privateEvidence) {
1831
+ console.log(
1832
+ ` Private evidence: DOB ${account.privateEvidence.dateOfBirth || "none"}; verified email(s) ${account.privateEvidence.verifiedEmails.join(", ") || "none"}.`,
1833
+ );
1834
+ }
1835
+ }
1836
+ return;
1837
+ }
1838
+ if (Array.isArray(data.escalations)) {
1839
+ console.log(`${data.escalations.length} escalation(s):`);
1840
+ for (const escalation of data.escalations) {
1841
+ const target =
1842
+ escalation.url ||
1843
+ `${escalation.targetType || "target"}:${escalation.targetId || "?"}`;
1844
+ console.log(
1845
+ ` #${escalation.auditId} ${String(escalation.status || "open").toUpperCase()} ${target} — ${escalation.summary}`,
1846
+ );
1847
+ if (escalation.decisionNote) {
1848
+ console.log(` Decision: ${escalation.decisionNote}`);
1849
+ }
1850
+ }
1851
+ if (data.truncated) {
1852
+ console.log(
1853
+ "More matching escalations exist than the requested limit; raise --limit or narrow --status.",
1854
+ );
1855
+ }
1856
+ return;
1857
+ }
1858
+ if (data.escalation?.decisionNote) {
1859
+ console.log(
1860
+ `Escalation #${data.escalation.auditId}: ${String(data.escalation.status).toUpperCase()} — ${data.escalation.decisionNote}`,
1861
+ );
1862
+ return;
1863
+ }
1716
1864
  if (data.bucket && Array.isArray(data.memberUserIds)) {
1717
1865
  const added = Array.isArray(data.accounts)
1718
1866
  ? `; added ${data.accounts.length} explicit account(s)`
package/lib/commands.js CHANGED
@@ -2226,6 +2226,7 @@ export function parseArgs(args) {
2226
2226
  "resume",
2227
2227
  "sinceRun",
2228
2228
  "includeLegacy",
2229
+ "includePrivateEvidence",
2229
2230
  "noReviewLoop",
2230
2231
  ]);
2231
2232
 
@@ -2328,6 +2329,7 @@ export function parseArgs(args) {
2328
2329
  adminAfter: raw.after ? String(raw.after) : "",
2329
2330
  adminSinceRun: Boolean(raw.sinceRun),
2330
2331
  adminIncludeLegacy: Boolean(raw.includeLegacy),
2332
+ adminIncludePrivateEvidence: parseBoolean(raw.includePrivateEvidence, false),
2331
2333
  adminAll: Boolean(raw.all),
2332
2334
  adminResume: Boolean(raw.resume),
2333
2335
  adminCheckpoint: raw.checkpoint ? String(raw.checkpoint) : "",
@@ -2338,6 +2340,7 @@ export function parseArgs(args) {
2338
2340
  adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
2339
2341
  adminReviewReceipt: raw.reviewReceipt ? String(raw.reviewReceipt) : "",
2340
2342
  adminSeverity: raw.severity ? String(raw.severity) : "",
2343
+ adminStatus: raw.status ? String(raw.status) : "",
2341
2344
  adminWaitMs: raw.waitMs ? String(raw.waitMs) : "",
2342
2345
  adminBrowserPath: raw.browserPath ? String(raw.browserPath) : "",
2343
2346
  adminEffort: raw.effort ? String(raw.effort) : "",
@@ -2707,6 +2710,7 @@ export function printHelp() {
2707
2710
  lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
2708
2711
  lumine doctor runtime-assets
2709
2712
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
2713
+ lumine admin identity inspect <user-id|username> --reason <management-reason> [--include-private-evidence] [--json]
2710
2714
  lumine admin ai-bucket create --label <name> --note <text> [--json]
2711
2715
  lumine admin ai-bucket get --bucket-id <id> [--json]
2712
2716
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
@@ -2714,6 +2718,8 @@ export function printHelp() {
2714
2718
  lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
2715
2719
  lumine admin daily-run status|report|complete|fail [--reason <text>] [--json]
2716
2720
  lumine admin daily-run escalation add --target <target> --note <summary> [--severity attention|urgent] [--json]
2721
+ lumine admin escalation list [--status open|acknowledged|resolved|all] [--limit <number>] [--json]
2722
+ lumine admin escalation set <audit-id> --status open|acknowledged|resolved --note <decision> [--json]
2717
2723
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
2718
2724
  lumine admin builds candidates [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
2719
2725
  lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--browser-path <path>] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.41",
3
+ "version": "0.2.43",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -89,6 +89,40 @@ Mikey; routine administrator runs still escalate suspected alternate accounts
89
89
  and never auto-enforce. `accounts add` and `note set` still accept only an
90
90
  existing **unbanned** bucket.
91
91
 
92
+ ### Audited identity inspection
93
+
94
+ Account-family evidence is private operator work, never a Zero/Ciel public
95
+ action and never a reason to browse unrelated user activity. Use the dedicated
96
+ lookup instead of direct database queries:
97
+
98
+ ```bash
99
+ lumine admin identity inspect Jay1216 \
100
+ --reason "Confirm the account family before updating its quota bucket" --json
101
+ lumine admin identity inspect Jay1216 \
102
+ --reason "Mikey requested DOB and email evidence for this decision" \
103
+ --include-private-evidence --json
104
+ ```
105
+
106
+ The default result resolves the exact username or user ID from the writer,
107
+ returns the canonical current AI bucket, orders candidate accounts oldest
108
+ first, identifies the oldest account within the strongest canonical family
109
+ boundary (bucket before email; never device alone) when that family fits in the
110
+ bounded evidence set, reports whether each
111
+ account has a DOB, and explains whether the link
112
+ came from explicit bucket membership, a verified-email match, or bounded exact-
113
+ device evidence. It does **not** reveal email addresses, DOB values, device IDs,
114
+ IP evidence, private messages, or unrelated activity. `--include-private-evidence`
115
+ adds DOB values and verified email addresses only; exact device IDs are never
116
+ returned.
117
+
118
+ Every inspection requires a concrete `--reason`. Before loading the evidence,
119
+ the API commits a private `identity.inspect` audit receipt containing the real
120
+ operator, requested target, reason, and whether private evidence was requested.
121
+ The evidence itself is deliberately not copied into the audit log. Inspection
122
+ is run-independent and has no public actor. Results are **candidate accounts
123
+ for human judgment**, not an automatic ownership finding and never automatic
124
+ grounds for moderation, bans, or bucket changes.
125
+
92
126
  ## Editorial priorities
93
127
 
94
128
  The CLI enforces none of this — it is the standing instruction for the operator
@@ -236,6 +270,29 @@ Two rules that keep the list worth reading:
236
270
  design. Do not delete, hide, argue with, or publicly accuse anyone, and do not
237
271
  warn a child that they are in trouble. Report it and let Mikey decide.
238
272
 
273
+ Mikey's decision must remain attached after the originating run closes. These
274
+ commands are private, run-independent bookkeeping:
275
+
276
+ ```bash
277
+ lumine admin escalation list --json
278
+ lumine admin escalation list --status all --json
279
+ lumine admin escalation set 123 --status acknowledged \
280
+ --note "Mikey is reviewing the bot response" --json
281
+ lumine admin escalation set 123 --status resolved \
282
+ --note "No user fault; this audit concerned Zero's response" --json
283
+ ```
284
+
285
+ `list` defaults to unresolved `open` items. `--status` also accepts
286
+ `acknowledged`, `resolved`, or `all`. The number passed to `set` is the original
287
+ `run.escalation` audit ID returned by the run report/list. Every disposition is
288
+ an immutable private `escalation.status.set` audit event. A revision allocated
289
+ while the original escalation is locked orders concurrent decisions, so the
290
+ last applied decision is the canonical status and annotation even if request
291
+ audit IDs were reserved in another order. `--status open` can deliberately
292
+ reopen an item with an explanatory note. Status filters walk indexed escalation
293
+ history rather than a fixed latest-event window. No active run or public bot
294
+ identity is used.
295
+
239
296
  ## Common JSON types
240
297
 
241
298
  All `--json` success output is one uncolored JSON value:
@@ -249,7 +306,9 @@ type Success<D> = {
249
306
  };
250
307
  ```
251
308
 
252
- Failures print one JSON value, write no progress prose, and exit nonzero:
309
+ Failures print one JSON value to stdout and exit nonzero. A failing `--all`
310
+ scan may already have written bounded page progress to stderr; stdout remains
311
+ protocol-clean:
253
312
 
254
313
  ```ts
255
314
  type Failure = {
@@ -430,6 +489,8 @@ lumine admin identity status --json
430
489
  lumine admin identity use zero --json
431
490
  lumine admin identity use ciel --json
432
491
  lumine admin identity use auto --json
492
+ lumine admin identity inspect Jay1216 \
493
+ --reason "Confirm the account family before a bucket change" --json
433
494
  ```
434
495
 
435
496
  Schemas:
@@ -448,6 +509,51 @@ type IdentityStatus = Success<{
448
509
  }>;
449
510
 
450
511
  type IdentityUse = IdentityStatus;
512
+
513
+ type IdentityInspection = Success<{
514
+ inspection: {
515
+ targetUserId: number;
516
+ privateEvidenceIncluded: boolean;
517
+ manualBucket: {
518
+ id: number;
519
+ label: string;
520
+ memberCount: number;
521
+ isBanned: boolean;
522
+ } | null;
523
+ oldestAccount: IdentityCandidate | null;
524
+ oldestAccountBasis: "manual_bucket" | "verified_email" | "target_only";
525
+ oldestAccountComplete: boolean;
526
+ candidateAltCount: number;
527
+ accounts: IdentityCandidate[];
528
+ evidenceCoverage: {
529
+ deviceLookbackDays: number;
530
+ targetDeviceEvidenceRows: number;
531
+ targetDeviceIdsConsidered: number;
532
+ relatedDeviceEvidenceRows: number;
533
+ candidateLimit: number;
534
+ truncated: boolean;
535
+ };
536
+ };
537
+ }>;
538
+
539
+ type IdentityCandidate = {
540
+ userId: number;
541
+ username: string | null;
542
+ joinedAt: number | null;
543
+ isTarget: boolean;
544
+ isOldestAccount: boolean;
545
+ hasDateOfBirth: boolean;
546
+ banned: boolean;
547
+ deleted: boolean;
548
+ relationBasis: Array<
549
+ "target" | "manual_bucket" | "verified_email" | "exact_device"
550
+ >;
551
+ sharedDeviceCount: number;
552
+ privateEvidence?: {
553
+ dateOfBirth: string | null;
554
+ verifiedEmails: string[];
555
+ };
556
+ };
451
557
  ```
452
558
 
453
559
  `identity use` changes only the preference for a future start. It never changes
@@ -465,6 +571,9 @@ lumine admin daily-run escalation add --target chatMessage:3768159 \
465
571
  lumine admin daily-run report --json
466
572
  lumine admin daily-run complete --json
467
573
  lumine admin daily-run fail --reason "operator stopped" --json
574
+ lumine admin escalation list --status all --json
575
+ lumine admin escalation set 123 --status resolved \
576
+ --note "Final owner decision" --json
468
577
  ```
469
578
 
470
579
  Schemas:
@@ -490,6 +599,10 @@ the current active run. Queue coverage is written automatically only after an
490
599
  `--all` traversal reaches canonical exhaustion; an interrupted scan remains in
491
600
  its local checkpoint and cannot be misreported as complete.
492
601
 
602
+ Creating an escalation belongs to the active run; acknowledging, annotating,
603
+ resolving, or reopening it does not. Use the run-independent `escalation`
604
+ commands after Mikey responds instead of starting a follow-up delegated run.
605
+
493
606
  `lastRun` makes a lost-response retry of `complete` or `fail` possible after
494
607
  the active pointer has been cleared. Other run-scoped commands accept only the
495
608
  current unexpired `active` run. Completion first finalizes any mutation whose
@@ -617,6 +730,12 @@ the same API, run, and exact request. The final result can be copied to
617
730
  `--after` is inclusive, and every opaque cursor is bound to its original
618
731
  filters.
619
732
 
733
+ For `--all --json`, stdout remains exactly one JSON value. Scan-start, first-
734
+ page, every-tenth-page, and exhaustion progress is written to **stderr** with
735
+ only page/scanned/candidate counts and the private checkpoint path. A long
736
+ traversal therefore no longer looks stalled, while piping stdout to `jq` or a
737
+ file remains safe.
738
+
620
739
  Recommendations default to `--since-run`: the server uses the previous
621
740
  completed run's start time (or the same bounded seven-day fallback used by the
622
741
  brief on a first run). That deliberate start-to-start overlap gives the queue
@@ -1264,6 +1383,22 @@ rows per source — retry with a narrower `--days` window, and do not complete
1264
1383
  the run while either flag remains true. Run it right after the
1265
1384
  brief, and **read every row** — the tool deliberately does no filtering,
1266
1385
  scoring, or keyword matching, because the judgment is the reviewing agent's.
1386
+
1387
+ **Privacy boundary:** this is an audit of how Twinkle's bots treated members,
1388
+ not a moderation queue for members' private use of the tool. Treat private
1389
+ human messages and creative work as confidential context. Read every
1390
+ bot-authored row, but inspect adjacent human messages only when the minimum
1391
+ necessary context is needed to judge the bot's response; never browse the rest
1392
+ of a private conversation out of curiosity. Do not characterize or escalate a
1393
+ member's lawful private creative writing — including a teenager's romance
1394
+ fiction — merely because its subject is intimate or romantic. An escalation
1395
+ must identify what **Zero or Ciel** did (for example, an invented premise,
1396
+ pressure, sexualization, abuse, or a failed boundary), include only the narrow
1397
+ context needed for Mikey to decide a remedy, and never reuse private material
1398
+ for public editorial judgment, Notable User selection, or unrelated identity
1399
+ investigation. A separate concrete risk to a member may still be escalated,
1400
+ but it does not authorize a broader review of their private activity.
1401
+
1267
1402
  Judge against the same values the editorial priorities encode:
1268
1403
 
1269
1404
  - **premises must be real.** The 08-11 message didn't merely choose a bad
@@ -1347,11 +1482,16 @@ farm-signal sections added the same day):
1347
1482
  ("$X so far today; complete days run ~$Y/day"). Real
1348
1483
  incident: a run report quoted a ~15%-complete day bucket ($5) as the site's
1349
1484
  daily AI spend (complete days were running ~$40-50). Flag accounts that jumped tiers or
1350
- dominate that report period. May be `{ unavailable: true }` if the cost
1485
+ dominate that report period. Routine `topAccounts` rows identify the account
1486
+ by user ID/username and expose only an `identitySummary` count/manual-bucket
1487
+ flag; raw identity strings and verified email addresses are deliberately
1488
+ omitted. Use reason-required `identity inspect`, with
1489
+ `--include-private-evidence` only when Mikey's concrete decision needs the
1490
+ addresses or DOB values. May be `{ unavailable: true }` if the cost
1351
1491
  report fails; say so rather than guessing. This section is also the run's
1352
1492
  AI-cost exploit watch: while reading it, actively look for the signatures the
1353
1493
  brief actually exposes — one risk group spanning several user IDs, repeated
1354
- plus-tag or dot-variant email families among top accounts, or heavy spend by
1494
+ account groups in `farmSignals.inboxFamilies`, or heavy spend by
1355
1495
  accounts that `economy.topGainers` or `notableCandidates` independently marks
1356
1496
  as recent signups. Cross-check those signals against the escalation
1357
1497
  categories. Missing join-date or community data is unknown, not evidence that
@@ -1366,9 +1506,11 @@ farm-signal sections added the same day):
1366
1506
  execute them with
1367
1507
  `lumine admin notable add <userId|username> --note "<specific rationale>"`
1368
1508
  (idempotent —
1369
- an existing member returns `already_done`; requires the `notable:write`
1370
- scope, audited as `notable.add`, and writes through the management page's
1371
- own canonical service). Without his approval the run only proposes.
1509
+ an existing member returns `already_done`; run-independent, privately audited
1510
+ as `notable.add`, and writes through the management page's own canonical
1511
+ service). It can therefore record Mikey's approval after the daily run has
1512
+ closed without opening another delegated run. Without his approval the run
1513
+ only proposes.
1372
1514
  **Always pass `--note`** with a concrete one-or-two-sentence record of what
1373
1515
  made them notable — real numbers and specifics from the brief window, not
1374
1516
  "active user". It lands in the management page's reason column, which is
@@ -1428,12 +1570,14 @@ farm-signal sections added the same day):
1428
1570
  Deliberately coarse: it is an onboarding health check, not per-child
1429
1571
  session tracking.
1430
1572
  - `farmSignals` — AI-cost farm signatures derivable with ZERO new data
1431
- collection: `inboxFamilies` (verified emails from accounts active in the
1432
- last `inboxFamilyActivityDays`, with only Gmail/googlemail's documented
1573
+ collection: `inboxFamilies` (account groups formed from verified emails of
1574
+ accounts active in the last `inboxFamilyActivityDays`, with only
1575
+ Gmail/googlemail's documented
1433
1576
  plus-tag and dot aliases collapsed, flagging inboxes behind 3+ accounts) and
1434
1577
  `youngAccountAiUsage` (accounts under 30 days old drawing battery in the
1435
- whole-day `aiUsageDayWindow`). SIGNAL ONLY: siblings legitimately share a
1436
- parent inbox, so an inbox family is a reason to look, never proof or grounds
1578
+ whole-day `aiUsageDayWindow`). The raw canonical inbox is never returned in
1579
+ the routine brief. SIGNAL ONLY: siblings legitimately share a parent inbox,
1580
+ so an inbox family is a reason to look, never proof or grounds
1437
1581
  for action. Feed real suspicions to the AI-cost escalation category. Shared
1438
1582
  AI device/IP risk evidence is already in `aiSpending.topRiskGroups`; do not
1439
1583
  guess it from inbox similarity.
@@ -1526,7 +1670,28 @@ type InsightsBrief = Success<{
1526
1670
  endDayInProgress: boolean;
1527
1671
  summary: unknown;
1528
1672
  byDay: unknown[];
1529
- topAccounts: unknown[];
1673
+ topAccounts: Array<{
1674
+ userId: number;
1675
+ username: string;
1676
+ eventCount: number;
1677
+ requestCount: number;
1678
+ estimatedCostUsd: number;
1679
+ inputTokens: number;
1680
+ cachedInputTokens: number;
1681
+ cacheEligibleInputTokens: number;
1682
+ outputTokens: number;
1683
+ totalTokens: number;
1684
+ imageCount: number;
1685
+ audioSeconds: number;
1686
+ energyUnits: number;
1687
+ energyChargedUnits: number;
1688
+ energyOverflowUnits: number;
1689
+ coinCharged: number;
1690
+ identitySummary: {
1691
+ observedIdentityCount: number;
1692
+ manualBucketObserved: boolean;
1693
+ };
1694
+ }>;
1530
1695
  topRiskGroups: unknown[];
1531
1696
  }
1532
1697
  | InsightUnavailable;
@@ -1614,7 +1779,6 @@ type InsightsBrief = Success<{
1614
1779
  | {
1615
1780
  inboxFamilyActivityDays: number;
1616
1781
  inboxFamilies: Array<{
1617
- inbox: string;
1618
1782
  accounts: Array<{
1619
1783
  userId: number;
1620
1784
  username: string | null;