@stage5/lumine 0.3.23 → 0.3.25

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
@@ -81,6 +81,17 @@ pulling the owner's main project creates or reuses your contribution branch and
81
81
  checks out that branch locally. Saves go to your branch, so the project owner
82
82
  can merge or replace main from Twinkle.
83
83
 
84
+ For an exact source review of the live app, use `lumine pull <build-id>
85
+ --published` in a persistent review folder. This checks out the requested
86
+ build's published artifact read-only; `--main` explicitly selects its root
87
+ project. It never substitutes the latest draft or creates a contribution
88
+ branch. The CLI checks the publication pointer again before writing and
89
+ refuses a concurrent publication; modified local files are stashed before a
90
+ successful checkout. It requires the API's published-version selector to be
91
+ deployed first. `--published` and `--version <n>` are mutually exclusive.
92
+ `lumine admin builds review` already reviews the published app; unsupported
93
+ selectors such as `--mode` or `--review-mode` fail instead of being ignored.
94
+
84
95
  Use `lumine branches <build-url-or-id>` to list the contribution branches you
85
96
  can review, including each contributor, branch number, status, and URL. Then use
86
97
  `lumine diff <branch-url>` to inspect one branch.
@@ -73,6 +73,7 @@ export function runAdmin(
73
73
  "reward-review": ["list"],
74
74
  approvals: ["list"],
75
75
  todo: ["list"],
76
+ followup: ["list"],
76
77
  escalation: ["list"],
77
78
  featured: ["list", "history"],
78
79
  sponsor: ["applications"],
@@ -192,6 +193,7 @@ export async function dailyRunGather(options) {
192
193
  // flags: a required report section (agent-guides/lumine-admin.md)
193
194
  step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
194
195
  step("todos", ["todo", "list"], "todos.json");
196
+ step("personal-followups", ["followup", "list"], "personal-followups.json");
195
197
  step("escalations", ["escalation", "list"], "escalations.json");
196
198
  step("featured-list", ["featured", "list"], "featured-start.json");
197
199
  step(
@@ -0,0 +1,66 @@
1
+ function seenAt(value) {
2
+ if (value == null || !Number.isFinite(Number(value)) || Number(value) <= 0)
3
+ return "unknown";
4
+ const date = new Date(Number(value) * 1000);
5
+ return Number.isNaN(date.getTime()) ? "unknown" : date.toISOString();
6
+ }
7
+
8
+ function eventTypes(counts) {
9
+ const entries = Object.entries(counts || {}).sort(([a], [b]) =>
10
+ a.localeCompare(b),
11
+ );
12
+ return entries.length
13
+ ? `; ${entries.map(([type, count]) => `${type}: ${count}`).join(", ")}`
14
+ : "";
15
+ }
16
+
17
+ /** Display observed device events separately from accounts and physical visits. */
18
+ export function formatIdentityDeviceUsage(inspection) {
19
+ const devices = inspection.sharedDevices || [];
20
+ const coverage = inspection.evidenceCoverage || {};
21
+ const lines = [];
22
+ if (devices.length) {
23
+ lines.push(
24
+ `Device history (${coverage.deviceLookbackDays || "unknown"} days): recorded account events, not visits or unique sessions.`,
25
+ );
26
+ for (const device of devices) {
27
+ const usage = device.usage;
28
+ if (!usage) {
29
+ lines.push(
30
+ ` Device ${device.key}: usage counts unavailable from this API; ${device.accountCount ?? "unknown"} observed account(s).`,
31
+ );
32
+ continue;
33
+ }
34
+ if (usage.status === "not_read" || usage.eventCount == null) {
35
+ lines.push(
36
+ ` Device ${device.key}: usage and sharing not read (${usage.truncatedBy?.join(", ") || "incomplete evidence"}).`,
37
+ );
38
+ continue;
39
+ }
40
+ const partial = usage.status !== "complete";
41
+ const prefix = partial ? "at least " : "";
42
+ lines.push(
43
+ ` Device ${device.key}: ${prefix}${usage.eventCount} recorded event(s), ${prefix}${device.accountCount} account(s)${eventTypes(usage.eventsByType)}.`,
44
+ ` First observed ${seenAt(usage.firstSeenAt)}; last observed ${seenAt(usage.lastSeenAt)}.`,
45
+ );
46
+ if (partial) {
47
+ lines.push(
48
+ ` Partial history (${usage.truncatedBy?.join(", ") || "incomplete evidence"}); older events and other accounts may be missing.`,
49
+ );
50
+ }
51
+ for (const account of usage.accounts || []) {
52
+ lines.push(
53
+ ` #${account.userId} ${account.username || "(unknown account)"}: ${prefix}${account.eventCount} recorded event(s)${eventTypes(account.eventsByType)}; first ${seenAt(account.firstSeenAt)}, last ${seenAt(account.lastSeenAt)}.`,
54
+ );
55
+ }
56
+ if (usage.accountsTruncated)
57
+ lines.push(" Account detail list capped; totals include omitted accounts.");
58
+ }
59
+ }
60
+ if (coverage.truncated) {
61
+ lines.push(
62
+ `Device discovery/evidence is incomplete (${coverage.truncatedBy?.join(", ") || "history limit"}); additional devices or accounts may exist.`,
63
+ );
64
+ }
65
+ return lines;
66
+ }
package/lib/admin.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  printOwnerTrace,
40
40
  } from "./admin-owner-trace.js";
41
41
  import { formatChatReportResult } from "./admin-chat-reports.js";
42
+ import { formatIdentityDeviceUsage } from "./admin-identity-inspection.js";
42
43
  import {
43
44
  buildTeacherBranchOperation,
44
45
  printTeacherBranchResult,
@@ -2478,6 +2479,21 @@ export function parseAdminOperation(options) {
2478
2479
  },
2479
2480
  );
2480
2481
  }
2482
+ if (action === "device" && target === "block-signup") {
2483
+ if (!/^[a-f0-9]{16}$/.test(options.adminDeviceKey || "")) {
2484
+ throw cliValidationError("Pass --device-key with the exact key from identity inspect.");
2485
+ }
2486
+ return writeOperation(
2487
+ "ai-bucket.device.signup.block", "POST",
2488
+ `/cli/admin/ai-buckets/${bucketId}/signup-devices`,
2489
+ {
2490
+ sourceUserId: parseRequiredInteger(options.adminUser, "--user", 1),
2491
+ deviceKey: options.adminDeviceKey,
2492
+ expectedUserIds: parseAiBucketUserIds(options.adminUserIds),
2493
+ note: parseAiBucketNote(options.note),
2494
+ },
2495
+ );
2496
+ }
2481
2497
  if (action === "note" && target === "set") {
2482
2498
  return writeOperation(
2483
2499
  "ai-bucket.note.set",
@@ -3174,6 +3190,25 @@ export function parseAdminOperation(options) {
3174
3190
  );
3175
3191
  }
3176
3192
 
3193
+ if (namespace === "followup" || namespace === "followups") {
3194
+ if (!action || action === "list") {
3195
+ return readOperation("followup.list", withQuery("/cli/admin/personal-followups", {
3196
+ cursor: options.cursor || undefined,
3197
+ status: options.adminStatus === "all" ? "all" : "pending",
3198
+ }));
3199
+ }
3200
+ if (action === "update") {
3201
+ const id = parseRequiredInteger(target, "Follow-up ID", 1);
3202
+ const revision = parseRequiredInteger(options.followupRevision, "--revision", 1);
3203
+ const note = String(options.note || "").trim();
3204
+ if (!note || note.length > MAX_TODO_NOTE_LENGTH) throw cliValidationError("Record the actual work, evidence and reply outcome (or blocker) in --note, up to 4000 characters.");
3205
+ return writeOperation("followup.update", "PUT", `/cli/admin/personal-followups/${id}`, {
3206
+ revision, status: parseTodoStatus(options.adminStatus), note,
3207
+ });
3208
+ }
3209
+ throw cliValidationError("Usage: lumine admin followup list [--cursor <id>] [--status all] | followup update <id> --revision <n> --status <status> --note <evidence and outcome>.");
3210
+ }
3211
+
3177
3212
  if (namespace === "todo" || namespace === "todos") {
3178
3213
  if (!action || action === "list") {
3179
3214
  return readOperation(
@@ -3229,6 +3264,16 @@ export function parseAdminOperation(options) {
3229
3264
  {
3230
3265
  status: parseTodoStatus(options.adminStatus),
3231
3266
  note,
3267
+ ...(options.todoWorkflowFile
3268
+ ? {
3269
+ workflow: readAdminJsonFile(options.todoWorkflowFile),
3270
+ expectedRevision: parseRequiredInteger(
3271
+ options.followupRevision,
3272
+ "--revision",
3273
+ 1,
3274
+ ),
3275
+ }
3276
+ : {}),
3232
3277
  },
3233
3278
  );
3234
3279
  }
@@ -6814,6 +6859,7 @@ function printAdminResult({ operation, result }) {
6814
6859
  );
6815
6860
  } else {
6816
6861
  printTodoItems(report.carryoverTodos?.items || [], "Unfinished work");
6862
+ printPersonalFollowups(report.personalFollowups);
6817
6863
  }
6818
6864
  if (report.sponsorIntegrity) {
6819
6865
  const pendingApplications = Number.isSafeInteger(
@@ -6870,6 +6916,7 @@ function printAdminResult({ operation, result }) {
6870
6916
  );
6871
6917
  }
6872
6918
  }
6919
+ for (const line of formatIdentityDeviceUsage(inspection)) console.log(line);
6873
6920
  return;
6874
6921
  }
6875
6922
  if (Array.isArray(data.escalations)) {
@@ -6912,6 +6959,8 @@ function printAdminResult({ operation, result }) {
6912
6959
  }
6913
6960
  return;
6914
6961
  }
6962
+ if (data.personalFollowups) printPersonalFollowups(data.personalFollowups);
6963
+ if (data.personalFollowup) printPersonalFollowups({ items: [data.personalFollowup], nextCursor: null });
6915
6964
  if (Array.isArray(data.todos)) {
6916
6965
  printTodoItems(data.todos, "Private carry-over work");
6917
6966
  if (data.truncated) {
@@ -7191,6 +7240,18 @@ function printFeaturedHistoryEvent(event) {
7191
7240
  );
7192
7241
  }
7193
7242
 
7243
+ function printPersonalFollowups(handoff) {
7244
+ if (!handoff) return;
7245
+ console.log(`Personal follow-ups: ${handoff.items.length}${handoff.nextCursor ? "+" : ""}`);
7246
+ for (const item of handoff.items) {
7247
+ console.log(` #${item.id} r${item.revision} ${item.status} — ${item.title}`);
7248
+ console.log(` ${item.details}`);
7249
+ console.log(` Source: ${item.source.type} ${item.source.id}, ${item.source.rootType} ${item.source.rootId}; member ${item.requesterUserId}`);
7250
+ if (item.lastProgressNote) console.log(` Progress: ${item.lastProgressNote}`);
7251
+ }
7252
+ if (handoff.nextCursor) console.log(` Continue: lumine admin followup list --cursor ${handoff.nextCursor}`);
7253
+ }
7254
+
7194
7255
  function printTodoItems(items, heading) {
7195
7256
  console.log(`${heading}: ${items.length} item(s).`);
7196
7257
  for (const todo of items) {
@@ -7198,6 +7259,14 @@ function printTodoItems(items, heading) {
7198
7259
  ` #${todo.id} ${String(todo.status || "open").toUpperCase()} ${todo.kind || "task"} — ${todo.title || "(untitled)"}`,
7199
7260
  );
7200
7261
  if (todo.details) console.log(` ${todo.details}`);
7262
+ if (todo.workflow) {
7263
+ const w = todo.workflow;
7264
+ console.log(` Implementation: ${w.implementation}; release: ${w.release}; verification: ${w.verification}`);
7265
+ if (w.fixRef) console.log(` Fix: ${w.fixRef}`);
7266
+ if (w.releaseRef) console.log(` Release: ${w.releaseRef}`);
7267
+ if (w.evidence) console.log(` Evidence: ${w.evidence}`);
7268
+ if (w.nextAction) console.log(` Next: ${w.nextAction}`);
7269
+ }
7201
7270
  if (todo.lastProgressNote) {
7202
7271
  console.log(` Latest progress: ${todo.lastProgressNote}`);
7203
7272
  }
package/lib/commands.js CHANGED
@@ -943,6 +943,8 @@ export async function selectProject(options) {
943
943
  }
944
944
 
945
945
  export async function pull(options) {
946
+ if (options.pullPublished && options.versionProvided)
947
+ throw new Error("Choose --published or --version, not both.");
946
948
  if (options.versionProvided && !(options.pullVersion > 0)) {
947
949
  throw new Error(
948
950
  "Pass a save number: `lumine pull --version <n>` (run `lumine versions` to see save numbers).",
@@ -962,7 +964,7 @@ export async function pull(options) {
962
964
  auth,
963
965
  buildId: requestedBuildId,
964
966
  });
965
- if (options.pullVersion > 0) {
967
+ if (options.pullPublished || options.pullVersion > 0) {
966
968
  await pullVersionBuildFiles({ options, auth, build: selectedBuild });
967
969
  return;
968
970
  }
@@ -1830,10 +1832,10 @@ export async function pullMainBuildFiles({ options, auth, build }) {
1830
1832
  }
1831
1833
 
1832
1834
  // Read-only checkout of ONE previous save (`lumine pull --version <n>`).
1833
- // Without --main it targets the build the workspace edits (your branch on team
1834
- // projects); with --main it targets the team project's main history.
1835
+ // Numeric saves target the editable workspace branch unless --main is given.
1836
+ // --published reads the named Build directly; --main selects the project root.
1835
1837
  export async function pullVersionBuildFiles({ options, auth, build }) {
1836
- const versionNumber = options.pullVersion;
1838
+ const versionNumber = options.pullPublished ? "published" : options.pullVersion;
1837
1839
  let targetBuild = build;
1838
1840
  if (options.pullMain) {
1839
1841
  const rootBuildId =
@@ -1842,7 +1844,7 @@ export async function pullVersionBuildFiles({ options, auth, build }) {
1842
1844
  rootBuildId === Number(build?.id || 0)
1843
1845
  ? build
1844
1846
  : await loadBuildMetadata({ options, auth, buildId: rootBuildId });
1845
- } else {
1847
+ } else if (!options.pullPublished) {
1846
1848
  targetBuild = await resolveEditableWorkspaceBuild({
1847
1849
  options,
1848
1850
  auth,
@@ -1856,6 +1858,27 @@ export async function pullVersionBuildFiles({ options, auth, build }) {
1856
1858
  buildId,
1857
1859
  versionNumber,
1858
1860
  });
1861
+ if (options.pullPublished) {
1862
+ const expected = Number(result.build?.publishedArtifactVersionId || 0);
1863
+ if (
1864
+ !expected ||
1865
+ Number(result.version?.versionId) !== expected ||
1866
+ !result.build?.isPublic
1867
+ ) {
1868
+ throw new Error(
1869
+ "The API did not return the exact published artifact; update the API before using --published.",
1870
+ );
1871
+ }
1872
+ const current = await loadBuildMetadata({ options, auth, buildId });
1873
+ if (
1874
+ !current.isPublic ||
1875
+ Number(current.publishedArtifactVersionId) !== expected
1876
+ ) {
1877
+ throw new Error(
1878
+ "The published version changed during checkout. Retry; no local files were changed.",
1879
+ );
1880
+ }
1881
+ }
1859
1882
  const resolvedBuild = result.build || targetBuild;
1860
1883
  const version = result.version || { version: versionNumber };
1861
1884
  const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
@@ -1879,6 +1902,7 @@ export async function pullVersionBuildFiles({ options, auth, build }) {
1879
1902
  ? path.join(path.dirname(enclosing.rootDir), dirName)
1880
1903
  : dirName),
1881
1904
  );
1905
+ await stashLocalProjectFilesBeforePull({ dir, files });
1882
1906
  await writeProjectFiles({ dir, files });
1883
1907
  if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1884
1908
  await removeLocalProjectFilesNotIn({ dir, files });
@@ -2422,6 +2446,7 @@ export function parseArgs(args) {
2422
2446
  "open",
2423
2447
  "noDescription",
2424
2448
  "publish",
2449
+ "published",
2425
2450
  "save",
2426
2451
  "force",
2427
2452
  "noUpdateCheck",
@@ -2491,6 +2516,35 @@ export function parseArgs(args) {
2491
2516
  }
2492
2517
  }
2493
2518
 
2519
+ if (
2520
+ command === "admin" &&
2521
+ positional[0] === "builds" &&
2522
+ positional[1] === "review"
2523
+ ) {
2524
+ const allowed = new Set([
2525
+ "help",
2526
+ "output",
2527
+ "outputDir",
2528
+ "dir",
2529
+ "waitMs",
2530
+ "interact",
2531
+ "browserPath",
2532
+ "json",
2533
+ "apiUrl",
2534
+ "siteUrl",
2535
+ "authFile",
2536
+ "authToken",
2537
+ "clientName",
2538
+ "timeoutMs",
2539
+ "noUpdateCheck",
2540
+ ]);
2541
+ for (const key of Object.keys(raw)) {
2542
+ if (!allowed.has(key))
2543
+ throw new Error(
2544
+ `Unsupported Build review option --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}. Runtime review already uses the published app; use pull --published for its source.`,
2545
+ );
2546
+ }
2547
+ }
2494
2548
  const suggestionInboxActions = new Set([
2495
2549
  "list",
2496
2550
  "merge",
@@ -2592,6 +2646,8 @@ export function parseArgs(args) {
2592
2646
  adminReviewed: Boolean(raw.reviewed),
2593
2647
  adminSeverity: raw.severity ? String(raw.severity) : "",
2594
2648
  adminStatus: raw.status ? String(raw.status) : "",
2649
+ followupRevision: raw.revision ? String(raw.revision) : "",
2650
+ todoWorkflowFile: raw.workflow ? String(raw.workflow) : "",
2595
2651
  adminDecision: raw.decision ? String(raw.decision) : "",
2596
2652
  adminWaitMs: raw.waitMs ? String(raw.waitMs) : "",
2597
2653
  adminBrowserPath: raw.browserPath ? String(raw.browserPath) : "",
@@ -2673,6 +2729,7 @@ export function parseArgs(args) {
2673
2729
  ? String(raw.addIds)
2674
2730
  : "",
2675
2731
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2732
+ adminDeviceKey: raw.deviceKey ? String(raw.deviceKey) : "",
2676
2733
  adminLabel: raw.label ? String(raw.label) : "",
2677
2734
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2678
2735
  adminUser: raw.user ? String(raw.user) : "",
@@ -2806,6 +2863,7 @@ export function parseArgs(args) {
2806
2863
  clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
2807
2864
  dir: raw.dir ? String(raw.dir) : "",
2808
2865
  pullMain: parseBoolean(raw.main, false),
2866
+ pullPublished: parseBoolean(raw.published, false),
2809
2867
  // Strict: only a plain positive integer counts. Anything else (missing
2810
2868
  // value, "foo", a swallowed flag like "--main", "2.5") stays 0 and the
2811
2869
  // consuming command must reject it via versionProvided instead of
@@ -3065,6 +3123,7 @@ export function printHelp() {
3065
3123
  lumine pull [twinkle-build-url]
3066
3124
  lumine pull [twinkle-build-url] --main
3067
3125
  lumine pull [twinkle-build-url] --version <n> [--main]
3126
+ lumine pull [twinkle-build-url] --published [--main]
3068
3127
  lumine versions [twinkle-build-url] [--main] [--limit <n>]
3069
3128
  lumine restore <n> [--main]
3070
3129
  lumine reference <twinkle-build-url>
@@ -3104,6 +3163,7 @@ export function printHelp() {
3104
3163
  lumine admin ai-bucket create --label <name> --note <text> [--json]
3105
3164
  lumine admin ai-bucket get --bucket-id <id> [--json]
3106
3165
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
3166
+ lumine admin ai-bucket device block-signup --bucket-id <id> --user <source-user-id> --device-key <inspection-key> --user-ids <reviewed-device-users> --note <reason> [--json]
3107
3167
  lumine admin ai-bucket note set --bucket-id <id> --note <text> [--json]
3108
3168
  lumine admin ai-email-policy get --email <address> [--json]
3109
3169
  lumine admin ai-email-policy set --email <address> --mode <automatic|separate_accounts> --note <text> [--json]
@@ -3114,7 +3174,7 @@ export function printHelp() {
3114
3174
  lumine admin escalation set <audit-id> --status open|acknowledged|resolved --note <decision> [--json]
3115
3175
  lumine admin todo list [--status pending|open|in_progress|blocked|completed|cancelled|all] [--limit <number>] [--json]
3116
3176
  lumine admin todo add --title <title> --note <handoff-and-acceptance-criteria> [--kind task|experiment] [--status open|in_progress|blocked] [--json]
3117
- lumine admin todo update <todo-id> --status open|in_progress|blocked|completed|cancelled --note <progress-or-evidence> [--json]
3177
+ lumine admin todo update <todo-id> --status open|in_progress|blocked|completed|cancelled --note <progress-or-evidence> [--workflow <file.json> --revision <n>] [--json]
3118
3178
  lumine admin sponsor applications list [--status pending|approved|rejected|withdrawn] [--json]
3119
3179
  lumine admin sponsor applications review <application-id> --decision approve|reject [--note <text>] [--json]
3120
3180
  lumine admin sponsor status set <user-id> --status probationary|trusted|suspended|revoked [--note <text>] [--json]
@@ -3277,6 +3337,7 @@ Options:
3277
3337
  --no-review-loop Skip the evidence-based post-run loop retrospective
3278
3338
  --target <build> Explicit Build URL or ID for rename/describe/upgrade
3279
3339
  --main With pull/versions/restore: target the team project's main
3340
+ --published With pull: exact currently published artifact, read-only
3280
3341
  --version <n> With pull: read-only checkout of previous save v<n>
3281
3342
  --title <text> Build title for new/rename, or private todo title
3282
3343
  --description <text> Build description for new/describe
@@ -0,0 +1,133 @@
1
+ // Merges parts/<level>.json into the private question sheet the Math Lab
2
+ // uploads with `lumine rewards sheet`, then validates the sheet against the
3
+ // workspace's rewards.json exactly the way the server will (same code).
4
+ // Usage: node --import tsx build-sheet.mjs --approved-review <live review id>
5
+ // Reads that review afresh and preserves its questions before writing output.
6
+ import fs from "node:fs";
7
+ import { execFileSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import path from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import {
12
+ LEVELS,
13
+ verifyApprovedPrefix,
14
+ verifyRewardEconomy,
15
+ } from "./math-lab-preservation.js";
16
+
17
+ export async function buildMathLabSheet({
18
+ contentDir,
19
+ args = process.argv.slice(2),
20
+ }) {
21
+ const here = path.resolve(contentDir);
22
+ const parts = path.join(here, "parts");
23
+ if (
24
+ args.length !== 2 ||
25
+ args[0] !== "--approved-review" ||
26
+ !/^[1-9]\d*$/.test(args[1])
27
+ ) {
28
+ throw new Error(
29
+ "Usage: node --import tsx build-sheet.mjs --approved-review <current live review id>. No sheet was written.",
30
+ );
31
+ }
32
+ const cli = fileURLToPath(new URL("../bin/lumine.js", import.meta.url));
33
+ const approved = JSON.parse(
34
+ execFileSync(
35
+ process.execPath,
36
+ [
37
+ cli,
38
+ "admin",
39
+ "reward-review",
40
+ "show",
41
+ args[1],
42
+ "--json",
43
+ "--no-update-check",
44
+ ],
45
+ {
46
+ encoding: "utf8",
47
+ timeout: 60000,
48
+ maxBuffer: 16 * 1024 * 1024,
49
+ },
50
+ ),
51
+ );
52
+ const sheet = { rules: {} };
53
+ const missing = [];
54
+ for (const level of LEVELS) {
55
+ const file = path.join(parts, `${level}.json`);
56
+ if (!fs.existsSync(file)) {
57
+ missing.push(level);
58
+ continue;
59
+ }
60
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
61
+ sheet.rules[`${level}-daily`] = {
62
+ sets: data.sets.map((set) => ({
63
+ key: set.key,
64
+ questions: set.questions.map((q) => ({
65
+ prompt: q.prompt,
66
+ answer: q.answer,
67
+ ...(q.hint ? { hint: q.hint } : {}),
68
+ ...(q.guide ? { guide: q.guide } : {}),
69
+ })),
70
+ })),
71
+ };
72
+ }
73
+ const out = path.resolve(here, "..", "math-lab-sheet.json");
74
+ if (missing.length)
75
+ throw new Error(
76
+ `Missing grade parts: ${missing.join(", ")}. No sheet was written.`,
77
+ );
78
+ const preservation = verifyApprovedPrefix(sheet, approved);
79
+
80
+ // Server-identical validation: rewards.json + sheet → approved-shape config.
81
+ const policy = await import(
82
+ pathToFileURL(
83
+ path.resolve(
84
+ here,
85
+ "..",
86
+ "..",
87
+ "twinkle-api",
88
+ "helpers",
89
+ "build",
90
+ "rewardsPolicy.ts",
91
+ ),
92
+ ).href
93
+ );
94
+ const declared = JSON.parse(
95
+ fs.readFileSync(
96
+ path.resolve(here, "..", "math-lab", "rewards.json"),
97
+ "utf8",
98
+ ),
99
+ );
100
+ const composed = policy.composeRewardConfig({ declared, sheet });
101
+ if (composed.errors.length) {
102
+ for (const error of composed.errors) console.log(`- ${error}`);
103
+ process.exit(1);
104
+ }
105
+ verifyRewardEconomy(composed.config, approved);
106
+ const total = composed.config.rules.reduce(
107
+ (sum, rule) => sum + (rule.sets || []).length,
108
+ 0,
109
+ );
110
+ // Nothing replaces the prior usable sheet until every check succeeds.
111
+ const output = JSON.stringify(sheet, null, 1) + "\n";
112
+ fs.writeFileSync(`${out}.next`, output, { mode: 0o600 });
113
+ fs.renameSync(`${out}.next`, out);
114
+ fs.writeFileSync(
115
+ `${out}.preservation.json`,
116
+ JSON.stringify(
117
+ {
118
+ ...preservation,
119
+ checkedAt: new Date().toISOString(),
120
+ sha256: createHash("sha256").update(output).digest("hex"),
121
+ },
122
+ null,
123
+ 2,
124
+ ) + "\n",
125
+ { mode: 0o600 },
126
+ );
127
+ console.log(
128
+ `wrote ${out}; preserved ${preservation.rules.reduce((n, r) => n + r.preserved, 0)} published questions from review ${preservation.liveReview}`,
129
+ );
130
+ console.log(
131
+ `composed OK: ${composed.config.rules.length} rules, ${total} sets, ${Buffer.byteLength(JSON.stringify(sheet))} bytes`,
132
+ );
133
+ }
@@ -0,0 +1,105 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ export const LEVELS = [
4
+ "e1",
5
+ "e2",
6
+ "e3",
7
+ "e4",
8
+ "e5",
9
+ "e6",
10
+ "m1",
11
+ "m2",
12
+ "m3",
13
+ "h1",
14
+ "h2",
15
+ "h3",
16
+ ];
17
+
18
+ export function verifyRewardEconomy(config, response) {
19
+ const withoutRefillSets = (value) => ({
20
+ ...value,
21
+ rules: value.rules.map((rule) => {
22
+ if (!LEVELS.some((level) => rule.id === `${level}-daily`)) return rule;
23
+ const { sets: _sets, ...economy } = rule;
24
+ return economy;
25
+ }),
26
+ });
27
+ if (
28
+ !isDeepStrictEqual(
29
+ withoutRefillSets(config),
30
+ withoutRefillSets(response.data.review.config),
31
+ )
32
+ ) {
33
+ throw new Error(
34
+ "A refill must preserve the approved reward rules and budgets. Review any economy change separately.",
35
+ );
36
+ }
37
+ }
38
+
39
+ // Refill-only: edits/removals to existing questions need a separate content review.
40
+ export function verifyApprovedPrefix(sheet, response) {
41
+ const review = response?.data?.review;
42
+ const artifact = Number(review?.publishedArtifactVersionId);
43
+ if (
44
+ response?.ok !== true ||
45
+ Number(review?.buildId) !== 2460 ||
46
+ review?.status !== "approved" ||
47
+ review?.isLive !== true ||
48
+ !Number.isSafeInteger(artifact) ||
49
+ artifact <= 0 ||
50
+ Number(review?.publishedVersionId) !== artifact
51
+ ) {
52
+ throw new Error(
53
+ "A fresh, live, approved Math Lab review is required. Re-read the current reward review.",
54
+ );
55
+ }
56
+ const rules = review.config?.rules;
57
+ if (!Array.isArray(rules))
58
+ throw new Error("The approved review has no question rules.");
59
+ const result = [];
60
+ for (const level of LEVELS) {
61
+ const id = `${level}-daily`;
62
+ const matches = rules.filter((rule) => rule.id === id);
63
+ const before = matches[0]?.sets;
64
+ const after = sheet?.rules?.[id]?.sets;
65
+ if (
66
+ matches.length !== 1 ||
67
+ !Array.isArray(before) ||
68
+ !before.length ||
69
+ !Array.isArray(after)
70
+ ) {
71
+ throw new Error(
72
+ `${id}: missing or ambiguous approved questions or candidate grade.`,
73
+ );
74
+ }
75
+ if (after.length < before.length)
76
+ throw new Error(
77
+ `${id}: candidate loses ${before.length - after.length} approved questions.`,
78
+ );
79
+ for (let i = 0; i < before.length; i++) {
80
+ if (!isDeepStrictEqual(after[i], before[i])) {
81
+ throw new Error(
82
+ `${id}: approved question ${before[i].key || i + 1} changed or moved. Sync the canonical sheet before refilling.`,
83
+ );
84
+ }
85
+ }
86
+ const keys = after.map((set) => set.key);
87
+ if (
88
+ keys.some((key) => typeof key !== "string" || !key.trim()) ||
89
+ new Set(keys).size !== keys.length
90
+ ) {
91
+ throw new Error(`${id}: question keys must be present and unique.`);
92
+ }
93
+ result.push({
94
+ rule: id,
95
+ preserved: before.length,
96
+ added: after.length - before.length,
97
+ });
98
+ }
99
+ return {
100
+ liveReview: Number(review.id),
101
+ artifactVersionId: artifact,
102
+ preservedExact: true,
103
+ rules: result,
104
+ };
105
+ }
package/lib/workspace.js CHANGED
@@ -738,6 +738,8 @@ export async function writeVersionCheckoutMetadata({
738
738
  readOnly: true,
739
739
  versionCheckout: true,
740
740
  checkoutVersion: Number(version?.version || 0) || null,
741
+ checkoutArtifactVersionId: Number(version?.versionId || 0) || null,
742
+ publishedCheckout: options.pullPublished === true,
741
743
  checkoutVersionSummary: version?.summary || null,
742
744
  checkoutVersionCreatedAt: Number(version?.createdAt || 0) || null,
743
745
  build: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -502,9 +502,9 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
502
502
  - Template values describe types, not answers to copy. All object keys are required and extra keys are rejected, including inside nested objects; {} means an exactly empty object, not a free-form dictionary. A non-empty array uses only its first item as the template for every returned item, with no fixed length; mixed examples do not declare a union. [] allows arbitrary JSON items. A null template accepts any JSON primitive (string, number, boolean or null), but the key is still required. Use 'number' for fractional numbers; 0 and other integer examples require integers. State content requirements in prompt or instructions. GPT routes retain descriptive string examples as field guidance, not enforced enum values.
503
503
  - GPT models prefer native JSON Schema output for compatible shapes. Shapes outside the strict provider subset, such as [] with arbitrary items or deeply nested structures, use prompt-based JSON generation with the same final validation. A provider rejection specifically of the native schema triggers one prompt-based fallback; auth, quota, safety and unrelated request failures do not trigger that fallback. Validation retries preserve the original task and instructions. The returned object/result shape and streaming callbacks are unchanged.
504
504
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
505
- - Omit model to use the normal Lite/Medium/High routing. model accepts claude-sonnet-5-5, gpt-6.1-sol or claude-opus-5-5, and every explicit model must be paired with thinkingMode: 'high'; unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
506
- - thinkingMode low uses GPT-6 Luna and consumes the viewer's AI Energy from confirmed provider usage; its smaller model is usually cheaper than Medium or High.
507
- - thinkingMode medium uses GPT-6 Luna with medium reasoning and consumes normal AI Energy.
505
+ - Omit model to use the normal Lite/Medium/High routing. Lumine Light defaults to claude-haiku-5-5; gpt-6-luna remains its second choice. Both model IDs accept Low, Medium or High thinking. Explicit claude-sonnet-5-5, gpt-6.1-sol or claude-opus-5-5 still require thinkingMode: 'high'; unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
506
+ - thinkingMode low uses Claude Haiku 5.5 with low adaptive thinking and consumes the viewer's AI Energy from confirmed provider usage. Set model: 'gpt-6-luna' to choose Luna instead.
507
+ - thinkingMode medium is Lumine Light: it uses Claude Haiku 5.5 with medium adaptive thinking and consumes normal AI Energy. Set model: 'gpt-6-luna' to keep Luna as the second choice.
508
508
  - thinkingMode high without model uses Claude Sonnet 5.5 with high adaptive thinking and consumes high AI Energy. Explicit model: 'gpt-6.1-sol' selects GPT-6.1 Sol with xhigh reasoning at the same High AI Energy tier; model: 'claude-sonnet-5-5' names Sonnet 5.5 directly.
509
509
  - claude-sonnet-5-5 and claude-opus-5-5 use Anthropic adaptive High thinking and debit confirmed provider usage at the High tier.
510
510
  - Pass onStatus, onReasoning, and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
@@ -513,7 +513,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
513
513
  - Put a user-facing field such as producerNotes in expectedStructure when commentary must be part of the validated final object rather than transient reasoning progress.
514
514
  - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
515
515
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
516
- - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while Anthropic routes (High without a model, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
516
+ - The server validates the final shape; OpenAI/xAI routes can retry malformed output, while Anthropic routes (claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
517
517
  - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 'number' }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
518
518
  - onChatStatus(listener) | scopes: none
519
519
  - Returns: unsubscribe function
@@ -130,13 +130,35 @@ private Lumine audit log; no public bot identity is involved.
130
130
  canonical bucket. Use it to distinguish quota bookkeeping from moderation;
131
131
  the note itself changes no access, ban, or identity rules.
132
132
 
133
- This surface is quota bookkeeping only. It cannot ban accounts, block signup,
133
+ The commands above are quota bookkeeping only. They cannot ban accounts, block signup,
134
134
  add IP/device/risk-key rules, or infer an account family. Identification
135
135
  remains a human/LLM evidence judgment and must be explicitly requested by
136
136
  Mikey; routine administrator runs still escalate suspected alternate accounts
137
137
  and never auto-enforce. `accounts add` and `note set` still accept only an
138
138
  existing **unbanned** bucket.
139
139
 
140
+ For an exact device Mikey explicitly authorizes blocking, use the separate
141
+ signup-only action after reviewing `identity inspect`:
142
+
143
+ ```bash
144
+ lumine admin ai-bucket device block-signup --bucket-id 10 --user 3127 \
145
+ --device-key 0123456789abcdef --user-ids 3127 \
146
+ --note "Mikey approved this reviewed device for the household signup block" --json
147
+ ```
148
+
149
+ `--user` is the source account ID; `--user-ids` is the complete reviewed list
150
+ of accounts observed on that device, not every member of the household.
151
+ The API resolves the inspection handle internally, rechecks bounded 365-day
152
+ device evidence, requires the exact expected account set, and rejects devices
153
+ used by anyone outside the existing bucket. Incomplete history, unresolved
154
+ handles and a rule belonging to another bucket stop the action. The bucket
155
+ must already have an active **signup-only** restriction. This cannot turn on
156
+ a bucket ban, change full-site bans, add network rules or infer membership.
157
+ The response confirms the canonical rule and restriction without returning the
158
+ raw device ID. The private audit records the request before evidence is read
159
+ and records successful completion separately. Routine reviews still never
160
+ auto-enforce; this action requires Mikey's explicit instruction for the device.
161
+
140
162
  ### Shared verified-email policies
141
163
 
142
164
  A verified address proves that an account can use that mailbox; it does not
@@ -198,6 +220,22 @@ IP evidence, private messages, or unrelated activity. `--include-private-evidenc
198
220
  adds DOB values and verified email addresses only; exact device IDs are never
199
221
  returned.
200
222
 
223
+ `sharedDevices[].usage` reports recorded event counts, first/last observed Unix
224
+ timestamps, an `eventsByType` breakdown, and the same details per account. These
225
+ come from `ai_usage_session_evidence`, which records the exact device identifier;
226
+ `users_actions` has account/IP activity but no device identifier. Counts include
227
+ events such as login, logout, socket binding and AI use, **not unique visits or
228
+ physical-device sessions**. Only events attached to an account are counted.
229
+
230
+ Usage `status: complete` means the queried device's records fit inside the
231
+ 365-day lookback and read limit. `partial` counts are lower bounds, with observed
232
+ dates and `truncatedBy` explaining the gap. `not_read` has a null event count,
233
+ never zero. `accountsTruncated` separately marks a capped account-detail list;
234
+ its totals still include omitted accounts. Device discovery can be truncated
235
+ even when an individual device's usage is complete: always check the overall
236
+ `evidenceCoverage` too. Human-readable CLI output includes these distinctions;
237
+ older APIs explicitly report that usage counts are unavailable.
238
+
201
239
  The CLI sends `identity inspect` and `identity network` as reads, so it keeps
202
240
  no copy of the result on disk; the server still records who looked, when and
203
241
  why, one audit row per call. When a case needs a kept copy (a bullying or police
@@ -1647,6 +1685,39 @@ to a child):
1647
1685
 
1648
1686
  ## Private carry-over todos
1649
1687
 
1688
+ ### Zero and Ciel's personal follow-ups
1689
+
1690
+ Each bot has a separate persistent list of commitments made to members. The
1691
+ API reply tools save a concrete task before saying it is on the bot's list;
1692
+ they link the real source comment/chat and keep the requesting member and bot
1693
+ server-owned. Retries of the same message return the same task. Members can
1694
+ ask that bot to read or cancel their own tasks. This does not approve any
1695
+ administrative action, deployment, reward, or publication.
1696
+
1697
+ Full `daily-run start` returns `personalFollowups` for the assigned bot;
1698
+ `daily-run gather` saves its first page in `personal-followups.json`. Follow
1699
+ `nextCursor` to exhaustion. Other bots' tasks wait for their own turns, and
1700
+ scoped sessions do not surface or modify the list.
1701
+
1702
+ ```bash
1703
+ lumine admin followup list --json
1704
+ lumine admin followup list --cursor 100 --json
1705
+ lumine admin followup update 17 --revision 2 --status completed --note "Played the requested path on published version 400; observed ...; replied in original comment 91 (reply 103)." --json
1706
+ ```
1707
+
1708
+ Read the original context, do the promised work, keep evidence, and report the
1709
+ real result to the member in the original context under the existing reply
1710
+ rules. Do not move a private request into a public post. Build replies still
1711
+ require exact-version review evidence. Record `blocked` with a concrete next
1712
+ step when access, the creator, or Mikey's decision is needed. A completed
1713
+ daily run never completes promises automatically. Update requires the current
1714
+ revision and the assigned bot's active full run; stale or sibling updates are
1715
+ refused. Daily reports include pending follow-ups and their latest progress.
1716
+
1717
+ Apply `add-ai-daily-followups.sql` before deploying this API/CLI capability.
1718
+
1719
+ ### Operator carryovers
1720
+
1650
1721
  ```bash
1651
1722
  lumine admin todo list --json
1652
1723
  lumine admin todo list --status all --json
@@ -1666,6 +1737,35 @@ audit path: canonical todo state and its private `todo.create` / `todo.update`
1666
1737
  audit response commit together, and no public bot, public mutation count, or
1667
1738
  rotation signal is involved.
1668
1739
 
1740
+ Track implementation, release and verification separately when a carryover
1741
+ involves a fix. After `add-admin-todo-workflow.sql` and the matching API are
1742
+ deployed, save a local JSON file such as:
1743
+
1744
+ ```json
1745
+ {
1746
+ "implementation": "ready",
1747
+ "release": "pending",
1748
+ "verification": "pending",
1749
+ "fixRef": "work/topic/release-review.md",
1750
+ "releaseRef": "",
1751
+ "evidence": "Focused regression passed; production is unchanged.",
1752
+ "nextAction": "Deployer releases the reviewed fix, then check the live result."
1753
+ }
1754
+ ```
1755
+
1756
+ Use `lumine admin todo update 12 --revision <current-revision> --workflow
1757
+ work/topic/workflow.json --status in_progress --note "Ready for the deployer."
1758
+ --json`. The revision comes from the canonical list/update response; stale
1759
+ updates are rejected. Implementation values are `needed`, `in_progress`,
1760
+ `ready`, `not_needed`; release is `pending`, `live`, `not_needed`; verification
1761
+ is `pending` or `passed`. Any unfinished phase needs a concrete `nextAction`;
1762
+ passed verification needs `evidence`. Completion requires implementation ready
1763
+ (or unnecessary), release live (or unnecessary), and verification passed.
1764
+ Keep physical-device checks and observation windows pending until they happen.
1765
+ Legacy todos have `workflow: null`, which means unknown, never verified. An
1766
+ update without `--workflow` preserves an existing workflow and cannot bypass
1767
+ its completion requirements.
1768
+
1669
1769
  Every successful full `daily-run start` response automatically includes all
1670
1770
  unfinished items under `data.carryoverTodos`. The same run ID increments an
1671
1771
  item's surfacing telemetry at most once, even when start is retried. This is the