@stage5/lumine 0.3.53 → 0.3.55

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,45 @@
1
+ import { readAdminJsonFile } from './admin-news.js';
2
+
3
+ const USAGE = 'Usage: lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] [--json]';
4
+ function invalid(message) {
5
+ const error = new Error(message);
6
+ error.code = 'CLI_ADMIN_CLI_VALIDATION';
7
+ throw error;
8
+ }
9
+ export function buildNonogramOperation({ action, target, options }) {
10
+ if (!action || action === 'status') return {
11
+ name: 'nonogram.status', method: 'GET', path: '/cli/admin/nonogram/status',
12
+ mutates: false, requiresRun: false
13
+ };
14
+ if (!['add', 'retire', 'restore'].includes(action)) invalid(USAGE);
15
+ if (options.adminConfirm !== '' && options.adminConfirm !== 'true')
16
+ invalid('Pass --confirm on its own, without a value.');
17
+ const confirm = options.adminConfirm === 'true';
18
+ if (action !== 'add') {
19
+ if (typeof target !== 'string' || !/^[a-z0-9][a-z0-9-]{2,79}$/.test(target)) invalid('Supply one exact picture key.');
20
+ return { name: `nonogram.${action}${confirm ? '' : '.preview'}`, method: 'POST',
21
+ path: '/cli/admin/nonogram/availability', body: { key: target, retire: action === 'retire', dryRun: !confirm },
22
+ mutates: confirm, requiresRun: false };
23
+ }
24
+ const pictures = readAdminJsonFile(options.adminFile, '--file <pictures.json>', { maxBytes: 1024 * 1024 });
25
+ if (!Array.isArray(pictures) || !pictures.length || pictures.length > 150)
26
+ invalid('The file must contain 1–150 reviewed pictures. Split larger refills.');
27
+ return {
28
+ name: confirm ? 'nonogram.add' : 'nonogram.add.preview', method: 'POST',
29
+ path: '/cli/admin/nonogram/add', body: { pictures, dryRun: !confirm },
30
+ mutates: confirm, requiresRun: false, timeoutMs: 120000
31
+ };
32
+ }
33
+ export function formatNonogramResult({ operation, data }) {
34
+ if (operation.name === 'nonogram.status') return (data?.sizes || []).map((s) =>
35
+ `${s.size}×${s.size}: ${s.total} pictures; at least ${s.leastUnseen} unseen for every learner (${s.reserveDays} days at 3/day; target ${s.targetDays}).`);
36
+ if (/^nonogram\.(retire|restore)/.test(operation.name)) return [
37
+ `${data?.dryRun ? 'Dry run: would mark' : 'Marked'} ${data?.key} ${data?.retired ? 'retired' : 'available'}. Started puzzles are preserved.`,
38
+ ...(data?.dryRun ? ['Nothing was written. Add --confirm to apply.'] : [])
39
+ ];
40
+ return [
41
+ `${data?.dryRun ? 'Dry run: would add' : 'Added'} ${data?.added?.length || 0} pictures; skipped ${data?.skipped?.length || 0} duplicates.`,
42
+ ...(data?.skipped || []).map((p) => `${p.key}: ${p.reason}`),
43
+ ...(data?.dryRun ? ['Nothing was written. Add --confirm to import this reviewed collection.'] : [])
44
+ ];
45
+ }
@@ -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,7 +44,9 @@ 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";
49
+ import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
48
50
  import {
49
51
  buildSlidingBlockOperation,
50
52
  formatSlidingBlockResult,
@@ -1544,7 +1546,10 @@ export async function adminCommand(options) {
1544
1546
  recommendationContentTypes,
1545
1547
  viewFilter,
1546
1548
  });
