@stage5/lumine 0.2.78 → 0.2.79

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
@@ -139,6 +139,80 @@ function readBuildReviewContextFile(filePath) {
139
139
  const MAX_REWARD_CONFIG_FILE_BYTES = 256 * 1024;
140
140
  const REWARD_REVIEW_STATUSES = ["pending", "approved", "all"];
141
141
  const REWARD_REVIEW_DECISIONS = ["approve", "reject", "revoke"];
142
+ const REWARD_PROPOSAL_MAX_FILES = 500;
143
+ const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
144
+ const REWARD_PROPOSAL_SKIPPED_DIRS = new Set([
145
+ ".git",
146
+ "node_modules",
147
+ ".lumine",
148
+ ".twinkle",
149
+ ]);
150
+
151
+ // Reads a reviewer's edited copy of a reward-review snapshot (a directory
152
+ // written by `show --dir`, then edited) back into project files for
153
+ // `reward-review propose`. Text files only; dotfiles and tool directories
154
+ // are skipped; paths are confined to the directory.
155
+ export function readRewardProposalDirectory(directory) {
156
+ const requested = String(directory || "").trim();
157
+ if (!requested) {
158
+ throw cliValidationError(
159
+ "Pass the edited snapshot directory with --dir <path>.",
160
+ );
161
+ }
162
+ const root = path.resolve(requested);
163
+ let rootStat;
164
+ try {
165
+ rootStat = lstatSync(root);
166
+ } catch {
167
+ throw cliValidationError(`--dir ${root} does not exist.`);
168
+ }
169
+ if (!rootStat.isDirectory()) {
170
+ throw cliValidationError(`--dir ${root} must be a directory.`);
171
+ }
172
+ const realRoot = realpathSync(root);
173
+ const files = [];
174
+ let bytes = 0;
175
+ const walk = (dir) => {
176
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
177
+ if (entry.name.startsWith(".") || REWARD_PROPOSAL_SKIPPED_DIRS.has(entry.name))
178
+ continue;
179
+ const fullPath = path.join(dir, entry.name);
180
+ if (entry.isSymbolicLink()) {
181
+ throw cliValidationError(
182
+ `Refusing to read symlink inside the proposal directory: ${fullPath}`,
183
+ );
184
+ }
185
+ if (entry.isDirectory()) {
186
+ walk(fullPath);
187
+ continue;
188
+ }
189
+ if (!entry.isFile()) continue;
190
+ const buffer = readFileSync(fullPath);
191
+ if (buffer.includes(0)) {
192
+ throw cliValidationError(
193
+ `${fullPath} is not a text file. Twinkle project files must be UTF-8 text; media belongs in build assets.`,
194
+ );
195
+ }
196
+ bytes += buffer.length;
197
+ const relative = path.relative(realRoot, fullPath).split(path.sep).join("/");
198
+ files.push({ path: `/${relative}`, content: buffer.toString("utf8") });
199
+ }
200
+ };
201
+ walk(realRoot);
202
+ if (files.length === 0) {
203
+ throw cliValidationError(`--dir ${root} holds no project files.`);
204
+ }
205
+ if (files.length > REWARD_PROPOSAL_MAX_FILES) {
206
+ throw cliValidationError(
207
+ `A proposal may carry at most ${REWARD_PROPOSAL_MAX_FILES} files.`,
208
+ );
209
+ }
210
+ if (bytes > REWARD_PROPOSAL_MAX_BYTES) {
211
+ throw cliValidationError("A proposal may carry at most 5 MB of files.");
212
+ }
213
+ files.sort((a, b) => a.path.localeCompare(b.path));
214
+ return files;
215
+ }
142
216
 
143
217
  // The reviewer's earning rules for one Build reward approval: budgets plus
144
218
  // server-verified numeric-quiz rules keyed by the rule IDs the app source
@@ -1453,6 +1527,32 @@ export function parseAdminOperation(options) {
1453
1527
  { requiresRun: false, reviewId, snapshotDir },
1454
1528
  );
1455
1529
  }
1530
+ if (action === "propose") {
1531
+ // Offer an edited copy of the snapshot as the condition of approval:
1532
+ // the creator accepts (which publishes it) or declines (which rejects).
1533
+ const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1534
+ const reason = String(options.adminReason || "").trim();
1535
+ if (reason.length > 1000) {
1536
+ throw cliValidationError("--reason must be at most 1000 characters.");
1537
+ }
1538
+ if (!options.adminConfigFile) {
1539
+ throw cliValidationError(
1540
+ "lumine admin reward-review propose <id> needs --config <rules.json> (the rules the creator's acceptance publishes) and --dir <edited snapshot>.",
1541
+ );
1542
+ }
1543
+ const files = readRewardProposalDirectory(options.dir);
1544
+ return writeOperation(
1545
+ "reward-review.propose",
1546
+ "POST",
1547
+ `/cli/admin/reward-reviews/${reviewId}/propose`,
1548
+ {
1549
+ files,
1550
+ config: readRewardConfigFile(options.adminConfigFile),
1551
+ reason,
1552
+ },
1553
+ { requiresRun: false, reviewId, fileCount: files.length },
1554
+ );
1555
+ }
1456
1556
  if (REWARD_REVIEW_DECISIONS.includes(action)) {
1457
1557
  const reviewId = parseRequiredInteger(target, "Reward review ID", 1);
1458
1558
  const reason = String(options.adminReason || "").trim();
@@ -1484,7 +1584,7 @@ export function parseAdminOperation(options) {
1484
1584
  );
1485
1585
  }
1486
1586
  throw cliValidationError(
1487
- "Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
1587
+ "Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | propose <id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
1488
1588
  );
1489
1589
  }
1490
1590
 
@@ -3794,6 +3894,32 @@ function printRewardReviewResult({ operation, data }) {
3794
3894
  console.log(
3795
3895
  `Decision recorded: ${operation.decision}. ${review.reason ? `Reason: ${review.reason}` : ""}`.trim(),
3796
3896
  );
3897
+ if (review.published) {
3898
+ console.log(
3899
+ `Published on approval: version ${review.published.version} (artifact ${review.published.artifactVersionId}, ${review.published.transition}). The app is live now.`,
3900
+ );
3901
+ }
3902
+ }
3903
+ if (operation.name === "reward-review.propose") {
3904
+ console.log(
3905
+ `Proposal offered to the creator (${operation.fileCount} file(s) sent). They can accept (which publishes your version with these rules) or decline (which rejects the request).`,
3906
+ );
3907
+ }
3908
+ if (review.proposal) {
3909
+ const summary = review.proposal.diffSummary || {};
3910
+ console.log(
3911
+ `Proposal: ${summary.total ?? 0} file(s) changed (${summary.added ?? 0} added, ${summary.updated ?? 0} updated, ${summary.deleted ?? 0} deleted)${review.proposal.note ? ` · note: ${review.proposal.note}` : ""}${review.status === "changes_offered" ? " · waiting for the creator" : ""}`,
3912
+ );
3913
+ for (const file of review.proposal.changedFiles || [])
3914
+ console.log(` ${file.status}: ${file.path}`);
3915
+ }
3916
+ if (review.status === "rejected" && review.declinedByCreator) {
3917
+ console.log("The creator declined the proposed changes; the request is closed.");
3918
+ }
3919
+ if (review.status === "approved" && review.publishedArtifactVersionId) {
3920
+ console.log(
3921
+ `Approved and published (artifact version ${review.publishedArtifactVersionId}).`,
3922
+ );
3797
3923
  }
