@kubitnodes/stellaris 0.3.19 → 0.3.21

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 (60) hide show
  1. package/dist/cli/index.js +394 -160
  2. package/dist/server/index.js +481 -147
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-23Q1JvmZ.js +1 -0
  5. package/web/dist/assets/{ChannelForms-BjD5Lp_u.js → ChannelForms-0Cwh59E7.js} +1 -1
  6. package/web/dist/assets/ChannelView-CqWV1zza.js +2 -0
  7. package/web/dist/assets/{CitizenView-tvC6dNUB.js → CitizenView-s5OdjXmB.js} +2 -2
  8. package/web/dist/assets/{CitizensView-DsUhIYtl.js → CitizensView-r6SZLnVl.js} +1 -1
  9. package/web/dist/assets/{CliIcon-TlyIqh7V.js → CliIcon-BOSygXeI.js} +1 -1
  10. package/web/dist/assets/CronsView-Y-bAE0l2.js +1 -0
  11. package/web/dist/assets/{KnowledgeView-CgRGZrOK.js → KnowledgeView-CI4PJWwm.js} +1 -1
  12. package/web/dist/assets/{Markdown-CePsfcUS.js → Markdown-DyckB74r.js} +4 -4
  13. package/web/dist/assets/MessageItem-DI05jbrI.js +1 -0
  14. package/web/dist/assets/MetricsView-DKh-lEW5.js +1 -0
  15. package/web/dist/assets/NeedsYouView-C6FfiTsg.js +1 -0
  16. package/web/dist/assets/Overview-D5RvWBg5.js +1 -0
  17. package/web/dist/assets/{Pane-DGtHsANK.js → Pane-BendOaje.js} +1 -1
  18. package/web/dist/assets/{Picker-Dq5Vvxod.js → Picker-BuVnakvi.js} +1 -1
  19. package/web/dist/assets/ProjectView-blBEh70W.js +1 -0
  20. package/web/dist/assets/ProposalView-CMntpKTc.js +2 -0
  21. package/web/dist/assets/{ProposalsView-BjEfHBtx.js → ProposalsView-CVUAV2Mm.js} +1 -1
  22. package/web/dist/assets/{RunnersView-CM-q_AX9.js → RunnersView-B-wuWVgB.js} +1 -1
  23. package/web/dist/assets/SocietyView-CaVbfJWz.js +1 -0
  24. package/web/dist/assets/SubjectThread-Bd_-sDhJ.js +1 -0
  25. package/web/dist/assets/{TaskFileView-BMAkbh-e.js → TaskFileView-CO4Y97OT.js} +2 -2
  26. package/web/dist/assets/{TaskView-HDwqXuYy.js → TaskView-DRZoHALo.js} +2 -2
  27. package/web/dist/assets/{TasksView-Dt8M_z96.js → TasksView-CQVQo3zF.js} +1 -1
  28. package/web/dist/assets/{ThreadForms-DbV3kPCi.js → ThreadForms-CUSeWu0c.js} +1 -1
  29. package/web/dist/assets/ThreadItem-BPYYXh3V.js +1 -0
  30. package/web/dist/assets/ThreadView-BcI2i6FI.js +1 -0
  31. package/web/dist/assets/{citizen-DQ0Bmk-r.js → citizen-Eff1h9rA.js} +1 -1
  32. package/web/dist/assets/{compose-Cx9c9k90.js → compose-D73Agshx.js} +1 -1
  33. package/web/dist/assets/{format-SP8GnzAX.js → format-fw0G_Vff.js} +1 -1
  34. package/web/dist/assets/governance-sRsfOw52.js +1 -0
  35. package/web/dist/assets/index-BkOq4PSU.css +2 -0
  36. package/web/dist/assets/index-CYiok0pT.js +23 -0
  37. package/web/dist/assets/link-Cl7vKbOz.js +1 -0
  38. package/web/dist/assets/{tasks-BeWF0Xy2.js → tasks-CyXIxoAy.js} +1 -1
  39. package/web/dist/assets/useMatch-D-tEHQEw.js +1 -0
  40. package/web/dist/assets/useStickyScroll-Bn4YyEXL.js +1 -0
  41. package/web/dist/index.html +9 -9
  42. package/web/dist/assets/BackLink-CDrsO9Ls.js +0 -1
  43. package/web/dist/assets/ChannelView-dI5EHpof.js +0 -2
  44. package/web/dist/assets/CronsView-BPlSyoAe.js +0 -1
  45. package/web/dist/assets/MessageItem-kg5zWMDe.js +0 -1
  46. package/web/dist/assets/MetricsView-cJeDTfBE.js +0 -1
  47. package/web/dist/assets/NeedsYouView-Cd1TnY7W.js +0 -1
  48. package/web/dist/assets/Overview-B6aBmove.js +0 -1
  49. package/web/dist/assets/ProjectView-uskZHP8l.js +0 -1
  50. package/web/dist/assets/ProposalView-DmjIL53_.js +0 -2
  51. package/web/dist/assets/SocietyView-DZuGEqIl.js +0 -1
  52. package/web/dist/assets/SubjectThread-pCRDn42X.js +0 -1
  53. package/web/dist/assets/ThreadItem-BVlz2w6q.js +0 -1
  54. package/web/dist/assets/ThreadView-CpBytSH7.js +0 -1
  55. package/web/dist/assets/governance-DX2ph-qr.js +0 -1
  56. package/web/dist/assets/index-BDKI0Rje.js +0 -23
  57. package/web/dist/assets/index-BPVeX6tV.css +0 -2
  58. package/web/dist/assets/link-DxSeFzFf.js +0 -1
  59. package/web/dist/assets/useMatch-HNekgJrF.js +0 -1
  60. package/web/dist/assets/useStickyScroll-BmqddNx5.js +0 -1
@@ -6,7 +6,7 @@ import path9 from "node:path";
6
6
  import { serve } from "@hono/node-server";
7
7
 
8
8
  // packages/board-core/dist/board.js
9
- import { chmod as chmod2, cp, mkdir as mkdir4, readdir as readdir3, readFile as readFile5, rename as rename3, rm as rm2, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
9
+ import { chmod as chmod2, cp, mkdir as mkdir4, readdir as readdir3, readFile as readFile4, rename as rename3, rm as rm2, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
10
10
  import path6 from "node:path";
11
11
  import { decodeTime, monotonicFactory } from "ulid";
12
12
  import { z as z13 } from "zod";
@@ -146,7 +146,7 @@ var SEED_ROLES = [
146
146
  },
147
147
  {
148
148
  name: "steward",
149
- purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time. Opens a channel with create_channel when a topic or a workstream needs one, and archives it once its work is done. Owns how work is shaped over time: answers stages waiting too long, stages assigned to roles nobody fills, and work sent back again and again by replanning with plan_task, adjusting gates, or proposing the role a plan needs; sets each project's completion effect with configure_project; turns recurring plan shapes into society skills and planning norms into the society's norms. Prefers scaling an existing role over inventing a new one. Never approves its own proposals; hiring, roles, and retirements are decided by the user, and channels, reallocations, and skills the steward may decide. Curates society knowledge and skills.",
149
+ purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time. Opens a channel with create_channel when a topic or a workstream needs one, and archives it once its work is done. Owns how work is shaped over time: answers stages waiting too long, stages assigned to roles nobody fills, and work sent back again and again by replanning with plan_task, adjusting gates, or proposing the role a plan needs; sets each project's completion effect with configure_project; turns recurring plan shapes into society skills and planning norms into the society's norms. Prefers scaling an existing role over inventing a new one, by a reallocation of its replicas, backlog threshold, wakes, or verbs. Never approves its own proposals; hiring, roles, retirements, and reallocations that change a role's verbs or replicas are decided by the user, and channels, skills, and other reallocations the steward may decide. Curates society knowledge and skills.",
150
150
  verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS, ...CURATING_VERBS],
151
151
  wakeTriggers: ["ops_event", "heartbeat"],
152
152
  maxReplicas: 1,
@@ -437,6 +437,14 @@ var MessageFrontmatterSchema = z4.object({
437
437
  ts: IsoDateTimeSchema,
438
438
  mentions: z4.array(NameSchema)
439
439
  });
440
+ var MessageRefSchema = MessageFrontmatterSchema.pick({
441
+ id: true,
442
+ author: true,
443
+ channel: true,
444
+ thread: true,
445
+ ts: true
446
+ });
447
+ var MESSAGE_LOOKUP_LIMIT = 50;
440
448
  var TaskStatusSchema = z4.enum(["open", "claimed", "done", "abandoned"]);
441
449
  var ThreadStateSchema = z4.enum(["open", "closed"]);
442
450
  var ThreadSubjectSchema = z4.object({
@@ -623,9 +631,54 @@ var ChannelProposalSchema = z4.object({
623
631
  name: NameSchema,
624
632
  purpose: z4.string().min(1)
625
633
  });
634
+ var RoleChangesSchema = z4.strictObject({
635
+ maxReplicas: z4.number().int().min(1).optional(),
636
+ backlogThreshold: z4.number().positive().optional(),
637
+ wakeTriggers: z4.array(CharterTriggerSchema).optional(),
638
+ verbs: z4.array(VerbNameSchema).optional()
639
+ }).refine((changes) => Object.values(changes).some((value) => value !== void 0), {
640
+ message: "a reallocation changes at least one of maxReplicas, backlogThreshold, wakeTriggers, verbs"
641
+ });
626
642
  var ReallocationProposalSchema = z4.object({
643
+ role: NameSchema,
644
+ changes: RoleChangesSchema,
627
645
  description: z4.string().min(1)
628
646
  });
647
+ var LegacyReallocationSchema = z4.object({ description: z4.string().min(1) });
648
+ function applyRoleChanges(role, changes) {
649
+ return {
650
+ ...role,
651
+ ...changes.maxReplicas === void 0 ? {} : { maxReplicas: changes.maxReplicas },
652
+ ...changes.backlogThreshold === void 0 ? {} : { backlogThreshold: changes.backlogThreshold },
653
+ ...changes.wakeTriggers === void 0 ? {} : { wakeTriggers: [...changes.wakeTriggers] },
654
+ ...changes.verbs === void 0 ? {} : { verbs: [...changes.verbs] }
655
+ };
656
+ }
657
+ function listedOrNone(values) {
658
+ return values.length === 0 ? "none" : values.join(", ");
659
+ }
660
+ function roleChangeLines(role, changes) {
661
+ const lines = [];
662
+ if (changes.maxReplicas !== void 0 && changes.maxReplicas !== role.maxReplicas) {
663
+ lines.push(`maxReplicas ${role.maxReplicas} \u2192 ${changes.maxReplicas}`);
664
+ }
665
+ if (changes.backlogThreshold !== void 0 && changes.backlogThreshold !== role.backlogThreshold) {
666
+ lines.push(`backlogThreshold ${role.backlogThreshold} \u2192 ${changes.backlogThreshold}`);
667
+ }
668
+ const wakes = changes.wakeTriggers;
669
+ if (wakes !== void 0 && (wakes.length !== role.wakeTriggers.length || wakes.some((trigger) => !role.wakeTriggers.includes(trigger)))) {
670
+ lines.push(`wakeTriggers ${listedOrNone(role.wakeTriggers)} \u2192 ${listedOrNone(wakes)}`);
671
+ }
672
+ if (changes.verbs !== void 0) {
673
+ const next = changes.verbs;
674
+ const added = next.filter((verb) => !role.verbs.includes(verb)).map((verb) => `+${verb}`);
675
+ const removed = role.verbs.filter((verb) => !next.includes(verb)).map((verb) => `\u2212${verb}`);
676
+ if (added.length + removed.length > 0) {
677
+ lines.push(`verbs ${[...added, ...removed].join(" ")}`);
678
+ }
679
+ }
680
+ return lines;
681
+ }
629
682
  var RetirementProposalSchema = z4.object({
630
683
  agent: NameSchema,
631
684
  reason: z4.string().min(1)
@@ -657,6 +710,32 @@ var ROLE_KIND_APPROVERS = {
657
710
  reallocation: [USER_ROLE, "steward"],
658
711
  skill: [USER_ROLE, "steward"]
659
712
  };
713
+ var USER_DECIDED_ROLE_CHANGES = ["verbs", "maxReplicas"];
714
+ function proposalApprovers(kind, charter) {
715
+ if (kind === "reallocation") {
716
+ const parsed = ReallocationProposalSchema.safeParse(charter);
717
+ if (parsed.success && USER_DECIDED_ROLE_CHANGES.some((field) => parsed.data.changes[field] !== void 0)) {
718
+ return [USER_ROLE];
719
+ }
720
+ }
721
+ return ROLE_KIND_APPROVERS[kind];
722
+ }
723
+ function describeRoleChanges(changes) {
724
+ const parts = [];
725
+ if (changes.maxReplicas !== void 0) {
726
+ parts.push(`up to ${changes.maxReplicas} per project`);
727
+ }
728
+ if (changes.backlogThreshold !== void 0) {
729
+ parts.push(`backlog threshold ${changes.backlogThreshold}`);
730
+ }
731
+ if (changes.wakeTriggers !== void 0) {
732
+ parts.push(changes.wakeTriggers.length === 0 ? "no ambient wakes" : `wakes on ${changes.wakeTriggers.join(", ")}`);
733
+ }
734
+ if (changes.verbs !== void 0) {
735
+ parts.push(`${changes.verbs.length} verbs`);
736
+ }
737
+ return parts.join(", ");
738
+ }
660
739
  function describeCharter(kind, charter) {
661
740
  switch (kind) {
662
741
  case "member": {
@@ -688,9 +767,14 @@ function describeCharter(kind, charter) {
688
767
  }
689
768
  case "reallocation": {
690
769
  const parsed = ReallocationProposalSchema.safeParse(charter);
691
- if (!parsed.success)
770
+ if (parsed.success) {
771
+ const { role, changes, description } = parsed.data;
772
+ return `reallocation of role ${role} (${describeRoleChanges(changes)}): ${description}`;
773
+ }
774
+ const legacy = LegacyReallocationSchema.safeParse(charter);
775
+ if (!legacy.success)
692
776
  break;
693
- return `reallocation: ${parsed.data.description}`;
777
+ return `reallocation: ${legacy.data.description}`;
694
778
  }
695
779
  case "skill": {
696
780
  const parsed = SkillProposalSchema.safeParse(charter);
@@ -999,7 +1083,7 @@ var VERB_DESCRIPTIONS = {
999
1083
  create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
1000
1084
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
1001
1085
  release_task: "Let go of the stage you hold so someone else can take it.",
1002
- update_task: "Move a task back to an earlier stage, abandon it, set what it is blocked by, or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
1086
+ update_task: "Move a task back to an earlier stage, abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
1003
1087
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
1004
1088
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
1005
1089
  unsubscribe: "Unsubscribe from a channel.",
@@ -1861,7 +1945,7 @@ var RunnerConfigSchema = z11.object({
1861
1945
  });
1862
1946
 
1863
1947
  // packages/shared/dist/version.js
1864
- var STELLARIS_VERSION = "0.3.19";
1948
+ var STELLARIS_VERSION = "0.3.21";
1865
1949
 
1866
1950
  // packages/board-core/dist/errors.js
1867
1951
  var BoardError = class extends Error {
@@ -1877,7 +1961,7 @@ function isBoardError(error, code) {
1877
1961
  }
1878
1962
 
1879
1963
  // packages/board-core/dist/events.js
1880
- import { appendFile, readFile as readFile2 } from "node:fs/promises";
1964
+ import { appendFile, open, stat } from "node:fs/promises";
1881
1965
  import path2 from "node:path";
1882
1966
 
1883
1967
  // packages/board-core/dist/fs.js
@@ -1962,10 +2046,19 @@ async function listDirs(dir) {
1962
2046
 
1963
2047
  // packages/board-core/dist/events.js
1964
2048
  var LEADING_ID = /^\{"id":"([0-9A-HJKMNP-TV-Z]{26})"/;
2049
+ var ID_PREFIX_BYTES = 64;
2050
+ var NEWLINE = 10;
1965
2051
  var EventLog = class {
1966
2052
  file;
1967
2053
  newId;
1968
2054
  now;
2055
+ lines = [];
2056
+ /** Bytes the index covers: always the end of a complete line. */
2057
+ indexed = 0;
2058
+ /** The indexed file, to tell a replaced log from a grown one. */
2059
+ inode = null;
2060
+ scanning = Promise.resolve();
2061
+ listeners = /* @__PURE__ */ new Set();
1969
2062
  constructor(file, newId, now) {
1970
2063
  this.file = file;
1971
2064
  this.newId = newId;
@@ -1982,48 +2075,97 @@ var EventLog = class {
1982
2075
  await ensureDir(path2.dirname(this.file));
1983
2076
  await appendFile(this.file, `${JSON.stringify(event)}
1984
2077
  `, "utf8");
2078
+ for (const listener of this.listeners) {
2079
+ try {
2080
+ listener(event);
2081
+ } catch {
2082
+ }
2083
+ }
1985
2084
  return event;
1986
2085
  }
2086
+ /**
2087
+ * Calls `listener` after each event this process appends, until the returned function is
2088
+ * called. Another process's appends, such as the admin CLI's, are seen only by the next read.
2089
+ */
2090
+ onAppend(listener) {
2091
+ this.listeners.add(listener);
2092
+ return () => {
2093
+ this.listeners.delete(listener);
2094
+ };
2095
+ }
1987
2096
  /** Events with an id greater than `since`, oldest first. `null` reads from the beginning. */
1988
2097
  async readSince(since, limit2 = 1e3) {
1989
- if (!await exists(this.file)) {
1990
- return [];
1991
- }
1992
- const raw = await readFile2(this.file, "utf8");
1993
- const events = [];
1994
- for (const line of raw.split("\n")) {
1995
- if (line.trim().length === 0) {
1996
- continue;
1997
- }
1998
- const leading = LEADING_ID.exec(line)?.[1];
1999
- if (since !== null && leading !== void 0 && leading <= since) {
2098
+ await this.refresh();
2099
+ const chosen = [];
2100
+ for (const line of this.lines) {
2101
+ if (since !== null && line.id <= since) {
2000
2102
  continue;
2001
2103
  }
2002
- const event = BoardEventSchema.parse(JSON.parse(line));
2003
- if (since !== null && event.id <= since) {
2004
- continue;
2005
- }
2006
- events.push(event);
2007
- if (events.length >= limit2) {
2104
+ chosen.push(line);
2105
+ if (chosen.length >= limit2) {
2008
2106
  break;
2009
2107
  }
2010
2108
  }
2011
- return events;
2109
+ const first = chosen[0];
2110
+ const last = chosen.at(-1);
2111
+ if (first === void 0 || last === void 0) {
2112
+ return [];
2113
+ }
2114
+ const span = await this.readBytes(first.start, last.end);
2115
+ return chosen.map((line) => BoardEventSchema.parse(JSON.parse(span.toString("utf8", line.start - first.start, line.end - first.start))));
2012
2116
  }
2013
2117
  /** The id of the newest event, or null for an empty log. */
2014
2118
  async lastId() {
2015
- if (!await exists(this.file)) {
2016
- return null;
2119
+ await this.refresh();
2120
+ return this.lines.at(-1)?.id ?? null;
2121
+ }
2122
+ /** Brings the index up to the file's end, one scan at a time. */
2123
+ refresh() {
2124
+ this.scanning = this.scanning.then(() => this.scan(), () => this.scan());
2125
+ return this.scanning;
2126
+ }
2127
+ async scan() {
2128
+ const info = await stat(this.file).catch((error) => {
2129
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
2130
+ return null;
2131
+ }
2132
+ throw error;
2133
+ });
2134
+ if (info === null || info.ino !== this.inode || info.size < this.indexed) {
2135
+ this.lines = [];
2136
+ this.indexed = 0;
2137
+ this.inode = info?.ino ?? null;
2138
+ }
2139
+ if (info === null || info.size === this.indexed) {
2140
+ return;
2141
+ }
2142
+ const added = await this.readBytes(this.indexed, info.size);
2143
+ let start = 0;
2144
+ for (let at = added.indexOf(NEWLINE); at >= 0; at = added.indexOf(NEWLINE, start)) {
2145
+ const prefix = added.toString("utf8", start, Math.min(at, start + ID_PREFIX_BYTES));
2146
+ if (prefix.trim().length > 0) {
2147
+ const id = LEADING_ID.exec(prefix)?.[1] ?? BoardEventSchema.parse(JSON.parse(added.toString("utf8", start, at))).id;
2148
+ this.lines.push({ id, start: this.indexed + start, end: this.indexed + at });
2149
+ }
2150
+ start = at + 1;
2151
+ }
2152
+ this.indexed += start;
2153
+ }
2154
+ async readBytes(from, to) {
2155
+ const handle = await open(this.file, "r");
2156
+ try {
2157
+ const buffer = Buffer.alloc(to - from);
2158
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
2159
+ return buffer.subarray(0, bytesRead);
2160
+ } finally {
2161
+ await handle.close();
2017
2162
  }
2018
- const lines = (await readFile2(this.file, "utf8")).trimEnd().split("\n");
2019
- const last = lines.at(-1) ?? "";
2020
- return last.length === 0 ? null : BoardEventSchema.parse(JSON.parse(last)).id;
2021
2163
  }
2022
2164
  };
2023
2165
 
2024
2166
  // packages/board-core/dist/files.js
2025
2167
  import { createHash } from "node:crypto";
2026
- import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename as rename2, rm, stat, writeFile as writeFile2 } from "node:fs/promises";
2168
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile2, rename as rename2, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
2027
2169
  import path3 from "node:path";
2028
2170
  var MAX_FILE_BYTES = 8 * 1024 * 1024;
2029
2171
  function isMissing2(error) {
@@ -2045,9 +2187,9 @@ var FileTree = class {
2045
2187
  }
2046
2188
  try {
2047
2189
  const absolute = path3.join(root, ...relative.split("/"));
2048
- const info = await stat(absolute);
2190
+ const info = await stat2(absolute);
2049
2191
  if (info.isFile() && info.size <= MAX_FILE_BYTES) {
2050
- files[relative] = (await readFile3(absolute)).toString("base64");
2192
+ files[relative] = (await readFile2(absolute)).toString("base64");
2051
2193
  }
2052
2194
  } catch (error) {
2053
2195
  if (!isMissing2(error)) {
@@ -2103,7 +2245,7 @@ var FileTree = class {
2103
2245
  }
2104
2246
  async hash(absolute) {
2105
2247
  try {
2106
- const info = await stat(absolute);
2248
+ const info = await stat2(absolute);
2107
2249
  if (info.size > MAX_FILE_BYTES) {
2108
2250
  return null;
2109
2251
  }
@@ -2112,7 +2254,7 @@ var FileTree = class {
2112
2254
  if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
2113
2255
  return fileVersion(known.sha, executable);
2114
2256
  }
2115
- const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
2257
+ const sha = createHash("sha256").update(await readFile2(absolute)).digest("hex");
2116
2258
  this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
2117
2259
  return fileVersion(sha, executable);
2118
2260
  } catch (error) {
@@ -2125,7 +2267,7 @@ var FileTree = class {
2125
2267
  };
2126
2268
 
2127
2269
  // packages/board-core/dist/homes.js
2128
- import { chmod, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2270
+ import { chmod, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
2129
2271
  import path4 from "node:path";
2130
2272
  import { execa } from "execa";
2131
2273
  var HOME_GITIGNORE = [
@@ -2269,7 +2411,7 @@ var HomeRepos = class {
2269
2411
  await writeFile3(ignore, IGNORE_FILE, "utf8");
2270
2412
  await git(["add", "--all"], home);
2271
2413
  await git(["commit", "--quiet", "--allow-empty", "-m", "home: created by the board"], home);
2272
- } else if (await readFile4(ignore, "utf8").catch(() => "") !== IGNORE_FILE) {
2414
+ } else if (await readFile3(ignore, "utf8").catch(() => "") !== IGNORE_FILE) {
2273
2415
  await writeFile3(ignore, IGNORE_FILE, "utf8");
2274
2416
  await git(["add", ".gitignore"], home);
2275
2417
  await git(["commit", "--quiet", "-m", "home: the board's ignore file"], home);
@@ -3188,8 +3330,8 @@ function restage(existing, edit) {
3188
3330
  };
3189
3331
  }
3190
3332
  function reopenStage(stage) {
3191
- const { completedBy: _by, completedAt: _at, ...open } = stage;
3192
- return open;
3333
+ const { completedBy: _by, completedAt: _at, ...open2 } = stage;
3334
+ return open2;
3193
3335
  }
3194
3336
  function gateChanges(before, after) {
3195
3337
  const changes = [];
@@ -3584,7 +3726,7 @@ var Board = class _Board {
3584
3726
  const task = { ...TaskFrontmatterSchema.parse(raw.data), body: raw.body };
3585
3727
  if ((legacy === "open" || legacy === "closed") && !await exists(this.paths.threadFile(threads, task.id))) {
3586
3728
  const ended = task.status === "done" || task.status === "abandoned";
3587
- const open = legacy === "open" && !ended;
3729
+ const open2 = legacy === "open" && !ended;
3588
3730
  const openedBy = opened.get(task.id);
3589
3731
  const closedBy = closed.get(task.id);
3590
3732
  await this.writeThread(threads, {
@@ -3592,10 +3734,10 @@ var Board = class _Board {
3592
3734
  channel: channelRef(slug, "general"),
3593
3735
  title: task.title,
3594
3736
  subject: { kind: "task", id: task.id },
3595
- state: open ? "open" : "closed",
3737
+ state: open2 ? "open" : "closed",
3596
3738
  openedBy: openedBy?.actor ?? task.createdBy,
3597
3739
  openedAt: openedBy?.ts ?? task.createdAt,
3598
- ...open ? {} : {
3740
+ ...open2 ? {} : {
3599
3741
  closedBy: closedBy?.actor ?? SYSTEM_ACTOR.name,
3600
3742
  closedAt: closedBy?.ts ?? this.now().toISOString()
3601
3743
  },
@@ -3658,7 +3800,7 @@ var Board = class _Board {
3658
3800
  for (const agent of await this.listAgents()) {
3659
3801
  const file = this.paths.agentCursors(agent.name);
3660
3802
  if (await exists(file)) {
3661
- const legacy = Legacy.safeParse(JSON.parse(await readFile5(file, "utf8")));
3803
+ const legacy = Legacy.safeParse(JSON.parse(await readFile4(file, "utf8")));
3662
3804
  if (legacy.success) {
3663
3805
  await writeJson(file, { digest: legacy.data.inbox });
3664
3806
  }
@@ -3997,6 +4139,10 @@ ${charter.purpose}
3997
4139
  async latestEventId() {
3998
4140
  return this.events.lastId();
3999
4141
  }
4142
+ /** Calls `listener` after each event the board appends, until the returned function is called. */
4143
+ onEventAppended(listener) {
4144
+ return this.events.onAppend(listener);
4145
+ }
4000
4146
  /** The roster as projected: every citizen with identity, reach, availability, and profile. */
4001
4147
  async listMembers() {
4002
4148
  const members = [];
@@ -4041,7 +4187,7 @@ ${charter.purpose}
4041
4187
  project,
4042
4188
  revision: revision.success ? revision.data : 0,
4043
4189
  updatedBy: updatedBy.success ? updatedBy.data : SYSTEM_ACTOR.name,
4044
- updatedAt: updatedAt.success ? updatedAt.data : (await stat2(file)).mtime.toISOString(),
4190
+ updatedAt: updatedAt.success ? updatedAt.data : (await stat3(file)).mtime.toISOString(),
4045
4191
  body
4046
4192
  };
4047
4193
  }
@@ -4143,6 +4289,28 @@ ${charter.purpose}
4143
4289
  async readThread(id) {
4144
4290
  return (await this.findThread(id)).thread;
4145
4291
  }
4292
+ /**
4293
+ * Where each of the messages named is and who wrote it, for text that names them by id; an id
4294
+ * that names no message is left out. Message files start with their ids, so only the files
4295
+ * asked for are read.
4296
+ */
4297
+ async findMessages(ids) {
4298
+ const wanted = new Set(ids);
4299
+ const found = [];
4300
+ for await (const dir of this.messageDirs()) {
4301
+ for (const file of await listFiles(dir)) {
4302
+ if (!wanted.delete(file.slice(0, 26))) {
4303
+ continue;
4304
+ }
4305
+ const { data } = await readMarkdown(path6.join(dir, file), MessageFrontmatterSchema);
4306
+ found.push(MessageRefSchema.parse(data));
4307
+ }
4308
+ if (wanted.size === 0) {
4309
+ break;
4310
+ }
4311
+ }
4312
+ return found;
4313
+ }
4146
4314
  /**
4147
4315
  * What citizens have asked the user and the user has not answered, oldest first: every message
4148
4316
  * by someone else that mentions the user, in an open thread or a channel the society or an
@@ -4514,18 +4682,16 @@ ${DASHBOARD_LINE}
4514
4682
  async readDigest(actor, input = {}) {
4515
4683
  const args = VerbInputs.read_inbox.parse(input);
4516
4684
  await this.authorize(actor, "read_inbox");
4517
- return this.mutex.run(async () => {
4518
- const stored = await this.readCursors(actor.name);
4519
- const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
4520
- const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
4521
- const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
4522
- if (args.advance) {
4523
- await this.advanceCursors(actor.name, filed);
4524
- }
4525
- const last = filed.at(-1)?.message.id;
4526
- const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
4527
- return { messages: filed.map((entry) => entry.message), cursor };
4528
- });
4685
+ const { cursors: stored, upto } = await this.digestSnapshot(actor.name);
4686
+ const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
4687
+ const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
4688
+ const filed = (await this.digestSince(actor, from, upto, conversation)).slice(0, args.limit);
4689
+ if (args.advance) {
4690
+ await this.mutex.run(() => this.advanceCursors(actor.name, filed));
4691
+ }
4692
+ const last = filed.at(-1)?.message.id;
4693
+ const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
4694
+ return { messages: filed.map((entry) => entry.message), cursor };
4529
4695
  }
4530
4696
  /**
4531
4697
  * How many unread digest messages a member has in each conversation: a scope's home, for its
@@ -4537,28 +4703,39 @@ ${DASHBOARD_LINE}
4537
4703
  * whose signals that call for action wake their readers directly.
4538
4704
  */
4539
4705
  async unreadByConversation(actor) {
4540
- return this.mutex.run(async () => {
4541
- const counts = /* @__PURE__ */ new Map();
4542
- const unscoped = { name: actor.name, role: actor.role };
4543
- for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
4544
- if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
4545
- continue;
4546
- }
4547
- const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
4548
- if (posted !== scope && scope !== SOCIETY_SCOPE) {
4549
- continue;
4550
- }
4551
- const key = conversationKey(scope, part);
4552
- const entry = counts.get(key) ?? {
4553
- scope,
4554
- thread: threadOfPart(part) ?? null,
4555
- channel: channelOfPart(part) ?? null,
4556
- count: 0
4557
- };
4558
- counts.set(key, { ...entry, count: entry.count + 1 });
4706
+ const { cursors, upto } = await this.digestSnapshot(actor.name);
4707
+ const counts = /* @__PURE__ */ new Map();
4708
+ const unscoped = { name: actor.name, role: actor.role };
4709
+ for (const { scope, part, message, news } of await this.digestSince(unscoped, cursors, upto)) {
4710
+ if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
4711
+ continue;
4559
4712
  }
4560
- return [...counts.values()];
4561
- });
4713
+ const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
4714
+ if (posted !== scope && scope !== SOCIETY_SCOPE) {
4715
+ continue;
4716
+ }
4717
+ const key = conversationKey(scope, part);
4718
+ const entry = counts.get(key) ?? {
4719
+ scope,
4720
+ thread: threadOfPart(part) ?? null,
4721
+ channel: channelOfPart(part) ?? null,
4722
+ count: 0
4723
+ };
4724
+ counts.set(key, { ...entry, count: entry.count + 1 });
4725
+ }
4726
+ return [...counts.values()];
4727
+ }
4728
+ /**
4729
+ * A reader's cursors and the id below which every message is already on disk, taken under the
4730
+ * mutex, which a digest scan then leaves: messages carry ids from the same monotonic source and are
4731
+ * written under the mutex, so the scan, which counts nothing newer than the mark, cannot pass over
4732
+ * one written while it reads, and a post never waits for a scan.
4733
+ */
4734
+ digestSnapshot(agent) {
4735
+ return this.mutex.run(async () => ({
4736
+ cursors: await this.readCursors(agent),
4737
+ upto: this.newId()
4738
+ }));
4562
4739
  }
4563
4740
  async readCursors(agent) {
4564
4741
  const file = this.paths.agentCursors(agent);
@@ -4600,9 +4777,10 @@ ${DASHBOARD_LINE}
4600
4777
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
4601
4778
  * message of the thread, since the conversation is about it; for a channel's, that channel's
4602
4779
  * top-level messages; for a scope's home, general's and those of a project the member is not in,
4603
- * since each thread and each other channel is a conversation of its own.
4780
+ * since each thread and each other channel is a conversation of its own. Nothing newer than
4781
+ * `upto` counts (see `digestSnapshot`).
4604
4782
  */
4605
- async digestSince(actor, cursors, conversation) {
4783
+ async digestSince(actor, cursors, upto, conversation) {
4606
4784
  const conversationThread = threadOfPart(conversation?.part);
4607
4785
  if (conversation !== void 0 && conversationThread !== void 0) {
4608
4786
  const { scope, part } = conversation;
@@ -4610,7 +4788,7 @@ ${DASHBOARD_LINE}
4610
4788
  if (found === null) {
4611
4789
  return [];
4612
4790
  }
4613
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
4791
+ const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part), upto);
4614
4792
  return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
4615
4793
  }
4616
4794
  const agent = await this.readAgent(actor.name);
@@ -4628,7 +4806,7 @@ ${DASHBOARD_LINE}
4628
4806
  ];
4629
4807
  const known = starts.filter((start) => start !== null);
4630
4808
  const since = known.length < starts.length ? null : known.toSorted((a, b) => a < b ? -1 : a > b ? 1 : 0)[0] ?? null;
4631
- for await (const message of this.iterateMessages(since)) {
4809
+ for await (const message of this.iterateMessages(since, void 0, upto)) {
4632
4810
  const filedIn = wakeScope(agent, parseChannelRef(message.channel).project);
4633
4811
  const thread = message.thread ?? null;
4634
4812
  const asked = message.mentions.includes(actor.name) || frontDesk && message.author === USER_NAME;
@@ -4644,12 +4822,12 @@ ${DASHBOARD_LINE}
4644
4822
  continue;
4645
4823
  }
4646
4824
  const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
4647
- let open = named === void 0 ? void 0 : openChannels.get(message.channel);
4648
- if (named !== void 0 && open === void 0) {
4649
- open = await this.channelOpen(message.channel);
4650
- openChannels.set(message.channel, open);
4825
+ let open2 = named === void 0 ? void 0 : openChannels.get(message.channel);
4826
+ if (named !== void 0 && open2 === void 0) {
4827
+ open2 = await this.channelOpen(message.channel);
4828
+ openChannels.set(message.channel, open2);
4651
4829
  }
4652
- const part = thread ?? (open === true ? `#${named}` : null);
4830
+ const part = thread ?? (open2 === true ? `#${named}` : null);
4653
4831
  if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
4654
4832
  continue;
4655
4833
  }
@@ -4714,7 +4892,7 @@ ${task.body}`;
4714
4892
  }
4715
4893
  }
4716
4894
  for (const file of await listFiles(this.paths.projectKnowledge(project))) {
4717
- const content = await readFile5(path6.join(this.paths.projectKnowledge(project), file), "utf8");
4895
+ const content = await readFile4(path6.join(this.paths.projectKnowledge(project), file), "utf8");
4718
4896
  if (content.toLowerCase().includes(needle)) {
4719
4897
  hits.push({
4720
4898
  kind: "knowledge",
@@ -4752,7 +4930,7 @@ ${task.body}`;
4752
4930
  });
4753
4931
  }
4754
4932
  for (const entry of shared) {
4755
- const content = await readFile5(entry.file, "utf8");
4933
+ const content = await readFile4(entry.file, "utf8");
4756
4934
  if (content.toLowerCase().includes(needle)) {
4757
4935
  hits.push({
4758
4936
  kind: entry.kind,
@@ -5752,6 +5930,9 @@ ${args.summary}`);
5752
5930
  }
5753
5931
  if (args.blocked_by !== void 0) {
5754
5932
  for (const id of args.blocked_by) {
5933
+ if (id === current.id) {
5934
+ throw new BoardError("VALIDATION", `task ${current.id} cannot be blocked by itself`);
5935
+ }
5755
5936
  await this.findTask(id);
5756
5937
  }
5757
5938
  next = { ...next, blockedBy: [...args.blocked_by] };
@@ -5788,6 +5969,7 @@ ${args.summary}`);
5788
5969
  from: current.status,
5789
5970
  to: task.status,
5790
5971
  note: args.note ?? null,
5972
+ ...args.blocked_by === void 0 ? {} : { blockedBy: task.blockedBy, previousBlockedBy: current.blockedBy },
5791
5973
  ...args.pull_request === void 0 ? {} : { pullRequest: task.pullRequest?.url ?? null }
5792
5974
  });
5793
5975
  }
@@ -6241,7 +6423,7 @@ ${rationale}`}`);
6241
6423
  throw new BoardError("NOT_FOUND", `no transcript of turn ${turnId} for ${agent}`);
6242
6424
  }
6243
6425
  const entries = [];
6244
- for (const line of (await readFile5(file, "utf8")).split("\n")) {
6426
+ for (const line of (await readFile4(file, "utf8")).split("\n")) {
6245
6427
  if (line.trim() === "")
6246
6428
  continue;
6247
6429
  const parsed = TranscriptEntrySchema.safeParse(JSON.parse(line));
@@ -6720,7 +6902,7 @@ ${rationale}`}`);
6720
6902
  }
6721
6903
  const file = entry.isFile() ? conflictCopyOf(relative) : null;
6722
6904
  if (file !== null) {
6723
- const since = await this.homes.changedAt(home, relative) ?? (await stat2(path6.join(home, relative)).catch(() => null))?.mtime.toISOString();
6905
+ const since = await this.homes.changedAt(home, relative) ?? (await stat3(path6.join(home, relative)).catch(() => null))?.mtime.toISOString();
6724
6906
  if (since !== void 0) {
6725
6907
  found.push({ path: relative, file, since });
6726
6908
  }
@@ -7086,12 +7268,37 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7086
7268
  case "archive":
7087
7269
  await this.validateArchive(ArchiveProposalSchema.parse(charter).project);
7088
7270
  return;
7089
- case "reallocation":
7271
+ case "reallocation": {
7272
+ const parsed = ReallocationProposalSchema.safeParse(charter);
7273
+ if (parsed.success) {
7274
+ await this.reallocatedRole(parsed.data);
7275
+ } else {
7276
+ LegacyReallocationSchema.parse(charter);
7277
+ }
7090
7278
  return;
7279
+ }
7091
7280
  default:
7092
7281
  return;
7093
7282
  }
7094
7283
  }
7284
+ /**
7285
+ * The role as a reallocation would leave it, with what changes: refused for the user's charter,
7286
+ * a role that does not exist, a charter the changes would make invalid, and changes the role
7287
+ * has already.
7288
+ */
7289
+ async reallocatedRole(reallocation) {
7290
+ if (reallocation.role === USER_ROLE) {
7291
+ throw new BoardError("FORBIDDEN", "the user charter is not subject to proposals");
7292
+ }
7293
+ const current = await this.readRole(reallocation.role);
7294
+ const next = RoleCharterSchema.parse(applyRoleChanges(current, reallocation.changes));
7295
+ await this.validateRole(next);
7296
+ const changed = roleChangeLines(current, reallocation.changes);
7297
+ if (changed.length === 0) {
7298
+ throw new BoardError("VALIDATION", `role ${current.name} has these values already; the reallocation changes nothing`);
7299
+ }
7300
+ return { next, changed };
7301
+ }
7095
7302
  async executeProvision(by, proposal) {
7096
7303
  const meta = { proposalId: proposal.id };
7097
7304
  switch (proposal.kind) {
@@ -7128,8 +7335,15 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7128
7335
  const { members } = await this.archiveUnlocked(by, input.project, input.reason, meta);
7129
7336
  return { project: input.project, members };
7130
7337
  }
7131
- case "reallocation":
7132
- return void 0;
7338
+ case "reallocation": {
7339
+ const parsed = ReallocationProposalSchema.safeParse(proposal.charter);
7340
+ if (!parsed.success) {
7341
+ return void 0;
7342
+ }
7343
+ const { next, changed } = await this.reallocatedRole(parsed.data);
7344
+ await this.writeRoleUnlocked(by, next, meta);
7345
+ return { role: next.name, changed };
7346
+ }
7133
7347
  default:
7134
7348
  return void 0;
7135
7349
  }
@@ -7298,7 +7512,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7298
7512
  if (!isSafeRelativePath(entry.path)) {
7299
7513
  continue;
7300
7514
  }
7301
- const bytes = await readFile5(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
7515
+ const bytes = await readFile4(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
7302
7516
  const text = bytes === null || bytes.includes(0) ? null : bytes.toString("utf8");
7303
7517
  files.push({
7304
7518
  ...entry,
@@ -7829,7 +8043,7 @@ ${charter.purpose}
7829
8043
  if (filed.proposedBy === actor.name) {
7830
8044
  throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7831
8045
  }
7832
- const approvers = ROLE_KIND_APPROVERS[filed.kind];
8046
+ const approvers = proposalApprovers(filed.kind, filed.charter);
7833
8047
  if (!approvers.includes(actor.role)) {
7834
8048
  throw new BoardError("FORBIDDEN", `a ${filed.kind} proposal requires one of: ${approvers.join(", ")}`);
7835
8049
  }
@@ -7912,7 +8126,7 @@ ${charter.purpose}
7912
8126
  }
7913
8127
  if (subject?.kind === "proposal") {
7914
8128
  const proposal = await this.readProposal(subject.id);
7915
- if (proposal.proposedBy === actor.name || proposal.decidedBy === actor.name || ROLE_KIND_APPROVERS[proposal.kind].includes(actor.role)) {
8129
+ if (proposal.proposedBy === actor.name || proposal.decidedBy === actor.name || proposalApprovers(proposal.kind, proposal.charter).includes(actor.role)) {
7916
8130
  return true;
7917
8131
  }
7918
8132
  return (await this.readRole(actor.role)).wakeTriggers.includes(OPS_WAKE_TRIGGER);
@@ -7969,11 +8183,12 @@ ${charter.purpose}
7969
8183
  });
7970
8184
  await this.endCronsUnlocked(by, `its thread closed: its ${found.thread.subject?.kind ?? "subject"} was ${ended}`, (cron) => cron.thread === subjectId);
7971
8185
  }
7972
- async readMessagesIn(dir, since) {
8186
+ /** The messages in a folder after `since` and, when given, up to `upto`, read by name first. */
8187
+ async readMessagesIn(dir, since, upto) {
7973
8188
  const messages = [];
7974
8189
  for (const file of await listFiles(dir)) {
7975
8190
  const id = file.slice(0, 26);
7976
- if (since !== null && id <= since) {
8191
+ if (since !== null && id <= since || upto !== void 0 && id > upto) {
7977
8192
  continue;
7978
8193
  }
7979
8194
  const doc = await readMarkdown(path6.join(dir, file), MessageFrontmatterSchema);
@@ -7981,25 +8196,45 @@ ${charter.purpose}
7981
8196
  }
7982
8197
  return messages;
7983
8198
  }
7984
- /** Every message newer than `since` across society channels, project channels, and threads. */
7985
- async *iterateMessages(since, project) {
8199
+ /**
8200
+ * Every message newer than `since`, and no newer than `upto` when given, across society
8201
+ * channels, project channels, and threads.
8202
+ */
8203
+ async *iterateMessages(since, project, upto) {
7986
8204
  if (project === void 0) {
7987
8205
  for (const channel of await listDirs(this.paths.societyChannels())) {
7988
- yield* await this.readMessagesIn(this.paths.societyChannel(channel), since);
8206
+ yield* await this.readMessagesIn(this.paths.societyChannel(channel), since, upto);
7989
8207
  }
7990
- yield* this.threadMessagesIn(this.paths.societyThreads(), since);
8208
+ yield* this.threadMessagesIn(this.paths.societyThreads(), since, upto);
7991
8209
  }
7992
8210
  const projects = project === void 0 ? await listDirs(this.paths.projects()) : [project];
7993
8211
  for (const slug of projects) {
7994
8212
  for (const channel of await listDirs(this.paths.projectChannels(slug))) {
7995
- yield* await this.readMessagesIn(this.paths.projectChannel(slug, channel), since);
8213
+ yield* await this.readMessagesIn(this.paths.projectChannel(slug, channel), since, upto);
7996
8214
  }
7997
- yield* this.threadMessagesIn(this.paths.threads(slug), since);
8215
+ yield* this.threadMessagesIn(this.paths.threads(slug), since, upto);
7998
8216
  }
7999
8217
  }
8000
- async *threadMessagesIn(threads, since) {
8218
+ /** Every folder that holds messages: each channel's and each thread's, in the society and every project. */
8219
+ async *messageDirs() {
8220
+ for (const channel of await listDirs(this.paths.societyChannels())) {
8221
+ yield this.paths.societyChannel(channel);
8222
+ }
8223
+ for (const id of await listDirs(this.paths.societyThreads())) {
8224
+ yield this.paths.threadMessages(this.paths.societyThreads(), id);
8225
+ }
8226
+ for (const slug of await listDirs(this.paths.projects())) {
8227
+ for (const channel of await listDirs(this.paths.projectChannels(slug))) {
8228
+ yield this.paths.projectChannel(slug, channel);
8229
+ }
8230
+ for (const id of await listDirs(this.paths.threads(slug))) {
8231
+ yield this.paths.threadMessages(this.paths.threads(slug), id);
8232
+ }
8233
+ }
8234
+ }
8235
+ async *threadMessagesIn(threads, since, upto) {
8001
8236
  for (const id of await listDirs(threads)) {
8002
- yield* await this.readMessagesIn(this.paths.threadMessages(threads, id), since);
8237
+ yield* await this.readMessagesIn(this.paths.threadMessages(threads, id), since, upto);
8003
8238
  }
8004
8239
  }
8005
8240
  };
@@ -8383,7 +8618,7 @@ var Scheduler = class {
8383
8618
  return;
8384
8619
  }
8385
8620
  if (stringOf(payload["thread"]) !== taskId) {
8386
- await this.wakeHolder(taskId, event.actor, now);
8621
+ await this.wakeHolder(taskId, event.actor, event.actor, now);
8387
8622
  }
8388
8623
  await this.pruneStageWakes(taskId);
8389
8624
  return;
@@ -8409,6 +8644,7 @@ var Scheduler = class {
8409
8644
  if (creator !== null && creator !== event.actor) {
8410
8645
  await this.wakeCreator(taskId, project, creator, event.actor, now);
8411
8646
  }
8647
+ await this.wakeDependents(taskId, event.actor, now);
8412
8648
  const threaded = await this.board.readThread(taskId).then(() => true, () => false);
8413
8649
  if (!threaded) {
8414
8650
  this.sweep({ kind: "thread", id: taskId });
@@ -8428,12 +8664,17 @@ var Scheduler = class {
8428
8664
  }
8429
8665
  case "task.updated": {
8430
8666
  const taskId = stringOf(payload["taskId"]);
8431
- if (taskId !== null && payload["to"] === "abandoned") {
8667
+ if (taskId === null) {
8668
+ return;
8669
+ }
8670
+ if (payload["to"] === "abandoned") {
8432
8671
  delete this.state.releaseCounts[taskId];
8433
8672
  await this.queueClosing(taskId);
8673
+ await this.wakeDependents(taskId, event.actor, now);
8434
8674
  }
8435
- if (taskId !== null) {
8436
- await this.pruneStageWakes(taskId);
8675
+ await this.pruneStageWakes(taskId);
8676
+ if (Array.isArray(payload["blockedBy"]) && (await this.blockersInPlay(stringArray(payload["previousBlockedBy"]))).length > 0) {
8677
+ await this.wakeUnblocked(taskId, event.actor, now);
8437
8678
  }
8438
8679
  return;
8439
8680
  }
@@ -8604,9 +8845,10 @@ var Scheduler = class {
8604
8845
  }
8605
8846
  /**
8606
8847
  * Entering a stage wakes its named citizen, else its last holder when work returns to it, else
8607
- * the project's members of its role, else every member of the project who may hold it.
8848
+ * the project's members of its role, else every member of the project who may hold it. A stage
8849
+ * of a blocked task wakes nobody until its last blocker ends (`wakeUnblocked`).
8608
8850
  */
8609
- async wakeStage(taskId, from, now, only) {
8851
+ async wakeStage(taskId, from, now, options = {}) {
8610
8852
  let task;
8611
8853
  try {
8612
8854
  task = (await this.board.findTask(taskId)).task;
@@ -8617,6 +8859,10 @@ var Scheduler = class {
8617
8859
  if (stage === void 0 || task.status !== "open" || task.completing) {
8618
8860
  return;
8619
8861
  }
8862
+ if ((await this.blockersInPlay(task.blockedBy)).length > 0) {
8863
+ return;
8864
+ }
8865
+ const { only, state = "is waiting for you" } = options;
8620
8866
  const lastHolder = stage.holders.at(-1);
8621
8867
  const targets = stage.agent !== void 0 ? [stage.agent] : lastHolder !== void 0 ? [lastHolder] : stage.role !== void 0 ? (await this.board.membersWithRole(task.project, stage.role)).map((a) => a.name) : (await this.board.projectMembers(task.project)).map((a) => a.name);
8622
8868
  for (const name of targets) {
@@ -8627,7 +8873,7 @@ var Scheduler = class {
8627
8873
  if (agent === null || !mayHoldStage(agent, task)) {
8628
8874
  continue;
8629
8875
  }
8630
- await this.enqueueStage(agent, task, from, now, "is waiting for you");
8876
+ await this.enqueueStage(agent, task, from, now, state);
8631
8877
  }
8632
8878
  }
8633
8879
  /**
@@ -8636,7 +8882,7 @@ var Scheduler = class {
8636
8882
  */
8637
8883
  async wakeNewcomer(name, project, now) {
8638
8884
  for (const task of await this.board.openTasks(project).catch(() => [])) {
8639
- await this.wakeStage(task.id, name, now, name);
8885
+ await this.wakeStage(task.id, name, now, { only: name });
8640
8886
  }
8641
8887
  }
8642
8888
  /**
@@ -8644,7 +8890,7 @@ var Scheduler = class {
8644
8890
  * worked in that conversation, which nothing else would start before the next heartbeat. Turns
8645
8891
  * of one conversation run one at a time, so the turn this queues is the one that sees the claim.
8646
8892
  */
8647
- async wakeHolder(taskId, holder, now) {
8893
+ async wakeHolder(taskId, holder, from, now, state = "is yours; its work happens here") {
8648
8894
  let task;
8649
8895
  try {
8650
8896
  task = (await this.board.findTask(taskId)).task;
@@ -8655,12 +8901,66 @@ var Scheduler = class {
8655
8901
  if (agent === null || task.status !== "claimed" || task.claimedBy !== holder) {
8656
8902
  return;
8657
8903
  }
8658
- const key = await this.enqueueStage(agent, task, holder, now, "is yours; its work happens here");
8904
+ const key = await this.enqueueStage(agent, task, from, now, state);
8659
8905
  const item = key === null ? void 0 : this.pending.get(key);
8660
8906
  if (item?.stageWakes !== void 0) {
8661
8907
  item.holder = true;
8662
8908
  }
8663
8909
  }
8910
+ /**
8911
+ * The ids among these that name a task still open or claimed, a landing one included. A task
8912
+ * blocked by any of them waits for them rather than for a holder: its stage wakes nobody, by
8913
+ * coming up or on a heartbeat, until the last one ends.
8914
+ */
8915
+ async blockersInPlay(ids) {
8916
+ const blocking = [];
8917
+ for (const id of ids) {
8918
+ try {
8919
+ const { task } = await this.board.findTask(id);
8920
+ if (task.status === "open" || task.status === "claimed") {
8921
+ blocking.push(id);
8922
+ }
8923
+ } catch {
8924
+ }
8925
+ }
8926
+ return blocking;
8927
+ }
8928
+ /**
8929
+ * A task's last blocker has ended, or its blockers were changed to none in play: its stage wakes
8930
+ * as on coming up, or its holder in the task's conversation, since nothing woke either for it
8931
+ * while it was blocked.
8932
+ */
8933
+ async wakeUnblocked(taskId, from, now) {
8934
+ let task;
8935
+ try {
8936
+ task = (await this.board.findTask(taskId)).task;
8937
+ } catch {
8938
+ return;
8939
+ }
8940
+ if (task.completing || (await this.blockersInPlay(task.blockedBy)).length > 0) {
8941
+ return;
8942
+ }
8943
+ if (task.status === "open") {
8944
+ await this.wakeStage(task.id, from, now, {
8945
+ state: "is waiting for you, now that nothing blocks it"
8946
+ });
8947
+ } else if (task.status === "claimed" && task.claimedBy !== void 0) {
8948
+ await this.wakeHolder(task.id, task.claimedBy, from, now, "is yours, and nothing blocks it any more");
8949
+ }
8950
+ }
8951
+ /** A task that ended unblocks every task in play that names it, once nothing else blocks them. */
8952
+ async wakeDependents(taskId, from, now) {
8953
+ for (const project of await this.board.listProjects()) {
8954
+ if (project.archived !== void 0) {
8955
+ continue;
8956
+ }
8957
+ for (const task of await this.board.listTasks(project.slug)) {
8958
+ if (task.blockedBy.includes(taskId) && (task.status === "open" || task.status === "claimed")) {
8959
+ await this.wakeUnblocked(task.id, from, now);
8960
+ }
8961
+ }
8962
+ }
8963
+ }
8664
8964
  /** Queues a stage wake in the task's conversation and returns its key, or null for no wake. */
8665
8965
  async enqueueStage(agent, task, from, now, state) {
8666
8966
  if (agent.status !== "active" || agent.cli === null) {
@@ -8696,9 +8996,9 @@ var Scheduler = class {
8696
8996
  }
8697
8997
  }
8698
8998
  /**
8699
- * The first of these stage wakes still due: its task waits, unheld, on a stage the agent may
8700
- * hold, or, with `holder`, the agent holds it, having claimed it outside the task's conversation.
8701
- * A wake for a stage the agent took in the turn the wake waited on is dropped.
8999
+ * The first of these stage wakes still due: its task waits, unheld and unblocked, on a stage the
9000
+ * agent may hold, or, with `holder`, the agent holds it, having claimed it outside the task's
9001
+ * conversation. A wake for a stage the agent took in the turn the wake waited on is dropped.
8702
9002
  */
8703
9003
  async stillWaiting(name, wakes, holder) {
8704
9004
  const agent = await this.tryReadAgent(name);
@@ -8714,7 +9014,7 @@ var Scheduler = class {
8714
9014
  if (task.completing || currentStage(task) === void 0) {
8715
9015
  continue;
8716
9016
  }
8717
- if (task.status === "open" && mayHoldStage(agent, task)) {
9017
+ if (task.status === "open" && mayHoldStage(agent, task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
8718
9018
  return wake;
8719
9019
  }
8720
9020
  if (holder && task.status === "claimed" && task.claimedBy === agent.name) {
@@ -8937,9 +9237,9 @@ var Scheduler = class {
8937
9237
  /**
8938
9238
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
8939
9239
  * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
8940
- * member holds, and a stage
8941
- * waiting that it may take, each in its task's thread. Each conversation with something wakes on
8942
- * its own, so a heartbeat never brings one task's business into another's turn.
9240
+ * member holds, and a stage of an unblocked task waiting that it may take, each in its task's
9241
+ * thread. Each conversation with something wakes on its own, so a heartbeat never brings one
9242
+ * task's business into another's turn.
8943
9243
  */
8944
9244
  async checkHeartbeats(now) {
8945
9245
  for (const agent of await this.board.listAgents()) {
@@ -8991,7 +9291,7 @@ var Scheduler = class {
8991
9291
  }
8992
9292
  if (project !== SOCIETY_SCOPE) {
8993
9293
  for (const task of await this.board.openTasks(project)) {
8994
- if (mayHoldStage(agent, task)) {
9294
+ if (mayHoldStage(agent, task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
8995
9295
  reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
8996
9296
  }
8997
9297
  }
@@ -9143,9 +9443,10 @@ var Scheduler = class {
9143
9443
  return latest;
9144
9444
  }
9145
9445
  /**
9146
- * A current stage without a holder past the threshold is logged as a signal once while it waits.
9147
- * It wakes nobody by itself: its assignees see the stage on their heartbeat, and the steward reads
9148
- * the signal at its next turn and decides whether to replan or to mention someone.
9446
+ * A current stage without a holder past the threshold is logged as a signal once while it waits,
9447
+ * naming the tasks that still block it. It wakes nobody by itself: its assignees see the stage on
9448
+ * their heartbeat, or a blocked one when its last blocker ends, and the steward reads the signal
9449
+ * at its next turn and decides whether to replan or to mention someone.
9149
9450
  */
9150
9451
  async checkWaitingStages(now) {
9151
9452
  const stillWaiting = /* @__PURE__ */ new Set();
@@ -9166,10 +9467,12 @@ var Scheduler = class {
9166
9467
  }
9167
9468
  this.state.waitingSeen[key] = iso(now);
9168
9469
  const assignee2 = stage.agent ?? stage.role ?? "anyone in the project";
9470
+ const blockers = await this.blockersInPlay(task.blockedBy);
9471
+ const waited = blockers.length === 0 ? "for a holder" : `blocked by ${blockers.map((id) => `task ${id}`).join(", ")}`;
9169
9472
  await this.board.publishSignal({
9170
9473
  kind: "waiting_stage",
9171
9474
  key: `waiting_stage:${key}`,
9172
- summary: `stage "${stage.name}" of task ${task.id} "${task.title}" in ${project.slug}, for ${assignee2}, has waited ${describeDuration(age)} for a holder`,
9475
+ summary: `stage "${stage.name}" of task ${task.id} "${task.title}" in ${project.slug}, for ${assignee2}, has waited ${describeDuration(age)} ${waited}`,
9173
9476
  value: age,
9174
9477
  threshold: this.timings.waitingStageMs,
9175
9478
  project: project.slug,
@@ -9632,6 +9935,7 @@ var TURN_CONTRACT = [
9632
9935
  "- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by holding a stage, sleeping, or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. The society's crons are under society/crons/ in the board projection.",
9633
9936
  "- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
9634
9937
  "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
9938
+ "- To show an image in a post, such as a screenshot or a chart, commit the file to a task's branch and link it by its path in the task's worktree, as `![what it shows](<path>)`: the board shows a file on a task's branch in place once it is committed, at the latest when your turn ends. An image at an https address shows too. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt.",
9635
9939
  "- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; a durable fact about a project's codebase or process goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
9636
9940
  '- A procedure you have followed twice is a skill: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Files the steps use, such as scripts/ and references/, go in the same folder, named in SKILL.md by paths relative to it. Propose it with kind "skill" when the whole society would use it.',
9637
9941
  "- To take a published skill for yourself, install it in a temporary folder and copy it into your home: in a folder from `mktemp -d`, `npx -y skills add <source> --skill <name> -a universal --copy -y` writes `.agents/skills/<name>/` and a lock file there, and you copy that skill folder to skills/<name>/ in your home, named in lowercase letters, digits, and hyphens. Never install one inside a worktree, where it would be committed into the project, or with -g, which writes to the runner's own home, shared by every citizen on it and listed in no skills index.",
@@ -9645,21 +9949,22 @@ var PLANNING = [
9645
9949
  "- Name stages by what gets done. Plan the next few steps rather than everything, and reshape with plan_task as the work teaches you.",
9646
9950
  "- Assign a stage to a role when anyone in it could do it, to a citizen only when it must be them, and to nobody when anyone in the project could. A citizen named on a stage must be a member of the task's project; add them first. A stage wakes everyone it is open to when it comes up, so the narrower the assignee, the fewer turns it costs.",
9647
9951
  "- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, send the task back to an earlier stage with update_task.",
9952
+ "- A task that cannot start until other tasks finish names them with update_task blocked_by. While any of them is in play, its stage wakes nobody, neither when it comes up nor on a heartbeat; once the last has ended, done or abandoned, the stage wakes as if it had just come up, or wakes its holder. Nobody needs to clear the block or mention anyone when they end; change blocked_by only when the plan changes.",
9648
9953
  "- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread when a gate should change.",
9649
9954
  "- Commit your work on the task's branch before you advance, and say what you did in the advance note; it is the handover the next holder reads in the task's thread."
9650
9955
  ].join("\n");
9651
9956
  var GOVERNANCE = [
9652
- "- Anything the society lacks is a proposal: call `propose` with a kind and a charter. It opens the proposal's thread in governance with your rationale, where it is discussed and where the decision is posted. Approval provisions it on the spot, and either decision wakes the proposer. The user decides members, roles, retirements, and archives; the steward may also decide channels, reallocations, and skills. Nobody decides their own proposal.",
9957
+ "- Anything the society lacks is a proposal: call `propose` with a kind and a charter. It opens the proposal's thread in governance with your rationale, where it is discussed and where the decision is posted. Approval provisions it on the spot, and either decision wakes the proposer. The user decides members, roles, retirements, archives, and reallocations that change a role's verbs or replicas; the steward may also decide channels, skills, and other reallocations. Nobody decides their own proposal.",
9653
9958
  "- Charter shapes, as JSON objects:",
9654
9959
  ' - member: {"name", "role", "cli": "claude" or "codex", "memberships": [project slugs], "model"?, "homeRunner"?, "subscriptions"?, "seedInstructions"?}',
9655
9960
  ' - role: {"name", "purpose", "verbs": [board verbs], "wakeTriggers"?: any of "user_post", "ops_event", "heartbeat" (heartbeat when omitted), "maxReplicas"?, "backlogThreshold"?, "societyScope"?: true for a society role, whose members are never in a project and keep watch over the whole society}. Mentions, stages that become yours, and finished tasks you created wake every role; a heartbeat wakes you when something is unread, held, or waiting for you.',
9656
9961
  ' - channel: {"project": slug or null, "name", "purpose"}',
9657
9962
  ' - retirement: {"agent", "reason"}',
9658
9963
  ' - archive: {"project", "reason"}: approval archives the project: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay; refused while a task there is in play',
9659
- ' - reallocation: {"description"}',
9660
- ' - skill: {"name", "summary"}: the folder skills/<name>/ in your home, SKILL.md and every file beside it, which the board takes as it stands when this turn ends into society/proposals/<id>/, where whoever decides reads it; a proposal whose folder has no SKILL.md then is rejected by the board. Approval publishes it under society/skills/<name>/, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded. A proposal from before folders carries "body", its SKILL.md text alone',
9964
+ ` - reallocation: {"role", "changes": {"maxReplicas"?, "backlogThreshold"?, "wakeTriggers"?, "verbs"?}, "description"}: approval applies the changes to the existing role, each the field's new value and verbs and wakeTriggers whole lists, for its members from their next turn; refused for a role that does not exist or that has those values already. Only the user decides one that changes verbs or maxReplicas`,
9965
+ ' - skill: {"name", "summary"}: the folder skills/<name>/ in your home, SKILL.md and every file beside it, which the board takes as it stands when this turn ends into society/proposals/<id>/, where whoever decides reads it; a proposal whose folder has no SKILL.md then is rejected by the board. Approval publishes it under society/skills/<name>/, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded. Once yours is approved, delete skills/<name>/ from your home and report memoryUpdated: the board leaves your copy there, and your index would list it beside the published one and follow it as it drifts from what was reviewed. To revise a published skill, copy its folder home, edit it, and propose it again. A proposal from before folders carries "body", its SKILL.md text alone',
9661
9966
  "- Projects and membership: a project is a lasting area of work, not one request; `create_project` opens one with its general channel (front desk and user), and `configure_project` sets its completion effect, its display name, or its default branch, never its slug (user, steward, concierge). A workstream within a project or the society, such as a release, gets a channel of its own: `create_channel` opens it, which its opener follows, and announces it in the place's general, where members subscribe when it concerns them, `create_task` with `channel` files the workstream's tasks there, and `archive_channel` ends it once none of its tasks is in play, leaving its history readable (user, steward, concierge; others propose a channel). A project whose work is finished or has moved to another project is archived by proposal, once no task there is in play. `join_project` and `leave_project` move yourself, or another citizen when you are the concierge, the steward, or the user; joining gives the pair a worktree and an onboarding turn. A society role, one that keeps watch over the whole society as the concierge and the steward do, is never in a project: it moves others, never itself.",
9662
- "- Prefer scaling an existing role over inventing one; a new role is justified by work a project needs that no existing role covers. A role that needs a tool the board lacks is an engineering task, not a hiring request.",
9967
+ "- Prefer scaling an existing role over inventing one, with a reallocation of its replicas, backlog threshold, wakes, or verbs; a new role is justified by work a project needs that no existing role covers. A role that needs a tool the board lacks is an engineering task, not a hiring request.",
9663
9968
  "- Operations signals are counters and timers the board logs, listed in the prompt of a role that reads them: stages waiting for a holder, backlog per role, stages waiting on a role nobody fills, churn, stale threads, idle members, missing capabilities, replicas added, spend. They are not posts in any channel; interpreting them is your judgment."
9664
9969
  ].join("\n");
9665
9970
  function renderOnboardingPreamble(context) {
@@ -9842,7 +10147,7 @@ function buildTurnPrompt(input) {
9842
10147
  lines.push("A task you created is done. Tell whoever asked for it, if anyone did.");
9843
10148
  }
9844
10149
  if (dispatch.trigger.kind === "proposal_decided") {
9845
- lines.push("A proposal you made was decided; the decision is the last post in the proposal's thread. A decision wakes you in the conversation you filed the proposal from, or at home once that has ended. Carry on with what an approval enables or a rejection asks, and stay silent if nothing follows.");
10150
+ lines.push("A proposal you made was decided; the decision is the last post in the proposal's thread. A decision wakes you in the conversation you filed the proposal from, or at home once that has ended. Carry on with what an approval enables or a rejection asks, and stay silent if nothing follows. An approved skill is published under society/skills/<name>/: delete your own skills/<name>/ from your home and report memoryUpdated, so your index lists the published copy alone.");
9846
10151
  }
9847
10152
  if (dispatch.trigger.kind === "cron") {
9848
10153
  lines.push("A cron came due: what it asks is under Crons below.");
@@ -10628,7 +10933,7 @@ var TurnHost = class {
10628
10933
  if (await this.board.hasMentioned(actor.name, USER_NAME, since)) {
10629
10934
  return;
10630
10935
  }
10631
- const proposed = (await this.board.listProposals()).some((proposal) => proposal.proposedBy === actor.name && proposal.createdAt >= since && ROLE_KIND_APPROVERS[proposal.kind].includes(USER_ROLE));
10936
+ const proposed = (await this.board.listProposals()).some((proposal) => proposal.proposedBy === actor.name && proposal.createdAt >= since && proposalApprovers(proposal.kind, proposal.charter).includes(USER_ROLE));
10632
10937
  if (proposed) {
10633
10938
  return;
10634
10939
  }
@@ -11864,7 +12169,7 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
11864
12169
  }
11865
12170
 
11866
12171
  // apps/server/dist/static.js
11867
- import { readFile as readFile6, stat as stat3 } from "node:fs/promises";
12172
+ import { readFile as readFile5, stat as stat4 } from "node:fs/promises";
11868
12173
  import path8 from "node:path";
11869
12174
  var CONTENT_TYPES = {
11870
12175
  ".html": "text/html; charset=utf-8",
@@ -11881,7 +12186,7 @@ var CONTENT_TYPES = {
11881
12186
  };
11882
12187
  async function isFile(file) {
11883
12188
  try {
11884
- return (await stat3(file)).isFile();
12189
+ return (await stat4(file)).isFile();
11885
12190
  } catch {
11886
12191
  return false;
11887
12192
  }
@@ -11906,7 +12211,7 @@ function spaHandler(staticDir) {
11906
12211
  const file = within(root, pathname);
11907
12212
  const headers = { "x-content-type-options": "nosniff" };
11908
12213
  if (file !== null && await isFile(file)) {
11909
- return c.body(await readFile6(file), 200, {
12214
+ return c.body(await readFile5(file), 200, {
11910
12215
  ...headers,
11911
12216
  "content-type": CONTENT_TYPES[path8.extname(file)] ?? "application/octet-stream",
11912
12217
  "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
@@ -11919,7 +12224,7 @@ function spaHandler(staticDir) {
11919
12224
  if (!await isFile(index)) {
11920
12225
  return c.text("The interface is not built: run pnpm build:web.", 404);
11921
12226
  }
11922
- return c.body(await readFile6(index), 200, {
12227
+ return c.body(await readFile5(index), 200, {
11923
12228
  ...headers,
11924
12229
  "content-type": CONTENT_TYPES[".html"] ?? "text/html",
11925
12230
  "cache-control": "no-cache"
@@ -11937,6 +12242,7 @@ var ERROR_STATUS = {
11937
12242
  INVALID_TRANSITION: 409,
11938
12243
  INVALID_STATE: 409
11939
12244
  };
12245
+ var EVENT_PAGE = 100;
11940
12246
  var RetireBodySchema = z16.object({ reason: z16.string().min(1) });
11941
12247
  var RunnerBodySchema = z16.object({ name: NameSchema });
11942
12248
  var EnrollmentApprovalSchema = z16.object({ name: NameSchema });
@@ -11944,6 +12250,7 @@ var HomeRunnerBodySchema = z16.object({ runner: NameSchema.nullable() });
11944
12250
  var ModelBodySchema = z16.object({ model: ModelNameSchema.nullable() });
11945
12251
  var EffortBodySchema = z16.object({ effort: EffortSchema.nullable() });
11946
12252
  var HistoryLimitSchema = z16.coerce.number().int().min(1).max(500);
12253
+ var MessageIdsSchema = z16.string().transform((ids) => ids.split(",").filter((id) => id !== "")).pipe(z16.array(UlidSchema).min(1).max(MESSAGE_LOOKUP_LIMIT));
11947
12254
  var TimezoneBodySchema = z16.object({ timezone: TimeZoneSchema });
11948
12255
  var ChannelBodySchema = z16.object({
11949
12256
  project: NameSchema.nullable().default(null),
@@ -11957,6 +12264,7 @@ function bearer2(c) {
11957
12264
  }
11958
12265
  function createApp(deps) {
11959
12266
  const { board: board2, version, turns: turns2, scheduler: scheduler2, models } = deps;
12267
+ const eventStreamPingMs = deps.eventStreamPingMs ?? 15e3;
11960
12268
  const app2 = new Hono3();
11961
12269
  app2.get("/health", (c) => c.json({ ok: true, version }));
11962
12270
  app2.onError((error, c) => {
@@ -12054,6 +12362,7 @@ function createApp(deps) {
12054
12362
  api.get("/proposals", async (c) => c.json(await board2.listProposals()));
12055
12363
  api.get("/crons", async (c) => c.json(await board2.listCrons()));
12056
12364
  api.get("/proposals/:id", async (c) => c.json(await board2.readProposal(c.req.param("id"))));
12365
+ api.get("/messages", async (c) => c.json(await board2.findMessages(MessageIdsSchema.parse(c.req.query("ids") ?? ""))));
12057
12366
  api.get("/proposals/:id/files", async (c) => c.json(await board2.readSkillFiles(UlidSchema.parse(c.req.param("id")))));
12058
12367
  api.get("/signals", async (c) => c.json(await board2.listSignals(Number(c.req.query("limit") ?? "100"))));
12059
12368
  api.get("/metrics", async (c) => {
@@ -12158,16 +12467,41 @@ function createApp(deps) {
12158
12467
  api.get("/events/stream", (c) => {
12159
12468
  let cursor = c.req.query("since") ?? null;
12160
12469
  return streamSSE2(c, async (stream) => {
12161
- if (cursor === "latest") {
12162
- cursor = await board2.latestEventId();
12163
- }
12164
- while (!stream.aborted && !stream.closed) {
12165
- const events = await board2.readEvents(cursor === "" ? null : cursor, 100);
12166
- for (const event of events) {
12167
- await stream.writeSSE({ id: event.id, event: event.type, data: JSON.stringify(event) });
12168
- cursor = event.id;
12470
+ let appended = false;
12471
+ let wake = null;
12472
+ const unsubscribe = board2.onEventAppended(() => {
12473
+ appended = true;
12474
+ wake?.();
12475
+ });
12476
+ stream.onAbort(() => wake?.());
12477
+ try {
12478
+ if (cursor === "latest") {
12479
+ cursor = await board2.latestEventId();
12169
12480
  }
12170
- await stream.sleep(500);
12481
+ while (!stream.aborted && !stream.closed) {
12482
+ appended = false;
12483
+ const events = await board2.readEvents(cursor === "" ? null : cursor, EVENT_PAGE);
12484
+ for (const event of events) {
12485
+ await stream.writeSSE({ id: event.id, event: event.type, data: JSON.stringify(event) });
12486
+ cursor = event.id;
12487
+ }
12488
+ if (appended || events.length === EVENT_PAGE) {
12489
+ continue;
12490
+ }
12491
+ const woken = await new Promise((resolve) => {
12492
+ const timer = setTimeout(() => resolve(false), eventStreamPingMs);
12493
+ wake = () => {
12494
+ clearTimeout(timer);
12495
+ resolve(true);
12496
+ };
12497
+ });
12498
+ wake = null;
12499
+ if (!woken && !stream.aborted) {
12500
+ await stream.writeSSE({ event: "ping", data: "" });
12501
+ }
12502
+ }
12503
+ } finally {
12504
+ unsubscribe();
12171
12505
  }
12172
12506
  });
12173
12507
  });