1547
- 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) {
1548
1553
  result = await runFeaturedWorkflow({
1549
1554
  options,
1550
1555
  operation,
@@ -1623,6 +1628,11 @@ export async function adminCommand(options) {
1623
1628
  // private evidence is printed, never kept in the receipt file
1624
1629
  mutationIntent?.confirm(receiptToKeep(operation, result));
1625
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
+ }
1626
1636
  if (operation.name === "runtime.evidence" && error.status === 404) {
1627
1637
  error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
1628
1638
  error.message =
@@ -2650,6 +2660,7 @@ export function parseAdminOperation(options) {
2650
2660
  if (namespace === "sliding-block") {
2651
2661
  return buildSlidingBlockOperation({ action, options });
2652
2662
  }
2663
+ if (namespace === 'nonogram') return buildNonogramOperation({ action, target, options });
2653
2664
  // Stuck Lumine Build plans and their free recheck (admin-build-plans.js).
2654
2665
  if (namespace === "build-plans") {
2655
2666
  return buildBuildPlansOperation({ action, options });
@@ -4472,6 +4483,8 @@ export function parseAdminOperation(options) {
4472
4483
  );
4473
4484
  }
4474
4485
 
4486
+ if (namespace === "spending") return buildSpendingOperation(options);
4487
+
4475
4488
  if (namespace === "ai-costs") {
4476
4489
  if (action === "monthly" && !target && !extra) {
4477
4490
  if (options.adminDays) {
@@ -7358,6 +7371,10 @@ function printAiBucketRuleResult({ operation, data }) {
7358
7371
 
7359
7372
  function printAdminResult({ operation, result }) {
7360
7373
  const data = result?.data || {};
7374
+ if (operation.name === "spending.report" && data.spending) {
7375
+ console.log(formatSpending(data.spending));
7376
+ return;
7377
+ }
7361
7378
  if (operation.name === "math-lab.queue") {
7362
7379
  console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
7363
7380
  for (const grade of data.grades || []) console.log(grade.available
@@ -7400,6 +7417,10 @@ function printAdminResult({ operation, result }) {
7400
7417
  console.log(line);
7401
7418
  return;
7402
7419
  }
7420
+ if (operation.name.startsWith('nonogram.')) {
7421
+ for (const line of formatNonogramResult({ operation, data })) console.log(line);
7422
+ return;
7423
+ }
7403
7424
  if (operation.name.startsWith("build-plans.")) {
7404
7425
  for (const line of formatBuildPlansResult({ operation, data }))
7405
7426
  console.log(line);
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) : "",
@@ -3263,6 +3274,7 @@ export function printHelp() {
3263
3274
  lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
3264
3275
  lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | revise --build <id> --pair <pairId|a+b> --why <text> [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
3265
3276
  lumine admin sliding-block status | add --file <levels.json> [--confirm] (sliding-block-v1 campaign refills, RushHour: dry run without --confirm; the server solves each board and appends it)
3277
+ lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
3266
3278
  lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
3267
3279
  lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
3268
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)
@@ -3324,6 +3336,7 @@ export function printHelp() {
3324
3336
  (with both a comment session and a daily run active, comment draft/reply/post/edit need --via session|run)
3325
3337
  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]
3326
3338
  lumine admin brief [--days <1..30>] [--json]
3339
+ 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)
3327
3340
  lumine admin ai-costs monthly [--output <file.json>] [--json] (no active run required)
3328
3341
  lumine admin ai-costs day <YYYY-MM-DD> [--json]
3329
3342
  lumine admin media-costs monthly [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.53",
3
+ "version": "0.3.55",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.68.0
4
4
  Updated: 2026-10-16
5
- Generated: 2026-10-21T05:16:33.397Z
5
+ Generated: 2026-10-22T03:03:30.790Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -1569,10 +1569,12 @@ world.updatePresence({ x, y, z, facing });
1569
1569
  - Rules may set maxLifetimeClaims, a per-learner limit for that rule across days and releases. rules[].lifetime contains the server-confirmed limit and remaining claims. App storage never enforces this limit.
1570
1570
  - howTo is the rule's optional plain-words steps from rewards.json (up to 300 characters, e.g. "Save a new design of at least 64 blocks in Designs"). The Earn page shows it under the title; show it in the app's own rewards list too so players always know exactly what earns each reward.
1571
1571
  - category and series are optional rewards.json labels for a long list. category is a short section header (up to 40 characters, e.g. "Bring friends"); series is a short lowercase key (e.g. "new-friend") tying repeat rules like vigil-friend-1/2/3 together, and rules in one series must share category, verifier, XP and Coins. The Earn page groups rules by category and shows each series as one row ("up to 3 a day", "1 of 3 today"); group the app's own rewards list the same way.
1572
+ - nonogram-picture-v1 uses Twinkle’s private, editorially reviewed picture bank. Set pictureSize to 5, 10 or 15 on each completion rule. Do not bundle daily solutions or a fixed answer catalog in app files. The collection is maintained through lumine admin nonogram; the reward amounts and daily limits still come from the approved rules.
1572
1573
  - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
1573
1574
  - Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
1574
1575
  - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
1575
1576
  - Reconcile a durable local reward outbox after a lost claim reply: awarded confirms the exact payment; pending means no receipt yet for a current unexpired challenge, so retry the same challengeId. expired or not_found confirms no paid receipt and no claim possible for that ID under the current release. Never refund app items just because getStatus history omitted an older claim or a network request failed. Preview has no durable paid receipts; keep it separate from live recovery.
1577
+ - For a paid nonogram-picture-v1 receipt, picture { title, category } may also be returned so the app can recover the reveal after a lost response. It contains no solution cells.
1576
1578
  - await Twinkle.rewards.start({ ruleId, levelIndex?, completionToken? }) | scopes: rewards:claim
1577
1579
  - Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1578
1580
  - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
@@ -1580,6 +1582,7 @@ world.updatePresence({ x, y, z, facing });
1580
1582
  - For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
1581
1583
  - For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
1582
1584
  - For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today. For sliding-block-v1, start deals the board (completion.board, positions, minMoves); render it, record each real slide as [ms, carIndex, newPosition], send moves with progress (frames up to maxFrames per call), then claim with the latest token. For app-check-v1, start returns completion { profile, puzzle }: show that puzzle (a repeat start of the same challenge returns the same one); there is no progress step.
1585
+ - For nonogram-picture-v1, start returns completion { profile, completed: false, readyInSeconds, puzzle: { size, rows: number[][], cols: number[][] } }. Render those row and column clues; there is no progress call. Live readyInSeconds is the remaining approved minimum wait, so reopening a puzzle does not restart its clock. The private picture is durably bound to this exact viewer, app, release, rule, challenge and expiry. Reopening returns the same clues even after a refill. New challenges use unseen pictures; build_reward_picture_collection_complete means this size needs a refill. Offer practice/community puzzles while waiting.
1583
1586
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1584
1587
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1585
1588
  - Verify a bounded batch of inputs for an approved server-simulated climb.
@@ -1599,6 +1602,7 @@ world.updatePresence({ x, y, z, facing });
1599
1602
  - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
1600
1603
  - For app-check-v1, pass completionToken as JSON.stringify(record), the record your check reads (at most 64 KB); the server deals the challenge again and runs the approved file's check before paying. A refused record returns { awarded: false, reason, attemptsRemaining } and uses a try; claim only when the app itself sees a finished solve.
1601
1604
  - For craft-target-v1 and craft-par-v1, pass completionToken as JSON.stringify({ v: 1, merges: [[a, b], ...] }): the item ids of the run's successful merges in order, starting from the starters (at most 300 merges, 32 KB). The server replays it against this app's known pairs, needs today's target (Twinkle.craft.getState().today) to appear, every result to be in the learner's collection, and at least one second per merge since start; it refuses an unknown pair, an item the run had not made yet, an over-par run (par rule), a par claim on a day the learner revealed a Twinkle.craft.hint (the target rule still pays) or a run on another day. For craft-discovery-v1, pass nothing: the server pays the oldest unpaid World First (receipt slot craft:<itemId>) and puts its name on the bounty story ("discovered <item>"). With tiers, a rule pays only World Firsts of its own tier (refused with a plain reason when the learner has none).
1605
+ - For nonogram-picture-v1, pass completionToken: JSON.stringify({ rows: ["01010", ...] }) with the filled grid. Wrong grids consume a try and never reveal answer cells. A correct grid returns picture { title, category } alongside the ordinary canonical reward receipt, including duplicate receipt recovery. Show the title with the solved pixel picture. The approved minimum time, retry throttle, daily limits and release binding all still apply. Preview validates the same private catalog using separate assignments and never pays rewards.
1602
1606
  - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1603
1607
  - Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
1604
1608
  - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
@@ -3886,6 +3886,61 @@ input snapshots as described below.
3886
3886
  See `twinkle-api/JEV_PILOT.md` for configuration,
3887
3887
  the synthetic evaluation step and release checks.
3888
3888
 
3889
+ ### Spending by calendar month
3890
+
3891
+ ```bash
3892
+ lumine admin spending --from 2026-08 --to 2026-10
3893
+ lumine admin spending --month 2026-09 --json --output work/spending-september.json
3894
+ lumine admin spending --aws-profile <profile> --json
3895
+ ```
3896
+
3897
+ This owner-only read needs no delegated run. With no dates, it reports the
3898
+ previous and current UTC calendar months. Use `--month` for one month, or both
3899
+ `--from` and `--to` for an inclusive range of up to 12 months. Future months
3900
+ are refused. Current-month AI and AWS usage ends at today's UTC midnight;
3901
+ today's partial AI usage is returned separately and excluded from totals.
3902
+ Paid reimbursements include today's settled payments through the report time.
3903
+ Their separate cutoff is explicit in the table and JSON.
3904
+
3905
+ The default output is a table separating AI costs after promotional credits,
3906
+ AWS infrastructure including tax, and reimbursements, with a combined total.
3907
+ It also shows gross AI usage, credits, and AI-service versus infrastructure
3908
+ reimbursements. JSON includes provider and AWS-service details and the paid
3909
+ invoice references and dates. AI is a usage-based estimate. Credits follow
3910
+ the server's private billing policy and account for usage earlier in the same
3911
+ credit cycle, even when that usage predates the requested calendar month.
3912
+
3913
+ The API supplies the canonical AI ledger and paid reimbursement history.
3914
+ Reimbursements use settlement dates, including old invoices paid later, and
3915
+ include all known subscription histories and invoice pages. They are gross
3916
+ paid amounts, not Stripe fee/refund or bank-payout reconciliation. Failed,
3917
+ expired and pending attempts are excluded; no payment or subscription action
3918
+ is performed. Reimbursements are a separate category included in the total;
3919
+ `providerOperatingCostUsd` also provides AI plus AWS without reimbursements.
3920
+
3921
+ AWS reads use the installed AWS CLI and the existing billing profile, or
3922
+ `--aws-profile`. The command checks the required account and principal before
3923
+ querying Cost Explorer, paginates service groups, validates exact UTC periods
3924
+ and USD currency, and preserves provisional billing status. It never changes
3925
+ AWS credentials or infrastructure. Cost Explorer's ordinary query fees apply.
3926
+ AWS authentication stays local; it is not uploaded to Twinkle.
3927
+
3928
+ `data.spending` is versioned with `schemaVersion: 1`. `months` contains each
3929
+ month's `ai`, `aws`, `reimbursements`, `providerOperatingCostUsd`, and
3930
+ `totalIncludingReimbursementsUsd`; `totals` sums the selected period. Missing
3931
+ sources are `null`, never zero. A partial report preserves available data,
3932
+ sets `complete: false` and receipt `status: "partial"`, saves with `--output`,
3933
+ and exits nonzero. Unknown provider attribution or AWS-billed AI can make a
3934
+ combined total unavailable until the credit/overlap is reconciled. Zero usage
3935
+ on the first UTC day means no completed days, not a full-month forecast;
3936
+ reimbursements paid on that day still contribute to the total.
3937
+
3938
+ This report does not replace the existing AI run-rate projections or AWS
3939
+ remaining-period forecast required for a full daily-management review.
3940
+ Release dependency: deploy the owner-authorized `/cli/admin/spending` API
3941
+ before publishing the CLI that calls it. No new database migration, npm
3942
+ dependency or server-side AWS permission is required.
3943
+
3889
3944
  ### Application AI calendar-month cost (standing duty, every full daily review)
3890
3945
 
3891
3946
  Run `lumine admin ai-costs monthly --json` during every full daily management
@@ -5402,6 +5457,36 @@ Since 2026-10-17 Bridge Builder unlocks from QR check-ins: 7 different people, o
5402
5457
  Every decision requires Mikey's authorization for that crew and action. Mutations are audited and idempotent; `--output <path>` is an explicit request to retain the canonical JSON. General write receipts omit private crew details.
5403
5458
 
5404
5459
 
5460
+ ## Nonogram picture collection
5461
+
5462
+ Nonogram's `nonogram-picture-v1` proof uses a private, reviewed picture bank.
5463
+ During full daily management, inspect artwork quality and the least unseen
5464
+ supply at each size; the target reserve is 14 days at three pictures per size
5465
+ per day. This is content maintenance under Mikey's standing daily scope, not
5466
+ permission to change reward amounts or publish a creator's app.
5467
+
5468
+ ```
5469
+ lumine admin nonogram status --json
5470
+ lumine admin nonogram add --file <pictures.json> --json
5471
+ lumine admin nonogram add --file <pictures.json> --confirm --json
5472
+ lumine admin nonogram retire <key> [--confirm]
5473
+ lumine admin nonogram restore <key> [--confirm]
5474
+ ```
5475
+
5476
+ An import file is an array of 1–150 pictures: `{ key, title, category, rows,
5477
+ artReview, difficultyReview }`. `rows` is a 5×5, 10×10 or 15×15 array of binary
5478
+ strings. Titles are plain text, at most 80 characters. Draw recognizable art
5479
+ first, verify its unique no-guess solve, and visually review it at play size
5480
+ before importing. Record actual visual features and the deduction/difficulty
5481
+ review, not just a pass count. Never pad the reserve with weak art or variants.
5482
+ The server rejects ambiguous puzzles, skips mirror/rotation duplicates, and
5483
+ refuses changing the solution under an existing key. Default operations are
5484
+ dry runs; confirmed writes are audited. Retirement affects future assignments
5485
+ only, preserving started puzzles and receipt recovery. Status separates total
5486
+ active pictures from the least unseen supply for the leading live learners.
5487
+ Previews have separate assignments. Solutions stay private; catalog refills
5488
+ need no app update. Keep prepared inventory distinct from verified live stock.
5489
+
5405
5490
  ## Word Search vocabulary upkeep
5406
5491
 
5407
5492
  Every full daily website-management run adds reviewed Word Master words across all five difficulty bands, with accurate definitions and natural example sentences. Use `lumine admin word-games status`, `candidates <1-5> --after <id> --limit 100`, and `import <reviewed-words.json> --dry-run` followed by the reviewed import. File shape: `{items:[{wordId,word,wordLevel,definition,example}]}` (up to 100). Candidate exports are unreviewed; use agent judgment and check the current censor table before importing. No paid batch APIs. The import is audited and preserves active generation and today's existing puzzles; future banks include the additions. The app receives all five ready banks in its normal HTML and has a bundled checked fallback, so word selection never creates a loading screen or separate word request. Report additions per difficulty and text corrections. Repository agents follow `agent-guides/word-search-vocabulary.md` for the full procedure. A scoped management request does not start this full-run duty.