@stage5/lumine 0.3.78 → 0.3.80

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.
@@ -190,6 +190,45 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
190
190
  Object.assign(steps.at(-1), { ok: false, error });
191
191
  };
192
192
  const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|jev-images|owner-wake|bot-output)/;
193
+ // A run started from a shell that then exits (`gather ... &`) used to be
194
+ // hung up and die before gather.json existed (11-02). Keep collecting like
195
+ // nohup, and keep gather.json current after every step, marked incomplete
196
+ // until the final summary replaces it, so an interrupted collection always
197
+ // leaves a readable receipt and `--resume` is only needed to finish it.
198
+ const ignoreHangup = () => {};
199
+ process.on("SIGHUP", ignoreHangup);
200
+ const writeProgress = () => {
201
+ try {
202
+ fs.writeFileSync(
203
+ path.join(dir, "gather.json"),
204
+ JSON.stringify(
205
+ {
206
+ inProgress: true,
207
+ note: "Collection is still running or was interrupted. Rerun the same gather with --resume to finish it; this file is replaced when it completes.",
208
+ updatedAt: new Date().toISOString(),
209
+ dir,
210
+ yesterdayUtc: yesterday,
211
+ costDateUtc: yesterday,
212
+ collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase },
213
+ steps,
214
+ failed: steps.filter((s) => !s.ok && !s.deferred).map((s) => s.name),
215
+ deferred: steps.filter((s) => s.deferred).map((s) => ({ name: s.name, reason: s.reason })),
216
+ },
217
+ null,
218
+ 2,
219
+ ),
220
+ { mode: 0o600 },
221
+ );
222
+ } catch {
223
+ // Progress is best effort; the final summary below is authoritative.
224
+ }
225
+ };
226
+ try {
227
+ return await gatherSteps();
228
+ } finally {
229
+ process.removeListener("SIGHUP", ignoreHangup);
230
+ }
231
+ async function gatherSteps() {
193
232
  const step = (name, args, file) => {
194
233
  const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
195
234
  steps.push({
@@ -203,6 +242,7 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
203
242
  reused: Boolean(res.reused),
204
243
  ...(res.deferred ? { deferred: true, reason: res.reason } : res.ok ? {} : { error: res.json?.error || res.stderr }),
205
244
  });
245
+ writeProgress();
206
246
  return res;
207
247
  };
208
248
  step("energy-budget", ["energy-budget"], "energy-budget.json");
@@ -411,6 +451,8 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
411
451
  if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
412
452
 
413
453
  const summary = {
454
+ // The collection finished (failed steps are listed separately).
455
+ inProgress: false,
414
456
  collectedAt: new Date().toISOString(),
415
457
  durationMs: Math.round(performance.now() - startedAt),
416
458
  dir,
@@ -460,6 +502,7 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
460
502
  status: summary.failed.length ? "partial_failure" : summary.deferred.length ? "deferred" : "success",
461
503
  data: summary,
462
504
  };
505
+ }
463
506
  }
464
507
 
465
508
  // Recent, provably never-Featured Subjects with the context the editorial
@@ -0,0 +1,247 @@
1
+ // `lumine admin member-trace ...`: the owner trace switched on for specific
2
+ // member accounts, so a bug one member reports (and Mikey cannot reproduce)
3
+ // can be read the way his own are. The server keeps 30 days.
4
+ //
5
+ // lumine admin member-trace list
6
+ // lumine admin member-trace on <username> --reason "<why>"
7
+ // lumine admin member-trace off <username>
8
+ // lumine admin member-trace <username> [--since 2h] [--source all|client|server] [...]
9
+ // (`member-trace read <username>` is the same, for a member named list/on/off/read)
10
+
11
+ import { buildOwnerTraceOperation, formatOwnerTrace } from "./admin-owner-trace.js";
12
+
13
+ export const MEMBER_TRACE_USAGE =
14
+ "Usage: lumine admin member-trace list | on <username> --reason <why> | off <username> | [read] <username> [--since 2h|30m|3d|<iso>|<unix>] [--until <unix-second>] [--cursor <time:id>] [--source all|client|server] [--type reload,route,chat-select,error,api,...] [--path <substring>] [--limit 300] [--json].";
15
+
16
+ const ACTIONS = new Set(["list", "on", "off", "read"]);
17
+ const SOURCES = new Set(["all", "client", "server"]);
18
+
19
+ function validationError(message) {
20
+ const error = new Error(message);
21
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
22
+ return error;
23
+ }
24
+
25
+ function memberName(value) {
26
+ const name = String(value || "").trim().replace(/^@/, "");
27
+ if (!name) throw validationError(MEMBER_TRACE_USAGE);
28
+ if (!/^[^\s/?#]{1,40}$/.test(name)) {
29
+ throw validationError("Name the member by username or user id.");
30
+ }
31
+ return name;
32
+ }
33
+
34
+ export function buildMemberTraceOperation({ action = "", target = "", options }) {
35
+ if (action === "list") {
36
+ return {
37
+ name: "member-trace.list",
38
+ method: "GET",
39
+ path: "/cli/admin/member-trace/accounts",
40
+ body: undefined,
41
+ mutates: false,
42
+ requiresRun: false,
43
+ };
44
+ }
45
+ if (action === "on") {
46
+ const reason = String(options.adminReason || "").trim();
47
+ if (!reason) {
48
+ throw validationError(
49
+ 'Say why with --reason (for example --reason "General chat main keeps reloading").',
50
+ );
51
+ }
52
+ return {
53
+ name: "member-trace.on",
54
+ method: "POST",
55
+ path: "/cli/admin/member-trace/on",
56
+ body: { user: memberName(target), reason: reason.slice(0, 255) },
57
+ mutates: true,
58
+ requiresRun: false,
59
+ };
60
+ }
61
+ if (action === "off") {
62
+ return {
63
+ name: "member-trace.off",
64
+ method: "POST",
65
+ path: "/cli/admin/member-trace/off",
66
+ body: { user: memberName(target) },
67
+ mutates: true,
68
+ requiresRun: false,
69
+ };
70
+ }
71
+ const user = memberName(action === "read" ? target : action);
72
+ if (!ACTIONS.has(action) && target) {
73
+ throw validationError(MEMBER_TRACE_USAGE);
74
+ }
75
+ // the owner trace's filters (--since/--until/--cursor/--type/--path/--limit)
76
+ const owner = buildOwnerTraceOperation(options);
77
+ const query = new URLSearchParams(owner.path.split("?")[1] || "");
78
+ query.set("user", user);
79
+ const source = String(options.adminTraceSource || "").trim();
80
+ if (source) {
81
+ if (!SOURCES.has(source)) {
82
+ throw validationError("--source is all, client (the website on the device) or server (the API's view of its requests).");
83
+ }
84
+ query.set("source", source);
85
+ }
86
+ return {
87
+ name: "member-trace.read",
88
+ method: "GET",
89
+ path: `/cli/admin/member-trace?${query.toString()}`,
90
+ body: undefined,
91
+ mutates: false,
92
+ requiresRun: false,
93
+ };
94
+ }
95
+
96
+ function formatWhen(seconds) {
97
+ const value = Number(seconds);
98
+ if (!value) return "never";
99
+ return new Date(value * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC";
100
+ }
101
+
102
+ export function formatMemberTraceList(data) {
103
+ const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
104
+ if (!accounts.length) return ["No member is traced."];
105
+ const lines = [`${accounts.length} traced member account(s):`];
106
+ for (const account of accounts) {
107
+ lines.push(
108
+ ` ${account.username || "?"} (#${account.userId}) · on since ${formatWhen(account.enabledAt)}${account.enabledByUsername ? ` by ${account.enabledByUsername}` : ""} · ${account.reason || "(no reason)"}`,
109
+ );
110
+ lines.push(
111
+ ` last 24 h: ${account.clientRows24h ?? "?"} website event(s) (last ${formatWhen(account.lastClientAt)}), ${account.serverRows24h ?? "?"} API request row(s) (last ${formatWhen(account.lastServerAt)})`,
112
+ );
113
+ }
114
+ return lines;
115
+ }
116
+
117
+ export function formatMemberTraceSwitch(data) {
118
+ const who = `${data?.username || "?"} (#${data?.userId ?? "?"})`;
119
+ if (data?.enabled) {
120
+ return [
121
+ data.alreadyOn
122
+ ? `Member trace was already on for ${who}.`
123
+ : `Member trace is on for ${who}. Every API process picks it up within ${data.effectiveWithinSeconds ?? 30} s; the website starts tracing at the member's next page load.`,
124
+ ];
125
+ }
126
+ return [
127
+ data?.wasOn
128
+ ? `Member trace is off for ${who}. Rows already kept expire after ${data.keptRowsExpireAfterDays ?? 30} days.`
129
+ : `Member trace was not on for ${who}.`,
130
+ ];
131
+ }
132
+
133
+ function count(map, key) {
134
+ map.set(key, (map.get(key) || 0) + 1);
135
+ }
136
+
137
+ function listCounts(map) {
138
+ return [...map.entries()]
139
+ .sort((a, b) => b[1] - a[1])
140
+ .map(([key, n]) => `${key} ×${n}`)
141
+ .join(", ");
142
+ }
143
+
144
+ // The plain-words summary the daily run reports: which website build the
145
+ // device runs (as the website says and as the API saw it), page loads and how
146
+ // they were opened, self-reloads by reason, errors.
147
+ export function summarizeMemberTrace(data) {
148
+ const events = Array.isArray(data?.events) ? data.events : [];
149
+ const client = events.filter((event) => event.type !== "api" && event.type !== "api-dropped");
150
+ const server = events.filter((event) => event.type === "api" || event.type === "api-dropped");
151
+ const versions = new Map();
152
+ const bundles = new Map();
153
+ const navTypes = new Map();
154
+ const reloads = new Map();
155
+ const errors = new Map();
156
+ const loadCauses = new Map();
157
+ let socketDrops = 0;
158
+ let socketDropsVisible = 0;
159
+ let chatLoadingScreens = 0;
160
+ for (const event of client) {
161
+ const data = event.data || {};
162
+ if (event.type === "session") {
163
+ count(versions, data.version || "?");
164
+ if (data.entry) count(bundles, data.entry);
165
+ count(navTypes, data.navType || "?");
166
+ } else if (event.type === "boot") {
167
+ count(
168
+ loadCauses,
169
+ data.byCode ? `code:${data.byCode}` : data.on ? "tracing switched on" : data.navType === "reload" ? "reload by hand/browser" : data.navType || "?",
170
+ );
171
+ } else if (event.type === "socket" && data.e === "disconnect") {
172
+ socketDrops += 1;
173
+ if (data.vis === "visible") socketDropsVisible += 1;
174
+ } else if (event.type === "chat-loading" && data.e === "shown") {
175
+ chatLoadingScreens += 1;
176
+ } else if (event.type === "reload") {
177
+ count(reloads, data.reason || "?");
178
+ } else if (event.type === "error" || event.type === "rejection" || event.type === "boundary-error") {
179
+ count(errors, `${event.type}: ${String(data.msg || "").slice(0, 80)}`);
180
+ }
181
+ }
182
+ const apiVersions = new Map();
183
+ const statuses = new Map();
184
+ const devices = new Set();
185
+ let sessionLoads = 0;
186
+ for (const event of server) {
187
+ const data = event.data || {};
188
+ if (event.type !== "api") continue;
189
+ count(apiVersions, data.v || "?");
190
+ devices.add(event.sessionId);
191
+ const status = data.s === "aborted" ? "aborted" : Number(data.s) >= 400 ? String(data.s) : null;
192
+ if (status) count(statuses, status);
193
+ if (String(event.path || "").startsWith("/user/session") && !String(event.path).startsWith("/user/session/")) {
194
+ sessionLoads += 1;
195
+ }
196
+ }
197
+ const lines = [];
198
+ lines.push(
199
+ `Website: ${client.filter((event) => event.type === "session").length} page load(s)${navTypes.size ? ` (opened by ${listCounts(navTypes)})` : ""}${versions.size ? ` · version ${listCounts(versions)}` : ""}${bundles.size ? ` · bundle ${listCounts(bundles)}` : ""}`,
200
+ );
201
+ if (loadCauses.size) lines.push(`Why pages loaded: ${listCounts(loadCauses)}`);
202
+ lines.push(`Self-reloads: ${reloads.size ? listCounts(reloads) : "none recorded"}`);
203
+ lines.push(
204
+ `Socket drops: ${socketDrops}${socketDrops ? ` (${socketDropsVisible} while the page was visible)` : ""} · full chat loading screens: ${chatLoadingScreens}`,
205
+ );
206
+ lines.push(`Errors: ${errors.size ? listCounts(errors) : "none recorded"}`);
207
+ lines.push(
208
+ `API view: ${server.length} request row(s) from ${devices.size} device(s)${apiVersions.size ? ` · reported website version ${listCounts(apiVersions)}` : ""} · session loads ${sessionLoads}${statuses.size ? ` · failures ${listCounts(statuses)}` : ""}`,
209
+ );
210
+ if (!client.length && server.length) {
211
+ lines.push(
212
+ " No website events: the device has not run a website build with the member trace yet (an older cached bundle?), or it has not loaded since tracing was switched on.",
213
+ );
214
+ }
215
+ return lines;
216
+ }
217
+
218
+ export function formatMemberTrace(data) {
219
+ const user = data?.user || {};
220
+ const who = `${user.username || "?"} (#${user.id ?? "?"})`;
221
+ const lines = [
222
+ `Member trace for ${who}${user.traced ? `, on since ${formatWhen(user.enabledAt)}: ${user.reason || ""}` : ", tracing is OFF (older rows only)"}${data?.source && data.source !== "all" ? ` · ${data.source} rows only` : ""}${data?.duplicates ? ` · ${data.duplicates} re-sent duplicate(s) hidden` : ""}`,
223
+ ];
224
+ for (const line of summarizeMemberTrace(data)) lines.push(` ${line}`);
225
+ const events = Array.isArray(data?.events) ? data.events : [];
226
+ const client = events.filter((event) => event.type !== "api" && event.type !== "api-dropped");
227
+ const server = events.filter((event) => event.type === "api" || event.type === "api-dropped");
228
+ if (data?.source !== "server") {
229
+ lines.push("");
230
+ lines.push(...formatOwnerTrace({ ...data, events: client }, { title: "Website events (from the device)" }));
231
+ }
232
+ if (data?.source !== "client") {
233
+ lines.push("");
234
+ lines.push(...formatOwnerTrace({ ...data, events: server }, { title: "API view (requests from this account; session = device tag)" }));
235
+ }
236
+ return lines;
237
+ }
238
+
239
+ export function printMemberTraceResult(operation, data) {
240
+ const lines =
241
+ operation.name === "member-trace.list"
242
+ ? formatMemberTraceList(data)
243
+ : operation.name === "member-trace.read"
244
+ ? formatMemberTrace(data)
245
+ : formatMemberTraceSwitch(data);
246
+ for (const line of lines) console.log(line);
247
+ }
package/lib/admin-news.js CHANGED
@@ -252,6 +252,20 @@ export function validateNewsEditorial({ claim: claimValue, editorial }) {
252
252
  export function writeNewsClaimArtifacts({ result, outputPath, scaffoldPath }) {
253
253
  const claim = result?.data?.claim || null;
254
254
  const artifacts = { claimFile: null, scaffoldFile: null };
255
+ if (!claim) {
256
+ // Nothing to claim (the edition already printed, or the next one has not
257
+ // opened yet). Never overwrite an existing claim file with a null claim:
258
+ // that destroyed a valid claim when a run tried too early (11-01, 11-02).
259
+ const nextEditionAt = Number(result?.data?.newspaper?.nextEditionAt) || null;
260
+ artifacts.notClaimed = {
261
+ status: result?.status || null,
262
+ printDecision: result?.data?.newspaper?.printDecision || null,
263
+ nextEditionAt,
264
+ nextEditionAtIso: nextEditionAt ? new Date(nextEditionAt * 1000).toISOString() : null,
265
+ outputLeftUnchanged: outputPath || null,
266
+ };
267
+ return artifacts;
268
+ }
255
269
  if (outputPath) {
256
270
  artifacts.claimFile = writeAdminJsonFile(
257
271
  outputPath,
@@ -129,6 +129,7 @@ function describeSession(data = {}, path = "") {
129
129
  : data.navType
130
130
  ? `nav ${data.navType}`
131
131
  : "",
132
+ data.entry ? `bundle ${data.entry}` : "",
132
133
  describeNet(data.net),
133
134
  ].filter(Boolean);
134
135
  if (path) parts.push(`at ${path}`);
@@ -186,6 +187,24 @@ export function describeOwnerTraceEvent(event) {
186
187
  case "error":
187
188
  case "rejection":
188
189
  return `${data.msg || "(no message)"}${data.src ? ` (${data.src}:${data.line}:${data.col})` : ""}${data.status ? ` HTTP ${data.status}` : ""}`;
190
+ // the member trace's events (and the reload/chat ones the owner trace
191
+ // now records too)
192
+ case "reload":
193
+ return `SELF-RELOAD ${data.reason || "?"}${pairs(data, ["reason", "v"]) ? ` · ${pairs(data, ["reason", "v"])}` : ""}`;
194
+ case "chat-select":
195
+ return `channel ${data.channelId ?? "?"}${data.subchannel ? `/${data.subchannel}` : ""}${data.subchannelId ? ` (subchannel ${data.subchannelId})` : ""}${data.pathId ? ` · path ${data.pathId}` : ""}${data.chatType ? ` · ${data.chatType}` : ""}`;
196
+ case "boot":
197
+ return `page loaded${data.navType ? ` (${data.navType})` : ""}${data.byCode ? ` · reloaded by code: ${data.byCode} ${data.byCodeAgoMs ?? "?"}ms earlier${data.byCodeAt ? ` at ${data.byCodeAt}` : ""}` : data.navType === "reload" ? " · no code reload note (by hand or by the browser)" : ""}${data.kept ? ` · ${data.kept} batch(es) kept from an earlier page` : ""}${data.on ? ` · tracing switched on (${data.on})` : ""}`;
198
+ case "socket":
199
+ return `socket ${data.e || "?"}${data.reason ? ` (${data.reason})` : ""} · ${data.vis || "?"}${data.visibleForMs !== undefined ? ` ${formatDuration(data.visibleForMs)} after the page became visible` : ""}${data.upMs !== undefined ? ` · was up ${formatDuration(data.upMs)}` : ""}${data.downMs !== undefined ? ` · was down ${formatDuration(data.downMs)}` : ""}`;
200
+ case "chat-loading":
201
+ return data.e === "shown" ? 'full "Loading Twinkle Chat..." screen shown' : `full chat loading screen gone after ${formatDuration(data.ms)}`;
202
+ case "boundary-error":
203
+ return `error screen at ${data.at || "?"}: ${data.msg || "(no message)"}${data.lazy ? " · chunk load" : ""}${data.truncated ? " · truncated chunk" : ""}`;
204
+ case "api":
205
+ return describeApiRow(data, event.path);
206
+ case "api-dropped":
207
+ return `${data.count ?? "?"} more API request(s) in that minute not kept (cap)`;
189
208
  default:
190
209
  return pairs(data);
191
210
  }
@@ -251,6 +270,16 @@ function describeWake(data) {
251
270
  return parts.join(" · ");
252
271
  }
253
272
 
273
+ // One request the API saw from a traced member: method, route, status, time,
274
+ // the website version the request reported.
275
+ function describeApiRow(data) {
276
+ const params = isPlainObject(data.p)
277
+ ? ` ${Object.entries(data.p).map(([key, value]) => `${key}=${value}`).join(",")}`
278
+ : "";
279
+ const server = [data.h ? `host ${data.h}` : "", data.pst ? `worker since ${new Date(data.pst * 1000).toISOString().slice(11, 19)}Z` : ""].filter(Boolean).join(" ");
280
+ return `${data.m || "?"} → ${data.s ?? "?"} in ${data.ms ?? "?"}ms · website v${data.v || "?"}${params}${server ? ` · ${server}` : ""}${data.ua ? ` · ua ${data.ua}` : ""}`;
281
+ }
282
+
254
283
  function isPlainObject(value) {
255
284
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
256
285
  }
@@ -336,13 +365,13 @@ function describeNavTiming(data) {
336
365
  return [head, `${data.nav || "?"} via ${data.via || "?"}`, ...steps, ...tail].join(" · ");
337
366
  }
338
367
 
339
- export function formatOwnerTrace(data) {
368
+ export function formatOwnerTrace(data, { title = "Owner trace" } = {}) {
340
369
  const events = Array.isArray(data?.events) ? data.events : [];
341
370
  const lines = [];
342
371
  const sessions = new Set(events.map((event) => event.sessionId));
343
372
  const since = Number(data?.since || 0);
344
373
  lines.push(
345
- `Owner trace since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ? ` · more pages (keep --since/--until; --cursor ${data.nextCursor || "unavailable"})` : ""}`,
374
+ `${title} since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ? ` · more pages (keep --since/--until; --cursor ${data.nextCursor || "unavailable"})` : ""}`,
346
375
  );
347
376
  if (!events.length) {
348
377
  lines.push("Nothing recorded in this window.");
package/lib/admin.js CHANGED
@@ -44,6 +44,10 @@ import {
44
44
  buildOwnerTraceOperation,
45
45
  printOwnerTrace,
46
46
  } from "./admin-owner-trace.js";
47
+ import {
48
+ buildMemberTraceOperation,
49
+ printMemberTraceResult,
50
+ } from "./admin-member-trace.js";
47
51
  import { buildRolesOperation, printRoles } from "./admin-roles.js";
48
52
  import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
49
53
  import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
@@ -1872,6 +1876,7 @@ function transformAdminResult({
1872
1876
  }
1873
1877
  if (operation.name === "subjects.candidates") {
1874
1878
  assertSubjectWindowResult({ operation, result: transformed });
1879
+ transformed = markSubjectsNeedingReveal(transformed);
1875
1880
  }
1876
1881
  if (operation.name === "builds.candidates") {
1877
1882
  assertBuildWindowResult({ operation, result: transformed });
@@ -1923,6 +1928,30 @@ export function assertRecommendationWindowResult({ operation, result }) {
1923
1928
  }
1924
1929
  }
1925
1930
 
1931
+ // A secret Subject's effort can't be set until its secret is revealed through
1932
+ // the canonical action (CLI_ADMIN_SECRET_REVEAL_REQUIRED). Say so on the
1933
+ // candidate itself, with the exact command, instead of failing at effort set.
1934
+ export function markSubjectsNeedingReveal(result) {
1935
+ const subjects = result?.data?.subjects;
1936
+ if (!Array.isArray(subjects)) return result;
1937
+ return {
1938
+ ...result,
1939
+ data: {
1940
+ ...result.data,
1941
+ subjects: subjects.map((subject) => {
1942
+ const needsReveal =
1943
+ (subject?.secret?.hasSecret === true ||
1944
+ subject?.hasSecretAnswer === true ||
1945
+ subject?.hasSecretAttachment === true) &&
1946
+ subject?.secret?.revealed !== true;
1947
+ return needsReveal
1948
+ ? { ...subject, needsReveal: true, revealCommand: `lumine admin subject reveal ${subject.id}` }
1949
+ : subject;
1950
+ }),
1951
+ },
1952
+ };
1953
+ }
1954
+
1926
1955
  export function assertSubjectWindowResult({ operation, result }) {
1927
1956
  const mode = operation?.pagination?.coverageMode;
1928
1957
  if (!mode || mode === "legacy") return;
@@ -2714,6 +2743,10 @@ export function parseAdminOperation(options) {
2714
2743
  if (namespace === "owner-trace") {
2715
2744
  return buildOwnerTraceOperation(options);
2716
2745
  }
2746
+ // The owner trace switched on for specific members (admin-member-trace.js).
2747
+ if (namespace === "member-trace") {
2748
+ return buildMemberTraceOperation({ action, target, options });
2749
+ }
2717
2750
  if (namespace === "jev-routing") {
2718
2751
  return buildJevRoutingReviewOperation({ action, target, extra, options });
2719
2752
  }
@@ -6199,9 +6232,10 @@ async function printSpooledAdminResult({ operation, result, storage }) {
6199
6232
  console.log(`${count} subject(s):`);
6200
6233
  await forEachPaginatedResultItem(result, async (subject) => {
6201
6234
  console.log(
6202
- `#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}`,
6235
+ `#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}${subject.needsReveal ? " — secret: reveal before setting effort" : ""}`,
6203
6236
  );
6204
6237
  console.log(` ${subject.url}`);
6238
+ if (subject.needsReveal) console.log(` ${subject.revealCommand}`);
6205
6239
  });
6206
6240
  } else if (storage.collectionKey === "events") {
6207
6241
  console.log(`${count} audit event(s):`);
@@ -7691,6 +7725,10 @@ function printAdminResult({ operation, result }) {
7691
7725
  printOwnerTrace(data);
7692
7726
  return;
7693
7727
  }
7728
+ if (String(operation.name || "").startsWith("member-trace.")) {
7729
+ printMemberTraceResult(operation, data);
7730
+ return;
7731
+ }
7694
7732
  if (operation.name === "jevRouting.review") {
7695
7733
  for (const line of formatJevRoutingReview(data)) console.log(line);
7696
7734
  return;
@@ -8227,9 +8265,10 @@ function printAdminResult({ operation, result }) {
8227
8265
  console.log(`${data.subjects.length} subject(s):`);
8228
8266
  for (const subject of data.subjects) {
8229
8267
  console.log(
8230
- `#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}`,
8268
+ `#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}${subject.needsReveal ? " — secret: reveal before setting effort" : ""}`,
8231
8269
  );
8232
8270
  console.log(` ${subject.url}`);
8271
+ if (subject.needsReveal) console.log(` ${subject.revealCommand}`);
8233
8272
  }
8234
8273
  printPagination(data.pagination);
8235
8274
  return;
@@ -8248,6 +8287,19 @@ function printAdminResult({ operation, result }) {
8248
8287
  printPagination(data.pagination);
8249
8288
  return;
8250
8289
  }
8290
+ if (data.artifacts?.notClaimed) {
8291
+ const info = data.artifacts.notClaimed;
8292
+ const opens = info.nextEditionAt
8293
+ ? `${new Date(info.nextEditionAt * 1000).toISOString().replace(".000Z", "Z")} (${new Date(info.nextEditionAt * 1000).toLocaleString("en-US", { timeZone: "Asia/Seoul", hour: "2-digit", minute: "2-digit", month: "short", day: "numeric" })} KST)`
8294
+ : "an unknown time";
8295
+ console.log(
8296
+ `No edition to claim${info.printDecision === "already_printed" ? ": today's paper is already printed" : ""}. The next edition opens at ${opens}.`,
8297
+ );
8298
+ if (info.outputLeftUnchanged) {
8299
+ console.log(`${info.outputLeftUnchanged} was left unchanged. Run news claim again after the edition opens.`);
8300
+ }
8301
+ return;
8302
+ }
8251
8303
  if (data.claim) {
8252
8304
  if (data.artifacts?.claimFile) {
8253
8305
  console.log(
package/lib/commands.js CHANGED
@@ -2701,6 +2701,8 @@ export function parseArgs(args) {
2701
2701
  adminPath: raw.path ? String(raw.path) : "",
2702
2702
  adminPerf: Boolean(raw.perf),
2703
2703
  adminWake: Boolean(raw.wake),
2704
+ // lumine admin member-trace <username> --source all|client|server
2705
+ adminTraceSource: raw.source ? String(raw.source) : "",
2704
2706
  adminMinLevel: raw.minLevel === undefined ? null : raw.minLevel,
2705
2707
  adminIncludeLegacy: Boolean(raw.includeLegacy),
2706
2708
  adminIncludePrivateEvidence: parseBoolean(
@@ -3405,6 +3407,7 @@ export function printHelp() {
3405
3407
  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)
3406
3408
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3407
3409
  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)
3410
+ lumine admin member-trace list | on <username> --reason <why> | off <username> | [read] <username> [--since 2h|30m|3d|<iso>] [--source all|client|server] [--type reload,route,chat-select,boot,session,error,rejection,boundary-error,api] [--path <substring>] [--limit 300] [--json] (the owner trace switched on for specific member accounts to chase a bug they report: on/off are audited admin actions and take effect within 30 s; the website side records page loads with version and bundle, routes, chat channel/subchannel selection, every self-reload with its reason (kept on the device across the reload), error screens and window errors; the API side records each request from that account with its route, status, time and the website version it reported, so a device stuck on an old cached bundle still shows; read prints a plain summary first; kept 30 days)
3408
3411
  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)
3409
3412
  lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] [--build <id>, sdk_decide only, no --daily-cap] | decide --area <area> --key <subject key> --decision <decision> | thumbnails --targets <subject:<id>,build:<id>,...> | --featured [--saved-only] [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy; thumbnails adds notes for Featured review and never replaces or unfeatures anything)
3410
3413
  lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements and Auto fallbacks; collection is not review)
@@ -1,4 +1,4 @@
1
1
  // Generated by twinkle-api/scripts/generate-build-sdk-index.mjs from
2
2
  // constants/buildSdkManifest.json (platformRules, essentialSdk). Do not edit by hand.
3
3
  export const LUMINE_PLATFORM_RULES = "Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.\n\n1. Every page that draws on a <canvas> (2D, WebGL or Three.js) says what kind of page it is on <html>: data-twinkle-game for a game that fills the screen, data-twinkle-document for a normal scrolling page with a drawing pad, chart, visualizer or background effect. Without it, unless the page lays itself out with Twinkle.preview, Twinkle guesses from the canvas, and any canvas larger than 160 px, even one hidden in a tab, locks the page to one screen so it no longer scrolls. Add the attribute together with the first canvas.\n2. Games (data-twinkle-game) size themselves from Twinkle.preview: subscribe with Twinkle.preview.subscribe(listener, { immediate: true }), size canvases and renderers from layout.stage or layout.viewport, and report the play area with Twinkle.preview.setPlayfield(). Never size a game from 100vh/100vw, window.innerWidth/innerHeight or visualViewport, and never draw a fake phone or browser frame unless the creator asks for a device mockup: those fight the host and leave the game blank, cropped or scrolling on phones. Pages marked data-twinkle-document use ordinary CSS layout instead.\n3. alert(), confirm() and prompt() are silently blocked: alert shows nothing, confirm() always returns false (so the action it guards never happens) and prompt() returns null. Use in-page UI instead: a message element, an in-page confirm step, an <input>.\n4. Apps cannot open new windows or tabs: window.open() and target=\"_blank\" links do nothing, and neither does a download link to an uploaded file. Open Twinkle pages with Twinkle.app.openContent(url) from a tap, save files with Twinkle.files.saveAs({ url, fileName }) (or { blob, fileName } for a file the app made), and show other web addresses as plain text.\n5. Do not use <form> elements: native form submission is blocked, so a submit button inside a form does nothing. Handle inputs and buttons in JavaScript.\n6. Code loads only from project files and Twinkle's vendor paths (/build/vendor/...). Package imports, CDN scripts, external network calls and app-local /api/* routes fail at runtime; use the Twinkle SDK for data, AI and storage.\n7. Call only Twinkle.* methods documented in the SDK reference. An invented or guessed method name throws and breaks the feature.\n8. Twinkle.privateDb and Twinkle.sharedDb save on a user action or as an occasional snapshot, never per frame, tick or keystroke: fast repeated writes are refused with 429 and the data is lost. A privateDb value is at most 16 KB. Keep live state in memory, and use Twinkle.world for realtime multiplayer.\n9. Start sound and image generation inside the viewer's tap or click handler. A Web Audio AudioContext stays silent until it is created or resumed in a tap (iPhones always enforce this), and Twinkle.ai.generateImage called from load or a timer fails with USER_ACTIVATION_REQUIRED.\n10. Call setPointerCapture only inside a pointerdown handler with that event's pointerId, wrapped in try/catch, and release only when hasPointerCapture says the element holds it. From click, mouse, timer or synthetic handlers it throws and the control stops working.\n11. Keyboard games call preventDefault for their keys (Space, arrows, WASD) from a window or document keydown listener added with { passive: false }, skipping inputs, textareas, selects and contenteditable, and make the playfield focusable; otherwise those keys scroll or jump the page during play.\n12. On phones a long press on game controls selects text or opens the Copy/Look Up menu unless the whole game area (controls, HUD, menus, empty play space) is marked data-twinkle-no-select and styled user-select: none; -webkit-user-select: none; -webkit-touch-callout: none. A game drawn without a canvas sets data-twinkle-game on <html> too, so Twinkle's phone touch protections apply. Keep inputs and copyable text selectable with data-twinkle-selectable.";
4
- export const LUMINE_ESSENTIAL_SDK = "Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.\n\n1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.\n2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.\n3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.\n4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.";
4
+ export const LUMINE_ESSENTIAL_SDK = "Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.\n\n1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view or tab, a long page the viewer scrolls. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState, { work: [...] }) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). The snapshot holds where the viewer is looking (scroll, open tab or view, selected item) and unfinished work (a game or level in play, a half-written answer, an unsaved drawing, a quiz in progress); list the work keys in { work }. A return (the phone unloaded the app, a reload, a quick switch away) gets everything back; a new visit (the next day, a new tab) starts at the app's start and gets only the work keys. Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.\n2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.\n3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.\n4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.78",
3
+ "version": "0.3.80",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.77.0
4
- Updated: 2026-11-01
5
- Generated: 2026-11-01T12:15:43.879Z
3
+ Version: 1.80.0
4
+ Updated: 2026-11-02
5
+ Generated: 2026-11-02T01:36:03.543Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -23,7 +23,7 @@ Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed f
23
23
  ## Essential SDK features
24
24
  Features every app needs whenever they apply. When you build a new app or make a substantial change, add every one that applies as part of that change, without being asked. On a small, targeted request, add a missing one only when the request touches what it covers (a change to levels, scoring or saving: keep-place; a new screen: back-button); otherwise name it in one line so the creator can ask. Say in one line what you added. Never add one that does not apply.
25
25
 
26
- 1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.
26
+ 1. When: The app has a place worth keeping: a game, a level or score in play, an open document, drawing or editor, a quiz in progress, a chosen view or tab, a long page the viewer scrolls. Use: Register Twinkle.app.lifecycle.onSuspend(() => smallState, { work: [...] }) and, at startup, restore from await Twinkle.app.lifecycle.getResumeState(). The snapshot holds where the viewer is looking (scroll, open tab or view, selected item) and unfinished work (a game or level in play, a half-written answer, an unsaved drawing, a quiz in progress); list the work keys in { work }. A return (the phone unloaded the app, a reload, a quick switch away) gets everything back; a new visit (the next day, a new tab) starts at the app's start and gets only the work keys. Phones unload apps that are not on screen (3D apps right away) and browsers kill tabs; without this the player starts over every time.
27
27
  2. When: The app has more than one view: a list and its details, menus, chapters, pages, settings. Use: Twinkle.app.history.push on each confirmed view change and Twinkle.app.history.subscribe to restore views, so Back and Forward move inside the app instead of leaving it.
28
28
  3. When: Progress, collections, unlocks, high scores or settings the viewer expects to still have tomorrow. Use: Twinkle.privateDb (Twinkle.sharedDb for what everyone sees), written on a user action or as an occasional snapshot, never per frame.
29
29
  4. When: The app shares or links to something inside itself (a level, a post, a result). Use: Twinkle.app.getShareUrl(pathSegment) for the link, and read the segment back at startup, instead of building twin-kle.com URLs by hand.
@@ -128,21 +128,32 @@ files:read, media:read, media:write, live:read, live:write, user:read, users:rea
128
128
  - Returns: Canonical shareable deep-link URL string, or null when app info is unavailable
129
129
  - Builds a canonical shareable deep link into this app, e.g. https://www.twin-kle.com/app/884/432-the-great-gatsby.
130
130
  - Example: await Twinkle.app.getShareUrl('432-the-great-gatsby');
131
- - lifecycle.onSuspend(getState) | scopes: none
131
+ - lifecycle.onSuspend(getState, { work }) | scopes: none
132
132
  - Returns: off function
133
- - Keep the viewer's place: Twinkle calls getState() for a small snapshot whenever the app is hidden, goes to the background, or every so often while in use, and hands it back the next time the app starts.
133
+ - Keep the viewer's place: Twinkle calls getState() for a small snapshot whenever the app is hidden, goes to the background, or every so often while in use. A return gets all of it back; a new visit gets only the keys listed in work.
134
134
  - Phones keep few apps loaded: an app that is not on screen can be unloaded at any time (3D apps right away), and the browser can kill the whole tab. Without a snapshot the viewer starts over.
135
- - getState() returns a plain JSON object of at most 1 MB and must be quick (a 1.5 s budget, sync or a promise). Save what is needed to put the viewer back: level, position, score in play, the open view, unsaved edits, a quiz in progress.
136
- - One handler per app; registering again replaces it. Long-term saves (collections, completed progress) still belong in Twinkle.privateDb.
137
- - After a restart with a saved place, Twinkle keeps that place until the viewer does something in the app (a tap or key): snapshots taken while the app is still loading or waiting on a CONTINUE screen are not sent. Call saveNow() if the app changes the place on its own before that.
138
- - Example: Twinkle.app.lifecycle.onSuspend(() => ({ level, hp, x: player.x, y: player.y, inventory }));
135
+ - Return or new visit: a start is a RETURN when the snapshot was taken in the same browser tab less than 30 minutes before (the phone unloaded the app, a reload, a quick switch away) and gets the whole snapshot back. Anything else is a NEW VISIT (the next day, after a long gap, a new tab): the app opens at its start, at the top, and gets back only the keys listed in work.
136
+ - work: the top-level snapshot keys that are unfinished work the viewer would be sorry to lose: a game or level in play, a half-written answer or draft, an unsaved drawing, a quiz in progress. Use work: true when the whole snapshot is that. Leave out where the viewer was looking (scroll position, open tab or view, selected article): it only comes back on a return. With no work option a new visit starts fresh.
137
+ - When the work is done (level won, answer sent, drawing saved) return that key as null: the next visit then starts fresh.
138
+ - getState() returns a plain JSON object of at most 1 MB and must be quick (a 1.5 s budget, sync or a promise). Save ids and small values, not whole data sets.
139
+ - One handler per app; registering again replaces it (with its work option). Long-term saves (collections, completed progress, settings) still belong in Twinkle.privateDb.
140
+ - After a restart with a saved place, Twinkle keeps that place until the viewer does something in the app (a tap, key or wheel scroll): snapshots taken while the app is still loading or waiting on a CONTINUE screen are not sent. Call saveNow() if the app changes the place on its own before that.
141
+ - Example: Twinkle.app.lifecycle.onSuspend(() => ({ scrollY: Math.round(window.scrollY), tab, game: inPlay ? { level, hp, x: player.x, y: player.y } : null }), { work: ['game'] });
139
142
  - async lifecycle.getResumeState() | scopes: none
140
- - Returns: { state, savedAt, reason } or null
141
- - At startup, the snapshot this viewer's last session of the app left behind (after an unload, a tab reload or crash), or null.
143
+ - Returns: { kind: 'return' | 'new_visit', state, savedAt, ageMs, reason } or null
144
+ - At startup, what the viewer's last session of the app left: on a return the whole snapshot, on a new visit only its work keys, or null to start fresh.
142
145
  - Resolves once, early; await it before drawing the first screen so the viewer does not see a fresh start flash by.
143
- - Check the snapshot's shape before using it: it can come from an older version of the app. Ignore what you do not recognise.
146
+ - kind 'return': the same browser tab within 30 minutes (unloaded, reloaded, quick switch): state is the whole snapshot, so put the viewer back exactly, scroll included. kind 'new_visit': state holds only the work keys; open at the start (top of the page, first tab) and bring the unfinished work back. null: nothing to restore, start fresh.
147
+ - Restore from whatever saved.state contains: Twinkle has already left the scroll and view out on a new visit, so the usual if (saved) restore(saved.state) is right for both kinds. Check kind only to show something different, such as a 'Continue your game?' card on a new visit.
148
+ - Check the snapshot's shape before using it: it can come from an older version of the app, and on a new visit only work keys are there. Ignore what you do not recognise.
144
149
  - Closing the app with its Close button starts it fresh next time (no snapshot). The Build editor preview always starts fresh.
145
- - Example: const saved = await Twinkle.app.lifecycle.getResumeState(); if (saved) restore(saved.state);
150
+ - Example: const saved = await Twinkle.app.lifecycle.getResumeState(); if (saved) restore(saved.state); // scroll and view are only in state on a return
151
+ - lifecycle.onResume(handler) | scopes: none
152
+ - Returns: off function
153
+ - Hear when the app comes back on screen without restarting (Twinkle kept it loaded, or the browser froze the page and thawed it): handler({ kind: 'return' | 'new_visit', awayMs }).
154
+ - Same rule as a restart: away less than 30 minutes is a return (leave everything as it is); longer is a new visit, so do what a fresh start would: back to the start and the top, unfinished work kept.
155
+ - Called each time the app is back on screen after being hidden or in the background; not at startup (use getResumeState() there).
156
+ - Example: Twinkle.app.lifecycle.onResume(({ kind }) => { if (kind === 'new_visit') { openFrontPage(); window.scrollTo(0, 0); } });
146
157
  - async lifecycle.saveNow() | scopes: none
147
158
  - Returns: true once Twinkle has the snapshot, false otherwise
148
159
  - Take a snapshot right now, e.g. at a checkpoint or after an important step.
@@ -971,10 +982,11 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
971
982
  - map.heightUrlTemplate (or null) gives the land's shape on the same tile grid for 3D maps (ground heights; buildings and trees narrower than about 20 blocks are left out, so draw those from getTerrainChunks): a gzipped 'THM1' file, 16-byte little-endian header (magic, uint16 samples per side, uint16 blocks per sample, float64 source time) then samples x samples int16 heights row by row (z, then x); -32768 = no ground. Images and heights are served with CORS, so WebGL can use them.
972
983
  - Example: const { worlds } = await Twinkle.minecraft.getWorlds(); const w = worlds.find((x) => x.id === 'world'); const z = w.map.maxZoom; const blocksPerTile = w.map.blocksPerTileAtMaxZoom * 2 ** (w.map.maxZoom - z); const tx = Math.floor(blockX / blocksPerTile), ty = Math.floor(blockZ / blocksPerTile); const url = w.map.tileUrlTemplate.replace('{z}', z).replace('{x}', tx).replace('{y}', ty);
973
984
  - async getOnlinePlayers() | scopes: content:read
974
- - Returns: { players: [{ name, world, x, y, z, isZero }], count, stale }
985
+ - Returns: { players: [{ name, world, x, y, z, isZero }], count, visitors: [{ userId, username, world, x, y, z, since }], visitorCount, stale }
975
986
  - List players currently online with their world and block coordinates. Zero appears with isZero: true and is not counted in count.
976
987
  - Cached for about 5 seconds; poll no faster than every 5 seconds.
977
988
  - Names are Minecraft usernames, not Twinkle usernames.
989
+ - visitors are website members walking the world from Twinkle MC Explorers (Twinkle usernames, not Minecraft names). They are online and visible in game but are not players on the server; count counts players only.
978
990
  - Example: const { players, count } = await Twinkle.minecraft.getOnlinePlayers();
979
991
  - async getZero() | scopes: content:read
980
992
  - Returns: { zero: { online, world, position: { x, y, z } | null, activity: 'idle'|'thinking'|'building'|'workshop', currentBuild: { id, title, status, placed, total, requester, world, bounds } | null, queue: [build], workshop: { world, min, max } | null, updatedAt }, stale }
@@ -1005,25 +1017,29 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1005
1017
  - Every change is logged on the server and emailed to the owner; the player is told in game.
1006
1018
  - Example: await Twinkle.minecraft.setPlayerRole({ uuid: person.uuid, role: 'builder' });
1007
1019
  - async getChat({ since } = {}) | scopes: content:read
1008
- - Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived }], latestSeq, online, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
1020
+ - Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, tag, archived }], latestSeq, online, visitorCount, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
1009
1021
  - Live server chat after a sequence number: player chat, Zero, messages sent from the web, joins, leaves and deaths.
1010
1022
  - Without since (or 0) it returns the newest 50 events, oldest first. Pass since = latestSeq from the previous response to get only new events. Poll every 2-3 seconds while the chat is visible; stop when hidden.
1011
- - kind: chat (a player), zero (Zero, the AI builder), web (someone chatting from the Twinkle app; userId is their Twinkle user id), join, leave, death. Private messages are never included.
1023
+ - kind: chat (a player), zero (Zero, the AI builder), web (someone chatting from the Twinkle app; userId is their Twinkle user id), join, leave, death, visit (a website visitor arrived in the world; userId is their Twinkle user id), unvisit (they left). Private messages are never included.
1012
1024
  - name is a Minecraft username for chat/join/leave/death, and a Twinkle username for web.
1013
1025
  - viewer.canModerate is true only for the server owner; mutedUserIds is filled only for them.
1026
+ - online counts players on the server; visitorCount counts website visitors walking the world (Twinkle MC Explorers).
1027
+ - tag is 'explorer' on web chat from someone walking Twinkle World right now (Twinkle MC Explorers): show them as Explorer instead of Web (in game: [Explorer] name). Otherwise null.
1014
1028
  - Example: let since = 0; async function poll() { const { events, latestSeq } = await Twinkle.minecraft.getChat({ since }); since = latestSeq; render(events); } setInterval(poll, 2500);
1015
1029
  - async getChatHistory({ before, limit, query, includeArchived } = {}) | scopes: content:read
1016
- - Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
1030
+ - Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, tag, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
1017
1031
  - Older server chat, newest first, for scrolling back or searching past days.
1018
1032
  - before is the nextBefore cursor from the previous page; omit it to start from the newest. limit 1-100 (default 50). query searches message text and names (60 chars).
1019
1033
  - Chat from before the web chat bridge existed (imported from server logs) is archived: only the server owner can include it with includeArchived: true; others never receive it.
1034
+ - tag is 'explorer' on web chat from someone walking Twinkle World right now (Twinkle MC Explorers): show them as Explorer instead of Web (in game: [Explorer] name). Otherwise null.
1020
1035
  - Example: const page = await Twinkle.minecraft.getChatHistory({ limit: 50 }); const older = await Twinkle.minecraft.getChatHistory({ before: page.nextBefore });
1021
1036
  - async sendChat({ text }) | scopes: content:write
1022
- - Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived } }
1037
+ - Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death'|'visit'|'unvisit', name, text, userId, tag, archived } }
1023
1038
  - Send a chat message from the app into the Minecraft server; players see it as [Web] <your Twinkle username>.
1024
1039
  - Signed-in viewers only. text is trimmed to 200 characters. About one message per 2.5 seconds and 10 per minute per person: 429 with code minecraft_chat_rate_limited and retryAfterMs.
1025
1040
  - 403 minecraft_chat_forbidden for accounts banned from chat; 400 minecraft_muted when the server owner muted them from web chat.
1026
1041
  - Send only on a user action (Enter or Send); never automatically.
1042
+ - tag is 'explorer' on web chat from someone walking Twinkle World right now (Twinkle MC Explorers): show them as Explorer instead of Web (in game: [Explorer] name). Otherwise null.
1027
1043
  - Example: await Twinkle.minecraft.sendChat({ text: 'Hi from the website!' });
1028
1044
  - async muteChatUser({ userId, muted }) | scopes: content:write
1029
1045
  - Returns: { userId, muted }
@@ -1053,6 +1069,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1053
1069
  - Built-in designs (redstone devices and so on) have no author and are public.
1054
1070
  - previewUrl is an image set with updateDesign, or null.
1055
1071
  - access says what this viewer may do: canOrder shows placement tools (builders, moderators, the owner).
1072
+ - access.web is true for a website member with no Minecraft account (a visitor): they may ask Zero for builds (the first is reviewed) and their designs are always public.
1056
1073
  - Example: const { designs } = await Twinkle.minecraft.getDesigns({ query: 'castle' });
1057
1074
  - async getDesign({ id }) | scopes: content:read
1058
1075
  - Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt }, size: [w, h, d], palette: [blockState], cells: number[] }
@@ -1083,27 +1100,50 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1083
1100
  - async checkPlacement({ designId, world, x, z, y?, facing?: 'north'|'east'|'south'|'west' }) | scopes: content:read
1084
1101
  - Returns: { check: { clear, reasons, world, facing, anchor, bounds: { min, max }, ground: { y, min, max, water }, builtBlocks, builtSamples, placedBy: [{ name, blocks }], design } }
1085
1102
  - Check a building site for a design: where it would sit and whether it would touch anyone's builds.
1086
- - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1103
+ - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Website members with no Minecraft account (visitors) may too; a visitor not approved yet has no builds to stop or undo. Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1087
1104
  - The footprint is centred on x/z and turned to face facing; without y it sits on the ground. clear is false when built (non-natural) blocks stand in the footprint or 3 blocks around it, the ground is very uneven, or it would leave the world; reasons explains. placedBy lists players CoreProtect recorded placing blocks there.
1088
1105
  - Call on a user action (drop or rotate), not while dragging.
1106
+ - Visitors (no Minecraft account) may only check or order a site within about 64 blocks of where they stand in Twinkle World (Twinkle.minecraft.visit): 403 minecraft_visit_closer otherwise; and about 10 checks or orders a minute (429 minecraft_web_order_rate_limited with retryAfterMs).
1089
1107
  - Example: const { check } = await Twinkle.minecraft.checkPlacement({ designId: 'harbor_house', world: 'world', x: 1200, z: 700, facing: 'south' }); outline.color = check.clear ? 'green' : 'red';
1090
1108
  - async buildDesign({ ...placement, force }) | scopes: content:write
1091
- - Returns: { jobId, check, priority }
1109
+ - Returns: { jobId, check, priority, pending?, requestId? }
1092
1110
  - Order Zero to build a design at a site; he switches to creative and builds it before other work.
1093
- - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1111
+ - Who may: the server owner; builders and moderators by their linked Minecraft account (createLinkCode + /link); and website members with no Minecraft account (visitors). A visitor's FIRST order is held for the server owner's review: the answer is { jobId: null, pending: true, requestId, check } and nothing is built yet; once approved (getVisitorRequests().mine.approved) they order like builders. Linked players below builder get 403 minecraft_rank_too_low.
1094
1112
  - Follow progress with getZero() (currentBuild) and getZeroBuilds().
1095
1113
  - Builders: one order waiting or building at a time, published designs or their own, and orders wait in the normal queue (priority false). Moderators: up to 3 orders, ahead of other work. The owner: no limit. force (build on a site that isn't clear) is owner-only.
1114
+ - Visitors (no Minecraft account) may only check or order a site within about 64 blocks of where they stand in Twinkle World (Twinkle.minecraft.visit): 403 minecraft_visit_closer otherwise; and about 10 checks or orders a minute (429 minecraft_web_order_rate_limited with retryAfterMs).
1096
1115
  - Example: const { jobId } = await Twinkle.minecraft.buildDesign({ designId: 'harbor_house', world: 'world', x: 1200, z: 700, facing: 'south' });
1116
+ - async getVisitorRequests() | scopes: content:read
1117
+ - Returns: { canReview, requests: [{ id, userId, username, uuid, designId, designName, blocks, world, anchor, facing, bounds, createdAt }], approved: [{ userId, username, approvedAt, approvedBy }], recent: [{ id, userId, username, decision, by, at, note }], mine: { approved, request, lastDecision } }
1118
+ - Website visitors' first Zero build requests, waiting for review like a builder request; and the viewer's own status.
1119
+ - Only the server owner, in an app they own, gets canReview: true and the full lists; everyone else gets empty lists and only mine (their own open request, whether they are approved, and their last decision).
1120
+ - Example: const { canReview, requests, mine } = await Twinkle.minecraft.getVisitorRequests(); if (mine.request) showWaiting(mine.request);
1121
+ - async decideVisitorRequest({ id, approve, note }) | scopes: content:write
1122
+ - Returns: { approved, built, jobId, check, message }
1123
+ - Approve or reject a website visitor's first Zero build request. Approving makes them a visitor who may order like a builder from then on and places the held build.
1124
+ - Server owner only, in an app they own; others get 403 minecraft_roles_forbidden.
1125
+ - built is false (with check and message) when the site is no longer clear: they stay approved and can order again elsewhere.
1126
+ - Example: await Twinkle.minecraft.decideVisitorRequest({ id: request.id, approve: true });
1127
+ - async withdrawVisitorRequest() | scopes: content:write
1128
+ - Returns: { withdrawn }
1129
+ - A visitor takes back their own build request that is still waiting for review.
1130
+ - withdrawn is false when there was nothing waiting (or the viewer has a linked Minecraft account).
1131
+ - Example: await Twinkle.minecraft.withdrawVisitorRequest();
1132
+ - async revokeVisitorBuilder({ userId }) | scopes: content:write
1133
+ - Returns: { revoked }
1134
+ - Take back a visitor's approval: their next Zero order is held for review again.
1135
+ - Server owner only, in an app they own; others get 403 minecraft_roles_forbidden.
1136
+ - Example: await Twinkle.minecraft.revokeVisitorBuilder({ userId: 101 });
1097
1137
  - async stopZero() | scopes: content:write
1098
1138
  - Returns: { stopped, message }
1099
1139
  - Stop the build Zero is doing right now (placed blocks stay).
1100
- - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1140
+ - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Website members with no Minecraft account (visitors) may too; a visitor not approved yet has no builds to stop or undo. Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1101
1141
  - Anyone may stop or undo their own builds; moderators also builders' and members' builds and Zero's own projects; the owner anything.
1102
1142
  - Example: await Twinkle.minecraft.stopZero();
1103
1143
  - async undoBuild({ jobId }) | scopes: content:write
1104
1144
  - Returns: { undone, title, message }
1105
1145
  - Undo one of Zero's builds, putting back what was there before.
1106
- - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1146
+ - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Website members with no Minecraft account (visitors) may too; a visitor not approved yet has no builds to stop or undo. Others get 403 minecraft_not_linked or minecraft_rank_too_low.
1107
1147
  - Anyone may stop or undo their own builds; moderators also builders' and members' builds and Zero's own projects; the owner anything.
1108
1148
  - Example: await Twinkle.minecraft.undoBuild({ jobId });
1109
1149
  - async getSnaps({ before, limit, uuid } = {}) | scopes: content:read
@@ -1194,6 +1234,28 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1194
1234
  - It shows the world as last saved (the server saves every few minutes). Pair it with world.map.heightUrlTemplate for the land farther away.
1195
1235
  - Reads share the 180-per-minute build read limit: ask for 24 at a time, nearest first, and keep what you loaded.
1196
1236
  - Example: const { scenes } = await Twinkle.minecraft.getTerrainChunks({ world: 'world', chunks: [[71, 38], [72, 38]] });
1237
+ - async getFullChunks({ world, chunks: [[cx, cz], ...] }) | scopes: content:read
1238
+ - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, wide? } | null }] }
1239
+ - Every block with a visible face in chunk columns of a world (up to 24 per call): roofed interiors and caves too, unlike getTerrainChunks. For walking inside buildings near the viewer.
1240
+ - Same scene format as getTerrainChunks and getPlayAreaChunks; larger, so ask only for the few columns around the viewer and use getTerrainChunks farther out.
1241
+ - It shows the world as last saved (the server saves every few minutes) and is cached for about a minute.
1242
+ - Reads share the 180-per-minute build read limit: keep what you loaded, nearest first.
1243
+ - Only the land around the viewer's own visit spot: call visit first; chunks more than 4 columns from where they stand in that world get 403 minecraft_visit_closer (use getTerrainChunks there).
1244
+ - Example: const { scenes } = await Twinkle.minecraft.getFullChunks({ world: 'world', chunks: [[71, 38]] });
1245
+ - async visit({ world, x, y, z, yaw, pitch, moving }) | scopes: content:write
1246
+ - Returns: { players: [{ name, uuid, world, x, y, z, yaw, pitch, sneaking, isZero }], visitors: [{ userId, username, world, x, y, z, yaw, pitch, moving }], stale, full, serverTime, intervalMs }
1247
+ - Visit the real Minecraft world from the app: report where the viewer is standing and get back where the players and the other visitors in that world are. In game, the visitor shows as a figure with their Twinkle username.
1248
+ - Signed-in viewers only. Call it about every intervalMs (500 ms) while the viewer is in the world; faster calls get 429 minecraft_visit_rate_limited with retryAfterMs. Interpolate avatars between answers.
1249
+ - A visitor is not a player on the server: they cannot break, place or use anything there. Members who own Minecraft play at twinklemc.site.
1250
+ - The viewer is never in visitors. stale: true means player positions could not be refreshed (show the last ones).
1251
+ - The figure disappears about 5 seconds after the last call; call leaveVisit when the viewer leaves or switches worlds. 403 minecraft_visit_forbidden for accounts banned from chat.
1252
+ - full: true means the world already shows the most visitors it can (40): this viewer can look around but is not shown in game yet.
1253
+ - Example: const view = await Twinkle.minecraft.visit({ world: 'world', x: 1146.5, y: 64, z: 618.5, yaw: 90, pitch: 0, moving: true }); drawAvatars(view.players, view.visitors);
1254
+ - async leaveVisit() | scopes: content:write
1255
+ - Returns: { left }
1256
+ - Stop visiting: the viewer's figure leaves the Minecraft world right away.
1257
+ - Call it when the viewer leaves the world, switches worlds or closes the app; left is false when they were not visiting.
1258
+ - Example: await Twinkle.minecraft.leaveVisit();
1197
1259
  - async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
1198
1260
  - Returns: { level }
1199
1261
  - Capture a box of a Minecraft world as a new play area (or re-capture an existing id). Runs in the background: poll getPlayArea until status is 'ready'.
@@ -1205,6 +1267,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
1205
1267
  - Zero's build queue: the build he is on, the orders waiting (in the order he will take them) and the campaign steps he works through when no order waits. For an RTS-style build panel and for drawing queued footprints on a map.
1206
1268
  - Everyone can read it; mine marks the viewer's own orders (linked account). Orders always run before campaign steps.
1207
1269
  - noBuildWorlds lists worlds Zero never builds in (World 3, World 4): don't offer placement there.
1270
+ - access.web is true for a website member with no Minecraft account (a visitor): they may ask Zero for builds (the first is reviewed) and their designs are always public.
1208
1271
  - Example: const { active, queue, campaign, access } = await Twinkle.minecraft.getBuildQueue();
1209
1272
  - async moveQueuedBuild({ jobId, to }) | scopes: content:write
1210
1273
  - Returns: same as getBuildQueue
@@ -1187,7 +1187,7 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
1187
1187
 
1188
1188
  These compose the documented commands in this contract, running them as child processes, so every scope check, audit and pagination rule applies unchanged.
1189
1189
  - **Arena members waiting for papers come first.** `arena paper-queue --cursor <member-id> --limit <1-100> --json` lists members blocked by the lack of a fresh complete published paper, using the member-facing pass/retry and solo-mastery rules. Follow every `nextCursor`, including after an empty page; require `demandBasis: waiting-published-papers-v1`. Group all pages by exam/difficulty and prioritize these waiting members before generic unseen-question depth. Failed ordinary papers remain retryable; open/pending-marking sittings are not publication waits. Ten successful distinct Full Hard clears, with Zero/Ciel allowed, are needed between solo mastery attempts. The pass and full-marks 20× XP/Coins rewards are separately reserved, each once. Publish complete independently reviewed papers and verify the same members can advance. `gather` collects this queue first as `arena-paper-queue-N.json`, with `paperQueue.complete/pages/waitingTracks`; missing/malformed/partial coverage must not be reported as an empty healthy queue. Use the workspace CLI until npm publication is verified. This step grants no new authoring spend or publication authority.
1190
- - **`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
+ - **`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. While it runs, `gather.json` is rewritten after every step with `inProgress: true` and the steps so far; the final summary replaces it with `inProgress: false`. An `inProgress: true` file means the collection was interrupted: rerun the same gather with `--resume`. A hang-up from a closing parent shell no longer stops it. 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.
1191
1191
  - **`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.
1192
1192
  - **`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.
1193
1193
  - **`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.
@@ -2257,6 +2257,11 @@ lumine admin builds review build:884 --output-dir ./build-review \
2257
2257
  --interact ./build-review/steps.json --json
2258
2258
  ```
2259
2259
 
2260
+ A secret Subject's effort can't be set until its secret is revealed
2261
+ (`CLI_ADMIN_SECRET_REVEAL_REQUIRED`). `subjects candidates` marks such rows
2262
+ with `needsReveal: true` and the exact `revealCommand`
2263
+ (`lumine admin subject reveal <id>`); run it before `subject effort set`.
2264
+
2260
2265
  Schemas:
2261
2266
 
2262
2267
  ```ts
@@ -3241,6 +3246,13 @@ lumine admin news submit --claim claim.json --file editorial.json \
3241
3246
  lumine admin news print --json
3242
3247
  ```
3243
3248
 
3249
+ When there is nothing to claim (today's paper is already printed, or the next
3250
+ edition has not opened yet), `news claim` writes no claim or scaffold file and
3251
+ leaves any existing `--output` file untouched. Its JSON carries
3252
+ `data.artifacts.notClaimed` with `nextEditionAt` (Unix seconds and ISO); the
3253
+ text output says when the next edition opens. The daily edition opens at 00:00
3254
+ UTC (09:00 KST).
3255
+
3244
3256
  The Twinkle Newspaper (Build app 1929) is normally printed by a community
3245
3257
  member spending their own AI Energy. Making sure today's paper exists is part
3246
3258
  of every delegated website-management run: check `lumine admin news` early in