@stage5/lumine 0.3.61 → 0.3.63

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.
@@ -23,6 +23,16 @@ function indent(text, prefix) {
23
23
  .join("\n");
24
24
  }
25
25
 
26
+ // A member's platinum cover photo goes through the same report queue
27
+ // (subjectType "profile_cover"); its snapshot is the cover as one line.
28
+ function isCoverReport(report) {
29
+ return report?.subjectType === "profile_cover";
30
+ }
31
+
32
+ function subjectNoun(report) {
33
+ return isCoverReport(report) ? "cover photo" : "message";
34
+ }
35
+
26
36
  function formatMessageLine(message, marker = " ") {
27
37
  const author = message.username || (message.userId ? `user #${message.userId}` : "unknown");
28
38
  const content = String(message.content ?? "").trim() || "(no text)";
@@ -32,6 +42,7 @@ function formatMessageLine(message, marker = " ") {
32
42
  }
33
43
 
34
44
  function where(report) {
45
+ if (isCoverReport(report)) return "their profile cover photo (only Mikey can take it down)";
35
46
  const parts = [];
36
47
  if (report.channelKind) parts.push(report.channelKind);
37
48
  if (report.channelId) parts.push(`channel ${report.channelId}`);
@@ -45,7 +56,7 @@ export function formatChatReportLine(report) {
45
56
  .replace(/\s+/g, " ")
46
57
  .trim();
47
58
  const short = preview.length > 80 ? `${preview.slice(0, 79)}…` : preview;
48
- return `#${report.id} ${String(report.status || "open").toUpperCase()} · ${report.reasonLabel || report.reason || "no reason"} · ${who(report.reporter)} → ${who(report.reported)} · ${formatTime(report.createdAt)}${Number(report.reportsOfThisMessage) > 1 ? ` · ${report.reportsOfThisMessage} reports of this message` : ""}${short ? `\n "${short}"` : ""}`;
59
+ return `#${report.id} ${String(report.status || "open").toUpperCase()} · ${report.reasonLabel || report.reason || "no reason"} · ${who(report.reporter)} → ${who(report.reported)} · ${formatTime(report.createdAt)}${Number(report.reportsOfThisMessage) > 1 ? ` · ${report.reportsOfThisMessage} reports of this ${subjectNoun(report)}` : ""}${short ? `\n "${short}"` : ""}`;
49
60
  }
50
61
 
51
62
  export function formatChatReportList(data = {}) {
@@ -71,7 +82,7 @@ export function formatChatReportList(data = {}) {
71
82
  export function formatChatReport(report = {}) {
72
83
  const lines = [];
73
84
  lines.push(
74
- `Chat report #${report.id}: ${String(report.status || "open").toUpperCase()} · filed ${formatTime(report.createdAt)}${report.ownerNotifiedAt ? ` · Mikey notified ${formatTime(report.ownerNotifiedAt)}` : ""}`,
85
+ `${isCoverReport(report) ? "Cover photo report" : "Chat report"} #${report.id}: ${String(report.status || "open").toUpperCase()} · filed ${formatTime(report.createdAt)}${report.ownerNotifiedAt ? ` · Mikey notified ${formatTime(report.ownerNotifiedAt)}` : ""}`,
75
86
  );
76
87
  lines.push(`Reporter → reported: ${who(report.reporter)} → ${who(report.reported)}`);
77
88
  lines.push(
@@ -81,7 +92,7 @@ export function formatChatReport(report = {}) {
81
92
  const place = where(report);
82
93
  if (place) lines.push(`Where: ${place}`);
83
94
  if (Number(report.reportsOfThisMessage) > 1) {
84
- lines.push(`This message has ${report.reportsOfThisMessage} reports.`);
95
+ lines.push(`This ${subjectNoun(report)} has ${report.reportsOfThisMessage} reports.`);
85
96
  }
86
97
  if (Array.isArray(report.safetyHoldIds) && report.safetyHoldIds.length) {
87
98
  lines.push(`Safety hold(s): #${report.safetyHoldIds.join(", #")}`);
@@ -96,8 +107,14 @@ export function formatChatReport(report = {}) {
96
107
 
97
108
  const message = report.message;
98
109
  lines.push("");
99
- lines.push(`Reported message${report.messageId ? ` #${report.messageId}` : ""}:`);
110
+ lines.push(
111
+ isCoverReport(report)
112
+ ? "Reported cover photo:"
113
+ : `Reported message${report.messageId ? ` #${report.messageId}` : ""}:`,
114
+ );
100
115
  lines.push(message ? formatMessageLine(message, ">") : " (no snapshot kept)");
116
+ // a cover has no surrounding conversation
117
+ if (isCoverReport(report)) return lines;
101
118
 
102
119
  const before = Array.isArray(report.context?.before) ? report.context.before : [];
103
120
  const after = Array.isArray(report.context?.after) ? report.context.after : [];
@@ -938,19 +938,12 @@ async function runChromeReview({
938
938
  child.exitCode !== null || child.signalCode !== null;
939
939
  const closeRequestedAt = Date.now();
940
940
  stderrAfterClose = "";
941
- await cdp.send("Browser.close").catch(() => {});
942
- let forcedTermination = false;
943
- const closeResult = await Promise.race([
944
- closed,
945
- delay(5_000).then(() => {
946
- forcedTermination = true;
947
- return stopChrome(child, closed);
948
- }),
949
- ]);
941
+ // Observation is complete; shutdown has its own bounded deadline.
942
+ clearTimeout(timer);
943
+ const closeResult = await closeReviewBrowser({ child, closed, cdp });
950
944
  return {
951
945
  ...closeResult,
952
946
  closeRequested: true,
953
- forcedTermination,
954
947
  evidenceCompleteBeforeClose,
955
948
  exitedBeforeClose,
956
949
  closeDurationMs: Date.now() - closeRequestedAt,
@@ -972,12 +965,40 @@ async function runChromeReview({
972
965
  }
973
966
  }
974
967
 
968
+ export async function closeReviewBrowser({ child, closed, cdp, graceMs = 12_000 }) {
969
+ // The macOS network-service watchdog can take ~10 s to print its shutdown
970
+ // diagnosis and exit 2. The former 5+3 s kill erased that evidence and made
971
+ // a completed app review look like an unexplained browser crash.
972
+ let closeAcknowledged = false, closeCommandError = null, forcedTermination = false;
973
+ const command = cdp.send("Browser.close").then(
974
+ () => { closeAcknowledged = true; },
975
+ error => { closeCommandError = String(error?.message || error).slice(0, 240); },
976
+ );
977
+ let result = await waitForChromeExit(closed, graceMs);
978
+ if (!result) {
979
+ forcedTermination = true;
980
+ result = await stopChrome(child, closed);
981
+ }
982
+ await command; // The CDP command has its existing timeout/channel-close bound.
983
+ return { ...result, forcedTermination, closeAcknowledged, closeCommandError };
984
+ }
985
+
986
+ async function waitForChromeExit(closed, milliseconds) {
987
+ let timer;
988
+ try {
989
+ return await Promise.race([
990
+ closed,
991
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), milliseconds); }),
992
+ ]);
993
+ } finally { clearTimeout(timer); }
994
+ }
995
+
975
996
  // SIGTERM, then SIGKILL if Chrome is still there after 3 s: a browser wedged
976
997
  // in teardown (the case this review must tolerate) can ignore SIGTERM, and
977
998
  // the review must never hang or leave Chrome running.
978
999
  async function stopChrome(child, closed) {
979
1000
  child.kill("SIGTERM");
980
- const ended = await Promise.race([closed, delay(3_000).then(() => null)]);
1001
+ const ended = await waitForChromeExit(closed, 3_000);
981
1002
  if (ended) return ended;
982
1003
  child.kill("SIGKILL");
983
1004
  return closed;
@@ -1151,6 +1172,8 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1151
1172
  exitCode: browser.code,
1152
1173
  signal: browser.signal,
1153
1174
  closeRequested: browser.closeRequested === true,
1175
+ closeAcknowledged: browser.closeAcknowledged === true,
1176
+ closeCommandError: browser.closeCommandError || null,
1154
1177
  forcedTermination: browser.forcedTermination === true,
1155
1178
  exitedCleanly: browser.code === 0 && !browser.signal,
1156
1179
  stdioDrainTimedOut: browser.stdioDrainTimedOut === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.61",
3
+ "version": "0.3.63",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.71.0
4
4
  Updated: 2026-10-24
5
- Generated: 2026-10-24T05:37:23.377Z
5
+ Generated: 2026-10-24T10:03:41.930Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -1500,17 +1500,19 @@ world.updatePresence({ x, y, z, facing });
1500
1500
 
1501
1501
  ### Twinkle.reflections
1502
1502
  - async getDailyReflections({ userIds, cursor, lastId, limit } = {}) | scopes: dailyReflections:read
1503
- - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
1503
+ - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question, dayType, pictureUrl, pictureStyle }], cursor? }
1504
1504
  - Read only currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
1505
1505
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
1506
1506
  - Only currently shared public reflections are returned. The response field is the stored shared response version, which may be raw or polished depending on what the author explicitly shared.
1507
1507
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
1508
+ - dayType is 'word' (Word Day), 'picture' (Picture Day) or null (an ordinary reflection). On a Picture Day the member described a picture instead of reflecting: pictureUrl is the picture they chose (null when they finished with words only), pictureStyle the style they asked for, and response is their description. Show the picture with the description, not the stock question.
1508
1509
  - async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
1509
- - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
1510
+ - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question, dayType, pictureUrl, pictureStyle }], cursor? }
1510
1511
  - Read one user's currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
1511
1512
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
1512
1513
  - Only currently shared public reflections are returned. The response field is the stored shared response version, which may be raw or polished depending on what the author explicitly shared.
1513
1514
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
1515
+ - dayType is 'word' (Word Day), 'picture' (Picture Day) or null (an ordinary reflection). On a Picture Day the member described a picture instead of reflecting: pictureUrl is the picture they chose (null when they finished with words only), pictureStyle the style they asked for, and response is their description. Show the picture with the description, not the stock question.
1514
1516
 
1515
1517
  ### Twinkle.privateDb
1516
1518
  - async get(key) | scopes: privateDb:read
@@ -1588,7 +1590,7 @@ world.updatePresence({ x, y, z, facing });
1588
1590
 
1589
1591
  ### Twinkle.rewards
1590
1592
  - await Twinkle.rewards.getStatus() | scopes: rewards:claim
1591
- - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, missedRematch?: { afterDays, perDay, usedToday, ready: [{ ruleId, setKey, variantOf, missed }], upcoming, nextReadyDay }, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, category?, series?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1593
+ - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, craftDiscovery?: { pendingByRule: { [ruleId]: number } }, missedRematch?: { afterDays, perDay, usedToday, ready: [{ ruleId, setKey, variantOf, missed }], upcoming, nextReadyDay }, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, category?, series?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
1592
1594
  - Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
1593
1595
  - rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
1594
1596
  - challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
@@ -1599,6 +1601,7 @@ world.updatePresence({ x, y, z, facing });
1599
1601
  - nonogram-picture-v1 uses Twinkle’s private, editorially reviewed picture bank. Set pictureSize to 5, 10 or 15 on each completion rule. Do not bundle daily solutions or a fixed answer catalog in app files. The collection is maintained through lumine admin nonogram; the reward amounts and daily limits still come from the approved rules.
1600
1602
  - Collection progression keeps every approved keyed set available independently of other players' solves. collectionSets lists { setKey, number }; challenges carry collection, practice, completed and completion. Live status includes viewerId for a per-viewer durable outbox. Attempts and completion persist across days/releases; the daily reward allowance does not reset a puzzle's paid tries. Existing receipts (including rematches), Vault solves/practice and studied guides count. Completed puzzles can be revisited; do not treat any receipt for the grade as completing all its puzzles. Collection rules support up to 500 permanent sets per rule; legacy queued/dated rules keep their 62-set limit.
1601
1603
  - missedRematch appears when rewards.json declares missedRematch ({ afterDays, perDay }; it requires userDailyClaims) and the app has collection rules. A collection set may declare variantOf: another set's key, meaning the same puzzle with new numbers (an ordinary puzzle for everyone). A learner who missed any puzzle of a family (an original plus its variants) and has not solved one of its puzzles on the first try gets the family's first unmet puzzle as a rematch afterDays site days after their last answer in the family. ready lists today's rematches (variantOf is the family's original, missed the puzzle this learner got wrong most recently; once perDay are used it only keeps one already opened today): open setKey with start({ ruleId, setKey }) at any time (before or after today's bounties) and the challenge comes back with rematch: { variantOf, answered }: one answer, the rule's full first-try amounts, a receipt (rematch: true) that does not count toward userDailyClaims but does count toward the XP/Coin budgets. usedToday against perDay says how many rematches were opened today; opening one uses it. upcoming and nextReadyDay describe rematches not ready yet.
1604
+ - For craft-discovery-v1 rules, live status includes craftDiscovery.pendingByRule: counts of this viewer’s unpaid discoveries eligible for each configured rule. Use these advisory counts to skip empty tier probes; they are not rewards or a promise that a later claim will succeed. claim still checks and consumes canonical proof transactionally. Refresh status after claims. Older servers and preview mode may omit this field.
1602
1605
  - await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
1603
1606
  - Returns: { mode: "live", status: "awarded" | "completed" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
1604
1607
  - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
@@ -2826,8 +2826,9 @@ never previously been Featured from canonical evidence.
2826
2826
 
2827
2827
  Keep current manual pins from the past two days (Mikey, October 13, 2026).
2828
2828
  Verify canonical pin history and exclude those subjects from removal proposals.
2829
- Subject 3529, “What is special about Twinkle?”, also has an explicit keep
2830
- instruction. An absent pin is still never restored. This supersedes the older
2829
+ Subject3529, “What is special about Twinkle?”, had an October13 keep instruction,
2830
+ but Mikey removed it on October19 at06:28:27UTC (canonical event2504).
2831
+ Do not restore it from that superseded keep note. An absent pin is still never restored. This supersedes the older
2831
2832
  instruction to offer yesterday's manual pins for rotation.
2832
2833
 
2833
2834
  For the daily old-to-new proposal, prepare a canonical plan without changing pins: