@stage5/lumine 0.3.69 → 0.3.71
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/lib/admin-arena.js +12 -1
- package/lib/admin-daily.js +55 -9
- package/lib/admin-diagnostic-collection.js +115 -0
- package/lib/admin-duties.js +4 -0
- package/lib/admin-jev-review.js +76 -0
- package/lib/admin-owner-trace.js +11 -2
- package/lib/admin.js +65 -4
- package/lib/commands.js +7 -1
- package/lib/game-diagnostic-summary.js +204 -0
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +49 -0
package/lib/admin-arena.js
CHANGED
|
@@ -14,7 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
// re-importing a folder retires that paper's earlier items
|
|
15
15
|
// status --item <id> --to live|retired|checked
|
|
16
16
|
export const ARENA_USAGE =
|
|
17
|
-
"Usage: lumine admin arena queue | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] [--json]";
|
|
17
|
+
"Usage: lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | marking [--cursor <id>] [--limit <1-200>] | import (--dir <paper folder> | --additive --file <reviewed-items.json>) [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked [--confirm] | today-queue [--cursor <id>] [--limit <1-100>] | today-member --user <id> | today-import --file <reviewed-questions.json> [--confirm] [--json]";
|
|
18
18
|
const MAX_ITEMS = 300;
|
|
19
19
|
|
|
20
20
|
function validationError(message) {
|
|
@@ -63,6 +63,12 @@ function readAdditiveItems(file) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function buildArenaOperation({ action, options }) {
|
|
66
|
+
if (action === "paper-queue") {
|
|
67
|
+
const cursor = Number(options.adminCursor || 0);
|
|
68
|
+
const limit = Number(options.adminLimit ?? 50);
|
|
69
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw validationError("Use a non-negative --cursor and --limit from 1 to 100.");
|
|
70
|
+
return { name: "arena.paper.queue", method: "GET", path: `/cli/admin/arena/paper-queue?cursor=${cursor}&limit=${limit}`, mutates: false, requiresRun: false };
|
|
71
|
+
}
|
|
66
72
|
if (action === "today-queue") {
|
|
67
73
|
const cursor = Number(options.adminCursor || 0);
|
|
68
74
|
const limit = Number(options.adminLimit ?? 50);
|
|
@@ -151,6 +157,11 @@ export function buildArenaOperation({ action, options }) {
|
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
export function formatArenaResult({ operation, data }) {
|
|
160
|
+
if (operation.name === "arena.paper.queue") return [
|
|
161
|
+
`Checked ${data?.scannedMembers ?? 0} members; ${data?.entries?.length ?? 0} exam tracks are waiting for fresh papers on this page.`,
|
|
162
|
+
...(data?.entries || []).map(row => `${row.username} (${row.userId}): ${row.examKey} ${row.difficulty}/${row.length}${row.reason === "solo_mastery_paper" ? " — solo mastery ready" : ""}`),
|
|
163
|
+
data?.nextCursor ? `Continue: lumine admin arena paper-queue --cursor ${data.nextCursor} --json` : "End of waiting-member queue.",
|
|
164
|
+
];
|
|
154
165
|
if (operation.name === "arena.today.queue") return [
|
|
155
166
|
...(data?.members || []).map(m => `#${m.userId} ${m.username}: ${m.ready} questions ready${m.refillDue ? `; refill to ${m.target}` : ""} (preferences v${m.revision})`),
|
|
156
167
|
data?.nextCursor ? `Continue: lumine admin arena today-queue --cursor ${data.nextCursor} --json` : "End of Today’s Q queue.",
|
package/lib/admin-daily.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
|
9
9
|
import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
|
|
10
10
|
import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
|
|
11
11
|
import { createDailyCollection } from "./admin-daily-collection.js";
|
|
12
|
+
import { collectDiagnosticPages, diagnosticCollectionSummary } from "./admin-diagnostic-collection.js";
|
|
12
13
|
export { dailyRunProgress };
|
|
13
14
|
|
|
14
15
|
// Multi-step conveniences for the full daily management run. Each one runs
|
|
@@ -86,7 +87,7 @@ export function runAdmin(
|
|
|
86
87
|
sponsor: ["applications"],
|
|
87
88
|
"bot-output": [null],
|
|
88
89
|
"math-lab": ["queue"],
|
|
89
|
-
arena: ["queue", "today-queue"],
|
|
90
|
+
arena: ["queue", "today-queue", "paper-queue"],
|
|
90
91
|
"word-games": ["status"],
|
|
91
92
|
nonogram: ["status"],
|
|
92
93
|
subjects: ["candidates"],
|
|
@@ -179,12 +180,15 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
179
180
|
);
|
|
180
181
|
const collection = createDailyCollection(dir, options, { now, clock });
|
|
181
182
|
const yesterday = collection.costDate;
|
|
183
|
+
// Resume keeps the original observation boundary; no page slides its window
|
|
184
|
+
// forward while the operator works through a long diagnostic queue.
|
|
185
|
+
const diagnosticUntil = Math.floor(Date.parse(collection.startedAt) / 1000);
|
|
182
186
|
const steps = [];
|
|
183
187
|
const failLast = (error) => {
|
|
184
188
|
collection.invalidate(steps.at(-1).name);
|
|
185
189
|
Object.assign(steps.at(-1), { ok: false, error });
|
|
186
190
|
};
|
|
187
|
-
const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|owner-wake|bot-output)/;
|
|
191
|
+
const evidenceNames = /^(ai-costs|media-costs|reward-activity|reward-diagnostics|jev-review|owner-wake|bot-output)/;
|
|
188
192
|
const step = (name, args, file) => {
|
|
189
193
|
const res = collection.collect(name, args, file, evidenceNames.test(name) ? "evidence" : "priority", run);
|
|
190
194
|
steps.push({
|
|
@@ -205,6 +209,29 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
205
209
|
step("brief", ["brief"], "brief.json");
|
|
206
210
|
step("todos", ["todo", "list"], "todos.json");
|
|
207
211
|
step("escalations", ["escalation", "list"], "escalations.json");
|
|
212
|
+
const paperQueue = { complete: false, pages: 0, waitingTracks: 0 };
|
|
213
|
+
let paperCursor = 0;
|
|
214
|
+
for (;;) {
|
|
215
|
+
paperQueue.pages++;
|
|
216
|
+
const res = step(`arena-paper-page-${paperQueue.pages}`,
|
|
217
|
+
["arena", "paper-queue", "--cursor", String(paperCursor), "--limit", "100"],
|
|
218
|
+
`arena-paper-queue-${paperQueue.pages}.json`);
|
|
219
|
+
if (!res.ok) break;
|
|
220
|
+
const data = res.json?.data;
|
|
221
|
+
const next = data?.nextCursor;
|
|
222
|
+
if (data?.demandBasis !== "waiting-published-papers-v1" || !Array.isArray(data?.entries) ||
|
|
223
|
+
!(next === null || (Number.isSafeInteger(next) && next > paperCursor))) {
|
|
224
|
+
failLast("Invalid or non-advancing Arena paper-waiting page.");
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
paperQueue.waitingTracks += data.entries.length;
|
|
228
|
+
if (next === null) { paperQueue.complete = true; break; }
|
|
229
|
+
if (paperQueue.pages >= 100) {
|
|
230
|
+
failLast("Arena paper-waiting queue reached its 100-page bound.");
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
paperCursor = next;
|
|
234
|
+
}
|
|
208
235
|
step("math-lab-queue", ["math-lab", "queue"], "math-lab-queue.json");
|
|
209
236
|
step("arena-queue", ["arena", "queue"], "arena-queue.json");
|
|
210
237
|
step("word-games-status", ["word-games", "status"], "word-games-status.json");
|
|
@@ -245,7 +272,17 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
245
272
|
["reward-activity", "--date", yesterday, "--days", "7"],
|
|
246
273
|
"reward-activity.json",
|
|
247
274
|
);
|
|
248
|
-
|
|
275
|
+
const rewardDiagnostics = collectDiagnosticPages({ name: "reward-diagnostics",
|
|
276
|
+
command: ["reward-diagnostics", "--days", "1"], since: diagnosticUntil - 86400,
|
|
277
|
+
until: diagnosticUntil, step, failLast });
|
|
278
|
+
writeAdminJsonFile(path.join(dir, "reward-diagnostics-summary.json"), diagnosticCollectionSummary(rewardDiagnostics), { privateFile: true });
|
|
279
|
+
steps.push({ name: "reward-diagnostics-summary", file: "reward-diagnostics-summary.json", ok: true });
|
|
280
|
+
const jevUntil = Math.floor(diagnosticUntil / 86400) * 86400;
|
|
281
|
+
const jevReview = collectDiagnosticPages({ name: "jev-review", jevReview: true,
|
|
282
|
+
command: ["jev-routing", "review", "--since", String(jevUntil - 86400), "--limit", "20"],
|
|
283
|
+
since: jevUntil - 86400, until: jevUntil, step, failLast });
|
|
284
|
+
writeAdminJsonFile(path.join(dir, "jev-review-summary.json"), diagnosticCollectionSummary(jevReview), { privateFile: true });
|
|
285
|
+
steps.push({ name: "jev-review-summary", file: "jev-review-summary.json", ok: true });
|
|
249
286
|
// One pending queue covers every Build approval type. Exhaust its cursor;
|
|
250
287
|
// an empty/malformed receipt must not look like an empty queue.
|
|
251
288
|
const buildReviews = { complete: false, pages: 0, items: 0 };
|
|
@@ -294,17 +331,21 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
294
331
|
// flags: a required report section (agent-guides/lumine-admin.md)
|
|
295
332
|
step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
|
|
296
333
|
step("personal-followups", ["followup", "list"], "personal-followups.json");
|
|
297
|
-
const wake =
|
|
334
|
+
const wake = collectDiagnosticPages({ name: "owner-wake", ownerTrace: true,
|
|
335
|
+
command: ["owner-trace", "--since", String(diagnosticUntil - 86400), "--wake", "--limit", "2000"],
|
|
336
|
+
since: diagnosticUntil - 86400, until: diagnosticUntil, step, failLast });
|
|
298
337
|
// A phone tab can live longer than the daily window. Retain its version,
|
|
299
338
|
// browser and device context instead of silently calling it unknown.
|
|
300
|
-
const wakeSessions =
|
|
339
|
+
const wakeSessions = collectDiagnosticPages({ name: "owner-wake-sessions", ownerTrace: true,
|
|
340
|
+
command: ["owner-trace", "--since", String(diagnosticUntil - 30 * 86400), "--type", "session", "--limit", "2000"],
|
|
341
|
+
since: diagnosticUntil - 30 * 86400, until: diagnosticUntil, step, failLast });
|
|
301
342
|
const ownerWake = summarizeOwnerViewport(
|
|
302
|
-
wake
|
|
303
|
-
wakeSessions
|
|
343
|
+
wake,
|
|
344
|
+
wakeSessions,
|
|
304
345
|
);
|
|
305
346
|
ownerWake.collectionFailures = [
|
|
306
|
-
...(!wake.
|
|
307
|
-
...(!wakeSessions.
|
|
347
|
+
...(!wake.complete ? ["owner-wake"] : []),
|
|
348
|
+
...(!wakeSessions.complete ? ["owner-wake-sessions"] : []),
|
|
308
349
|
];
|
|
309
350
|
if (ownerWake.collectionFailures.length) {
|
|
310
351
|
ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
|
|
@@ -372,12 +413,17 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
|
|
|
372
413
|
costDateUtc: yesterday,
|
|
373
414
|
collection: { id: collection.collectionId, startedAt: collection.startedAt, phase: collection.phase, warnings: collection.warnings },
|
|
374
415
|
personalQueue,
|
|
416
|
+
paperQueue,
|
|
375
417
|
buildReviews,
|
|
418
|
+
rewardDiagnostics: diagnosticCollectionSummary(rewardDiagnostics),
|
|
419
|
+
jevReview: diagnosticCollectionSummary(jevReview),
|
|
376
420
|
ownerWake: {
|
|
377
421
|
coverage: ownerWake.coverage,
|
|
378
422
|
truncated: ownerWake.truncated,
|
|
379
423
|
largeGapCycles: ownerWake.largeGapCycles,
|
|
380
424
|
reportFile: path.join(dir, "owner-wake-summary.md"),
|
|
425
|
+
collection: diagnosticCollectionSummary(wake),
|
|
426
|
+
sessionCollection: diagnosticCollectionSummary(wakeSessions),
|
|
381
427
|
},
|
|
382
428
|
botOutput: {
|
|
383
429
|
complete: botOk,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Fixed-window keyset collection. Every page stays a separate acknowledged
|
|
2
|
+
// source; aggregates are conveniences, never proof that the source was read.
|
|
3
|
+
function jevPosition(cursor, since, until) {
|
|
4
|
+
try {
|
|
5
|
+
if (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor)) return null;
|
|
6
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
7
|
+
if (value.version !== 1 || value.kind !== "jev-routing-review" ||
|
|
8
|
+
value.since !== since || value.until !== until || value.includeSynthetic !== false ||
|
|
9
|
+
!Number.isSafeInteger(value.createdAt) || value.createdAt < since || value.createdAt >= until ||
|
|
10
|
+
!Number.isSafeInteger(value.id) || value.id <= 0) return null;
|
|
11
|
+
return value;
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// MySQL BIGINT columns are serialized as decimal strings in production.
|
|
16
|
+
// Normalize only the comparison key; keep the original source rows intact.
|
|
17
|
+
function diagnosticId(value) {
|
|
18
|
+
if (typeof value !== "number" && typeof value !== "string") return null;
|
|
19
|
+
const id = Number(value);
|
|
20
|
+
return Number.isSafeInteger(id) && id > 0 &&
|
|
21
|
+
(typeof value === "number" || String(id) === value) ? id : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function collectDiagnosticPages({ name, command, since, until, ownerTrace = false, jevReview = false, step, failLast }) {
|
|
25
|
+
const events = [];
|
|
26
|
+
const ids = new Set();
|
|
27
|
+
const cursors = new Set();
|
|
28
|
+
const sourceFiles = [];
|
|
29
|
+
let cursor = null;
|
|
30
|
+
let pages = 0;
|
|
31
|
+
let duplicateRows = 0;
|
|
32
|
+
let complete = false;
|
|
33
|
+
let deferred = false;
|
|
34
|
+
let failure = null;
|
|
35
|
+
const pageLimit = ownerTrace ? 20 : 100;
|
|
36
|
+
for (;;) {
|
|
37
|
+
pages++;
|
|
38
|
+
const args = [...command, "--until", String(until)];
|
|
39
|
+
if (cursor !== null) args.push("--cursor", String(cursor));
|
|
40
|
+
const file = `${name}${pages === 1 ? "" : `-${pages}`}.json`;
|
|
41
|
+
const result = step(`${name}${pages === 1 ? "" : `-page-${pages}`}`, args, file);
|
|
42
|
+
sourceFiles.push(file);
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
deferred = Boolean(result.deferred);
|
|
45
|
+
failure = deferred ? result.reason : "source-read-failed";
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const data = result.json?.data;
|
|
49
|
+
const next = data?.nextCursor;
|
|
50
|
+
const rows = jevReview ? data?.items : data?.events;
|
|
51
|
+
const window = jevReview ? data?.collectionWindow : data;
|
|
52
|
+
const validCursor = next === null || (jevReview ? Boolean(jevPosition(next, since, until)) : ownerTrace
|
|
53
|
+
? typeof next === "string" && /^\d+:\d+$/.test(next)
|
|
54
|
+
: Number.isSafeInteger(next) && next > 0 && (cursor === null || next < cursor));
|
|
55
|
+
if (!Array.isArray(rows) || window?.since !== since || window?.until !== until ||
|
|
56
|
+
!validCursor || data.coverage === "unavailable" ||
|
|
57
|
+
(jevReview && (window.includeSynthetic !== false || data.after !== cursor ||
|
|
58
|
+
data.hasMore !== (next !== null) || data.coverage?.scope !== "page" ||
|
|
59
|
+
data.coverage?.status !== (next === null ? "exhausted" : "partial"))) ||
|
|
60
|
+
(ownerTrace && data.truncated !== (next !== null))) {
|
|
61
|
+
failure = "Invalid diagnostic page or fixed-window coverage; deploy the matching API before collecting.";
|
|
62
|
+
failLast(failure);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
const idOf = row => diagnosticId(jevReview ? row?.eventId : row?.id);
|
|
66
|
+
if (rows.some(row => idOf(row) === null)) {
|
|
67
|
+
failure = "Diagnostic page contains an invalid event ID.";
|
|
68
|
+
failLast(failure);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (jevReview && cursor !== null && next !== null) {
|
|
72
|
+
const old = jevPosition(cursor, since, until), current = jevPosition(next, since, until);
|
|
73
|
+
if (!(current.createdAt > old.createdAt || current.createdAt === old.createdAt && current.id > old.id)) {
|
|
74
|
+
failure = "JEV review cursor did not advance.";
|
|
75
|
+
failLast(failure);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (ownerTrace && cursor !== null && next !== null) {
|
|
80
|
+
const [oldTime, oldId] = cursor.split(":").map(Number);
|
|
81
|
+
const [time, id] = next.split(":").map(Number);
|
|
82
|
+
if (!(time < oldTime || time === oldTime && id < oldId)) {
|
|
83
|
+
failure = "Owner trace cursor did not advance.";
|
|
84
|
+
failLast(failure);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if (ids.has(idOf(row))) { duplicateRows++; continue; }
|
|
90
|
+
ids.add(idOf(row));
|
|
91
|
+
events.push(row);
|
|
92
|
+
}
|
|
93
|
+
if (next === null) { complete = true; break; }
|
|
94
|
+
if (cursors.has(next) || pages >= pageLimit) {
|
|
95
|
+
failure = `Diagnostic pagination repeated a cursor or reached its ${pageLimit}-page bound.`;
|
|
96
|
+
failLast(failure);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
cursors.add(next);
|
|
100
|
+
cursor = next;
|
|
101
|
+
}
|
|
102
|
+
if (ownerTrace) events.sort((a, b) => a.serverTime - b.serverTime || a.id - b.id);
|
|
103
|
+
return { since, until, complete, pages, rows: events.length, duplicateRows,
|
|
104
|
+
sourceFiles, deferred, failure, events, truncated: !complete };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function diagnosticCollectionSummary(collection) {
|
|
108
|
+
const { events, ...summary } = collection;
|
|
109
|
+
const outcomes = {};
|
|
110
|
+
for (const row of events) {
|
|
111
|
+
const key = `${row.source || row.type || row.route || "unknown"}:${row.operation || ""}:${row.outcome || row.selectedSource || ""}`;
|
|
112
|
+
outcomes[key] = (outcomes[key] || 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
return { ...summary, outcomes, note: "Metadata only. Read the source pages; this summary does not acknowledge review or prove member outcomes." };
|
|
115
|
+
}
|
package/lib/admin-duties.js
CHANGED
|
@@ -44,7 +44,11 @@ export function createDailyReviewState(dir, gather, previous = {}) {
|
|
|
44
44
|
if (gather.buildReviews && (
|
|
45
45
|
source.file === "rr-pending.json" || /^build-reviews-\d+\.json$/.test(source.file)
|
|
46
46
|
)) continue;
|
|
47
|
+
if (gather.paperQueue && /^arena-paper-queue-\d+\.json$/.test(source.file)) continue;
|
|
47
48
|
if (gather.personalQueue && /^arena-today-queue-\d+\.json$/.test(source.file)) continue;
|
|
49
|
+
if (gather.rewardDiagnostics && /^reward-diagnostics(?:-\d+|-summary)?\.json$/.test(source.file)) continue;
|
|
50
|
+
if (gather.jevReview && /^jev-review(?:-\d+|-summary)?\.json$/.test(source.file)) continue;
|
|
51
|
+
if (gather.ownerWake?.collection && /^owner-wake(?:-sessions)?(?:-\d+)?\.json$/.test(source.file)) continue;
|
|
48
52
|
if (gather.botOutput && (
|
|
49
53
|
source.file === "bot-chats.txt" || /^bot-output-\d+\.json$/.test(source.file)
|
|
50
54
|
)) continue;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Bounded owner-only frozen evidence. Collection never acknowledges reading
|
|
2
|
+
// and never reconstructs missing input from the member's edited chat history.
|
|
3
|
+
export const JEV_REVIEW_USAGE =
|
|
4
|
+
"Usage: lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor>] [--include-synthetic] [--all] [--json].";
|
|
5
|
+
|
|
6
|
+
function invalid(message) {
|
|
7
|
+
const error = new Error(message);
|
|
8
|
+
error.code = "CLI_ADMIN_CLI_VALIDATION";
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function explicitTimestamp(value, name) {
|
|
13
|
+
if (typeof value !== "string" && typeof value !== "number") invalid(JEV_REVIEW_USAGE);
|
|
14
|
+
const text = String(value).trim();
|
|
15
|
+
if (!/^\d+$/.test(text) && !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(text)) {
|
|
16
|
+
invalid(`--${name} requires an explicit Unix timestamp or ISO date, not a relative window.`);
|
|
17
|
+
}
|
|
18
|
+
const seconds = /^\d+$/.test(text) ? Number(text) : Math.floor(Date.parse(text) / 1000);
|
|
19
|
+
if (!Number.isSafeInteger(seconds) || seconds <= 0) invalid(`Invalid --${name} timestamp.`);
|
|
20
|
+
// Keep ISO input intact for the API's strict calendar/date-zone validation.
|
|
21
|
+
return { text, seconds };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildJevRoutingReviewOperation({ action, target, extra, options = {} }) {
|
|
25
|
+
if (action !== "review" || target || extra) invalid(JEV_REVIEW_USAGE);
|
|
26
|
+
if (options.adminAll && options.adminCursor) {
|
|
27
|
+
invalid("Use --all from the beginning of the window, or --resume with its saved checkpoint; use --cursor only for an individual page.");
|
|
28
|
+
}
|
|
29
|
+
const since = explicitTimestamp(options.adminSince, "since");
|
|
30
|
+
const until = explicitTimestamp(options.adminUntil, "until");
|
|
31
|
+
if (until.seconds <= since.seconds || until.seconds - since.seconds > 8 * 86400) {
|
|
32
|
+
invalid("Use a fixed window with since < until, at most eight days.");
|
|
33
|
+
}
|
|
34
|
+
// The global parser's `limit` defaults to 50 for unrelated commands.
|
|
35
|
+
// `adminLimit` records only an explicitly supplied --limit.
|
|
36
|
+
const rawLimit = options.adminLimit;
|
|
37
|
+
const limit = rawLimit == null || rawLimit === "" ? 20 : Number(rawLimit);
|
|
38
|
+
if ((rawLimit != null && !["string", "number"].includes(typeof rawLimit)) ||
|
|
39
|
+
!Number.isSafeInteger(limit) || limit < 1 || limit > 20) invalid("--limit must be 1–20.");
|
|
40
|
+
if (options.adminIncludeSynthetic !== undefined && typeof options.adminIncludeSynthetic !== "boolean") {
|
|
41
|
+
invalid("--include-synthetic is a flag without a value.");
|
|
42
|
+
}
|
|
43
|
+
const includeSynthetic = options.adminIncludeSynthetic === true;
|
|
44
|
+
const query = new URLSearchParams({ since: since.text, until: until.text, limit: String(limit) });
|
|
45
|
+
if (includeSynthetic) query.set("includeSynthetic", "true");
|
|
46
|
+
if (options.adminCursor) query.set("cursor", String(options.adminCursor));
|
|
47
|
+
return {
|
|
48
|
+
name: "jevRouting.review",
|
|
49
|
+
method: "GET",
|
|
50
|
+
path: `/cli/admin/jev-routing/review?${query.toString()}`,
|
|
51
|
+
body: undefined,
|
|
52
|
+
mutates: false,
|
|
53
|
+
requiresRun: false,
|
|
54
|
+
pagination: {
|
|
55
|
+
collectionKey: "items",
|
|
56
|
+
after: since.seconds,
|
|
57
|
+
summaryKeys: ["collectionWindow", "coverage", "notes"],
|
|
58
|
+
filters: { since: since.text, until: until.text, includeSynthetic },
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatJevRoutingReview(data = {}) {
|
|
64
|
+
const window = data.collectionWindow || {};
|
|
65
|
+
const coverage = data.coverage || {};
|
|
66
|
+
const lines = [
|
|
67
|
+
`JEV review: [${window.since ?? "?"}, ${window.until ?? "?"}) · synthetic ${window.includeSynthetic ? "included" : "excluded"}`,
|
|
68
|
+
`${data.items?.length || 0} events returned · page ${coverage.status || "coverage unknown"}${coverage.fromBeginning === false ? " · resumed after a cursor" : ""}`,
|
|
69
|
+
];
|
|
70
|
+
for (const item of data.items || []) {
|
|
71
|
+
lines.push(`#${item.eventId} ${item.route} · ${(item.reviewReasons || []).join(", ")} · selected ${item.selectedSource || "not recorded"} · frozen input ${item.contextAvailable ? "available" : item.contextUnavailableReason || "unavailable"}`);
|
|
72
|
+
}
|
|
73
|
+
if (data.nextCursor) lines.push(`Next cursor: ${data.nextCursor}`);
|
|
74
|
+
lines.push("Collection is not review. Read the JSON source pages; an exhausted resumed page does not prove the whole window was collected.");
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
package/lib/admin-owner-trace.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// no daily run needed. The server keeps 30 days.
|
|
5
5
|
|
|
6
6
|
export const OWNER_TRACE_USAGE =
|
|
7
|
-
"Usage: lumine admin owner-trace [--since 2h|30m|3d|<iso>|<unix>] [--type route,feed-open,scroll-anchor,content-scroll,error,...] [--perf] [--wake] [--path <substring>] [--limit 300] [--json].";
|
|
7
|
+
"Usage: lumine admin owner-trace [--since 2h|30m|3d|<iso>|<unix>] [--until <unix-second>] [--cursor <time:id>] [--type route,feed-open,scroll-anchor,content-scroll,error,...] [--perf] [--wake] [--path <substring>] [--limit 300] [--json].";
|
|
8
8
|
|
|
9
9
|
// --perf: only the performance events (tap-to-page timeline, slow API
|
|
10
10
|
// requests, main-thread stalls, page load).
|
|
@@ -59,6 +59,15 @@ export function buildOwnerTraceOperation(options) {
|
|
|
59
59
|
const query = new URLSearchParams();
|
|
60
60
|
query.set("since", normalizeSince(options.adminSince));
|
|
61
61
|
query.set("limit", String(limit));
|
|
62
|
+
if (options.adminUntil) {
|
|
63
|
+
const until = Number(options.adminUntil);
|
|
64
|
+
if (!Number.isSafeInteger(until) || until <= 0) throw validationError("--until must be a positive unix second.");
|
|
65
|
+
query.set("until", String(until));
|
|
66
|
+
}
|
|
67
|
+
if (options.adminCursor) {
|
|
68
|
+
if (!/^\d+:\d+$/.test(options.adminCursor)) throw validationError("--cursor must be a returned time:id pair.");
|
|
69
|
+
query.set("cursor", options.adminCursor);
|
|
70
|
+
}
|
|
62
71
|
const types = String(options.adminType || "")
|
|
63
72
|
.split(",")
|
|
64
73
|
.map((type) => type.trim())
|
|
@@ -333,7 +342,7 @@ export function formatOwnerTrace(data) {
|
|
|
333
342
|
const sessions = new Set(events.map((event) => event.sessionId));
|
|
334
343
|
const since = Number(data?.since || 0);
|
|
335
344
|
lines.push(
|
|
336
|
-
`Owner trace since ${since ? `${formatDay(since * 1000)} ${formatClock(since * 1000).slice(0, 5)}` : "?"} · ${events.length} event(s) · ${sessions.size} session(s)${data?.truncated ?
|
|
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"})` : ""}`,
|
|
337
346
|
);
|
|
338
347
|
if (!events.length) {
|
|
339
348
|
lines.push("Nothing recorded in this window.");
|
package/lib/admin.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readdirSync,
|
|
8
8
|
readFileSync,
|
|
9
9
|
realpathSync,
|
|
10
|
+
statSync,
|
|
10
11
|
writeFileSync,
|
|
11
12
|
} from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
@@ -48,6 +49,8 @@ import { buildSpendingOperation, completeSpendingReport, formatSpending } from "
|
|
|
48
49
|
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
49
50
|
import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
|
|
50
51
|
import { buildArenaOperation, formatArenaResult } from "./admin-arena.js";
|
|
52
|
+
import { buildJevRoutingReviewOperation, formatJevRoutingReview } from "./admin-jev-review.js";
|
|
53
|
+
import { summarizeGameDiagnostics } from "./game-diagnostic-summary.js";
|
|
51
54
|
import {
|
|
52
55
|
buildSlidingBlockOperation,
|
|
53
56
|
formatSlidingBlockResult,
|
|
@@ -1314,6 +1317,32 @@ function readEditorialFile(filePath) {
|
|
|
1314
1317
|
}
|
|
1315
1318
|
}
|
|
1316
1319
|
|
|
1320
|
+
function gameDiagnosticSummary(options) {
|
|
1321
|
+
const filePath = String(options.adminFile || "").trim();
|
|
1322
|
+
const outputPath = options.adminOutput ? String(options.adminOutput).trim() : null;
|
|
1323
|
+
if (options.positional?.length !== 2 || !filePath || outputPath === "") {
|
|
1324
|
+
throw cliValidationError("Usage: lumine admin game-diagnostics summarize --file <envelope.json> [--json]. This reads retained evidence locally.");
|
|
1325
|
+
}
|
|
1326
|
+
const maxBytes = 16 * 1024 * 1024;
|
|
1327
|
+
let metadata;
|
|
1328
|
+
try { metadata = statSync(filePath); }
|
|
1329
|
+
catch { throw cliValidationError("Could not read the game diagnostic envelope."); }
|
|
1330
|
+
if (!metadata.isFile() || metadata.size > maxBytes) {
|
|
1331
|
+
throw cliValidationError("The game diagnostic envelope must be a regular file under 16 MB.");
|
|
1332
|
+
}
|
|
1333
|
+
if (outputPath) {
|
|
1334
|
+
let outputMetadata;
|
|
1335
|
+
try { outputMetadata = statSync(outputPath); }
|
|
1336
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
1337
|
+
if (path.resolve(outputPath) === path.resolve(filePath) ||
|
|
1338
|
+
(outputMetadata?.dev === metadata.dev && outputMetadata?.ino === metadata.ino)) {
|
|
1339
|
+
throw cliValidationError("Keep the raw evidence: summary output must differ from the input file, including aliases.");
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const envelope = readAdminJsonFile(filePath, "game diagnostic envelope", { maxBytes });
|
|
1343
|
+
return { ok: true, status: "success", changed: false, data: summarizeGameDiagnostics(envelope) };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1317
1346
|
export async function adminCommand(options) {
|
|
1318
1347
|
const [namespace, action] = options.positional || [];
|
|
1319
1348
|
const composite =
|
|
@@ -1323,15 +1352,21 @@ export async function adminCommand(options) {
|
|
|
1323
1352
|
? dailyRunProgress
|
|
1324
1353
|
: namespace === "featured" && action === "candidates"
|
|
1325
1354
|
? featuredCandidates
|
|
1355
|
+
: namespace === "game-diagnostics" && action === "summarize"
|
|
1356
|
+
? gameDiagnosticSummary
|
|
1326
1357
|
: namespace === "report" && action === "serve"
|
|
1327
1358
|
? reportServe
|
|
1328
1359
|
: null;
|
|
1329
1360
|
if (composite) {
|
|
1330
1361
|
const result = await composite(options);
|
|
1331
1362
|
if (options.adminOutput) {
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
}
|
|
1363
|
+
if (composite === gameDiagnosticSummary) {
|
|
1364
|
+
writeAdminJsonFile(options.adminOutput, result, { privateFile: true });
|
|
1365
|
+
} else {
|
|
1366
|
+
writeFileSync(options.adminOutput, JSON.stringify(result), {
|
|
1367
|
+
mode: 0o600,
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1335
1370
|
}
|
|
1336
1371
|
console.log(JSON.stringify(result, null, options.json ? 0 : 2));
|
|
1337
1372
|
if (result && result.ok === false) process.exitCode = 1;
|
|
@@ -2409,6 +2444,7 @@ const FEATURED_RUN_OPERATIONS = new Set([
|
|
|
2409
2444
|
// run's write work without the newspaper, sponsor scan or bank seeding (the
|
|
2410
2445
|
// API holds the same line through the run's scopes).
|
|
2411
2446
|
const APPROVALS_RUN_EXCLUDED_OPERATIONS = new Set([
|
|
2447
|
+
"subject.website-update.post",
|
|
2412
2448
|
"news.claim",
|
|
2413
2449
|
"news.print",
|
|
2414
2450
|
"news.submit",
|
|
@@ -2427,6 +2463,7 @@ export function assertAdminOperationAllowedForRunScope({
|
|
|
2427
2463
|
"news.claim",
|
|
2428
2464
|
"news.submit",
|
|
2429
2465
|
"news.print",
|
|
2466
|
+
"subject.website-update.post",
|
|
2430
2467
|
"daily-run.complete",
|
|
2431
2468
|
"daily-run.fail",
|
|
2432
2469
|
].includes(operation.name)
|
|
@@ -2674,6 +2711,9 @@ export function parseAdminOperation(options) {
|
|
|
2674
2711
|
if (namespace === "owner-trace") {
|
|
2675
2712
|
return buildOwnerTraceOperation(options);
|
|
2676
2713
|
}
|
|
2714
|
+
if (namespace === "jev-routing") {
|
|
2715
|
+
return buildJevRoutingReviewOperation({ action, target, extra, options });
|
|
2716
|
+
}
|
|
2677
2717
|
// Who holds which management level (admin-roles.js); read-only, no run.
|
|
2678
2718
|
if (namespace === "roles") {
|
|
2679
2719
|
return buildRolesOperation(options);
|
|
@@ -3696,7 +3736,8 @@ export function parseAdminOperation(options) {
|
|
|
3696
3736
|
if (days > 30 || userId && !buildId) throw cliValidationError("Use --build with --user; --days must be 1–30.");
|
|
3697
3737
|
return readOperation("reward-diagnostics.read", withQuery("/cli/admin/reward-diagnostics", {
|
|
3698
3738
|
buildId: buildId || undefined, userId: userId || undefined, days,
|
|
3699
|
-
before: options.adminCursor ? parseRequiredInteger(options.adminCursor, "--cursor", 1) : undefined
|
|
3739
|
+
before: options.adminCursor ? parseRequiredInteger(options.adminCursor, "--cursor", 1) : undefined,
|
|
3740
|
+
until: options.adminUntil ? parseRequiredInteger(options.adminUntil, "--until", 1) : undefined
|
|
3700
3741
|
}), { requiresRun: false });
|
|
3701
3742
|
}
|
|
3702
3743
|
|
|
@@ -4162,6 +4203,22 @@ export function parseAdminOperation(options) {
|
|
|
4162
4203
|
}
|
|
4163
4204
|
|
|
4164
4205
|
if (namespace === "subject") {
|
|
4206
|
+
if (action === "post-update") {
|
|
4207
|
+
if (target || extra || !options.adminFile) {
|
|
4208
|
+
throw cliValidationError("Usage: lumine admin subject post-update --file <article.json> [--confirm]. Preview is the default.");
|
|
4209
|
+
}
|
|
4210
|
+
const article = readAdminJsonFile(options.adminFile, "--file", { maxBytes: 256 * 1024 });
|
|
4211
|
+
if (!article || typeof article !== "object" || Array.isArray(article)) {
|
|
4212
|
+
throw cliValidationError("The article file must contain one composed website-update object.");
|
|
4213
|
+
}
|
|
4214
|
+
if (options.adminConfirm && options.adminConfirm !== "true") {
|
|
4215
|
+
throw cliValidationError("Pass --confirm on its own, without a value.");
|
|
4216
|
+
}
|
|
4217
|
+
const confirm = options.adminConfirm === "true";
|
|
4218
|
+
if (confirm && options.adminDryRun) throw cliValidationError("Choose --confirm or --dry-run, not both.");
|
|
4219
|
+
const operation = confirm ? writeOperation : bodyReadOperation;
|
|
4220
|
+
return operation("subject.website-update.post", "POST", "/cli/admin/subjects/website-update", { article, confirm });
|
|
4221
|
+
}
|
|
4165
4222
|
if (action === "get") return subjectGetOperation(target, options);
|
|
4166
4223
|
if (action === "comments") {
|
|
4167
4224
|
const subjectId = parseSubjectId(target);
|
|
@@ -7595,6 +7652,10 @@ function printAdminResult({ operation, result }) {
|
|
|
7595
7652
|
printOwnerTrace(data);
|
|
7596
7653
|
return;
|
|
7597
7654
|
}
|
|
7655
|
+
if (operation.name === "jevRouting.review") {
|
|
7656
|
+
for (const line of formatJevRoutingReview(data)) console.log(line);
|
|
7657
|
+
return;
|
|
7658
|
+
}
|
|
7598
7659
|
if (operation.name === "roles.read") {
|
|
7599
7660
|
printRoles(data);
|
|
7600
7661
|
return;
|
package/lib/commands.js
CHANGED
|
@@ -2508,6 +2508,7 @@ export function parseArgs(args) {
|
|
|
2508
2508
|
"wake",
|
|
2509
2509
|
"includeLegacy",
|
|
2510
2510
|
"includePrivateEvidence",
|
|
2511
|
+
"includeSynthetic",
|
|
2511
2512
|
"allFlagged",
|
|
2512
2513
|
"reviewed",
|
|
2513
2514
|
"noReviewLoop",
|
|
@@ -2681,6 +2682,8 @@ export function parseArgs(args) {
|
|
|
2681
2682
|
adminApprove: raw.approve ? String(raw.approve) : "",
|
|
2682
2683
|
adminSinceRun: Boolean(raw.sinceRun),
|
|
2683
2684
|
adminSince: raw.since ? String(raw.since) : "",
|
|
2685
|
+
adminUntil: raw.until ? String(raw.until) : "",
|
|
2686
|
+
adminIncludeSynthetic: Boolean(raw.includeSynthetic),
|
|
2684
2687
|
adminPath: raw.path ? String(raw.path) : "",
|
|
2685
2688
|
adminPerf: Boolean(raw.perf),
|
|
2686
2689
|
adminWake: Boolean(raw.wake),
|
|
@@ -3322,7 +3325,7 @@ export function printHelp() {
|
|
|
3322
3325
|
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
3323
3326
|
lumine admin craft seed --build <id> --file <pairs.json> [--confirm] | stats --build <id> [--limit <1-100>] | revise --build <id> --item <id|name> [--why <text>] [--emoji <emoji>] [--category <field>] [--confirm] | revise --build <id> --pair <pairId|a+b> --why <text> [--confirm] | retire --build <id> --item <id|name> --reason <text> [--confirm] | unfizzle --build <id> [--since <iso>] [--pair <a+b>] [--confirm] (Twinkle.craft curation: dry run without --confirm)
|
|
3324
3327
|
lumine admin sliding-block status | add --file <levels.json> [--confirm] (sliding-block-v1 campaign refills, RushHour: dry run without --confirm; the server solves each board and appends it)
|
|
3325
|
-
lumine admin arena queue | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm (Arena's mock-exam bank: import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
|
|
3328
|
+
lumine admin arena queue | paper-queue [--cursor <id>] [--limit <1-100>] | import --dir <paper folder> [--audio audio/<exam>/<paper>] [--live] [--confirm] | status --item <id> --to live|retired|checked --confirm (Arena's mock-exam bank: import is a validation-only dry run without --confirm; upload audio first with twinkle-api scripts/arena-upload-media.ts)
|
|
3326
3329
|
lumine admin nonogram status | add --file <pictures.json> | retire <key> | restore <key> [--confirm] (private reviewed pictures; changes are dry runs without --confirm)
|
|
3327
3330
|
lumine admin build-plans stuck [--days <1-90>] [--all] | recheck <buildId> [<buildId> ...] [--confirm] (Lumine Build plans stuck on one milestone: runs, Energy vs estimate, gradings, loop-guard state; recheck re-grades the current milestone with today's evaluator, free for the creator, dry run without --confirm)
|
|
3328
3331
|
lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
|
|
@@ -3330,6 +3333,7 @@ export function printHelp() {
|
|
|
3330
3333
|
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)
|
|
3331
3334
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
3332
3335
|
lumine admin reward-diagnostics [--build <id> --user <id>] [--days <1..30>] [--cursor <id>] [--json]
|
|
3336
|
+
lumine admin game-diagnostics summarize --file <envelope.json> [--json] (local retained sharedDb receipts; deduplicates stable events, preserves delivery and coverage limits)
|
|
3333
3337
|
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
|
3334
3338
|
lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
|
|
3335
3339
|
lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
|
|
@@ -3352,6 +3356,7 @@ export function printHelp() {
|
|
|
3352
3356
|
lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--interact <steps.json>] [--browser-path <path>] [--json]
|
|
3353
3357
|
lumine admin subjects candidates [--since-run|--after <date>|--include-legacy] [--effort unassigned] [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
|
|
3354
3358
|
lumine admin subject get|reveal <subject-url-or-id> [--json]
|
|
3359
|
+
lumine admin subject post-update --file <article.json> [--confirm] [--json]
|
|
3355
3360
|
lumine admin subject comments <subject-url-or-id> [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
|
|
3356
3361
|
lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
|
|
3357
3362
|
lumine admin subject creator set-made-by-poster <subject-id> [--json]
|
|
@@ -3378,6 +3383,7 @@ export function printHelp() {
|
|
|
3378
3383
|
lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
|
|
3379
3384
|
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)
|
|
3380
3385
|
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)
|
|
3386
|
+
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)
|
|
3381
3387
|
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]
|
|
3382
3388
|
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]
|
|
3383
3389
|
lumine admin comment post --draft-id <id> [--json]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
const MAX_ROWS = 10000;
|
|
5
|
+
const DELIVERY_NUMBERS = new Set([
|
|
6
|
+
"v", "pending", "acknowledged", "requestFailures", "evicted", "expired",
|
|
7
|
+
"exhausted", "storageFailures", "oversized", "invalid", "attempt", "attempts",
|
|
8
|
+
"failures", "localEvictions", "coalescedBatches", "retryInMs"
|
|
9
|
+
]);
|
|
10
|
+
const DELIVERY_FIELDS = new Set([...DELIVERY_NUMBERS, "sending", "lastOutcome"]);
|
|
11
|
+
const KINDS = new Set([
|
|
12
|
+
"lifecycle", "launch-outcome", "save-failed", "save-ok", "save-load-failed",
|
|
13
|
+
"save-protected", "save-recovered", "soundtrack-diagnostics", "error", "startup"
|
|
14
|
+
]);
|
|
15
|
+
const EVENTS = new Set([
|
|
16
|
+
"hidden", "visible", "blur", "focus", "pagehide", "session_started", "action",
|
|
17
|
+
"battle_choice", "board_solved", "board_started", "fight_left", "fight_result",
|
|
18
|
+
"fight_started", "input", "lesson_step", "progress", "puzzle_progress",
|
|
19
|
+
"runtime_error", "save_status", "scene", "stalled"
|
|
20
|
+
]);
|
|
21
|
+
const SOUNDTRACK_COUNTERS = new Set([
|
|
22
|
+
"ready", "recommendations", "recommendations-empty", "tag-load", "tag-search",
|
|
23
|
+
"tag-submit", "tag-remove", "lock-open", "lock-open-failed", "progress-load", "progress-save"
|
|
24
|
+
]);
|
|
25
|
+
const record = value => value !== null && typeof value === "object" &&
|
|
26
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
27
|
+
const positiveId = value => Number.isSafeInteger(value) && value > 0;
|
|
28
|
+
const stableId = value => typeof value === "string" && /^[\w:.-]{1,160}$/.test(value);
|
|
29
|
+
const hash = text => createHash("sha256").update(text).digest("hex");
|
|
30
|
+
|
|
31
|
+
// Validate the pure helper too: a caller need not have used the CLI's file cap.
|
|
32
|
+
// Sort keys for semantic comparison, but never mutate the retained source.
|
|
33
|
+
function canonicalJson(value, limit = MAX_BYTES) {
|
|
34
|
+
let bytes = 0;
|
|
35
|
+
const ancestors = new Set();
|
|
36
|
+
const token = text => {
|
|
37
|
+
bytes += Buffer.byteLength(text);
|
|
38
|
+
if (bytes > limit) throw new Error("Game diagnostic JSON exceeds its byte limit.");
|
|
39
|
+
return text;
|
|
40
|
+
};
|
|
41
|
+
function visit(item, depth) {
|
|
42
|
+
if (depth > 32) throw new Error("Game diagnostic JSON exceeds depth 32.");
|
|
43
|
+
if (item === null || typeof item === "boolean") return token(JSON.stringify(item));
|
|
44
|
+
if (typeof item === "number" && Number.isFinite(item)) return token(JSON.stringify(item));
|
|
45
|
+
if (typeof item === "string") {
|
|
46
|
+
if (item.length > limit) throw new Error("Game diagnostic JSON exceeds its byte limit.");
|
|
47
|
+
return token(JSON.stringify(item));
|
|
48
|
+
}
|
|
49
|
+
if ((!Array.isArray(item) && !record(item)) || ancestors.has(item)) throw new Error("Game diagnostics require finite, acyclic JSON values.");
|
|
50
|
+
ancestors.add(item);
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (Array.isArray(item)) {
|
|
53
|
+
parts.push(token("["));
|
|
54
|
+
for (let i = 0; i < item.length; i++) {
|
|
55
|
+
if (i) parts.push(token(","));
|
|
56
|
+
parts.push(visit(item[i], depth + 1));
|
|
57
|
+
}
|
|
58
|
+
parts.push(token("]"));
|
|
59
|
+
} else {
|
|
60
|
+
parts.push(token("{"));
|
|
61
|
+
for (const key of Object.keys(item).sort()) {
|
|
62
|
+
if (parts.length > 1) parts.push(token(","));
|
|
63
|
+
parts.push(token(JSON.stringify(key)), token(":"), visit(item[key], depth + 1));
|
|
64
|
+
}
|
|
65
|
+
parts.push(token("}"));
|
|
66
|
+
}
|
|
67
|
+
ancestors.delete(item);
|
|
68
|
+
return parts.join("");
|
|
69
|
+
}
|
|
70
|
+
return visit(value, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function semanticJson(data) {
|
|
74
|
+
if (!record(data.delivery)) return canonicalJson(data, 16384);
|
|
75
|
+
const semantic = { ...data };
|
|
76
|
+
const remaining = Object.entries(data.delivery).filter(([key]) => !DELIVERY_FIELDS.has(key));
|
|
77
|
+
if (remaining.length) semantic.delivery = Object.fromEntries(remaining);
|
|
78
|
+
else delete semantic.delivery;
|
|
79
|
+
return canonicalJson(semantic, 16384);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function deliverySnapshot(data) {
|
|
83
|
+
if (!record(data.delivery)) return null;
|
|
84
|
+
const snapshot = {};
|
|
85
|
+
for (const key of DELIVERY_NUMBERS) {
|
|
86
|
+
const value = data.delivery[key];
|
|
87
|
+
if (Number.isSafeInteger(value) && value >= 0) snapshot[key] = value;
|
|
88
|
+
}
|
|
89
|
+
if (typeof data.delivery.sending === "boolean") snapshot.sending = data.delivery.sending;
|
|
90
|
+
if (["none", "viewer-changed", "acknowledged", "failed"].includes(data.delivery.lastOutcome)) snapshot.lastOutcome = data.delivery.lastOutcome;
|
|
91
|
+
return Object.keys(snapshot).length ? snapshot : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function coverageOf(value) {
|
|
95
|
+
if (value !== undefined && !record(value)) throw new Error("Game diagnostic coverage must be an object.");
|
|
96
|
+
const coverage = { complete: value?.complete === true };
|
|
97
|
+
if (value?.complete !== undefined && typeof value.complete !== "boolean") throw new Error("Coverage complete must be a boolean.");
|
|
98
|
+
for (const key of ["since", "until"]) if (value?.[key] !== undefined) {
|
|
99
|
+
if (!Number.isSafeInteger(value[key]) || value[key] < 0) throw new Error("Coverage boundaries must be nonnegative integers.");
|
|
100
|
+
coverage[key] = value[key];
|
|
101
|
+
}
|
|
102
|
+
if (coverage.since !== undefined && coverage.until !== undefined && coverage.since >= coverage.until) throw new Error("Coverage until must be after since.");
|
|
103
|
+
return coverage;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Local, metadata-only summary. Input rows remain the canonical evidence. */
|
|
107
|
+
export function summarizeGameDiagnostics(envelope) {
|
|
108
|
+
if (!record(envelope) || !positiveId(envelope.buildId) ||
|
|
109
|
+
typeof envelope.topic !== "string" || !/^[\w.-]{1,100}$/.test(envelope.topic) ||
|
|
110
|
+
!Array.isArray(envelope.entries) || envelope.entries.length > MAX_ROWS) {
|
|
111
|
+
throw new Error("Expected {buildId, topic, entries}; buildId must be positive, topic a technical name and entries at most 10000 rows.");
|
|
112
|
+
}
|
|
113
|
+
canonicalJson(envelope);
|
|
114
|
+
const collection = coverageOf(envelope.coverage);
|
|
115
|
+
const receipts = new Map(), groups = new Map(), legacy = [], observations = [];
|
|
116
|
+
let duplicateInputRows = 0, stableRows = 0, unknownArtifactRows = 0;
|
|
117
|
+
for (const entry of envelope.entries) {
|
|
118
|
+
if (!record(entry) || !positiveId(entry.id) || !positiveId(entry.userId) || !positiveId(entry.topicId) || !record(entry.data)) {
|
|
119
|
+
throw new Error("Every diagnostic entry requires canonical positive id, userId, topicId and object data; payload viewerId is not identity.");
|
|
120
|
+
}
|
|
121
|
+
if (entry.buildId !== undefined && entry.buildId !== envelope.buildId) throw new Error("Entry buildId does not match the envelope.");
|
|
122
|
+
const data = entry.data;
|
|
123
|
+
if (data.artifactVersionId != null && !positiveId(data.artifactVersionId)) throw new Error("artifactVersionId must be positive or unknown.");
|
|
124
|
+
for (const field of ["eventId", "batchId"]) if (data[field] != null && !stableId(data[field])) throw new Error(`Invalid diagnostic ${field}.`);
|
|
125
|
+
const exact = canonicalJson(data, 16384);
|
|
126
|
+
const receiptHash = hash(canonicalJson([entry.userId, entry.topicId, data.artifactVersionId ?? null, exact]));
|
|
127
|
+
if (receipts.has(entry.id)) {
|
|
128
|
+
if (receipts.get(entry.id) !== receiptHash) throw new Error("Conflicting copies of one canonical entry ID; inspect the retained raw source.");
|
|
129
|
+
duplicateInputRows++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
receipts.set(entry.id, receiptHash);
|
|
133
|
+
const artifactVersionId = data.artifactVersionId ?? null;
|
|
134
|
+
if (artifactVersionId === null) unknownArtifactRows++;
|
|
135
|
+
const fields = { entryId: entry.id, userId: entry.userId, topicId: entry.topicId, artifactVersionId };
|
|
136
|
+
const snapshot = deliverySnapshot(data);
|
|
137
|
+
if (snapshot) observations.push({ ...fields, delivery: snapshot });
|
|
138
|
+
const idType = data.eventId != null ? "eventId" : data.batchId != null ? "batchId" : null;
|
|
139
|
+
if (!idType) { legacy.push(data); continue; }
|
|
140
|
+
stableRows++;
|
|
141
|
+
const key = canonicalJson([envelope.buildId, envelope.topic, entry.topicId, entry.userId, artifactVersionId, idType, data[idType]]);
|
|
142
|
+
let group = groups.get(key);
|
|
143
|
+
if (!group) {
|
|
144
|
+
group = { ...fields, idType, stableId: data[idType], hashes: new Map(), rows: 0 };
|
|
145
|
+
groups.set(key, group);
|
|
146
|
+
}
|
|
147
|
+
group.rows++;
|
|
148
|
+
const fingerprint = hash(semanticJson(data));
|
|
149
|
+
let variant = group.hashes.get(fingerprint);
|
|
150
|
+
if (!variant) { variant = { data, entryIds: [] }; group.hashes.set(fingerprint, variant); }
|
|
151
|
+
variant.entryIds.push(entry.id);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const counted = [...legacy], collisions = [];
|
|
155
|
+
let uniqueStableEvents = 0, retryDuplicateReceipts = 0, collisionGroups = 0, collidingReceipts = 0;
|
|
156
|
+
for (const group of groups.values()) {
|
|
157
|
+
if (group.hashes.size > 1) {
|
|
158
|
+
collisionGroups++; collidingReceipts += group.rows;
|
|
159
|
+
if (collisions.length < 100) collisions.push({ userId: group.userId, topicId: group.topicId,
|
|
160
|
+
artifactVersionId: group.artifactVersionId, idType: group.idType, stableId: group.stableId,
|
|
161
|
+
receipts: group.rows, variants: [...group.hashes].slice(0, 20).map(([sha256, variant]) => ({ sha256,
|
|
162
|
+
entryIds: variant.entryIds.slice(0, 20), omittedEntryIds: Math.max(0, variant.entryIds.length - 20) })),
|
|
163
|
+
omittedVariants: Math.max(0, group.hashes.size - 20) });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
uniqueStableEvents++; retryDuplicateReceipts += group.rows - 1;
|
|
167
|
+
counted.push(group.hashes.values().next().value.data);
|
|
168
|
+
}
|
|
169
|
+
const byKind = {}, alzebraEventNames = {}, soundtrackCounters = {};
|
|
170
|
+
let invalidCounterMetrics = 0;
|
|
171
|
+
for (const data of counted) {
|
|
172
|
+
const kind = data.batchId && !data.kind ? "alzebra-batch" : KINDS.has(data.kind) ? data.kind : "other";
|
|
173
|
+
byKind[kind] = (byKind[kind] || 0) + 1;
|
|
174
|
+
if (kind === "alzebra-batch" && Array.isArray(data.events)) for (const event of data.events) {
|
|
175
|
+
const name = EVENTS.has(event?.name) ? event.name : "other";
|
|
176
|
+
alzebraEventNames[name] = (alzebraEventNames[name] || 0) + 1;
|
|
177
|
+
}
|
|
178
|
+
if (kind === "soundtrack-diagnostics" && record(data.counters)) for (const name of SOUNDTRACK_COUNTERS) {
|
|
179
|
+
const value = data.counters[name];
|
|
180
|
+
if (value === undefined) continue;
|
|
181
|
+
if (!record(value) || !["count", "failed", "durationMs", "maxMs"].every(key => Number.isSafeInteger(value[key]) && value[key] >= 0 && value[key] <= 1e11) || value.failed > value.count) {
|
|
182
|
+
invalidCounterMetrics++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const total = soundtrackCounters[name] ||= { count: 0, failed: 0, durationMs: 0, maxMs: 0 };
|
|
186
|
+
total.count += value.count; total.failed += value.failed; total.durationMs += value.durationMs;
|
|
187
|
+
total.maxMs = Math.max(total.maxMs, value.maxMs);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
observations.sort((a, b) => b.entryId - a.entryId);
|
|
191
|
+
return {
|
|
192
|
+
schema: 1, buildId: envelope.buildId, topic: envelope.topic, collection,
|
|
193
|
+
rows: { input: envelope.entries.length, canonical: receipts.size, duplicateInputRows,
|
|
194
|
+
stableRows, legacyRows: legacy.length, unknownArtifactRows },
|
|
195
|
+
deduplication: { coverage: receipts.size === 0 ? "empty" : legacy.length || unknownArtifactRows || collisionGroups ? "partial" : "complete",
|
|
196
|
+
uniqueStableEvents, retryDuplicateReceipts, collisionGroups, collidingReceipts,
|
|
197
|
+
countedEvents: counted.length, collisions, omittedCollisions: Math.max(0, collisionGroups - collisions.length) },
|
|
198
|
+
counts: { byKind, alzebraEventNames, soundtrackCounters, invalidCounterMetrics },
|
|
199
|
+
delivery: { observations: observations.slice(0, 100), totalObservations: observations.length,
|
|
200
|
+
omittedObservations: Math.max(0, observations.length - 100),
|
|
201
|
+
note: "Per-receipt client snapshots; cumulative counters are not summed. An acknowledged count concerns earlier client receipts, not this row's gameplay save." },
|
|
202
|
+
note: "Preserve and read the raw receipts. Legacy rows remain separate and may contain retries; unknown artifacts are not version evidence. Collisions are excluded from event counts. Collection completeness describes supplied server rows, not successful delivery of every client event."
|
|
203
|
+
};
|
|
204
|
+
}
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1186,6 +1186,7 @@ lumine admin report serve --file <dir>/daily-management-report.md [--port <n>] [
|
|
|
1186
1186
|
```
|
|
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
|
+
- **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.
|
|
1189
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
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.
|
|
1191
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.
|
|
@@ -1880,6 +1881,10 @@ and seeding coverage for steps 1-3 once the app is approved.
|
|
|
1880
1881
|
decisions. The old Math Studio steps 1–3 / 20-per-run recipe is historical,
|
|
1881
1882
|
not current authorization.
|
|
1882
1883
|
|
|
1884
|
+
### Game diagnostic delivery
|
|
1885
|
+
|
|
1886
|
+
For retained game delivery evidence, `lumine admin game-diagnostics summarize --file <envelope.json> --json` is local-only and requires no login. The envelope is `{buildId,topic,entries,coverage:{complete,since,until}}`; keep the canonical sharedDb `id`, `userId`, `topicId` and parsed `data` on every row. The command deduplicates stable event/batch IDs within their canonical user/topic/artifact scope, flags conflicting copies, and reports legacy/unknown-version coverage. Raw evidence is still required for judgment. Cumulative delivery snapshots are not summed; server collection completeness does not prove complete client delivery. The reviewed October28 producers and CLI are not live until their release receipts confirm them. See `work/games-followup-20261028/` and the repository's daily guide.
|
|
1887
|
+
|
|
1883
1888
|
### Reward activity report (standing duty, every full daily review; added 2026-09-12)
|
|
1884
1889
|
|
|
1885
1890
|
Also inspect failed or unconfirmed payouts with `lumine admin reward-diagnostics --days 1 --json`.
|
|
@@ -3155,6 +3160,46 @@ type PostSkip = Success<{
|
|
|
3155
3160
|
|
|
3156
3161
|
## Twinkle Newspaper
|
|
3157
3162
|
|
|
3163
|
+
**Daily website-code update article (Mikey, 2026-10-28):** before claiming an
|
|
3164
|
+
edition, the duty bot (Zero/Ciel) publishes a real Subject at reward/effort level
|
|
3165
|
+
**2** about member-relevant website code changes shipped since the previous
|
|
3166
|
+
paper. Check Git/push history against successful production deployments and the
|
|
3167
|
+
actual live revision, then explain the useful changes in plain language; a push
|
|
3168
|
+
or a proposed fix is not a release. This is website development news, not a
|
|
3169
|
+
roundup of member posts or internal tooling. Cite that Subject's actual event
|
|
3170
|
+
from the claim digest and include the linked article on every front page; use
|
|
3171
|
+
it as the lead only when its significance warrants it. Verify author, level,
|
|
3172
|
+
source link and printed placement, and avoid duplicate posts or repeated release
|
|
3173
|
+
coverage. If no relevant changes shipped, say so briefly without inventing news.
|
|
3174
|
+
The standing instruction authorizes this scoped post and coverage; it grants no
|
|
3175
|
+
unrelated release authority. Keep release evidence and private diagnostics out
|
|
3176
|
+
of the public article. Follow the repository's daily website-code update
|
|
3177
|
+
procedure and the existing dated-revision contract for an approved correction.
|
|
3178
|
+
|
|
3179
|
+
Use the scoped posting command after composing and reviewing the article:
|
|
3180
|
+
|
|
3181
|
+
```bash
|
|
3182
|
+
lumine admin subject post-update --file article.json --json
|
|
3183
|
+
lumine admin subject post-update --file article.json --confirm --json
|
|
3184
|
+
```
|
|
3185
|
+
|
|
3186
|
+
The first command is a read-only preview. The second publishes as the selected
|
|
3187
|
+
run's duty bot at level 2; `newspaper` or `full` run scope is required. The file
|
|
3188
|
+
contains `title`, `description`, `coverageStart` and `coverageEnd` (Unix seconds),
|
|
3189
|
+
and `releaseEvidence`: an array of `{repository, commit, deployedAt, summary}`.
|
|
3190
|
+
Use full Git commit hashes and verified deployment times inside the completed
|
|
3191
|
+
coverage interval. The evidence stays private; put only useful member-facing
|
|
3192
|
+
information in the article. An empty evidence array is allowed when no relevant
|
|
3193
|
+
changes shipped, and the article should say that briefly. The command does not
|
|
3194
|
+
independently verify GitHub/Vercel receipts: the operator must do that first.
|
|
3195
|
+
|
|
3196
|
+
One canonical article is permitted per scheduled editorial day. An identical
|
|
3197
|
+
retry returns its existing Subject instead of notifying members again; a
|
|
3198
|
+
changed payload, changed duty bot or edited/deleted post requires inspection
|
|
3199
|
+
and is not silently overwritten or recreated. Preview reports any existing
|
|
3200
|
+
Subject. The already-published October28 article is reserved as a legacy post;
|
|
3201
|
+
reuse Subject37265 rather than trying to republish it with this new command.
|
|
3202
|
+
|
|
3158
3203
|
**Audience (Mikey, 2026-09-15): children and young Twinkle users aged 10–15.**
|
|
3159
3204
|
Choose and write stories for what these readers would voluntarily spend time
|
|
3160
3205
|
reading. Before selecting a story, identify its appeal to them: curiosity,
|
|
@@ -5467,6 +5512,10 @@ Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
|
|
|
5467
5512
|
`comments get`, and `recommend` remain accepted, but the singular command forms
|
|
5468
5513
|
shown above are the canonical interface.
|
|
5469
5514
|
|
|
5515
|
+
For bounded case evidence, use `lumine admin jev-routing review --since <UTC-day> --until <next-UTC-day> --json` and follow every cursor, or `--all --checkpoint <persistent-file>`. Daily gather retains every20-event source page for the preceding complete UTC day. The reader covers paired disagreements and Auto fallbacks once per event; synthetic traffic is excluded unless `--include-synthetic` is explicit. It returns only currently unexpired frozen input and labels absent/expired input. A missing endpoint is unavailable coverage, not an empty healthy day. Collection never acknowledges reading or proves classification accuracy. This October28 reader and independent Auto-input capture are local until the matching release is verified.
|
|
5516
|
+
|
|
5517
|
+
Before calling a reply unrelated, check its original generation trigger and the bounded `askedAbout` descriptor. A member may have edited the visible turn or asked about linked content. The descriptor explicitly represents current stored metadata, not a separately frozen generation snapshot; never bypass deleted context.
|
|
5518
|
+
|
|
5470
5519
|
Read `jevRoutingShadow` alongside `jevPilot` in both the admin brief and full
|
|
5471
5520
|
daily report. The September 21 primary-chat experiment is historical: chat
|
|
5472
5521
|
routing is off by default and served-route audits were retired September 23.
|