@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 +32 -6
- package/lib/admin.js +175 -3
- package/lib/api.js +57 -3
- package/lib/app-mcp/server.js +255 -0
- package/lib/commands.js +33 -7
- package/lib/constants.js +24 -2
- package/lib/doctor.js +4 -3
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +34 -9
- package/sdk/LUMINE_ADMIN.md +95 -7
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
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
304
|
-
require a delegated daily run. Identity inspection always requires an
|
|
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) &&
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
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('
|
|
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
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-08-
|
|
5
|
-
Generated: 2026-08-
|
|
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,
|
|
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
|
-
-
|
|
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
|
|
337
|
-
- Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: '
|
|
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.
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -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
|
|
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
|
|
79
|
-
|
|
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<{
|
|
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
|
|
2180
|
-
|
|
2181
|
-
|
|
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.
|