@stage5/lumine 0.2.59 → 0.2.61

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
@@ -276,6 +276,13 @@ heartbeat, supervisor, or provider-spawning daemon is not sponsor duty. The CLI
276
276
  rejects a provider name that differs from the agent session actually running
277
277
  the command.
278
278
 
279
+ `duty watch` may return a `teamAccessRequest` instead of a job. Tell the
280
+ sponsor which requester, owner, and project issued the invitation, then return
281
+ to duty without waiting or accepting on the sponsor's behalf. The ordinary
282
+ Twinkle team invitation is already waiting for the sponsor; no Workshop work
283
+ starts unless they accept it there. This notice is invitation metadata only;
284
+ it does not expose the unapproved Workshop plan or private chat.
285
+
279
286
  When a user approves a plan, `duty watch` returns the scoped assignment file and
280
287
  contribution workspace to that same session. It does not launch a fresh Codex
281
288
  or Claude process. The on-duty agent uses this explicit lifecycle while it
@@ -283,13 +290,18 @@ works:
283
290
 
284
291
  ```bash
285
292
  lumine sponsor job begin 42
293
+ lumine sponsor job update 42 --file /path/to/lumine-update.txt --phase building
286
294
  lumine sponsor job pulse 42
287
295
  lumine sponsor job relay-applied 42 101
288
296
  lumine sponsor job complete 42 --summary "Implemented and tested the approved change"
289
297
  ```
290
298
 
291
299
  `pulse` renews the duty/job leases and returns any newly approved follow-up.
292
- Run it between substantial work steps. `relay-applied` is an explicit receipt;
300
+ `update` sends the exact file text back to Zero or Ciel as a user-visible
301
+ message from Lumine, their added brain. Use it for concise, deliberate
302
+ milestones only; never send hidden reasoning, raw terminal output, credentials,
303
+ tokens, private paths, or unrelated data. Run `pulse` between substantial work
304
+ steps. `relay-applied` is an explicit receipt;
293
305
  completion is rejected until every delivered relay has actually been applied
294
306
  and acknowledged. Use `helper-start` and `helper-complete` to record native
295
307
  same-session subagents within the configured helper limit—the CLI records them
package/lib/admin.js CHANGED
@@ -310,6 +310,9 @@ export async function adminCommand(options) {
310
310
  typeof operation.body.buildReviewUnderstanding === "string",
311
311
  });
312
312
  }
313
+ if (operation.name === "ai-email-policy.set") {
314
+ assertAiEmailPolicySetResult({ operation, result });
315
+ }
313
316
  if (operation.name === "daily-run.start") {
314
317
  assertAdminTodoHandoffResult(result);
315
318
  }
@@ -493,6 +496,55 @@ export function assertComposedCommentDraftResult({
493
496
  throw error;
494
497
  }
495
498
 
499
+ export function assertAiEmailPolicySetResult({ operation, result }) {
500
+ const requestedEmail = String(operation?.body?.email || "");
501
+ const requestedMode = String(operation?.body?.mode || "");
502
+ const policy = result?.data?.policy;
503
+ const projection = result?.data?.projection;
504
+ const accountUserIds = Array.isArray(result?.data?.accountUserIds)
505
+ ? result.data.accountUserIds
506
+ : [];
507
+ const accountCount = Number(result?.data?.accountCount);
508
+ const expectedIdentityType =
509
+ requestedMode === "separate_accounts"
510
+ ? "separate_verified_email"
511
+ : "verified_email";
512
+ if (
513
+ policy?.exists === true &&
514
+ policy?.normalizedEmail === requestedEmail &&
515
+ policy?.mode === requestedMode &&
516
+ Number.isSafeInteger(accountCount) &&
517
+ accountCount >= 0 &&
518
+ accountUserIds.length === accountCount &&
519
+ projection?.expectedIdentityType === expectedIdentityType &&
520
+ projection?.accountCount === accountCount &&
521
+ projection?.matchingAccountCount === accountCount &&
522
+ Array.isArray(projection?.mismatchedAccountUserIds) &&
523
+ projection.mismatchedAccountUserIds.length === 0 &&
524
+ projection?.converged === true
525
+ ) {
526
+ return;
527
+ }
528
+ const error = new Error(
529
+ "The API did not confirm that every matching account converged to the requested AI email policy. Retry only after reviewing the canonical response and deployed API.",
530
+ );
531
+ error.code = "LUMINE_ADMIN_AI_EMAIL_POLICY_NOT_CONVERGED";
532
+ error.data = {
533
+ ok: false,
534
+ status: "partial_failure",
535
+ error: {
536
+ code: error.code,
537
+ message: error.message,
538
+ details: {
539
+ requestedEmail,
540
+ requestedMode,
541
+ canonical: result?.data || null,
542
+ },
543
+ },
544
+ };
545
+ throw error;
546
+ }
547
+
496
548
  const RECOMMENDATION_CONTENT_TYPES = new Map([
497
549
  ["comment", "comment"],
498
550
  ["aistory", "aiStory"],
@@ -616,6 +668,7 @@ function adminOperationRequiresRun(operation) {
616
668
  "notable.add",
617
669
  ].includes(operation.name) &&
618
670
  !operation.name.startsWith("ai-bucket.") &&
671
+ !operation.name.startsWith("ai-email-policy.") &&
619
672
  !operation.name.startsWith("todo.")
620
673
  );
621
674
  }
@@ -774,6 +827,30 @@ export function parseAdminOperation(options) {
774
827
  }
775
828
  }
