@stage5/lumine 0.3.24 → 0.3.25

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
@@ -81,6 +81,17 @@ pulling the owner's main project creates or reuses your contribution branch and
81
81
  checks out that branch locally. Saves go to your branch, so the project owner
82
82
  can merge or replace main from Twinkle.
83
83
 
84
+ For an exact source review of the live app, use `lumine pull <build-id>
85
+ --published` in a persistent review folder. This checks out the requested
86
+ build's published artifact read-only; `--main` explicitly selects its root
87
+ project. It never substitutes the latest draft or creates a contribution
88
+ branch. The CLI checks the publication pointer again before writing and
89
+ refuses a concurrent publication; modified local files are stashed before a
90
+ successful checkout. It requires the API's published-version selector to be
91
+ deployed first. `--published` and `--version <n>` are mutually exclusive.
92
+ `lumine admin builds review` already reviews the published app; unsupported
93
+ selectors such as `--mode` or `--review-mode` fail instead of being ignored.
94
+
84
95
  Use `lumine branches <build-url-or-id>` to list the contribution branches you
85
96
  can review, including each contributor, branch number, status, and URL. Then use
86
97
  `lumine diff <branch-url>` to inspect one branch.
package/lib/admin.js CHANGED
@@ -3264,6 +3264,16 @@ export function parseAdminOperation(options) {
3264
3264
  {
3265
3265
  status: parseTodoStatus(options.adminStatus),
3266
3266
  note,
3267
+ ...(options.todoWorkflowFile
3268
+ ? {
3269
+ workflow: readAdminJsonFile(options.todoWorkflowFile),
3270
+ expectedRevision: parseRequiredInteger(
3271
+ options.followupRevision,
3272
+ "--revision",
3273
+ 1,
3274
+ ),
3275
+ }
3276
+ : {}),
3267
3277
  },
3268
3278
  );
3269
3279
  }
@@ -7249,6 +7259,14 @@ function printTodoItems(items, heading) {
7249
7259
  ` #${todo.id} ${String(todo.status || "open").toUpperCase()} ${todo.kind || "task"} — ${todo.title || "(untitled)"}`,
7250
7260
  );
7251
7261
  if (todo.details) console.log(` ${todo.details}`);
7262
+ if (todo.workflow) {
7263
+ const w = todo.workflow;
7264
+ console.log(` Implementation: ${w.implementation}; release: ${w.release}; verification: ${w.verification}`);
7265
+ if (w.fixRef) console.log(` Fix: ${w.fixRef}`);
7266
+ if (w.releaseRef) console.log(` Release: ${w.releaseRef}`);
7267
+ if (w.evidence) console.log(` Evidence: ${w.evidence}`);
7268
+ if (w.nextAction) console.log(` Next: ${w.nextAction}`);
7269
+ }
7252
7270
  if (todo.lastProgressNote) {
7253
7271
  console.log(` Latest progress: ${todo.lastProgressNote}`);
7254
7272
  }
package/lib/commands.js CHANGED
@@ -943,6 +943,8 @@ export async function selectProject(options) {
943
943
  }
944
944
 
945
945
  export async function pull(options) {
946
+ if (options.pullPublished && options.versionProvided)
947
+ throw new Error("Choose --published or --version, not both.");
946
948
  if (options.versionProvided && !(options.pullVersion > 0)) {
947
949
  throw new Error(
948
950
  "Pass a save number: `lumine pull --version <n>` (run `lumine versions` to see save numbers).",
@@ -962,7 +964,7 @@ export async function pull(options) {
962
964
  auth,
963
965
  buildId: requestedBuildId,
964
966
  });
965
- if (options.pullVersion > 0) {
967
+ if (options.pullPublished || options.pullVersion > 0) {
966
968
  await pullVersionBuildFiles({ options, auth, build: selectedBuild });
967
969
  return;
968
970
  }
@@ -1830,10 +1832,10 @@ export async function pullMainBuildFiles({ options, auth, build }) {
1830
1832
  }
1831
1833
 
1832
1834
  // Read-only checkout of ONE previous save (`lumine pull --version <n>`).
1833
- // Without --main it targets the build the workspace edits (your branch on team
1834
- // projects); with --main it targets the team project's main history.
1835
+ // Numeric saves target the editable workspace branch unless --main is given.
1836
+ // --published reads the named Build directly; --main selects the project root.
1835
1837
  export async function pullVersionBuildFiles({ options, auth, build }) {
1836
- const versionNumber = options.pullVersion;
1838
+ const versionNumber = options.pullPublished ? "published" : options.pullVersion;
1837
1839
  let targetBuild = build;
1838
1840
  if (options.pullMain) {
1839
1841
  const rootBuildId =
@@ -1842,7 +1844,7 @@ export async function pullVersionBuildFiles({ options, auth, build }) {
1842
1844
  rootBuildId === Number(build?.id || 0)
1843
1845
  ? build
1844
1846
  : await loadBuildMetadata({ options, auth, buildId: rootBuildId });
1845
- } else {
1847
+ } else if (!options.pullPublished) {
1846
1848
  targetBuild = await resolveEditableWorkspaceBuild({
1847
1849
  options,
1848
1850
  auth,
@@ -1856,6 +1858,27 @@ export async function pullVersionBuildFiles({ options, auth, build }) {
1856
1858
  buildId,
1857
1859
  versionNumber,
1858
1860
  });
1861
+ if (options.pullPublished) {
1862
+ const expected = Number(result.build?.publishedArtifactVersionId || 0);
1863
+ if (
1864
+ !expected ||
1865
+ Number(result.version?.versionId) !== expected ||
1866
+ !result.build?.isPublic
1867
+ ) {
1868
+ throw new Error(
1869
+ "The API did not return the exact published artifact; update the API before using --published.",
1870
+ );
1871
+ }
1872
+ const current = await loadBuildMetadata({ options, auth, buildId });
1873
+ if (
1874
+ !current.isPublic ||
1875
+ Number(current.publishedArtifactVersionId) !== expected
1876
+ ) {
1877
+ throw new Error(
1878
+ "The published version changed during checkout. Retry; no local files were changed.",
1879
+ );
1880
+ }
1881
+ }
1859
1882
  const resolvedBuild = result.build || targetBuild;
1860
1883
  const version = result.version || { version: versionNumber };
1861
1884
  const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
@@ -1879,6 +1902,7 @@ export async function pullVersionBuildFiles({ options, auth, build }) {
1879
1902
  ? path.join(path.dirname(enclosing.rootDir), dirName)
1880
1903
  : dirName),
1881
1904
  );
1905
+ await stashLocalProjectFilesBeforePull({ dir, files });
1882
1906
  await writeProjectFiles({ dir, files });
1883
1907
  if (files.some((file) => isIndexHtmlPath(String(file.path)))) {
1884
1908
  await removeLocalProjectFilesNotIn({ dir, files });
@@ -2422,6 +2446,7 @@ export function parseArgs(args) {
2422
2446
  "open",
2423
2447
  "noDescription",
2424
2448
  "publish",
2449
+ "published",
2425
2450
  "save",
2426
2451
  "force",
2427
2452
  "noUpdateCheck",
@@ -2491,6 +2516,35 @@ export function parseArgs(args) {
2491
2516
  }
2492
2517
  }
2493
2518
 
2519
+ if (
2520
+ command === "admin" &&
2521
+ positional[0] === "builds" &&
2522
+ positional[1] === "review"
2523
+ ) {
2524
+ const allowed = new Set([
2525
+ "help",
2526
+ "output",
2527
+ "outputDir",
2528
+ "dir",
2529
+ "waitMs",
2530
+ "interact",
2531
+ "browserPath",
2532
+ "json",
2533
+ "apiUrl",
2534
+ "siteUrl",
2535
+ "authFile",
2536
+ "authToken",
2537
+ "clientName",
2538
+ "timeoutMs",
2539
+ "noUpdateCheck",
2540
+ ]);
2541
+ for (const key of Object.keys(raw)) {
2542
+ if (!allowed.has(key))
2543
+ throw new Error(
2544
+ `Unsupported Build review option --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}. Runtime review already uses the published app; use pull --published for its source.`,
2545
+ );
2546
+ }
2547
+ }
2494
2548
  const suggestionInboxActions = new Set([
2495
2549
  "list",
2496
2550
  "merge",
@@ -2593,6 +2647,7 @@ export function parseArgs(args) {
2593
2647
  adminSeverity: raw.severity ? String(raw.severity) : "",
2594
2648
  adminStatus: raw.status ? String(raw.status) : "",
2595
2649
  followupRevision: raw.revision ? String(raw.revision) : "",
2650
+ todoWorkflowFile: raw.workflow ? String(raw.workflow) : "",
2596
2651
  adminDecision: raw.decision ? String(raw.decision) : "",
2597
2652
  adminWaitMs: raw.waitMs ? String(raw.waitMs) : "",
2598
2653
  adminBrowserPath: raw.browserPath ? String(raw.browserPath) : "",
@@ -2808,6 +2863,7 @@ export function parseArgs(args) {
2808
2863
  clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
2809
2864
  dir: raw.dir ? String(raw.dir) : "",
2810
2865
  pullMain: parseBoolean(raw.main, false),
2866
+ pullPublished: parseBoolean(raw.published, false),
2811
2867
  // Strict: only a plain positive integer counts. Anything else (missing
2812
2868
  // value, "foo", a swallowed flag like "--main", "2.5") stays 0 and the
2813
2869
  // consuming command must reject it via versionProvided instead of
@@ -3067,6 +3123,7 @@ export function printHelp() {
3067
3123
  lumine pull [twinkle-build-url]
3068
3124
  lumine pull [twinkle-build-url] --main
3069
3125
  lumine pull [twinkle-build-url] --version <n> [--main]
3126
+ lumine pull [twinkle-build-url] --published [--main]
3070
3127
  lumine versions [twinkle-build-url] [--main] [--limit <n>]
3071
3128
  lumine restore <n> [--main]
3072
3129
  lumine reference <twinkle-build-url>
@@ -3117,7 +3174,7 @@ export function printHelp() {
3117
3174
  lumine admin escalation set <audit-id> --status open|acknowledged|resolved --note <decision> [--json]
3118
3175
  lumine admin todo list [--status pending|open|in_progress|blocked|completed|cancelled|all] [--limit <number>] [--json]
3119
3176
  lumine admin todo add --title <title> --note <handoff-and-acceptance-criteria> [--kind task|experiment] [--status open|in_progress|blocked] [--json]
3120
- lumine admin todo update <todo-id> --status open|in_progress|blocked|completed|cancelled --note <progress-or-evidence> [--json]
3177
+ lumine admin todo update <todo-id> --status open|in_progress|blocked|completed|cancelled --note <progress-or-evidence> [--workflow <file.json> --revision <n>] [--json]
3121
3178
  lumine admin sponsor applications list [--status pending|approved|rejected|withdrawn] [--json]
3122
3179
  lumine admin sponsor applications review <application-id> --decision approve|reject [--note <text>] [--json]
3123
3180
  lumine admin sponsor status set <user-id> --status probationary|trusted|suspended|revoked [--note <text>] [--json]
@@ -3280,6 +3337,7 @@ Options:
3280
3337
  --no-review-loop Skip the evidence-based post-run loop retrospective
3281
3338
  --target <build> Explicit Build URL or ID for rename/describe/upgrade
3282
3339
  --main With pull/versions/restore: target the team project's main
3340
+ --published With pull: exact currently published artifact, read-only
3283
3341
  --version <n> With pull: read-only checkout of previous save v<n>
3284
3342
  --title <text> Build title for new/rename, or private todo title
3285
3343
  --description <text> Build description for new/describe
@@ -0,0 +1,133 @@
1
+ // Merges parts/<level>.json into the private question sheet the Math Lab
2
+ // uploads with `lumine rewards sheet`, then validates the sheet against the
3
+ // workspace's rewards.json exactly the way the server will (same code).
4
+ // Usage: node --import tsx build-sheet.mjs --approved-review <live review id>
5
+ // Reads that review afresh and preserves its questions before writing output.
6
+ import fs from "node:fs";
7
+ import { execFileSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import path from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import {
12
+ LEVELS,
13
+ verifyApprovedPrefix,
14
+ verifyRewardEconomy,
15
+ } from "./math-lab-preservation.js";
16
+
17
+ export async function buildMathLabSheet({
18
+ contentDir,
19
+ args = process.argv.slice(2),
20
+ }) {
21
+ const here = path.resolve(contentDir);
22
+ const parts = path.join(here, "parts");
23
+ if (
24
+ args.length !== 2 ||
25
+ args[0] !== "--approved-review" ||
26
+ !/^[1-9]\d*$/.test(args[1])
27
+ ) {
28
+ throw new Error(
29
+ "Usage: node --import tsx build-sheet.mjs --approved-review <current live review id>. No sheet was written.",
30
+ );
31
+ }
32
+ const cli = fileURLToPath(new URL("../bin/lumine.js", import.meta.url));
33
+ const approved = JSON.parse(
34
+ execFileSync(
35
+ process.execPath,
36
+ [
37
+ cli,
38
+ "admin",
39
+ "reward-review",
40
+ "show",
41
+ args[1],
42
+ "--json",
43
+ "--no-update-check",
44
+ ],
45
+ {
46
+ encoding: "utf8",
47
+ timeout: 60000,
48
+ maxBuffer: 16 * 1024 * 1024,
49
+ },
50
+ ),
51
+ );
52
+ const sheet = { rules: {} };
53
+ const missing = [];
54
+ for (const level of LEVELS) {
55
+ const file = path.join(parts, `${level}.json`);
56
+ if (!fs.existsSync(file)) {
57
+ missing.push(level);
58
+ continue;
59
+ }
60
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
61
+ 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
+ })),
71
+ };
72
+ }
73
+ const out = path.resolve(here, "..", "math-lab-sheet.json");
74
+ if (missing.length)
75
+ throw new Error(
76
+ `Missing grade parts: ${missing.join(", ")}. No sheet was written.`,
77
+ );
78
+ const preservation = verifyApprovedPrefix(sheet, approved);
79
+
80
+ // Server-identical validation: rewards.json + sheet → approved-shape config.
81
+ const policy = await import(
82
+ pathToFileURL(
83
+ path.resolve(
84
+ here,
85
+ "..",
86
+ "..",
87
+ "twinkle-api",
88
+ "helpers",
89
+ "build",
90
+ "rewardsPolicy.ts",
91
+ ),
92
+ ).href
93
+ );
94
+ const declared = JSON.parse(
95
+ fs.readFileSync(
96
+ path.resolve(here, "..", "math-lab", "rewards.json"),
97
+ "utf8",
98
+ ),
99
+ );
100
+ const composed = policy.composeRewardConfig({ declared, sheet });
101
+ if (composed.errors.length) {
102
+ for (const error of composed.errors) console.log(`- ${error}`);
103
+ process.exit(1);
104
+ }
105
+ verifyRewardEconomy(composed.config, approved);
106
+ const total = composed.config.rules.reduce(
107
+ (sum, rule) => sum + (rule.sets || []).length,
108
+ 0,
109
+ );
110
+ // Nothing replaces the prior usable sheet until every check succeeds.
111
+ const output = JSON.stringify(sheet, null, 1) + "\n";
112
+ fs.writeFileSync(`${out}.next`, output, { mode: 0o600 });
113
+ fs.renameSync(`${out}.next`, out);
114
+ fs.writeFileSync(
115
+ `${out}.preservation.json`,
116
+ JSON.stringify(
117
+ {
118
+ ...preservation,
119
+ checkedAt: new Date().toISOString(),
120
+ sha256: createHash("sha256").update(output).digest("hex"),
121
+ },
122
+ null,
123
+ 2,
124
+ ) + "\n",
125
+ { mode: 0o600 },
126
+ );
127
+ console.log(
128
+ `wrote ${out}; preserved ${preservation.rules.reduce((n, r) => n + r.preserved, 0)} published questions from review ${preservation.liveReview}`,
129
+ );
130
+ console.log(
131
+ `composed OK: ${composed.config.rules.length} rules, ${total} sets, ${Buffer.byteLength(JSON.stringify(sheet))} bytes`,
132
+ );
133
+ }
@@ -0,0 +1,105 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ export const LEVELS = [
4
+ "e1",
5
+ "e2",
6
+ "e3",
7
+ "e4",
8
+ "e5",
9
+ "e6",
10
+ "m1",
11
+ "m2",
12
+ "m3",
13
+ "h1",
14
+ "h2",
15
+ "h3",
16
+ ];
17
+
18
+ export function verifyRewardEconomy(config, response) {
19
+ const withoutRefillSets = (value) => ({
20
+ ...value,
21
+ rules: value.rules.map((rule) => {
22
+ if (!LEVELS.some((level) => rule.id === `${level}-daily`)) return rule;
23
+ const { sets: _sets, ...economy } = rule;
24
+ return economy;
25
+ }),
26
+ });
27
+ if (
28
+ !isDeepStrictEqual(
29
+ withoutRefillSets(config),
30
+ withoutRefillSets(response.data.review.config),
31
+ )
32
+ ) {
33
+ throw new Error(
34
+ "A refill must preserve the approved reward rules and budgets. Review any economy change separately.",
35
+ );
36
+ }
37
+ }
38
+
39
+ // Refill-only: edits/removals to existing questions need a separate content review.
40
+ export function verifyApprovedPrefix(sheet, response) {
41
+ const review = response?.data?.review;
42
+ const artifact = Number(review?.publishedArtifactVersionId);
43
+ if (
44
+ response?.ok !== true ||
45
+ Number(review?.buildId) !== 2460 ||
46
+ review?.status !== "approved" ||
47
+ review?.isLive !== true ||
48
+ !Number.isSafeInteger(artifact) ||
49
+ artifact <= 0 ||
50
+ Number(review?.publishedVersionId) !== artifact
51
+ ) {
52
+ throw new Error(
53
+ "A fresh, live, approved Math Lab review is required. Re-read the current reward review.",
54
+ );
55
+ }
56
+ const rules = review.config?.rules;
57
+ if (!Array.isArray(rules))
58
+ throw new Error("The approved review has no question rules.");
59
+ const result = [];
60
+ for (const level of LEVELS) {
61
+ const id = `${level}-daily`;
62
+ const matches = rules.filter((rule) => rule.id === id);
63
+ const before = matches[0]?.sets;
64
+ const after = sheet?.rules?.[id]?.sets;
65
+ if (
66
+ matches.length !== 1 ||
67
+ !Array.isArray(before) ||
68
+ !before.length ||
69
+ !Array.isArray(after)
70
+ ) {
71
+ throw new Error(
72
+ `${id}: missing or ambiguous approved questions or candidate grade.`,
73
+ );
74
+ }
75
+ if (after.length < before.length)
76
+ throw new Error(
77
+ `${id}: candidate loses ${before.length - after.length} approved questions.`,
78
+ );
79
+ for (let i = 0; i < before.length; i++) {
80
+ if (!isDeepStrictEqual(after[i], before[i])) {
81
+ throw new Error(
82
+ `${id}: approved question ${before[i].key || i + 1} changed or moved. Sync the canonical sheet before refilling.`,
83
+ );
84
+ }
85
+ }
86
+ const keys = after.map((set) => set.key);
87
+ if (
88
+ keys.some((key) => typeof key !== "string" || !key.trim()) ||
89
+ new Set(keys).size !== keys.length
90
+ ) {
91
+ throw new Error(`${id}: question keys must be present and unique.`);
92
+ }
93
+ result.push({
94
+ rule: id,
95
+ preserved: before.length,
96
+ added: after.length - before.length,
97
+ });
98
+ }
99
+ return {
100
+ liveReview: Number(review.id),
101
+ artifactVersionId: artifact,
102
+ preservedExact: true,
103
+ rules: result,
104
+ };
105
+ }
package/lib/workspace.js CHANGED
@@ -738,6 +738,8 @@ export async function writeVersionCheckoutMetadata({
738
738
  readOnly: true,
739
739
  versionCheckout: true,
740
740
  checkoutVersion: Number(version?.version || 0) || null,
741
+ checkoutArtifactVersionId: Number(version?.versionId || 0) || null,
742
+ publishedCheckout: options.pullPublished === true,
741
743
  checkoutVersionSummary: version?.summary || null,
742
744
  checkoutVersionCreatedAt: Number(version?.createdAt || 0) || null,
743
745
  build: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1737,6 +1737,35 @@ audit path: canonical todo state and its private `todo.create` / `todo.update`
1737
1737
  audit response commit together, and no public bot, public mutation count, or
1738
1738
  rotation signal is involved.
1739
1739
 
1740
+ Track implementation, release and verification separately when a carryover
1741
+ involves a fix. After `add-admin-todo-workflow.sql` and the matching API are
1742
+ deployed, save a local JSON file such as:
1743
+
1744
+ ```json
1745
+ {
1746
+ "implementation": "ready",
1747
+ "release": "pending",
1748
+ "verification": "pending",
1749
+ "fixRef": "work/topic/release-review.md",
1750
+ "releaseRef": "",
1751
+ "evidence": "Focused regression passed; production is unchanged.",
1752
+ "nextAction": "Deployer releases the reviewed fix, then check the live result."
1753
+ }
1754
+ ```
1755
+
1756
+ Use `lumine admin todo update 12 --revision <current-revision> --workflow
1757
+ work/topic/workflow.json --status in_progress --note "Ready for the deployer."
1758
+ --json`. The revision comes from the canonical list/update response; stale
1759
+ updates are rejected. Implementation values are `needed`, `in_progress`,
1760
+ `ready`, `not_needed`; release is `pending`, `live`, `not_needed`; verification
1761
+ is `pending` or `passed`. Any unfinished phase needs a concrete `nextAction`;
1762
+ passed verification needs `evidence`. Completion requires implementation ready
1763
+ (or unnecessary), release live (or unnecessary), and verification passed.
1764
+ Keep physical-device checks and observation windows pending until they happen.
1765
+ Legacy todos have `workflow: null`, which means unknown, never verified. An
1766
+ update without `--workflow` preserves an existing workflow and cannot bypass
1767
+ its completion requirements.
1768
+
1740
1769
  Every successful full `daily-run start` response automatically includes all
1741
1770
  unfinished items under `data.carryoverTodos`. The same run ID increments an
1742
1771
  item's surfacing telemetry at most once, even when start is retried. This is the