@stage5/lumine 0.2.19 → 0.2.21

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.js CHANGED
@@ -1,9 +1,39 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
2
3
  import { assertAuthScope, resolveAuth } from "./auth.js";
3
4
  import { requestJson } from "./http.js";
4
5
 
6
+ const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
7
+
8
+ function readEditorialFile(filePath) {
9
+ const normalizedPath = String(filePath || "").trim();
10
+ if (!normalizedPath) {
11
+ throw cliValidationError(
12
+ "Pass the editorial JSON with --file <editorial.json>.",
13
+ );
14
+ }
15
+ let contents;
16
+ try {
17
+ contents = readFileSync(normalizedPath, "utf8");
18
+ } catch {
19
+ throw cliValidationError(`Could not read ${normalizedPath}.`);
20
+ }
21
+ if (Buffer.byteLength(contents, "utf8") > MAX_EDITORIAL_FILE_BYTES) {
22
+ throw cliValidationError("The editorial file must be under 256KB.");
23
+ }
24
+ try {
25
+ return JSON.parse(contents);
26
+ } catch {
27
+ throw cliValidationError(`${normalizedPath} is not valid JSON.`);
28
+ }
29
+ }
30
+
5
31
  export async function adminCommand(options) {
6
32
  const operation = parseAdminOperation(options);
33
+ const recommendationContentTypes =
34
+ operation.name === "recommendations.list"
35
+ ? parseRecommendationContentTypes(options.adminContentTypes)
36
+ : null;
7
37
  const auth = await resolveAuth(options);
8
38
  await assertAuthScope({
9
39
  options,
@@ -54,6 +84,12 @@ export async function adminCommand(options) {
54
84
  },
55
85
  timeoutMs: options.timeoutMs,
56
86
  });
87
+ if (recommendationContentTypes) {
88
+ result = filterRecommendationQueueResult({
89
+ result,
90
+ contentTypes: recommendationContentTypes,
91
+ });
92
+ }
57
93
  } catch (error) {
58
94
  if (operation.mutates && requestId) {
59
95
  const retryInstruction = `Retry with --idempotency-key ${requestId}.`;
@@ -90,6 +126,49 @@ export async function adminCommand(options) {
90
126
  return result;
91
127
  }
92
128
 
129
+ const RECOMMENDATION_CONTENT_TYPES = new Map([
130
+ ["comment", "comment"],
131
+ ["aistory", "aiStory"],
132
+ ["dailyreflection", "dailyReflection"],
133
+ ]);
134
+
135
+ export function parseRecommendationContentTypes(value) {
136
+ const raw = String(value || "").trim();
137
+ if (!raw) return null;
138
+ const contentTypes = raw
139
+ .split(",")
140
+ .map((item) => item.trim())
141
+ .filter(Boolean)
142
+ .map((item) => RECOMMENDATION_CONTENT_TYPES.get(item.toLowerCase()));
143
+ if (
144
+ contentTypes.length === 0 ||
145
+ contentTypes.some((contentType) => !contentType) ||
146
+ new Set(contentTypes).size !== contentTypes.length
147
+ ) {
148
+ throw cliValidationError(
149
+ "--content-types accepts comment, aiStory, and dailyReflection.",
150
+ );
151
+ }
152
+ return [...new Set(contentTypes)];
153
+ }
154
+
155
+ export function filterRecommendationQueueResult({ result, contentTypes }) {
156
+ const items = Array.isArray(result?.data?.items) ? result.data.items : [];
157
+ const allowed = new Set(contentTypes);
158
+ const filteredItems = items.filter((item) => allowed.has(item?.contentType));
159
+ return {
160
+ ...result,
161
+ data: {
162
+ ...result.data,
163
+ items: filteredItems,
164
+ clientFilter: {
165
+ contentTypes,
166
+ excludedItems: items.length - filteredItems.length,
167
+ },
168
+ },
169
+ };
170
+ }
171
+
93
172
  function adminOperationRequiresRun(operation) {
94
173
  return ![
95
174
  "identity.list",
@@ -178,6 +257,7 @@ export function parseAdminOperation(options) {
178
257
  "recommendations.list",
179
258
  withQuery("/cli/admin/recommendations", {
180
259
  kind,
260
+ contentTypes: options.adminContentTypes,
181
261
  cursor: options.adminCursor,
182
262
  limit: options.limit,
183
263
  }),
@@ -279,6 +359,23 @@ export function parseAdminOperation(options) {
279
359
  if (action === "recommend") {
280
360
  return recommendOperation(target, options);
281
361
  }
362
+ if (action === "skip") {
363
+ const parsedTarget = parseRecommendationTarget({
364
+ target,
365
+ explicitType: options.adminType,
366
+ });
367
+ if (parsedTarget.type === "subject") {
368
+ throw cliValidationError(
369
+ "Skips apply to comment, aiStory, and dailyReflection targets; subjects leave the queue through effort assignment.",
370
+ );
371
+ }
372
+ return writeOperation(
373
+ "post.skip",
374
+ "POST",
375
+ `/cli/admin/skips/${parsedTarget.type}/${parsedTarget.id}`,
376
+ { reason: options.adminReason || undefined },
377
+ );
378
+ }
282
379
  if (action === "reward") {
283
380
  const parsedTarget = parseRecommendationTarget({
284
381
  target,
@@ -303,15 +400,87 @@ export function parseAdminOperation(options) {
303
400
  return recommendOperation(action, options);
304
401
  }
305
402
 
403
+ if (namespace === "news") {
404
+ if (!action || action === "status") {
405
+ return readOperation("news.status", "/cli/admin/news");
406
+ }
407
+ if (action === "print") {
408
+ return writeOperation("news.print", "POST", "/cli/admin/news/print", {});
409
+ }
410
+ if (action === "claim") {
411
+ const repairDate = String(options.adminDate || "").trim();
412
+ if (repairDate && !/^\d{4}-\d{2}-\d{2}$/.test(repairDate)) {
413
+ throw cliValidationError("--date must be YYYY-MM-DD.");
414
+ }
415
+ return writeOperation("news.claim", "POST", "/cli/admin/news/claim", {
416
+ ...(repairDate ? { date: repairDate } : {}),
417
+ });
418
+ }
419
+ if (action === "submit") {
420
+ const editionId = parseRequiredInteger(
421
+ options.adminEditionId,
422
+ "--edition-id",
423
+ 1,
424
+ );
425
+ const leaseToken = String(options.adminLeaseToken || "").trim();
426
+ if (!leaseToken) {
427
+ throw cliValidationError(
428
+ "Pass the claim's lease token with --lease-token <token>.",
429
+ );
430
+ }
431
+ return writeOperation(
432
+ "news.submit",
433
+ "POST",
434
+ "/cli/admin/news/submit",
435
+ {
436
+ editionId,
437
+ leaseToken,
438
+ editorial: readEditorialFile(options.adminFile),
439
+ model: options.model || undefined,
440
+ },
441
+ );
442
+ }
443
+ throw cliValidationError(
444
+ "Usage: lumine admin news [status] | news print | news claim | news submit --edition-id <id> --lease-token <token> --file <editorial.json>",
445
+ );
446
+ }
447
+
448
+ if (namespace === "audit" && (!action || action === "list")) {
449
+ const runFilter = String(options.adminRun || "").trim();
450
+ if (runFilter && !["current", "last"].includes(runFilter)) {
451
+ parseRequiredInteger(runFilter, "--run", 1);
452
+ }
453
+ return readOperation(
454
+ "audit.list",
455
+ withQuery("/cli/admin/audit", {
456
+ run: runFilter,
457
+ target: options.adminTarget,
458
+ actions: options.adminActions,
459
+ cursor: options.adminCursor,
460
+ limit: options.limit,
461
+ full: options.adminFull ? "true" : "",
462
+ }),
463
+ );
464
+ }
465
+
306
466
  if (namespace === "comment") {
307
- if (action === "draft") {
308
- const subjectId = parseSubjectId(target);
467
+ if (action === "draft" || action === "reply") {
468
+ const parsedTarget = parseRecommendationTarget({
469
+ target,
470
+ explicitType: options.adminType,
471
+ });
472
+ if (action === "reply" && parsedTarget.type !== "comment") {
473
+ throw cliValidationError(
474
+ "comment reply targets a comment: lumine admin comment reply comment:<id>.",
475
+ );
476
+ }
309
477
  return writeOperation(
310
478
  "comment.draft",
311
479
  "POST",
312
480
  "/cli/admin/comment-drafts",
313
481
  {
314
- subjectId,
482
+ targetType: parsedTarget.type,
483
+ targetId: parsedTarget.id,
315
484
  identity: options.adminIdentity
316
485
  ? parseIdentity(options.adminIdentity)
317
486
  : undefined,
@@ -334,7 +503,7 @@ export function parseAdminOperation(options) {
334
503
  }
335
504
 
336
505
  throw cliValidationError(
337
- "Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment ...",
506
+ "Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|news|audit ...",
338
507
  );
339
508
  }
340
509
 
@@ -687,6 +856,65 @@ function printAdminResult({ operation, result }) {
687
856
  printPagination(data.pagination);
688
857
  return;
689
858
  }
859
+ if (Array.isArray(data.events)) {
860
+ console.log(`${data.events.length} audit event(s):`);
861
+ for (const event of data.events) {
862
+ const target =
863
+ event.targetType && event.targetId
864
+ ? ` ${event.targetType}:${event.targetId}`
865
+ : "";
866
+ console.log(
867
+ `#${event.id} run ${event.runId ?? "-"} ${event.action}${target} — ${event.result}${event.changed === true ? " (changed)" : event.changed === false ? " (no change)" : ""}`,
868
+ );
869
+ }
870
+ printPagination(data.pagination);
871
+ return;
872
+ }
873
+ if (data.claim) {
874
+ console.log(
875
+ `Claimed edition #${data.claim.editionId} (${data.claim.dateKey}): ${data.claim.events.length} event(s); lease token ${data.claim.leaseToken}.`,
876
+ );
877
+ console.log(
878
+ `Write the editorial JSON, then run: lumine admin news submit --edition-id ${data.claim.editionId} --lease-token ${data.claim.leaseToken} --file editorial.json`,
879
+ );
880
+ return;
881
+ }
882
+ if (data.newspaper) {
883
+ const paper = data.newspaper;
884
+ if (paper.printedToday) {
885
+ const printed = paper.latestPrinted || {};
886
+ console.log(
887
+ `Newspaper ${paper.dateKey}: printed (revision ${printed.revisionNumber || 1}, ${printed.sourceEventCount ?? 0} sources).`,
888
+ );
889
+ } else {
890
+ console.log(
891
+ `Newspaper ${paper.dateKey}: not printed (${paper.generationStatus}).`,
892
+ );
893
+ }
894
+ if (paper.requestedAction && paper.requestedAction !== "none") {
895
+ console.log(
896
+ `${paper.requestedAction === "retry" ? "Queued a retry of" : "Queued"} today's edition; the press typesets it within about a minute. Re-check with: lumine admin news`,
897
+ );
898
+ } else if (
899
+ !paper.printedToday &&
900
+ ["pending", "generating"].includes(paper.generationStatus)
901
+ ) {
902
+ console.log(
903
+ "An edition is being typeset now. Re-check with: lumine admin news",
904
+ );
905
+ }
906
+ if (paper.failureMessage && !paper.printedToday) {
907
+ console.log(`Last attempt failed: ${paper.failureMessage}`);
908
+ }
909
+ return;
910
+ }
911
+ if (data.skip) {
912
+ console.log(
913
+ `${result.status}: ${data.skip.contentType}:${data.skip.contentId} skipped.`,
914
+ );
915
+ if (data.skip.url) console.log(data.skip.url);
916
+ return;
917
+ }
690
918
  if (Array.isArray(data.items)) {
691
919
  console.log(`${data.items.length} recommendation candidate(s):`);
692
920
  for (const item of data.items) {
@@ -719,7 +947,8 @@ function printAdminResult({ operation, result }) {
719
947
  `Draft #${data.draft.id}: ${data.draft.decision} (${data.draft.status}).`,
720
948
  );
721
949
  if (data.draft.content) console.log(data.draft.content);
722
- if (data.draft.subjectUrl) console.log(data.draft.subjectUrl);
950
+ const draftUrl = data.draft.targetUrl || data.draft.subjectUrl;
951
+ if (draftUrl) console.log(draftUrl);
723
952
  return;
724
953
  }
725
954
  if (data.subject) {
package/lib/commands.js CHANGED
@@ -2096,6 +2096,7 @@ export function parseArgs(args) {
2096
2096
  "noBrowser",
2097
2097
  "includeComments",
2098
2098
  "anyoneCanReward",
2099
+ "full",
2099
2100
  ]);
2100
2101
 
2101
2102
  for (let i = 0; i < rest.length; i += 1) {
@@ -2175,6 +2176,7 @@ export function parseArgs(args) {
2175
2176
  : "",
2176
2177
  adminType: raw.type ? String(raw.type) : "",
2177
2178
  adminKind: raw.kind ? String(raw.kind) : "",
2179
+ adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
2178
2180
  adminIdentity: raw.identity ? String(raw.identity) : "",
2179
2181
  commentMode: raw.commentMode ? String(raw.commentMode) : "",
2180
2182
  runKey: raw.runKey ? String(raw.runKey) : "",
@@ -2182,6 +2184,14 @@ export function parseArgs(args) {
2182
2184
  draftId: raw.draftId ? String(raw.draftId) : "",
2183
2185
  twinkles: raw.twinkles ? String(raw.twinkles) : "",
2184
2186
  adminReason: raw.reason ? String(raw.reason) : "",
2187
+ adminRun: raw.run ? String(raw.run) : "",
2188
+ adminTarget: raw.target ? String(raw.target) : "",
2189
+ adminActions: raw.actions ? String(raw.actions) : "",
2190
+ adminDate: raw.date ? String(raw.date) : "",
2191
+ adminEditionId: raw.editionId ? String(raw.editionId) : "",
2192
+ adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
2193
+ adminFile: raw.file ? String(raw.file) : "",
2194
+ adminFull: parseBoolean(raw.full, false),
2185
2195
  rewardTwinkles: raw.rewardTwinkles ? String(raw.rewardTwinkles) : "",
2186
2196
  includeComments: parseBoolean(raw.includeComments, false),
2187
2197
  anyoneCanReward: parseBoolean(raw.anyoneCanReward, false),
@@ -2500,7 +2510,7 @@ export function printHelp() {
2500
2510
  lumine admin identity list|status|use <zero|ciel|auto> [--json]
2501
2511
  lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
2502
2512
  lumine admin daily-run status|complete|fail [--reason <text>] [--json]
2503
- lumine admin recommendations list [--cursor <cursor>] [--json]
2513
+ lumine admin recommendations list [--content-types comment,dailyReflection] [--cursor <cursor>] [--json]
2504
2514
  lumine admin subjects candidates [--after <date>] [--effort unassigned] [--cursor <cursor>] [--json]
2505
2515
  lumine admin subject get|comments|reveal <subject-url-or-id> [--cursor <cursor>] [--json]
2506
2516
  lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
@@ -2509,9 +2519,12 @@ export function printHelp() {
2509
2519
  lumine admin featured list|reorder --subject-ids <id,id,...> [--json]
2510
2520
  lumine admin post get|comments <target> [--type subject|comment|aiStory|dailyReflection] [--cursor <cursor>] [--json]
2511
2521
  lumine admin post recommend <target> [--type subject|comment|aiStory|dailyReflection] [--anyone-can-reward] [--reward-twinkles 3] [--json]
2522
+ lumine admin post skip <target> [--type comment|aiStory|dailyReflection] [--reason <text>] [--json]
2512
2523
  lumine admin post reward <target> [--type subject|comment|aiStory|dailyReflection] --twinkles 3 [--json]
2513
- lumine admin comment draft <subject-id> [--identity zero|ciel|auto] [--json]
2524
+ lumine admin comment draft <target> [--type subject|comment|aiStory|dailyReflection] [--identity zero|ciel|auto] [--json]
2525
+ lumine admin comment reply comment:<id> [--identity zero|ciel|auto] [--json]
2514
2526
  lumine admin comment post --draft-id <id> [--json]
2527
+ lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--cursor <cursor>] [--json]
2515
2528
 
2516
2529
  Examples:
2517
2530
  npx @stage5/lumine@latest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -273,9 +273,26 @@ type DailyRunFail = DailyRunComplete;
273
273
 
274
274
  `lastRun` makes a lost-response retry of `complete` or `fail` possible after
275
275
  the active pointer has been cleared. Other run-scoped commands accept only the
276
- current unexpired `active` run. Completion rejects while a content mutation or
277
- audit finalization is pending; `fail` remains available to abandon such a run
278
- without advancing rotation.
276
+ current unexpired `active` run. Completion first finalizes any mutation whose
277
+ content change committed but whose audit bookkeeping was still pending,
278
+ counting it toward the run's rotation signal. It then rejects only while a
279
+ mutation from the last ten minutes is genuinely in flight (the 409 lists the
280
+ pending mutations and a `retryAfterSeconds`); older in-flight rows are treated
281
+ as orphans of a dead process and no longer block completion. `fail` remains
282
+ available to abandon a run without advancing rotation, including a run whose
283
+ six-hour authorization has expired; an expired run can never be completed.
284
+ A TTL-expired run is reported with status `expired` even before the next
285
+ start reaps it, so `daily-run status` never shows an unusable run as
286
+ `active`.
287
+
288
+ Starting with a run key that belongs to a finished or expired run fails with
289
+ `CLI_ADMIN_RUN_KEY_ALREADY_USED`; supply a fresh `--run-key` (for example
290
+ `daily:2026-08-07:2`) to start again the same day. Reusing the key of the
291
+ live active run returns that run only when the requested `--comment-mode`
292
+ and any explicit `--identity` match it; otherwise the start fails with
293
+ `CLI_ADMIN_RUN_SETTINGS_MISMATCH` instead of silently returning a run with
294
+ different scopes. The same check applies when a start without the active
295
+ run's key would fall back to that active run.
279
296
 
280
297
  The default run key is `daily:YYYY-MM-DD` in Asia/Bangkok. Supply `--run-key`
281
298
  for a separate explicit run. `--idempotency-key` may be supplied to any
@@ -286,7 +303,8 @@ JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
286
303
  ## Canonical lists and inspection
287
304
 
288
305
  ```bash
289
- lumine admin recommendations list --kind recommend --cursor '<cursor>' --json
306
+ lumine admin recommendations list --kind recommend \
307
+ --content-types comment,dailyReflection --cursor '<cursor>' --json
290
308
  lumine admin subjects candidates --after 2026-08-01T00:00:00Z \
291
309
  --cursor '<cursor>' --json
292
310
  lumine admin subjects candidates --effort unassigned --json
@@ -321,20 +339,37 @@ type RecommendationQueueList = Success<{
321
339
  recommendation: RecommendationState;
322
340
  reward: RewardState;
323
341
  }>;
324
- pagination: Pagination & { scannedCount: number };
342
+ pagination: Pagination & {
343
+ scannedCount: number;
344
+ contentTypes?: Array<"comment" | "aiStory" | "dailyReflection">;
345
+ };
346
+ clientFilter?: {
347
+ contentTypes: Array<"comment" | "aiStory" | "dailyReflection">;
348
+ excludedItems: number;
349
+ };
325
350
  }>;
326
351
 
327
352
  type SubjectCandidates = Success<{
328
353
  subjects: Subject[];
329
- pagination: Pagination;
354
+ pagination: Pagination & { scannedCount: number };
330
355
  }>;
331
356
  ```
332
357
 
333
358
  Both cursors freeze a primary-key high-water mark and traverse descending IDs,
334
- so concurrent inserts cannot shift or duplicate later pages. A recommendation
335
- page can be empty while `hasMore` remains true; continue until `exhausted`.
336
- Subject `--after` is inclusive, and the opaque cursor is bound to its original
337
- date and effort filters.
359
+ so concurrent inserts cannot shift or duplicate later pages. Both walks scan a
360
+ bounded primary-key window (500 rows) per call before applying their residual
361
+ filters, so a page — recommendation or subject — can be empty while `hasMore`
362
+ remains true; continue until `exhausted`. Subject `--after` is inclusive, and
363
+ the opaque cursor is bound to its original date and effort filters.
364
+
365
+ `--content-types` is sent to APIs that support server-side filtering so excluded
366
+ types do not run their eligibility/content queries. The local CLI also filters
367
+ the returned page defensively for deployment compatibility. The server cursor
368
+ still advances across every underlying feed row, so excluding `aiStory` cannot
369
+ create gaps in later comment or Daily Reflection pages. New server cursors bind
370
+ the canonical content-type set; one legacy unbound cursor can be resumed and is
371
+ then reissued as bound. `clientFilter.excludedItems` makes any client-side
372
+ filtering explicit in JSON output.
338
373
 
339
374
  For a run-scoped command, `--identity zero|ciel` is an assertion against the
340
375
  server-selected run identity; it cannot switch actors locally. A mismatch
@@ -343,9 +378,14 @@ selection.
343
378
 
344
379
  ### Query and index design
345
380
 
346
- Subject and queue traversal are bounded primary-key walks; the queue reads at
347
- most 500 `noti_feeds` rows per cursor step before applying the existing Earn
348
- Recommend eligibility predicates. The joins/`NOT EXISTS` checks are necessary
381
+ Subject and queue traversal are bounded primary-key walks; the subject walk
382
+ reads at most 500 `content_subjects` rows per cursor step, and the queue reads
383
+ at most 500 `noti_feeds` rows per cursor step before applying the existing Earn
384
+ Recommend eligibility predicates. The effort projection's
385
+ `UPDATE noti_feeds ... WHERE type = 'subject' AND contentId = ?` reuses the
386
+ website's canonical reward-level projection shape; deployment should verify
387
+ `noti_feeds` carries an index whose leading columns cover `(type, contentId)`
388
+ (or `(contentId, ...)`) as the canonical route already requires. The joins/`NOT EXISTS` checks are necessary
349
389
  to preserve the normal recommendation and skip rules, but they run only for
350
390
  IDs in that bounded window. Subject-comment traversal uses
351
391
  `idx_comments_isDeleted_subject_id`; standalone-post comments use the existing
@@ -414,7 +454,13 @@ type SubjectGet = Success<{
414
454
  answer: string | null;
415
455
  attachment: unknown | null;
416
456
  };
457
+ // True only when comments were actually returned; a secret-gated subject
458
+ // reports false here (with secret.shown false) even when they were
459
+ // requested.
417
460
  commentsIncluded: boolean;
461
+ // The inline list is capped at 200 comments in conversation order; when
462
+ // true, page through `subject comments` for the rest.
463
+ commentsTruncated: boolean;
418
464
  comments: Comment[];
419
465
  };
420
466
  }>;
@@ -503,7 +549,11 @@ type FeaturedReorder = SubjectFeature;
503
549
  `reveal` publishes the existing hidden “viewed without responding” notification
504
550
  as the selected bot, with the ordinary notification and socket side effects.
505
551
  Effort assignment rejects an unrevealed secret subject. Creator attribution
506
- requires an attachment. Every response is reloaded from the writer.
552
+ requires an attachment. Both effort assignment and creator attribution enforce
553
+ the website's moderator-precedence rule: when a strictly higher-level
554
+ moderator recorded the current value, the mutation fails with
555
+ `CLI_ADMIN_MODERATOR_PRECEDENCE` (the bots compare at the shared canonical
556
+ effective level). Every response is reloaded from the writer.
507
557
 
508
558
  Featured reorder is a complete-set replacement: it rejects duplicates,
509
559
  unknown/deleted IDs, missing current members, non-subject rows, and more than
@@ -596,27 +646,263 @@ writer-locked state. A new approval reports the canonical 10-point contribution;
596
646
  a retry reports zero newly awarded and the same confirmed absolute balance.
597
647
 
598
648
  Recommendation history is checked across both management bots, so rotation
599
- does not recommend the same target again. Changing only `anyoneCanReward` does
600
- not rerun prior-recommender approval. Approval reward rows are locked and
601
- writer-read, so concurrent or restored attempts cannot insert the same
602
- approval twice.
649
+ does not recommend the same target again — unless the request asks for
650
+ `--anyone-can-reward` and the other bot's recommendation does not carry that
651
+ permission, in which case the actor proceeds with its own recommendation so
652
+ the requested permission and paired reward are honored rather than silently
653
+ dropped. When the other bot's recommendation does satisfy the request, the
654
+ `managementBotDeduplication` payload is returned and any requested 3-Twinkle
655
+ reward is still processed. A bare recommend never downgrades an existing
656
+ recommendation's anyone-can-reward permission; only an explicit
657
+ `--anyone-can-reward` changes it, and only in the granting direction.
658
+ Changing only `anyoneCanReward` does not rerun prior-recommender approval.
659
+ Approval reward rows are locked and writer-read, so concurrent or restored
660
+ attempts cannot insert the same approval twice.
603
661
 
604
662
  Both the standalone and combined reward paths also inspect existing 3-Twinkle
605
663
  management rewards across Zero and Ciel. A canonical three from either bot is
606
664
  reported as already rewarded instead of adding another management reward.
607
665
 
608
666
  The separate 3-Twinkle reward targets the worthwhile canonical post or comment.
609
- It uses the selected bot and Twinkle's ordinary canonical balance, Level, and
610
- recipient rules; Mikey is never charged while Zero/Ciel is displayed. Thus the
611
- bot's canonical balance is charged whenever the normal economy requires
612
- payment, while the existing Level-based no-charge rule remains unchanged. The
667
+ It uses the selected bot and Twinkle's ordinary canonical Level and recipient
668
+ rules; Mikey is never charged while Zero/Ciel is displayed. Zero and Ciel are
669
+ exempt from recommendation and reward coin charges in the shared canonical
670
+ mutation helpers, so neither `insufficient_coins` nor a bot balance decrease
671
+ can occur for these actors; every human actor still pays under the existing
672
+ Level-based rules. The
613
673
  reward transaction serializes the rewarder and cap-bearing content row, then
614
674
  adds only the amount needed for that actor to total exactly three. Existing
615
675
  three is `already_done`; a cap is `maximum_reached`.
616
676
  If recommendation succeeds but reward fails, the command exits nonzero with
617
677
  `partial_failure` and `retrySafe: true`.
618
678
 
619
- ## Persona-backed comments
679
+ ## Skip decisions
680
+
681
+ ```bash
682
+ lumine admin post skip dailyReflection:99 --json
683
+ lumine admin post skip comment:456 --reason "one-line answer, nothing to add" --json
684
+ ```
685
+
686
+ A skip records that the management rotation has judged a recommend-queue item
687
+ and decided not to act, so neither bot's queue resurfaces it. It writes the
688
+ same canonical `users_earn_skip_status` row the website's Earn page writes
689
+ (`earnType 'karma'`, `action 'recommendation'`) under the acting bot, and the
690
+ queue eligibility predicates honor either bot's row. Human moderators' own
691
+ Earn queues are deliberately unaffected: the bots are not database supermods,
692
+ so a bot skip never hides content from a human, who may judge differently.
693
+
694
+ Targets are `comment`, `aiStory`, and `dailyReflection` only; subjects leave
695
+ their queue through effort assignment. Skipping an already-skipped item (by
696
+ either bot) returns `already_done` with `changed: false`. The optional
697
+ `--reason` (at most 500 characters) is stored in the private audit row's
698
+ metadata — it is the agent's memory of the judgment, not public content.
699
+ The skip requires the `recommendation:write` scope and is audited like every
700
+ other mutation.
701
+
702
+ ```ts
703
+ type PostSkip = Success<{
704
+ skip: {
705
+ contentType: "comment" | "aiStory" | "dailyReflection";
706
+ contentId: number;
707
+ url: string;
708
+ skippedByUserId: number;
709
+ skippedAt: number | null;
710
+ };
711
+ }>;
712
+ ```
713
+
714
+ ## Twinkle Newspaper
715
+
716
+ ```bash
717
+ lumine admin news --json
718
+ lumine admin news claim --json
719
+ lumine admin news submit --edition-id 42 --lease-token <token> \
720
+ --file editorial.json --model "Claude" --json
721
+ lumine admin news print --json
722
+ ```
723
+
724
+ The Twinkle Newspaper (Build app 1929) is normally printed by a community
725
+ member spending their own AI Energy. Making sure today's paper exists is part
726
+ of every delegated website-management run: check `lumine admin news` early in
727
+ the run, and if `printedToday` is false with no edition `pending` or
728
+ `generating`, print it.
729
+
730
+ **Preferred: write the editorial yourself.** `news claim` reserves today's
731
+ edition under the server's generation lease and returns the exact canonical
732
+ event digest the server would otherwise send to its own model, so no provider
733
+ API credits are spent. Write a `GeneratedEditorial` JSON and send it back with
734
+ `news submit` within the ten-minute lease. The server treats the editorial as
735
+ untrusted regardless of author: every story must cite an exact `eventKey`
736
+ from the digest, front-page `sourceQuote`s must be verbatim contiguous
737
+ passages of the cited event's summary (invalid quotes are replaced with
738
+ canonical text), section and page layout are server-enforced, announcements
739
+ are appended verbatim outside your output, and source visibility is
740
+ re-checked transactionally at commit.
741
+
742
+ ```ts
743
+ type GeneratedEditorial = {
744
+ mastheadHeadline: string;
745
+ mastheadDeck: string;
746
+ lead: { eventKey: string; headline: string; summary: string; sourceQuote: string } | null;
747
+ stories: Array<{ eventKey: string; headline: string; summary: string; sourceQuote: string }>;
748
+ editorsNote: string;
749
+ };
750
+ ```
751
+
752
+ Editorial rules (the same ones the server's own model works under): use only
753
+ the supplied events — never world news, invented names, invented statistics,
754
+ or unsupported claims. Subjects and shared Daily Reflections are the primary
755
+ authored material; only a `section: "front"` event may be the lead. Preserve
756
+ substance, names, and numbers. Do not mention official announcements (the
757
+ server adds them), and give non-front events an empty `sourceQuote`.
758
+
759
+ Claim edge cases: a quiet day (no editorial events) is committed as the
760
+ canonical quiet edition at claim time — no editorial needed, the response
761
+ says so. If the claim is not submitted before the lease expires, the server's
762
+ press worker falls back to generating the edition itself. `news submit`
763
+ failing with `CLI_ADMIN_NEWS_CLAIM_LOST` means the lease was superseded —
764
+ re-check `lumine admin news` and claim again only if the paper still needs
765
+ printing.
766
+
767
+ **Repairing a past edition.** `news claim --date YYYY-MM-DD` leases an
768
+ already-printed historical edition and returns a fresh digest of its original
769
+ coverage window (primary Subjects/Reflections are re-projected from canonical
770
+ tables, and anything since deleted or made private drops out). Submitting
771
+ appends the next revision — every prior press run stays browsable in the
772
+ archive, and repairs never re-notify subscribers (only a day's first revision
773
+ does). Today's edition is never repaired this way; refreshing today is the
774
+ Newspaper owner's website-only action. Repair only when an edition is
775
+ genuinely degraded (missing masthead, missing lead, empty pages), not to
776
+ rewrite history editorially.
777
+
778
+ **Fallback: queue the server's own model.** `news print` reserves the edition
779
+ and lets the server's press worker write it (spends provider credits). It is
780
+ idempotent per day: it queues a new edition when today has none, requeues a
781
+ retry when today's only attempts failed, and returns `already_done` when the
782
+ paper is printed or being typeset.
783
+
784
+ Neither path ever reprints or refreshes an already-printed edition —
785
+ refreshing is the Newspaper owner's website-only action. The acting bot is
786
+ recorded as the requester, and the management bots are exempt from AI Energy
787
+ for newspaper generation: the platform absorbs the cost, exactly like their
788
+ coin-exempt recommends and rewards. When a day's first edition is printed,
789
+ the server notifies the app's notification subscribers (users can mute the
790
+ app or unsubscribe in the app; the bots never need to send anything). All
791
+ three mutations require the `news:print` scope (in every run's base scopes)
792
+ and are audited as `news.print` / `news.claim` / `news.submit` against
793
+ `news_edition` targets.
794
+
795
+ ```ts
796
+ type NewsStatus = Success<{
797
+ newspaper: {
798
+ dayIndex: number;
799
+ dateKey: string; // YYYY-MM-DD
800
+ printedToday: boolean;
801
+ generationStatus:
802
+ | "available" // no edition requested today
803
+ | "pending"
804
+ | "generating"
805
+ | "ready"
806
+ | "failed";
807
+ failureMessage: string | null;
808
+ attempts: number | null;
809
+ latestPrinted: {
810
+ dayIndex: number;
811
+ dateKey: string;
812
+ generatedAt: number | null;
813
+ sourceEventCount: number;
814
+ revisionNumber: number;
815
+ } | null; // most recent printed edition, possibly a previous day
816
+ nextEditionAt: number;
817
+ printDecision: "already_printed" | "in_progress" | "retry" | "create";
818
+ requestedAction?: "none" | "retry" | "create"; // print responses only
819
+ };
820
+ }>;
821
+
822
+ type NewsPrint = NewsStatus; // "success" (queued) or "already_done"
823
+
824
+ type NewsClaim = Success<{
825
+ newspaper: NewsStatus["data"]["newspaper"] & {
826
+ quietEditionPrinted?: boolean;
827
+ };
828
+ claim: {
829
+ editionId: number;
830
+ dayIndex: number;
831
+ dateKey: string;
832
+ leaseToken: string;
833
+ leaseExpiresAt: number;
834
+ coverage: { startedAt: number; endedAt: number };
835
+ maxSourceQuoteLength: number;
836
+ announcementCount: number;
837
+ events: Array<{
838
+ eventKey: string;
839
+ kind: string;
840
+ section: string; // front | community | notices | scores | marketplace
841
+ occurredAt: number;
842
+ priority: number;
843
+ title: string;
844
+ summary: string;
845
+ payload: unknown; // may include author and canonical topComments
846
+ }>;
847
+ } | null; // null: already printed/typesetting, or the quiet edition auto-committed
848
+ }>;
849
+
850
+ type NewsSubmit = NewsStatus; // "success"; newspaper includes revisionNumber
851
+ ```
852
+
853
+ ## Audit history
854
+
855
+ ```bash
856
+ lumine admin audit list --json
857
+ lumine admin audit list --run current --json
858
+ lumine admin audit list --run last --actions recommendation.skip --json
859
+ lumine admin audit list --target dailyReflection:99 --full --json
860
+ lumine admin audit list --cursor '<cursor>' --limit 50 --json
861
+ ```
862
+
863
+ Lists the operator's own private audit events, newest first, so an agent can
864
+ see what earlier runs did. `--run` accepts `current`, `last`, or a run ID;
865
+ `--target` accepts `<targetType>:<id>`; `--actions` is a comma-separated
866
+ action list. Filters are bound into the cursor exactly like the other list
867
+ cursors. The walk is a bounded descending primary-key traversal over the
868
+ existing operator/run/target audit indexes.
869
+
870
+ Rows are compact by default (identifiers, action, target, result, response
871
+ `status`/`changed`, timestamps, and the request's idempotency key). `--full`
872
+ adds the stored `beforeState`, `afterState`, `responseJson`, and `metadata`
873
+ payloads — the same data the mutation already returned to this operator. The
874
+ private per-attempt fencing token is never returned. Reading audit history
875
+ requires only the `content:read` scope of an active run.
876
+
877
+ ```ts
878
+ type AuditEvent = {
879
+ id: number;
880
+ runId: number | null;
881
+ publicActorUserId: number | null;
882
+ sessionKind: string;
883
+ action: string;
884
+ targetType: string | null;
885
+ targetId: number | null;
886
+ requestId: string;
887
+ result: string; // in_progress | completed | failed | partial_failure | bookkeeping_pending
888
+ status: string | null; // response status, e.g. success | already_done
889
+ changed: boolean | null;
890
+ createdAt: number;
891
+ completedAt: number | null;
892
+ // Present only with --full:
893
+ beforeState?: unknown;
894
+ afterState?: unknown;
895
+ responseJson?: unknown;
896
+ metadata?: unknown;
897
+ };
898
+
899
+ type AuditList = Success<{
900
+ events: AuditEvent[];
901
+ pagination: Pagination;
902
+ }>;
903
+ ```
904
+
905
+ ## Persona-backed comments and replies
620
906
 
621
907
  ```bash
622
908
  lumine admin daily-run start --identity auto --comment-mode draft --json
@@ -626,21 +912,44 @@ lumine admin daily-run start --identity ciel --comment-mode post \
626
912
  --run-key daily:2026-08-06:comments --json
627
913
  lumine admin comment draft 123 --identity ciel \
628
914
  --idempotency-key comment-123-draft-v1 --json
915
+ lumine admin comment draft dailyReflection:99 --json
916
+ lumine admin comment reply comment:456 --json
629
917
  lumine admin comment post --draft-id 77 \
630
918
  --idempotency-key comment-123-post-v1 --json
631
919
  ```
632
920
 
921
+ A draft targets one of:
922
+
923
+ - `subject:<id>` (or a bare numeric ID) — a top-level comment on the subject;
924
+ - `aiStory:<id>` / `dailyReflection:<id>` — a top-level comment on the
925
+ standalone post;
926
+ - `comment:<id>` — a public reply to that specific comment. `comment reply`
927
+ is the same operation and requires a comment target.
928
+
929
+ A reply's container resolves canonically from the target comment: its subject,
930
+ or its AI Story / Daily Reflection root. Comments under any other root are
931
+ rejected with `CLI_ADMIN_UNSUPPORTED_REPLY_ROOT`. Replies to Zero/Ciel
932
+ comments and to notification comments are rejected with
933
+ `CLI_ADMIN_INVALID_REPLY_TARGET` — the bots never thread with themselves or
934
+ each other, and a human's later reply to a delegated comment still enters the
935
+ existing autonomous comment-assistant pipeline. Published replies carry the
936
+ ordinary thread linkage (thread root and reply-to), and notification fan-out
937
+ uses the normal canonical path.
938
+
633
939
  ```ts
634
940
  type CommentDraft = Success<{
635
941
  draft: {
636
942
  id: number;
637
943
  runId: number;
638
- subjectId: number;
639
- subjectUrl: string;
944
+ targetType: "subject" | "comment" | "aiStory" | "dailyReflection";
945
+ targetId: number;
946
+ targetUrl: string;
947
+ subjectId: number | null; // container subject; null for standalone posts
948
+ subjectUrl: string | null;
640
949
  publicActorUserId: number;
641
950
  commentMode: "draft" | "post";
642
951
  personaRevision: string; // SHA-256; raw prompt is never returned
643
- contextRevision: string; // SHA-256 of canonical subject/comments
952
+ contextRevision: string; // SHA-256 of canonical container/comments/target
644
953
  decision: "draft" | "skip";
645
954
  reason: string | null;
646
955
  content: string | null;
@@ -663,24 +972,44 @@ type CommentPost = CommentGet & {
663
972
  };
664
973
  published: {
665
974
  commentId: number;
666
- subjectId: number;
667
- subjectUrl: string;
975
+ targetType: "subject" | "comment" | "aiStory" | "dailyReflection";
976
+ targetId: number;
977
+ subjectId: number | null;
978
+ subjectUrl: string | null;
979
+ containerUrl: string;
668
980
  commentUrl: string;
669
981
  };
670
982
  };
671
983
  };
672
984
  ```
673
985
 
674
- The server loads the canonical subject and complete visible comment context,
675
- then invokes the existing exact Zero/Ciel system prompt through the shared
676
- response assembler. The raw prompt is never returned or audited. The model—not
677
- regexes or keyword rules—chooses `draft` or `skip` under the run policy.
678
-
679
- Draft IDs are bound to operator, run, public bot, subject, comment mode,
680
- context revision, persona revision, expiry, and idempotency key. Posting locks
681
- the draft and context in the same transaction as the ordinary comment insert.
682
- Changed context or persona rejects publication and requires regeneration.
683
- Retries return the already-published comment instead of duplicating it.
986
+ The server loads the canonical container (subject or standalone post) and its
987
+ complete visible comment context, then invokes the existing exact Zero/Ciel
988
+ system prompt through the shared response assembler with a mode-specific
989
+ decision policy (comment vs reply). The raw prompt is never returned or
990
+ audited. The model—not regexes or keyword rules—chooses `draft` or `skip`
991
+ under the run policy.
992
+
993
+ Draft IDs are bound to operator, run, public bot, target, comment mode,
994
+ context revision, persona revision, expiry, and idempotency key. The context
995
+ revision covers the container, every visible comment, and the target binding,
996
+ so a thread that changes between draft and publish rejects publication.
997
+ Posting locks the draft and context in the same transaction as the ordinary
998
+ comment insert. Changed context or persona rejects publication and requires
999
+ regeneration. Retries return the already-published comment instead of
1000
+ duplicating it. Secret-subject gating applies whenever the container is a
1001
+ subject, including replies inside it.
1002
+
1003
+ Draft idempotency keys are permanent per operator: supplying a key that an
1004
+ earlier run (or another target) already used fails rather than resolving to
1005
+ the old reservation — normally as the audit layer's
1006
+ `CLI_ADMIN_AUDIT_IDENTITY_MISMATCH` (different run) or
1007
+ `CLI_ADMIN_IDEMPOTENCY_KEY_MISMATCH` (different target), with
1008
+ `CLI_ADMIN_DRAFT_KEY_REUSED` as the draft-table backstop. All three mean the
1009
+ same thing: embed the run or date in any caller-supplied draft key and retry
1010
+ with a fresh one. Note that the agent's
1011
+ own `subject effort set` or `creator set-made-by-poster` between draft and
1012
+ post changes the context revision — order those mutations before drafting.
684
1013
 
685
1014
  ## Audit, sockets, and deployment
686
1015
 
@@ -692,9 +1021,9 @@ cookies, passwords, or raw system prompts.
692
1021
 
693
1022
  Retry acquisition and completion are row-locked and fenced by a private
694
1023
  per-attempt token, so an expired request cannot overwrite a newer retry. A new
695
- recommendation and its normal recommendation coin charge commit together; the
696
- canonical prior-recommender approval and the separate 3-Twinkle reward remain
697
- independently retryable.
1024
+ recommendation commits atomically (the management bots are exempt from the
1025
+ recommendation coin charge); the canonical prior-recommender approval and the
1026
+ separate 3-Twinkle reward remain independently retryable.
698
1027
 
699
1028
  Public content actions use ordinary Twinkle fan-out:
700
1029
 
@@ -705,9 +1034,11 @@ Public content actions use ordinary Twinkle fan-out:
705
1034
  - effort/creator changes emit `edit_content`;
706
1035
  - Featured changes emit a canonical `home_outdated` refresh.
707
1036
 
708
- Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql` before
709
- deploying the API. It adds only focused daily-run, rotation, draft, and audit
710
- tables and indexes; there are no runtime schema checks. The local CLI changes
1037
+ Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql` and
1038
+ then `add-lumine-admin-comment-targets.sql` before deploying the API. They add
1039
+ only focused daily-run, rotation, draft, and audit tables/columns and indexes;
1040
+ there are no runtime schema checks. The comment-targets migration backfills
1041
+ existing subject drafts into the generalized target columns. The local CLI changes
711
1042
  are not available to users until a separately authorized npm publication.
712
1043
 
713
1044
  Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,