@astrofoundry/pi-astro 0.34.1 → 0.36.0

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.
Files changed (36) hide show
  1. package/README.md +3 -3
  2. package/agents/arcane.md +3 -1
  3. package/agents/inference.md +4 -5
  4. package/agents/release-reviewer.md +2 -2
  5. package/extensions/astro-discord/daily-report.test.ts +153 -77
  6. package/extensions/astro-discord/daily-report.ts +322 -53
  7. package/extensions/astro-discord/index.ts +180 -51
  8. package/extensions/astro-discord/reports.ts +0 -2
  9. package/extensions/astro-discord/tools-report.test.ts +78 -13
  10. package/extensions/astro-discord/tools-report.ts +123 -40
  11. package/extensions/specialist-gate/approvals.test.ts +4 -1
  12. package/extensions/specialist-gate/index.ts +1 -1
  13. package/package.json +1 -1
  14. package/skills/arcane/SKILL.md +16 -3
  15. package/skills/edge/SKILL.md +1 -1
  16. package/skills/inference/SKILL.md +3 -27
  17. package/skills/proxmox/SKILL.md +2 -2
  18. package/skills/security/SKILL.md +2 -2
  19. package/specialists/AGENTS.md +8 -7
  20. package/specialists/README.md +24 -18
  21. package/specialists/arcane/run.ts +223 -10
  22. package/specialists/edge/run.ts +3 -3
  23. package/specialists/entry/remote/spc-arcane-install.sh +2 -2
  24. package/specialists/entry/remote/spc-arcane-op.py +3 -0
  25. package/specialists/entry/specialist-install.sh +266 -8
  26. package/specialists/entry/specialist-install.test.ts +50 -1
  27. package/specialists/inference/run.ts +12 -94
  28. package/specialists/lib/arcane-host.test.ts +24 -1
  29. package/specialists/lib/arcane-host.ts +6 -2
  30. package/specialists/lib/deployment.ts +1 -1
  31. package/specialists/lib/stack-update.test.ts +101 -0
  32. package/specialists/lib/stack-update.ts +115 -0
  33. package/specialists/provision/provision.sh +2 -2
  34. package/specialists/wrappers.test.ts +7 -16
  35. package/specialists/entry/remote/spc-inference-hermes-entry.sh +0 -27
  36. package/specialists/entry/remote/spc-inference-pulsar-entry.sh +0 -14
@@ -14,7 +14,23 @@ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./comman
14
14
  import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, type ReportName, type ReportSchedule, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, reportSchedule, saveConfig } from "./config.ts";
15
15
  import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
16
16
  import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
17
- import { failureMessage, formatDailyReport, MANAGER_BUTTON, managerButton, managerOffer, managerSummary } from "./daily-report.ts";
17
+ import {
18
+ type ArcaneReport,
19
+ buildStacks,
20
+ failureMessage,
21
+ layoutMessages as arcaneLayout,
22
+ majorLines,
23
+ managerSummary,
24
+ messageComponents as arcaneComponents,
25
+ parseArcaneButton,
26
+ readPostedArcane,
27
+ releaseNotesFromJson,
28
+ type ServiceLine,
29
+ serviceUsage,
30
+ stackOutcome,
31
+ type StackState,
32
+ writePostedArcane,
33
+ } from "./daily-report.ts";
18
34
  import { GatewayClient } from "./gateway.ts";
19
35
  import { type CommandOption, IS_COMPONENTS_V2, KeyedQueue, MEDIA, MEDIA_COMMANDS, type MediaKind, type Outcome, addOutcome, applyOutcome, commandArgs, hasActiveButton, listMessage, mediaAccess, mediaPointer, parseAddButton, parseAddReport, parseMediaList, parseQueueState, queueLine } from "./media.ts";
20
36
  import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
@@ -35,6 +51,7 @@ import {
35
51
  notesFor,
36
52
  parseToolsButton,
37
53
  parseVerdict,
54
+ reviewTask,
38
55
  readPackageVersion,
39
56
  readPostedReport,
40
57
  REVIEW_SOURCES,
@@ -135,8 +152,12 @@ const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url
135
152
  /** Upper bound for one tool update from an Update button; `brew upgrade` of everything can take a while. */
136
153
  const UPDATE_TIMEOUT_MS = 30 * 60_000;
137
154
  const REVIEWER = "astro.release-reviewer";
138
- /** Key of a running Arcane manager upgrade among the tool runs, which count as open work. */
139
- const MANAGER_RUN = "arcane-manager";
155
+ /** Key of a running Arcane stack update or manager upgrade among the tool runs; one at a time, because all of them push to the same GitOps checkout. */
156
+ const ARCANE_RUN = "arcane-update";
157
+ /** Upper bound for one `stack-update`: its own worst case (sync, redeploy, and health wait, again for a rollback, plus the checks) is about 125 minutes. */
158
+ const STACK_UPDATE_TIMEOUT_MS = 135 * 60_000;
159
+ /** Interaction tokens last 15 minutes; a later note goes to the channel as a reply to the report. */
160
+ const FOLLOW_UP_WINDOW_MS = 14 * 60_000;
140
161
 
141
162
  interface ActiveTask {
142
163
  specialist: string;
@@ -307,6 +328,7 @@ class Bridge {
307
328
  this.gateway = gateway;
308
329
  void this.registerMediaCommands();
309
330
  await this.closeInterruptedUpdates();
331
+ await this.closeInterruptedArcane();
310
332
  // Checked every minute: each report decides whether today's is due, and waiting restarts check whether their service is idle.
311
333
  this.reportTimer = setInterval(() => {
312
334
  void this.maybeReports();
@@ -423,32 +445,40 @@ class Bridge {
423
445
  }
424
446
  }
425
447
 
426
- /** The Arcane version report through the specialist wrapper, without a model, posted with its prefix. */
448
+ /**
449
+ * The Arcane Daily Report through the specialist wrapper, without a model:
450
+ * one section per stack with updates, a release-notes review for every
451
+ * major-version update, and an Update button per stack that is not held.
452
+ */
427
453
  private async arcaneReport(schedule: ReportSchedule, date: string): Promise<void> {
428
454
  if (!loadSpecialists().config) {
429
455
  log("arcane report: specialists.json missing; no report");
430
456
  return;
431
457
  }
432
458
  log(`arcane report ${date}: running arcane update-report`);
433
- let message: { content: string; attachment?: string };
434
- let offer: string | null = null;
459
+ let report: ArcaneReport | null = null;
435
460
  try {
436
- const report: unknown = JSON.parse(await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000));
437
- message = formatDailyReport(report, schedule.timeZone);
438
- offer = managerOffer(report);
461
+ const built = buildStacks(JSON.parse(await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000)));
462
+ if (!built) throw new Error("update-report returned an unexpected answer");
463
+ for (const { stack, line } of majorLines(built.stacks)) if (!this.stopping) line.review = await this.reviewMajor(stack, line);
464
+ report = { date, channelId: schedule.channelId, timeZone: schedule.timeZone, ...built, messages: [] };
439
465
  } catch (err) {
440
466
  log(`arcane report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
441
- message = { content: failureMessage() };
442
467
  }
443
468
  if (this.stopping) return;
444
469
  writeReportState("arcane", { date, status: "sending" });
445
- const posted = await this.rest.createMessage(schedule.channelId, {
446
- content: message.content,
447
- ...(message.attachment ? { file: { name: `arcane-daily-report-${date}.txt`, content: message.attachment } } : {}),
448
- ...(offer ? { components: managerButton(offer, "offer") } : {}),
449
- });
450
- writeReportState("arcane", { date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id, ...(offer ? { managerOffer: offer } : {}) });
451
- log(`arcane report ${date}: posted ${posted.id}`);
470
+ if (!report) {
471
+ const posted = await this.rest.createMessage(schedule.channelId, { content: failureMessage() });
472
+ writeReportState("arcane", { date, status: "failed", messageId: posted.id });
473
+ return;
474
+ }
475
+ for (const page of arcaneLayout(report)) {
476
+ const posted = await this.rest.createComponentsMessage(schedule.channelId, arcaneComponents(report, page));
477
+ report.messages.push({ id: posted.id, ...page });
478
+ }
479
+ writePostedArcane(report);
480
+ writeReportState("arcane", { date, status: "sent", messageId: report.messages[0]?.id });
481
+ log(`arcane report ${date}: ${report.stacks.length} stack(s) in ${report.messages.length} message(s)`);
452
482
  }
453
483
 
454
484
  /** Runs a program with an argument list as this user, never through a shell; resolves even on failure. */
@@ -497,10 +527,28 @@ class Bridge {
497
527
  log(`tools report ${date}: ${report.lines.length} update(s) in ${report.messages.length} message(s)`);
498
528
  }
499
529
 
500
- /** The release-notes verdict of one line from the tool-less reviewer agent; the notes reach it only as task text. */
530
+ /** The release-notes verdict of one tools line. */
501
531
  private async reviewLine(line: ToolLine): Promise<Review> {
502
532
  const notes = await notesFor(line, fetch);
503
533
  if (notes.notes === null) return { verdict: null, reason: notes.reason };
534
+ return this.review(`${line.label} ${line.installed} -> ${line.latest}`, reviewPrompt(line, notes.notes, notes.omitted));
535
+ }
536
+
537
+ /** The release-notes verdict of a stack service's major-version update, from the wrapper's `release-notes`. */
538
+ private async reviewMajor(stack: string, line: ServiceLine): Promise<Review> {
539
+ let notes: ReturnType<typeof releaseNotesFromJson>;
540
+ try {
541
+ notes = releaseNotesFromJson(JSON.parse(await this.runEntry("arcane", "daily-report", ["release-notes", `${stack}/${line.service}`], 5 * 60_000)));
542
+ } catch (err) {
543
+ return { verdict: null, reason: `release notes unavailable: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 120)}` };
544
+ }
545
+ if (notes.notes === null) return { verdict: null, reason: notes.reason };
546
+ const subject = { label: `${stack}/${line.service}`, installed: line.running ?? "an older build", latest: line.latest ?? "a newer build", usage: serviceUsage(stack, line) };
547
+ return this.review(`${subject.label} ${subject.installed} -> ${subject.latest}`, reviewTask(subject, notes.notes, notes.omitted));
548
+ }
549
+
550
+ /** The tool-less reviewer agent's verdict on one task; the release notes reach it only as task text. */
551
+ private async review(subject: string, task: string): Promise<Review> {
504
552
  const agent = this.specialists().agents.find((a) => a.name === REVIEWER);
505
553
  if (!agent) return { verdict: null, reason: `${REVIEWER} is not installed` };
506
554
  const { depth } = currentDepth();
@@ -508,7 +556,7 @@ class Bridge {
508
556
  const result = await runAgent({
509
557
  agent,
510
558
  skills: [],
511
- task: reviewPrompt(line, notes.notes, notes.omitted),
559
+ task,
512
560
  cwd: process.cwd(),
513
561
  depth,
514
562
  remaining: 0,
@@ -516,7 +564,7 @@ class Bridge {
516
564
  env: { [ACTIVATION_ENV]: "0" },
517
565
  });
518
566
  const verdict = isFailed(result) ? null : parseVerdict(finalOutput(result.messages));
519
- log(`tools report: reviewed ${line.label} ${line.installed} -> ${line.latest}: ${verdict?.verdict ?? "no answer"}`);
567
+ log(`release review: ${subject}: ${verdict?.verdict ?? "no answer"}`);
520
568
  return verdict ?? { verdict: null, reason: "the review gave no usable answer; read the notes before updating" };
521
569
  } catch (err) {
522
570
  return { verdict: null, reason: `the review failed: ${err instanceof Error ? err.message.slice(0, 120) : String(err)}` };
@@ -541,6 +589,19 @@ class Bridge {
541
589
  for (const m of report.messages) await this.rest.editComponents(report.channelId, m.id, messageComponents(report, m)).catch(() => undefined);
542
590
  }
543
591
 
592
+ /** Stacks a crash or restart left "updating" become Retry (the manager: did not complete); the wrapper may have stopped halfway. */
593
+ private async closeInterruptedArcane(): Promise<void> {
594
+ const report = readPostedArcane();
595
+ const stuck = report?.stacks.filter((s) => s.state === "running") ?? [];
596
+ if (!report || stuck.length === 0) return;
597
+ for (const s of stuck) {
598
+ s.state = "failed";
599
+ s.detail = "interrupted by a bridge restart; check the stack and the GitOps repository with the arcane specialist before pressing Retry";
600
+ }
601
+ writePostedArcane(report);
602
+ for (const m of report.messages.filter((x) => stuck.some((s) => x.stacks.includes(s.stack)))) await this.rest.editComponents(report.channelId, m.id, arcaneComponents(report, m)).catch(() => undefined);
603
+ }
604
+
544
605
  private async redrawLine(report: ToolsReport, key: string | null): Promise<void> {
545
606
  const m = report.messages.find((x) => (key === null ? x.all : x.keys.includes(key)));
546
607
  if (m) await this.rest.editComponents(report.channelId, m.id, messageComponents(report, m)).catch((err) => log(`tools report: redraw failed: ${err instanceof Error ? err.message : String(err)}`));
@@ -659,67 +720,135 @@ class Bridge {
659
720
  if (services.length > 0) writeRestarts(addRestarts(readRestarts(), [...new Set(services)], label, new Date()));
660
721
  }
661
722
 
662
- /** The Arcane Daily Report's Upgrade button: owners only, the offer of the newest report only, one upgrade at a time. */
663
- private async onManagerButton(interaction: Interaction, customId: string): Promise<void> {
664
- const match = MANAGER_BUTTON.exec(customId);
723
+ /**
724
+ * The Arcane Daily Report's Update and Upgrade Arcane buttons: owners only,
725
+ * stacks the newest report still offers, one Arcane update at a time. The
726
+ * run is marked before the first await, so a second click cannot start
727
+ * another one.
728
+ */
729
+ private async onArcaneButton(interaction: Interaction, customId: string): Promise<void> {
730
+ const button = parseArcaneButton(customId);
665
731
  const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
666
732
  this.refreshConfig();
667
- if (!match) {
733
+ if (!button) {
668
734
  await this.rest.ephemeralReply(interaction.id, interaction.token, "This button is no longer active.");
669
735
  return;
670
736
  }
671
737
  if (!isOwner(this.config, userId)) {
672
- await this.rest.ephemeralReply(interaction.id, interaction.token, "Only owners upgrade Arcane.");
738
+ await this.rest.ephemeralReply(interaction.id, interaction.token, button.kind === "manager" ? "Only owners upgrade Arcane." : "Only owners update stacks.");
673
739
  return;
674
740
  }
675
741
  if (this.stopping) {
676
742
  await this.rest.ephemeralReply(interaction.id, interaction.token, "The bridge is restarting; try again in a minute.");
677
743
  return;
678
744
  }
679
- const state = readReportState("arcane");
680
- if (!state || state.messageId !== interaction.message?.id || state.managerOffer !== match[1]) {
745
+ const stack = button.kind === "manager" ? "arcane" : button.stack;
746
+ const report = readPostedArcane();
747
+ const page = report?.messages.find((m) => m.id === interaction.message?.id && m.stacks.includes(stack));
748
+ const entry = page ? report?.stacks.find((s) => s.stack === stack && s.actionable && (button.kind === "stack" || s.manager === button.version)) : undefined;
749
+ if (!report || !entry) {
681
750
  await this.rest.ephemeralReply(interaction.id, interaction.token, "This offer is outdated; use the newest Arcane Daily Report.");
682
751
  return;
683
752
  }
684
- if (this.toolRuns.has(MANAGER_RUN)) {
685
- await this.rest.ephemeralReply(interaction.id, interaction.token, "The Arcane upgrade is already running.");
753
+ if (entry.state !== "offered" && !(entry.state === "failed" && button.kind === "stack")) {
754
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "This update already ran; its line in the report says how it ended.");
686
755
  return;
687
756
  }
688
- await this.rest.deferUpdate(interaction.id, interaction.token);
689
- const message = { channelId: interaction.channel_id ?? "", id: interaction.message?.id ?? "", content: interaction.message?.content ?? "" };
690
- await this.toolClicks.run(MANAGER_RUN, () => this.runManagerUpgrade(message, match[1], userId)).catch((err) => log(`arcane upgrade failed: ${err instanceof Error ? err.message : String(err)}`));
757
+ const running = this.toolRuns.get(ARCANE_RUN);
758
+ if (running) {
759
+ await this.rest.ephemeralReply(interaction.id, interaction.token, `An Arcane update is already running (${running.label}); try again when it finishes.`);
760
+ return;
761
+ }
762
+ this.toolRuns.set(ARCANE_RUN, { channelId: report.channelId, label: button.kind === "manager" ? `the Arcane manager to v${button.version}` : `the ${stack} stack` });
763
+ this.saveOpenWork();
764
+ const click = { token: interaction.token, at: Date.now(), userId };
765
+ try {
766
+ await this.rest.deferUpdate(interaction.id, interaction.token);
767
+ if (button.kind === "manager") await this.runManagerUpgrade(report, button.version, click);
768
+ else await this.runStackUpdate(report, stack, click);
769
+ } catch (err) {
770
+ log(`arcane: ${stack} update failed: ${err instanceof Error ? err.message : String(err)}`);
771
+ } finally {
772
+ this.toolRuns.delete(ARCANE_RUN);
773
+ this.saveOpenWork();
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Sets one stack's state in the posted report and redraws its message
779
+ * through the bot's own route, which outlives the interaction token. A run
780
+ * from an older report changes only that report's message.
781
+ */
782
+ private async markArcane(report: ArcaneReport, stack: string, state: StackState, detail?: string): Promise<void> {
783
+ const current = readPostedArcane();
784
+ const target = current && current.date === report.date ? current : report;
785
+ const entry = target.stacks.find((s) => s.stack === stack);
786
+ if (entry) {
787
+ entry.state = state;
788
+ entry.detail = detail;
789
+ }
790
+ if (target === current) writePostedArcane(target);
791
+ const page = target.messages.find((m) => m.stacks.includes(stack));
792
+ if (page) await this.rest.editComponents(target.channelId, page.id, arcaneComponents(target, page)).catch((err) => log(`arcane report: redraw failed: ${err instanceof Error ? err.message : String(err)}`));
793
+ }
794
+
795
+ /** A note only the clicking owner sees while the interaction token lives; after that, a reply to the report in the channel. */
796
+ private async arcaneNote(report: ArcaneReport, stack: string, click: { token: string; at: number }, content: string): Promise<void> {
797
+ if (Date.now() - click.at < FOLLOW_UP_WINDOW_MS) {
798
+ try {
799
+ await this.rest.followUp(this.config.applicationId, click.token, { content, flags: EPHEMERAL });
800
+ return;
801
+ } catch (err) {
802
+ log(`arcane: ephemeral note failed: ${err instanceof Error ? err.message : String(err)}`);
803
+ }
804
+ }
805
+ const page = report.messages.find((m) => m.stacks.includes(stack)) ?? report.messages[0];
806
+ await this.rest.createMessage(report.channelId, { content, ...(page ? { replyTo: page.id } : {}) }).catch(() => undefined);
807
+ }
808
+
809
+ /** One stack's `stack-update`, which edits, commits, redeploys, checks, and rolls back on its own; the button shows how it ended. */
810
+ private async runStackUpdate(report: ArcaneReport, stack: string, click: { token: string; at: number; userId: string }): Promise<void> {
811
+ await this.markArcane(report, stack, "running");
812
+ log(`arcane: updating stack ${stack} for ${click.userId}`);
813
+ let outcome: { state: StackState; detail: string };
814
+ try {
815
+ // The owner saw the major-version lines and their review in the report; a major that appeared since is refused.
816
+ const shown = report.stacks.find((s) => s.stack === stack)?.lines.some((l) => l.major) ?? false;
817
+ const args = ["stack-update", stack, ...(shown ? ["--confirm"] : [])];
818
+ outcome = stackOutcome(JSON.parse(await this.runEntry("arcane", `discord-${click.userId}`, args, STACK_UPDATE_TIMEOUT_MS)) as Record<string, unknown>);
819
+ } catch (err) {
820
+ outcome = { state: "failed", detail: `failed: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300)}` };
821
+ }
822
+ await this.markArcane(report, stack, outcome.state, outcome.detail);
823
+ log(`arcane: ${stack} ${outcome.detail}`);
824
+ if (outcome.state !== "updated") await this.arcaneNote(report, stack, click, `${stack}: ${outcome.detail}`);
691
825
  }
692
826
 
693
827
  /**
694
828
  * The upgrade runs through the wrapper (arcane/AGENTS.md "Manager upgrades")
695
829
  * and can take longer than an interaction token lives, so the report and the
696
- * reply go through the bot's own message routes. Counted as open work, so no
697
- * bridge restart lands in the middle.
830
+ * reply go through the bot's own message routes.
698
831
  */
699
- private async runManagerUpgrade(message: { channelId: string; id: string; content: string }, version: string, userId: string): Promise<void> {
700
- await this.rest.editMessage(message.channelId, message.id, message.content, managerButton(version, "running")).catch(() => undefined);
701
- this.toolRuns.set(MANAGER_RUN, { channelId: message.channelId, label: `the Arcane manager to v${version}` });
702
- this.saveOpenWork();
703
- log(`arcane: manager upgrade to ${version} for ${userId}`);
704
- let ok = false;
832
+ private async runManagerUpgrade(report: ArcaneReport, version: string, click: { userId: string }): Promise<void> {
833
+ await this.markArcane(report, "arcane", "running");
834
+ log(`arcane: manager upgrade to ${version} for ${click.userId}`);
835
+ let state: StackState = "failed";
705
836
  let summary: string;
706
837
  try {
707
- const out = JSON.parse(await this.runEntry("arcane", `discord-${userId}`, ["manager-upgrade", version], 80 * 60_000)) as Record<string, unknown>;
708
- ok = out.status === "upgraded";
838
+ const out = JSON.parse(await this.runEntry("arcane", `discord-${click.userId}`, ["manager-upgrade", version], 80 * 60_000)) as Record<string, unknown>;
839
+ state = out.status === "upgraded" ? "updated" : out.status === "rolled back" ? "rolled back" : "failed";
709
840
  summary = managerSummary(out);
710
- if (ok) {
841
+ if (state === "updated") {
711
842
  const cli = await this.execTool("/usr/bin/sudo", ["-n", INSTALLER, "arcane-cli", version], { timeoutMs: 10 * 60_000 });
712
843
  summary += cli.code === 0 ? ` arcane-cli ${version} is installed for the specialists.` : ` arcane-cli ${version} was not installed (${cli.stderr.trim().split("\n").pop()?.slice(0, 200) ?? `exit ${cli.code}`}); the Cortex Tools Report offers it.`;
713
844
  }
714
845
  } catch (err) {
715
846
  summary = `❌ The Arcane upgrade to v${version} did not finish: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300)}. Check the manager with the arcane specialist before retrying.`;
716
- } finally {
717
- this.toolRuns.delete(MANAGER_RUN);
718
- this.saveOpenWork();
719
847
  }
720
- await this.rest.editMessage(message.channelId, message.id, message.content, managerButton(version, ok ? "upgraded" : "failed")).catch(() => undefined);
721
- await this.rest.createMessage(message.channelId, { content: summary, replyTo: message.id }).catch(() => undefined);
722
- log(`arcane: manager upgrade to ${version} ${ok ? "done" : "did not complete"}`);
848
+ await this.markArcane(report, "arcane", state);
849
+ const page = report.messages.find((m) => m.stacks.includes("arcane")) ?? report.messages[0];
850
+ await this.rest.createMessage(report.channelId, { content: summary, ...(page ? { replyTo: page.id } : {}) }).catch(() => undefined);
851
+ log(`arcane: manager upgrade to ${version} ${state === "updated" ? "done" : "did not complete"}`);
723
852
  }
724
853
 
725
854
  private async brewNodeVersion(): Promise<string> {
@@ -1109,8 +1238,8 @@ class Bridge {
1109
1238
  await this.onToolsButton(interaction, customId);
1110
1239
  return;
1111
1240
  }
1112
- if (customId.startsWith("arcane:manager:")) {
1113
- await this.onManagerButton(interaction, customId);
1241
+ if (customId.startsWith("arcane:")) {
1242
+ await this.onArcaneButton(interaction, customId);
1114
1243
  return;
1115
1244
  }
1116
1245
  const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(customId);
@@ -7,8 +7,6 @@ export interface ReportState {
7
7
  date: string;
8
8
  status: "sending" | "sent" | "failed";
9
9
  messageId?: string;
10
- /** Arcane report only: the manager release its Upgrade button offers, `2.17.0`. */
11
- managerOffer?: string;
12
10
  }
13
11
 
14
12
  /** Local date and minutes past midnight in a time zone. */
@@ -7,6 +7,11 @@ import { addRestarts, astrogateActivity, herdrActivity, needsPrompt, readRestart
7
7
  import {
8
8
  allId,
9
9
  arcaneServerVersion,
10
+ bwRelease,
11
+ gcloudRelease,
12
+ installedComponent,
13
+ jqRelease,
14
+ pythonBuild,
10
15
  brewHasOutdated,
11
16
  buildLines,
12
17
  type Collected,
@@ -80,10 +85,23 @@ const COLLECTED: Collected = {
80
85
  piLatest: "1.3.0",
81
86
  piAstroInstalled: "0.33.0",
82
87
  piAstroLatest: "0.33.0",
83
- installer: { node: { current: "24.22.0", previous: null }, wrappers: "0.32.0", arcaneCli: "2.16.1", trendarr: "0.6.0" },
88
+ installer: {
89
+ node: { current: "24.22.0", previous: null },
90
+ wrappers: "0.32.0",
91
+ arcaneCli: "2.16.1",
92
+ trendarr: "0.6.0",
93
+ python: { current: "3.14.8+20261009", previous: null },
94
+ gcloud: { current: "589.0.0", previous: null },
95
+ bw: { current: "2026.10.0", previous: null },
96
+ jq: { current: null, previous: null },
97
+ },
84
98
  arcaneServer: "2.17.0",
85
99
  trendarrLatest: "0.6.0",
86
100
  nodeLts: "24.22.0",
101
+ pythonLatest: "3.14.8+20261013",
102
+ gcloudLatest: "590.0.0",
103
+ bwLatest: "2026.11.0",
104
+ jqLatest: "1.8.2",
87
105
  warnings: [],
88
106
  };
89
107
 
@@ -113,6 +131,34 @@ describe("versions", () => {
113
131
  });
114
132
  });
115
133
 
134
+ describe("root-owned tool releases", () => {
135
+ it("reads the newest Python build of the installed minor, Bitwarden CLI, jq, and gcloud versions", () => {
136
+ const asset = (v: string) => ({ name: `cpython-${v}+20261013-aarch64-apple-darwin-install_only_stripped.tar.gz` });
137
+ const python = { tag_name: "20261013", assets: [asset("3.14.8"), asset("3.15.0"), asset("3.14.10"), { name: "cpython-3.14.9+20261013-x86_64-apple-darwin-install_only_stripped.tar.gz" }, { name: "SHA256SUMS" }] };
138
+ expect(pythonBuild(python, "3.14")).toBe("3.14.10+20261013");
139
+ expect(pythonBuild(python, "3.13")).toBeNull();
140
+ expect(pythonBuild({ tag_name: "latest", assets: [] }, "3.14")).toBeNull();
141
+ expect(bwRelease([{ tag_name: "desktop-v2026.11.0" }, { tag_name: "cli-v2026.11.1", prerelease: true }, { tag_name: "cli-v2026.10.0" }, { tag_name: "cli-v2026.9.1" }])).toBe("2026.10.0");
142
+ expect(bwRelease({})).toBeNull();
143
+ expect(jqRelease({ tag_name: "jq-1.8.2" })).toBe("1.8.2");
144
+ expect(jqRelease({ tag_name: "1.8.2" })).toBeNull();
145
+ expect(gcloudRelease({ version: "589.0.0", revision: 20261009074343 })).toBe("589.0.0");
146
+ expect(gcloudRelease({ version: "latest" })).toBeNull();
147
+ });
148
+
149
+ it("reads each component from the installer status", () => {
150
+ const status = { node: { current: "24.22.0", previous: null }, wrappers: "0.34.1", arcaneCli: "2.16.1", trendarr: null, python: { current: "3.14.8+20261013" }, jq: { current: null } };
151
+ expect(installedComponent(status, "node")).toBe("24.22.0");
152
+ expect(installedComponent(status, "wrappers")).toBe("0.34.1");
153
+ expect(installedComponent(status, "arcane-cli")).toBe("2.16.1");
154
+ expect(installedComponent(status, "trendarr")).toBeNull();
155
+ expect(installedComponent(status, "python")).toBe("3.14.8+20261013");
156
+ expect(installedComponent(status, "jq")).toBeNull();
157
+ expect(installedComponent(status, "gcloud")).toBeNull();
158
+ expect(installedComponent(null, "bw")).toBeNull();
159
+ });
160
+ });
161
+
116
162
  describe("report lines", () => {
117
163
  it("lists our tools first, then requested formulae and casks; dependencies and pinned packages stay out", () => {
118
164
  const lines = buildLines(COLLECTED);
@@ -120,17 +166,20 @@ describe("report lines", () => {
120
166
  "pi 0.87.1->1.3.0",
121
167
  "spc:wrappers 0.32.0->0.33.0",
122
168
  "spc:arcane-cli 2.16.1->2.17.0",
169
+ "spc:python 3.14.8+20261009->3.14.8+20261013",
170
+ "spc:gcloud 589.0.0->590.0.0",
171
+ "spc:bw 2026.10.0->2026.11.0",
123
172
  "brew:bitwarden-cli 2026.8.0->2026.9.0",
124
173
  "brew:gh 2.97.0->2.101.0",
125
174
  "brew:herdr 0.9.0->0.9.1",
126
175
  "brew:pnpm 11.22.0->12.6.0",
127
176
  "cask:codex 0.152.1->0.156.1",
128
177
  "cask:temurin@17 17.0.20,8->17.0.20.1,1",
129
- "cask:gcloud-cli 585.0.0->586.0.0",
130
178
  ]);
131
179
  expect(lines[0].major).toBe(true);
132
- expect(lines.find((l) => l.key === "cask:gcloud-cli")?.action).toEqual({ kind: "cask", token: "gcloud-cli", greedy: true });
133
- expect(lines.find((l) => l.key === "cask:codex")?.action).toEqual({ kind: "cask", token: "codex", greedy: false });
180
+ expect(lines.find((l) => l.key === "spc:gcloud")?.major).toBe(false);
181
+ expect(lines.find((l) => l.key === "spc:python")?.action).toEqual({ kind: "installer", component: "python" });
182
+ expect(lines.find((l) => l.key === "cask:codex")?.action).toEqual({ kind: "cask", token: "codex" });
134
183
  expect(lines.every((l) => l.state === "offered")).toBe(true);
135
184
  expect(brewHasOutdated(OUTDATED)).toBe(true);
136
185
  expect(brewHasOutdated({ formulae: [OUTDATED.formulae[6]], casks: [] })).toBe(false);
@@ -166,12 +215,26 @@ describe("collection", () => {
166
215
  const dir = mkdtempSync(join(tmpdir(), "tools-pkg-"));
167
216
  try {
168
217
  writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "0.33.0" }));
169
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify([{ version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar"] }]))) as unknown as typeof fetch;
218
+ const bodies: Record<string, unknown> = {
219
+ "https://nodejs.org/dist/index.json": [{ version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar"] }],
220
+ "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest": { tag_name: "20261013", assets: [{ name: "cpython-3.14.8+20261013-aarch64-apple-darwin-install_only_stripped.tar.gz" }] },
221
+ "https://dl.google.com/dl/cloudsdk/channels/rapid/components-2.json": { version: "590.0.0" },
222
+ "https://api.github.com/repos/bitwarden/clients/releases?per_page=30": [{ tag_name: "cli-v2026.11.0" }],
223
+ };
224
+ const fetched: string[] = [];
225
+ const fetchImpl = vi.fn(async (url: string | URL) => {
226
+ fetched.push(String(url));
227
+ const body = bodies[String(url)];
228
+ return body === undefined ? new Response("{}", { status: 404 }) : new Response(JSON.stringify(body));
229
+ }) as unknown as typeof fetch;
170
230
  const c = await collectTools({ exec, fetchImpl, piAstroPackage: join(dir, "package.json"), arcaneServer: async () => "2.16.1" });
171
231
  expect(calls[0]).toBe("brew update");
172
232
  expect(c.warnings.join(" | ")).toMatch(/brew update failed, versions may be stale: Error: network down/);
173
233
  expect(c.warnings.join(" | ")).toMatch(/npm view @astrofoundry\/trendarr failed/);
174
234
  expect(c).toMatchObject({ piInstalled: "0.87.1", piLatest: "1.3.0", piAstroInstalled: "0.33.0", nodeLts: "24.22.0", arcaneServer: "2.16.1", trendarrLatest: null });
235
+ expect(c).toMatchObject({ pythonLatest: "3.14.8+20261013", gcloudLatest: "590.0.0", bwLatest: "2026.11.0", jqLatest: null });
236
+ // jq is not installed by the installer yet, so its releases are not even asked for.
237
+ expect(fetched.some((u) => u.includes("jqlang"))).toBe(false);
175
238
  } finally {
176
239
  rmSync(dir, { recursive: true, force: true });
177
240
  }
@@ -237,8 +300,8 @@ describe("buttons and commands", () => {
237
300
 
238
301
  it("runs one fixed command per kind, reads the version back, and knows what to roll back and restart", () => {
239
302
  expect(updateCommand({ kind: "brew", name: "monadeo/tap/astrogate" }, "0.0.11")).toEqual({ file: "/opt/homebrew/bin/brew", args: ["upgrade", "monadeo/tap/astrogate"] });
240
- expect(updateCommand({ kind: "cask", token: "gcloud-cli", greedy: true }, "586.0.0").args).toEqual(["upgrade", "--cask", "--greedy", "gcloud-cli"]);
241
- expect(updateCommand({ kind: "cask", token: "codex", greedy: false }, "0.156.1").args).toEqual(["upgrade", "--cask", "codex"]);
303
+ expect(updateCommand({ kind: "cask", token: "codex" }, "0.156.1").args).toEqual(["upgrade", "--cask", "codex"]);
304
+ expect(updateCommand({ kind: "installer", component: "python" }, "3.14.8+20261013").args).toEqual(["-n", "/usr/local/sbin/specialist-install", "python", "3.14.8+20261013"]);
242
305
  expect(updateCommand({ kind: "pi" }, "1.3.0")).toEqual({ file: "/opt/homebrew/bin/pi", args: ["update", "--self"] });
243
306
  expect(updateCommand({ kind: "pi-astro" }, "0.33.0").args).toEqual(["update", "npm:@astrofoundry/pi-astro"]);
244
307
  expect(updateCommand({ kind: "installer", component: "arcane-cli" }, "2.17.0")).toEqual({ file: "/usr/bin/sudo", args: ["-n", "/usr/local/sbin/specialist-install", "arcane-cli", "2.17.0"] });
@@ -249,6 +312,8 @@ describe("buttons and commands", () => {
249
312
  expect(verifiedVersion({ kind: "brew", name: "node" }, "node 26.7.0 26.9.0\n", "26.9.0")).toBe(true);
250
313
  expect(verifiedVersion({ kind: "brew", name: "gh" }, "gh 2.97.0\n", "2.101.0")).toBe(false);
251
314
  expect(verifiedVersion({ kind: "installer", component: "node" }, JSON.stringify({ node: { current: "24.23.0" } }), "24.23.0")).toBe(true);
315
+ expect(verifiedVersion({ kind: "installer", component: "gcloud" }, JSON.stringify({ gcloud: { current: "590.0.0", previous: "589.0.0" } }), "590.0.0")).toBe(true);
316
+ expect(verifiedVersion({ kind: "installer", component: "arcane-cli" }, JSON.stringify({ arcaneCli: "2.17.0" }), "2.17.0")).toBe(true);
252
317
  expect(verifiedVersion({ kind: "pi" }, "not json", "1.3.0")).toBe(false);
253
318
  expect(servicesFor({ kind: "pi" })).toEqual(["bridge"]);
254
319
  expect(servicesFor({ kind: "brew", name: "node" })).toEqual(["astrogate", "bridge"]);
@@ -262,17 +327,17 @@ describe("release-notes review", () => {
262
327
  const release = (tag: string, body: string, prerelease = false) => ({ tag_name: tag, name: tag, body, published_at: "2026-10-15T00:00:00Z", html_url: `https://github.com/x/releases/${tag}`, draft: false, prerelease });
263
328
 
264
329
  it("reads only the tool's own releases in a shared repository, between the two versions", async () => {
265
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.9.0", "bw: new list flag"), release("desktop-v2026.9.0", "desktop only"), release("cli-v2026.8.0", "old")]))) as unknown as typeof fetch;
266
- const line = buildLines(COLLECTED).find((l) => l.key === "brew:bitwarden-cli") as ToolLine;
330
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.11.0", "bw: new list flag"), release("desktop-v2026.11.0", "desktop only"), release("cli-v2026.10.0", "old")]))) as unknown as typeof fetch;
331
+ const line = buildLines(COLLECTED).find((l) => l.key === "spc:bw") as ToolLine;
267
332
  const notes = await notesFor(line, fetchImpl);
268
- expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.9.0"]);
333
+ expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.11.0"]);
269
334
  const prompt = reviewPrompt(line, notes.notes ?? [], []);
270
- expect(prompt).toMatch(/How Cortex uses it: specialist-cli provision runs bw/);
271
- expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.9\.0 \(2026-10-15\)\nbw: new list flag\nRELEASE NOTES>>>/);
335
+ expect(prompt).toMatch(/How we use it: specialist-cli provision runs bw/);
336
+ expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.11\.0 \(2026-10-15\)\nbw: new list flag\nRELEASE NOTES>>>/);
272
337
  });
273
338
 
274
339
  it("says why there is nothing to review", async () => {
275
- const gcloud = buildLines(COLLECTED).find((l) => l.key === "cask:gcloud-cli") as ToolLine;
340
+ const gcloud = buildLines(COLLECTED).find((l) => l.key === "spc:gcloud") as ToolLine;
276
341
  expect(await notesFor(gcloud, fetch)).toEqual({ notes: null, reason: "no release notes to review here; see https://cloud.google.com/sdk/docs/release-notes" });
277
342
  const trendarr: ToolLine = { key: "spc:trendarr", label: "trendarr", installed: "0.6.0", latest: "0.7.0", major: false, action: { kind: "installer", component: "trendarr" }, state: "offered" };
278
343
  const notFound = vi.fn(async () => new Response("{}", { status: 404 })) as unknown as typeof fetch;