@stage5/lumine 0.2.62 → 0.2.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -381,6 +381,8 @@ lumine admin featured list --json
381
381
  lumine admin subject feature 123 --json
382
382
  lumine admin subject unfeature 123 --json
383
383
  lumine admin featured reorder --subject-ids 30,20,10 --json
384
+ lumine admin featured rotate --remove-subject-ids 30,20 \
385
+ --add-subject-ids 50,40 --json
384
386
  lumine admin brief --days 3 --json
385
387
  lumine admin notable add Minecrarft_guy --note "Created 8 thoughtful subjects and helped peers in 23 comments this window." --json
386
388
  lumine admin post recommend comment:456 --anyone-can-reward --reward-twinkles 3 --json
@@ -542,6 +542,12 @@ function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
542
542
  const last = lastPage || { ok: true, status: "success", data: {} };
543
543
  const lastData = { ...(last.data || {}) };
544
544
  delete lastData[operation.pagination.collectionKey];
545
+ const lastPagination = { ...(last.data?.pagination || {}) };
546
+ const pageScannedCount = normalizeScannedCount(
547
+ lastPagination.scannedCount,
548
+ 0,
549
+ );
550
+ delete lastPagination.scannedCount;
545
551
  if (state.filterSummariesComplete) {
546
552
  if (state.clientFilter) lastData.clientFilter = state.clientFilter;
547
553
  else delete lastData.clientFilter;
@@ -568,7 +574,8 @@ function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
568
574
  data: {
569
575
  ...lastData,
570
576
  pagination: {
571
- ...(last.data?.pagination || {}),
577
+ ...lastPagination,
578
+ pageScannedCount,
572
579
  nextCursor: state.nextCursor,
573
580
  hasMore: !state.exhausted,
574
581
  exhausted: state.exhausted,
package/lib/admin.js CHANGED
@@ -171,12 +171,32 @@ export async function adminCommand(options) {
171
171
  scope: operation.mutates ? "build:write" : "build:read",
172
172
  });
173
173
  let runId = 0;
174
+ let correctionSessionId = 0;
175
+ let correctionSession = null;
174
176
  if (adminOperationRequiresRun(operation)) {
175
- const runStatus = await requestJson({
176
- url: `${options.apiUrl}/cli/admin/daily-runs/status`,
177
- authToken: auth.token,
178
- timeoutMs: options.timeoutMs,
179
- });
177
+ if (operation.correctionEligible) {
178
+ const correctionStatus = await requestJson({
179
+ url: `${options.apiUrl}/cli/admin/corrections/status`,
180
+ authToken: auth.token,
181
+ timeoutMs: options.timeoutMs,
182
+ });
183
+ const correction = correctionStatus?.data?.correction || null;
184
+ if (
185
+ correction?.status === "active" &&
186
+ Number(correction.correctionCommentId || 0) ===
187
+ Number(operation.correctionCommentId || 0)
188
+ ) {
189
+ correctionSessionId = Number(correction.id || 0);
190
+ correctionSession = correction;
191
+ }
192
+ }
193
+ const runStatus = correctionSessionId
194
+ ? null
195
+ : await requestJson({
196
+ url: `${options.apiUrl}/cli/admin/daily-runs/status`,
197
+ authToken: auth.token,
198
+ timeoutMs: options.timeoutMs,
199
+ });
180
200
  const activeRun = runStatus?.data?.run || null;
181
201
  const retryableFinishedRun = [
182
202
  "daily-run.complete",
@@ -186,15 +206,29 @@ export async function adminCommand(options) {
186
206
  : null;
187
207
  const selectedRun = activeRun || retryableFinishedRun;
188
208
  runId = Number(selectedRun?.id || 0);
189
- if (!runId) throw noActiveRunError();
190
- if (options.adminIdentity) {
209
+ if (!runId && !correctionSessionId) {
210
+ if (operation.correctionEligible) {
211
+ const error = new Error(
212
+ `Start a correction session first: lumine admin correction start ${operation.correctionCommentId}.`,
213
+ );
214
+ error.code = "CLI_ADMIN_CORRECTION_NOT_ACTIVE";
215
+ throw error;
216
+ }
217
+ throw noActiveRunError();
218
+ }
219
+ if (options.adminIdentity && (selectedRun || correctionSession)) {
191
220
  const requestedIdentity = parseIdentity(options.adminIdentity);
221
+ const canonicalIdentity = correctionSession
222
+ ? correctionSession.identity?.key
223
+ : selectedRun?.identity?.key;
192
224
  if (
193
225
  requestedIdentity !== "auto" &&
194
- requestedIdentity !== selectedRun?.identity?.key
226
+ requestedIdentity !== canonicalIdentity
195
227
  ) {
196
228
  throw cliValidationError(
197
- `--identity ${requestedIdentity} does not match active run #${runId} (${selectedRun?.identity?.key || "unknown"}).`,
229
+ correctionSession
230
+ ? `--identity ${requestedIdentity} does not match correction session #${correctionSessionId} (${canonicalIdentity || "unknown"}).`
231
+ : `--identity ${requestedIdentity} does not match active run #${runId} (${canonicalIdentity || "unknown"}).`,
198
232
  );
199
233
  }
200
234
  }
@@ -211,6 +245,13 @@ export async function adminCommand(options) {
211
245
  body: operation.body,
212
246
  headers: {
213
247
  ...(runId ? { "x-lumine-admin-run-id": String(runId) } : {}),
248
+ ...(correctionSessionId
249
+ ? {
250
+ "x-lumine-admin-correction-session-id": String(
251
+ correctionSessionId,
252
+ ),
253
+ }
254
+ : {}),
214
255
  ...(requestId ? { "x-lumine-idempotency-key": requestId } : {}),
215
256
  },
216
257
  timeoutMs: options.timeoutMs,
@@ -663,6 +704,9 @@ function adminOperationRequiresRun(operation) {
663
704
  "rescue.wordle.audit",
664
705
  "daily-run.start",
665
706
  "daily-run.status",
707
+ "correction.start",
708
+ "correction.status",
709
+ "correction.complete",
666
710
  "escalation.list",
667
711
  "escalation.set",
668
712
  "notable.add",
@@ -694,6 +738,39 @@ export function parseAdminOperation(options) {
694
738
  const [namespace = "", action = "", target = "", extra = ""] =
695
739
  options.positional;
696
740
 
741
+ if (namespace === "correction" || namespace === "corrections") {
742
+ if (!action || action === "status") {
743
+ return readOperation("correction.status", "/cli/admin/corrections/status");
744
+ }
745
+ if (action === "start") {
746
+ const commentId = parseRequiredInteger(target, "comment ID", 1);
747
+ return writeOperation(
748
+ "correction.start",
749
+ "POST",
750
+ "/cli/admin/corrections",
751
+ {
752
+ commentId,
753
+ },
754
+ );
755
+ }
756
+ if (action === "complete" || action === "stop") {
757
+ const correctionId = parseRequiredInteger(
758
+ target,
759
+ "correction session ID",
760
+ 1,
761
+ );
762
+ return writeOperation(
763
+ "correction.complete",
764
+ "POST",
765
+ `/cli/admin/corrections/${correctionId}/complete`,
766
+ {},
767
+ );
768
+ }
769
+ throw cliValidationError(
770
+ "Usage: lumine admin correction start <commentId> | correction status | correction complete <sessionId>.",
771
+ );
772
+ }
773
+
697
774
  if (namespace === "identity") {
698
775
  if (action === "list") {
699
776
  return readOperation("identity.list", "/cli/admin/identities");
@@ -1256,11 +1333,17 @@ export function parseAdminOperation(options) {
1256
1333
  if (action === "reorder") {
1257
1334
  return featuredReorderOperation(options);
1258
1335
  }
1336
+ if (action === "rotate") {
1337
+ return featuredRotateOperation(options);
1338
+ }
1259
1339
  }
1260
1340
 
1261
1341
  if (namespace === "comments" && action === "get") {
1262
1342
  const commentId = parseRequiredInteger(target, "comment ID", 1);
1263
- return readOperation("comments.get", `/cli/admin/comments/${commentId}`);
1343
+ return readOperation("comments.get", `/cli/admin/comments/${commentId}`, {
1344
+ correctionEligible: true,
1345
+ correctionCommentId: commentId,
1346
+ });
1264
1347
  }
1265
1348
 
1266
1349
  if (namespace === "post") {
@@ -1674,6 +1757,10 @@ export function parseAdminOperation(options) {
1674
1757
  "PUT",
1675
1758
  `/cli/admin/comments/${commentId}`,
1676
1759
  { content: readComposedTextFile(options.adminFile) },
1760
+ {
1761
+ correctionEligible: true,
1762
+ correctionCommentId: commentId,
1763
+ },
1677
1764
  );
1678
1765
  }
1679
1766
  if (action === "post") {
@@ -1738,7 +1825,10 @@ function postGetOperation(target, options) {
1738
1825
  return subjectGetOperation(parsedTarget.id, options);
1739
1826
  }
1740
1827
  if (parsedTarget.type === "comment") {
1741
- return readOperation("post.get", `/cli/admin/comments/${parsedTarget.id}`);
1828
+ return readOperation("post.get", `/cli/admin/comments/${parsedTarget.id}`, {
1829
+ correctionEligible: true,
1830
+ correctionCommentId: parsedTarget.id,
1831
+ });
1742
1832
  }
1743
1833
  return readOperation(
1744
1834
  "post.get",
@@ -1775,6 +1865,7 @@ function legacySubjectsOperation({ action, target, options }) {
1775
1865
  );
1776
1866
  }
1777
1867
  if (action === "reorder") return featuredReorderOperation(options);
1868
+ if (action === "rotate") return featuredRotateOperation(options);
1778
1869
  throw cliValidationError(
1779
1870
  `Unknown subjects action: ${action || "(missing)"}.`,
1780
1871
  );
@@ -1799,6 +1890,37 @@ function featuredReorderOperation(options) {
1799
1890
  );
1800
1891
  }
1801
1892
 
1893
+ function featuredRotateOperation(options) {
1894
+ const removeIds = parseFeaturedSubjectIds(
1895
+ options.adminRemoveIds,
1896
+ "--remove-subject-ids",
1897
+ );
1898
+ const addIds = parseFeaturedSubjectIds(
1899
+ options.adminAddIds,
1900
+ "--add-subject-ids",
1901
+ );
1902
+ if (removeIds.length !== addIds.length) {
1903
+ throw cliValidationError(
1904
+ "Featured rotation requires the same number of removal and addition IDs.",
1905
+ );
1906
+ }
1907
+ const removeSet = new Set(removeIds);
1908
+ if (addIds.some((id) => removeSet.has(id))) {
1909
+ throw cliValidationError(
1910
+ "Featured rotation removal and addition IDs must not overlap.",
1911
+ );
1912
+ }
1913
+ return writeOperation(
1914
+ "featured.rotate",
1915
+ "PUT",
1916
+ "/cli/admin/subjects/featured/rotation",
1917
+ {
1918
+ removeIds,
1919
+ addIds,
1920
+ },
1921
+ );
1922
+ }
1923
+
1802
1924
  function recommendOperation(target, options) {
1803
1925
  const recommendationTarget = parseRecommendationTarget({
1804
1926
  target,
@@ -2066,8 +2188,8 @@ function bodyReadOperation(name, method, path, body) {
2066
2188
  return { name, method, path, body, mutates: false };
2067
2189
  }
2068
2190
 
2069
- function writeOperation(name, method, path, body) {
2070
- return { name, method, path, body, mutates: true };
2191
+ function writeOperation(name, method, path, body, extra = {}) {
2192
+ return { name, method, path, body, mutates: true, ...extra };
2071
2193
  }
2072
2194
 
2073
2195
  function withQuery(path, values) {
@@ -2172,18 +2294,24 @@ function parseTodoListStatus(value) {
2172
2294
  }
2173
2295
 
2174
2296
  function parseOrderedIds(value) {
2297
+ return parseFeaturedSubjectIds(value, "--subject-ids", true);
2298
+ }
2299
+
2300
+ function parseFeaturedSubjectIds(value, flag, completeList = false) {
2175
2301
  const ids = String(value || "")
2176
2302
  .split(",")
2177
2303
  .map((part) => part.trim())
2178
2304
  .filter(Boolean)
2179
2305
  .map((part) => parseRequiredInteger(part, "Featured subject ID", 1));
2180
- if (!String(value || "").trim()) {
2306
+ if (!String(value || "").trim() || ids.length === 0) {
2181
2307
  throw cliValidationError(
2182
- "Pass the complete ordered list with --subject-ids <id,id,...>.",
2308
+ completeList
2309
+ ? "Pass the complete ordered list with --subject-ids <id,id,...>."
2310
+ : `Pass at least one subject with ${flag} <id,id,...>.`,
2183
2311
  );
2184
2312
  }
2185
2313
  if (new Set(ids).size !== ids.length) {
2186
- throw cliValidationError("Featured subject IDs must be unique.");
2314
+ throw cliValidationError(`${flag} subject IDs must be unique.`);
2187
2315
  }
2188
2316
  return ids;
2189
2317
  }
@@ -2810,6 +2938,11 @@ function printAdminResult({ operation, result }) {
2810
2938
  console.log(
2811
2939
  `Run #${data.run.id}: ${data.run.status}; identity ${data.run.identity.key}; comments ${data.run.commentMode}.`,
2812
2940
  );
2941
+ if (data.scheduledDay && data.scheduledIdentity) {
2942
+ console.log(
2943
+ `Bangkok schedule for ${data.scheduledDay}: ${data.scheduledIdentity.key}.`,
2944
+ );
2945
+ }
2813
2946
  if (data.carryoverTodos) {
2814
2947
  printTodoItems(data.carryoverTodos.items || [], "Carry-over work");
2815
2948
  }
@@ -2824,6 +2957,11 @@ function printAdminResult({ operation, result }) {
2824
2957
  }
2825
2958
  if (Object.hasOwn(data, "activeRun")) {
2826
2959
  console.log(`Preferred identity: ${data.preferredIdentity}.`);
2960
+ if (data.scheduledDay && data.scheduledIdentity) {
2961
+ console.log(
2962
+ `Bangkok schedule for ${data.scheduledDay}: ${data.scheduledIdentity.key}.`,
2963
+ );
2964
+ }
2827
2965
  console.log(
2828
2966
  `Last completed identity: ${data.lastCompletedIdentity || "none"}.`,
2829
2967
  );
package/lib/commands.js CHANGED
@@ -2436,6 +2436,16 @@ export function parseArgs(args) {
2436
2436
  : raw.ids
2437
2437
  ? String(raw.ids)
2438
2438
  : "",
2439
+ adminRemoveIds: raw.removeSubjectIds
2440
+ ? String(raw.removeSubjectIds)
2441
+ : raw.removeIds
2442
+ ? String(raw.removeIds)
2443
+ : "",
2444
+ adminAddIds: raw.addSubjectIds
2445
+ ? String(raw.addSubjectIds)
2446
+ : raw.addIds
2447
+ ? String(raw.addIds)
2448
+ : "",
2439
2449
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2440
2450
  adminLabel: raw.label ? String(raw.label) : "",
2441
2451
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
@@ -2834,6 +2844,7 @@ export function printHelp() {
2834
2844
  lumine admin subject feature|unfeature <subject-id> [--json]
2835
2845
  lumine admin featured list [--unviewed|--viewed] [--json]
2836
2846
  lumine admin featured reorder --subject-ids <id,id,...> [--json]
2847
+ lumine admin featured rotate --remove-subject-ids <id,id,...> --add-subject-ids <id,id,...> [--json]
2837
2848
  lumine admin post get <target> [--type subject|comment|aiStory|dailyReflection] [--json]
2838
2849
  lumine admin post comments <target> [--type subject|aiStory|dailyReflection] [--unviewed|--viewed] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--json]
2839
2850
  lumine admin post recommend <target> [--type subject|comment|aiStory|dailyReflection] [--anyone-can-reward] [--reward-twinkles 3] [--json]
@@ -2954,6 +2965,8 @@ Options:
2954
2965
  --idempotency-key <k> Stable retry key for one admin mutation
2955
2966
  --level <1|2|3> Admin subject effort level
2956
2967
  --subject-ids <ids> Complete ordered Featured subject IDs
2968
+ --remove-subject-ids <ids> Featured subjects approved for rotation removal
2969
+ --add-subject-ids <ids> Ordered replacement subjects for Featured rotation
2957
2970
  --bucket-id <id> Unbanned AI identity bucket for account consolidation
2958
2971
  --label <name> Name for a new unbanned AI identity bucket
2959
2972
  --user-ids <ids> Explicit user IDs for an AI bucket batch (up to 500)
@@ -478,8 +478,12 @@ async function changeDutyState(options, action) {
478
478
  );
479
479
  if (duties.length === 0) {
480
480
  if (action === "stop") {
481
- const localState = await readSponsorState(options, { required: false });
482
- if (!localState) {
481
+ const localRecord = await readSponsorStateForStop(options);
482
+ const localState = localRecord.state;
483
+ if (localState) assertSponsorStateAccount(localState, auth);
484
+ const preservesAnotherAccountState =
485
+ sponsorStateBelongsToAnotherAccount(localRecord.invalidState, auth);
486
+ if (!localState && !localRecord.invalidStatePath) {
483
487
  printJsonOrLines(
484
488
  options,
485
489
  { changed: false, duty: null },
@@ -487,30 +491,52 @@ async function changeDutyState(options, action) {
487
491
  );
488
492
  return;
489
493
  }
490
- assertSponsorStateAccount(localState, auth);
491
- const preservedWorkspaces = dutyWorkspacePaths(localState);
492
- const localArchive =
493
- preservedWorkspaces.length > 0
494
+ const preservedWorkspaces = localState
495
+ ? dutyWorkspacePaths(localState)
496
+ : [];
497
+ const invalidArchive =
498
+ localRecord.invalidStatePath && !preservesAnotherAccountState
499
+ ? await tryArchiveInvalidSponsorStateForStop(options)
500
+ : null;
501
+ const localArchive = invalidArchive
502
+ ? invalidArchive.localArchive
503
+ : preservedWorkspaces.length > 0
494
504
  ? await archiveSponsorState(options, localState)
495
505
  : null;
496
- if (!localArchive) await removeSponsorState(options);
506
+ if (!localArchive && localState) await removeSponsorState(options);
497
507
  printJsonOrLines(
498
508
  options,
499
509
  {
500
510
  changed: false,
501
511
  duty: null,
502
512
  ...(localArchive ? { localArchive, preservedWorkspaces } : {}),
513
+ ...(invalidArchive?.localCleanupWarning
514
+ ? { localCleanupWarning: invalidArchive.localCleanupWarning }
515
+ : preservesAnotherAccountState
516
+ ? {
517
+ localCleanupWarning:
518
+ "Preserved an outdated local duty record belonging to another sponsor account.",
519
+ }
520
+ : {}),
503
521
  },
504
522
  [
505
523
  "No active sponsor duty session.",
506
524
  ...(localArchive
507
525
  ? [
508
- `Preserved the expired job record at ${localArchive}.`,
526
+ localRecord.invalidStatePath
527
+ ? `Preserved the unreadable or outdated local duty record at ${localArchive}.`
528
+ : `Preserved the expired job record at ${localArchive}.`,
509
529
  ...preservedWorkspaces.map(
510
530
  (workspace) => `Preserved workspace: ${workspace}`,
511
531
  ),
512
532
  ]
513
- : ["Removed the stale local duty record."]),
533
+ : invalidArchive?.localCleanupWarning
534
+ ? [invalidArchive.localCleanupWarning]
535
+ : preservesAnotherAccountState
536
+ ? [
537
+ "Preserved an outdated local duty record belonging to another sponsor account.",
538
+ ]
539
+ : ["Removed the stale local duty record."]),
514
540
  ],
515
541
  );
516
542
  return;
@@ -523,8 +549,18 @@ async function changeDutyState(options, action) {
523
549
  );
524
550
  }
525
551
  const dutyId = Number(duties[0].id || 0);
526
- let localState = await readSponsorState(options, { required: false });
552
+ const localRecord =
553
+ action === "stop"
554
+ ? await readSponsorStateForStop(options)
555
+ : {
556
+ state: await readSponsorState(options, { required: false }),
557
+ invalidStatePath: null,
558
+ invalidState: null,
559
+ };
560
+ let localState = localRecord.state;
527
561
  if (localState) assertSponsorStateAccount(localState, auth);
562
+ const preservesAnotherAccountState =
563
+ sponsorStateBelongsToAnotherAccount(localRecord.invalidState, auth);
528
564
  if (action === "resume") {
529
565
  localState = await loadOwnedState({ options, auth, state: localState });
530
566
  if (Number(localState.duty.id) !== dutyId) {
@@ -550,8 +586,16 @@ async function changeDutyState(options, action) {
550
586
  },
551
587
  });
552
588
  let localArchive = null;
589
+ let localCleanupWarning = null;
553
590
  let preservedWorkspaces = [];
554
- if (localState && Number(localState.duty?.id) === dutyId) {
591
+ if (
592
+ nextState === "stopped" &&
593
+ localRecord.invalidStatePath &&
594
+ !preservesAnotherAccountState
595
+ ) {
596
+ ({ localArchive, localCleanupWarning } =
597
+ await tryArchiveInvalidSponsorStateForStop(options));
598
+ } else if (localState && Number(localState.duty?.id) === dutyId) {
555
599
  if (nextState === "stopped") {
556
600
  preservedWorkspaces = dutyWorkspacePaths(localState);
557
601
  if (preservedWorkspaces.length > 0) {
@@ -568,6 +612,10 @@ async function changeDutyState(options, action) {
568
612
  await writeSponsorState(options, localState);
569
613
  }
570
614
  }
615
+ if (nextState === "stopped" && preservesAnotherAccountState) {
616
+ localCleanupWarning =
617
+ "Preserved an outdated local duty record belonging to another sponsor account.";
618
+ }
571
619
  printJsonOrLines(
572
620
  options,
573
621
  {
@@ -575,17 +623,21 @@ async function changeDutyState(options, action) {
575
623
  ...(localArchive
576
624
  ? { localArchive, preservedWorkspaces }
577
625
  : {}),
626
+ ...(localCleanupWarning ? { localCleanupWarning } : {}),
578
627
  },
579
628
  [
580
629
  `Sponsor duty is now ${nextState}.`,
581
630
  ...(localArchive
582
631
  ? [
583
- `Preserved the expired job record at ${localArchive}.`,
632
+ localRecord.invalidStatePath
633
+ ? `Preserved the unreadable or outdated local duty record at ${localArchive}.`
634
+ : `Preserved the expired job record at ${localArchive}.`,
584
635
  ...preservedWorkspaces.map(
585
636
  (workspace) => `Preserved workspace: ${workspace}`,
586
637
  ),
587
638
  ]
588
639
  : []),
640
+ ...(localCleanupWarning ? [localCleanupWarning] : []),
589
641
  ],
590
642
  );
591
643
  }
@@ -1511,6 +1563,14 @@ function assertSponsorStateAccount(state, auth) {
1511
1563
  }
1512
1564
  }
1513
1565
 
1566
+ function sponsorStateBelongsToAnotherAccount(state, auth) {
1567
+ return Boolean(
1568
+ auth.userId &&
1569
+ state?.sponsorUserId &&
1570
+ Number(auth.userId) !== Number(state.sponsorUserId),
1571
+ );
1572
+ }
1573
+
1514
1574
  async function readSponsorState(options, { required = true } = {}) {
1515
1575
  const filePath = sponsorDutyStatePath(options);
1516
1576
  try {
@@ -1536,6 +1596,31 @@ async function readSponsorState(options, { required = true } = {}) {
1536
1596
  }
1537
1597
  }
1538
1598
 
1599
+ async function readSponsorStateForStop(options) {
1600
+ const filePath = sponsorDutyStatePath(options);
1601
+ try {
1602
+ const rawState = await fs.readFile(filePath, "utf8");
1603
+ try {
1604
+ const state = JSON.parse(rawState);
1605
+ if (
1606
+ Number(state?.version) === STATE_VERSION &&
1607
+ state?.duty?.id &&
1608
+ state?.duty?.leaseToken
1609
+ ) {
1610
+ return { state, invalidStatePath: null, invalidState: null };
1611
+ }
1612
+ return { state: null, invalidStatePath: filePath, invalidState: state };
1613
+ } catch {
1614
+ return { state: null, invalidStatePath: filePath, invalidState: null };
1615
+ }
1616
+ } catch (error) {
1617
+ if (error.code === "ENOENT") {
1618
+ return { state: null, invalidStatePath: null, invalidState: null };
1619
+ }
1620
+ return { state: null, invalidStatePath: filePath, invalidState: null };
1621
+ }
1622
+ }
1623
+
1539
1624
  async function writeSponsorState(options, state) {
1540
1625
  const filePath = sponsorDutyStatePath(options);
1541
1626
  const directory = path.dirname(filePath);
@@ -1566,6 +1651,36 @@ async function removeSponsorState(options) {
1566
1651
  });
1567
1652
  }
1568
1653
 
1654
+ async function archiveInvalidSponsorState(options) {
1655
+ const filePath = sponsorDutyStatePath(options);
1656
+ const archivePath = `${filePath}.invalid-${Date.now()}`;
1657
+ try {
1658
+ await fs.rename(filePath, archivePath);
1659
+ } catch (error) {
1660
+ if (error.code === "ENOENT") return null;
1661
+ throw error;
1662
+ }
1663
+ await fs.chmod(archivePath, 0o600);
1664
+ return archivePath;
1665
+ }
1666
+
1667
+ async function tryArchiveInvalidSponsorStateForStop(options) {
1668
+ try {
1669
+ return {
1670
+ localArchive: await archiveInvalidSponsorState(options),
1671
+ localCleanupWarning: null,
1672
+ };
1673
+ } catch (error) {
1674
+ const statePath = sponsorDutyStatePath(options);
1675
+ const message =
1676
+ error instanceof Error ? error.message : "unknown local filesystem error";
1677
+ return {
1678
+ localArchive: null,
1679
+ localCleanupWarning: `The canonical duty stopped, but the unreadable local record remains at ${statePath}: ${message}`,
1680
+ };
1681
+ }
1682
+ }
1683
+
1569
1684
  async function withSponsorStateLock(options, operation) {
1570
1685
  const lockPath = `${sponsorDutyStatePath(options)}.lock`;
1571
1686
  const lockDirectory = path.dirname(lockPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,11 +39,19 @@ canonical structured data.
39
39
  described below. The human's normal AI Energy and sponsor path applies;
40
40
  Lumine does not need to remain running.
41
41
 
42
- `auto` selects Zero first when no completed rotation exists, then alternates
43
- after a successfully completed run that performed a mutation. Failed,
44
- abandoned, expired, and read-only runs do not advance rotation. Reusing a run
45
- key returns the original run and identity. One writer-locked identity-state row
46
- serializes concurrent starts.
42
+ The Zero/Ciel shift is assigned by **Asia/Bangkok calendar day, not by run**.
43
+ The schedule is anchored with 2026-08-31 assigned to Zero and alternates by
44
+ elapsed Bangkok dates from there. Every automatic primary, supplemental,
45
+ retry, read-only, or resumed run started on one date uses that date's same
46
+ actor; a skipped date still counts in the alternation. Completing, failing,
47
+ abandoning, or expiring a run never changes the schedule. An explicit
48
+ `--identity zero` or `--identity ciel` is an auditable override for that run
49
+ only: it does not rewrite the scheduled identity. Automatic runs expire at the
50
+ next Bangkok midnight so yesterday's actor cannot continue as today's
51
+ automatic identity. Reusing a live run key still returns the original run and
52
+ identity. `identity use zero|ciel` is the separate, explicit persistent
53
+ override for future starts; it remains in force until `identity use auto`, and
54
+ does not alter the underlying calendar schedule reported by status commands.
47
55
 
48
56
  Comment mode is stored only on the current run:
49
57
 
@@ -245,7 +253,15 @@ posts that most need Zero or Ciel are the ones nobody else answered.
245
253
  mention is what notifies him (`postComment` runs `processMentions` /
246
254
  `postMentions` and emits `new_targeted_upload`), so a bug-report comment
247
255
  without `@mikey` fails its main job. Restate what the child observed; never
248
- promise a fix or a timeline.
256
+ promise a fix or a timeline. This reporting duty does not suspend the acting
257
+ bot's character: the public comment must still sound like Zero or Ciel
258
+ talking naturally to that member, not like an operator, ticket, or incident
259
+ report. Read the full thread first. If the bot already noticed, explained, or
260
+ apologized for the bug, do not post another reply that treats it as a fresh
261
+ discovery or repeats the same acknowledgement. Continue from what the bot
262
+ already said and add only what is missing, such as naturally bringing
263
+ `@mikey` into the conversation. Put the formal defect summary, evidence, and
264
+ review request in the private run escalation.
249
265
  - **Guide users through the website, without waiting to be asked.** A post can
250
266
  show that a child is stuck, confused, or unaware a feature exists without ever
251
267
  containing a question — someone begging for coins who does not know about
@@ -262,15 +278,45 @@ posts that most need Zero or Ciel are the ones nobody else answered.
262
278
  question should normally receive Level 3. Level 3 is the highest delegated
263
279
  setting, and the level tells respondents that substantial, carefully
264
280
  reasoned answers are wanted.
281
+ - **Ongoing series are participation, not noise.** Daily records, journals,
282
+ logs, recurring updates, and serialized creative work are not spam, filler,
283
+ engagement farming, or duplicates merely because they reuse a title or
284
+ format, or because one installment is concise. Review the series context and
285
+ what the current entry contributes. Never lower effort, skip, withhold a
286
+ recommendation or comment, or propose Featured removal solely because of
287
+ that recurring format or brevity; only an actual duplicate or genuine noise
288
+ is treated as such.
265
289
  - **Sincere requests for personal help are Featured material.** Posts asking
266
290
  the community for advice about school, friendships, loneliness, or other
267
291
  ordinary real-life problems embody Twinkle's purpose; being personal is never
268
- a reason to suppress or remove them. Rotate one out after it has received
269
- meaningful support to make room for a newer overlooked request — not because
270
- of its subject matter. Only a concrete safety/privacy issue or the author's
271
- request changes that visibility judgment.
272
- - **Featured still selects for quality**, but when two candidates are close,
273
- prefer the child who has never been featured over the one who has.
292
+ a reason to suppress or remove them. Receiving meaningful support does not
293
+ make one stale or create a duty to rotate it out; keep judging what the live
294
+ board contributes now, and never use prior recognition as a removal proxy.
295
+ - **Speculative privacy is never an editorial signal.** Do not lower, remove,
296
+ unfeature, or escalate content because it might identify someone, mentions a
297
+ school, city, class, or ordinary location, or invites everyday community
298
+ context. Those possibilities carry zero weight in Featured decisions. An
299
+ actual sensitive disclosure or an author's explicit removal request follows
300
+ the separate concrete-safety path; it does not make the post low-quality.
301
+ - **Featured values participation, continuity, and child voice—not adult
302
+ polish.** Accessible questions that many children can answer and ongoing
303
+ personal series such as records, journals, or recurring updates are strong
304
+ Featured forms. Age, brevity, simplicity, prior recognition, or a modest
305
+ description is not a removal reason. Review the whole subject and its role in
306
+ the community instead of grading its opening text like an essay.
307
+ - **A "new" Featured addition has two non-negotiable eligibility gates.** When
308
+ Mikey asks for new Featured subjects, additions, or replacements, a candidate
309
+ must both (1) have been posted recently and (2) have never appeared on the
310
+ Featured board before. Unless Mikey gives a different recency window,
311
+ "recently" means the preceding seven calendar days; do not widen into older
312
+ inventory merely to fill capacity or produce a longer proposal list. Absence
313
+ from the current `featured list` proves only that a Subject is not Featured
314
+ now — it does not prove that the Subject has never been Featured. Verify
315
+ lifetime Featured history from canonical evidence before recommending or
316
+ adding it. If the available CLI/API cannot prove that history, leave the
317
+ candidate out and report the capability gap instead of guessing. Quality is
318
+ still required after both eligibility gates pass; recent and never-Featured
319
+ does not make filler acceptable.
274
320
  - **The live Featured board is Mikey's word.** Do not remove or reorder a
275
321
  currently Featured subject without first showing Mikey the planned removals
276
322
  and replacements and getting his go-ahead. Additions have standing approval
@@ -282,7 +328,8 @@ posts that most need Zero or Ciel are the ones nobody else answered.
282
328
  deliberately — never re-feature it to "restore" the board, even when space
283
329
  is available, and never treat any subject as a permanent fixture from memory
284
330
  or old run notes. Derive the board fresh from `featured list` at the start of
285
- every run; the only pins that exist are the ones currently on it.
331
+ every Featured review or mutation; the only pins that exist are the ones
332
+ currently on it.
286
333
 
287
334
  Sensitive disclosures, active disputes, and anything needing crisis or medical
288
335
  judgment remain out of scope for a bot comment no matter how neglected the post
@@ -296,11 +343,13 @@ happen. **Every run ends with an escalation list**, and it belongs in the run's
296
343
  final report whether or not anyone asks for it.
297
344
 
298
345
  Keep that list narrow enough to be useful. Escalate concrete child-safety,
299
- exploitation, privacy, targeted harassment, or platform/system-abuse risk — not
346
+ exploitation, targeted harassment, or platform/system-abuse risk — not
300
347
  ordinary children experimenting, arguing, making rumors, proposing informal
301
348
  in-site loans or contests, asking where media can be found, or making an
302
349
  unverified ownership claim. Those may merit a normal age-appropriate response,
303
350
  but they are not escalations without credible harmful conduct or a real victim.
351
+ Hypothetical identifiability and ordinary school, city, class, or location
352
+ references are not escalation signals.
304
353
 
305
354
  Escalate, with the canonical `https://www.twin-kle.com/subjects/<id>` or
306
355
  `/comments/<id>` URL, a one-line summary, and why it needs him:
@@ -565,6 +614,8 @@ type DailyRun = {
565
614
  failedAt: number | null;
566
615
  failureReason: string | null;
567
616
  identity: Identity;
617
+ scheduledDay: string; // YYYY-MM-DD in Asia/Bangkok
618
+ scheduledIdentity: Identity;
568
619
  };
569
620
  ```
570
621
 
@@ -592,6 +643,9 @@ type IdentityList = Success<{
592
643
  type IdentityStatus = Success<{
593
644
  preferredIdentity: "auto" | "zero" | "ciel";
594
645
  lastCompletedIdentity: "zero" | "ciel" | null;
646
+ scheduledDay: string;
647
+ scheduledIdentity: Identity;
648
+ scheduleTimeZone: "Asia/Bangkok";
595
649
  activeRun: DailyRun | null;
596
650
  }>;
597
651
 
@@ -643,8 +697,10 @@ type IdentityCandidate = {
643
697
  };
644
698
  ```
645
699
 
646
- `identity use` changes only the preference for a future start. It never changes
647
- an active run or advances rotation.
700
+ `identity use zero|ciel` changes the explicit persistent override for future
701
+ starts; `identity use auto` returns future starts to the Bangkok calendar
702
+ schedule. It never changes an active run, alters the reported scheduled
703
+ identity, or advances rotation.
648
704
 
649
705
  ```bash
650
706
  lumine admin daily-run start --identity auto --comment-mode off --json
@@ -668,6 +724,9 @@ Schemas:
668
724
  ```ts
669
725
  type DailyRunStart = Success<{
670
726
  run: DailyRun;
727
+ scheduledDay: string;
728
+ scheduledIdentity: Identity;
729
+ scheduleTimeZone: "Asia/Bangkok";
671
730
  carryoverTodos: CarryoverTodos;
672
731
  }>;
673
732
  type DailyRunStatus = Success<{
@@ -676,7 +735,7 @@ type DailyRunStatus = Success<{
676
735
  }>;
677
736
  type DailyRunComplete = Success<{
678
737
  run: DailyRun;
679
- rotationAdvanced: boolean;
738
+ rotationAdvanced: false; // legacy field; calendar schedules never advance by run
680
739
  }>;
681
740
  type DailyRunFail = DailyRunComplete;
682
741
  ```
@@ -730,9 +789,12 @@ section.** Base it on a fresh `featured list`. When capacity exists, make and
730
789
  report strong additions during the run under the standing approval above; do
731
790
  not defer them as proposals. Then name each current Subject proposed for
732
791
  removal with its canonical URL and a concrete editorial reason, followed by
733
- any replacement that would require that removal. Never omit the section; when
734
- no removal or reorder is honestly warranted, say `None` and explain why.
735
- Removing or reordering pins remains a proposal until Mikey gives his go-ahead.
792
+ any replacement that would require that removal. Every proposed or completed
793
+ addition described as new must include its posting date and canonical evidence
794
+ that it has never been Featured; omit it when either gate is unverified. Never
795
+ omit the section; when no removal or reorder is honestly warranted, say `None`
796
+ and explain why. Removing or reordering pins remains a proposal until Mikey
797
+ gives his go-ahead.
736
798
 
737
799
  Creating an escalation belongs to the active run; acknowledging, annotating,
738
800
  resolving, or reopening it does not. Use the run-independent `escalation`
@@ -1108,6 +1170,7 @@ type CommentGet = Success<{
1108
1170
  id: number;
1109
1171
  url: string;
1110
1172
  title: string | null;
1173
+ author: Author;
1111
1174
  secretShown: boolean;
1112
1175
  } | null;
1113
1176
  }>;
@@ -1139,6 +1202,8 @@ lumine admin subject feature 123 --json
1139
1202
  lumine admin subject unfeature 123 --json
1140
1203
  lumine admin featured list --json
1141
1204
  lumine admin featured reorder --subject-ids 30,20,10 --json
1205
+ lumine admin featured rotate --remove-subject-ids 30,20 \
1206
+ --add-subject-ids 50,40 --json
1142
1207
  ```
1143
1208
 
1144
1209
  Schemas:
@@ -1175,6 +1240,17 @@ type SubjectFeature = FeaturedList & {
1175
1240
 
1176
1241
  type SubjectUnfeature = SubjectFeature;
1177
1242
  type FeaturedReorder = SubjectFeature;
1243
+ type FeaturedRotate = FeaturedList & {
1244
+ status: "success" | "already_done";
1245
+ changed: boolean;
1246
+ data: FeaturedList["data"] & {
1247
+ rotation: {
1248
+ removeSubjectIds: number[];
1249
+ addSubjectIds: number[];
1250
+ finalSubjectIds: number[];
1251
+ };
1252
+ };
1253
+ };
1178
1254
  ```
1179
1255
 
1180
1256
  `reveal` publishes the existing hidden “viewed without responding” notification
@@ -1191,6 +1267,29 @@ unknown/deleted IDs, missing current members, non-subject rows, and more than
1191
1267
  20 subjects. Permanent pins and editorial ordering policy are deliberately not
1192
1268
  hardcoded.
1193
1269
 
1270
+ Featured rotate is the direct, atomic replacement verb for an approved
1271
+ rotation. `--remove-subject-ids` names the exact current members Mikey approved
1272
+ for removal; `--add-subject-ids` names the same number of replacements in
1273
+ descending editorial relevance. The API locks the canonical board, requires
1274
+ every removal to still be present and every addition to still be absent,
1275
+ validates every subject, then commits one complete-set replacement. Additions
1276
+ become the front of the board in the supplied order and all surviving members
1277
+ keep their relative order. A stale or partly mismatched plan fails without
1278
+ changing anything; an exact retry returns the canonical completed response.
1279
+ The response includes the canonical final board and the confirmed rotation
1280
+ IDs, so callers never synthesize Featured state locally.
1281
+
1282
+ The two lists must have the same nonzero length, so rotation never changes the
1283
+ board's count. That also lets an approved swap proceed when the website has
1284
+ left a pre-existing board above the delegated maximum without growing it;
1285
+ repair the inherited overflow separately with an approved `subject unfeature`.
1286
+
1287
+ This mutation deliberately does not choose candidates or override the
1288
+ editorial gates above. Before invoking it, the management agent must have
1289
+ freshly reviewed the board, shown Mikey the removals and replacements, received
1290
+ his go-ahead, and verified that every proposed new addition is recent and has
1291
+ never previously been Featured from canonical evidence.
1292
+
1194
1293
  ## Recommendation, Karma approval, and Twinkle rewards
1195
1294
 
1196
1295
  ```bash
@@ -2484,8 +2583,29 @@ type InsightsBrief = Success<{
2484
2583
  }>;
2485
2584
  ```
2486
2585
 
2586
+ ### Narrow comment-correction sessions
2587
+
2588
+ Correcting one existing bot comment does not require opening a daily run:
2589
+
2590
+ ```bash
2591
+ lumine admin correction start <commentId> --json
2592
+ lumine admin comments get <commentId> --json
2593
+ lumine admin comment edit <commentId> --file corrected.md --json
2594
+ lumine admin correction complete <sessionId> --json
2595
+ ```
2596
+
2597
+ The server derives Zero or Ciel from the comment's actual author; the caller
2598
+ cannot choose or override that identity. The authorization lasts ten minutes,
2599
+ is bound to that exact comment, permits only reading that target and editing
2600
+ it, and never runs daily duties, changes the Bangkok calendar assignment, or
2601
+ contributes to a daily-run mutation count. A correction cannot target a human
2602
+ comment, notification record, deleted comment, or Build thread. Build comments
2603
+ still require a fresh version-bound correction reply after genuinely reviewing
2604
+ the published app. Starting a newer correction supersedes an older active one.
2605
+ Finish it explicitly after the canonical edit is confirmed.
2606
+
2487
2607
  **Editing the bot's own comments.** `comment edit <commentId> --file
2488
- <comment.md>` replaces the text of a comment the ACTING bot itself authored —
2608
+ <comment.md>` replaces the text of a comment the acting bot itself authored —
2489
2609
  for correcting a factual error, an unfulfillable claim, or outdated guidance
2490
2610
  in Zero/Ciel's own words. It is deliberately not a moderation verb: comments
2491
2611
  by the other bot, by any human, and hidden notification records are all
@@ -2495,12 +2615,14 @@ composed-comment rules (plain UTF-8, 10,000-character limit, truth about what
2495
2615
  the session actually did) and publishes through the website's canonical
2496
2616
  comment-edit pipeline — mentions are reprocessed (a newly added `@mikey`
2497
2617
  notifies him), and Earn-candidate projections resync. Submitting identical
2498
- text returns `already_done`. Requires the `comment:post` scope of a
2499
- comment-mode `post` run, and is audited as `comment.edit` with the previous
2500
- content in `beforeState` and `data.edit.previousContent`. Edit sparingly:
2618
+ text returns `already_done`. It requires either the exact active correction
2619
+ session above or the `comment:post` scope of a comment-mode `post` run, and is
2620
+ audited as `comment.edit` with the previous content in `beforeState` and
2621
+ `data.edit.previousContent`. Edit sparingly:
2501
2622
  kids may have already read the original, so a comment that changed meaning
2502
2623
  (not just wording) usually deserves a follow-up reply instead of a silent
2503
- rewrite.
2624
+ rewrite. Inside a normal daily run it still requires that run's `comment:post`
2625
+ scope; for a one-comment repair, use the narrower correction session above.
2504
2626
 
2505
2627
  ## Direct bot chat messages
2506
2628
 
@@ -2657,6 +2779,33 @@ from memory:
2657
2779
  decisions are yours to make and record with `post skip` or in the run
2658
2780
  report).
2659
2781
 
2782
+ Persona and conversational state are continuous across the whole thread,
2783
+ including acknowledgements, corrections, bug reports, `@mikey` notifications,
2784
+ and other reporting duties. The acting bot must remember what it already said:
2785
+ do not reintroduce a conclusion, apology, explanation, question, or offer as if
2786
+ the earlier bot comment did not exist. A follow-up should advance the existing
2787
+ conversation and make its relationship to the prior reply natural and clear.
2788
+ Accuracy and escalation requirements never authorize a sudden formal admin
2789
+ voice. Before posting, read the full thread, identify the acting bot's existing
2790
+ claims and commitments, and preserve the canonical character plus the
2791
+ established conversational register where they agree: phrasing,
2792
+ capitalization, warmth, humor, and emoji restraint should feel like the same
2793
+ Zero or Ciel continuing the conversation. Do not mechanically copy typos,
2794
+ mistakes, unsafe behavior, or a voice that conflicts with the canonical
2795
+ persona. Keep the two surfaces distinct: the public comment is an in-character
2796
+ continuation for the member, while `daily-run escalation add` and the final
2797
+ management report carry the concise operator-facing diagnosis and evidence. A
2798
+ comment that is factually correct but ignores the bot's earlier reply, repeats
2799
+ it as new, or reads like a support ticket or management audit fails this
2800
+ requirement.
2801
+
2802
+ Keep participant ownership exact while continuing that thread. The
2803
+ `subject.author` (or root post's `author`) owns the topic, subject, post, or
2804
+ project. A selected comment's `author` owns only that comment. Addressing a
2805
+ commenter does not make the root content theirs: never call the root topic,
2806
+ subject, post, or project “yours” unless that participant is also its canonical
2807
+ author. Read both author fields before composing a reply.
2808
+
2660
2809
  **Lumine Build apps and app posts are composed-only, and only after actually looking
2661
2810
  (Mikey's direction, 2026-08-10).** When a post is about a Build app — the
2662
2811
  author shares their app, announces an update, or asks for feedback on their
@@ -2891,6 +3040,9 @@ and indexes;
2891
3040
  there are no runtime schema checks. The comment-targets migration backfills
2892
3041
  existing subject drafts into the generalized target columns. The local CLI changes
2893
3042
  are not available to users until a separately authorized npm publication.
3043
+ Deploy and verify the API's `/cli/admin/subjects/featured/rotation` route before
3044
+ publishing a CLI release that exposes `featured rotate`; an older API rejects
3045
+ the command without changing Featured state.
2894
3046
 
2895
3047
  Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
2896
3048
  `comments get`, and `recommend` remain accepted, but the singular command forms