3798
3924
  if (Array.isArray(review.detectedRuleIds)) {
3799
3925
  console.log(
package/lib/commands.js CHANGED
@@ -2902,7 +2902,8 @@ export function printHelp() {
2902
2902
  lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
2903
2903
  lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
2904
2904
  lumine admin reward-review show <review-id> [--dir <path>] [--json]
2905
- lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json]
2905
+ lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
2906
+ lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--json]
2906
2907
  lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
2907
2908
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
2908
2909
  lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
@@ -3015,7 +3016,7 @@ Options:
3015
3016
  --preview-url <url> Twinkle Build preview origin
3016
3017
  --auth-file <path> Saved login path
3017
3018
  --auth-token <token> Override saved login
3018
- --dir <path> Directory for pulled project files or a reward-review source snapshot
3019
+ --dir <path> Directory for pulled project files, a reward-review source snapshot, or the edited snapshot a reward-review proposal sends
3019
3020
  --config <file> Replacement earning rules JSON for reward-review approve (default: the app's own proposal)
3020
3021
  --provider <agent> Subscription agent for lumine agent: codex or claude-code
3021
3022
  --provider-path <p> Override the selected agent CLI executable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.78",
3
+ "version": "0.2.79",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1065,9 +1065,16 @@ and answer keys from a private question sheet the creator's Lumine uploads with
1065
1065
  readable by every player). **Send for review** freezes the code and proposes
1066
1066
  `rewards.json` merged with the sheet. Approval is Mikey's decision: read the
1067
1067
  frozen code, check that the amounts are right and that the app cannot be
1068
- farmed, change anything that is wrong, approve. These commands need no daily
1069
- run and can be used whenever a request arrives (the reviewer also receives a
1070
- DM card per request).
1068
+ farmed, change anything that is wrong, approve. **Approval publishes** (since
1069
+ 2026-09-15): the exact frozen snapshot goes live in the same transaction, with
1070
+ no Publish click by the creator; the app's previous release stays up until
1071
+ that commit lands. Instead of approving, the reviewer may **propose changes**:
1072
+ edit a copy of the frozen snapshot and offer it as the condition of approval.
1073
+ The creator sees every changed line and either accepts (the proposed version
1074
+ is approved and published) or declines (the request is rejected). Nothing in
1075
+ that flow joins the creator's team. These commands need no daily run and can
1076
+ be used whenever a request arrives (the reviewer also receives a DM card per
1077
+ request).
1071
1078
 
1072
1079
  ```bash
1073
1080
  lumine admin reward-review list --json # pending (default)
@@ -1077,7 +1084,10 @@ lumine admin reward-review show 2 --json # summary + file si
1077
1084
  lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json
1078
1085
  lumine admin reward-review approve 2 --json # approve exactly what the app proposed
1079
1086
  lumine admin reward-review approve 2 --config rules.json \
1080
- --reason "Halved the stage amounts" --json # approve with changes
1087
+ --reason "Halved the stage amounts" --json # approve with changes (publishes)
1088
+ lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json # then edit that directory…
1089
+ lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 \
1090
+ --config rules.json --reason "Moved the claim after the stage clears" --json # …and offer it
1081
1091
  lumine admin reward-review reject 2 --reason "Rewards fire on game over; nothing is earned" --json
1082
1092
  lumine admin reward-review revoke 2 --reason "Farmable; pausing until redesigned" --json
1083
1093
  ```
@@ -1156,12 +1166,49 @@ Arcade Typing (Mikey, 2026-09-12): XP for clearing campaign stages, up to
1156
1166
  10,000 Coins per rule and per learner per day, 10,000,000 XP / 1,000,000 Coins
1157
1167
  per app per day, 1,000,000,000 XP / 100,000,000 Coins per app lifetime.
1158
1168
 
1159
- Approval freezes these rules with the reviewed snapshot; an approval without at
1160
- least one rule is refused. Rejection and revocation require a `--reason` the
1161
- creator reads verbatim in their workspace. Approval never publishes: the creator
1162
- publishes the approved version themselves, and a later code save needs a new
1163
- request. Never approve without reading the code; never approve a request whose
1164
- `isLatest` is false.
1169
+ Approval freezes these rules with the reviewed snapshot and publishes that
1170
+ snapshot immediately (the result carries `published.version`); an approval
1171
+ without at least one rule is refused, and an approval whose creator has saved
1172
+ past the frozen version is refused as `build_reward_review_stale` (the request
1173
+ also closes itself on that save). Rejection and revocation require a
1174
+ `--reason` the creator reads verbatim in their workspace. A later code save
1175
+ needs a new request. Never approve without reading the code; never approve a
1176
+ request whose `isLatest` is false.
1177
+
1178
+ `propose <id> --dir <edited> --config rules.json [--reason]` sends the edited
1179
+ directory (text files only; dotfiles and tool folders skipped) as the
1180
+ reviewer's proposal: the review moves to `changes_offered`, the creator's card
1181
+ and workspace show the note and every changed line, and the creator's
1182
+ **Accept & go live** publishes exactly those files with these rules (their
1183
+ workspace is replaced by the accepted version). **No thanks** rejects the
1184
+ request (`declinedByCreator: true`). A proposal must still use the rewards
1185
+ SDK, must differ from the submitted snapshot, and is refused once the creator
1186
+ saves past the submitted version. Offering again replaces the earlier offer;
1187
+ approving or rejecting while an offer is out decides the request as
1188
+ submitted. The website equivalent is the Management panel's "Edit a copy to
1189
+ propose changes" (a private workspace copy owned by the reviewer) followed by
1190
+ "Offer my copy with these rules".
1191
+
1192
+ Each offer has a server-owned revision. Changing the files, rules or note
1193
+ creates a new revision; a creator looking at an older comparison or decline
1194
+ confirmation cannot answer the replacement offer. The creator sees its reward
1195
+ amounts as well as its file changes. Proposed rules stay separate from the
1196
+ submitted rules until acceptance, so `approve` without `--config` still uses
1197
+ the original submitted configuration. The CLI audits the offer atomically
1198
+ and includes file contents in its retry fingerprint.
1199
+
1200
+ Approval also attempts a free preview thumbnail when the app has none. That
1201
+ capture uses the published version and cannot overwrite a later release or a
1202
+ thumbnail the creator chooses while it runs. It is best effort: a capture
1203
+ failure leaves publication successful and does not spend AI-image credits.
1204
+
1205
+ The review copy carries independent copies of referenced uploaded media.
1206
+ Before freezing an offer, the server reuses the creator's original media and
1207
+ copies new reviewer media into the creator's library within their storage
1208
+ quota. Its final URLs are included in the comparison, so acceptance publishes
1209
+ those exact files and does not depend on keeping the review copy. Re-offers
1210
+ reuse the media; a failed transaction cleans up its copied objects. Declining
1211
+ leaves the offered media as unused uploads in the creator's library.
1165
1212
 
1166
1213
  ### Reward activity report (standing duty, every full daily review; added 2026-09-12)
1167
1214