@stage5/lumine 0.3.73 → 0.3.75

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
@@ -305,10 +305,11 @@ viewer sees (by default the MCP client's own name).
305
305
  ## Inspecting Build SDK data
306
306
 
307
307
  `lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
308
- endpoint with your login and prints the response, so you can inspect real data
308
+ endpoint and prints the response, so you can inspect real data
309
309
  and measure latency while building. Run `lumine sdk list` to see callable
310
- methods. The JSON args are sent as the request body (shapes follow
311
- `TWINKLE_BUILD_SDK.md`).
310
+ methods. Most methods use your login and send JSON args as the request body
311
+ (shapes follow `TWINKLE_BUILD_SDK.md`). Newspaper reads use the same public
312
+ GET routes as the app and send no login or Build API token.
312
313
 
313
314
  ```bash
314
315
  lumine sdk call aiStories.chapters '{"limit": 5}'
@@ -317,6 +318,9 @@ lumine sdk call live.list '{}'
317
318
  lumine sdk call live.get '{"sessionId": "..."}'
318
319
  lumine sdk call live.listReplays '{"limit": 20}'
319
320
  lumine sdk call live.getReplay '{"replayId": "..."}'
321
+ lumine sdk call news.getCurrentEdition '{}' --build 1929
322
+ lumine sdk call news.listEditions '{"limit": 12}' --build 1929
323
+ lumine sdk call news.getEdition '{"dayIndex": 1762, "revisionNumber": 1}' --build 1929
320
324
  ```
321
325
 
322
326
  It targets the build in the current workspace, or pass `--build <id>`. Add
@@ -324,6 +328,10 @@ It targets the build in the current workspace, or pass `--build <id>`. Add
324
328
  which can differ from a method's `Twinkle.*` SDK return shape — check
325
329
  `TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
326
330
  `--allow-write`.
331
+ For newspaper reads, omit `revisionNumber` to read the day's canonical revision;
332
+ pass the archive's `cursor` to `news.listEditions` for the next page. These reads
333
+ never queue generation or refresh an edition; publication uses the separate
334
+ authorized newspaper workflow.
327
335
  Stopping a hosted stream is available as
328
336
  `lumine sdk call live.stop '{"sessionId":"..."}' --allow-write`; the command
329
337
  prints canonical server state and refuses to mint `live:write` without the
@@ -186,12 +186,14 @@ export function formatArenaResult({ operation, data }) {
186
186
  ...types.map(
187
187
  (t) =>
188
188
  `${t.examFamily} ${t.difficulty || "normal"} ${t.itemType}: ${t.live} live, ${t.activeLearners ?? "unknown"} active learner(s), ${t.minFresh} unseen${
189
- t.stockLow && !t.refillDue ? " (low stock; no active demand)" : ""
189
+ t.refillUse === "complete-paper-only" ? " (complete paper required; check paper-queue)"
190
+ : t.refillUse === "unknown-type" ? " (unknown current type; review before authoring)"
191
+ : t.stockLow && !t.refillDue && t.activeLearners === 0 ? " (low stock; no active demand)" : ""
190
192
  }${
191
193
  t.refillDue ? ` -> write ${t.toWrite}` : ""
192
194
  }`,
193
195
  ),
194
- due.length ? `${due.length} type(s) due for a refill.` : "No refill due.",
196
+ due.length ? `${due.length} type(s) due for a refill.` : "No standalone practice refill due; check paper-queue for waiting exams.",
195
197
  ];
196
198
  }
