@stage5/lumine 0.2.36 → 0.2.38

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/lib/admin.js CHANGED
@@ -4,19 +4,17 @@ import { assertAuthScope, resolveAuth } from "./auth.js";
4
4
  import { requestJson } from "./http.js";
5
5
 
6
6
  const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
7
- const MAX_COMPOSED_COMMENT_FILE_BYTES = 64 * 1024;
8
- const MAX_COMPOSED_COMMENT_LENGTH = 10_000;
7
+ const MAX_COMPOSED_TEXT_FILE_BYTES = 64 * 1024;
8
+ const MAX_COMPOSED_TEXT_LENGTH = 10_000;
9
9
  const MAX_NOTABLE_NOTE_LENGTH = 2_000;
10
10
 
11
- // Operator-composed persona comment text (plain UTF-8, not JSON). The agent
12
- // writes the comment in the bot's persona itself; the server never invokes
11
+ // Operator-composed persona text (plain UTF-8, not JSON). The agent writes
12
+ // the content in the bot's persona itself; the server never invokes
13
13
  // its model and no AI Energy is spent.
14
- function readComposedCommentFile(filePath) {
14
+ function readComposedTextFile(filePath) {
15
15
  const normalizedPath = String(filePath || "").trim();
16
16
  if (!normalizedPath) {
17
- throw cliValidationError(
18
- "Pass composed comment text with --file <comment.md>.",
19
- );
17
+ throw cliValidationError("Pass composed text with --file <file.md>.");
20
18
  }
21
19
  let contents;
22
20
  try {
@@ -24,18 +22,18 @@ function readComposedCommentFile(filePath) {
24
22
  } catch {
25
23
  throw cliValidationError(`Could not read ${normalizedPath}.`);
26
24
  }
27
- if (Buffer.byteLength(contents, "utf8") > MAX_COMPOSED_COMMENT_FILE_BYTES) {
28
- throw cliValidationError("The composed comment file must be under 64KB.");
25
+ if (Buffer.byteLength(contents, "utf8") > MAX_COMPOSED_TEXT_FILE_BYTES) {
26
+ throw cliValidationError("The composed text file must be under 64KB.");
29
27
  }
30
28
  const normalized = contents.trim();
31
29
  if (!normalized) {
32
30
  throw cliValidationError(
33
- `${normalizedPath} is empty; a composed comment needs text.`,
31
+ `${normalizedPath} is empty; composed text is required.`,
34
32
  );
35
33
  }
36
- if (normalized.length > MAX_COMPOSED_COMMENT_LENGTH) {
34
+ if (normalized.length > MAX_COMPOSED_TEXT_LENGTH) {
37
35
  throw cliValidationError(
38
- `A composed comment must be at most ${MAX_COMPOSED_COMMENT_LENGTH} characters.`,
36
+ `Composed text must be at most ${MAX_COMPOSED_TEXT_LENGTH} characters.`,
39
37
  );
40
38
  }
41
39
  return normalized;
@@ -724,6 +722,17 @@ export function parseAdminOperation(options) {
724
722
  );
725
723
  }
726
724
 
725
+ if (namespace === "announcement" && action === "post") {
726
+ return writeOperation(
727
+ "announcement.post",
728
+ "POST",
729
+ "/cli/admin/announcements",
730
+ {
731
+ content: readComposedTextFile(options.adminFile),
732
+ },
733
+ );
734
+ }
735
+
727
736
  if (namespace === "chat" && action === "send") {
728
737
  const rawTarget = String(target || "").trim();
729
738
  if (!rawTarget) {
@@ -735,7 +744,7 @@ export function parseAdminOperation(options) {
735
744
  // the bot's voice; the server never invokes a model for it.
736
745
  return writeOperation("chat.send", "POST", "/cli/admin/chat-messages", {
737
746
  target: rawTarget,
738
- content: readComposedCommentFile(options.adminFile),
747
+ content: readComposedTextFile(options.adminFile),
739
748
  });
740
749
  }
741
750
 
@@ -821,7 +830,7 @@ export function parseAdminOperation(options) {
821
830
  ? parseIdentity(options.adminIdentity)
822
831
  : undefined,
823
832
  ...(options.adminFile
824
- ? { content: readComposedCommentFile(options.adminFile) }
833
+ ? { content: readComposedTextFile(options.adminFile) }
825
834
  : {}),
826
835
  ...(reviewedBuildVersionId ? { reviewedBuildVersionId } : {}),
827
836
  ...(buildReviewMethod ? { buildReviewMethod } : {}),
@@ -849,7 +858,7 @@ export function parseAdminOperation(options) {
849
858
  "comment.edit",
850
859
  "PUT",
851
860
  `/cli/admin/comments/${commentId}`,
852
- { content: readComposedCommentFile(options.adminFile) },
861
+ { content: readComposedTextFile(options.adminFile) },
853
862
  );
854
863
  }
855
864
  if (action === "post") {
@@ -868,7 +877,7 @@ export function parseAdminOperation(options) {
868
877
  }
869
878
 
870
879
  throw cliValidationError(
871
- "Usage: lumine admin identity|daily-run|recommendations|builds|post|subjects|subject|featured|comment|chat|news|audit|brief|bot-output|notable ...",
880
+ "Usage: lumine admin identity|daily-run|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
872
881
  );
873
882
  }
874
883
 
package/lib/commands.js CHANGED
@@ -908,7 +908,7 @@ export async function save(options) {
908
908
  buildId = Number(build?.id || buildId);
909
909
  const dir = resolveProjectDirForSave({ options, localProject });
910
910
  const files = await collectProjectFiles(dir);
911
- assertProjectFilesWithinLimits(files);
911
+ assertProjectFilesWithinLimits(files, build?.projectLimits);
912
912
  // Only claim a base snapshot when this workspace was pulled from the same
913
913
  // build we are saving to. Missing filesHash used to mean "unguarded save",
914
914
  // which let stale checkouts silently rewind newer server versions — refuse
@@ -975,12 +975,25 @@ export async function save(options) {
975
975
  }
976
976
  throw error;
977
977
  }
978
- build = result.build ||
979
- build ||
980
- (await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
978
+ build = result.build ? { ...build, ...result.build } : build;
979
+ if (result.copilotPolicy?.limits) {
980
+ build = {
981
+ ...build,
982
+ projectLimits: {
983
+ maxProjectBytes: result.copilotPolicy.limits.maxProjectBytes,
984
+ maxFilesPerProject: result.copilotPolicy.limits.maxFilesPerProject,
985
+ maxFileLines: result.copilotPolicy.limits.maxFileLines,
986
+ },
987
+ };
988
+ }
989
+ if (!build) {
990
+ build = (await loadBuildMetadata({ options, auth, buildId }).catch(
991
+ () => null,
992
+ )) || {
981
993
  id: buildId,
982
994
  title: `Build ${buildId}`,
983
995
  };
996
+ }
984
997
  await saveSelectedBuild({ options, auth, build });
985
998
  await writeProjectMetadata({
986
999
  dir,
@@ -1010,9 +1023,10 @@ export async function save(options) {
1010
1023
  }
1011
1024
 
1012
1025
  export async function check(options) {
1013
- await reportLocalProjectFindings(options);
1014
1026
  const auth = await resolveAuth(options);
1015
1027
  const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
1028
+ const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
1029
+ await reportLocalProjectFindings(options, canonicalBuild?.projectLimits);
1016
1030
  const result = await requestJson({
1017
1031
  url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
1018
1032
  authToken: auth.token,
@@ -1022,9 +1036,13 @@ export async function check(options) {
1022
1036
  if (!result.ok) process.exitCode = 1;
1023
1037
  }
1024
1038
 
1025
- // Local, network-free half of `lumine check`: validate the workspace against
1026
- // the platform's project-file rules so agents see violations before saving.
1027
- export async function reportLocalProjectFindings(options) {
1039
+ // Local half of `lumine check`: validate the workspace against the canonical
1040
+ // project limits fetched by check(), so a newly approved allowance works
1041
+ // immediately without requiring another pull just to refresh local metadata.
1042
+ export async function reportLocalProjectFindings(
1043
+ options,
1044
+ canonicalProjectLimits,
1045
+ ) {
1028
1046
  const localProject = await findLocalProjectMetadata(
1029
1047
  path.resolve(options.dir || process.cwd()),
1030
1048
  );
@@ -1037,7 +1055,10 @@ export async function reportLocalProjectFindings(options) {
1037
1055
  process.exitCode = 1;
1038
1056
  return;
1039
1057
  }
1040
- const { errors, warnings } = collectProjectLimitFindings(files);
1058
+ const { errors, warnings } = collectProjectLimitFindings(
1059
+ files,
1060
+ canonicalProjectLimits || localProject?.metadata?.build?.projectLimits,
1061
+ );
1041
1062
  for (const warning of warnings) {
1042
1063
  console.error(`Local check warning: ${warning}`);
1043
1064
  }
@@ -1171,10 +1192,14 @@ export async function resolveBuildForSave({
1171
1192
  const localBuild = localProject?.metadata?.build;
1172
1193
  const localBuildId =
1173
1194
  Number(localBuild?.id || 0) || Number(localProject?.metadata?.buildId || 0);
1195
+ // Save is already an online operation. Refresh the server-owned Build
1196
+ // metadata before enforcing permissions and project limits so a newly
1197
+ // approved Main allowance (inherited by branches) works without re-pulling.
1198
+ const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
1174
1199
  const build =
1175
1200
  localBuild && localBuildId === Number(buildId)
1176
- ? { ...localBuild, id: buildId }
1177
- : await loadBuildMetadata({ options, auth, buildId });
1201
+ ? { ...localBuild, ...canonicalBuild, id: buildId }
1202
+ : canonicalBuild;
1178
1203
  return await resolveEditableWorkspaceBuild({
1179
1204
  options,
1180
1205
  auth,
@@ -1281,6 +1306,7 @@ export function normalizeContributionBranchBuild({ branch, sourceBuild }) {
1281
1306
  Number(branch?.contributionContributorId || branch?.userId || 0) || null,
1282
1307
  contributionBranchNumber:
1283
1308
  Number(branch?.contributionBranchNumber || 0) || null,
1309
+ projectLimits: branch?.projectLimits || sourceBuild?.projectLimits || null,
1284
1310
  };
1285
1311
  }
1286
1312
 
@@ -2541,6 +2567,7 @@ export function printHelp() {
2541
2567
  lumine admin comment edit <comment-id> --file <comment.md> [--json]
2542
2568
  lumine admin brief [--days <1..30>] [--json]
2543
2569
  lumine admin bot-output [--days <1..30>] [--json]
2570
+ lumine admin announcement post --file <announcement.md> [--json]
2544
2571
  lumine admin chat send <user-id|username> --file <message.md> [--json]
2545
2572
  lumine admin notable add <user-id|username> --note <text> [--json]
2546
2573
  lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--cursor <cursor>] [--json]
@@ -2626,7 +2653,7 @@ Options:
2626
2653
  --reward-twinkles 3 Pair a recommendation with exactly 3 Twinkles
2627
2654
  --twinkles 3 Give exactly 3 Twinkles through the normal economy
2628
2655
  --draft-id <id> Canonical delegated comment draft ID
2629
- --file <path> Editorial JSON or composed comment text file
2656
+ --file <path> Editorial JSON or composed text file
2630
2657
  --reviewed-version <id> Published Build artifact version actually reviewed
2631
2658
  --reviewed-via <method> Build review method: runtime or code
2632
2659
  --reason <text> Reason when marking an admin run failed
package/lib/constants.js CHANGED
@@ -69,14 +69,12 @@ export const ASSET_MIME_BY_EXTENSION = {
69
69
  ".bin": "application/octet-stream",
70
70
  ".drc": "application/octet-stream",
71
71
  };
72
- // Mirrors the platform's project-file limits. File count and effective-line
73
- // limits are hardcoded server constants; the total-byte limit is
74
- // env-overridable server-side, so the CLI only warns on it.
72
+ // Default platform project-file limits. A pulled workspace can carry larger
73
+ // canonical per-project limits approved on Main in lumine-project.json.
75
74
  export const PROJECT_MAX_FILES = 100;
76
75
  export const PROJECT_MAX_EFFECTIVE_FILE_LINES = 500;
77
76
  export const PROJECT_EFFECTIVE_LINE_MAX_COLUMNS = 160;
78
77
  export const PROJECT_MAX_TOTAL_BYTES_DEFAULT = 1024 * 1024;
79
- export const PROJECT_WARN_TOTAL_BYTES = 500 * 1024;
80
78
  // Matches asset references in project files: absolute CloudFront URLs and
81
79
  // relative /attachments/build-runtime|optimized/... paths.
82
80
  export const RUNTIME_ASSET_REFERENCE_PATTERN =
@@ -233,10 +231,12 @@ lumine save --summary "Describe the change"
233
231
 
234
232
  ## Project File Limits
235
233
 
236
- - Max ${PROJECT_MAX_FILES} files per project; each file max ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines
234
+ - Default max ${PROJECT_MAX_FILES} files and ${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB per project. Read
235
+ \`.twinkle/lumine-project.json\` for this project's canonical \`projectLimits\`;
236
+ an approved Main project can allow up to 500 files and 5 MB, inherited by branches.
237
+ - Each file max ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines
237
238
  (a physical line counts once per ${PROJECT_EFFECTIVE_LINE_MAX_COLUMNS} characters, so minified one-liners
238
- blow the limit). Default total project size cap ${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB,
239
- with a soft warning from ${Math.floor(PROJECT_WARN_TOTAL_BYTES / 1024)} KB.
239
+ blow the limit).
240
240
  - Plan file splits BEFORE writing code; \`lumine check\` validates the local
241
241
  workspace against these limits without saving, and \`lumine save\` fails fast
242
242
  locally listing every violation.
package/lib/workspace.js CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  PROJECT_MAX_EFFECTIVE_FILE_LINES,
16
16
  PROJECT_MAX_FILES,
17
17
  PROJECT_MAX_TOTAL_BYTES_DEFAULT,
18
- PROJECT_WARN_TOTAL_BYTES,
19
18
  PROJECT_METADATA_DIR,
20
19
  PROJECT_METADATA_FILE,
21
20
  SDK_REFERENCE_FALLBACK,
@@ -342,16 +341,30 @@ export function countEffectiveLines(value) {
342
341
  );
343
342
  }
344
343
 
345
- // Local mirror of the server's project limits so violations fail fast with
346
- // the full list (the server stops at the first). File count and per-file
347
- // effective lines are hard server constants -> errors; the total-byte limit
348
- // is env-overridable server-side -> warning only, the server stays the gate.
349
- export function collectProjectLimitFindings(files) {
344
+ // Local mirror of the canonical per-project limits returned by the server so
345
+ // violations fail fast with the full list (the server stops at the first).
346
+ export function collectProjectLimitFindings(files, projectLimits = {}) {
350
347
  const errors = [];
351
348
  const warnings = [];
352
- if (files.length > PROJECT_MAX_FILES) {
349
+ const canonicalMaxFiles = Math.floor(
350
+ Number(projectLimits?.maxFilesPerProject || 0),
351
+ );
352
+ const canonicalMaxProjectBytes = Math.floor(
353
+ Number(projectLimits?.maxProjectBytes || 0),
354
+ );
355
+ const maxFilesPerProject =
356
+ canonicalMaxFiles > 0 ? canonicalMaxFiles : PROJECT_MAX_FILES;
357
+ const maxProjectBytes =
358
+ canonicalMaxProjectBytes > 0
359
+ ? canonicalMaxProjectBytes
360
+ : PROJECT_MAX_TOTAL_BYTES_DEFAULT;
361
+ const warnProjectBytes = Math.min(
362
+ Math.floor(maxProjectBytes * 0.8),
363
+ maxProjectBytes - 1,
364
+ );
365
+ if (files.length > maxFilesPerProject) {
353
366
  errors.push(
354
- `Project exceeds the ${PROJECT_MAX_FILES}-file limit (${files.length} files). Split or consolidate files before saving.`,
367
+ `Project exceeds the ${maxFilesPerProject}-file limit (${files.length} files). Split or consolidate files before saving.`,
355
368
  );
356
369
  }
357
370
  for (const file of files) {
@@ -369,26 +382,23 @@ export function collectProjectLimitFindings(files) {
369
382
  total + Buffer.byteLength(String(file.content || ""), "utf8"),
370
383
  0,
371
384
  );
372
- if (totalBytes > PROJECT_MAX_TOTAL_BYTES_DEFAULT) {
373
- warnings.push(
374
- `Project files total ${formatBytes(totalBytes)}, above the default ${Math.floor(
375
- PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
376
- )} KB project limit — the server may reject this save.`,
385
+ if (totalBytes > maxProjectBytes) {
386
+ errors.push(
387
+ `Project files total ${formatBytes(totalBytes)}, above this project's ${formatBytes(maxProjectBytes)} limit.`,
377
388
  );
378
- } else if (totalBytes > PROJECT_WARN_TOTAL_BYTES) {
389
+ } else if (totalBytes > warnProjectBytes) {
379
390
  warnings.push(
380
- `Project files total ${formatBytes(totalBytes)} — approaching the ${Math.floor(
381
- PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
382
- )} KB project cap (soft warning from ${Math.floor(
383
- PROJECT_WARN_TOTAL_BYTES / 1024,
384
- )} KB). Keep files lean where structure allows.`,
391
+ `Project files total ${formatBytes(totalBytes)} — approaching this project's ${formatBytes(maxProjectBytes)} cap. Keep files lean where structure allows.`,
385
392
  );
386
393
  }
387
394
  return { errors, warnings };
388
395
  }
389
396
 
390
- export function assertProjectFilesWithinLimits(files) {
391
- const { errors, warnings } = collectProjectLimitFindings(files);
397
+ export function assertProjectFilesWithinLimits(files, projectLimits = {}) {
398
+ const { errors, warnings } = collectProjectLimitFindings(
399
+ files,
400
+ projectLimits,
401
+ );
392
402
  for (const warning of warnings) {
393
403
  console.error(`lumine: warning — ${warning}`);
394
404
  }
@@ -587,6 +597,10 @@ export async function writeProjectMetadata({
587
597
  build.canPublish !== undefined
588
598
  ? Boolean(build.canPublish)
589
599
  : build.role === "owner",
600
+ projectLimits:
601
+ build.projectLimits && typeof build.projectLimits === "object"
602
+ ? build.projectLimits
603
+ : null,
590
604
  }
591
605
  : null,
592
606
  apiUrl: options.apiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1621,6 +1621,30 @@ Ordinary warm follow-ups (checking on a member the bots already know after
1621
1621
  something the run surfaced) are within a run's judgment, sparingly, and are
1622
1622
  always reported in the run report.
1623
1623
 
1624
+ ## Official announcements
1625
+
1626
+ ```bash
1627
+ lumine admin announcement post --file announcement.md --json
1628
+ ```
1629
+
1630
+ The run's selected bot posts one composed message to General's announcement
1631
+ subchannel (`channelId` 2, `subchannelId` 2). This is the public official
1632
+ board, not a DM and not a Home comment. Mechanics:
1633
+
1634
+ - requires the `chat:post` scope of a comment-mode `post` run;
1635
+ - composed-only (`--file`, same 10,000-character limit as `chat send`);
1636
+ - authors as Zero or Ciel only — the ordinary chat post route and the
1637
+ announcement socket relay now treat those two IDs as allowed announcement
1638
+ authors, same as management-level 3 humans;
1639
+ - persists through the ordinary `msg_chats` insert (channel lock +
1640
+ visibility restore), writes the Twinkle Newspaper `announcement:<messageId>`
1641
+ event, and relays `new_chat_message` on General. No bot socket, session, or
1642
+ presence;
1643
+ - audited as `announcement.post` and idempotent per request key.
1644
+
1645
+ Use this only when Mikey asks for an official announcement. Do not treat a
1646
+ management run as a standing license to post there.
1647
+
1624
1648
  ## Audit history
1625
1649
 
1626
1650
  ```bash