776
829
 
830
+ if (namespace === "ai-email-policy" || namespace === "ai-email-policies") {
831
+ const email = parseAiEmailPolicyEmail(options.adminEmail);
832
+ if (action === "get" || action === "status") {
833
+ return bodyReadOperation(
834
+ "ai-email-policy.get",
835
+ "POST",
836
+ "/cli/admin/ai-email-policies/lookup",
837
+ { email },
838
+ );
839
+ }
840
+ if (action === "set") {
841
+ return writeOperation(
842
+ "ai-email-policy.set",
843
+ "PUT",
844
+ "/cli/admin/ai-email-policies",
845
+ {
846
+ email,
847
+ mode: parseAiEmailPolicyMode(options.adminMode),
848
+ note: parseAiEmailPolicyNote(options.note),
849
+ },
850
+ );
851
+ }
852
+ }
853
+
777
854
  if (namespace === "todo" || namespace === "todos") {
778
855
  if (!action || action === "list") {
779
856
  return readOperation(
@@ -1985,6 +2062,10 @@ function readOperation(name, path, extra = {}) {
1985
2062
  };
1986
2063
  }
1987
2064
 
2065
+ function bodyReadOperation(name, method, path, body) {
2066
+ return { name, method, path, body, mutates: false };
2067
+ }
2068
+
1988
2069
  function writeOperation(name, method, path, body) {
1989
2070
  return { name, method, path, body, mutates: true };
1990
2071
  }
@@ -2156,6 +2237,49 @@ function parseAiBucketUserIds(value) {
2156
2237
  return ids;
2157
2238
  }
2158
2239
 
2240
+ function parseAiEmailPolicyEmail(value) {
2241
+ const email = String(value || "")
2242
+ .trim()
2243
+ .toLowerCase();
2244
+ const at = email.indexOf("@");
2245
+ if (
2246
+ !email ||
2247
+ email.length > 320 ||
2248
+ /\s/.test(email) ||
2249
+ at <= 0 ||
2250
+ at !== email.lastIndexOf("@") ||
2251
+ at === email.length - 1
2252
+ ) {
2253
+ throw cliValidationError(
2254
+ "Pass a valid verified email with --email <address>.",
2255
+ );
2256
+ }
2257
+ return email;
2258
+ }
2259
+
2260
+ function parseAiEmailPolicyMode(value) {
2261
+ const mode = String(value || "")
2262
+ .trim()
2263
+ .toLowerCase();
2264
+ if (mode !== "automatic" && mode !== "separate_accounts") {
2265
+ throw cliValidationError("--mode must be automatic or separate_accounts.");
2266
+ }
2267
+ return mode;
2268
+ }
2269
+
2270
+ function parseAiEmailPolicyNote(value) {
2271
+ const note = String(value || "").trim();
2272
+ if (!note) {
2273
+ throw cliValidationError("Pass the policy reason with --note <text>.");
2274
+ }
2275
+ if (note.length > 255) {
2276
+ throw cliValidationError(
2277
+ "An AI email-policy note can be at most 255 characters.",
2278
+ );
2279
+ }
2280
+ return note;
2281
+ }
2282
+
2159
2283
  function parseRequiredInteger(
2160
2284
  value,
2161
2285
  label,
@@ -2421,7 +2545,9 @@ async function printSpooledAdminResult({ result, storage }) {
2421
2545
  );
2422
2546
  });
2423
2547
  } else if (storage.collectionKey === "comments") {
2424
- console.log(`${count} comment(s) for ${data.subject?.url || "this target"}:`);
2548
+ console.log(
2549
+ `${count} comment(s) for ${data.subject?.url || "this target"}:`,
2550
+ );
2425
2551
  await forEachPaginatedResultItem(result, async (comment) => {
2426
2552
  console.log(
2427
2553
  `#${comment.id} ${comment.author?.username || "unknown"}: ${comment.content || "(empty)"}`,
@@ -2502,11 +2628,12 @@ function printAdminResult({ operation, result }) {
2502
2628
  console.log(
2503
2629
  `Sponsor-integrity case #${item.id}: ${String(item.status).toUpperCase()} · job #${item.job?.id} · ${item.sponsorUsername || `sponsor ${item.sponsorUserId}`}.`,
2504
2630
  );
2505
- const relays = Array.isArray(item.relays) && item.relays.length > 0
2506
- ? item.relays
2507
- : item.relay
2508
- ? [item.relay]
2509
- : [];
2631
+ const relays =
2632
+ Array.isArray(item.relays) && item.relays.length > 0
2633
+ ? item.relays
2634
+ : item.relay
2635
+ ? [item.relay]
2636
+ : [];
2510
2637
  console.log(
2511
2638
  `Relays: ${relays.map((relay) => `${relay.kind || "initial_request"}: ${relay.summary || "(missing)"}`).join(" | ") || "(missing)"}`,
2512
2639
  );
package/lib/commands.js CHANGED
@@ -2409,12 +2409,10 @@ export function parseArgs(args) {
2409
2409
  sponsorAgentOrdinal: raw.ordinal,
2410
2410
  sponsorOutcome: raw.outcome ? String(raw.outcome) : "",
2411
2411
  sponsorFailureReason: raw.reason ? String(raw.reason) : "",
2412
- sponsorResolvedModel: raw.resolvedModel
2413
- ? String(raw.resolvedModel)
2414
- : "",
2415
- sponsorResolvedEffort: raw.resolvedEffort
2416
- ? String(raw.resolvedEffort)
2417
- : "",
2412
+ sponsorUpdateFile: raw.file ? String(raw.file) : "",
2413
+ sponsorUpdatePhase: raw.phase ? String(raw.phase) : "",
2414
+ sponsorResolvedModel: raw.resolvedModel ? String(raw.resolvedModel) : "",
2415
+ sponsorResolvedEffort: raw.resolvedEffort ? String(raw.resolvedEffort) : "",
2418
2416
  sponsorResolvedServiceTier: raw.resolvedServiceTier
2419
2417
  ? String(raw.resolvedServiceTier)
2420
2418
  : "",
@@ -2440,6 +2438,8 @@ export function parseArgs(args) {
2440
2438
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2441
2439
  adminLabel: raw.label ? String(raw.label) : "",
2442
2440
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2441
+ adminEmail: raw.email ? String(raw.email) : "",
2442
+ adminMode: raw.mode ? String(raw.mode) : "",
2443
2443
  adminType: raw.type ? String(raw.type) : "",
2444
2444
  adminKind: raw.kind ? String(raw.kind) : "",
2445
2445
  adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
@@ -2749,6 +2749,7 @@ export function printHelp() {
2749
2749
  lumine sponsor duty watch [--wait-ms <ms>] [--json]
2750
2750
  lumine sponsor duty pause|resume|stop
2751
2751
  lumine sponsor job status|pulse|begin <job-id>
2752
+ lumine sponsor job update <job-id> --file <path> [--phase <name>]
2752
2753
  lumine sponsor job relay-applied <job-id> <relay-id...>
2753
2754
  lumine sponsor job helper-start|helper-complete <job-id> [options]
2754
2755
  lumine sponsor job complete <job-id> --summary <text>
@@ -2804,6 +2805,8 @@ export function printHelp() {
2804
2805
  lumine admin ai-bucket get --bucket-id <id> [--json]
2805
2806
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
2806
2807
  lumine admin ai-bucket note set --bucket-id <id> --note <text> [--json]
2808
+ lumine admin ai-email-policy get --email <address> [--json]
2809
+ lumine admin ai-email-policy set --email <address> --mode <automatic|separate_accounts> --note <text> [--json]
2807
2810
  lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
2808
2811
  lumine admin daily-run status|report|complete|fail [--reason <text>] [--json]
2809
2812
  lumine admin daily-run escalation add --target <target> --note <summary> [--severity attention|urgent] [--json]
@@ -21,6 +21,18 @@ const DEFAULT_DUTY_WATCH_MS = 20_000;
21
21
  const MAX_DUTY_WATCH_MS = 60_000;
22
22
  const MAX_FORUM_CONTEXT_CHARS = 8_000;
23
23
 
24
+ function sanitizeSponsorTerminalLabel(value, fallback) {
25
+ const label = String(value || "")
26
+ .replace(
27
+ /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,
28
+ " ",
29
+ )
30
+ .replace(/\s+/g, " ")
31
+ .trim()
32
+ .slice(0, 200);
33
+ return label || fallback;
34
+ }
35
+
24
36
  export async function sponsorDutyCommand(options, args, commandServices) {
25
37
  const action = String(args[0] || "status")
26
38
  .trim()
@@ -66,6 +78,13 @@ export async function sponsorJobCommand(options, args, commandServices) {
66
78
  await withSponsorStateLock(options, () => beginJob(options, jobId));
67
79
  return;
68
80
  }
81
+ if (action === "update") {
82
+ assertNoExtraArgs(args, 2);
83
+ await withSponsorStateLock(options, () =>
84
+ publishDialogueUpdate(options, jobId),
85
+ );
86
+ return;
87
+ }
69
88
  if (action === "relay-applied") {
70
89
  const relayIds = args
71
90
  .slice(2)
@@ -343,6 +362,40 @@ async function watchDuty(options, commandServices) {
343
362
  );
344
363
  continue;
345
364
  }
365
+ if (claim.teamAccessRequest) {
366
+ const request = claim.teamAccessRequest;
367
+ const requester = request.requesterUsername
368
+ ? `@${sanitizeSponsorTerminalLabel(
369
+ request.requesterUsername,
370
+ `user-${request.requesterUserId}`,
371
+ )}`
372
+ : `user #${request.requesterUserId}`;
373
+ const owner = request.ownerUsername
374
+ ? `@${sanitizeSponsorTerminalLabel(
375
+ request.ownerUsername,
376
+ `user-${request.ownerUserId}`,
377
+ )}`
378
+ : `user #${request.ownerUserId}`;
379
+ const buildTitle = sanitizeSponsorTerminalLabel(
380
+ request.buildTitle,
381
+ `Build #${request.buildId}`,
382
+ );
383
+ printJsonOrLines(
384
+ options,
385
+ {
386
+ duty: publicDuty(state.duty),
387
+ teamAccessRequest: request,
388
+ assignment: null,
389
+ nextCommand: "lumine sponsor duty watch --json",
390
+ },
391
+ [
392
+ `${requester} asked to invite this sponsor account to ${buildTitle}, owned by ${owner}.`,
393
+ "Ask the sponsor whether they want to join. Their usual Twinkle team invitation is already waiting; no Workshop work starts unless they accept it.",
394
+ "Do not hold the Workshop queue open for a reply. Return to duty after sharing this notice.",
395
+ ],
396
+ );
397
+ return;
398
+ }
346
399
  if (claim.job) {
347
400
  state = await recordClaim({ options, state, claim });
348
401
  const prepared = await prepareClaimedJob({
@@ -539,10 +592,61 @@ async function beginJob(options, jobId) {
539
592
  printJsonOrLines(options, { job: canonicalJobState.job, coordinator: agent }, [
540
593
  `Workshop job #${jobId} is now being handled by this same on-duty agent session.`,
541
594
  `Apply the approved plan in ${jobState.workspaceDir}.`,
595
+ `Introduce yourself to ${displayPersona(jobState.job.persona)} as Lumine with: lumine sponsor job update ${jobId} --file <message-file> --phase starting`,
542
596
  `Check in with: lumine sponsor job pulse ${jobId}`,
543
597
  ]);
544
598
  }
545
599
 
600
+ async function publishDialogueUpdate(options, jobId) {
601
+ const messageFile = String(options.sponsorUpdateFile || "").trim();
602
+ if (!messageFile) {
603
+ throw new Error(
604
+ "Write the deliberate user-facing Lumine update to a file and pass --file <path>.",
605
+ );
606
+ }
607
+ const phase = String(options.sponsorUpdatePhase || "").trim();
608
+ if (phase.length > 40) {
609
+ throw new Error("--phase must be at most 40 characters.");
610
+ }
611
+ const messagePath = path.resolve(messageFile);
612
+ const messageStat = await fs.stat(messagePath);
613
+ if (!messageStat.isFile() || messageStat.size > 8_000) {
614
+ throw new Error("The Lumine update file must be a text file under 8 KB.");
615
+ }
616
+ const message = String(await fs.readFile(messagePath, "utf8")).trim();
617
+ if (!message) throw new Error("The Lumine update file is empty.");
618
+ if (message.length > 2_000) {
619
+ throw new Error("The Lumine update must be at most 2,000 characters.");
620
+ }
621
+
622
+ const auth = await ensureSponsorAuth(options);
623
+ let state = await loadOwnedState({ options, auth });
624
+ ({ state } = await heartbeatJob({ options, auth, state, jobId }));
625
+ const jobState = requireJobState(state, jobId);
626
+ const clientUpdateKey = randomBytes(18).toString("base64url");
627
+ const result = await retrySponsorCheckIn(() =>
628
+ sponsorJobRequest({
629
+ options,
630
+ auth,
631
+ state,
632
+ jobState,
633
+ path: "/dialogue",
634
+ body: {
635
+ clientUpdateKey,
636
+ phase: phase || null,
637
+ message,
638
+ },
639
+ }),
640
+ );
641
+ if (!result?.update?.message) {
642
+ throw new Error("Twinkle did not confirm the canonical Lumine update.");
643
+ }
644
+ printJsonOrLines(options, result, [
645
+ `Lumine → ${displayPersona(jobState.job.persona)}:`,
646
+ result.update.message,
647
+ ]);
648
+ }
649
+
546
650
  async function startHelper(options, jobId) {
547
651
  const auth = await ensureSponsorAuth(options);
548
652
  let state = await loadOwnedState({ options, auth });
@@ -1177,25 +1281,36 @@ async function writeAssignment(jobState, state) {
1177
1281
  const unapplied = new Set(unappliedRelayIds(jobState));
1178
1282
  const relays = (jobState.relays || [])
1179
1283
  .map((relay) => {
1180
- const details = [
1181
- relay.summary,
1182
- relay.requestedOutcome ? `What to deliver: ${relay.requestedOutcome}` : "",
1183
- relay.constraints?.length
1184
- ? `Constraints: ${relay.constraints.join("; ")}`
1185
- : "",
1186
- relay.acceptanceCriteria?.length
1187
- ? `Done means: ${relay.acceptanceCriteria.join("; ")}`
1188
- : "",
1189
- ].filter(Boolean);
1190
- return `## Relay #${relay.id}${unapplied.has(Number(relay.id)) ? " (not yet acknowledged as applied)" : " (applied receipt confirmed)"}\n\n${details.join("\n\n")}`;
1284
+ const dialogueText =
1285
+ String(relay.dialogueText || "").trim() ||
1286
+ [
1287
+ relay.summary,
1288
+ relay.projectTitleHint
1289
+ ? `Project: ${relay.projectTitleHint}`
1290
+ : "",
1291
+ relay.requestedOutcome
1292
+ ? `What to build: ${relay.requestedOutcome}`
1293
+ : "",
1294
+ relay.constraints?.length
1295
+ ? `Keeping in mind:\n${relay.constraints.map((item) => `• ${item}`).join("\n")}`
1296
+ : "",
1297
+ relay.acceptanceCriteria?.length
1298
+ ? `Done means:\n${relay.acceptanceCriteria.map((item) => `• ${item}`).join("\n")}`
1299
+ : "",
1300
+ ]
1301
+ .filter(Boolean)
1302
+ .join("\n\n");
1303
+ return `## Relay #${relay.id}${unapplied.has(Number(relay.id)) ? " (not yet acknowledged as applied)" : " (applied receipt confirmed)"}\n\n${dialogueText}`;
1191
1304
  })
1192
1305
  .join("\n\n");
1193
1306
  const content = `# Lumine Build Workshop assignment #${jobState.job.id}
1194
1307
 
1195
- You are the same live ${displayProvider(state.operatorSession.provider)} agent session that opened sponsor duty. Zero or Ciel is the user's visible teammate; you are lending that teammate an added brain. Perform this work in this session. Do not launch a replacement coding provider or leave an unattended heartbeat process standing in for you.
1308
+ You are the same live ${displayProvider(state.operatorSession.provider)} agent session that opened sponsor duty. Zero or Ciel is the user's visible teammate; you are lending that teammate an added brain. In every user-facing Workshop update, you are Lumine: the added brain talking with that teammate. Perform this work in this session. Do not launch a replacement coding provider or leave an unattended heartbeat process standing in for you.
1196
1309
 
1197
1310
  The user approved sharing only this structured plan, active-job follow-ups, the assigned Build project, and its scoped Forum. Never inspect or infer from their private Zero/Ciel chat. Edit only contribution Build #${jobState.job.contributionBuild.id}; never merge Main, publish, or contact the user directly.
1198
1311
 
1312
+ Lumine updates are a deliberate public channel. Write concise messages about what you are checking, what you found, or what happens next. Never publish hidden chain-of-thought, raw terminal output, credentials, tokens, private paths, or unrelated data. The exact file text you submit is shown in Twinkle and echoed back by the CLI.
1313
+
1199
1314
  - User: @${jobState.job.requester.username}
1200
1315
  - Visible teammate: ${displayPersona(jobState.job.persona)}
1201
1316
  - Main project: ${jobState.job.rootBuild.title} (#${jobState.job.rootBuild.id})
@@ -1208,10 +1323,11 @@ ${jobState.forumContext ? `## Scoped Build Forum snapshot\n\n${jobState.forumCon
1208
1323
  ## Duty protocol
1209
1324
 
1210
1325
  1. Run \`lumine sponsor job begin ${jobState.job.id}\` before editing.
1211
- 2. Work only in the workspace above. Use your native same-session subagents only after registering each with \`helper-start\`. The coordinator alone runs sponsor CLI commands; helpers report their results back to it.
1212
- 3. Run \`lumine sponsor job pulse ${jobState.job.id}\` between substantial work steps to keep the lease alive and receive approved follow-ups.
1213
- 4. After applying a relay, record its exact ID with \`lumine sponsor job relay-applied ${jobState.job.id} <relay-id...>\`.
1214
- 5. Finish with \`lumine sponsor job complete ${jobState.job.id} --summary "..."\`.
1326
+ 2. Introduce yourself and publish meaningful milestones with \`lumine sponsor job update ${jobState.job.id} --file <message-file> --phase <name>\`. Speak as Lumine, never as the underlying provider.
1327
+ 3. Work only in the workspace above. Use your native same-session subagents only after registering each with \`helper-start\`. The coordinator alone runs sponsor CLI commands; helpers report their results back to it.
1328
+ 4. Run \`lumine sponsor job pulse ${jobState.job.id}\` between substantial work steps to keep the lease alive and receive approved follow-ups.
1329
+ 5. After applying a relay, record its exact ID with \`lumine sponsor job relay-applied ${jobState.job.id} <relay-id...>\`.
1330
+ 6. Finish with \`lumine sponsor job complete ${jobState.job.id} --summary "..."\`.
1215
1331
  `;
1216
1332
  await fs.writeFile(jobState.assignmentPath, content, {
1217
1333
  encoding: "utf8",
@@ -1865,6 +1981,7 @@ function sponsorJobUsage() {
1865
1981
  "Usage:",
1866
1982
  " lumine sponsor job status|pulse <job-id>",
1867
1983
  " lumine sponsor job begin <job-id>",
1984
+ " lumine sponsor job update <job-id> --file <path> [--phase <name>]",
1868
1985
  " lumine sponsor job relay-applied <job-id> <relay-id...>",
1869
1986
  " lumine sponsor job helper-start <job-id> [--ordinal <n>]",
1870
1987
  " lumine sponsor job helper-complete <job-id> --ordinal <n> --outcome <text> [--resolved-model <name>] [--resolved-effort <level>]",
package/lib/sponsor.js CHANGED
@@ -391,6 +391,7 @@ function sponsorUsage() {
391
391
  " lumine sponsor duty watch [--wait-ms <ms>] [--json]",
392
392
  " lumine sponsor duty status|pause|resume|stop",
393
393
  " lumine sponsor job status|pulse|begin <job-id>",
394
+ " lumine sponsor job update <job-id> --file <path> [--phase <name>]",
394
395
  " lumine sponsor job relay-applied <job-id> <relay-id...>",
395
396
  " lumine sponsor job helper-start|helper-complete <job-id> [options]",
396
397
  " lumine sponsor job complete <job-id> --summary <text>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.59",
3
+ "version": "0.2.61",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,6 +93,41 @@ Mikey; routine administrator runs still escalate suspected alternate accounts
93
93
  and never auto-enforce. `accounts add` and `note set` still accept only an
94
94
  existing **unbanned** bucket.
95
95
 
96
+ ### Shared verified-email policies
97
+
98
+ A verified address proves that an account can use that mailbox; it does not
99
+ always prove that every account using the mailbox is one person. When a teacher,
100
+ school, or other provisioning owner intentionally verifies separate people's
101
+ accounts with one address, record that confirmed operational fact explicitly:
102
+
103
+ ```bash
104
+ lumine admin ai-email-policy get --email teacher@example.com --json
105
+ lumine admin ai-email-policy set --email teacher@example.com \
106
+ --mode separate_accounts \
107
+ --note "Teacher-confirmed classroom provisioning address" --json
108
+ ```
109
+
110
+ `separate_accounts` preserves ordinary email verification and recovery while
111
+ giving every current and future account whose stable verified address matches
112
+ its own AI Energy and AI Card quota identity. The mutation re-attributes the
113
+ current day's per-account usage, updates all affected live quota projections
114
+ from canonical server state, and records the real operator in the private
115
+ Lumine audit log. It is a provisioning policy, not an alternate-account finding
116
+ or moderation flag, and must never be inferred from account count, usernames,
117
+ devices, IPs, or email-provider heuristics.
118
+
119
+ The CLI accepts a successful `set` only when the writer response confirms the
120
+ requested policy and reports every matching account on its exact expected
121
+ durable projection. A missing or mismatched projection fails closed instead of
122
+ letting an operator mistake a stored policy row for completed account repair.
123
+
124
+ Use `--mode automatic` with a new explanatory `--note` to restore Twinkle's
125
+ default verified-email grouping. Both modes are durable and apply to later
126
+ signups and later verifications without another code change. An active
127
+ email-wide manual identity rule conflicts with `separate_accounts`, so the API
128
+ rejects either operation until the operator explicitly resolves the conflict.
129
+ Exact per-user manual bucket rules remain authoritative in both modes.
130
+
96
131
  ### Audited identity inspection
97
132
 
98
133
  Account-family evidence is private operator work, never a Zero/Ciel public