@stage5/lumine 0.3.54 → 0.3.58

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.
@@ -0,0 +1,472 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+ const round = (value) => {
6
+ if (!Number.isFinite(value))
7
+ throw new Error("Invalid computed spending amount.");
8
+ return Number(value.toFixed(8));
9
+ };
10
+ const USAGE =
11
+ "lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--json]";
12
+
13
+ function invalid(message) {
14
+ return Object.assign(new Error(message), {
15
+ code: "CLI_ADMIN_CLI_VALIDATION",
16
+ });
17
+ }
18
+
19
+ export function buildSpendingOperation(options) {
20
+ if (
21
+ (options.positional || []).length !== 1 ||
22
+ options.adminDays ||
23
+ options.adminDate ||
24
+ options.adminAll ||
25
+ options.adminResume
26
+ ) {
27
+ throw invalid(`Usage: ${USAGE}.`);
28
+ }
29
+ const query = new URLSearchParams();
30
+ const values = {
31
+ month: options.adminSpendingMonth,
32
+ from: options.adminSpendingFrom,
33
+ to: options.adminSpendingTo,
34
+ };
35
+ for (const [key, value] of Object.entries(values)) {
36
+ if (value === undefined) continue;
37
+ if (typeof value !== "string" || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(value))
38
+ throw invalid(`--${key} must use YYYY-MM format.`);
39
+ query.set(key, value);
40
+ }
41
+ if (query.has("month") && (query.has("from") || query.has("to")))
42
+ throw invalid("Use --month or --from/--to, not both.");
43
+ if (query.has("from") !== query.has("to"))
44
+ throw invalid("Provide both --from and --to.");
45
+ if (query.has("from") && query.get("from") > query.get("to"))
46
+ throw invalid("--from must not follow --to.");
47
+ const qs = query.toString();
48
+ return {
49
+ name: "spending.report",
50
+ method: "GET",
51
+ path: `/cli/admin/spending${qs ? `?${qs}` : ""}`,
52
+ mutates: false,
53
+ requiresRun: false,
54
+ timeoutMs: 60000,
55
+ };
56
+ }
57
+
58
+ async function runAws(args, { profile, region, timeoutMs }) {
59
+ try {
60
+ const { stdout } = await execFileAsync(
61
+ "aws",
62
+ [
63
+ ...args,
64
+ "--profile",
65
+ profile,
66
+ "--region",
67
+ region,
68
+ "--output",
69
+ "json",
70
+ "--no-cli-pager",
71
+ ],
72
+ {
73
+ encoding: "utf8",
74
+ timeout: timeoutMs,
75
+ maxBuffer: 8 * 1024 * 1024,
76
+ env: { ...process.env, AWS_PAGER: "", AWS_CLI_AUTO_PROMPT: "off" },
77
+ },
78
+ );
79
+ return JSON.parse(stdout);
80
+ } catch (error) {
81
+ // Do not expose subprocess stdout, credentials or arbitrary provider error
82
+ // payloads in a saved financial report.
83
+ throw new Error(
84
+ error.code === "ENOENT"
85
+ ? "AWS CLI is not installed. Install it and configure the requested profile."
86
+ : `AWS billing could not be read with profile ${profile}; check its authentication and Cost Explorer access.`,
87
+ );
88
+ }
89
+ }
90
+
91
+ function amount(metric) {
92
+ if (
93
+ metric?.Unit !== "USD" ||
94
+ metric.Amount === "" ||
95
+ metric.Amount == null ||
96
+ !Number.isFinite(Number(metric.Amount))
97
+ ) {
98
+ throw new Error(
99
+ "AWS returned an invalid amount or a currency other than USD.",
100
+ );
101
+ }
102
+ return Number(metric.Amount);
103
+ }
104
+
105
+ export function normalizeAwsSpending(pages, months) {
106
+ const expected = new Map(
107
+ months
108
+ .filter((m) => m.startDayKey < m.endDayKeyExclusive)
109
+ .map((m) => [m.startDayKey, m]),
110
+ );
111
+ const found = new Map();
112
+ for (const page of pages) {
113
+ if (!Array.isArray(page?.ResultsByTime))
114
+ throw new Error("AWS returned no billing periods.");
115
+ for (const period of page.ResultsByTime) {
116
+ const start = period.TimePeriod?.Start;
117
+ const requested = expected.get(start);
118
+ if (
119
+ !requested ||
120
+ period.TimePeriod?.End !== requested.endDayKeyExclusive ||
121
+ typeof period.Estimated !== "boolean"
122
+ ) {
123
+ throw new Error(
124
+ "AWS billing periods do not match the requested UTC months.",
125
+ );
126
+ }
127
+ let target = found.get(start);
128
+ if (!target) {
129
+ target = {
130
+ status: "available",
131
+ costUsd: 0,
132
+ estimated: period.Estimated,
133
+ services: [],
134
+ startDayKey: start,
135
+ endDayKeyExclusive: period.TimePeriod.End,
136
+ };
137
+ found.set(start, target);
138
+ }
139
+ if (
140
+ target.estimated !== period.Estimated ||
141
+ !Array.isArray(period.Groups)
142
+ )
143
+ throw new Error("Inconsistent AWS billing page.");
144
+ if (
145
+ period.Groups.length === 0 &&
146
+ period.Total?.UnblendedCost &&
147
+ amount(period.Total.UnblendedCost) !== 0
148
+ ) {
149
+ throw new Error("AWS omitted the requested service breakdown.");
150
+ }
151
+ for (const group of period.Groups) {
152
+ if (
153
+ !Array.isArray(group.Keys) ||
154
+ group.Keys.length !== 1 ||
155
+ typeof group.Keys[0] !== "string" ||
156
+ target.services.some((s) => s.service === group.Keys[0])
157
+ )
158
+ throw new Error("Duplicate or invalid AWS service row.");
159
+ const costUsd = amount(group.Metrics?.UnblendedCost);
160
+ target.services.push({ service: group.Keys[0], costUsd });
161
+ target.costUsd += costUsd;
162
+ }
163
+ }
164
+ }
165
+ if (found.size !== expected.size)
166
+ throw new Error("AWS billing is missing a requested month.");
167
+ return months.map((month) => {
168
+ if (month.startDayKey === month.endDayKeyExclusive)
169
+ return {
170
+ status: "no_completed_days",
171
+ costUsd: 0,
172
+ estimated: true,
173
+ services: [],
174
+ startDayKey: month.startDayKey,
175
+ endDayKeyExclusive: month.endDayKeyExclusive,
176
+ };
177
+ const value = found.get(month.startDayKey);
178
+ return {
179
+ ...value,
180
+ costUsd: round(value.costUsd),
181
+ services: value.services.sort((a, b) => b.costUsd - a.costUsd),
182
+ };
183
+ });
184
+ }
185
+
186
+ function validateReport(report) {
187
+ if (
188
+ report?.schemaVersion !== 1 ||
189
+ report.currency !== "USD" ||
190
+ report.timezone !== "UTC" ||
191
+ !Array.isArray(report.months) ||
192
+ !report.months.length ||
193
+ report.months.length > 12 ||
194
+ !report.sources?.aws ||
195
+ report.range?.includesInProgressDay !== false
196
+ ) {
197
+ throw new Error(
198
+ "The API did not return a supported spending report; deploy the matching API release.",
199
+ );
200
+ }
201
+ let expectedStart = report.range.startDayKey;
202
+ for (const month of report.months) {
203
+ const nextMonth = new Date(
204
+ Date.UTC(
205
+ Number(month.monthKey?.slice(0, 4)),
206
+ Number(month.monthKey?.slice(5, 7)),
207
+ 1,
208
+ ),
209
+ )
210
+ .toISOString()
211
+ .slice(0, 10);
212
+ if (
213
+ !/^20\d{2}-(0[1-9]|1[0-2])$/.test(month.monthKey) ||
214
+ month.startDayKey !== `${month.monthKey}-01` ||
215
+ month.startDayKey !== expectedStart ||
216
+ !/^20\d{2}-\d{2}-\d{2}$/.test(month.endDayKeyExclusive) ||
217
+ month.endDayKeyExclusive < month.startDayKey ||
218
+ month.endDayKeyExclusive > nextMonth ||
219
+ (month.status === "closed" && month.endDayKeyExclusive !== nextMonth) ||
220
+ (month !== report.months.at(-1) &&
221
+ month.endDayKeyExclusive !== nextMonth) ||
222
+ new Date(`${month.endDayKeyExclusive}T00:00:00Z`)
223
+ .toISOString()
224
+ .slice(0, 10) !== month.endDayKeyExclusive
225
+ ) {
226
+ throw new Error("The API spending report has invalid month boundaries.");
227
+ }
228
+ expectedStart = nextMonth;
229
+ // An available source must supply its amounts. In particular, null paid
230
+ // totals must never be coerced to zero when combined with operating costs.
231
+ const required = [
232
+ ...(month.ai ? [month.ai.grossUsd] : []),
233
+ ...(month.reimbursements
234
+ ? [
235
+ month.reimbursements.aiServiceUsd,
236
+ month.reimbursements.infrastructureUsd,
237
+ month.reimbursements.totalUsd,
238
+ ]
239
+ : []),
240
+ ];
241
+ if (
242
+ required.some(
243
+ (value) => typeof value !== "number" || !Number.isFinite(value),
244
+ )
245
+ )
246
+ throw new Error("The API spending report has incomplete source amounts.");
247
+ for (const value of [
248
+ month.ai?.grossUsd,
249
+ month.ai?.creditUsd,
250
+ month.ai?.estimatedCashUsd,
251
+ month.reimbursements?.aiServiceUsd,
252
+ month.reimbursements?.infrastructureUsd,
253
+ month.reimbursements?.totalUsd,
254
+ ]) {
255
+ if (
256
+ value != null &&
257
+ (typeof value !== "number" || !Number.isFinite(value) || value < 0)
258
+ )
259
+ throw new Error("Invalid spending source amount.");
260
+ }
261
+ }
262
+ if (
263
+ report.months.at(-1).endDayKeyExclusive !== report.range.endDayKeyExclusive
264
+ )
265
+ throw new Error("The API spending report has incomplete month coverage.");
266
+ }
267
+
268
+ export async function completeSpendingReport(receipt, options, aws = runAws) {
269
+ if (receipt?.ok !== true) return receipt;
270
+ const report = receipt.data?.spending;
271
+ validateReport(report);
272
+ const warnings = [...(report.warnings || [])];
273
+ const source = report.sources.aws;
274
+ const profile = options.adminAwsProfile || source.defaultProfile;
275
+ if (typeof profile !== "string" || !profile.trim())
276
+ throw invalid("--aws-profile must name an AWS CLI profile.");
277
+ let awsMonths = null;
278
+ let awsError = null;
279
+ try {
280
+ const settings = {
281
+ profile,
282
+ region: source.region,
283
+ timeoutMs: Math.max(
284
+ 1000,
285
+ Math.min(60000, Number(options.timeoutMs) || 30000),
286
+ ),
287
+ };
288
+ const hasCompletedDays =
289
+ report.range.startDayKey < report.range.endDayKeyExclusive;
290
+ const pages = [];
291
+ if (hasCompletedDays) {
292
+ const identity = await aws(["sts", "get-caller-identity"], settings);
293
+ if (
294
+ identity?.Account !== source.accountId ||
295
+ identity?.Arn !== source.principalArn
296
+ ) {
297
+ throw new Error(
298
+ "AWS identity does not match the Twinkle billing principal. No Cost Explorer query was sent.",
299
+ );
300
+ }
301
+ let token;
302
+ const seen = new Set();
303
+ do {
304
+ if (pages.length >= 100)
305
+ throw new Error("AWS billing exceeded the pagination limit.");
306
+ const page = await aws(
307
+ [
308
+ "ce",
309
+ "get-cost-and-usage",
310
+ "--time-period",
311
+ `Start=${report.range.startDayKey},End=${report.range.endDayKeyExclusive}`,
312
+ "--granularity",
313
+ "MONTHLY",
314
+ "--metrics",
315
+ "UnblendedCost",
316
+ "--group-by",
317
+ "Type=DIMENSION,Key=SERVICE",
318
+ "--no-paginate",
319
+ ...(token ? ["--next-page-token", token] : []),
320
+ ],
321
+ settings,
322
+ );
323
+ pages.push(page);
324
+ token = page.NextPageToken;
325
+ if (token && (typeof token !== "string" || seen.has(token)))
326
+ throw new Error("AWS billing pagination did not advance.");
327
+ if (token) seen.add(token);
328
+ } while (token);
329
+ }
330
+ awsMonths = normalizeAwsSpending(pages, report.months);
331
+ } catch (error) {
332
+ awsError = error.message;
333
+ warnings.push(awsError);
334
+ }
335
+ const months = report.months.map((month, i) => {
336
+ const billing = awsMonths?.[i] ?? null;
337
+ const overlap = month.ai?.awsOverlapDetected === true;
338
+ if (overlap)
339
+ warnings.push(
340
+ `${month.monthKey}: AWS-billed AI detected; combined totals require overlap reconciliation.`,
341
+ );
342
+ const operating =
343
+ month.ai?.estimatedCashUsd != null && billing && !overlap
344
+ ? round(month.ai.estimatedCashUsd + billing.costUsd)
345
+ : null;
346
+ return {
347
+ ...month,
348
+ aws: billing,
349
+ providerOperatingCostUsd: operating,
350
+ totalIncludingReimbursementsUsd:
351
+ operating !== null && month.reimbursements
352
+ ? round(operating + month.reimbursements.totalUsd)
353
+ : null,
354
+ };
355
+ });
356
+ const complete = months.every(
357
+ (m) => m.totalIncludingReimbursementsUsd !== null,
358
+ );
359
+ const sum = (select) =>
360
+ months.every((m) => select(m) != null)
361
+ ? round(months.reduce((n, m) => n + select(m), 0))
362
+ : null;
363
+ return {
364
+ ...receipt,
365
+ status: complete ? "success" : "partial",
366
+ data: {
367
+ ...receipt.data,
368
+ spending: {
369
+ ...report,
370
+ complete,
371
+ months,
372
+ warnings,
373
+ sources: {
374
+ ...report.sources,
375
+ aws: {
376
+ ...source,
377
+ profile,
378
+ status: awsMonths ? "available" : "unavailable",
379
+ error: awsError,
380
+ },
381
+ },
382
+ totals: {
383
+ aiEstimatedCashUsd: sum((m) => m.ai?.estimatedCashUsd),
384
+ awsUsd: sum((m) => m.aws?.costUsd),
385
+ reimbursementsUsd: sum((m) => m.reimbursements?.totalUsd),
386
+ totalIncludingReimbursementsUsd: sum(
387
+ (m) => m.totalIncludingReimbursementsUsd,
388
+ ),
389
+ },
390
+ },
391
+ },
392
+ };
393
+ }
394
+
395
+ export function formatSpending(report) {
396
+ const money = (value) =>
397
+ value == null
398
+ ? "Unavailable"
399
+ : `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
400
+ const lines = [
401
+ `Twinkle spending · USD · UTC · ${report.complete ? "complete sources" : "INCOMPLETE SOURCES"}`,
402
+ ];
403
+ const rows = [
404
+ ["Category", ...report.months.map((m) => m.monthKey)],
405
+ [
406
+ "AI after credits",
407
+ ...report.months.map((m) => money(m.ai?.estimatedCashUsd)),
408
+ ],
409
+ ["AWS (including tax)", ...report.months.map((m) => money(m.aws?.costUsd))],
410
+ [
411
+ "Reimbursements",
412
+ ...report.months.map((m) => money(m.reimbursements?.totalUsd)),
413
+ ],
414
+ [
415
+ "TOTAL incl. reimbursements",
416
+ ...report.months.map((m) => money(m.totalIncludingReimbursementsUsd)),
417
+ ],
418
+ ["", ...report.months.map(() => "")],
419
+ ["AI before credits", ...report.months.map((m) => money(m.ai?.grossUsd))],
420
+ ["Promotional credit", ...report.months.map((m) => money(m.ai?.creditUsd))],
421
+ [
422
+ "Reimbursement: AI service",
423
+ ...report.months.map((m) => money(m.reimbursements?.aiServiceUsd)),
424
+ ],
425
+ [
426
+ "Reimbursement: infrastructure",
427
+ ...report.months.map((m) => money(m.reimbursements?.infrastructureUsd)),
428
+ ],
429
+ ];
430
+ const widths = rows[0].map((_, i) =>
431
+ Math.max(...rows.map((row) => row[i].length)),
432
+ );
433
+ lines.push(
434
+ ...rows.map((row) =>
435
+ row
436
+ .map((cell, i) =>
437
+ i ? cell.padStart(widths[i]) : cell.padEnd(widths[i]),
438
+ )
439
+ .join(" | "),
440
+ ),
441
+ );
442
+ for (const month of report.months) {
443
+ if (month.status === "in_progress")
444
+ lines.push(
445
+ `${month.monthKey} AI/AWS: ${month.throughDayKey ? `through ${month.throughDayKey} UTC` : "no completed UTC days"}; current usage day excluded.`,
446
+ );
447
+ if (month.aws?.estimated)
448
+ lines.push(
449
+ `${month.monthKey} AWS is provisional and may change with billing updates.`,
450
+ );
451
+ }
452
+ lines.push(
453
+ `Selected-period total: ${money(report.totals.totalIncludingReimbursementsUsd)}`,
454
+ );
455
+ lines.push(
456
+ "AI is a usage-based estimate after credits. AWS estimated flags and service details are in JSON.",
457
+ );
458
+ lines.push(
459
+ "Reimbursements are gross paid amounts by payment date, before refunds/fees; included once in the total above.",
460
+ );
461
+ if (report.sources.reimbursements?.includesCurrentDay)
462
+ lines.push(
463
+ `Reimbursements include today's settled payments through ${new Date((report.sources.reimbursements.endAtExclusive - 1) * 1000).toISOString()}.`,
464
+ );
465
+ if (report.inProgressDay)
466
+ lines.push(
467
+ `${report.inProgressDay.dayKey} partial AI usage: ${money(report.inProgressDay.ai?.grossUsd)} before credits, excluded.`,
468
+ );
469
+ for (const warning of report.warnings || [])
470
+ lines.push(`Unavailable: ${warning}`);
471
+ return lines.join("\n");
472
+ }
package/lib/admin.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  printOwnerTrace,
45
45
  } from "./admin-owner-trace.js";
