@stage5/lumine 0.3.55 → 0.3.59

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
@@ -134,6 +134,15 @@ version, records the same save metadata, and marks public builds as having
134
134
  unpublished changes. For projects you own, run `lumine launch` to publish the
135
135
  saved changes, or `lumine save --publish` to save and publish in one step.
136
136
 
137
+ For automation, `lumine save --json` emits one canonical JSON receipt with
138
+ `schemaVersion`, `ok`, the saved artifact metadata, `build`, and `publication`.
139
+ `lumine versions --json` returns the server's version history without prose.
140
+ If `save --publish --json` saves successfully but publication fails, the receipt
141
+ still identifies that saved version, sets `ok: false`, and reports the publication
142
+ error; the command exits nonzero. A contribution branch reports
143
+ `publication.published: false` with `reason: "contribution_requires_owner"`.
144
+ Saving a branch does not publish the owner's app.
145
+
137
146
  Pulled workspaces include `AGENTS.md` and `CLAUDE.md` guides for local coding
138
147
  agents, `TWINKLE_BUILD_SDK.md` with the current Build SDK reference, plus
139
148
  `.twinkle/lumine-project.json` metadata that tells agents whether the checkout
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
7
7
  import { adminWorkDirectory } from "./admin-work-directory.js";
8
8
  import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
9
9
  import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
10
+ import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
10
11
  export { dailyRunProgress };
11
12
 
12
13
  // Multi-step conveniences for the full daily management run. Each one runs
@@ -66,12 +67,15 @@ export function runAdmin(
66
67
  result.status === 0 && parsed?.ok === true && !error && !result.error;
67
68
  const reads = {
68
69
  "energy-budget": [null],
70
+ "owner-trace": [null],
69
71
  news: [null],
70
72
  brief: [null],
71
73
  "ai-costs": ["monthly", "day"],
72
74
  "media-costs": ["monthly"],
73
75
  "reward-activity": [null],
76
+ "reward-diagnostics": [null],
74
77
  "reward-review": ["list"],
78
+ review: ["list"],
75
79
  approvals: ["list"],
76
80
  todo: ["list"],
77
81
  followup: ["list"],
@@ -162,7 +166,7 @@ export function renderBotOutput({ chatMessages, comments }, file) {
162
166
  fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
163
167
  }
164
168
 
165
- export async function dailyRunGather(options) {
169
+ export async function dailyRunGather(options, { run = runAdmin } = {}) {
166
170
  const startedAt = performance.now();
167
171
  const dir = ensureDir(
168
172
  path.resolve(options.adminOutputDir || options.adminDir || "."),
@@ -171,7 +175,7 @@ export async function dailyRunGather(options) {
171
175
  const steps = [];
172
176
  const step = (name, args, file) => {
173
177
  const outputFile = path.join(dir, file);
174
- const res = runAdmin(args, { outputFile, retryRead: true });
178
+ const res = run(args, { outputFile, retryRead: true });
175
179
  steps.push({
176
180
  name,
177
181
  file,
@@ -195,13 +199,75 @@ export async function dailyRunGather(options) {
195
199
  ["reward-activity", "--date", yesterday, "--days", "7"],
196
200
  "reward-activity.json",
197
201
  );
198
- step("reward-review-pending", ["reward-review", "list"], "rr-pending.json");
202
+ step("reward-diagnostics", ["reward-diagnostics", "--days", "1"], "reward-diagnostics.json");
203
+ // One pending queue covers every Build approval type. Exhaust its cursor;
204
+ // an empty/malformed receipt must not look like an empty queue.
205
+ const buildReviews = { complete: false, pages: 0, items: 0 };
206
+ let reviewCursor = null;
207
+ const reviewCursors = new Set();
208
+ for (;;) {
209
+ buildReviews.pages += 1;
210
+ const args = ["review", "list", "--status", "pending"];
211
+ if (reviewCursor) args.push("--cursor", reviewCursor);
212
+ const res = step(
213
+ `build-reviews-page-${buildReviews.pages}`,
214
+ args,
215
+ `build-reviews-${buildReviews.pages}.json`,
216
+ );
217
+ if (!res.ok) break;
218
+ const data = res.json?.data;
219
+ if (
220
+ data?.filter !== "pending" ||
221
+ !Array.isArray(data?.items) ||
222
+ !(data.nextCursor === null ||
223
+ (typeof data.nextCursor === "string" && /^\d+(\.\d\.\d+)?$/.test(data.nextCursor)))
224
+ ) {
225
+ Object.assign(steps[steps.length - 1], {
226
+ ok: false,
227
+ error: "Invalid pending Build review page: expected filter, items and nextCursor.",
228
+ });
229
+ break;
230
+ }
231
+ buildReviews.items += data.items.length;
232
+ if (data.nextCursor === null) {
233
+ buildReviews.complete = true;
234
+ break;
235
+ }
236
+ if (reviewCursors.has(data.nextCursor) || buildReviews.pages >= 100) {
237
+ steps.push({
238
+ name: "build-reviews-pagination",
239
+ ok: false,
240
+ error: reviewCursors.has(data.nextCursor)
241
+ ? "Build review cursor repeated before exhaustion."
242
+ : "Build review pagination reached the 100-page limit before exhaustion.",
243
+ });
244
+ break;
245
+ }
246
+ reviewCursors.add(data.nextCursor);
247
+ reviewCursor = data.nextCursor;
248
+ }
199
249
  // management approvals (birthdates, teacher signups, meetups) with their
200
250
  // flags: a required report section (agent-guides/lumine-admin.md)
201
251
  step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
202
252
  step("todos", ["todo", "list"], "todos.json");
203
253
  step("personal-followups", ["followup", "list"], "personal-followups.json");
204
254
  step("escalations", ["escalation", "list"], "escalations.json");
255
+ const wake = step("owner-wake", ["owner-trace", "--since", "24h", "--wake", "--limit", "2000"], "owner-wake.json");
256
+ // A phone tab can live longer than the daily window. Retain its version,
257
+ // browser and device context instead of silently calling it unknown.
258
+ const wakeSessions = step("owner-wake-sessions", ["owner-trace", "--since", "30d", "--type", "session", "--limit", "2000"], "owner-wake-sessions.json");
259
+ const ownerWake = summarizeOwnerViewport(
260
+ wake.ok ? wake.json.data : null,
261
+ wakeSessions.ok ? wakeSessions.json.data : null,
262
+ );
263
+ ownerWake.collectionFailures = [
264
+ ...(!wake.ok ? ["owner-wake"] : []),
265
+ ...(!wakeSessions.ok ? ["owner-wake-sessions"] : []),
266
+ ];
267
+ if (ownerWake.collectionFailures.length) ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
268
+ writeAdminJsonFile(path.join(dir, "owner-wake-summary.json"), ownerWake, { privateFile: true });
269
+ fs.writeFileSync(path.join(dir, "owner-wake-summary.md"), formatOwnerViewportReport(ownerWake), { mode: 0o600 });
270
+ steps.push({ name: "owner-wake-summary", file: "owner-wake-summary.json", ok: true });
205
271
  step("featured-list", ["featured", "list"], "featured-start.json");
206
272
  step(
207
273
  "sponsor-applications",
@@ -250,6 +316,13 @@ export async function dailyRunGather(options) {
250
316
  durationMs: Math.round(performance.now() - startedAt),
251
317
  dir,
252
318
  yesterdayUtc: yesterday,
319
+ buildReviews,
320
+ ownerWake: {
321
+ coverage: ownerWake.coverage,
322
+ truncated: ownerWake.truncated,
323
+ largeGapCycles: ownerWake.largeGapCycles,
324
+ reportFile: path.join(dir, "owner-wake-summary.md"),
325
+ },
253
326
  botOutput: {
254
327
  complete: botOk,
255
328
  pages,
@@ -37,12 +37,18 @@ export function createDailyReviewState(dir, gather, previous = {}) {
37
37
  read: false,
38
38
  });
39
39
  for (const source of previous.sources || []) {
40
+ // Keep prior receipts on disk as history, not as current queue evidence or
41
+ // duplicate reading duties after a new (possibly partial) collection.
42
+ if (gather.buildReviews && (
43
+ source.file === "rr-pending.json" || /^build-reviews-\d+\.json$/.test(source.file)
44
+ )) continue;
40
45
  if (!sources.some((current) => current.file === source.file)) sources.push({ ...source, retained: true, read: false });
41
46
  }
42
47
  return {
43
48
  protocol: 1,
44
49
  scope: previous.scope || "collection",
45
50
  sources,
51
+ collectionFailures: gather.failed || [],
46
52
  requiredDuties: [...new Set(["effort-levels", "report-browser", ...(previous.requiredDuties || [])])],
47
53
  note: "Collection is not a full review. Add every duty required by the run scope and its carryovers. Reading and browser checks require explicit evidence acknowledgments.",
48
54
  };
@@ -150,14 +156,16 @@ export function summarizeDailyProgress(dir, state, evidence = {}) {
150
156
  const incomplete = duties
151
157
  .filter((item) => item.status !== "completed")
152
158
  .map((item) => item.id);
159
+ const collectionFailures = state.collectionFailures || [];
153
160
  const complete =
154
- sources.every((item) => item.read) && incomplete.length === 0;
161
+ collectionFailures.length === 0 && sources.every((item) => item.read) && incomplete.length === 0;
155
162
  return {
156
163
  scope: state.scope || "declared-duties",
157
164
  coverageBasis: "declared sources and duties only",
158
165
  canonicalRun,
159
166
  effortAssignments,
160
167
  sources,
168
+ collectionFailures,
161
169
  duties,
162
170
  complete,
163
171
  reading: {
@@ -16,12 +16,13 @@ export const OWNER_TRACE_PERF_TYPES = [
16
16
  "scroll-perf",
17
17
  "scroll-frame",
18
18
  "wake",
19
+ "wake-viewport",
19
20
  ];
20
21
 
21
22
  // --wake: the session headers and the wake profiles (what the site did in the
22
23
  // 20 s after each load or return to the tab), to compare Wi-Fi and cellular
23
- // wakes (the server tags both with the network).
24
- export const OWNER_TRACE_WAKE_TYPES = ["session", "wake"];
24
+ // wakes. Server network tags describe the upload, which can follow a switch.
25
+ export const OWNER_TRACE_WAKE_TYPES = ["session", "wake", "wake-viewport", "trace-dropped"];
25
26
 
26
27
  const MAX_LIMIT = 2000;
27
28
 
@@ -159,6 +160,8 @@ export function describeOwnerTraceEvent(event) {
159
160
  return describeScrollFrame(data);
160
161
  case "wake":
161
162
  return describeWake(data);
163
+ case "wake-viewport":
164
+ return describeViewport(data);
162
165
  case "stall":
163
166
  return `main thread blocked ${data.ms}ms${data.n > 1 ? ` (${data.n} stalls, ${data.total}ms blocked over ${data.span}ms)` : ""}`;
164
167
  case "page-load": {
@@ -179,13 +182,29 @@ export function describeOwnerTraceEvent(event) {
179
182
  }
180
183
  }
181
184
 
182
- // The network an upload came from: v4/v6 plus an opaque prefix hash. The same
183
- // tag = the same network (home Wi-Fi vs the carrier).
185
+ // The network an upload came from: v4/v6 plus an opaque prefix hash. This does
186
+ // not establish whether the device used Wi-Fi or cellular when it captured.
184
187
  function describeNet(net) {
185
188
  if (!net?.id) return "";
186
189
  return `net v${net.v}:${net.id}`;
187
190
  }
188
191
 
192
+ function describeViewport(data) {
193
+ if (data.error) return `geometry unavailable (${data.error})`;
194
+ const visual = isPlainObject(data.visual) ? data.visual : {};
195
+ const nav = isPlainObject(data.nav) ? data.nav : {};
196
+ const focus = isPlainObject(data.focus) ? data.focus : {};
197
+ return [
198
+ `v${data.version || "?"} cycle ${data.cycle ?? "?"} ${data.phase || "?"} +${data.elapsedMs ?? "?"}ms`,
199
+ `window ${data.window?.height ?? "?"} root ${data.root?.height ?? "?"}`,
200
+ `visual ${visual.height ?? "?"} top ${visual.offsetTop ?? "?"} scale ${visual.scale ?? "?"}`,
201
+ `nav bottom ${nav.bottom ?? "?"} (${nav.position || "absent"})`,
202
+ `inset ${data.inset ?? "?"} focus ${focus.tag || "none"}${focus.type ? `:${focus.type}` : ""}`,
203
+ `scroll ${data.root?.scrollTop ?? "?"}`,
204
+ data._trimmed ? `trimmed ${JSON.stringify(data._trimmed)}` : "",
205
+ ].filter(Boolean).join(" · ");
206
+ }
207
+
189
208
  // What the site did in the window after a load or a return to the tab.
190
209
  // A field the server cut to "[cut N chars]" arrives as a string: every list or
191
210
  // object is checked before use, so one trimmed row never breaks the output.
@@ -0,0 +1,113 @@
1
+ // Read-only interpretation of bounded wake geometry. Large gaps are leads,
2
+ // never a diagnosis or evidence that a browser workaround is safe.
3
+ export function summarizeOwnerViewport(data, sessionData = data) {
4
+ const events = Array.isArray(data?.events) ? data.events : [];
5
+ const sessions = new Map(
6
+ [...(sessionData?.events || []), ...events].filter(e => e.type === "session").map(e => [e.sessionId, e.data || {}]),
7
+ );
8
+ const cycles = new Map();
9
+ const usableBySession = new Map();
10
+ let unavailable = 0;
11
+ let dropped = 0;
12
+ for (const event of events) {
13
+ if (event.type === "trace-dropped") dropped += Number(event.data?.count) || 0;
14
+ if (event.type !== "wake-viewport") continue;
15
+ const d = event.data || {};
16
+ if (d.error || d.v !== 1 || d._trimmed || !d.window || !d.root || !d.focus) {
17
+ unavailable += 1;
18
+ continue;
19
+ }
20
+ usableBySession.set(event.sessionId, (usableBySession.get(event.sessionId) || 0) + 1);
21
+ const key = `${event.sessionId}:${d.cycle}`;
22
+ const header = sessions.get(event.sessionId);
23
+ let cycle = cycles.get(key);
24
+ if (!cycle) {
25
+ cycle = {
26
+ sessionId: event.sessionId, cycle: d.cycle, version: d.version || header?.version || "unknown",
27
+ platform: header?.platform || "unknown", ua: header?.ua || null,
28
+ sessionHeaderAvailable: Boolean(header), samples: 0, settledSamples: 0,
29
+ largeGapSamples: 0, backgroundSamples: 0, lastScheduledMs: 0,
30
+ maxRootGap: null, maxVisualGap: null, firstLargeGapMs: null, lastLargeGapMs: null, examples: [],
31
+ };
32
+ cycles.set(key, cycle);
33
+ }
34
+ cycle.samples += 1;
35
+ if (d.phase === "background") cycle.backgroundSamples += 1;
36
+ cycle.lastScheduledMs = Math.max(cycle.lastScheduledMs, Number(d.scheduledMs) || 0);
37
+ if (d.phase !== "settle" || d.scheduledMs < 1000 || d.visibility !== "visible") continue;
38
+ cycle.settledSamples += 1;
39
+ const nav = d.nav;
40
+ // The desktop header is fixed at the TOP. Only a nav anchored to bottom:0
41
+ // can support this candidate. Keyboard/zoom/hidden states stay in raw data.
42
+ if (!nav || nav.position !== "fixed" || parseFloat(nav.bottomStyle) !== 0 || nav.height <= 0 || nav.display === "none" ||
43
+ nav.visibility === "hidden" || nav.transform !== "none" ||
44
+ !d.visual || !Number.isFinite(d.visual.scale) || Math.abs(d.visual.scale - 1) > 0.01 ||
45
+ d.focus.editable || ["input", "textarea", "iframe"].includes(d.focus.tag) || d.inset !== 0) continue;
46
+ if (![nav.bottom, d.root.height, d.visual.height, d.visual.offsetTop, d.elapsedMs].every(Number.isFinite)) continue;
47
+ const rootGap = Math.round(d.root.height - nav.bottom);
48
+ const visualGap = Math.round(d.visual.height + d.visual.offsetTop - nav.bottom);
49
+ cycle.maxRootGap = Math.max(cycle.maxRootGap ?? -Infinity, rootGap);
50
+ cycle.maxVisualGap = Math.max(cycle.maxVisualGap ?? -Infinity, visualGap);
51
+ if (Math.max(rootGap, visualGap) >= 100) {
52
+ cycle.largeGapSamples += 1;
53
+ cycle.firstLargeGapMs = Math.min(cycle.firstLargeGapMs ?? Infinity, d.elapsedMs);
54
+ cycle.lastLargeGapMs = Math.max(cycle.lastLargeGapMs ?? -Infinity, d.elapsedMs);
55
+ if (cycle.examples.length < 3) cycle.examples.push({
56
+ seq: event.seq, clientTime: event.clientTime, path: event.path,
57
+ elapsedMs: d.elapsedMs, rootGap, visualGap,
58
+ });
59
+ }
60
+ }
61
+ const rows = [...cycles.values()];
62
+ const viewportEvents = events.filter(e => e.type === "wake-viewport").length;
63
+ const clients = [...new Set(events.map(e => e.sessionId))].map(sessionId => {
64
+ const own = events.filter(e => e.sessionId === sessionId);
65
+ const header = sessions.get(sessionId);
66
+ return {
67
+ sessionId, version: header?.version || own.find(e => e.type === "wake-viewport")?.data?.version || "unknown",
68
+ platform: header?.platform || "unknown",
69
+ viewportSupport: header ? header.viewportTrace === 1 : null,
70
+ geometrySamples: own.filter(e => e.type === "wake-viewport").length,
71
+ usableSamples: usableBySession.get(sessionId) || 0,
72
+ };
73
+ });
74
+ const missingHeaders = rows.filter(c => !c.sessionHeaderAvailable).length;
75
+ const clientsWithoutGeometry = clients.filter(c => !c.usableSamples).length;
76
+ const usableSamples = viewportEvents - unavailable;
77
+ const partial = Boolean(data?.truncated || sessionData?.truncated || unavailable || dropped || missingHeaders || clientsWithoutGeometry);
78
+ return {
79
+ schema: 1, since: data?.since ?? null,
80
+ coverage: !usableSamples ? "unavailable" : partial ? "partial" : "observed",
81
+ truncated: Boolean(data?.truncated), sessionHeadersTruncated: Boolean(sessionData?.truncated),
82
+ viewportEvents, usableSamples, unavailableSamples: unavailable, droppedEvents: dropped,
83
+ wakeProfiles: events.filter(e => e.type === "wake").length,
84
+ clients, clientsWithoutGeometry,
85
+ cycles: rows, cyclesMissingHeaders: missingHeaders,
86
+ cyclesReaching20s: rows.filter(c => c.lastScheduledMs >= 20_000).length,
87
+ largeGapCycles: rows.filter(c => c.largeGapSamples > 0).length,
88
+ // Timers can all be released together after a stall. Scheduled times alone
89
+ // do not establish separate observations; require actual time separation.
90
+ repeatedLargeGapCycles: rows.filter(c => c.largeGapSamples >= 2 && c.lastLargeGapMs - c.firstLargeGapMs >= 500).length,
91
+ note: "A >=100px settled gap is an investigation lead, not a proven defect. Normal browser chrome, viewport offsets and rendering state still require comparison. No samples means unavailable, not healthy. Wake network tags identify the upload network, not necessarily the network at capture.",
92
+ };
93
+ }
94
+
95
+ export function formatOwnerViewportReport(summary) {
96
+ const lines = [
97
+ "# Owner wake viewport follow-up", "",
98
+ `Coverage: **${summary.coverage}**. ${summary.usableSamples} usable geometry samples (${summary.viewportEvents} raw events) across ${summary.cycles.length} load/return cycles; ${summary.cyclesReaching20s} reached the 20-second sample.`, "",
99
+ `Observed clients: ${[...new Set(summary.clients.map(c => `${c.version} (${c.platform}; geometry support ${c.viewportSupport === null ? "unknown" : c.viewportSupport ? "yes" : "no"})`))].join(", ") || "none"}.`, "",
100
+ `Collection failures: ${summary.collectionFailures?.join(", ") || "none"}.`, "",
101
+ `Large-gap candidates: ${summary.largeGapCycles} cycles; ${summary.repeatedLargeGapCycles} had settled bottom-nav gaps >=100px at least 500ms apart in actual time, no focused input, no app keyboard inset and scale 1.`, "",
102
+ `Missing/trimmed geometry: ${summary.unavailableSamples}; clients without usable geometry: ${summary.clientsWithoutGeometry}; reported dropped events: ${summary.droppedEvents}; cycles without session context: ${summary.cyclesMissingHeaders}; truncated trace: ${summary.truncated}; truncated session history: ${summary.sessionHeadersTruncated}.`, "",
103
+ summary.note, "",
104
+ "Read owner-wake.json and owner-wake-sessions.json alongside this summary. Compare affected and ordinary cycles within the same client/browser version. Distinguish viewport height changes, viewport panning and nav displacement; correlate the wake performance profile and its upload network tag. Record the evidence and next action in the daily report even when coverage is unavailable.", "",
105
+ "| Client | Platform | Cycle | Samples | Settled | Large gap | Max root gap | Max visual gap |",
106
+ "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |",
107
+ ];
108
+ for (const c of summary.cycles) {
109
+ const safe = v => String(v).replace(/[|\r\n]/g, " ");
110
+ lines.push(`| ${safe(c.version)} | ${safe(c.platform)} | ${safe(c.sessionId).slice(0, 8)}:${c.cycle} | ${c.samples} | ${c.settledSamples} | ${c.largeGapSamples} | ${c.maxRootGap ?? "unavailable"} | ${c.maxVisualGap ?? "unavailable"} |`);
111
+ }
112
+ return lines.join("\n") + "\n";
113
+ }
package/lib/admin.js CHANGED
@@ -381,7 +381,7 @@ function buildApprovalOperation({ action, target, options }) {
381
381
  "approvals.decide",
382
382
  "POST",
383
383
  `/cli/admin/approvals/${id}`,
384
- { decision: action, reason },
384
+ { decision: action, reason, ...(options.adminFingerprint ? { fingerprint: String(options.adminFingerprint) } : {}) },
385
385
  { requiresRun: false },
386
386
  );
387
387
  }
@@ -3086,11 +3086,11 @@ export function parseAdminOperation(options) {
3086
3086
  }
3087
3087
 
3088
3088
  if (MEETUP_NAMESPACES.has(namespace)) {
3089
- // Bridge Builder crews (achievement type 'meetup'): the same admin tools as
3090
- // the website. Crews no longer wait for staff approvals (check-ins are the
3091
- // 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).
3092
3092
  const usage =
3093
- "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].";
3094
3094
  const note = String(options.note || "").trim();
3095
3095
  if (note.length > 1000) {
3096
3096
  throw cliValidationError("--note must be at most 1000 characters.");
@@ -3242,6 +3242,14 @@ export function parseAdminOperation(options) {
3242
3242
  { requiresRun: false },
3243
3243
  );
3244
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
+ }
3245
3253
  if (action === "info") {
3246
3254
  // Staff's "who are you" check on a member (ruled 2026-10-07): the member
3247
3255
  // gives their teacher's name and class name (or how they know the crew)
@@ -3638,6 +3646,17 @@ export function parseAdminOperation(options) {
3638
3646
  throw cliValidationError(usage);
3639
3647
  }
3640
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
+
3641
3660
  if (namespace === "reward-activity" || namespace === "reward-telemetry") {
3642
3661
  // Read-only claim telemetry for the daily run: no run lease, no mutation.
3643
3662
  const date = options.adminDate
@@ -5858,8 +5877,14 @@ function printAdminMonthlyAiCosts(monthlyAiCosts) {
5858
5877
 
5859
5878
  function printAdminDailyAiCosts(dailyAiCosts) {
5860
5879
  console.log(
5861
- `Application AI-cost ledger for ${dailyAiCosts.day.dayKey} (closed UTC day): ${formatAdminUsd(dailyAiCosts.summary?.estimatedCostUsd)} across ${Number(dailyAiCosts.summary?.requestCount || 0)} request(s).`,
5880
+ `Application AI usage before credits for ${dailyAiCosts.day.dayKey} (closed UTC day): ${formatAdminUsd(dailyAiCosts.summary?.estimatedCostUsd)} across ${Number(dailyAiCosts.summary?.requestCount || 0)} request(s).`,
5862
5881
  );
5882
+ const credit = dailyAiCosts.creditAccounting;
5883
+ if (credit?.status === "estimated" && Number.isFinite(credit.creditUsd) && Number.isFinite(credit.estimatedCashUsd)) {
5884
+ console.log(`Promotional credit: ${formatAdminUsd(credit.creditUsd)}; estimated cash cost: ${formatAdminUsd(credit.estimatedCashUsd)}.`);
5885
+ } else {
5886
+ console.log("Promotional credit and estimated cash cost unavailable; the usage total above is before credits.");
5887
+ }
5863
5888
  for (const row of dailyAiCosts.byProviderModel || []) {
5864
5889
  console.log(
5865
5890
  ` ${row.provider || "unknown"}/${row.model || "unknown"}: ${formatAdminUsd(row.estimatedCostUsd)} · ${Number(row.totalTokens || 0)} token(s) · ${Number(row.requestCount || 0)} request(s).`,
@@ -6631,8 +6656,10 @@ function printMeetupResult({ operation, data, result }) {
6631
6656
  }
6632
6657
  if (operation.name === "meetup.show") {
6633
6658
  printMeetupCrew(data.crew || {});
6659
+ if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
6634
6660
  return;
6635
6661
  }
6662
+ if (operation.name === "meetup.review-completion") console.log(`Meetup completion ${data.status}.`);
6636
6663
  if (data.crew) printMeetupCrew(data.crew);
6637
6664
  }
6638
6665
 
@@ -6944,6 +6971,7 @@ function printApprovalResult({ operation, data }) {
6944
6971
  `${item.ref} · ${APPROVAL_LABELS[item.type] || item.type} · ${item.status} · ${item.requesterUsername} (#${item.requesterId})`,
6945
6972
  );
6946
6973
  printEvidence(data.evidence);
6974
+ if (data.meetupEvidence) console.log(JSON.stringify(data.meetupEvidence, null, 2));
6947
6975
  return;
6948
6976
  }
6949
6977
  if (operation.name === "approvals.decide") {
@@ -7378,7 +7406,9 @@ function printAdminResult({ operation, result }) {
7378
7406
  if (operation.name === "math-lab.queue") {
7379
7407
  console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
7380
7408
  for (const grade of data.grades || []) console.log(grade.available
7381
- ? `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7409
+ ? grade.progression === "collection"
7410
+ ? `${grade.ruleId}: ${grade.sets} in collection; ${grade.remaining} fresh choices minimum (${grade.activeLearners} active learners; median ${grade.medianRemaining})${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7411
+ : `${grade.ruleId}: ${grade.current || "exhausted"}${grade.clearedToday ? " (cleared today)" : ""}; ${grade.remaining} unearned of ${grade.sets}${grade.refillDue ? `; refill to ${data.refillTarget}` : ""}`
7382
7412
  : `${grade.ruleId}: queue unavailable`);
7383
7413
  if (!data.complete) console.log("Queue coverage is incomplete; unavailable grades are not empty queues.");
7384
7414
  return;
package/lib/commands.js CHANGED
@@ -1276,7 +1276,7 @@ export async function save(options) {
1276
1276
  lastSavedAt: new Date().toISOString(),
1277
1277
  filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
1278
1278
  });
1279
- if (!options.quiet) {
1279
+ if (!options.quiet && !options.json) {
1280
1280
  printSaveResult({
1281
1281
  result,
1282
1282
  build,
@@ -1286,14 +1286,31 @@ export async function save(options) {
1286
1286
  });
1287
1287
  }
1288
1288
 
1289
+ const jsonReceipt = (publication = null, ok = true) => {
1290
+ if (options.json && !options.quiet)
1291
+ console.log(JSON.stringify({ ...result, schemaVersion: 1, ok, build, publication }));
1292
+ };
1289
1293
  if (options.publish) {
1290
1294
  if (build?.canPublish === false) {
1291
- console.log(
1295
+ if (!options.json) console.log(
1292
1296
  "Saved to your branch. The project owner can merge or replace main from Twinkle.",
1293
1297
  );
1298
+ jsonReceipt({ published: false, reason: "contribution_requires_owner" });
1299
+ return result;
1300
+ }
1301
+ let publish;
1302
+ try { publish = await publishBuild({ options, buildId, auth }); }
1303
+ catch (error) {
1304
+ // Saving already succeeded. Preserve its exact receipt even if the
1305
+ // separate publication fails; the command still exits nonzero.
1306
+ jsonReceipt({ published: false, error: String(error?.message || error) }, false);
1307
+ throw error;
1308
+ }
1309
+ if (options.json) {
1310
+ build = publish.build || build;
1311
+ jsonReceipt({ published: true, skipped: Boolean(publish.skipped), build: publish.build || null });
1294
1312
  return result;
1295
1313
  }
1296
- const publish = await publishBuild({ options, buildId, auth });
1297
1314
  if (publish.skipped) {
1298
1315
  console.log("Publish skipped: already up to date.");
1299
1316
  } else {
@@ -1304,6 +1321,7 @@ export async function save(options) {
1304
1321
  );
1305
1322
  console.log(`App: ${options.siteUrl}/app/${buildId}`);
1306
1323
  }
1324
+ jsonReceipt();
1307
1325
  return result;
1308
1326
  }
1309
1327
 
@@ -1979,7 +1997,8 @@ export async function versionsCommand(options) {
1979
1997
  buildId,
1980
1998
  limit: options.limit,
1981
1999
  });
1982
- printVersionList({ result });
2000
+ if (options.json) console.log(JSON.stringify(result));
2001
+ else printVersionList({ result });
1983
2002
  }
1984
2003
 
1985
2004
  export function printVersionList({ result }) {
@@ -3196,7 +3215,7 @@ export function printHelp() {
3196
3215
  lumine pull [twinkle-build-url] --main
3197
3216
  lumine pull [twinkle-build-url] --version <n> [--main]
3198
3217
  lumine pull [twinkle-build-url] --published [--main]
3199
- lumine versions [twinkle-build-url] [--main] [--limit <n>]
3218
+ lumine versions [twinkle-build-url] [--main] [--limit <n>] [--json]
3200
3219
  lumine restore <n> [--main]
3201
3220
  lumine reference <twinkle-build-url>
3202
3221
  lumine fork <twinkle-build-url>
@@ -3204,7 +3223,7 @@ export function printHelp() {
3204
3223
  lumine merge <twinkle-branch-url>
3205
3224
  lumine replace-main <twinkle-branch-url>
3206
3225
  lumine update-from-main [twinkle-branch-url]
3207
- lumine save
3226
+ lumine save [--summary <text>] [--publish] [--json]
3208
3227
  lumine check [twinkle-build-url]
3209
3228
  lumine launch [twinkle-build-url]
3210
3229
  lumine sdk list
@@ -3280,6 +3299,7 @@ export function printHelp() {
3280
3299
  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)
3281
3300
  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)
3282
3301
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
3302
+ lumine admin reward-diagnostics [--build <id> --user <id>] [--days <1..30>] [--cursor <id>] [--json]
3283
3303
  lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
3284
3304
  lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
3285
3305
  lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
@@ -3326,7 +3346,7 @@ export function printHelp() {
3326
3346
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3327
3347
  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)
3328
3348
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3329
- 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)
3349
+ 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)
3330
3350
  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)
3331
3351
  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]
3332
3352
  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]
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.55",
3
+ "version": "0.3.59",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,9 +2,10 @@
2
2
 
3
3
  Version: 1.68.0
4
4
  Updated: 2026-10-16
5
- Generated: 2026-10-22T03:03:30.790Z
5
+ Generated: 2026-10-23T06:17:07.718Z
6
6
 
7
7
  ## Notes
8
+ - Reward is part of completion: a reward-paying run must stay pending until awarded:true and its canonical receipt (or getReceipt status awarded). Do not show final solved/completed status, advance rewarded progress, or discard finished proof before that. Preserve the exact challenge/proof and acknowledged progress in a durable per-viewer outbox; reconcile getReceipt after a lost reply and retry the same challenge, never inventing a payment. Explain explicit practice, already collected, exhausted approved limits, preview and disclosed zero-value retry outcomes. Use the start response as a conservative elapsed-time reference, and test delayed start, network failure, lost claim reply, reload, day/viewer change and duplicate recovery.
8
9
  - This SDK is injected into Build iframes via the Build preview/runtime.
9
10
  - Widgets call SDK methods; the parent proxies to the API.
10
11
  - Data API methods require scoped tokens handled by the parent; some namespaces include write methods.
@@ -34,7 +35,7 @@ Generated: 2026-10-22T03:03:30.790Z
34
35
  - Use Twinkle.live for one-way app livestreams and Twinkle.chat for the accompanying thread. Free livestreams require a verified host, end after at most 15 minutes, and issue at most 10 private viewer grants. Twinkle keeps platform-owned live-status/end controls above active hosts, so app code cannot hide or replace the broadcaster's Stop path.
35
36
  - Media Energy is separate from AI Energy. Replace Media Energy UI only from canonical mediaEnergy/getUsage responses; never decrement, reserve, or synthesize it in app code.
36
37
  - Twinkle.rewards awards real XP and Coins only in the current approved published release. Drafts, local previews, private apps and superseded releases cannot earn. The server supplies a published-runtime grant; app code cannot choose a recipient or award amount.
37
- - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day; an award larger than the learner's remaining room today pays what fits, XP and Coins clamped separately; a capped award still counts as claimed, including a one-time maxLifetimeClaims reward, and its receipt carries cappedFrom) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
38
+ - The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day; an award larger than the learner's remaining room today pays what fits, XP and Coins clamped separately; a capped award still counts as claimed, including a one-time maxLifetimeClaims reward, and its receipt carries cappedFrom) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' | 'collection' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
38
39
  - Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified. So a plain timer is only for activity nothing can check (time spent reading, a visit). When the reward is for a result the app could check (a solved puzzle, a cleared level, a won game, a right answer), add a completionProof: a registered profile below when one fits, otherwise app-check-v1, the app's own server-side check. 'generated-quiz' pays from a question bank the server fills itself: for one declared topic at the learner's step of the ladder, the server writes a question with GPT-6.1 Sol (billed to that learner's AI Energy), solves it again independently and keeps it only when both answers agree; every learner who has not seen a question gets it before anything new is written. `xp`/`coins` are the top step's amounts and each step pays its `steps` percent of them (then the usual retry share). Two first-try answers in a row climb a step; two misses in a row step down. Learners may earn many bank questions a day, bounded by userDailyXP/userDailyCoins. The reviewer approves the ladder, amounts, topics and instructions, not each question; learner reports and solve rates hide bad questions. Use the rewards bank methods (getBankStatus, startBankQuestion, generateBankQuestion, claimBankQuestion, reportBankQuestion); start and claim refuse bank rules. 'archive-rematch' is the daily rematch: one retired until-earned puzzle of the app a day, chosen by the server (celebrated, high-rated solves first; never repeated within 60 days) and answered from its frozen approved sheet. Declare at most one, with no questions, sets, progression, series, minSeconds or completionProof, e.g. { "id": "rematch-daily", "title": "Daily rematch", "xp": 20000, "coins": 1000, "verifier": "archive-rematch", "retry": { "xpPercent": 50, "coinsPercent": 0, "paidAttempts": 3 }, "maxAttempts": null }. Only players who never met that puzzle (no official receipt, wrong answer or challenge while it was live, no Vault practice) may answer it; its receipt does not use userDailyClaims but counts toward userDailyXP/userDailyCoins. Use getRematch and claimRematch; start and claim refuse it.
39
40
  - Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
40
41
  - Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Twinkle.craft apps also have craft-target-v1 (the learner's run of merges reaches today's target in this app's universe; every merge is a known pair and every result is one the learner made through Twinkle.craft.merge), craft-par-v1 (the same run reaches it in at most par merges, with no Twinkle.craft.hint revealed that day) and craft-discovery-v1 (the oldest World First of the learner's that no rule has paid yet; each World First pays once ever; optional minWordLevel basic|elementary|intermediate|advanced|epic makes tiers: either every craft-discovery-v1 rule has one or none does; each World First pays under exactly one tier, the highest configured tier at or below its Word Master level, so a series per tier can pay rarer words more and the order of claims never matters; an item whose level is still pending pays under no tier yet). sliding-block-v1 with minMoves (2 to 40) verifies a Rush Hour style 6x6 sliding-block puzzle the server deals: start returns completion { board: [[row, col, length, horizontal 0|1], ...] (car 0 is the red car on row 2), positions, minMoves, token, maxFrames }; show exactly that board, send each move as a frame [msSincePreviousMove, carIndex, newPosition] through progress (only real slides: a tap that moves nothing is refused), and claim once the red car has left on the right (position 4). Moves faster than 250 ms, impossible slides, or more time than has passed are refused; each challenge has one fixed board once started. Pass levelIndex to start to play a numbered campaign level instead of a random board: minMoves is then a floor, so an easy level never pays a harder rule. The campaign only grows and is never renumbered: the 857-level base (RushHour, build 2799, ships it as levels.js) plus levels Twinkle adds over time, and getStatus returns slidingBlock { levels, baseLevels, added: [[minMoves, board], ...] } for an app with such a rule so it can append the new ones after its base. Use it for any sliding-block, parking or 'unblock me' puzzle instead of a plain completion timer. app-check-v1 is the app's own server-side check, for any game or activity whose result can be checked that no registered profile covers (a nonogram, a maze, a word or number puzzle, a quiz with a hidden answer, a game whose moves can be replayed). Add /rewards.verify.js at the project root: plain JavaScript defining function deal(input) and function check(input), with no imports, no network and nothing but its input. deal({ ruleId, params }) returns { puzzle, secret }: the challenge's puzzle (sent to the app) and anything only the server should know (an answer, a hidden layout). Its Math.random is a server stream unique to the challenge, so the same challenge always deals the same puzzle and nobody can predict it from the public file; Date is frozen at 1970. check({ ruleId, params, puzzle, secret, record, elapsedSeconds }) returns { ok: true } or { ok: false, reason } (the reason is shown to the player). It must check the player's own record (the filled grid, the moves, the answer) against the dealt puzzle, never a client 'won' flag, and may require a human-paced elapsedSeconds. A refused record uses one of the challenge's tries like a wrong quiz answer (maxAttempts, default 3; retry shares apply), and any claim within two seconds after a refusal, right or wrong, gets build_reward_throttled (429) and should be sent again after the wait, and claim then returns { awarded: false, reason, attemptsRemaining }. The reason is shown to the player, so it must never reveal the answer or any part of the secret (say 'Not this time', not which cell is wrong). For a hidden-answer quiz, set maxAttempts (often 1) so guessing doesn't pay. Each call gets 250 ms and 16 MB; a record is at most 64 KB. The rule's checkParams (JSON, up to 2,000 characters, e.g. { "size": 10 }) reach both as params. `lumine rewards check` deals twice and offers check records that prove nothing (none, true, {}, "solved", the puzzle itself) and reports a check that accepts any; previews deal and check from the draft's file, so test there before sending for review. The other profiles are platform-owned. An app-check-v1 rule pays only after the reviewer approves that exact file with the rest of the code.
@@ -1509,7 +1510,8 @@ world.updatePresence({ x, y, z, facing });
1509
1510
  - Pass null as expectedValue for an absent/null value. Both values are limited to 16 KB. Optional expectedUserId prevents a held operation from crossing an account change.
1510
1511
  - operationId is required: 8–64 letters, digits, underscores or hyphens. Reuse it for retries of the same logical change.
1511
1512
  - On conflict, rebase the intent onto the returned canonical item before comparing again. Do not retry-loop a 429.
1512
- - A duplicate operation returns the current canonical item without applying again. Ordinary set/remove remain unconditional; use a dedicated key for a compare-and-save workflow.
1513
+ - An applied response confirms the save; use its canonical item as the next expectedValue. JSON storage may normalize floating-point numbers, so byte-for-byte equality with the submitted value is not a valid success check.
1514
+ - A duplicate operation returns the current canonical item without applying again. matchesSubmittedValue is true when that current item equals this operation's submitted value after database normalization, and false if another save has since changed it. A receipt alone does not mean an older snapshot is still current. Ordinary set/remove remain unconditional; use a dedicated key for a compare-and-save workflow.
1513
1515
 
1514
1516
  ### Twinkle.reminders
1515
1517
  - async list({ includeDisabled, limit } = {}) | scopes: reminders:read
@@ -1570,19 +1572,23 @@ world.updatePresence({ x, y, z, facing });
1570
1572
  - 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
1573
  - 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
1574
  - 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.
1575
+ - Collection progression keeps every approved keyed set available independently of other players' solves. collectionSets lists { setKey, number }; challenges carry collection, practice, completed and completion. Live status includes viewerId for a per-viewer durable outbox. Attempts and completion persist across days/releases; the daily reward allowance does not reset a puzzle's paid tries. Existing receipts (including rematches), Vault solves/practice and studied guides count. Completed puzzles can be revisited; do not treat any receipt for the grade as completing all its puzzles. Collection rules support up to 500 permanent sets per rule; legacy queued/dated rules keep their 62-set limit.
1573
1576
  - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
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 }
1577
+ - Returns: { mode: "live", status: "awarded" | "completed" | "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 }
1575
1578
  - 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.
1576
1579
  - 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
1580
  - 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.
1578
- - await Twinkle.rewards.start({ ruleId, levelIndex?, completionToken? }) | scopes: rewards:claim
1579
- - Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1581
+ - For collection practice, status:'completed' confirms a saved unpaid completion and includes completion { ruleId, setKey, at }, practice:true and receipt:null. Another challenge for the same personally completed collection set resolves to its existing receipt/completion, never a second payout.
1582
+ - await Twinkle.rewards.start({ ruleId, setKey?, levelIndex?, completionToken? }) | scopes: rewards:claim
1583
+ - Returns: { mode: "live", challengeId, readyInSeconds?, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1580
1584
  - 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.
1581
1585
  - Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
1582
1586
  - 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).
1583
1587
  - 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.
1584
1588
  - 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
1589
  - 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.
1590
+ - For collection rules, setKey is required and must be one of the approved permanent keys. Start returns that exact question and persistent attempts. practice:true means no XP/Coins or official rating (daily allowance reached, earlier practice, or viewed archive guide); display that before answering. Merely opening a practice question does not consume its future eligibility, but answering it does. completed:true returns its lesson and completion, without a new challenge or payout. A current eligible daily-rematch puzzle must be opened as the rematch. A challenge still expires at the usual day/release boundary; reopen the same set to resume its saved attempts.
1591
+ - Live completion starts return readyInSeconds, the remaining server-enforced wait for that challenge (including a resumed challenge). Anchor it at response arrival and queue one claim after that time; do not poll claim while too early. Retain the finished proof in a per-viewer outbox and reconcile getReceipt after a lost reply or reload. It is timing metadata, not proof of completion or permission to pay. Older servers may omit it; use the rule minSeconds conservatively. Preview timing and receipts remain separate.
1586
1592
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1587
1593
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1588
1594
  - Verify a bounded batch of inputs for an approved server-simulated climb.
@@ -1597,12 +1603,13 @@ world.updatePresence({ x, y, z, facing });
1597
1603
  - Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
1598
1604
  - After a wrong answer, the next answer within two seconds, right or wrong, is refused with build_reward_throttled (HTTP 429): it does not count and says nothing about whether it was right. Keep the question open and let the person send it again after the wait; never treat a 429 as a wrong answer or retry-loop it.
1599
1605
  - Optional subject says what the reward was for; it is shown on the public bounty story on Home while the app is public (e.g. "listened to Night Drive"). title is plain text up to 80 characters, path is this app's share-link segment (the one you pass to Twinkle.app.getShareUrl, letters, digits and dashes), imageUrl a Twinkle-hosted image. A malformed subject is refused with build_reward_subject_invalid (HTTP 400); an older server without subjects ignores it. Proof-backed rules that read the item on the server (Groove Lab songs) replace it with the stored title, link, cover and creator.
1600
- - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
1606
+ - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start. Preserve the finished proof, show reward pending, and schedule one retry using the start clock; let unrelated play continue. In preview mode the receipt carries preview: true and nothing is paid.
1601
1607
  - A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction; every receipt counts, including one capped by the daily budget (cappedFrom). build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
1602
1608
  - 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.
1603
1609
  - 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.
1604
1610
  - 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
1611
  - 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.
1612
+ - A collection claim is for its server-bound set. A first paid solve creates the usual receipt once per viewer/puzzle across days and releases. Free practice returns completed:true, practice:true, awarded:false and completion, with no reward receipt, official rating, Home story or daily bounty count. Do not mistake completed practice for a wrong answer. A wrong answer returns completed:false and canonical attempts/questions. Daily eligibility is rechecked transactionally, including Vault/guide exposure; no client amount or completion flag is trusted.
1606
1613
  - await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
1607
1614
  - 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 }
1608
1615
  - 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.
@@ -1618,11 +1625,12 @@ world.updatePresence({ x, y, z, facing });
1618
1625
  - Drafts and owner previews return mode 'preview' and need no open challenge.
1619
1626
  - await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
1620
1627
  - Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures?, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", firstTry, firstSolve, failersBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
1621
- - Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
1628
+ - Community difficulty and personal completion for quiz queues and persistent collections.
1622
1629
  - Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
1623
1630
  - puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers, sent only while the puzzle is still unsolved (absent on defeated puzzles, so a solver's own misses cannot be worked out).
1624
1631
  - tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later. firstTry is true when the solver got it on the first try; which try it took and the solver's own wrong answers are never shown to other players, so never reconstruct them.
1625
1632
  - No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
1633
+ - Collection rules include all published sets, with number, priority and personal { completed, practice, attempts, available, rewardAvailable }. Those personal fields belong only to the signed-in viewer. priority = 10*log2(1+failers)/(1+0.35*log2(1+solvers)), plus 2+min(daysStanding,7)/7 only when failers>0 and solvers=0. This is a ranking score, not a failure probability; failers can also be solvers. Prioritize personal reward availability, then priority; use grade preference and stable daily rotation for ties. Completed puzzles stay browsable. daysStanding stops at the first solve for collections. The daily claim cap remains separate from per-puzzle rewardAvailable.
1626
1634
  - await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
1627
1635
  - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, firstTry, story: { tier, firstTry, failersBefore, daysStanding } | null, puzzle: { rating, solvers, failers } | null, vault: { rating, solvers, failers, failures } | null, rematchOpen }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
1628
1636
  - Browse confirmed solves of retired until-earned quiz bounties, newest first.
@@ -1631,6 +1639,7 @@ world.updatePresence({ x, y, z, facing });
1631
1639
  - No daily-claim limit applies to reading. Requires the current approved published runtime grant; previews return an empty timeline. Never awards or changes balances. Open a receipt with getArchivedProblem to load the original question and guide.
1632
1640
  - Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', firstTry: whether the solver got it on the first try, never which try, how many other players had missed it, days it stood); null for receipts older than solve logging. puzzle is the official challenge rating and counts now (no total wrong answers: it is solved, and that total would give away the solver's own misses); vault is the puzzle's living practice stats (its own rating, practice solvers, players whose first practice answer missed, all wrong practice answers), or null before anyone practised it. Counts only: players who missed are never named.
1633
1641
  - rematchOpen is true on the entry that is today's daily rematch while the viewer can still answer it for pay: label it and open it as the rematch, not as a lesson. Daily-rematch receipts are never timeline entries.
1642
+ - Collection solves also enter the museum after the next UTC reset. Museum availability does not retire a collection puzzle; other viewers can still earn it once. Opening its guide or explanations, or practising it in the Vault, makes subsequent collection answers practice for that viewer. Revisiting the same lesson after an official first miss already unlocked it does not forfeit the retry bounty.
1634
1643
  - await Twinkle.rewards.getArchivedProblem({ receiptId, withGuide?: true }) | scopes: rewards:claim
1635
1644
  - Returns: { mode: "live", entry: <same solve entry as getTimeline, with rematchOpen>, questions: [{ prompt, hint?, guide? }], rematchOpen } | { mode: "preview", entry: null, questions: [], message }
1636
1645
  - Read a retired bounty’s original questions and guides from the approval attached to its solve receipt.
@@ -1655,6 +1664,7 @@ world.updatePresence({ x, y, z, facing });
1655
1664
  - Idea tags come from each set's questions[0].guide.ideaTags (1-3 kebab-case slugs). The current release's copy of a set wins, else the frozen sheet. Ordered by shared tags, then newest; one entry per retired set (its earliest receipt).
1656
1665
  - With ruleId: today's set of that until-earned rule. It would be a hint, so it opens only once the viewer has a receipt for today's set or a challenge on it with a wrong attempt; otherwise build_reward_related_locked (409). With receiptId: no gate; the source puzzle is excluded.
1657
1666
  - Rate limited (build_reward_throttled, 429). Daily-rematch receipts are never related entries.
1667
+ - For a collection source, send { ruleId, setKey }; the gate checks that exact puzzle's personal completion or wrong attempts across days, never another puzzle of the grade.
1658
1668
  - await Twinkle.rewards.listExplanations({ receiptId }) | scopes: rewards:claim
1659
1669
  - Returns: { mode: "live", entries: [{ id, userId, username, profilePicUrl, text, votes, myVote: 0 | 1, createdAt, updatedAt, mine, pinned }], canModerate } | { mode: "preview", entries: [], message }
1660
1670
  - Solvers' explanations of a retired puzzle, most helpful first.
@@ -636,20 +636,22 @@ it never authorizes unrelated daily work or generic recommendation commands.
636
636
 
637
637
  Mikey added Math Lab question design and publishing to the full daily workflow
638
638
  on 2026-09-08. Follow [Math Lab daily question publishing](../../agent-guides/math-lab-daily.md)
639
- for the canonical Build 2460, owner account, twelve grade queues of ordered
640
- until-earned puzzles, verification, repeat-run recovery, release gates, and
641
- final reporting. Every full daily run reports each grade's current question,
642
- whether it was cleared today, uncleared published questions remaining, and
643
- refill status. Count distinct cleared keys across all users and the app's full
644
- history against the live approved sheet; the recent usage window and draft
645
- additions are not the live inventory. At four or fewer remaining, prepare a
646
- refill to at least twenty, with complete interactive guides, and track it until
647
- approved publication. Report one or zero remaining prominently. Details are
648
- in the linked guide's **Daily queue monitoring and refill** section.
639
+ for the canonical Build 2460, owner account, twelve grade collections,
640
+ verification, repeat-run recovery, release gates, and final reporting.
641
+ Since 2026-10-22, another member solving a puzzle does not remove it. Every full
642
+ daily run reports each grade's collection size, 30-day active learner count,
643
+ minimum and median fresh choices, and refill status. For each active learner,
644
+ exclude all-history solves, attempts, practice and studied lessons, including
645
+ rematches. At four or fewer minimum fresh choices, append enough unique
646
+ puzzles with complete interactive guides to restore that minimum to twenty.
647
+ Track the refill until approved publication; draft additions never count as
648
+ live stock. Report one or zero fresh choices prominently. Legacy
649
+ `until-earned` rules retain the global queue procedure in the linked guide.
649
650
  Read this inventory with `lumine admin math-lab queue --json`. Its receipt
650
651
  binds the published artifact and live reward review, includes all twelve
651
- grades, and reports incomplete coverage explicitly. Today’s cleared set stays
652
- current until UTC midnight, but no longer counts as unearned inventory.
652
+ grades, and reports incomplete coverage explicitly. Read each live rule's
653
+ progression before choosing the inventory calculation; daily bounty limits
654
+ remain separate from access to the permanent collection.
653
655
  This is not part of Featured-only or newspaper-only work and is not a new
654
656
  scheduler, delegated API scope, or automatic extension of admin permissions.
655
657
  Use the expressly authorized owner Build workflow for Math Lab; retain the
@@ -1184,8 +1186,8 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
1184
1186
  ```
1185
1187
 
1186
1188
  These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
1187
- - **`gather`** saves its fixed set of read-only reports into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json`. It is not the complete daily checklist: app refills/usage, all-type Build reviews, sponsor integrity and other duties need their own steps. Repository agents follow the current full-run coverage index in `agent-guides/lumine-admin.md`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. It also creates `review-state.json`: every source is fetched, not read. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
1188
- - **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
1189
+ - **`gather`** saves its fixed set of read-only reports into `<dir>`, including the canonical published Math Lab queue in `math-lab-queue.json` and all types of pending Build review requests in `build-reviews-N.json`. Build reviews follow `nextCursor` to exhaustion; `gather.json` → `buildReviews` records `complete`, attempted `pages` and collected `items`. A failed/malformed page, cursor loop or 100-page bound reports incomplete coverage, never an empty healthy queue. Read the current collection once; it replaces the rewards-only list. Older packages without `buildReviews` need a separate `review list --status pending` collection through cursor exhaustion. Previous queue receipts remain on disk as history but do not become current evidence or duplicate reading duties after a new gather. It is not the complete daily checklist: app refills/usage, individual review assessments, sponsor integrity and other duties need their own steps. Repository agents follow the current full-run coverage index in `agent-guides/lumine-admin.md`. It follows `bot-output` to exhaustion and renders `bot-chats.txt`, then lists any failed step in `gather.json`. Total/per-step `durationMs`, attempts and recovered reads make recurring slow or failing collection steps visible during the daily review. It also creates `review-state.json`: collection never acknowledges reading. Children bypass update checks, require matching fresh stdout/file receipts, and retry an unconfirmed opted-in read at most once. Mutations never auto-retry. It needs the active run for run-scoped reads.
1190
+ - **`progress`** reads local review state and explicit operator acknowledgments, writes private `progress.json`, and keeps reading, canonical effort mutations, the run lease, and browser checks separate. `collectionFailures` includes failures without a page file, such as a cursor loop; acknowledging the available files cannot make an incomplete collection complete. A fresh successful gather clears those failures. Add all required duties for the actual run scope and its carryovers; collection alone is not a full review. A completed run lease cannot complete a pending duty. Reading acknowledgments bind to the current file bytes and are operator attestations, not proof of comprehension.
1189
1191
  - **`featured candidates`** returns Subjects posted after the Bangkok-midnight cutoff that are off the board and provably never Featured. Each one comes with its human/bot comment counts, explicit comment truncation/completeness, and whether Mikey has viewed it. Counts from truncated context are partial. An unavailable Subject context fails the collection. Eligibility only: ranking stays editorial.
1190
1192
  - **`report serve`** detaches a loopback server that answers only `/`, `marked.min.js` and the report Markdown. `--open` opens Chrome and reports whether the OS accepted the request. Render, navigation and original-source verification remain pending until actually checked. Startup failures stop only the child this command started.
1191
1193
 
@@ -1292,20 +1294,21 @@ JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
1292
1294
 
1293
1295
  Everything a creator asks Mikey to unlock is one review request with a type:
1294
1296
  `rewards` (XP & Coin rewards, below), `project-limit` (more project room),
1295
- `storage-limit` (more Lumine file storage, per creator) and `cardcraft` (an AI
1296
- Card crafting recipe). Mikey sees them in one list (Management → **Build
1297
+ `storage-limit` (more Lumine file storage, per creator), `cardcraft` (an AI
1298
+ Card crafting recipe) and `soundtrack` (soundtrack requests). Mikey sees them in one list (Management → **Build
1297
1299
  approvals**, or the CLI below), gets the same DM card for each, and decides
1298
1300
  them the same way. Each type keeps its own rules: reward approvals carry the
1299
1301
  earning rules and publish the frozen version; storage approvals take a size
1300
1302
  (500 MB / 1 GB / 2 GB tiers, any size up to the 2 GB cap, never lowering an
1301
1303
  existing approval); project room approvals raise Main to the fixed bounds;
1302
1304
  card crafting approvals make the recipe live in the published app without a
1303
- new release. Only rewards and card crafting approvals can be revoked. The
1305
+ new release. Rewards, card crafting and soundtrack approvals can be revoked. The
1304
1306
  older per-type commands (`reward-review`, `cardcraft-review`, `storage
1305
1307
  approve|reject|list`) are aliases of these routes and keep working.
1306
1308
 
1307
1309
  ```bash
1308
1310
  lumine admin review list --json # waiting on a decision (default)
1311
+ lumine admin review list --status pending --cursor <nextCursor> --json # continue until nextCursor is null
1309
1312
  lumine admin review list --status queue --type storage,project --json
1310
1313
  lumine admin review show storage:12 --json # <type>:<id>, or <id> --type storage
1311
1314
  lumine admin review approve storage:12 --size 1GB --json
@@ -1313,13 +1316,15 @@ lumine admin review reject project:4 --reason "Split the big file first" --json
1313
1316
  lumine admin review approve cardcraft:3 --json
1314
1317
  lumine admin review revoke cardcraft:3 --reason "Too strong for level 1 cards" --json
1315
1318
  lumine admin review approve rewards:70 --config rules.json --json
1316
- lumine admin review propose rewards:70 --dir /private/tmp/reward-review-70 --config rules.json --json
1319
+ lumine admin review propose rewards:70 --dir work/reward-review-70 --config rules.json --json
1317
1320
  ```
1318
1321
 
1319
1322
  ### Management approvals and the teacher audit (any time; also a full-daily-review duty; added 2026-10-04)
1320
1323
 
1321
1324
  Birthdate, teacher-signup (`mentor`) and meetup-achievement requests, each with its evidence: the account, the request, who invited them, accounts linked by verified email, exact device or a manual identity bucket (with each one's age and role), the internet addresses the account acted from (network evidence, below), activity, and flags. These are Mikey-only and need no daily run.
1322
1325
 
1326
+ **Cadence (Mikey, October 22):** pending approvals and the teacher watch stay in every full daily run. Run the full teacher audit weekly, or sooner for a new teacher or materially changed evidence. Record the last complete audit and next due date; if that history is unavailable, perform the audit. Do not re-submit unchanged cases for another decision. This changes the collection cadence, not who decides an account's status.
1327
+
1323
1328
  ```bash
1324
1329
  lumine admin approvals list [--status pending|approved|rejected|all] [--type dob|mentor|meetup]
1325
1330
  lumine admin approvals show <id>
@@ -1519,9 +1524,11 @@ Rule fields (all server-enforced, none inferred from app code):
1519
1524
  - Top-level `userDailyClaims`: receipts one learner may earn per site day
1520
1525
  across all rules. `1` is "one bounty a day".
1521
1526
 
1522
- Math Lab's economy (Mikey, equal pay 2026-10-04): twelve level rules, one per grade per
1523
- day, all paying 70,000 XP + 5,000 Coins; `retry` 50 % XP / 0 % Coins; `maxAttempts` null;
1524
- `userDailyClaims` 1; until-earned sets authored from the Korean curriculum.
1527
+ Math Lab's economy (Mikey, equal pay 2026-10-04; persistent collection 2026-10-22):
1528
+ twelve grade rules, all paying 70,000 XP + 5,000 Coins; `retry` 50 % XP / 0 % Coins;
1529
+ `maxAttempts` null; `userDailyClaims` 1. Collection puzzles follow the Korean
1530
+ curriculum, stay available after others solve them, and pay each learner at
1531
+ most once per puzzle. The daily paid allowance remains separate from free practice.
1525
1532
  Arcade Typing (Mikey, 2026-09-12): XP for clearing campaign stages, up to
1526
1533
  10,000 XP per learner per day, no Coins. Platform ceilings: 100,000 XP /
1527
1534
  10,000 Coins per rule and per learner per day. No app-wide ceiling exists,
@@ -1598,7 +1605,7 @@ leaves the offered media as unused uploads in the creator's library.
1598
1605
 
1599
1606
  ### Owner client trace (any time, read-only; added 2026-10-06)
1600
1607
 
1601
- When Mikey reports a UI issue from his phone, read what his browser did
1608
+ When Mikey reports a UI issue on desktop or mobile, read what his browser did
1602
1609
  before guessing. Only the owner's own signed-in browser records it (everyone
1603
1610
  else's is a no-op and the upload route refuses them); the server keeps 30 days.
1604
1611
 
@@ -1607,6 +1614,7 @@ lumine admin owner-trace --since 30m # readable timeline,
1607
1614
  lumine admin owner-trace --since 2026-10-06T09:00 --type route,feed-open
1608
1615
  lumine admin owner-trace --path /comments/123 --json # raw rows
1609
1616
  lumine admin owner-trace --since 1h --perf # performance events only
1617
+ lumine admin owner-trace --since 24h --wake --limit 2000 # wake profiles and viewport geometry
1610
1618
  ```
1611
1619
 
1612
1620
  Event types: `session` (version, viewport, platform, PWA or tab), `route`
@@ -1616,7 +1624,7 @@ triggered and the tapped element's tag, card position), `scroll-anchor` (the
1616
1624
  scroll-anchor diagnostics: save/restore/cancel with scroll numbers),
1617
1625
  `content-scroll` (scrollTop at mount, when the content is ready, and 1 s/3 s
1618
1626
  later), `error`/`rejection` (short message and source file), `visibility`,
1619
- `pagehide`, `trace-dropped`. Times are the phone's clock in this machine's
1627
+ `pagehide`, `trace-dropped`. Client times come from the device's clock, displayed in this machine's
1620
1628
  time zone. Ids and paths only, never message text or other members' content.
1621
1629
 
1622
1630
  Performance events (`--perf` shows only these): `nav-timing` (one per
@@ -1628,7 +1636,34 @@ one committed), `slow-request` (API requests over 1 s: method, path pattern
1628
1636
  with query keys only, status, ms, scheduler queue time), `stall` (main-thread
1629
1637
  gaps over 200 ms while the page is visible, bursts within 1 s coalesced) and
1630
1638
  `page-load` (once per load: TTFB, DOMContentLoaded, load, first route ready,
1631
- ms since navigation start).
1639
+ ms since navigation start). Published 0.3.55 also includes `scroll-perf`
1640
+ (bounded scrolling-frame summaries), `scroll-frame` (frame attribution) and
1641
+ `wake` (return-to-tab work) in `--perf`. Session headers and `trace-dropped`
1642
+ need separate types or the explicit daily collection request; `--perf` alone
1643
+ does not establish device context or complete coverage.
1644
+
1645
+ CLI 0.3.57 includes the following wake-viewport additions and their daily
1646
+ coverage report. Older 0.3.55 packages do not contain them; upgrade or use
1647
+ the current repository CLI (`node lumine-cli/bin/lumine.js`).
1648
+ `--wake` includes `session`, `wake`, `wake-viewport` and `trace-dropped`.
1649
+ `wake-viewport` is bounded owner-only geometry on load/return/`pageshow`, at
1650
+ 250ms/1s/3s/20s and once when backgrounded. It records viewport sizes/offsets,
1651
+ nav and shell bounds, scroll, keyboard inset and focused-control type, never
1652
+ control contents. Normal wakes are retained for comparison. `--perf` also
1653
+ includes the geometry rows. The session's `viewportTrace: 1` marks support;
1654
+ older tabs do not acquire it until they load a version containing the change.
1655
+
1656
+ `daily-run gather` additionally saves `owner-wake.json`, the retained session
1657
+ context in `owner-wake-sessions.json`, and `owner-wake-summary.json` / `.md`.
1658
+ Include the coverage and follow-up in the daily report. A >=100px settled gap
1659
+ under a fixed bottom-anchored nav is an investigation candidate, not a diagnosis.
1660
+ The desktop top nav is excluded; repeated candidates need at least 500ms of
1661
+ actual time separation, not timers released together after a stall. No usable
1662
+ samples is unavailable, not healthy; missing measurements on another observed
1663
+ client make coverage partial. Network tags describe the upload network, which can differ from
1664
+ the capture network. Check truncation: the endpoint has no backward cursor or
1665
+ end-time filter; splitting by event type can reduce caps, but narrowing the
1666
+ start time cannot recover older omitted rows. Preserve unresolved coverage.
1632
1667
 
1633
1668
  ### Who holds which management level (any time, read-only; added 2026-10-10)
1634
1669
 
@@ -1747,7 +1782,15 @@ lumine admin craft unfizzle --build 1499 --since 2026-10-13T00:00:00Z --confirm
1747
1782
  try the pair asks again. `--since` and `--pair` narrow it; up to 5,000
1748
1783
  fizzles are scanned per run.
1749
1784
 
1750
- ### Reward question-bank seeding (every full daily review; added 2026-09-28)
1785
+ <a id="reward-question-bank-seeding-every-full-daily-review-added-2026-09-28"></a>
1786
+
1787
+ ### Reward question-bank inspection and optional seeding
1788
+
1789
+ **Math Studio #2655 seeding was stopped by Mikey on October 4.** Its coverage
1790
+ and learner-health report remain daily duties. The `seed` command below is a
1791
+ technical capability, not permission to run it: neither a full daily run nor
1792
+ missing bank slots resumes the stopped seeding plan. Any resumption needs
1793
+ Mikey's explicit app, scope and spending instruction.
1751
1794
 
1752
1795
  `generated-quiz` rules fill a question bank on the server: a question is
1753
1796
  written for one declared topic at one ladder step (GPT-6.1 Sol) and kept only
@@ -1757,7 +1800,7 @@ them so the first learner on a unit never waits or pays.
1757
1800
 
1758
1801
  ```bash
1759
1802
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json] # read-only, no run
1760
- lumine admin reward-bank seed <buildId> [--max-step 3] [--json] # delegated full run
1803
+ lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel] [--json] # explicit seeding authority
1761
1804
  lumine admin reward-bank report <buildId> [--days 1] [--json] # read-only telemetry
1762
1805
  lumine admin reward-bank question <buildId> --question-id <id> [--json]
1763
1806
  lumine admin reward-bank repair <buildId> --file <repair.json> --dry-run [--json]
@@ -1801,9 +1844,11 @@ and seeding coverage for steps 1-3 once the app is approved.
1801
1844
  (about 20–40 s; the CLI allows 4 minutes) and returns `seeded`
1802
1845
  (`questionId`, `ruleId`, `topicKey`, `step`) or `complete: true`, plus
1803
1846
  `coverage` (`filled` / `total`).
1804
- - It needs the `reward:bank-seed` scope, which only full delegated runs carry.
1805
- The question is written as the run identity (Zero or Ciel) and billed to
1806
- that account's own AI Energy. It never pays anyone and opens no challenge.
1847
+ - Inside a delegated run it needs `reward:bank-seed`, carried only by full
1848
+ runs; the approved operator can also call it without a run using a Zero/Ciel
1849
+ identity. This route support does not override the seeding stop. The question
1850
+ is billed to that identity's own AI Energy; it never pays anyone or opens a
1851
+ challenge.
1807
1852
  - It refuses unless the app's rewards are approved and published
1808
1853
  (`CLI_ADMIN_BUILD_REWARD_BANK_NOT_LIVE`), and refuses to store a question
1809
1854
  if the approval changed while it was being written
@@ -1811,11 +1856,20 @@ and seeding coverage for steps 1-3 once the app is approved.
1811
1856
  `CLI_ADMIN_BUILD_REWARD_BANK_UNVERIFIED` means the two solves disagreed
1812
1857
  twice; nothing was stored.
1813
1858
  - Which apps to seed, how many steps and how many per run are Mikey's
1814
- decisions, recorded in the agent guide (Math Studio #2655: steps 1–3, at
1815
- most 20 per run).
1859
+ decisions. The old Math Studio steps 1–3 / 20-per-run recipe is historical,
1860
+ not current authorization.
1816
1861
 
1817
1862
  ### Reward activity report (standing duty, every full daily review; added 2026-09-12)
1818
1863
 
1864
+ Also inspect failed or unconfirmed payouts with `lumine admin reward-diagnostics --days 1 --json`.
1865
+ Follow `nextCursor` using `--cursor <id>`; narrow an incident with `--build <id> --user <id>`
1866
+ to see correlated request observations alongside canonical challenges and receipts.
1867
+ The trail is best effort, retained for 30 days, and has no coverage before deployment.
1868
+ Read `reward-diagnostics-coverage` runtime warnings for dropped writes or retention backlog.
1869
+ A delayed client report's reporting version is not necessarily the original request's version;
1870
+ use its request ID to find the authoritative server event. Neither an open challenge nor a
1871
+ client error proves an unpaid eligible solve. Never infer a payment repair from those alone.
1872
+
1819
1873
  Completion rewards (Arcade Typing's stage clears) prove nothing but elapsed
1820
1874
  time, so the run reads the shape of the week's claims instead of trusting them:
1821
1875
 
@@ -1999,6 +2053,8 @@ Apply `add-ai-daily-followups.sql` before deploying this API/CLI capability.
1999
2053
 
2000
2054
  ### Operator carryovers
2001
2055
 
2056
+ Keep unresolved investigations, release dependencies and creator decisions visible with their exact next evidence trigger. Once a bug is verified closed, ordinary logs/health telemetry cover it; do not require a separate daily retest or write-up. Reopen on a recurrence, new report, contradicting evidence or a relevant code change. No new event is not proof that an unresolved item is fixed.
2057
+
2002
2058
  ```bash
2003
2059
  lumine admin todo list --json
2004
2060
  lumine admin todo list --status all --json
@@ -3812,7 +3868,8 @@ without a comparison carry `baselineStatus: not_run`; its fallback LLM spend is
3812
3868
  unchanged. Chat routing is also off since September 23
3813
3869
  (`JEV_CHAT_ROUTING_ENABLED`, default off; Jev served ~1% of chats and could not
3814
3870
  judge when a reply needs older history), so zero chat serving rows is expected.
3815
- The rest of this section describes the pre-September-23 pilot.
3871
+ The current daily procedure follows. Paired reaction/emoji checks and the 5%
3872
+ served-route audit belong to the historical pilot, not new daily evidence.
3816
3873
 
3817
3874
  Read `data.jevPilot` from `lumine admin brief --json` and carry it into
3818
3875
  the full report for Mikey. The active `daily-run report --json` also includes
@@ -3822,37 +3879,20 @@ treat a missing section as zero traffic or a healthy pilot.
3822
3879
 
3823
3880
  State the configuration and operating status even when off/blocked/awaiting
3824
3881
  samples. Headline the named last completed UTC day, compare with the trailing
3825
- seven completed days, and keep the in-progress day separate. Include paired
3826
- decision counts, disagreements (especially Jev react / baseline respond),
3827
- p50/p95 latency for each model, provider errors/timeouts, pending observations,
3828
- known incremental cost, unknown-cost requests, ledger gaps and cap status.
3829
- Separate `bySurface.comment` from `bySurface.chat`. For chat, report
3830
- `routingFieldDisagreements`, `candidateSkippedBaselineRequired`, and missing
3831
- routing-comparison evidence. Equal reply actions do not prove equal tool/history
3832
- routing. Chat's baseline also extracts structured plans, while Jev compares seven
3833
- routing choices plus reaction emoji; these latencies do not establish an end-to-end speedup.
3834
- Mikey authorized production serving on September 20 for the tested comment and
3835
- text-chat routing decisions. Include `serving.jevDecisions`, baseline and
3836
- unreserved fallbacks with reasons, audit coverage, served disagreements,
3837
- `serving.decisionLatencyMs`, chat added wait, and comment baseline calls avoided.
3838
- Chat retains the existing full planner for outputs outside Jev's tested scope;
3839
- comments run a 5% independent background baseline audit. Distinguish actual
3840
- selected routes from unused comparisons and identify Turtle's deployment tests.
3841
- Chat's `baseline_requires_reply` fallback preserves the planner's written reply
3842
- when Jev would only react; report its frequency and review those disagreements.
3843
- Reaction-only chat responses require both models to agree.
3844
- Jev chooses the emoji from all 18 supported reactions in `chat-routing-v2`.
3845
- Report `reactionChoices` usage by source, paired emoji disagreements, and missing
3846
- legacy evidence; review whether the chosen tone fits the canonical conversation.
3847
- Candidate and served emoji are in `reviewCandidates` routing objects. Earlier
3848
- `chat-routing-v1` rows have no emoji comparison and must not count as agreement.
3882
+ seven completed days, and keep the in-progress day separate. Separate
3883
+ `bySurface.comment` from `bySurface.chat`. Report real selections, fallback
3884
+ rates and reasons, `serving.decisionLatencyMs` p50/p95, provider failures,
3885
+ pending observations, unknown usage and ledger gaps. Calls avoided are a count,
3886
+ not measured dollar savings. No chat selections while routing is off and no
3887
+ served-route audit comparisons are expected; do not manufacture missing-data
3888
+ findings from them. Identify synthetic deployment probes separately.
3849
3889
  Mikey explicitly requested all eligible requests with no daily request cap
3850
3890
  (`dailyLimit: 0`) and a cost report during every full website-management run.
3851
3891
  Run `lumine admin ai-costs day YYYY-MM-DD --json` for the last completed UTC day.
3852
- Report the canonical `data.dailyAiCosts.byOperation` USD totals for `jev_reply_gate_serve`,
3853
- `jev_chat_routing_serve`, any `_shadow` operations, and `jev_reply_gate_audit`.
3854
- Separate comment/chat provider spend from background baseline-audit spend.
3855
- Report unfinished selection/baseline-audit telemetry; synthetic probes are
3892
+ Report the canonical `data.dailyAiCosts.byOperation` USD totals for enabled serve
3893
+ and shadow operations, including Auto and its fallback. Keep any historical
3894
+ baseline-audit costs separate; new served-route audit calls are not expected.
3895
+ Report unfinished selection telemetry; synthetic probes are
3856
3896
  excluded from performance metrics but included in daily request and cost counts.
3857
3897
  If `telemetryStatus: partial` or `telemetryComplete: false`, detail metrics are a
3858
3898
  bounded recent sample, not full-day performance or cost; use the canonical daily
@@ -3868,8 +3908,8 @@ are evaluation findings; outages, stuck telemetry or missing ledger entries are
3868
3908
  operational findings. Report a recommendation to continue, adjust or stop, without
3869
3909
  automatically changing mode, scope or caps.
3870
3910
 
3871
- Mikey's September 21 reporting requirement: include a private case-by-case
3872
- comparison for the reviewed disagreements. For each event give the minimum
3911
+ For actual comparisons from the separate shadow families or retained historical
3912
+ pilot, preserve Mikey's September 21 case-review requirement. For each reviewed event give the minimum
3873
3913
  relevant excerpt/context, field name and plain-language meaning, exact baseline
3874
3914
  and JEV values, selected value/source and fallback, observed outcome, and your
3875
3915
  assessment with evidence. Explicitly allow “both defensible” or “insufficient
@@ -5401,10 +5441,10 @@ Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
5401
5441
  `comments get`, and `recommend` remain accepted, but the singular command forms
5402
5442
  shown above are the canonical interface.
5403
5443
 
5404
- September 21 routing expansion: read `jevRoutingShadow` alongside `jevPilot` in
5405
- both the admin brief and full daily report. The reviewed `chat-routing-v3` path
5406
- is primary; report actual selection latency, chat baseline calls avoided and
5407
- fallback reasons (no audits since September 23). New routing
5444
+ Read `jevRoutingShadow` alongside `jevPilot` in both the admin brief and full
5445
+ daily report. The September 21 primary-chat experiment is historical: chat
5446
+ routing is off by default and served-route audits were retired September 23.
5447
+ Review actual selection latency and fallback reasons on enabled routes. New routing
5408
5448
  families remain comparison-only until Mikey reviews each one and explicitly
5409
5449
  promotes it. Report every registered family, including zero-sample and skipped
5410
5450
  families; no traffic is not a pass. Show paired counts, exact differing fields,
@@ -5421,27 +5461,30 @@ selector immediately. Its per-choice LLM comparison was retired September 23;
5421
5461
  the LLM runs only as the fallback when JEV cannot decide.
5422
5462
  Auto is the new default; stored manual preferences remain manual. Review
5423
5463
  `jevRoutingShadow` / `byRoute.lumine_model` separately from the eight
5424
- comparison-only families: selected model/effort, both decisions, exact selection
5464
+ comparison-only families: selected model/effort, available decisions, exact selection
5425
5465
  context, confidence, fallback, actual selection latency, missing evidence and
5426
- observed task outcome. Report `jev_lumine_model_serve` and the fallback
5466
+ observed task outcome. A served Auto row with `baselineStatus: not_run` is expected
5467
+ after the audit retirement, not an incomplete comparison. Report `jev_lumine_model_serve` and the fallback
5427
5468
  `lumine_model_fallback` spend separately, using canonical AI-cost totals without
5428
5469
  double-counting. JEV choice confidence is not a correctness score. The other
5429
5470
  eight families still require review before promotion.
5430
5471
 
5431
5472
 
5432
- ### September 21 verified reward follow-up
5473
+ <a id="september-21-verified-reward-follow-up"></a>
5474
+
5475
+ ### Study reward health (every full daily review)
5433
5476
 
5434
- After the Study review migration is deployed, the full daily website run also reads the API-host report `node scripts/build-study-reviews-daily.cjs --days 1`. It is indexed, read-only and bounded; it reports decisions, known measured cost, unknown-usage requests, p50/p95 latency, settled checks, claims, pending rows older than 60 seconds and graded-but-unsettled rows. `--examples` includes at most 30 private full study records and their exact prompt version for administrator quality review; keep that output private. No traffic is not a quality pass. The 12 synthetic pre-release records are a small initial check, not measured real-user accuracy.
5477
+ The full daily website run reads `study.json` from the operations gather, using the API-host report `node scripts/build-study-reviews-daily.cjs --days 1`. It is indexed, read-only and bounded; it reports decisions, known measured cost, unknown-usage requests, p50/p95 latency, settled checks, claims, pending rows older than 60 seconds and graded-but-unsettled rows. Report collection/migration failures as unavailable. `--examples` includes at most 30 private full study records and their exact prompt version for administrator quality review; keep that output private. No traffic is not a quality pass. The 12 synthetic pre-release records are a small initial check, not measured real-user accuracy.
5435
5478
 
5436
- Study's registered `study-record-v1` checks use learner AI Energy for accepted, revise and uncertain results. Preview checks use real AI Energy but cannot award XP/Coins. The agreed proposal pays 2,000 XP + 500 Coins at most once per learner per UTC day. Breadface's `breadface-v1` uses deterministic server physics, not a model. Both creator proposals must be accepted to publish; inspect the canonical review state and acceptance/rejection events during follow-up.
5479
+ Study's registered `study-record-v1` checks use learner AI Energy for accepted, revise and uncertain results. Preview checks use real AI Energy but cannot award XP/Coins. The original proposal paid 2,000 XP + 500 Coins at most once per learner per UTC day; use the current approved rules for current claims. Breadface's `breadface-v1` uses deterministic server physics, not a model. Check current pending reviews and acceptance/rejection events; the original two launch proposals are not a standing pending queue.
5437
5480
 
5438
5481
  For total JEV cost, include `dailyAiCosts.byProviderModel` for TypeSafe/JEV across both AI Energy and system-covered ledgers. Report serving/shadow/audit operations separately as above; the Build SDK's system-covered/failed calls use `jev_decision`, while ordinary viewer SDK checks are already counted by the AI Energy ledger. Do not add the Study report's known-cost sum on top of the canonical overall AI-cost total: it is a diagnostic breakdown of those same calls.
5439
5482
 
5440
5483
  ## Bridge Builder crews and check-ins (no daily run required)
5441
5484
 
5442
- Since 2026-10-17 Bridge Builder unlocks from QR check-ins: 7 different people, or one meetup a grown-up witnessed. Crews no longer wait for staff approvals. On the website, Zero and Ciel acting for an admin can make the staff decisions below (who-are-you checks, branch corrections and branch reviews). They use the same routes as the admin's own taps, and only after the admin's yes on the approval card (Mikey, 2026-10-10). A member's Zero or Ciel still cannot.
5485
+ Bridge Builder qualifies from QR check-ins: 7 different people, or one meetup a grown-up witnessed. Since Mikey’s 2026-10-22 ruling, every physical meetup completion waits for his manual review before the achievement or public crew milestone. The separate recruitment route remains automatic. The retired plan/video workflow stays retired. On the website, Zero and Ciel acting for an admin can make the staff decisions below (who-are-you checks, branch corrections and branch reviews). They use the same routes as the admin's own taps, and only after the admin's yes on the approval card (Mikey, 2026-10-10). A member's Zero or Ciel still cannot.
5443
5486
 
5444
- - `lumine admin meetup list` shows crews whose member answered a who-are-you check. `list --status active` shows every live crew, and `list --status completed` the crews that have met up.
5487
+ - `lumine admin meetup list` shows pending meetup completions and crews whose member answered a who-are-you check. `list --status active` shows every live crew, and `list --status completed` the crews that have met up.
5445
5488
  - `lumine admin meetup show <crewId>` shows canonical progress, who checked in, the tier and private staff evidence.
5446
5489
  - `lumine admin meetup info <crewId> <userId> --decision ask|accept|ask-again|withdraw` is a **private identity check**. Its question and answer are visible only to the named member and staff. Until staff accept, that member doesn't count toward the crew's tier.
5447
5490
  - `lumine admin meetup set-branch <crewId> <userId> --branch <name> [--note <text>]` corrects a member's branch instead of a database write.
@@ -5452,7 +5495,9 @@ Since 2026-10-17 Bridge Builder unlocks from QR check-ins: 7 different people, o
5452
5495
  - `lumine admin meetup funnel [--days <1-90>]` shows views, crews by stage, and check-ins: pairs, how many were on one network or at an academy, and refusals by reason.
5453
5496
  - `lumine admin meetup emails` lists parent emails held for review. Kind `witness` is a child asking a parent to be the grown-up.
5454
5497
  - `lumine admin meetup announce-checkins [--send] [--as zero|ciel]` is about the one-time announcement of the check-in rework in each crew chat. It posts itself when the website with check-ins is out. The messages are pre-written; without `--send` this only shows them. `--send` retries a crew whose post failed. Each crew gets it once.
5455
- - **Held unlocks:** a member who reached 7 people when no counted check-in was on one network or at an academy gets a `meetup` approval item instead of an instant unlock (`lumine admin approvals list --type meetup`). Approving it unlocks Bridge Builder.
5498
+ - **Held unlocks:** every physical-meetup qualification gets a `meetup` approval item (`lumine admin approvals list --type meetup`). `approvals show <id> --json` includes `meetupEvidence` for all participants. After Mikey decides, use `approvals approve|reject <id> --fingerprint <meetupEvidence.fingerprint>`. Proximity never bypasses review.
5499
+ - **Crew completions:** `meetup show <crewId> --json` includes the same owner-only scan packet. After Mikey decides, use `meetup review-completion <crewId> --decision approve|reject --fingerprint <meetupEvidence.fingerprint>`. The decision fails if the shown evidence or proposed attendance changed. New attendees and tier upgrades need a new decision.
5500
+ - **Evidence:** the browser used for every scan, its first/last observed use by that account, previous browsers, other accounts seen on that browser, current account-identity overlaps, every witness attendee’s scan time and proximity, and explicit gaps/caps. History is frozen at scan time. Browser IDs can be reset/copied; shared devices and networks are clues, never a verdict. Older scans cannot recover missing device snapshots.
5456
5501
 
5457
5502
  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.
5458
5503