@stage5/lumine 0.2.40 → 0.2.42

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
@@ -164,7 +164,35 @@ version state in `.twinkle/lumine-project.json` so local agents can tell when
164
164
  they should rerun with `npx @stage5/lumine@latest`. Use `--no-update-check` to
165
165
  skip that advisory network check.
166
166
 
167
- After pulling a project, run an agent from the pulled folder:
167
+ ## Subscription agents through the Lumine loop
168
+
169
+ After pulling a project, a signed-in Codex or Claude Code subscription can
170
+ power Lumine's workspace loop without spending Twinkle AI Energy:
171
+
172
+ ```bash
173
+ lumine agent --provider codex "Add keyboard controls"
174
+ lumine agent --provider claude-code "Fix the mobile layout"
175
+ ```
176
+
177
+ The external model cannot write project files directly. Lumine supplies the
178
+ same core workspace prompt/tool contract, read-before-edit behavior, bounded
179
+ scope checks, and validation-repair passes used by the hosted Build agent. A save happens only
180
+ after validation passes and still uses the workspace's server-issued
181
+ `filesHash`, so a concurrent server change stops the run instead of being
182
+ overwritten. Provider login and model usage stay inside the selected local CLI;
183
+ Twinkle never receives a provider credential and does not reserve AI Energy.
184
+
185
+ Every run writes a sanitized observable tool trace under
186
+ `.twinkle/agent-runs/`. By default, the selected subscription agent reviews
187
+ that trace after the pass and records evidence-based loop feedback without
188
+ collecting hidden chain-of-thought; use `--no-review-loop` to skip that extra
189
+ provider turn. Provider support is adapter-based rather than Codex-specific.
190
+ Codex uses its local app-server protocol and Claude Code uses the same Lumine
191
+ tools over MCP. Both adapters disable inherited model-side project tooling and
192
+ launch with a credential-minimized environment, so project access remains at
193
+ the Lumine tool boundary.
194
+
195
+ You can still use a coding agent directly for a manual edit-and-save workflow:
168
196
 
169
197
  ```bash
170
198
  codex "Read AGENTS.md, then make the requested change."
@@ -186,11 +214,16 @@ events.
186
214
 
187
215
  ```bash
188
216
  lumine admin identity list --json
217
+ lumine admin identity inspect Jay1216 \
218
+ --reason "Confirm account family before a quota-bucket change" --json
189
219
  lumine admin daily-run start --identity auto --comment-mode off --json
190
- lumine admin recommendations list --cursor '<cursor>' --json
220
+ lumine admin recommendations list --all --checkpoint recommendations.json --json
221
+ lumine admin recommendations list --after 2026-08-14T00:00:00Z --all --json
222
+ lumine admin recommendations list --include-legacy --all --json
191
223
  lumine admin subjects candidates --after 2026-08-01T00:00:00Z --json
192
224
  lumine admin subjects candidates --effort unassigned --json
193
- lumine admin builds candidates --cursor '<cursor>' --json
225
+ lumine admin builds candidates --all --json
226
+ lumine admin builds review build:884 --output-dir ./build-review --json
194
227
  lumine admin subject get 123 --include-comments --json
195
228
  lumine admin post get https://www.twin-kle.com/ai-stories/88 --json
196
229
  lumine admin post comments dailyReflection:99 --json
@@ -206,10 +239,20 @@ lumine admin brief --days 3 --json
206
239
  lumine admin notable add Minecrarft_guy --note "Created 8 thoughtful subjects and helped peers in 23 comments this window." --json
207
240
  lumine admin post recommend comment:456 --anyone-can-reward --reward-twinkles 3 --json
208
241
  lumine admin post reward comment:456 --twinkles 3 --json
242
+ lumine admin post skip-batch --target-file skipped.json --checkpoint skip-progress.json --json
209
243
  lumine admin comment draft build:884 --file comment.md \
210
- --reviewed-version 4512 --reviewed-via runtime --json
244
+ --review-receipt /path/from-build-review/review.json --json
211
245
  lumine admin comment post --draft-id 77 --json
246
+ lumine admin news claim --output claim.json --scaffold editorial.json --json
247
+ lumine admin news validate --claim claim.json --file editorial.json --json
248
+ lumine admin news submit --claim claim.json --file editorial.json --json
249
+ lumine admin daily-run escalation add --target subject:123 \
250
+ --note "Concrete privacy issue requiring owner review" --json
251
+ lumine admin daily-run report --json
212
252
  lumine admin daily-run complete --json