197
199
  if (operation.name === "arena.import.preview") {
@@ -14,11 +14,37 @@ import {
14
14
  verifyRewardEconomy,
15
15
  } from "./math-lab-preservation.js";
16
16
 
17
+ export function serializeMathLabSets(sets) {
18
+ return sets.map((set) => ({
19
+ key: set.key,
20
+ // This also makes a set eligible as a rematch of the named puzzle.
21
+ // It remains an ordinary collection puzzle for other learners.
22
+ // Preserve it for existing and appended sets; let canonical validation
23
+ // reject invalid references instead of silently changing their meaning.
24
+ ...(set.variantOf !== undefined ? { variantOf: set.variantOf } : {}),
25
+ questions: set.questions.map((q) => ({
26
+ prompt: q.prompt,
27
+ answer: q.answer,
28
+ ...(q.hint ? { hint: q.hint } : {}),
29
+ ...(q.guide ? { guide: q.guide } : {}),
30
+ })),
31
+ }));
32
+ }
33
+
17
34
  export async function buildMathLabSheet({
18
35
  contentDir,
36
+ apiRoot,
37
+ appDir,
38
+ outputFile,
19
39
  args = process.argv.slice(2),
20
40
  }) {
21
41
  const here = path.resolve(contentDir);
42
+ // Private review directories need not imitate the historical sibling layout.
43
+ // Defaults preserve existing wrappers; explicit paths retain the same guards.
44
+ const policyRoot = path.resolve(
45
+ apiRoot || path.join(here, "..", "..", "twinkle-api"),
46
+ );
47
+ const declaredApp = path.resolve(appDir || path.join(here, "..", "math-lab"));
22
48
  const parts = path.join(here, "parts");
23
49
  if (
24
50
  args.length !== 2 ||
@@ -59,18 +85,12 @@ export async function buildMathLabSheet({
59
85
  }
60
86
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
61
87
  sheet.rules[`${level}-daily`] = {
62
- sets: data.sets.map((set) => ({
63
- key: set.key,
64
- questions: set.questions.map((q) => ({
65
- prompt: q.prompt,
66
- answer: q.answer,
67
- ...(q.hint ? { hint: q.hint } : {}),
68
- ...(q.guide ? { guide: q.guide } : {}),
69
- })),
70
- })),
88
+ sets: serializeMathLabSets(data.sets),
71
89
  };
72
90
  }
73
- const out = path.resolve(here, "..", "math-lab-sheet.json");
91
+ const out = path.resolve(
92
+ outputFile || path.join(here, "..", "math-lab-sheet.json"),
93
+ );
74
94
  if (missing.length)
75
95
  throw new Error(
76
96
  `Missing grade parts: ${missing.join(", ")}. No sheet was written.`,
@@ -80,22 +100,11 @@ export async function buildMathLabSheet({
80
100
  // Server-identical validation: rewards.json + sheet → approved-shape config.
81
101
  const policy = await import(
82
102
  pathToFileURL(
83
- path.resolve(
84
- here,
85
- "..",
86
- "..",
87
- "twinkle-api",
88
- "helpers",
89
- "build",
90
- "rewardsPolicy.ts",
91
- ),
103
+ path.join(policyRoot, "helpers", "build", "rewardsPolicy.ts"),
92
104
  ).href
93
105
  );
94
106
  const declared = JSON.parse(
95
- fs.readFileSync(
96
- path.resolve(here, "..", "math-lab", "rewards.json"),
97
- "utf8",
98
- ),
107
+ fs.readFileSync(path.join(declaredApp, "rewards.json"), "utf8"),
99
108
  );
100
109
  const composed = policy.composeRewardConfig({ declared, sheet });
101
110
  if (composed.errors.length) {
package/lib/sdk.js CHANGED
@@ -13,6 +13,11 @@ import { findLocalProjectMetadata } from "./workspace.js";
13
13
  // TWINKLE_BUILD_SDK.md. Methods that mutate data require --allow-write.
14
14
  // Anything not listed can be called with --path api/<endpoint>.
15
15
  export const SDK_CLI_METHODS = {
16
+ // Newspaper reads use the public GET routes, not POST /api/* or Build
17
+ // tokens. Generation remains a separate, explicitly authorized workflow.
18
+ "news.getCurrentEdition": { special: "newsRead", operation: "current", publicRead: true },
19
+ "news.listEditions": { special: "newsRead", operation: "list", publicRead: true },
20
+ "news.getEdition": { special: "newsRead", operation: "edition", publicRead: true },
16
21
  "soundtracks.searchSources": { path: "api/soundtracks/sources", scopes: ["sharedDb:read"] },
17
22
  "soundtracks.getTags": { path: "api/soundtracks/tags", scopes: ["sharedDb:read"] },
18
23
  "soundtracks.getRecommendations": { path: "api/soundtracks/recommendations", scopes: ["sharedDb:read"] },
@@ -458,7 +463,7 @@ export async function sdkCommand(options) {
458
463
 
459
464
  export function printSdkMethodList() {
460
465
  console.log(
461
- "Callable Build endpoints (args JSON = request body). Prints the raw\n" +
466
+ "Callable Build endpoints (args JSON selects the documented request fields). Prints the raw\n" +
462
467
  "endpoint response, which can differ from the SDK return shape (*).\n",
463
468
  );
464
469
  const byNamespace = new Map();
@@ -485,8 +490,10 @@ Usage:
485
490
  lumine sdk call leaderboards.get '{"boardKey": "default", "limit": 10}'
486
491
  lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
487
492
  lumine sdk call userDb.query '{"sql": "SELECT * FROM sqlite_master"}'
493
+ lumine sdk call news.getEdition '{"dayIndex": 1762, "revisionNumber": 1}' --build 1929
488
494
 
489
- Request-body (args) shapes follow TWINKLE_BUILD_SDK.md. Output is the raw
495
+ Argument shapes follow TWINKLE_BUILD_SDK.md; public newspaper reads use GET
496
+ without a login or Build token. Output is the raw
490
497
  endpoint response; (*) methods are reshaped by their Twinkle.* SDK wrapper, so
491
498
  check TWINKLE_BUILD_SDK.md for the real SDK return shape. Add --repeat <n> for
492
499
  latency stats, --build <id> to target a build outside the current workspace.
@@ -570,6 +577,9 @@ export async function sdkCall(options) {
570
577
  const requestedScopes = options.sdkScopes.length
571
578
  ? options.sdkScopes
572
579
  : endpoint.scopes || [];
580
+ if (endpoint.publicRead && options.sdkScopes.length) {
581
+ throw new Error(`${methodName} is a public read and does not use --scopes.`);
582
+ }
573
583
  // A curated readOnly method (rewards.getStatus) needs a scope whose name is
574
584
  // write-capable but only ever sends its read operation; its own default
575
585
  // scopes do not trip the gate. Explicit --scopes overrides always do.
@@ -597,12 +607,23 @@ export async function sdkCall(options) {
597
607
  // before any auth side effects, so invalid local input fails immediately
598
608
  // instead of after starting the device-login flow.
599
609
  const buildId = await resolveSdkBuildId(options);
600
- const auth = await ensureAuth(options);
601
- await assertAuthScope({ options, auth, scope: "build:sdk" });
610
+ // Validate selectors before login/network calls. Public reads deliberately
611
+ // carry no saved login, even when one is available in this CLI session.
612
+ const newsUrl = endpoint.special === "newsRead"
613
+ ? buildNewsReadUrl({ apiUrl: options.apiUrl, buildId, operation: endpoint.operation, args })
614
+ : null;
615
+ const auth = endpoint.publicRead ? null : await ensureAuth(options);
616
+ if (auth) await assertAuthScope({ options, auth, scope: "build:sdk" });
602
617
  const attempts = [];
603
618
  let lastResult = null;
604
619
 
605
- if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
620
+ if (endpoint.special === "newsRead") {
621
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
622
+ lastResult = await executeSdkHttpCall({ options, method: "GET", url: newsUrl });
623
+ attempts.push(lastResult);
624
+ printSdkAttemptLine(attempts.length, lastResult);
625
+ }
626
+ } else if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
606
627
  const boardKey = String(args.boardKey || "default");
607
628
  const rest = { ...args };
608
629
  delete rest.boardKey;
@@ -742,6 +763,39 @@ export async function sdkCall(options) {
742
763
  }
743
764
  }
744
765
 
766
+ function buildNewsReadUrl({ apiUrl, buildId, operation, args }) {
767
+ const allowed = operation === "edition" ? ["dayIndex", "revisionNumber"]
768
+ : operation === "list" ? ["limit", "cursor"] : [];
769
+ if (Object.keys(args).some((key) => !allowed.includes(key))) {
770
+ throw new Error(`Unexpected newspaper selector; accepted fields: ${allowed.join(", ") || "none"}.`);
771
+ }
772
+ let suffix = "current";
773
+ if (operation === "edition") {
774
+ if (!Number.isSafeInteger(args.dayIndex) || args.dayIndex < 0) {
775
+ throw new Error("dayIndex is required and must be a nonnegative integer.");
776
+ }
777
+ if (args.revisionNumber != null && (!Number.isSafeInteger(args.revisionNumber) || args.revisionNumber <= 0)) {
778
+ throw new Error("revisionNumber must be a positive integer.");
779
+ }
780
+ suffix = `editions/${args.dayIndex}`;
781
+ } else if (operation === "list") {
782
+ if (args.limit != null && (!Number.isSafeInteger(args.limit) || args.limit < 1)) {
783
+ throw new Error("limit must be a positive integer (capped at 30).");
784
+ }
785
+ if (args.cursor != null && (typeof args.cursor !== "string" || args.cursor.length > 200)) {
786
+ throw new Error("cursor must be the string returned by news.listEditions.");
787
+ }
788
+ suffix = "editions";
789
+ }
790
+ const url = new URL(`${apiUrl}/build/${buildId}/news/${suffix}`);
791
+ for (const [key, value] of operation === "edition"
792
+ ? [["revision", args.revisionNumber]] : operation === "list"
793
+ ? [["limit", args.limit == null ? null : Math.min(30, args.limit)], ["cursor", args.cursor]] : []) {
794
+ if (value != null) url.searchParams.set(key, String(value));
795
+ }
796
+ return url.toString();
797
+ }
798
+
745
799
  export function printSdkAttemptLine(attemptNumber, result) {
746
800
  console.error(
747
801
  `#${attemptNumber} ${result.status}${result.ok ? " OK" : ""} · ` +
package/lib/workspace.js CHANGED
@@ -8,9 +8,11 @@ import {
8
8
  EXCLUDED_UPLOAD_FILES,
9
9
  LUMINE_AGENT_INSTRUCTIONS,
10
10
  LUMINE_AGENT_INSTRUCTIONS_MARKER,
11
+ LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER,
11
12
  LUMINE_REFERENCE_INSTRUCTIONS,
12
13
  LUMINE_REFERENCE_INSTRUCTIONS_MARKER,
13
14
  LUMINE_SDK_REFERENCE_MARKER,
15
+ LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER,
14
16
  PROJECT_EFFECTIVE_LINE_MAX_COLUMNS,
15
17
  PROJECT_MAX_EFFECTIVE_FILE_LINES,
16
18
  PROJECT_MAX_FILES,
@@ -39,14 +41,23 @@ export async function writeReferenceInstructions({ dir }) {
39
41
  });
40
42
  }
41
43
 
42
- // Write AGENTS.md/CLAUDE.md, but never clobber a file the user customized:
43
- // only (re)write when the file is absent or still carries our marker.
44
+ // All checkout modes generate these same two files. A previous mode's header
45
+ // still identifies a CLI-managed guide and must change with the checkout.
46
+ // Custom guides omit the generated header; quoting a marker later is not ownership.
44
47
  export async function writeInstructionFiles({ dir, marker, content }) {
48
+ const managedHeaders = new Set([
49
+ marker,
50
+ LUMINE_AGENT_INSTRUCTIONS_MARKER,
51
+ LUMINE_REFERENCE_INSTRUCTIONS_MARKER,
52
+ LUMINE_MAIN_CHECKOUT_INSTRUCTIONS_MARKER,
53
+ LUMINE_VERSION_CHECKOUT_INSTRUCTIONS_MARKER,
54
+ ]);
45
55
  for (const fileName of AGENT_INSTRUCTION_FILES) {
46
56
  const filePath = path.join(dir, fileName);
47
57
  try {
48
58
  const existing = await fs.readFile(filePath, "utf8");
49
- if (!existing.includes(marker)) {
59
+ const header = existing.replace(/^\uFEFF/, "").split(/\r?\n/, 1)[0];
60
+ if (!managedHeaders.has(header)) {
50
61
  continue;
51
62
  }
52
63
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.73",
3
+ "version": "0.3.75",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {