@stage5/lumine 0.3.58 → 0.3.59

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
@@ -134,6 +134,15 @@ version, records the same save metadata, and marks public builds as having
134
134
  unpublished changes. For projects you own, run `lumine launch` to publish the
135
135
  saved changes, or `lumine save --publish` to save and publish in one step.
136
136
 
137
+ For automation, `lumine save --json` emits one canonical JSON receipt with
138
+ `schemaVersion`, `ok`, the saved artifact metadata, `build`, and `publication`.
139
+ `lumine versions --json` returns the server's version history without prose.
140
+ If `save --publish --json` saves successfully but publication fails, the receipt
141
+ still identifies that saved version, sets `ok: false`, and reports the publication
142
+ error; the command exits nonzero. A contribution branch reports
143
+ `publication.published: false` with `reason: "contribution_requires_owner"`.
144
+ Saving a branch does not publish the owner's app.
145
+
137
146
  Pulled workspaces include `AGENTS.md` and `CLAUDE.md` guides for local coding
138
147
  agents, `TWINKLE_BUILD_SDK.md` with the current Build SDK reference, plus
139
148
  `.twinkle/lumine-project.json` metadata that tells agents whether the checkout
package/lib/admin.js CHANGED
@@ -5877,8 +5877,14 @@ function printAdminMonthlyAiCosts(monthlyAiCosts) {
5877
5877
 
5878
5878
  function printAdminDailyAiCosts(dailyAiCosts) {
5879
5879
  console.log(
5880
- `Application AI-cost ledger for ${dailyAiCosts.day.dayKey} (closed UTC day): ${formatAdminUsd(dailyAiCosts.summary?.estimatedCostUsd)} across ${Number(dailyAiCosts.summary?.requestCount || 0)} request(s).`,
5880
+ `Application AI usage before credits for ${dailyAiCosts.day.dayKey} (closed UTC day): ${formatAdminUsd(dailyAiCosts.summary?.estimatedCostUsd)} across ${Number(dailyAiCosts.summary?.requestCount || 0)} request(s).`,
5881
5881
  );
5882
+ const credit = dailyAiCosts.creditAccounting;
5883
+ if (credit?.status === "estimated" && Number.isFinite(credit.creditUsd) && Number.isFinite(credit.estimatedCashUsd)) {
5884
+ console.log(`Promotional credit: ${formatAdminUsd(credit.creditUsd)}; estimated cash cost: ${formatAdminUsd(credit.estimatedCashUsd)}.`);
5885
+ } else {
5886
+ console.log("Promotional credit and estimated cash cost unavailable; the usage total above is before credits.");
5887
+ }
5882
5888
  for (const row of dailyAiCosts.byProviderModel || []) {
5883
5889
  console.log(
5884
5890
  ` ${row.provider || "unknown"}/${row.model || "unknown"}: ${formatAdminUsd(row.estimatedCostUsd)} · ${Number(row.totalTokens || 0)} token(s) · ${Number(row.requestCount || 0)} request(s).`,
package/lib/commands.js CHANGED
@@ -1276,7 +1276,7 @@ export async function save(options) {
1276
1276
  lastSavedAt: new Date().toISOString(),
1277
1277
  filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
1278
1278
  });
1279
- if (!options.quiet) {
1279
+ if (!options.quiet && !options.json) {
1280
1280
  printSaveResult({
1281
1281
  result,
1282
1282
  build,
@@ -1286,14 +1286,31 @@ export async function save(options) {
1286
1286
  });
1287
1287
  }
1288
1288
 
1289
+ const jsonReceipt = (publication = null, ok = true) => {
1290
+ if (options.json && !options.quiet)
1291
+ console.log(JSON.stringify({ ...result, schemaVersion: 1, ok, build, publication }));
1292
+ };
1289
1293
  if (options.publish) {
1290
1294
  if (build?.canPublish === false) {
1291
- console.log(
1295
+ if (!options.json) console.log(
1292
1296
  "Saved to your branch. The project owner can merge or replace main from Twinkle.",
1293
1297
  );
1298
+ jsonReceipt({ published: false, reason: "contribution_requires_owner" });
1299
+ return result;
1300
+ }
1301
+ let publish;
1302
+ try { publish = await publishBuild({ options, buildId, auth }); }
1303
+ catch (error) {
1304
+ // Saving already succeeded. Preserve its exact receipt even if the
1305
+ // separate publication fails; the command still exits nonzero.
1306
+ jsonReceipt({ published: false, error: String(error?.message || error) }, false);
1307
+ throw error;
1308
+ }
1309
+ if (options.json) {
1310
+ build = publish.build || build;
1311
+ jsonReceipt({ published: true, skipped: Boolean(publish.skipped), build: publish.build || null });
1294
1312
  return result;
1295
1313
  }
1296
- const publish = await publishBuild({ options, buildId, auth });
1297
1314
  if (publish.skipped) {
1298
1315
  console.log("Publish skipped: already up to date.");
1299
1316
  } else {
@@ -1304,6 +1321,7 @@ export async function save(options) {
1304
1321
  );
1305
1322
  console.log(`App: ${options.siteUrl}/app/${buildId}`);
1306
1323
  }
1324
+ jsonReceipt();
1307
1325
  return result;
1308
1326
  }
1309
1327
 
@@ -1979,7 +1997,8 @@ export async function versionsCommand(options) {
1979
1997
  buildId,
1980
1998
  limit: options.limit,
1981
1999
  });
1982
- printVersionList({ result });
2000
+ if (options.json) console.log(JSON.stringify(result));
2001
+ else printVersionList({ result });
1983
2002
  }
1984
2003
 
1985
2004
  export function printVersionList({ result }) {
@@ -3196,7 +3215,7 @@ export function printHelp() {
3196
3215
  lumine pull [twinkle-build-url] --main
3197
3216
  lumine pull [twinkle-build-url] --version <n> [--main]
3198
3217
  lumine pull [twinkle-build-url] --published [--main]
3199
- lumine versions [twinkle-build-url] [--main] [--limit <n>]
3218
+ lumine versions [twinkle-build-url] [--main] [--limit <n>] [--json]
3200
3219
  lumine restore <n> [--main]
3201
3220
  lumine reference <twinkle-build-url>
3202
3221
  lumine fork <twinkle-build-url>
@@ -3204,7 +3223,7 @@ export function printHelp() {
3204
3223
  lumine merge <twinkle-branch-url>
3205
3224
  lumine replace-main <twinkle-branch-url>
3206
3225
  lumine update-from-main [twinkle-branch-url]
3207
- lumine save
3226
+ lumine save [--summary <text>] [--publish] [--json]
3208
3227
  lumine check [twinkle-build-url]
3209
3228
  lumine launch [twinkle-build-url]
3210
3229
  lumine sdk list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.58",
3
+ "version": "0.3.59",
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.68.0
4
4
  Updated: 2026-10-16
5
- Generated: 2026-10-22T07:54:40.033Z
5
+ Generated: 2026-10-23T06:17:07.718Z
6
6
 
7
7
  ## Notes
8
8
  - Reward is part of completion: a reward-paying run must stay pending until awarded:true and its canonical receipt (or getReceipt status awarded). Do not show final solved/completed status, advance rewarded progress, or discard finished proof before that. Preserve the exact challenge/proof and acknowledged progress in a durable per-viewer outbox; reconcile getReceipt after a lost reply and retry the same challenge, never inventing a payment. Explain explicit practice, already collected, exhausted approved limits, preview and disclosed zero-value retry outcomes. Use the start response as a conservative elapsed-time reference, and test delayed start, network failure, lost claim reply, reload, day/viewer change and duplicate recovery.
@@ -1510,7 +1510,8 @@ world.updatePresence({ x, y, z, facing });
1510
1510
  - Pass null as expectedValue for an absent/null value. Both values are limited to 16 KB. Optional expectedUserId prevents a held operation from crossing an account change.
1511
1511
  - operationId is required: 8–64 letters, digits, underscores or hyphens. Reuse it for retries of the same logical change.
1512
1512
  - On conflict, rebase the intent onto the returned canonical item before comparing again. Do not retry-loop a 429.
1513
- - A duplicate operation returns the current canonical item without applying again. Ordinary set/remove remain unconditional; use a dedicated key for a compare-and-save workflow.
1513
+ - An applied response confirms the save; use its canonical item as the next expectedValue. JSON storage may normalize floating-point numbers, so byte-for-byte equality with the submitted value is not a valid success check.
1514
+ - A duplicate operation returns the current canonical item without applying again. matchesSubmittedValue is true when that current item equals this operation's submitted value after database normalization, and false if another save has since changed it. A receipt alone does not mean an older snapshot is still current. Ordinary set/remove remain unconditional; use a dedicated key for a compare-and-save workflow.
1514
1515
 
1515
1516
  ### Twinkle.reminders
1516
1517
  - async list({ includeDisabled, limit } = {}) | scopes: reminders:read
@@ -1579,7 +1580,7 @@ world.updatePresence({ x, y, z, facing });
1579
1580
  - For a paid nonogram-picture-v1 receipt, picture { title, category } may also be returned so the app can recover the reveal after a lost response. It contains no solution cells.
1580
1581
  - For collection practice, status:'completed' confirms a saved unpaid completion and includes completion { ruleId, setKey, at }, practice:true and receipt:null. Another challenge for the same personally completed collection set resolves to its existing receipt/completion, never a second payout.
1581
1582
  - await Twinkle.rewards.start({ ruleId, setKey?, levelIndex?, completionToken? }) | scopes: rewards:claim
1582
- - Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1583
+ - Returns: { mode: "live", challengeId, readyInSeconds?, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
1583
1584
  - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
1584
1585
  - Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
1585
1586
  - For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
@@ -1587,6 +1588,7 @@ world.updatePresence({ x, y, z, facing });
1587
1588
  - For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today. For sliding-block-v1, start deals the board (completion.board, positions, minMoves); render it, record each real slide as [ms, carIndex, newPosition], send moves with progress (frames up to maxFrames per call), then claim with the latest token. For app-check-v1, start returns completion { profile, puzzle }: show that puzzle (a repeat start of the same challenge returns the same one); there is no progress step.
1588
1589
  - For nonogram-picture-v1, start returns completion { profile, completed: false, readyInSeconds, puzzle: { size, rows: number[][], cols: number[][] } }. Render those row and column clues; there is no progress call. Live readyInSeconds is the remaining approved minimum wait, so reopening a puzzle does not restart its clock. The private picture is durably bound to this exact viewer, app, release, rule, challenge and expiry. Reopening returns the same clues even after a refill. New challenges use unseen pictures; build_reward_picture_collection_complete means this size needs a refill. Offer practice/community puzzles while waiting.
1589
1590
  - For collection rules, setKey is required and must be one of the approved permanent keys. Start returns that exact question and persistent attempts. practice:true means no XP/Coins or official rating (daily allowance reached, earlier practice, or viewed archive guide); display that before answering. Merely opening a practice question does not consume its future eligibility, but answering it does. completed:true returns its lesson and completion, without a new challenge or payout. A current eligible daily-rematch puzzle must be opened as the rematch. A challenge still expires at the usual day/release boundary; reopen the same set to resume its saved attempts.
1591
+ - Live completion starts return readyInSeconds, the remaining server-enforced wait for that challenge (including a resumed challenge). Anchor it at response arrival and queue one claim after that time; do not poll claim while too early. Retain the finished proof in a per-viewer outbox and reconcile getReceipt after a lost reply or reload. It is timing metadata, not proof of completion or permission to pay. Older servers may omit it; use the rule minSeconds conservatively. Preview timing and receipts remain separate.
1590
1592
  - await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
1591
1593
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
1592
1594
  - Verify a bounded batch of inputs for an approved server-simulated climb.
@@ -1601,7 +1603,7 @@ world.updatePresence({ x, y, z, facing });
1601
1603
  - Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
1602
1604
  - After a wrong answer, the next answer within two seconds, right or wrong, is refused with build_reward_throttled (HTTP 429): it does not count and says nothing about whether it was right. Keep the question open and let the person send it again after the wait; never treat a 429 as a wrong answer or retry-loop it.
1603
1605
  - Optional subject says what the reward was for; it is shown on the public bounty story on Home while the app is public (e.g. "listened to Night Drive"). title is plain text up to 80 characters, path is this app's share-link segment (the one you pass to Twinkle.app.getShareUrl, letters, digits and dashes), imageUrl a Twinkle-hosted image. A malformed subject is refused with build_reward_subject_invalid (HTTP 400); an older server without subjects ignores it. Proof-backed rules that read the item on the server (Groove Lab songs) replace it with the stored title, link, cover and creator.
1604
- - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
1606
+ - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start. Preserve the finished proof, show reward pending, and schedule one retry using the start clock; let unrelated play continue. In preview mode the receipt carries preview: true and nothing is paid.
1605
1607
  - A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction; every receipt counts, including one capped by the daily budget (cappedFrom). build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
1606
1608
  - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
1607
1609
  - For app-check-v1, pass completionToken as JSON.stringify(record), the record your check reads (at most 64 KB); the server deals the challenge again and runs the approved file's check before paying. A refused record returns { awarded: false, reason, attemptsRemaining } and uses a try; claim only when the app itself sees a finished solve.