253
+ lumine admin escalation list --status all --json
254
+ lumine admin escalation set 123 --status resolved \
255
+ --note "Final owner decision" --json
213
256
  ```
214
257
 
215
258
  Numeric recommendation targets default to subjects. Use `comment:<id>`,
@@ -221,11 +264,15 @@ reply is handled by Twinkle's existing autonomous Zero/Ciel responder without
221
264
  Lumine remaining active.
222
265
 
223
266
  Management agents also inspect recent public Build candidates during each run.
224
- A direct Build comment is never server-generated: first open and try the
225
- published app or pull and read its code, then compose with `--file` and bind
226
- the draft to the exact `publishedArtifactVersionId` using
227
- `--reviewed-version` and `--reviewed-via runtime|code`. The server rejects
228
- missing or stale review evidence and any app/thread change before publication.
267
+ `builds review` opens one published app in an isolated temporary Chromium
268
+ profile, captures a screenshot and console evidence, verifies that the
269
+ published version stayed fixed, and writes a review receipt in a unique output
270
+ subdirectory. A direct Build comment is never server-generated: review the
271
+ runtime (or pull and read an
272
+ open-source app), compose with `--file`, and attach the receipt. The server
273
+ rejects missing or stale review evidence and any app/thread change before
274
+ publication. Manual `--reviewed-version` / `--reviewed-via` evidence remains
275
+ available for genuine code reviews.
229
276
 
230
277
  Every operation is noninteractive when its required arguments are present.
231
278
  `--json` prints exactly one uncolored JSON value and returns a nonzero status
@@ -235,10 +282,28 @@ server also enforces canonical no-duplicate invariants. Failed mutation JSON
235
282
  includes `error.details.retryIdempotencyKey` so a partial attempt can be
236
283
  resumed with the exact generated key.
237
284
 
238
- Subject and recommendation-queue listings use opaque, stable snapshot cursors.
239
- Pass the returned `data.pagination.nextCursor` back through `--cursor` until
240
- `data.pagination.exhausted` is true. Subject `--after` is inclusive and cursors
241
- are bound to the original date and effort filters.
285
+ Subject and queue listings use opaque, stable snapshot cursors. `--all`
286
+ follows them automatically, saves a checkpoint after every canonical page,
287
+ and records completed queue coverage in the run audit; `--resume` continues
288
+ the exact same request. With `--all --json`, bounded progress goes to stderr so
289
+ stdout remains one pipe-safe JSON value. Recommendation scans default to the previous completed
290
+ run's start boundary for at-least-once coverage. Use `--after` for an explicit
291
+ timestamp or `--include-legacy`
292
+ for an intentional all-history scan. Subject `--after` is inclusive and
293
+ cursors are bound to the original date and effort filters.
294
+
295
+ `news claim` can write both the canonical leased digest and an editable
296
+ editorial scaffold. `news validate` is local and checks every citation and
297
+ 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.
301
+
302
+ 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
306
+ `--include-private-evidence`. Routine briefs omit raw email identities.
242
307
 
243
308
  The complete run lifecycle, command contracts, nullable fields, Karma approval
244
309
  behavior, pagination semantics, secret-subject behavior, presence isolation,
@@ -0,0 +1,236 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const MAX_ADMIN_JSON_BYTES = 2 * 1024 * 1024;
11
+
12
+ function validationError(message) {
13
+ const error = new Error(message);
14
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
15
+ return error;
16
+ }
17
+
18
+ function formatByteLimit(maxBytes) {
19
+ const megabytes = maxBytes / (1024 * 1024);
20
+ return Number.isInteger(megabytes)
21
+ ? `${megabytes} MB`
22
+ : `${maxBytes} bytes`;
23
+ }
24
+
25
+ export function readAdminJsonFile(
26
+ filePath,
27
+ label = "JSON file",
28
+ { maxBytes = MAX_ADMIN_JSON_BYTES } = {},
29
+ ) {
30
+ const normalizedPath = String(filePath || "").trim();
31
+ if (!normalizedPath) throw validationError(`Pass ${label}.`);
32
+ let contents;
33
+ try {
34
+ contents = readFileSync(normalizedPath, "utf8");
35
+ } catch {
36
+ throw validationError(`Could not read ${normalizedPath}.`);
37
+ }
38
+ if (Buffer.byteLength(contents, "utf8") > maxBytes) {
39
+ throw validationError(`${label} must be under ${formatByteLimit(maxBytes)}.`);
40
+ }
41
+ try {
42
+ return JSON.parse(contents);
43
+ } catch {
44
+ throw validationError(`${normalizedPath} is not valid JSON.`);
45
+ }
46
+ }
47
+
48
+ export function writeAdminJsonFile(
49
+ filePath,
50
+ value,
51
+ { privateFile = false, maxBytes = null } = {},
52
+ ) {
53
+ const resolved = path.resolve(String(filePath || "").trim());
54
+ if (!String(filePath || "").trim()) {
55
+ throw validationError("An output file path is required.");
56
+ }
57
+ mkdirSync(path.dirname(resolved), { recursive: true });
58
+ const temporary = `${resolved}.tmp-${process.pid}`;
59
+ const contents = `${JSON.stringify(value, null, 2)}\n`;
60
+ if (
61
+ Number.isSafeInteger(maxBytes) &&
62
+ maxBytes > 0 &&
63
+ Buffer.byteLength(contents, "utf8") > maxBytes
64
+ ) {
65
+ throw validationError(
66
+ `The output exceeds the ${formatByteLimit(maxBytes)} safety limit. Narrow the request before retrying.`,
67
+ );
68
+ }
69
+ writeFileSync(temporary, contents, {
70
+ encoding: "utf8",
71
+ mode: privateFile ? 0o600 : 0o644,
72
+ });
73
+ renameSync(temporary, resolved);
74
+ if (privateFile) chmodSync(resolved, 0o600);
75
+ return resolved;
76
+ }
77
+
78
+ export function extractNewsClaim(value) {
79
+ const claim = value?.claim || value?.data?.claim || value;
80
+ if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
81
+ throw validationError("The claim file does not contain a newspaper claim.");
82
+ }
83
+ const editionId = Number(claim.editionId || 0);
84
+ const leaseToken = String(claim.leaseToken || "").trim();
85
+ const events = Array.isArray(claim.events) ? claim.events : null;
86
+ if (!Number.isSafeInteger(editionId) || editionId <= 0 || !leaseToken || !events) {
87
+ throw validationError(
88
+ "The claim file is missing editionId, leaseToken, or canonical events.",
89
+ );
90
+ }
91
+ return { ...claim, editionId, leaseToken, events };
92
+ }
93
+
94
+ function exactQuoteFromSummary(summary, maximum) {
95
+ return String(summary || "").slice(0, Math.max(0, maximum));
96
+ }
97
+
98
+ function scaffoldStory(event, maximum) {
99
+ return {
100
+ eventKey: String(event.eventKey || ""),
101
+ headline: "",
102
+ summary: "",
103
+ sourceQuote:
104
+ String(event.section || "") === "front"
105
+ ? exactQuoteFromSummary(event.summary, maximum)
106
+ : "",
107
+ coveredEventKeys: [],
108
+ };
109
+ }
110
+
111
+ export function createNewsEditorialScaffold(claimValue) {
112
+ const claim = extractNewsClaim(claimValue);
113
+ const maximum = Math.max(0, Number(claim.maxSourceQuoteLength || 360));
114
+ const frontIndex = claim.events.findIndex(
115
+ (event) => String(event?.section || "") === "front",
116
+ );
117
+ const lead = frontIndex >= 0 ? scaffoldStory(claim.events[frontIndex], maximum) : null;
118
+ const stories = claim.events
119
+ .filter((_event, index) => index !== frontIndex)
120
+ .map((event) => scaffoldStory(event, maximum));
121
+ return {
122
+ mastheadHeadline: "",
123
+ mastheadDeck: "",
124
+ ...(lead ? { lead } : {}),
125
+ stories,
126
+ editorsNote: "",
127
+ };
128
+ }
129
+
130
+ function requireEditorialText(value, label) {
131
+ if (typeof value !== "string" || !value.trim()) {
132
+ throw validationError(`${label} must be non-empty text.`);
133
+ }
134
+ }
135
+
136
+ export function validateNewsEditorial({ claim: claimValue, editorial }) {
137
+ const claim = extractNewsClaim(claimValue);
138
+ if (!editorial || typeof editorial !== "object" || Array.isArray(editorial)) {
139
+ throw validationError("The editorial must be a JSON object.");
140
+ }
141
+ requireEditorialText(editorial.mastheadHeadline, "mastheadHeadline");
142
+ requireEditorialText(editorial.mastheadDeck, "mastheadDeck");
143
+ requireEditorialText(editorial.editorsNote, "editorsNote");
144
+ if (!Array.isArray(editorial.stories)) {
145
+ throw validationError("stories must be an array.");
146
+ }
147
+ const eventByKey = new Map(
148
+ claim.events.map((event) => [String(event?.eventKey || ""), event]),
149
+ );
150
+ const usedKeys = new Set();
151
+ const maximum = Math.max(0, Number(claim.maxSourceQuoteLength || 360));
152
+ const entries = [
153
+ ...(editorial.lead ? [{ label: "lead", story: editorial.lead, lead: true }] : []),
154
+ ...editorial.stories.map((story, index) => ({
155
+ label: `stories[${index}]`,
156
+ story,
157
+ lead: false,
158
+ })),
159
+ ];
160
+ for (const entry of entries) {
161
+ const story = entry.story;
162
+ if (!story || typeof story !== "object" || Array.isArray(story)) {
163
+ throw validationError(`${entry.label} must be an object.`);
164
+ }
165
+ const eventKey = String(story.eventKey || "").trim();
166
+ const event = eventByKey.get(eventKey);
167
+ if (!event) throw validationError(`${entry.label}.eventKey is not in the claim.`);
168
+ if (usedKeys.has(eventKey)) {
169
+ throw validationError(`${eventKey} is cited or covered more than once.`);
170
+ }
171
+ usedKeys.add(eventKey);
172
+ if (entry.lead && String(event.section || "") !== "front") {
173
+ throw validationError("The lead must cite a front-section event.");
174
+ }
175
+ requireEditorialText(story.headline, `${entry.label}.headline`);
176
+ requireEditorialText(story.summary, `${entry.label}.summary`);
177
+ const quote = typeof story.sourceQuote === "string" ? story.sourceQuote : "";
178
+ if (String(event.section || "") === "front") {
179
+ const canonicalSummary = String(event.summary || "");
180
+ const quoteIsValid = canonicalSummary
181
+ ? Boolean(
182
+ quote &&
183
+ quote.length <= maximum &&
184
+ canonicalSummary.includes(quote),
185
+ )
186
+ : quote === "";
187
+ if (!quoteIsValid) {
188
+ throw validationError(
189
+ `${entry.label}.sourceQuote must be an exact contiguous claim-summary passage no longer than ${maximum} characters.`,
190
+ );
191
+ }
192
+ } else if (quote !== "") {
193
+ throw validationError(`${entry.label}.sourceQuote must be empty outside the front section.`);
194
+ }
195
+ const covered = story.coveredEventKeys ?? [];
196
+ if (!Array.isArray(covered)) {
197
+ throw validationError(`${entry.label}.coveredEventKeys must be an array.`);
198
+ }
199
+ for (const rawCoveredKey of covered) {
200
+ const coveredKey = String(rawCoveredKey || "").trim();
201
+ if (!eventByKey.has(coveredKey)) {
202
+ throw validationError(`${entry.label} covers an eventKey not in the claim.`);
203
+ }
204
+ if (coveredKey === eventKey || usedKeys.has(coveredKey)) {
205
+ throw validationError(`${coveredKey} is cited or covered more than once.`);
206
+ }
207
+ usedKeys.add(coveredKey);
208
+ }
209
+ }
210
+ return {
211
+ valid: true,
212
+ editionId: claim.editionId,
213
+ citedEventCount: entries.length,
214
+ coveredEventCount: usedKeys.size - entries.length,
215
+ availableEventCount: claim.events.length,
216
+ };
217
+ }
218
+
219
+ export function writeNewsClaimArtifacts({ result, outputPath, scaffoldPath }) {
220
+ const claim = result?.data?.claim || null;
221
+ const artifacts = { claimFile: null, scaffoldFile: null };
222
+ if (outputPath) {
223
+ artifacts.claimFile = writeAdminJsonFile(
224
+ outputPath,
225
+ { schemaVersion: 1, savedAt: new Date().toISOString(), claim },
226
+ { privateFile: true },
227
+ );
228
+ }
229
+ if (scaffoldPath && claim) {
230
+ artifacts.scaffoldFile = writeAdminJsonFile(
231
+ scaffoldPath,
232
+ createNewsEditorialScaffold(claim),
233
+ );
234
+ }
235
+ return artifacts;
236
+ }