@stage5/lumine 0.2.60 → 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
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
@@ -2411,12 +2411,8 @@ export function parseArgs(args) {
2411
2411
  sponsorFailureReason: raw.reason ? String(raw.reason) : "",
2412
2412
  sponsorUpdateFile: raw.file ? String(raw.file) : "",
2413
2413
  sponsorUpdatePhase: raw.phase ? String(raw.phase) : "",
2414
- sponsorResolvedModel: raw.resolvedModel
2415
- ? String(raw.resolvedModel)
2416
- : "",
2417
- sponsorResolvedEffort: raw.resolvedEffort
2418
- ? String(raw.resolvedEffort)
2419
- : "",
2414
+ sponsorResolvedModel: raw.resolvedModel ? String(raw.resolvedModel) : "",
2415
+ sponsorResolvedEffort: raw.resolvedEffort ? String(raw.resolvedEffort) : "",
2420
2416
  sponsorResolvedServiceTier: raw.resolvedServiceTier
2421
2417
  ? String(raw.resolvedServiceTier)
2422
2418
  : "",
@@ -2442,6 +2438,8 @@ export function parseArgs(args) {
2442
2438
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2443
2439
  adminLabel: raw.label ? String(raw.label) : "",
2444
2440
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2441
+ adminEmail: raw.email ? String(raw.email) : "",
2442
+ adminMode: raw.mode ? String(raw.mode) : "",
2445
2443
  adminType: raw.type ? String(raw.type) : "",
2446
2444
  adminKind: raw.kind ? String(raw.kind) : "",
2447
2445
  adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
@@ -2807,6 +2805,8 @@ export function printHelp() {
2807
2805
  lumine admin ai-bucket get --bucket-id <id> [--json]
2808
2806
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
2809
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]
2810
2810
  lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
2811
2811
  lumine admin daily-run status|report|complete|fail [--reason <text>] [--json]
2812
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()
@@ -350,6 +362,40 @@ async function watchDuty(options, commandServices) {
350
362
  );
351
363
  continue;
352
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
+ }
353
399
  if (claim.job) {
354
400
  state = await recordClaim({ options, state, claim });
355
401
  const prepared = await prepareClaimedJob({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.60",
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