46
46
  import { buildRolesOperation, printRoles } from "./admin-roles.js";
47
+ import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
47
48
  import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
48
49
  import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
49
50
  import {
@@ -380,7 +381,7 @@ function buildApprovalOperation({ action, target, options }) {
380
381
  "approvals.decide",
381
382
  "POST",
382
383
  `/cli/admin/approvals/${id}`,
383
- { decision: action, reason },
384
+ { decision: action, reason, ...(options.adminFingerprint ? { fingerprint: String(options.adminFingerprint) } : {}) },
384
385
  { requiresRun: false },
385
386
  );
386
387
  }
@@ -1545,7 +1546,10 @@ export async function adminCommand(options) {
1545
1546
  recommendationContentTypes,
1546
1547
  viewFilter,
1547
1548
  });
1548
- if (operation.featuredWorkflow) {
1549
+ if (operation.name === "spending.report") {
1550
+ result = await completeSpendingReport(await fetchOperation(), options);
1551
+ if (result.status === "partial") process.exitCode = 1;
1552
+ } else if (operation.featuredWorkflow) {
1549
1553
  result = await runFeaturedWorkflow({
1550
1554
  options,
1551
1555
  operation,
@@ -1624,6 +1628,11 @@ export async function adminCommand(options) {
1624
1628
  // private evidence is printed, never kept in the receipt file
1625
1629
  mutationIntent?.confirm(receiptToKeep(operation, result));
1626
1630
  } catch (error) {
1631
+ if (operation.name === "spending.report" && error.status === 404) {
1632
+ error.message = "The spending report API is not deployed yet. Deploy the matching API before using this CLI command.";
1633
+ error.code = "CLI_ADMIN_SPENDING_NOT_DEPLOYED";
1634
+ error.data = { ok: false, status: "unavailable", error: { code: error.code, message: error.message, details: null } };
1635
+ }
1627
1636
  if (operation.name === "runtime.evidence" && error.status === 404) {
1628
1637
  error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
1629
1638
  error.message =
@@ -3077,11 +3086,11 @@ export function parseAdminOperation(options) {
3077
3086
  }
3078
3087
 
3079
3088
  if (MEETUP_NAMESPACES.has(namespace)) {
3080
- // Bridge Builder crews (achievement type 'meetup'): the same admin tools as
3081
- // the website. Crews no longer wait for staff approvals (check-ins are the
3082
- // proof, 2026-10-17). Mikey's own decision, any time, never in a daily run.
3089
+ // Bridge Builder crews (achievement type 'meetup'): the same owner tools
3090
+ // as the website. Check-ins provide evidence; every meetup completion
3091
+ // waits for Mikey's decision (2026-10-22).
3083
3092
  const usage =
3084
- "Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch <name> [--note <text>] | branches [list|show|approve|reject|merge] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | story ... | announce-checkins [--send] [--as zero|ciel].";
3093
+ "Usage: lumine admin meetup list [--status review|active|completed|all] | show <crewId> | review-completion <crewId> --decision approve|reject --fingerprint <review fingerprint> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch <name> [--note <text>] | branches [list|show|approve|reject|merge] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | story ... | announce-checkins [--send] [--as zero|ciel].";
3085
3094
  const note = String(options.note || "").trim();
3086
3095
  if (note.length > 1000) {
3087
3096
  throw cliValidationError("--note must be at most 1000 characters.");
@@ -3233,6 +3242,14 @@ export function parseAdminOperation(options) {
3233
3242
  { requiresRun: false },
3234
3243
  );
3235
3244
  }
3245
+ if (action === "review-completion") {
3246
+ const crewId = parseRequiredInteger(target, "Crew ID", 1);
3247
+ const decision = parseChoice(options.adminDecision || "", "--decision", ["approve", "reject"]);
3248
+ const fingerprint = String(options.adminFingerprint || "");
3249
+ if (!/^[0-9a-f]{64}$/.test(fingerprint)) throw cliValidationError("Use --fingerprint from meetup show --json after reviewing every participant's evidence.");
3250
+ return writeOperation("meetup.review-completion", "POST", `/cli/admin/meetup-quest/crews/${crewId}/checkin-review`,
3251
+ { isApproved: decision === "approve", fingerprint }, { requiresRun: false });
3252
+ }
3236
3253
  if (action === "info") {
3237
3254
  // Staff's "who are you" check on a member (ruled 2026-10-07): the member
3238
3255
  // gives their teacher's name and class name (or how they know the crew)
@@ -3629,6 +3646,17 @@ export function parseAdminOperation(options) {
3629
3646
  throw cliValidationError(usage);
3630
3647
  }
3631
3648
 
3649
+ if (namespace === "reward-diagnostics") {
3650
+ const buildId = options.buildIdFlag ? parseRequiredInteger(options.buildIdFlag, "--build", 1) : 0;
3651
+ const userId = options.adminUser ? parseRequiredInteger(options.adminUser, "--user", 1) : 0;
3652
+ const days = options.adminDays ? parseRequiredInteger(options.adminDays, "--days", 1) : 1;
3653
+ if (days > 30 || userId && !buildId) throw cliValidationError("Use --build with --user; --days must be 1–30.");
3654
+ return readOperation("reward-diagnostics.read", withQuery("/cli/admin/reward-diagnostics", {
3655
+ buildId: buildId || undefined, userId: userId || undefined, days,
3656
+ before: options.adminCursor ? parseRequiredInteger(options.adminCursor, "--cursor", 1) : undefined
3657
+ }), { requiresRun: false });
3658
+ }
3659
+
3632
3660
  if (namespace === "reward-activity" || namespace === "reward-telemetry") {
3633
3661
  // Read-only claim telemetry for the daily run: no run lease, no mutation.
3634
3662
  const date = options.adminDate
@@ -4474,6 +4502,8 @@ export function parseAdminOperation(options) {
4474
4502
  );
4475
4503
  }
4476
4504
 
4505
+ if (namespace === "spending") return buildSpendingOperation(options);
4506
+
4477
4507
  if (namespace === "ai-costs") {
4478
4508
  if (action === "monthly" && !target && !extra) {
4479
4509
  if (options.adminDays) {
@@ -6620,8 +6650,10 @@ function printMeetupResult({ operation, data, result }) {
6620
6650
  }
6621
6651
  if (operation.name === "meetup.show") {
6622
6652
  printMeetupCrew(data.crew || {});
6653
+ if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
6623
6654
  return;
6624
6655
  }
6656
+ if (operation.name === "meetup.review-completion") console.log(`Meetup completion ${data.status}.`);
6625
6657
  if (data.crew) printMeetupCrew(data.crew);
6626
6658
  }
6627
6659
 
@@ -6933,6 +6965,7 @@ function printApprovalResult({ operation, data }) {
6933
6965
  `${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId})`,
6934
6966
  );
6935
6967
  printEvidence(data.evidence);
6968
+ if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
6936
6969
  return;
6937
6970
  }
6938
6971
  if (operation.name === "approvals.decide") {
@@ -7360,10 +7393,16 @@ function printAiBucketRuleResult({ operation, data }) {
7360
7393
 
7361
7394
  function printAdminResult({ operation, result }) {
7362
7395
  const data = result?.data || {};
7396
+ if (operation.name === "spending.report" && data.spending) {
7397
+ console.log(formatSpending(data.spending));
7398
+ return;
7399
+ }
7363
7400
  if (operation.name === "math-lab.queue") {
7364
7401
  console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
7365
7402
  for (const grade of data.grades || []) console.log(grade.available
7366
- ? `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7403
+ ? grade.progression === "collection"
7404
+ ? `${grade.ruleId}: ${grade.sets} in collection; ${grade.remaining} fresh choices minimum (${grade.activeLearners} active learners; median ${grade.medianRemaining})${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7405
+ : `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7367
7406
  : `${grade.ruleId}: queue unavailable`);
7368
7407
  if (!data.complete) console.log("Queue coverage is incomplete; unavailable grades are not empty queues.");
7369
7408
  return;
package/lib/commands.js CHANGED
@@ -2562,6 +2562,13 @@ export function parseArgs(args) {
2562
2562
  );
2563
2563
  }
2564
2564
  }
2565
+ if (command === "admin" && positional[0] === "spending") {
2566
+ const allowed = new Set(["help", "month", "from", "to", "awsProfile", "output", "json",
2567
+ "apiUrl", "siteUrl", "authFile", "authToken", "clientName", "timeoutMs", "noUpdateCheck"]);
2568
+ for (const key of Object.keys(raw)) {
2569
+ if (!allowed.has(key)) throw new Error(`Unsupported spending option --${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.`);
2570
+ }
2571
+ }
2565
2572
  const suggestionInboxActions = new Set([
2566
2573
  "list",
2567
2574
  "merge",
@@ -2805,6 +2812,10 @@ export function parseArgs(args) {
2805
2812
  adminActions: raw.actions ? String(raw.actions) : "",
2806
2813
  adminDate: raw.date ? String(raw.date) : "",
2807
2814
  adminDays: raw.days ? String(raw.days) : "",
2815
+ adminSpendingMonth: raw.month,
2816
+ adminSpendingFrom: raw.from,
2817
+ adminSpendingTo: raw.to,
2818
+ adminAwsProfile: raw.awsProfile,
2808
2819
  adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
2809
2820
  adminBuild: raw.build ? String(raw.build) : "",
2810
2821
  adminEditionId: raw.editionId ? String(raw.editionId) : "",
@@ -3269,6 +3280,7 @@ export function printHelp() {
3269
3280
  lumine admin meetup list [--status review|active|completed|all] | show <crewId> | info <crewId> <userId> --decision ask|accept|ask-again|withdraw [--note <text>] | set-branch <crewId> <userId> --branch "<name>" [--note <text>] | branches [list [--status pending|official|rejected|all] | show <id> | approve <id> | reject <id> [--note <text>] | merge <id> --into <officialId>] | emails [list|send <id>|discard <id>|review on|off] | friends [status|on|off] | funnel [--days <1-90>] | announce-checkins [--send] [--as zero|ciel] (Bridge Builder crews and check-ins)
3270
3281
  lumine admin meetup story list | show <crewId> | approve <crewId> [--note <text>] [--no-announce] | send-back <crewId> --note <text> | unpublish <crewId> --note <text> (Bridge Builder story pages: publishes only what every tagged member's parent allowed)
3271
3282
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3283
+ lumine admin reward-diagnostics [--build <id> --user <id>] [--days <1..30>] [--cursor <id>] [--json]
3272
3284
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
3273
3285
  lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
3274
3286
  lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
@@ -3315,7 +3327,7 @@ export function printHelp() {
3315
3327
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3316
3328
  lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
3317
3329
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3318
- lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake: only sessions and the 20 s wake profiles after each load or return, with the network tag that separates Wi-Fi from cellular; what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
3330
+ lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake: sessions, 20 s wake profiles, bounded viewport geometry and dropped-event counts after loads or returns; network tags identify the upload network; what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
3319
3331
  lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3320
3332
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3321
3333
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
@@ -3325,6 +3337,7 @@ export function printHelp() {
3325
3337
  (with both a comment session and a daily run active, comment draft/reply/post/edit need --via session|run)
3326
3338
  lumine admin comment edit <comment-id> --file <comment.md> [--review-receipt <review.json> | --reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--json]
3327
3339
  lumine admin brief [--days <1..30>] [--json]
3340
+ lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--output <file.json>] [--json] (owner spending: AI, AWS, reimbursements; no active run required)
3328
3341
  lumine admin ai-costs monthly [--output <file.json>] [--json] (no active run required)
3329
3342
  lumine admin ai-costs day <YYYY-MM-DD> [--json]
3330
3343
  lumine admin media-costs monthly [--json]
package/lib/sdk.js CHANGED
@@ -303,7 +303,7 @@ export const SDK_CLI_METHODS = {
303
303
  path: "api/rewards/related-archive", special: "rewards", operation: "related-archive",
304
304
  scopes: ["rewards:claim"], readOnly: true,
305
305
  mapArgs: (args) =>
306
- args.receiptId !== undefined ? { receiptId: args.receiptId } : { ruleId: args.ruleId },
306
+ args.receiptId !== undefined ? { receiptId: args.receiptId } : { ruleId: args.ruleId, ...(args.setKey !== undefined ? { setKey: args.setKey } : {}) },
307
307
  },
308
308
  "rewards.listExplanations": {
309
309
  path: "api/rewards/explanations", special: "rewards", operation: "explanations",
@@ -349,7 +349,7 @@ export const SDK_CLI_METHODS = {
349
349
  operation: "start",
350
350
  scopes: ["rewards:claim"],
351
351
  write: true,
352
- mapArgs: (args) => ({ ruleId: args.ruleId }),
352
+ mapArgs: (args) => ({ ruleId: args.ruleId, ...(args.setKey !== undefined ? { setKey: args.setKey } : {}) }),
353
353
  },
354
354
  "rewards.claim": {
355
355
  path: "api/rewards/claim",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.54",
3
+ "version": "0.3.58",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {