@stage5/lumine 0.2.43 → 0.2.46

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
@@ -113,6 +113,21 @@ Reference folders are marked `readOnly` in `.twinkle/lumine-project.json`.
113
113
  Running `lumine save` from a reference folder is blocked; fork the source Build
114
114
  first if you want an editable workspace.
115
115
 
116
+ ## Using a published app over MCP
117
+
118
+ `lumine app-mcp <published-app-url-or-id>` turns an opted-in published Build
119
+ app into a standard stdio MCP server. Lumine pins the current published
120
+ artifact, reads its `/app-tools.json` manifest, and opens a dedicated signed-in
121
+ app tab. Tool calls run serially in that visible iframe through handlers the app
122
+ registered with `Twinkle.appTools.register({ handlers })`, so the agent and
123
+ viewer operate the same live UI and state. Keep that tab open while the MCP
124
+ client is connected. Pass `--no-open` only when you will open the URL printed on
125
+ stderr yourself.
126
+
127
+ Discovery is static and fail-closed: runtime code cannot add tools that were
128
+ not declared in the pinned manifest, and a session refuses to connect if any
129
+ declared handler is missing.
130
+
116
131
  ## Inspecting Build SDK data
117
132
 
118
133
  `lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
@@ -217,6 +232,12 @@ lumine admin identity list --json
217
232
  lumine admin identity inspect Jay1216 \
218
233
  --reason "Confirm account family before a quota-bucket change" --json
219
234
  lumine admin daily-run start --identity auto --comment-mode off --json
235
+ lumine admin todo list --json
236
+ lumine admin todo add --kind experiment --status in_progress \
237
+ --title "Validate Zero/Ciel cost optimization" \
238
+ --note "Complete only after old-vs-new response-quality parity." --json
239
+ lumine admin todo update 12 --status blocked \
240
+ --note "Waiting for a complete cost bucket and parity replay." --json
220
241
  lumine admin recommendations list --all --checkpoint recommendations.json --json
221
242
  lumine admin recommendations list --after 2026-08-14T00:00:00Z --all --json
222
243
  lumine admin recommendations list --include-legacy --all --json
@@ -295,14 +316,19 @@ cursors are bound to the original date and effort filters.
295
316
  `news claim` can write both the canonical leased digest and an editable
296
317
  editorial scaffold. `news validate` is local and checks every citation and
297
318
  quote before submission; `news submit --claim` reads the lease identity from
298
- the claim file. `daily-run report` summarizes confirmed mutations, completed
299
- queue coverage, explicitly recorded escalations, and the run brief before the
300
- run is completed.
319
+ the claim file. Every `daily-run start` response includes writer-confirmed
320
+ unfinished private todos, with once-per-run surfacing telemetry, so an agent
321
+ can resume earlier work without relying on conversation memory. Record progress
322
+ with `todo update`; completing a run does not complete its todos. Experiments
323
+ must meet their stated acceptance criteria—lower AI cost with weaker user
324
+ responses is not a successful optimization. `daily-run report` summarizes
325
+ confirmed mutations, completed queue coverage, explicitly recorded escalations,
326
+ unfinished todos, and the run brief before the run is completed.
301
327
 
302
328
  Identity inspection, escalation dispositions, AI-bucket maintenance, and
303
- approved Notable User additions are private operator bookkeeping and do not
304
- require a delegated daily run. Identity inspection always requires an audited
305
- `--reason`; raw email/DOB evidence additionally requires
329
+ approved Notable User additions and todos are private operator bookkeeping and
330
+ do not require a delegated daily run. Identity inspection always requires an
331
+ audited `--reason`; raw email/DOB evidence additionally requires
306
332
  `--include-private-evidence`. Routine briefs omit raw email identities.
307
333
 
308
334
  The complete run lifecycle, command contracts, nullable fields, Karma approval
package/lib/admin.js CHANGED
@@ -23,6 +23,8 @@ const MAX_COMPOSED_TEXT_LENGTH = 10_000;
23
23
  const MAX_NOTABLE_NOTE_LENGTH = 2_000;
24
24
  const MAX_IDENTITY_INSPECTION_REASON_LENGTH = 500;
25
25
  const MAX_ESCALATION_DECISION_NOTE_LENGTH = 2_000;
26
+ const MAX_TODO_TITLE_LENGTH = 200;
27
+ const MAX_TODO_NOTE_LENGTH = 4_000;
26
28
 
27
29
  // Operator-composed persona text (plain UTF-8, not JSON). The agent writes
28
30
  // the content in the bot's persona itself; the server never invokes
@@ -246,6 +248,9 @@ export async function adminCommand(options) {
246
248
  expectedContent: operation.body.content,
247
249
  });
248
250
  }
251
+ if (operation.name === "daily-run.start") {
252
+ assertAdminTodoHandoffResult(result);
253
+ }
249
254
  if (operation.name === "news.claim") {
250
255
  const artifacts = writeNewsClaimArtifacts({
251
256
  result,
@@ -506,7 +511,9 @@ function adminOperationRequiresRun(operation) {
506
511
  "escalation.list",
507
512
  "escalation.set",
508
513
  "notable.add",
509
- ].includes(operation.name) && !operation.name.startsWith("ai-bucket.")
514
+ ].includes(operation.name) &&
515
+ !operation.name.startsWith("ai-bucket.") &&
516
+ !operation.name.startsWith("todo.")
510
517
  );
511
518
  }
512
519
 
@@ -621,6 +628,69 @@ export function parseAdminOperation(options) {
621
628
  }
622
629
  }
623
630
 
631
+ if (namespace === "todo" || namespace === "todos") {
632
+ if (!action || action === "list") {
633
+ return readOperation(
634
+ "todo.list",
635
+ withQuery("/cli/admin/todos", {
636
+ status: parseTodoListStatus(options.adminStatus || "pending"),
637
+ limit: options.limit,
638
+ }),
639
+ );
640
+ }
641
+ if (action === "add" || action === "create") {
642
+ const title = String(options.title || "").trim();
643
+ const details = String(options.note || "").trim();
644
+ if (!title || !details) {
645
+ throw cliValidationError(
646
+ "Usage: lumine admin todo add --title <title> --note <handoff and acceptance criteria> [--kind task|experiment] [--status open|in_progress|blocked].",
647
+ );
648
+ }
649
+ if (title.length > MAX_TODO_TITLE_LENGTH) {
650
+ throw cliValidationError(
651
+ `A todo title must be at most ${MAX_TODO_TITLE_LENGTH} characters.`,
652
+ );
653
+ }
654
+ if (details.length > MAX_TODO_NOTE_LENGTH) {
655
+ throw cliValidationError(
656
+ `Todo details must be at most ${MAX_TODO_NOTE_LENGTH} characters.`,
657
+ );
658
+ }
659
+ return writeOperation("todo.add", "POST", "/cli/admin/todos", {
660
+ kind: parseTodoKind(options.adminKind || "task"),
661
+ title,
662
+ details,
663
+ status: parseTodoInitialStatus(options.adminStatus || "open"),
664
+ });
665
+ }
666
+ if (action === "update") {
667
+ const todoId = parseRequiredInteger(target, "Todo ID", 1);
668
+ const note = String(options.note || "").trim();
669
+ if (!note) {
670
+ throw cliValidationError(
671
+ "Record concrete progress, evidence, or the reason for the state change with --note <text>.",
672
+ );
673
+ }
674
+ if (note.length > MAX_TODO_NOTE_LENGTH) {
675
+ throw cliValidationError(
676
+ `A todo progress note must be at most ${MAX_TODO_NOTE_LENGTH} characters.`,
677
+ );
678
+ }
679
+ return writeOperation(
680
+ "todo.update",
681
+ "PUT",
682
+ `/cli/admin/todos/${todoId}`,
683
+ {
684
+ status: parseTodoStatus(options.adminStatus),
685
+ note,
686
+ },
687
+ );
688
+ }
689
+ throw cliValidationError(
690
+ "Usage: lumine admin todo list [--status pending|open|in_progress|blocked|completed|cancelled|all] | todo add --title <title> --note <details> | todo update <id> --status <status> --note <progress>.",
691
+ );
692
+ }
693
+
624
694
  if (namespace === "daily-run") {
625
695
  if (action === "start") {
626
696
  return writeOperation(
@@ -1240,7 +1310,7 @@ export function parseAdminOperation(options) {
1240
1310
  }
1241
1311
 
1242
1312
  throw cliValidationError(
1243
- "Usage: lumine admin identity|daily-run|escalation|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
1313
+ "Usage: lumine admin identity|daily-run|escalation|todo|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
1244
1314
  );
1245
1315
  }
1246
1316
 
@@ -1573,6 +1643,28 @@ export function formatAdminJsonError(error) {
1573
1643
  };
1574
1644
  }
1575
1645
 
1646
+ export function assertAdminTodoHandoffResult(result) {
1647
+ const runId = Number(result?.data?.run?.id || 0);
1648
+ const handoff = result?.data?.carryoverTodos;
1649
+ if (
1650
+ !runId ||
1651
+ !handoff ||
1652
+ !Array.isArray(handoff.items) ||
1653
+ Number(handoff.count) !== handoff.items.length ||
1654
+ Number(handoff.surfacedForRunId) !== runId ||
1655
+ !Number.isSafeInteger(Number(handoff.newlySurfacedCount)) ||
1656
+ Number(handoff.newlySurfacedCount) < 0 ||
1657
+ Number(handoff.newlySurfacedCount) > handoff.items.length
1658
+ ) {
1659
+ const error = cliValidationError(
1660
+ "The API did not confirm the canonical carry-over todo handoff. Deploy the todo migration/API before using this Lumine CLI for community management.",
1661
+ );
1662
+ error.code = "LUMINE_ADMIN_TODO_HANDOFF_UNSUPPORTED";
1663
+ throw error;
1664
+ }
1665
+ return handoff;
1666
+ }
1667
+
1576
1668
  function readOperation(name, path, extra = {}) {
1577
1669
  return {
1578
1670
  name,
@@ -1643,6 +1735,56 @@ function parseEscalationListStatus(value) {
1643
1735
  return status;
1644
1736
  }
1645
1737
 
1738
+ function parseTodoKind(value) {
1739
+ const kind = String(value || "task")
1740
+ .trim()
1741
+ .toLowerCase();
1742
+ if (!["task", "experiment"].includes(kind)) {
1743
+ throw cliValidationError("--kind must be task or experiment.");
1744
+ }
1745
+ return kind;
1746
+ }
1747
+
1748
+ function parseTodoInitialStatus(value) {
1749
+ const status = String(value || "open")
1750
+ .trim()
1751
+ .toLowerCase();
1752
+ if (!["open", "in_progress", "blocked"].includes(status)) {
1753
+ throw cliValidationError(
1754
+ "A new todo --status must be open, in_progress, or blocked.",
1755
+ );
1756
+ }
1757
+ return status;
1758
+ }
1759
+
1760
+ function parseTodoStatus(value) {
1761
+ const status = String(value || "")
1762
+ .trim()
1763
+ .toLowerCase();
1764
+ if (
1765
+ ![
1766
+ "open",
1767
+ "in_progress",
1768
+ "blocked",
1769
+ "completed",
1770
+ "cancelled",
1771
+ ].includes(status)
1772
+ ) {
1773
+ throw cliValidationError(
1774
+ "--status must be open, in_progress, blocked, completed, or cancelled.",
1775
+ );
1776
+ }
1777
+ return status;
1778
+ }
1779
+
1780
+ function parseTodoListStatus(value) {
1781
+ const status = String(value || "pending")
1782
+ .trim()
1783
+ .toLowerCase();
1784
+ if (status === "pending" || status === "all") return status;
1785
+ return parseTodoStatus(status);
1786
+ }
1787
+
1646
1788
  function parseOrderedIds(value) {
1647
1789
  const ids = String(value || "")
1648
1790
  .split(",")
@@ -1776,7 +1918,7 @@ function printAdminResult({ operation, result }) {
1776
1918
  if (data.report) {
1777
1919
  const report = data.report;
1778
1920
  console.log(
1779
- `Run #${report.run.id}: ${report.mutations.successfulMutationCount} successful mutation(s), ${report.queueCoverage.length} queue coverage record(s), ${report.escalations.length} escalation(s).`,
1921
+ `Run #${report.run.id}: ${report.mutations.successfulMutationCount} successful mutation(s), ${report.queueCoverage.length} queue coverage record(s), ${report.escalations.length} escalation(s), ${report.carryoverTodos?.count || 0} unfinished todo(s).`,
1780
1922
  );
1781
1923
  for (const coverage of report.queueCoverage) {
1782
1924
  console.log(
@@ -1791,6 +1933,7 @@ function printAdminResult({ operation, result }) {
1791
1933
  ` ${String(escalation.severity || "attention").toUpperCase()} ${target} — ${escalation.summary}`,
1792
1934
  );
1793
1935
  }
1936
+ printTodoItems(report.carryoverTodos?.items || [], "Unfinished work");
1794
1937
  const surfaces = report.brief?.engagementPulse?.surfaces;
1795
1938
  if (surfaces && typeof surfaces === "object") {
1796
1939
  const deltas = Object.entries(surfaces)
@@ -1861,6 +2004,19 @@ function printAdminResult({ operation, result }) {
1861
2004
  );
1862
2005
  return;
1863
2006
  }
2007
+ if (Array.isArray(data.todos)) {
2008
+ printTodoItems(data.todos, "Private carry-over work");
2009
+ if (data.truncated) {
2010
+ console.log(
2011
+ "More matching todos exist than the requested limit; raise --limit or narrow --status.",
2012
+ );
2013
+ }
2014
+ return;
2015
+ }
2016
+ if (data.todo) {
2017
+ printTodoItems([data.todo], "Canonical todo");
2018
+ return;
2019
+ }
1864
2020
  if (data.bucket && Array.isArray(data.memberUserIds)) {
1865
2021
  const added = Array.isArray(data.accounts)
1866
2022
  ? `; added ${data.accounts.length} explicit account(s)`
@@ -1883,6 +2039,9 @@ function printAdminResult({ operation, result }) {
1883
2039
  console.log(
1884
2040
  `Run #${data.run.id}: ${data.run.status}; identity ${data.run.identity.key}; comments ${data.run.commentMode}.`,
1885
2041
  );
2042
+ if (data.carryoverTodos) {
2043
+ printTodoItems(data.carryoverTodos.items || [], "Carry-over work");
2044
+ }
1886
2045
  return;
1887
2046
  }
1888
2047
  if (Array.isArray(data.identities)) {
@@ -2070,6 +2229,19 @@ function printAdminResult({ operation, result }) {
2070
2229
  );
2071
2230
  }
2072
2231
 
2232
+ function printTodoItems(items, heading) {
2233
+ console.log(`${heading}: ${items.length} item(s).`);
2234
+ for (const todo of items) {
2235
+ console.log(
2236
+ ` #${todo.id} ${String(todo.status || "open").toUpperCase()} ${todo.kind || "task"} — ${todo.title || "(untitled)"}`,
2237
+ );
2238
+ if (todo.details) console.log(` ${todo.details}`);
2239
+ if (todo.lastProgressNote) {
2240
+ console.log(` Latest progress: ${todo.lastProgressNote}`);
2241
+ }
2242
+ }
2243
+ }
2244
+
2073
2245
  function printPagination(pagination) {
2074
2246
  if (!pagination) return;
2075
2247
  console.log(
package/lib/api.js CHANGED
@@ -83,6 +83,63 @@ export async function loadBuildFiles({ options, auth, buildId, includeContent })
83
83
  });
84
84
  }
85
85
 
86
+ export async function createAppMcpSession({ options, auth, buildId }) {
87
+ return await requestJson({
88
+ method: "POST",
89
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions`,
90
+ authToken: auth.token,
91
+ body: {},
92
+ timeoutMs: options.timeoutMs,
93
+ });
94
+ }
95
+
96
+ export async function createAppMcpCall({
97
+ options,
98
+ auth,
99
+ buildId,
100
+ sessionId,
101
+ name,
102
+ arguments: toolArguments,
103
+ }) {
104
+ return await requestJson({
105
+ method: "POST",
106
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}/calls`,
107
+ authToken: auth.token,
108
+ body: { name, arguments: toolArguments ?? {} },
109
+ timeoutMs: options.timeoutMs,
110
+ });
111
+ }
112
+
113
+ export async function loadAppMcpCall({
114
+ options,
115
+ auth,
116
+ buildId,
117
+ sessionId,
118
+ callId,
119
+ }) {
120
+ return await requestJson({
121
+ method: "POST",
122
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}/calls/${callId}/status`,
123
+ authToken: auth.token,
124
+ body: {},
125
+ timeoutMs: options.timeoutMs,
126
+ });
127
+ }
128
+
129
+ export async function closeAppMcpSession({
130
+ options,
131
+ auth,
132
+ buildId,
133
+ sessionId,
134
+ }) {
135
+ return await requestJson({
136
+ method: "DELETE",
137
+ url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}`,
138
+ authToken: auth.token,
139
+ timeoutMs: options.timeoutMs,
140
+ });
141
+ }
142
+
86
143
  export async function loadExternalAgentRuntime({
87
144
  options,
88
145
  auth,
@@ -412,9 +469,6 @@ export async function replaceMainWithContribution({
412
469
  }
413
470
 
414
471
  export async function publishBuild({ options, buildId, auth }) {
415
- if (auth.releaseStatus?.state === "up_to_date") {
416
- return { skipped: true };
417
- }
418
472
  try {
419
473
  const result = await requestJson({
420
474
  method: "POST",
@@ -0,0 +1,255 @@
1
+ import { spawn } from "node:child_process";
2
+ import readline from "node:readline";
3
+
4
+ import {
5
+ closeAppMcpSession,
6
+ createAppMcpCall,
7
+ createAppMcpSession,
8
+ loadAppMcpCall,
9
+ } from "../api.js";
10
+ import { assertAuthScope, resolveAuth } from "../auth.js";
11
+ import { resolveRequiredBuildId } from "../util.js";
12
+
13
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
14
+ const CALL_POLL_MS = 250;
15
+ const CALL_TIMEOUT_MS = 5 * 60 * 1000;
16
+
17
+ function delay(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ function openApp(url) {
22
+ const command =
23
+ process.platform === "darwin"
24
+ ? ["open", [url]]
25
+ : process.platform === "win32"
26
+ ? ["cmd", ["/c", "start", "", url]]
27
+ : ["xdg-open", [url]];
28
+ const child = spawn(command[0], command[1], {
29
+ detached: true,
30
+ stdio: "ignore",
31
+ });
32
+ child.unref();
33
+ child.on("error", () => {
34
+ process.stderr.write(
35
+ `lumine app-mcp: open this signed-in app tab: ${url}\n`,
36
+ );
37
+ });
38
+ }
39
+
40
+ export async function appMcpCommand(options) {
41
+ const buildId = resolveRequiredBuildId(options.target || options.buildIdFlag);
42
+ if (!buildId) {
43
+ throw new Error("Usage: lumine app-mcp <published-app-url-or-id>");
44
+ }
45
+ const auth = await resolveAuth(options);
46
+ await assertAuthScope({ options, auth, scope: "build:read" });
47
+ await assertAuthScope({ options, auth, scope: "build:write" });
48
+ const created = await createAppMcpSession({ options, auth, buildId });
49
+ const session = created?.session;
50
+ if (!session?.id || !session?.manifest?.tools?.length) {
51
+ if (session?.id) {
52
+ await closeAppMcpSession({
53
+ options,
54
+ auth,
55
+ buildId,
56
+ sessionId: session.id,
57
+ }).catch(() => {});
58
+ }
59
+ throw new Error("Twinkle did not return an app MCP session.");
60
+ }
61
+ try {
62
+ if (options.openBrowser !== false) {
63
+ openApp(session.appUrl);
64
+ } else {
65
+ process.stderr.write(
66
+ `lumine app-mcp: open this signed-in app tab: ${session.appUrl}\n`,
67
+ );
68
+ }
69
+ process.stderr.write(
70
+ `lumine app-mcp: ${session.buildTitle} is pinned to artifact ${session.artifactVersionId}. ` +
71
+ `Keep the opened Twinkle tab running.\n`,
72
+ );
73
+
74
+ const tools = session.manifest.tools.map((tool) => ({
75
+ name: tool.name,
76
+ description: tool.description || "",
77
+ inputSchema: tool.inputSchema || {
78
+ type: "object",
79
+ additionalProperties: false,
80
+ },
81
+ }));
82
+ const input = readline.createInterface({
83
+ input: process.stdin,
84
+ crlfDelay: Infinity,
85
+ terminal: false,
86
+ });
87
+ const pending = new Set();
88
+ let toolCallQueue = Promise.resolve();
89
+ input.on("line", (line) => {
90
+ if (!line.trim()) return;
91
+ const execute = () =>
92
+ handleMcpMessage({
93
+ line,
94
+ options,
95
+ auth,
96
+ buildId,
97
+ session,
98
+ tools,
99
+ });
100
+ let isToolCall = false;
101
+ try {
102
+ isToolCall = JSON.parse(line)?.method === "tools/call";
103
+ } catch {
104
+ // The normal handler returns the canonical JSON-RPC parse error.
105
+ }
106
+ // App mutations are stateful and the browser runtime can execute only one
107
+ // canonical call at a time. Preserve the MCP client's receive order so a
108
+ // burst never depends on UUID or second-resolution database ordering.
109
+ const operation = isToolCall
110
+ ? (toolCallQueue = toolCallQueue.then(execute, execute))
111
+ : execute();
112
+ const observed = operation.catch((error) => {
113
+ process.stderr.write(
114
+ `lumine app-mcp: ${String(error?.message || error)}\n`,
115
+ );
116
+ });
117
+ pending.add(observed);
118
+ observed.finally(() => pending.delete(observed));
119
+ });
120
+ await new Promise((resolve) => input.once("close", resolve));
121
+ await Promise.allSettled(Array.from(pending));
122
+ } finally {
123
+ await closeAppMcpSession({
124
+ options,
125
+ auth,
126
+ buildId,
127
+ sessionId: session.id,
128
+ }).catch(() => {});
129
+ }
130
+ }
131
+
132
+ async function callAppTool({
133
+ options,
134
+ auth,
135
+ buildId,
136
+ sessionId,
137
+ name,
138
+ arguments: toolArguments,
139
+ }) {
140
+ const created = await createAppMcpCall({
141
+ options,
142
+ auth,
143
+ buildId,
144
+ sessionId,
145
+ name,
146
+ arguments: toolArguments,
147
+ });
148
+ const callId = created?.call?.id;
149
+ if (!callId) throw new Error("Twinkle did not create the app tool call.");
150
+ const deadline = Date.now() + CALL_TIMEOUT_MS;
151
+ while (Date.now() < deadline) {
152
+ const payload = await loadAppMcpCall({
153
+ options,
154
+ auth,
155
+ buildId,
156
+ sessionId,
157
+ callId,
158
+ });
159
+ const call = payload?.call;
160
+ if (call?.status === "completed") return call.result;
161
+ if (call?.status === "failed") {
162
+ throw new Error(call.errorMessage || "App tool failed.");
163
+ }
164
+ await delay(CALL_POLL_MS);
165
+ }
166
+ const finalPayload = await loadAppMcpCall({
167
+ options,
168
+ auth,
169
+ buildId,
170
+ sessionId,
171
+ callId,
172
+ });
173
+ if (finalPayload?.call?.status === "completed") {
174
+ return finalPayload.call.result;
175
+ }
176
+ if (finalPayload?.call?.status === "failed") {
177
+ throw new Error(finalPayload.call.errorMessage || "App tool failed.");
178
+ }
179
+ throw new Error("App tool call timed out. Keep the MCP app tab open.");
180
+ }
181
+
182
+ async function handleMcpMessage({ line, options, auth, buildId, session, tools }) {
183
+ let message;
184
+ try {
185
+ message = JSON.parse(line);
186
+ } catch {
187
+ return writeMcpError(null, -32700, "Parse error");
188
+ }
189
+ const id = message?.id;
190
+ const method = String(message?.method || "");
191
+ if (id === undefined || id === null) return;
192
+ if (method === "initialize") {
193
+ return writeMcpResult(id, {
194
+ protocolVersion: MCP_PROTOCOL_VERSION,
195
+ capabilities: { tools: { listChanged: false } },
196
+ serverInfo: {
197
+ name: `lumine-app-${buildId}`,
198
+ version: "1.0.0",
199
+ },
200
+ instructions:
201
+ session.manifest.description ||
202
+ `Use the semantic tools exposed by ${session.buildTitle}.`,
203
+ });
204
+ }
205
+ if (method === "ping") return writeMcpResult(id, {});
206
+ if (method === "tools/list") return writeMcpResult(id, { tools });
207
+ if (method === "tools/call") {
208
+ const name = String(message?.params?.name || "");
209
+ if (!tools.some((tool) => tool.name === name)) {
210
+ return writeMcpError(id, -32602, `Unknown tool: ${name}`);
211
+ }
212
+ try {
213
+ const result = await callAppTool({
214
+ options,
215
+ auth,
216
+ buildId,
217
+ sessionId: session.id,
218
+ name,
219
+ arguments: message?.params?.arguments || {},
220
+ });
221
+ return writeMcpResult(id, {
222
+ content: [{ type: "text", text: JSON.stringify(result) }],
223
+ structuredContent:
224
+ result && typeof result === "object" && !Array.isArray(result)
225
+ ? result
226
+ : { result },
227
+ isError: false,
228
+ });
229
+ } catch (error) {
230
+ return writeMcpResult(id, {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: JSON.stringify({
235
+ ok: false,
236
+ error: String(error?.message || error),
237
+ }),
238
+ },
239
+ ],
240
+ isError: true,
241
+ });
242
+ }
243
+ }
244
+ return writeMcpError(id, -32601, `Method not found: ${method}`);
245
+ }
246
+
247
+ function writeMcpResult(id, result) {
248
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
249
+ }
250
+
251
+ function writeMcpError(id, code, message) {
252
+ process.stdout.write(
253
+ `${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`,
254
+ );
255
+ }
package/lib/commands.js CHANGED
@@ -66,6 +66,7 @@ import { adminCommand } from "./admin.js";
66
66
  import { runBuildForumCommand } from "./forum.js";
67
67
  import { agentCommand } from "./agent.js";
68
68
  import { agentMcpCommand } from "./agent/mcp-server.js";
69
+ import { appMcpCommand } from "./app-mcp/server.js";
69
70
  import {
70
71
  defaultMainCheckoutDir,
71
72
  defaultReferenceDir,
@@ -111,6 +112,10 @@ export async function main() {
111
112
  await agentMcpCommand(options);
112
113
  return;
113
114
  }
115
+ if (options.command === "app-mcp") {
116
+ await appMcpCommand(options);
117
+ return;
118
+ }
114
119
  options.lumineCli = await loadLumineCliVersionInfo({ options });
115
120
  if (options.help) {
116
121
  printHelp();
@@ -1104,7 +1109,13 @@ export async function save(options) {
1104
1109
  lastSavedAt: new Date().toISOString(),
1105
1110
  filesHash: typeof result.filesHash === "string" ? result.filesHash : null,
1106
1111
  });
1107
- printSaveResult({ result, build, dir, files });
1112
+ printSaveResult({
1113
+ result,
1114
+ build,
1115
+ dir,
1116
+ files,
1117
+ publishRequested: Boolean(options.publish),
1118
+ });
1108
1119
 
1109
1120
  if (options.publish) {
1110
1121
  if (build?.canPublish === false) {
@@ -1119,6 +1130,9 @@ export async function save(options) {
1119
1130
  } else {
1120
1131
  console.log("Publish complete.");
1121
1132
  }
1133
+ console.log(
1134
+ `Release status: ${publish.build?.releaseStatus?.state || "unknown"}`,
1135
+ );
1122
1136
  console.log(`App: ${options.siteUrl}/app/${buildId}`);
1123
1137
  }
1124
1138
  }
@@ -2043,7 +2057,13 @@ export function printForkResult({ forkResult, pullResult }) {
2043
2057
  printPullResult(pullResult);
2044
2058
  }
2045
2059
 
2046
- export function printSaveResult({ result, build, dir, files }) {
2060
+ export function printSaveResult({
2061
+ result,
2062
+ build,
2063
+ dir,
2064
+ files,
2065
+ publishRequested = false,
2066
+ }) {
2047
2067
  const entryPath = result.projectManifest?.entryPath || "unknown";
2048
2068
  const version = result.artifactVersion?.versionNumber
2049
2069
  ? ` v${result.artifactVersion.versionNumber}`
@@ -2054,7 +2074,8 @@ export function printSaveResult({ result, build, dir, files }) {
2054
2074
  `Uploaded ${files.length} file${files.length === 1 ? "" : "s"} from ${dir}`,
2055
2075
  );
2056
2076
  console.log(`Entry: ${entryPath}`);
2057
- console.log(`Release status: ${releaseState}`);
2077
+ const willPublish = publishRequested && build?.canPublish !== false;
2078
+ if (!willPublish) console.log(`Release status: ${releaseState}`);
2058
2079
  if (isContributionBranch(build) && build.canPublish === false) {
2059
2080
  console.log(
2060
2081
  'Next: notify the project owner with `lumine suggest branch "Ready for review"`.',
@@ -2062,7 +2083,7 @@ export function printSaveResult({ result, build, dir, files }) {
2062
2083
  console.log(
2063
2084
  "To offer this branch's thumbnail, run `lumine suggest thumbnail`.",
2064
2085
  );
2065
- } else {
2086
+ } else if (!willPublish) {
2066
2087
  console.log(
2067
2088
  "Next: run `lumine launch` to publish, or `lumine save --publish` next time.",
2068
2089
  );
@@ -2668,6 +2689,7 @@ export function printHelp() {
2668
2689
  lumine whoami
2669
2690
  lumine logout
2670
2691
  lumine agent --provider <codex|claude-code> "<build request>"
2692
+ lumine app-mcp <published-app-url-or-id> [--no-open]
2671
2693
  lumine new [title]
2672
2694
  lumine rename [title] [--target <twinkle-build-url-or-id>]
2673
2695
  lumine describe [description] [--target <twinkle-build-url-or-id>]
@@ -2720,6 +2742,9 @@ export function printHelp() {
2720
2742
  lumine admin daily-run escalation add --target <target> --note <summary> [--severity attention|urgent] [--json]
2721
2743
  lumine admin escalation list [--status open|acknowledged|resolved|all] [--limit <number>] [--json]
2722
2744
  lumine admin escalation set <audit-id> --status open|acknowledged|resolved --note <decision> [--json]
2745
+ lumine admin todo list [--status pending|open|in_progress|blocked|completed|cancelled|all] [--limit <number>] [--json]
2746
+ lumine admin todo add --title <title> --note <handoff-and-acceptance-criteria> [--kind task|experiment] [--status open|in_progress|blocked] [--json]
2747
+ lumine admin todo update <todo-id> --status open|in_progress|blocked|completed|cancelled --note <progress-or-evidence> [--json]
2723
2748
  lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
2724
2749
  lumine admin builds candidates [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
2725
2750
  lumine admin builds review <build-url-or-id> [--output-dir <dir>] [--wait-ms <ms>] [--browser-path <path>] [--json]
@@ -2816,11 +2841,11 @@ Options:
2816
2841
  --target <build> Explicit Build URL or ID for rename/describe/upgrade
2817
2842
  --main With pull/versions/restore: target the team project's main
2818
2843
  --version <n> With pull: read-only checkout of previous save v<n>
2819
- --title <text> Build title for new/rename
2844
+ --title <text> Build title for new/rename, or private todo title
2820
2845
  --description <text> Build description for new/describe
2821
2846
  --no-description Skip New description or clear with describe
2822
2847
  --summary <text> Save summary
2823
- --note <text> Suggestion, notable-user, or AI-bucket context
2848
+ --note <text> Suggestion, notable-user, AI-bucket, or todo context
2824
2849
  --cursor <id> Continue suggestions, Forum activity, or admin listing
2825
2850
  --poll-ms <ms> Forum listener interval (1000-60000; default 3000)
2826
2851
  --after <date> Admin listing: inclusive Unix/ISO creation boundary
@@ -2836,6 +2861,7 @@ Options:
2836
2861
  --target-file <file> JSON array or newline list for audited batch skips
2837
2862
  --review-receipt <f> Confirmed managed Build runtime review receipt
2838
2863
  --severity <level> Run escalation severity: attention or urgent
2864
+ --status <state> Private escalation or todo lifecycle filter/state
2839
2865
  --wait-ms <ms> Managed Build runtime observation time (1000-45000)
2840
2866
  --browser-path <path> Chrome/Chromium executable for managed Build review
2841
2867
  --effort unassigned Admin subjects: show only unassigned effort
@@ -2851,7 +2877,7 @@ Options:
2851
2877
  --label <name> Name for a new unbanned AI identity bucket
2852
2878
  --user-ids <ids> Explicit user IDs for an AI bucket batch (up to 500)
2853
2879
  --type <type> Admin target: subject, comment, build, aiStory, or dailyReflection
2854
- --kind recommend Admin recommendation queue kind
2880
+ --kind <kind> Admin recommendation kind or todo task/experiment kind
2855
2881
  --anyone-can-reward Enable canonical reward eligibility
2856
2882
  --reward-twinkles 3 Pair a recommendation with exactly 3 Twinkles
2857
2883
  --twinkles 3 Give exactly 3 Twinkles through the normal economy
package/lib/constants.js CHANGED
@@ -31,6 +31,18 @@ export const THUMBNAIL_CONTENT_TYPE_BY_EXTENSION = {
31
31
  export const THUMBNAIL_MAX_FILE_SIZE_BYTES = 8 * 1024 * 1024;
32
32
  export const UPDATE_CHECK_TIMEOUT_MS = 1500;
33
33
  export const DEFAULT_PROJECT_LIMIT = 50;
34
+ export const BUILD_VENDOR_THREE_VERSION = "0.184.0";
35
+ export const BUILD_VENDOR_THREE_LEGACY_VERSION = "0.160.0";
36
+ export const BUILD_VENDOR_THREE_PREFIX =
37
+ `/build/vendor/three/${BUILD_VENDOR_THREE_VERSION}/`;
38
+ export const BUILD_VENDOR_THREE_MODULE_IMPORT =
39
+ `${BUILD_VENDOR_THREE_PREFIX}three.module.min.js`;
40
+ export const BUILD_VENDOR_THREE_WEBGPU_MODULE_IMPORT =
41
+ `${BUILD_VENDOR_THREE_PREFIX}three.webgpu.min.js`;
42
+ export const BUILD_VENDOR_THREE_TSL_MODULE_IMPORT =
43
+ `${BUILD_VENDOR_THREE_PREFIX}three.tsl.min.js`;
44
+ export const BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX =
45
+ `${BUILD_VENDOR_THREE_PREFIX}addons/`;
34
46
  export const PROJECT_METADATA_DIR = ".twinkle";
35
47
  export const PROJECT_METADATA_FILE = "lumine-project.json";
36
48
  export const ASSETS_METADATA_FILE = "assets.json";
@@ -120,6 +132,14 @@ Use these current source-of-truth rules:
120
132
  - Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
121
133
  - Prefer existing documented Twinkle.* methods over guessing names from old code.
122
134
  `;
135
+ export const LUMINE_THREE_VENDOR_GUIDANCE = `- For Three.js, use the first-party core module: import * as THREE from '${BUILD_VENDOR_THREE_MODULE_IMPORT}';
136
+ - Twinkle serves the supported official Three.js ${BUILD_VENDOR_THREE_VERSION} addon tree under ${BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX}, including controls, loaders, CSS renderers, shaders, physics and WebXR helpers, and WebGLRenderer post-processing modules such as EffectComposer, RenderPass, SSAOPass/GTAOPass, UnrealBloomPass, and OutputPass. Example: import { EffectComposer } from '${BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX}postprocessing/EffectComposer.js';
137
+ - The workspace file tree does not enumerate vendor modules, so absence there is not evidence that an official addon is unavailable. Use its documented addon subpath and run lumine check; validation checks the exact file and its transitive imports.
138
+ - WebGPU and TSL entry modules are also vendored at ${BUILD_VENDOR_THREE_WEBGPU_MODULE_IMPORT} and ${BUILD_VENDOR_THREE_TSL_MODULE_IMPORT}, but WebGL EffectComposer passes do not work with WebGPURenderer and runtime GPU support varies.
139
+ - Treat addons as available tools, not defaults. Use them when they materially serve the requested experience; for continuously animated mobile builds, include a performance profile that targets about 30 FPS, caps render pixel ratio around 1-1.25, and lowers expensive post-processing resolution or quality only when that preserves the requested visual behavior. Do not remove or disable a requested visual effect as a performance tradeoff without the user's explicit approval.
140
+ - Keep one Three.js version throughout a project. If a project using ${BUILD_VENDOR_THREE_LEGACY_VERSION} needs current addons, migrate every Three.js import to ${BUILD_VENDOR_THREE_VERSION} in one coherent change; never mix the legacy core with current addons.
141
+ - This vendor surface covers supported official Three.js modules, not arbitrary third-party Three.js packages. Do not paste library source into project files or use npm/CDN copies. Loader runtime assets are served too: point decoder/transcoder paths at the addon prefix (example: dracoLoader.setDecoderPath('${BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX}libs/draco/');).
142
+ - Size both WebGLRenderer and EffectComposer from Twinkle.preview layout dimensions, coalesce resize work, and render with composer.render() when a composer owns the pass chain.`;
123
143
  export const LUMINE_AGENT_INSTRUCTIONS = `${LUMINE_AGENT_INSTRUCTIONS_MARKER}
124
144
  # Lumine Project Agent Guide
125
145
 
@@ -260,14 +280,15 @@ lumine save --summary "Describe the change"
260
280
  - Interface text must not be selectable on touch devices: long-pressing UI on mobile must not highlight it. Apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls). Keep text inputs and genuinely user-copyable content (story text, chat messages, user-written text) selectable. lumine check flags projects whose reachable files have clickable UI but no user-select: none rule.
261
281
  - CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body (even a decorative background canvas) or game-y words in visible text switch the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. Document-style apps that use a canvas must call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot — any of those opts out of auto game mode — then pad by layout.safeInsets and scroll within layout.viewport.height.
262
282
  - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
263
- - For Three.js, use import * as THREE from '/build/vendor/three/0.184.0/three.module.min.js';. Addons (OrbitControls, GLTFLoader, ...) live under /build/vendor/three/0.184.0/addons/, e.g. import { OrbitControls } from '/build/vendor/three/0.184.0/addons/controls/OrbitControls.js';. Builds saved with the older /build/vendor/three/0.160.0/ path keep working.
283
+ ${LUMINE_THREE_VENDOR_GUIDANCE}
264
284
  - Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
265
285
  - Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
266
286
 
267
287
  ## Local Testing (Playwright / browser probes)
268
288
 
269
289
  - Serve the workspace with a tiny local HTTP server and drive it with Playwright. NEVER copy probe/vendor files into the workspace dir — lumine save uploads everything here (and binary files fail validation). Build a sibling probe dir that symlinks the workspace files instead.
270
- - Vendored imports like /build/vendor/three/0.184.0/... are absolute paths: mirror that directory under your probe dir's root and fetch the files from the LIVE SITE (e.g. https://www.twin-kle.com/build/vendor/three/0.184.0/three.webgpu.min.js). three 0.184 splits into three.module.min.js + three.core.min.js — mirror BOTH or imports fail. Do NOT use npm/CDN copies — the platform's vendored builds have rewritten import specifiers (npm three.tsl.min.js still imports bare "three/webgpu" and breaks the module graph).
290
+ - Vendored imports like ${BUILD_VENDOR_THREE_PREFIX}... are absolute paths: mirror that directory under your probe dir's root and fetch the files from the LIVE SITE (e.g. ${DEFAULT_SITE_URL}${BUILD_VENDOR_THREE_WEBGPU_MODULE_IMPORT}). Three.js ${BUILD_VENDOR_THREE_VERSION} splits into three.module.min.js + three.core.min.js — mirror BOTH plus every addon's relative import closure or imports fail. Do NOT use npm/CDN copies — the platform's vendored builds have rewritten import specifiers (npm three.tsl.min.js still imports bare "three/webgpu" and breaks the module graph).
291
+ - A local 404 caused by an incomplete vendor mirror is a probe setup failure, not evidence that the addon is unavailable on Twinkle. Check the live same-origin vendor URL or the saved Twinkle preview before reporting a platform limitation.
271
292
  - The three WebGPU renderer falls back to WebGL2 in headless Chromium automatically. Headless software rendering runs at ~2-5fps, so anything time-based (walking a character, timers) takes ~10-20x longer than real time — loop with generous waits instead of fixed short sleeps, and bump navigation timeouts.
272
293
  - To inspect module-scope game state, append debug getters when SERVING main.js (e.g. body += "window.__dbg = () => ({...})") rather than editing workspace files.
273
294
  - SDK calls are absent when serving locally; well-written builds optional-chain window.Twinkle and fall back to localStorage. Seed localStorage in the probe to fake saves.
@@ -345,6 +366,7 @@ export const COMMANDS = new Set([
345
366
  "admin",
346
367
  "agent",
347
368
  "agent-mcp",
369
+ "app-mcp",
348
370
  "workspace",
349
371
  "login",
350
372
  "logout",
package/lib/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { createRequire } from "module";
3
3
  import { buildApiJson, mintBuildApiToken } from "./api.js";
4
4
  import { ensureAuth, assertAuthScope } from "./auth.js";
5
5
  import { uploadRuntimeAsset } from "./assets.js";
6
+ import { BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX } from "./constants.js";
6
7
  import { requestJson } from "./http.js";
7
8
  import { resolveSdkBuildId } from "./sdk.js";
8
9
  import { formatBytes, trimTrailingSlash } from "./util.js";
@@ -389,7 +390,7 @@ async function createRuntimeAssetsPreviewSession({
389
390
  };
390
391
  }
391
392
 
392
- function buildRuntimeAssetsProbeHtml({ hdrUrl, glbUrl }) {
393
+ export function buildRuntimeAssetsProbeHtml({ hdrUrl, glbUrl }) {
393
394
  return `<!doctype html>
394
395
  <html>
395
396
  <head>
@@ -429,7 +430,7 @@ function buildRuntimeAssetsProbeHtml({ hdrUrl, glbUrl }) {
429
430
  }
430
431
 
431
432
  async function loadHdr(url) {
432
- const { RGBELoader } = await import('/build/vendor/three/0.184.0/addons/loaders/RGBELoader.js');
433
+ const { RGBELoader } = await import('${BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX}loaders/RGBELoader.js');
433
434
  const texture = await new Promise((resolve, reject) => {
434
435
  new RGBELoader().load(url, resolve, undefined, reject);
435
436
  });
@@ -442,7 +443,7 @@ function buildRuntimeAssetsProbeHtml({ hdrUrl, glbUrl }) {
442
443
  }
443
444
 
444
445
  async function loadGlb(url) {
445
- const { GLTFLoader } = await import('/build/vendor/three/0.184.0/addons/loaders/GLTFLoader.js');
446
+ const { GLTFLoader } = await import('${BUILD_VENDOR_THREE_ADDONS_IMPORT_PREFIX}loaders/GLTFLoader.js');
446
447
  const gltf = await new Promise((resolve, reject) => {
447
448
  new GLTFLoader().load(url, resolve, undefined, reject);
448
449
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.43",
3
+ "version": "0.2.46",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.33.0
4
- Updated: 2026-08-15
5
- Generated: 2026-08-15T03:00:24.685Z
3
+ Version: 1.35.0
4
+ Updated: 2026-08-18
5
+ Generated: 2026-08-18T06:06:02.345Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -113,6 +113,16 @@ files:read, user:read, users:read, dailyReflections:read, content:read, content:
113
113
  - Use navigate() for routes inside the current Build and openContent() for Twinkle subjects, comments, apps, profiles, and other content pages.
114
114
  - Example: await Twinkle.app.openContent('https://www.twin-kle.com/subjects/432');
115
115
 
116
+ ### Twinkle.appTools
117
+ - async register({ handlers }) | scopes: none
118
+ - Returns: { success, session } when opened by lumine app-mcp
119
+ - Register the live iframe handlers for the published app's static MCP tool manifest.
120
+ - Tool discovery comes only from /app-tools.json in the pinned published artifact; runtime code cannot add or rename tools.
121
+ - Every declared tool needs a same-named handler. Outside an app-mcp session, registration stores the handlers and resolves with active:false without starting a relay.
122
+ - Handlers run serially inside the visible app iframe and should return the confirmed post-action state.
123
+ - Do not synthesize server-owned state; await Twinkle SDK mutations before returning.
124
+ - Example: await Twinkle.appTools.register({ handlers: { get_state: () => ({ view, data }), open_view: ({ view }) => navigateTo(view) } });
125
+
116
126
  ### Twinkle.preview
117
127
  - getLayout() | scopes: none
118
128
  - Returns: { mode, viewport, stage, safeInsets, playfield }
@@ -302,6 +312,16 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
302
312
  - Example: await Twinkle.files.delete(assetId);
303
313
 
304
314
  ### Twinkle.ai
315
+ - async getUsagePolicy() | scopes: none
316
+ - Returns: BuildAiUsagePolicy | null
317
+ - Load the signed-in viewer's canonical current AI Energy battery policy.
318
+ - Signed-in viewers only.
319
+ - Returns canonical server state and does not consume AI Energy.
320
+ - Use energyPercent for a percentage meter and energySegments plus energySegmentsRemaining for segmented battery UI.
321
+ - The Build-safe response includes battery/day/usage fields only; account identity, email, risk, and recharge-eligibility metadata are not exposed to the app iframe.
322
+ - Successful AI calls return a newer aiUsagePolicy snapshot. Energy-related SDK errors expose the confirmed snapshot as error.aiUsagePolicy. Replace displayed state only from those confirmed values; do not decrement or synthesize battery state locally.
323
+ - Example: const policy = await Twinkle.ai.getUsagePolicy();
324
+ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
305
325
  - async listPrompts() | scopes: none
306
326
  - Returns: Array<{ id, title, description }>
307
327
  - Legacy helper. Twinkle.ai.chat does not require promptId for default runtime text generation.
@@ -321,20 +341,25 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
321
341
  - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
322
342
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
323
343
  const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
324
- - async generateObject({ prompt, expectedStructure, thinkingMode, mode, instructions, systemPrompt, webSearch } = {}) | scopes: none
325
- - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, webSearch, aiUsagePolicy }
326
- - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, optionally using live web search.
344
+ - async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus } = {}) | scopes: none
345
+ - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, requestedModel, webSearch, aiUsagePolicy }
346
+ - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, with optional live output/status callbacks and web search.
327
347
  - Signed-in viewers only.
328
348
  - Use this instead of asking Twinkle.ai.chat to return JSON.
329
349
  - expectedStructure must be a JSON object that describes the exact returned object shape.
330
350
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
351
+ - Omit model to use the normal Lite/Medium/High routing. model accepts only claude-opus-5 or claude-fable-5, and either explicit model must be paired with thinkingMode: 'high'; unknown model IDs reject instead of silently falling back.
331
352
  - thinkingMode low uses GPT-5.6 Luna and consumes the viewer's AI Energy from confirmed provider usage; its smaller model is usually cheaper than Medium or High.
332
353
  - thinkingMode medium uses Grok 4.6 with medium reasoning and consumes normal AI Energy.
333
354
  - thinkingMode high uses GPT-5.6 Sol with high reasoning and consumes high AI Energy.
334
- - When AI Energy is empty, Low, Medium, and High all reject before new provider work; there is no free fallback mode.
355
+ - claude-opus-5 uses Anthropic adaptive High thinking. claude-fable-5 uses Anthropic xhigh thinking and normally consumes more AI Energy for comparable token use. Both debit confirmed provider usage at the High tier.
356
+ - Pass onStatus and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
357
+ - onText receives accumulated structured-output text plus { done, delta, requestId, status }. Partial output is intentionally incomplete and may include provider formatting; parse only when done is true, when the callback receives the canonical object serialized as JSON, and use the resolved object as the source of truth.
358
+ - Streaming exposes app-visible structured output and high-level phases, not private model chain-of-thought. Put a user-facing field such as producerNotes in expectedStructure when the app should display model-authored commentary from the same generation.
359
+ - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
335
360
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
336
- - The SDK validates shape and retries malformed JSON, but app code should still validate business-specific enum values.
337
- - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'medium', prompt: 'Classify the player intent from: ' + playerText, expectedStructure: { action: 'string', targetCharacter: 'string', confidence: 0, shouldAskFollowUp: false } });
361
+ - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output. App code should still validate business-specific enum values.
362
+ - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
338
363
  - onChatStatus(listener) | scopes: none
339
364
  - Returns: unsubscribe function
340
365
  - Listen to shared runtime AI chat stream events.
@@ -73,10 +73,12 @@ members. The response returns the canonical bucket, including its id for
73
73
  later `get` / `accounts add` / `note set` calls.
74
74
 
75
75
  `accounts add` accepts 1-500 unique positive user IDs, preflights the complete
76
- batch before writing, adds the canonical user and durable verified-email rules,
76
+ batch before writing, adds one exact canonical user rule per requested account,
77
77
  re-attributes current-day AI usage, and returns the canonical bucket members.
78
- It is idempotent to retry. The API records the real operator in the private
79
- Lumine audit log; no public bot identity is involved.
78
+ It never infers or adds email aliases: shared verified addresses can belong to
79
+ unrelated accounts, so email rules require a separate explicit operator action.
80
+ The command is idempotent to retry. The API records the real operator in the
81
+ private Lumine audit log; no public bot identity is involved.
80
82
 
81
83
  `note set` records up to 255 characters of private operational context on the
82
84
  canonical bucket. Use it to distinguish quota bookkeeping from moderation;
@@ -579,7 +581,10 @@ lumine admin escalation set 123 --status resolved \
579
581
  Schemas:
580
582
 
581
583
  ```ts
582
- type DailyRunStart = Success<{ run: DailyRun }>;
584
+ type DailyRunStart = Success<{
585
+ run: DailyRun;
586
+ carryoverTodos: CarryoverTodos;
587
+ }>;
583
588
  type DailyRunStatus = Success<{
584
589
  run: DailyRun | null;
585
590
  lastRun: DailyRun | null;
@@ -632,6 +637,86 @@ mutation when a caller needs the same retry identity across processes. The CLI
632
637
  generates a fresh key for every mutation invocation; if a mutation fails, its
633
638
  JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
634
639
 
640
+ ## Private carry-over todos
641
+
642
+ ```bash
643
+ lumine admin todo list --json
644
+ lumine admin todo list --status all --json
645
+ lumine admin todo add --kind experiment --status in_progress \
646
+ --title "Validate Zero/Ciel cost optimization" \
647
+ --note "Replay baseline and optimized conversations. Complete only after response-quality parity; lower cost with a weaker reply fails." --json
648
+ lumine admin todo update 12 --status blocked \
649
+ --note "Implementation is ready; waiting for a complete cost bucket and old-vs-new quality replay." --json
650
+ lumine admin todo update 12 --status completed \
651
+ --note "Blind parity comparison passed every required dimension; measured cost and latency evidence attached in this note." --json
652
+ ```
653
+
654
+ Todos are private operator work, not Zero/Ciel public actions. They persist
655
+ independently of daily runs and are therefore available before a run starts and
656
+ after it closes. Creating or updating one uses the run-independent transactional
657
+ audit path: canonical todo state and its private `todo.create` / `todo.update`
658
+ audit response commit together, and no public bot, public mutation count, or
659
+ rotation signal is involved.
660
+
661
+ Every successful `daily-run start` response automatically includes all
662
+ unfinished items under `data.carryoverTodos`. The same run ID increments an
663
+ item's surfacing telemetry at most once, even when start is retried. This is the
664
+ canonical handoff: read it before discretionary new work, resume what can safely
665
+ progress after the run's mandatory newspaper/brief/conduct duties, and record a
666
+ concrete progress note before the run closes. The daily-run report includes the
667
+ still-unfinished set again. Completing a daily run never silently completes its
668
+ todos. A CLI carrying this contract rejects a start response that does not echo
669
+ the canonical handoff, so a newer CLI against an API deployed before the todo
670
+ migration cannot quietly treat unsupported telemetry as an empty list.
671
+
672
+ `kind` is `task` or `experiment`. New items may start `open`, `in_progress`, or
673
+ `blocked`; updates may also use `completed` or `cancelled`. A progress note is
674
+ required for every update. For experiments, put the acceptance criteria in the
675
+ initial details and use evidence—not implementation status—as the completion
676
+ boundary. In particular, an AI-cost experiment is not complete until old-vs-new
677
+ response-quality parity is demonstrated; a cheaper but weaker user response is
678
+ a failed experiment. Up to 50 unfinished items may be carried so the automatic
679
+ start payload remains complete and bounded.
680
+
681
+ ```ts
682
+ type AdminTodo = {
683
+ id: number;
684
+ kind: "task" | "experiment";
685
+ title: string;
686
+ details: string;
687
+ status: "open" | "in_progress" | "blocked" | "completed" | "cancelled";
688
+ revision: number;
689
+ createdRunId: number | null;
690
+ lastWorkedRunId: number | null;
691
+ lastSurfacedRunId: number | null;
692
+ surfaceCount: number;
693
+ lastProgressNote: string | null;
694
+ createdAt: number;
695
+ updatedAt: number;
696
+ lastSurfacedAt: number | null;
697
+ completedAt: number | null;
698
+ cancelledAt: number | null;
699
+ };
700
+
701
+ type AdminTodoList = Success<{
702
+ todos: AdminTodo[];
703
+ statusFilter:
704
+ | "pending"
705
+ | "all"
706
+ | AdminTodo["status"];
707
+ truncated: boolean;
708
+ }>;
709
+
710
+ type AdminTodoMutation = Success<{ todo: AdminTodo }>;
711
+
712
+ type CarryoverTodos = {
713
+ items: AdminTodo[];
714
+ count: number;
715
+ surfacedForRunId: number;
716
+ newlySurfacedCount: number;
717
+ };
718
+ ```
719
+
635
720
  ## Canonical lists and inspection
636
721
 
637
722
  ```bash
@@ -2176,9 +2261,12 @@ Public content actions use ordinary Twinkle fan-out:
2176
2261
  - effort/creator changes emit `edit_content`;
2177
2262
  - Featured changes emit a canonical `home_outdated` refresh.
2178
2263
 
2179
- Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql` and
2180
- then `add-lumine-admin-comment-targets.sql` before deploying the API. They add
2181
- only focused daily-run, rotation, draft, and audit tables/columns and indexes;
2264
+ Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql`, then
2265
+ `add-lumine-admin-comment-targets.sql`, and apply
2266
+ `add-lumine-admin-todos.sql` before deploying an API that exposes carry-over
2267
+ todos. They add
2268
+ only focused daily-run, rotation, draft, audit, and private-todo tables/columns
2269
+ and indexes;
2182
2270
  there are no runtime schema checks. The comment-targets migration backfills
2183
2271
  existing subject drafts into the generalized target columns. The local CLI changes
2184
2272
  are not available to users until a separately authorized npm publication.