@kubitnodes/stellaris 0.3.18 → 0.3.20

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 (57) hide show
  1. package/dist/cli/index.js +602 -196
  2. package/dist/server/index.js +606 -156
  3. package/package.json +1 -1
  4. package/web/dist/assets/{BackLink-DUOMS3fv.js → BackLink-BjfX2KZl.js} +1 -1
  5. package/web/dist/assets/{ChannelForms-DfG4vYvk.js → ChannelForms-BYrcUldk.js} +1 -1
  6. package/web/dist/assets/ChannelView-GJ_DusLQ.js +2 -0
  7. package/web/dist/assets/CitizenView-Cx7IYSRr.js +2 -0
  8. package/web/dist/assets/{CitizensView-bKbUaCab.js → CitizensView-IKH7_yNQ.js} +1 -1
  9. package/web/dist/assets/{CliIcon-DTVk0pGU.js → CliIcon-vSuwLv2K.js} +1 -1
  10. package/web/dist/assets/CronsView-DJbi8Qhx.js +1 -0
  11. package/web/dist/assets/{KnowledgeView-Dt7wE3FT.js → KnowledgeView-CoYOG9tI.js} +1 -1
  12. package/web/dist/assets/{Markdown-DeF9PKNm.js → Markdown-YC-fbHiE.js} +4 -4
  13. package/web/dist/assets/MessageItem-D7MUCA_T.js +1 -0
  14. package/web/dist/assets/{MetricsView-Bw7nL6Y9.js → MetricsView-CE94VW0I.js} +1 -1
  15. package/web/dist/assets/{NeedsYouView-DbESNtQT.js → NeedsYouView-Otnm3lcG.js} +1 -1
  16. package/web/dist/assets/{Overview-Dv7SS4yW.js → Overview-B8f6rLb2.js} +1 -1
  17. package/web/dist/assets/{Pane-v044FaP5.js → Pane-_D7n5uKH.js} +1 -1
  18. package/web/dist/assets/{Picker-wWsO86nu.js → Picker-DUiiQMFN.js} +1 -1
  19. package/web/dist/assets/ProjectView-D_ACOmEd.js +1 -0
  20. package/web/dist/assets/ProposalView-4d0U3nEb.js +2 -0
  21. package/web/dist/assets/{ProposalsView-dVA50yG1.js → ProposalsView-BovF6CMD.js} +1 -1
  22. package/web/dist/assets/{RunnersView-C-l5QYIa.js → RunnersView-B2_uCVHC.js} +1 -1
  23. package/web/dist/assets/{SocietyView--mWRXRj_.js → SocietyView-Bf0iX8AY.js} +1 -1
  24. package/web/dist/assets/SubjectThread-B_Vmgs3d.js +1 -0
  25. package/web/dist/assets/TaskFileView-BzO0gjE6.js +4 -0
  26. package/web/dist/assets/{TaskView-BgJcOmGj.js → TaskView-DB_Nji99.js} +2 -2
  27. package/web/dist/assets/{TasksView-f8KIsif_.js → TasksView-Bz36YYWq.js} +1 -1
  28. package/web/dist/assets/{ThreadForms-CXsunS-N.js → ThreadForms-1mbMZsbv.js} +1 -1
  29. package/web/dist/assets/ThreadItem-QJSA92C4.js +1 -0
  30. package/web/dist/assets/ThreadView-BECLqt5B.js +1 -0
  31. package/web/dist/assets/{citizen-VtLNxjtf.js → citizen-DVFr5T6R.js} +1 -1
  32. package/web/dist/assets/{compose-D-SO-HE-.js → compose-_6feqP1t.js} +1 -1
  33. package/web/dist/assets/{format-CkySAdH1.js → format-DxXtbdNM.js} +1 -1
  34. package/web/dist/assets/governance-C9xH4QTm.js +1 -0
  35. package/web/dist/assets/index-BEVCPm4C.js +23 -0
  36. package/web/dist/assets/index-BkOq4PSU.css +2 -0
  37. package/web/dist/assets/link-CTjFMNoY.js +1 -0
  38. package/web/dist/assets/{tasks-CD70eybR.js → tasks-B6I9DJ8O.js} +1 -1
  39. package/web/dist/assets/useMatch-B3RzAe8y.js +1 -0
  40. package/web/dist/assets/useStickyScroll-Ca7sWbGH.js +1 -0
  41. package/web/dist/index.html +9 -9
  42. package/web/dist/assets/ChannelView-DTLhLXy4.js +0 -2
  43. package/web/dist/assets/CitizenView-q24CExsI.js +0 -2
  44. package/web/dist/assets/CronsView-DaG1xhQu.js +0 -1
  45. package/web/dist/assets/MessageItem-DDs7m3-4.js +0 -1
  46. package/web/dist/assets/ProjectView--nzlqSDA.js +0 -1
  47. package/web/dist/assets/ProposalView-Cdw_xzG1.js +0 -1
  48. package/web/dist/assets/SubjectThread-DvvsxHN9.js +0 -1
  49. package/web/dist/assets/TaskFileView-DYuPrCdk.js +0 -4
  50. package/web/dist/assets/ThreadCard-BRLvjdz3.js +0 -1
  51. package/web/dist/assets/ThreadView-DzpaXkik.js +0 -1
  52. package/web/dist/assets/governance-1UeTD2K_.js +0 -1
  53. package/web/dist/assets/index-CwkK8XtJ.js +0 -23
  54. package/web/dist/assets/index-DaES0xVj.css +0 -2
  55. package/web/dist/assets/link-BN04dcBX.js +0 -1
  56. package/web/dist/assets/useMatch-i33QT_GI.js +0 -1
  57. package/web/dist/assets/useStickyScroll-WSVpAMrA.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 { mkdir as mkdir4, readdir as readdir3, readFile as readFile5, rename as rename3, stat as stat2 } 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";
@@ -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({
@@ -637,7 +645,7 @@ var ArchiveProposalSchema = z4.object({
637
645
  var SkillProposalSchema = z4.object({
638
646
  name: NameSchema,
639
647
  summary: z4.string().min(1).max(200),
640
- body: z4.string().min(1)
648
+ body: z4.string().min(1).optional()
641
649
  });
642
650
  var ProposalCharterSchemas = {
643
651
  role: RoleCharterSchema,
@@ -764,6 +772,7 @@ var HomeFileDiffSchema = HomeFileChangeSchema.pick({ path: true, status: true })
764
772
  var KnowledgeSchema = z4.object({
765
773
  topic: NameSchema,
766
774
  project: NameSchema.nullable(),
775
+ revision: z4.number().int().min(0),
767
776
  updatedBy: NameSchema,
768
777
  updatedAt: IsoDateTimeSchema
769
778
  });
@@ -786,6 +795,24 @@ var ProposalOriginSchema = z4.object({
786
795
  thread: UlidSchema.optional(),
787
796
  channel: NameSchema.optional()
788
797
  });
798
+ var SKILL_FILES_LIMIT = 100;
799
+ var SKILL_BYTES_LIMIT = 10 * 1024 * 1024;
800
+ var SkillFileSchema = z4.object({
801
+ path: z4.string().min(1),
802
+ size: z4.number().int().nonnegative(),
803
+ executable: z4.boolean()
804
+ });
805
+ var SkillFilesSchema = z4.object({
806
+ /** The commit of the proposer's home the files came from. */
807
+ commit: CommitIdSchema,
808
+ takenAt: IsoDateTimeSchema,
809
+ files: z4.array(SkillFileSchema)
810
+ });
811
+ var SkillFileContentSchema = SkillFileSchema.extend({
812
+ text: z4.string().nullable(),
813
+ /** The text was cut at its size limit. */
814
+ truncated: z4.boolean()
815
+ });
789
816
  var ProposalFrontmatterSchema = z4.object({
790
817
  id: UlidSchema,
791
818
  kind: ProposalKindSchema,
@@ -794,6 +821,8 @@ var ProposalFrontmatterSchema = z4.object({
794
821
  createdAt: IsoDateTimeSchema,
795
822
  /** Absent for a proposal filed outside a turn, or before proposals recorded it. */
796
823
  filedFrom: ProposalOriginSchema.optional(),
824
+ /** A skill proposal's folder, once taken; absent for one proposed as text alone. */
825
+ skillFiles: SkillFilesSchema.optional(),
797
826
  decidedBy: NameSchema.optional(),
798
827
  decidedAt: IsoDateTimeSchema.optional(),
799
828
  reason: z4.string().optional(),
@@ -896,7 +925,9 @@ var VerbInputs = {
896
925
  /** A project you belong to, or null for society knowledge, which the steward and the user curate. */
897
926
  project: NameSchema.nullable().default(null),
898
927
  topic: NameSchema,
899
- body: z5.string().min(1)
928
+ body: z5.string().min(1),
929
+ /** The `revision` in the frontmatter of the topic you read, 0 when it names none or for a new topic. */
930
+ revision: z5.number().int().min(0)
900
931
  }),
901
932
  remove_knowledge: z5.object({
902
933
  /** As for write_knowledge: a project you belong to, or null for society knowledge. */
@@ -986,7 +1017,7 @@ var VERB_DESCRIPTIONS = {
986
1017
  create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none, merge, or ghpr for a GitHub remote).",
987
1018
  join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn, and follows the project's general; the result lists its other open channels, to subscribe to when the work needs them.",
988
1019
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
989
- write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
1020
+ write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those. Pass the revision from the frontmatter of the topic you read, or 0 for a new topic; if it was written since, the call is refused with the current text to merge yours into.",
990
1021
  remove_knowledge: "Remove a knowledge topic that is wrong, superseded, or folded into another, so no turn or search finds it again. Whoever may write the topic may remove it; the board keeps its text aside for the user to restore.",
991
1022
  plan_task: "Reshape a task's plan from the current stage onward, or after it while someone holds it: keep a stage by passing its id, drop it by leaving it out, add one without an id. Gated stages and on_done: user, steward, concierge.",
992
1023
  advance_task: "Finish the stage you hold, with a note for the task's thread on what you did. The next stage becomes current; past the last one the task is done.",
@@ -1030,6 +1061,7 @@ var BOARD_EVENT_TYPES = [
1030
1061
  "proposal.created",
1031
1062
  "proposal.decided",
1032
1063
  "proposal.provisioned",
1064
+ "proposal.files_taken",
1033
1065
  "channel.added",
1034
1066
  "channel.archived",
1035
1067
  "role.added",
@@ -1458,6 +1490,9 @@ var RunnerAnswerSchema = z7.discriminatedUnion("ok", [
1458
1490
  ]);
1459
1491
  var WarmSessionsSchema = z7.object({ warm: z7.array(z7.string()) });
1460
1492
  var FileManifestSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1493
+ function fileVersion(sha, executable) {
1494
+ return executable ? `${sha}+x` : sha;
1495
+ }
1461
1496
  var FileReadSchema = z7.object({ paths: z7.array(z7.string()) });
1462
1497
  var FileContentsSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1463
1498
  function isSafeRelativePath(file) {
@@ -1834,7 +1869,7 @@ var RunnerConfigSchema = z11.object({
1834
1869
  });
1835
1870
 
1836
1871
  // packages/shared/dist/version.js
1837
- var STELLARIS_VERSION = "0.3.18";
1872
+ var STELLARIS_VERSION = "0.3.20";
1838
1873
 
1839
1874
  // packages/board-core/dist/errors.js
1840
1875
  var BoardError = class extends Error {
@@ -1850,7 +1885,7 @@ function isBoardError(error, code) {
1850
1885
  }
1851
1886
 
1852
1887
  // packages/board-core/dist/events.js
1853
- import { appendFile, readFile as readFile2 } from "node:fs/promises";
1888
+ import { appendFile, open, stat } from "node:fs/promises";
1854
1889
  import path2 from "node:path";
1855
1890
 
1856
1891
  // packages/board-core/dist/fs.js
@@ -1891,7 +1926,9 @@ async function readMarkdown(file, schema) {
1891
1926
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1892
1927
  }
1893
1928
  async function readLooseMarkdown(file) {
1894
- const raw = await readFile(file, "utf8");
1929
+ return parseLooseMarkdown(await readFile(file, "utf8"));
1930
+ }
1931
+ function parseLooseMarkdown(raw) {
1895
1932
  try {
1896
1933
  const parsed = parseMatter(raw);
1897
1934
  return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
@@ -1933,10 +1970,19 @@ async function listDirs(dir) {
1933
1970
 
1934
1971
  // packages/board-core/dist/events.js
1935
1972
  var LEADING_ID = /^\{"id":"([0-9A-HJKMNP-TV-Z]{26})"/;
1973
+ var ID_PREFIX_BYTES = 64;
1974
+ var NEWLINE = 10;
1936
1975
  var EventLog = class {
1937
1976
  file;
1938
1977
  newId;
1939
1978
  now;
1979
+ lines = [];
1980
+ /** Bytes the index covers: always the end of a complete line. */
1981
+ indexed = 0;
1982
+ /** The indexed file, to tell a replaced log from a grown one. */
1983
+ inode = null;
1984
+ scanning = Promise.resolve();
1985
+ listeners = /* @__PURE__ */ new Set();
1940
1986
  constructor(file, newId, now) {
1941
1987
  this.file = file;
1942
1988
  this.newId = newId;
@@ -1953,48 +1999,97 @@ var EventLog = class {
1953
1999
  await ensureDir(path2.dirname(this.file));
1954
2000
  await appendFile(this.file, `${JSON.stringify(event)}
1955
2001
  `, "utf8");
2002
+ for (const listener of this.listeners) {
2003
+ try {
2004
+ listener(event);
2005
+ } catch {
2006
+ }
2007
+ }
1956
2008
  return event;
1957
2009
  }
2010
+ /**
2011
+ * Calls `listener` after each event this process appends, until the returned function is
2012
+ * called. Another process's appends, such as the admin CLI's, are seen only by the next read.
2013
+ */
2014
+ onAppend(listener) {
2015
+ this.listeners.add(listener);
2016
+ return () => {
2017
+ this.listeners.delete(listener);
2018
+ };
2019
+ }
1958
2020
  /** Events with an id greater than `since`, oldest first. `null` reads from the beginning. */
1959
2021
  async readSince(since, limit2 = 1e3) {
1960
- if (!await exists(this.file)) {
1961
- return [];
1962
- }
1963
- const raw = await readFile2(this.file, "utf8");
1964
- const events = [];
1965
- for (const line of raw.split("\n")) {
1966
- if (line.trim().length === 0) {
2022
+ await this.refresh();
2023
+ const chosen = [];
2024
+ for (const line of this.lines) {
2025
+ if (since !== null && line.id <= since) {
1967
2026
  continue;
1968
2027
  }
1969
- const leading = LEADING_ID.exec(line)?.[1];
1970
- if (since !== null && leading !== void 0 && leading <= since) {
1971
- continue;
1972
- }
1973
- const event = BoardEventSchema.parse(JSON.parse(line));
1974
- if (since !== null && event.id <= since) {
1975
- continue;
1976
- }
1977
- events.push(event);
1978
- if (events.length >= limit2) {
2028
+ chosen.push(line);
2029
+ if (chosen.length >= limit2) {
1979
2030
  break;
1980
2031
  }
1981
2032
  }
1982
- return events;
2033
+ const first = chosen[0];
2034
+ const last = chosen.at(-1);
2035
+ if (first === void 0 || last === void 0) {
2036
+ return [];
2037
+ }
2038
+ const span = await this.readBytes(first.start, last.end);
2039
+ return chosen.map((line) => BoardEventSchema.parse(JSON.parse(span.toString("utf8", line.start - first.start, line.end - first.start))));
1983
2040
  }
1984
2041
  /** The id of the newest event, or null for an empty log. */
1985
2042
  async lastId() {
1986
- if (!await exists(this.file)) {
1987
- return null;
2043
+ await this.refresh();
2044
+ return this.lines.at(-1)?.id ?? null;
2045
+ }
2046
+ /** Brings the index up to the file's end, one scan at a time. */
2047
+ refresh() {
2048
+ this.scanning = this.scanning.then(() => this.scan(), () => this.scan());
2049
+ return this.scanning;
2050
+ }
2051
+ async scan() {
2052
+ const info = await stat(this.file).catch((error) => {
2053
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
2054
+ return null;
2055
+ }
2056
+ throw error;
2057
+ });
2058
+ if (info === null || info.ino !== this.inode || info.size < this.indexed) {
2059
+ this.lines = [];
2060
+ this.indexed = 0;
2061
+ this.inode = info?.ino ?? null;
2062
+ }
2063
+ if (info === null || info.size === this.indexed) {
2064
+ return;
2065
+ }
2066
+ const added = await this.readBytes(this.indexed, info.size);
2067
+ let start = 0;
2068
+ for (let at = added.indexOf(NEWLINE); at >= 0; at = added.indexOf(NEWLINE, start)) {
2069
+ const prefix = added.toString("utf8", start, Math.min(at, start + ID_PREFIX_BYTES));
2070
+ if (prefix.trim().length > 0) {
2071
+ const id = LEADING_ID.exec(prefix)?.[1] ?? BoardEventSchema.parse(JSON.parse(added.toString("utf8", start, at))).id;
2072
+ this.lines.push({ id, start: this.indexed + start, end: this.indexed + at });
2073
+ }
2074
+ start = at + 1;
2075
+ }
2076
+ this.indexed += start;
2077
+ }
2078
+ async readBytes(from, to) {
2079
+ const handle = await open(this.file, "r");
2080
+ try {
2081
+ const buffer = Buffer.alloc(to - from);
2082
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
2083
+ return buffer.subarray(0, bytesRead);
2084
+ } finally {
2085
+ await handle.close();
1988
2086
  }
1989
- const lines = (await readFile2(this.file, "utf8")).trimEnd().split("\n");
1990
- const last = lines.at(-1) ?? "";
1991
- return last.length === 0 ? null : BoardEventSchema.parse(JSON.parse(last)).id;
1992
2087
  }
1993
2088
  };
1994
2089
 
1995
2090
  // packages/board-core/dist/files.js
1996
2091
  import { createHash } from "node:crypto";
1997
- import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename as rename2, rm, stat, writeFile as writeFile2 } from "node:fs/promises";
2092
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile2, rename as rename2, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
1998
2093
  import path3 from "node:path";
1999
2094
  var MAX_FILE_BYTES = 8 * 1024 * 1024;
2000
2095
  function isMissing2(error) {
@@ -2016,9 +2111,9 @@ var FileTree = class {
2016
2111
  }
2017
2112
  try {
2018
2113
  const absolute = path3.join(root, ...relative.split("/"));
2019
- const info = await stat(absolute);
2114
+ const info = await stat2(absolute);
2020
2115
  if (info.isFile() && info.size <= MAX_FILE_BYTES) {
2021
- files[relative] = (await readFile3(absolute)).toString("base64");
2116
+ files[relative] = (await readFile2(absolute)).toString("base64");
2022
2117
  }
2023
2118
  } catch (error) {
2024
2119
  if (!isMissing2(error)) {
@@ -2074,17 +2169,18 @@ var FileTree = class {
2074
2169
  }
2075
2170
  async hash(absolute) {
2076
2171
  try {
2077
- const info = await stat(absolute);
2172
+ const info = await stat2(absolute);
2078
2173
  if (info.size > MAX_FILE_BYTES) {
2079
2174
  return null;
2080
2175
  }
2176
+ const executable = (info.mode & 73) !== 0;
2081
2177
  const known = this.hashes.get(absolute);
2082
2178
  if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
2083
- return known.sha;
2179
+ return fileVersion(known.sha, executable);
2084
2180
  }
2085
- const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
2181
+ const sha = createHash("sha256").update(await readFile2(absolute)).digest("hex");
2086
2182
  this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
2087
- return sha;
2183
+ return fileVersion(sha, executable);
2088
2184
  } catch (error) {
2089
2185
  if (isMissing2(error)) {
2090
2186
  return null;
@@ -2095,7 +2191,7 @@ var FileTree = class {
2095
2191
  };
2096
2192
 
2097
2193
  // packages/board-core/dist/homes.js
2098
- import { chmod, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2194
+ import { chmod, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
2099
2195
  import path4 from "node:path";
2100
2196
  import { execa } from "execa";
2101
2197
  var HOME_GITIGNORE = [
@@ -2239,7 +2335,7 @@ var HomeRepos = class {
2239
2335
  await writeFile3(ignore, IGNORE_FILE, "utf8");
2240
2336
  await git(["add", "--all"], home);
2241
2337
  await git(["commit", "--quiet", "--allow-empty", "-m", "home: created by the board"], home);
2242
- } else if (await readFile4(ignore, "utf8").catch(() => "") !== IGNORE_FILE) {
2338
+ } else if (await readFile3(ignore, "utf8").catch(() => "") !== IGNORE_FILE) {
2243
2339
  await writeFile3(ignore, IGNORE_FILE, "utf8");
2244
2340
  await git(["add", ".gitignore"], home);
2245
2341
  await git(["commit", "--quiet", "-m", "home: the board's ignore file"], home);
@@ -2335,6 +2431,55 @@ var HomeRepos = class {
2335
2431
  }
2336
2432
  return files;
2337
2433
  }
2434
+ /**
2435
+ * The files of a folder in a home's newest commit, by path within the folder, with the commit;
2436
+ * conflict copies and anything but a regular file are left out. Null when the home is not a
2437
+ * repository yet.
2438
+ */
2439
+ async folder(home, folder) {
2440
+ if (!await exists(path4.join(home, ".git"))) {
2441
+ return null;
2442
+ }
2443
+ const commit = (await read(home, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
2444
+ const prefix = `${folder}/`;
2445
+ const listed = await read(home, [
2446
+ "ls-tree",
2447
+ "-r",
2448
+ "-z",
2449
+ "--long",
2450
+ "--full-tree",
2451
+ commit,
2452
+ prefix
2453
+ ]);
2454
+ const files = [];
2455
+ for (const entry of listed.split("\0")) {
2456
+ const match = /^(100644|100755) blob ([0-9a-f]+) +(\d+)\t(.+)$/.exec(entry);
2457
+ const [, mode = "", object = "", size = "0", file = ""] = match ?? [];
2458
+ if (match === null || !file.startsWith(prefix) || !isSafeRelativePath(file) || conflictCopyOf(file) !== null) {
2459
+ continue;
2460
+ }
2461
+ files.push({
2462
+ path: file.slice(prefix.length),
2463
+ object,
2464
+ size: Number.parseInt(size, 10),
2465
+ executable: mode === "100755"
2466
+ });
2467
+ }
2468
+ return { commit, files };
2469
+ }
2470
+ /** A blob of a home's repository, as `folder` names it. */
2471
+ async blob(home, object) {
2472
+ const result = await execa("git", ["cat-file", "blob", object], {
2473
+ cwd: home,
2474
+ encoding: "buffer",
2475
+ stripFinalNewline: false,
2476
+ reject: false
2477
+ });
2478
+ if (result.exitCode !== 0) {
2479
+ throw new Error(`git cat-file failed: ${Buffer.from(result.stderr).toString().trim()}`);
2480
+ }
2481
+ return Buffer.from(result.stdout);
2482
+ }
2338
2483
  /** The conflict copies a merge added, against its first parent. */
2339
2484
  async keptCopies(home, parent, commit) {
2340
2485
  const added = await read(home, [
@@ -2750,6 +2895,10 @@ var BoardPaths = class {
2750
2895
  proposal(id) {
2751
2896
  return path5.join(this.proposals(), `${id}.md`);
2752
2897
  }
2898
+ /** A skill proposal's folder, as the board took it from its proposer's home. */
2899
+ proposalFiles(id) {
2900
+ return path5.join(this.proposals(), id);
2901
+ }
2753
2902
  decisions() {
2754
2903
  return path5.join(this.society, "decisions");
2755
2904
  }
@@ -3001,6 +3150,20 @@ var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
3001
3150
  function withoutMentions(text) {
3002
3151
  return text.replaceAll(MENTION_PATTERN, "$1$2");
3003
3152
  }
3153
+ var SKILL_TEXT_LIMIT_CHARS = 1e5;
3154
+ function skillFolderOf(proposal) {
3155
+ if (proposal.kind !== "skill") {
3156
+ return null;
3157
+ }
3158
+ const parsed = SkillProposalSchema.safeParse(proposal.charter);
3159
+ return parsed.success && parsed.data.body === void 0 ? parsed.data.name : null;
3160
+ }
3161
+ function proposedFrom(origin, where) {
3162
+ return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel;
3163
+ }
3164
+ function megabytes(bytes) {
3165
+ return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
3166
+ }
3004
3167
  var CURATING_ROLES = [USER_ROLE, "steward"];
3005
3168
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
3006
3169
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -3091,8 +3254,8 @@ function restage(existing, edit) {
3091
3254
  };
3092
3255
  }
3093
3256
  function reopenStage(stage) {
3094
- const { completedBy: _by, completedAt: _at, ...open } = stage;
3095
- return open;
3257
+ const { completedBy: _by, completedAt: _at, ...open2 } = stage;
3258
+ return open2;
3096
3259
  }
3097
3260
  function gateChanges(before, after) {
3098
3261
  const changes = [];
@@ -3487,7 +3650,7 @@ var Board = class _Board {
3487
3650
  const task = { ...TaskFrontmatterSchema.parse(raw.data), body: raw.body };
3488
3651
  if ((legacy === "open" || legacy === "closed") && !await exists(this.paths.threadFile(threads, task.id))) {
3489
3652
  const ended = task.status === "done" || task.status === "abandoned";
3490
- const open = legacy === "open" && !ended;
3653
+ const open2 = legacy === "open" && !ended;
3491
3654
  const openedBy = opened.get(task.id);
3492
3655
  const closedBy = closed.get(task.id);
3493
3656
  await this.writeThread(threads, {
@@ -3495,10 +3658,10 @@ var Board = class _Board {
3495
3658
  channel: channelRef(slug, "general"),
3496
3659
  title: task.title,
3497
3660
  subject: { kind: "task", id: task.id },
3498
- state: open ? "open" : "closed",
3661
+ state: open2 ? "open" : "closed",
3499
3662
  openedBy: openedBy?.actor ?? task.createdBy,
3500
3663
  openedAt: openedBy?.ts ?? task.createdAt,
3501
- ...open ? {} : {
3664
+ ...open2 ? {} : {
3502
3665
  closedBy: closedBy?.actor ?? SYSTEM_ACTOR.name,
3503
3666
  closedAt: closedBy?.ts ?? this.now().toISOString()
3504
3667
  },
@@ -3561,7 +3724,7 @@ var Board = class _Board {
3561
3724
  for (const agent of await this.listAgents()) {
3562
3725
  const file = this.paths.agentCursors(agent.name);
3563
3726
  if (await exists(file)) {
3564
- const legacy = Legacy.safeParse(JSON.parse(await readFile5(file, "utf8")));
3727
+ const legacy = Legacy.safeParse(JSON.parse(await readFile4(file, "utf8")));
3565
3728
  if (legacy.success) {
3566
3729
  await writeJson(file, { digest: legacy.data.inbox });
3567
3730
  }
@@ -3900,6 +4063,10 @@ ${charter.purpose}
3900
4063
  async latestEventId() {
3901
4064
  return this.events.lastId();
3902
4065
  }
4066
+ /** Calls `listener` after each event the board appends, until the returned function is called. */
4067
+ onEventAppended(listener) {
4068
+ return this.events.onAppend(listener);
4069
+ }
3903
4070
  /** The roster as projected: every citizen with identity, reach, availability, and profile. */
3904
4071
  async listMembers() {
3905
4072
  const members = [];
@@ -3923,22 +4090,41 @@ ${charter.purpose}
3923
4090
  const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
3924
4091
  const topics = [];
3925
4092
  for (const file of await listFiles(dir)) {
3926
- const doc = await readMarkdown(path6.join(dir, file), z13.record(z13.string(), z13.unknown()));
3927
- const parsed = KnowledgeSchema.safeParse({
3928
- ...doc.data,
3929
- project,
3930
- topic: file.replace(/\.md$/, "")
3931
- });
3932
- const data = parsed.success ? parsed.data : {
3933
- topic: file.replace(/\.md$/, ""),
3934
- project,
3935
- updatedBy: SYSTEM_ACTOR.name,
3936
- updatedAt: (await stat2(path6.join(dir, file))).mtime.toISOString()
3937
- };
3938
- topics.push({ ...data, body: doc.body });
4093
+ topics.push(await this.knowledgeAt(path6.join(dir, file), project, file.replace(/\.md$/, "")));
3939
4094
  }
3940
4095
  return topics;
3941
4096
  }
4097
+ knowledgeFile(project, topic) {
4098
+ return project === null ? this.paths.societyKnowledgeFile(topic) : this.paths.projectKnowledgeFile(project, topic);
4099
+ }
4100
+ /**
4101
+ * Read loosely, as a dashboard is: a topic written before revisions counts as revision 0, and an
4102
+ * author or a time that does not parse is the board's at the file's mtime.
4103
+ */
4104
+ async knowledgeAt(file, project, topic) {
4105
+ const { data, body } = await readLooseMarkdown(file);
4106
+ const revision = z13.number().int().min(0).safeParse(data["revision"]);
4107
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
4108
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
4109
+ return {
4110
+ topic,
4111
+ project,
4112
+ revision: revision.success ? revision.data : 0,
4113
+ updatedBy: updatedBy.success ? updatedBy.data : SYSTEM_ACTOR.name,
4114
+ updatedAt: updatedAt.success ? updatedAt.data : (await stat3(file)).mtime.toISOString(),
4115
+ body
4116
+ };
4117
+ }
4118
+ /** The highest revision among a topic's removed copies, 0 when it has none. */
4119
+ async removedRevision(project, topic) {
4120
+ const aside = this.paths.removedKnowledge(project ?? SOCIETY_SCOPE);
4121
+ let highest = 0;
4122
+ for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
4123
+ const copy = await this.knowledgeAt(path6.join(aside, file), project, topic);
4124
+ highest = Math.max(highest, copy.revision);
4125
+ }
4126
+ return highest;
4127
+ }
3942
4128
  /** Skills promoted to the society: every citizen may read them. */
3943
4129
  async listSocietySkills() {
3944
4130
  return this.listSkillsIn(this.paths.societySkills(), "society");
@@ -4027,6 +4213,28 @@ ${charter.purpose}
4027
4213
  async readThread(id) {
4028
4214
  return (await this.findThread(id)).thread;
4029
4215
  }
4216
+ /**
4217
+ * Where each of the messages named is and who wrote it, for text that names them by id; an id
4218
+ * that names no message is left out. Message files start with their ids, so only the files
4219
+ * asked for are read.
4220
+ */
4221
+ async findMessages(ids) {
4222
+ const wanted = new Set(ids);
4223
+ const found = [];
4224
+ for await (const dir of this.messageDirs()) {
4225
+ for (const file of await listFiles(dir)) {
4226
+ if (!wanted.delete(file.slice(0, 26))) {
4227
+ continue;
4228
+ }
4229
+ const { data } = await readMarkdown(path6.join(dir, file), MessageFrontmatterSchema);
4230
+ found.push(MessageRefSchema.parse(data));
4231
+ }
4232
+ if (wanted.size === 0) {
4233
+ break;
4234
+ }
4235
+ }
4236
+ return found;
4237
+ }
4030
4238
  /**
4031
4239
  * What citizens have asked the user and the user has not answered, oldest first: every message
4032
4240
  * by someone else that mentions the user, in an open thread or a channel the society or an
@@ -4528,12 +4736,12 @@ ${DASHBOARD_LINE}
4528
4736
  continue;
4529
4737
  }
4530
4738
  const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
4531
- let open = named === void 0 ? void 0 : openChannels.get(message.channel);
4532
- if (named !== void 0 && open === void 0) {
4533
- open = await this.channelOpen(message.channel);
4534
- openChannels.set(message.channel, open);
4739
+ let open2 = named === void 0 ? void 0 : openChannels.get(message.channel);
4740
+ if (named !== void 0 && open2 === void 0) {
4741
+ open2 = await this.channelOpen(message.channel);
4742
+ openChannels.set(message.channel, open2);
4535
4743
  }
4536
- const part = thread ?? (open === true ? `#${named}` : null);
4744
+ const part = thread ?? (open2 === true ? `#${named}` : null);
4537
4745
  if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
4538
4746
  continue;
4539
4747
  }
@@ -4598,7 +4806,7 @@ ${task.body}`;
4598
4806
  }
4599
4807
  }
4600
4808
  for (const file of await listFiles(this.paths.projectKnowledge(project))) {
4601
- const content = await readFile5(path6.join(this.paths.projectKnowledge(project), file), "utf8");
4809
+ const content = await readFile4(path6.join(this.paths.projectKnowledge(project), file), "utf8");
4602
4810
  if (content.toLowerCase().includes(needle)) {
4603
4811
  hits.push({
4604
4812
  kind: "knowledge",
@@ -4636,7 +4844,7 @@ ${task.body}`;
4636
4844
  });
4637
4845
  }
4638
4846
  for (const entry of shared) {
4639
- const content = await readFile5(entry.file, "utf8");
4847
+ const content = await readFile4(entry.file, "utf8");
4640
4848
  if (content.toLowerCase().includes(needle)) {
4641
4849
  hits.push({
4642
4850
  kind: entry.kind,
@@ -4652,26 +4860,42 @@ ${task.body}`;
4652
4860
  }
4653
4861
  /**
4654
4862
  * Writes a topic: a project's by its members, the society's by the curating roles. The note in
4655
- * the matching general channel carries no mention, so nobody is woken.
4863
+ * the matching general channel carries no mention, so nobody is woken. A turn reads a topic from
4864
+ * the copy of the projection pulled when it started, so a revision other than the current one, 0
4865
+ * for a topic not there, means someone wrote or removed it since; the refusal carries the current
4866
+ * text, which that copy lacks.
4656
4867
  */
4657
4868
  async writeKnowledge(actor, input) {
4658
4869
  const args = VerbInputs.write_knowledge.parse(input);
4659
4870
  await this.authorize(actor, "write_knowledge");
4660
4871
  return this.mutex.run(async () => {
4661
4872
  await this.assertMayCurate(actor, args.project);
4873
+ const file = this.knowledgeFile(args.project, args.topic);
4874
+ const current = await exists(file) ? await this.knowledgeAt(file, args.project, args.topic) : null;
4875
+ const place = args.project ?? "the society";
4876
+ if (current === null && args.revision !== 0) {
4877
+ throw new BoardError("INVALID_STATE", `${place} has no knowledge topic ${args.topic}, so it has no revision ${args.revision}: it was removed since you read it, or never written. Write it with revision 0 to start it anew.`);
4878
+ }
4879
+ if (current !== null && args.revision !== current.revision) {
4880
+ throw new BoardError("INVALID_STATE", `the knowledge topic ${args.topic} of ${place} is at revision ${current.revision}, updated by ${current.updatedBy} at ${current.updatedAt}, not ${args.revision}. Merge your changes into its current text, below, and write again with revision ${current.revision}.
4881
+
4882
+ ${current.body}`);
4883
+ }
4884
+ const base = current?.revision ?? await this.removedRevision(args.project, args.topic);
4662
4885
  const data = KnowledgeSchema.parse({
4663
4886
  topic: args.topic,
4664
4887
  project: args.project,
4888
+ revision: base + 1,
4665
4889
  updatedBy: actor.name,
4666
4890
  updatedAt: this.now().toISOString()
4667
4891
  });
4668
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4669
- const replaced = await exists(file);
4892
+ const replaced = current !== null;
4670
4893
  await writeMarkdown(file, data, args.body);
4671
4894
  await this.events.append("knowledge.written", actor.name, {
4672
4895
  topic: args.topic,
4673
4896
  project: args.project,
4674
- replaced
4897
+ replaced,
4898
+ revision: data.revision
4675
4899
  });
4676
4900
  const where = args.project === null ? "general" : channelRef(args.project, "general");
4677
4901
  await this.appendMessage(actor.name, where, `Knowledge ${replaced ? "updated" : "written"}: ${args.topic}. It is under knowledge/${args.topic}.md${args.project === null ? " of the society" : ""}.`, {
@@ -4694,7 +4918,7 @@ ${task.body}`;
4694
4918
  await this.authorize(actor, "remove_knowledge");
4695
4919
  return this.mutex.run(async () => {
4696
4920
  await this.assertMayCurate(actor, args.project);
4697
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4921
+ const file = this.knowledgeFile(args.project, args.topic);
4698
4922
  if (!await exists(file)) {
4699
4923
  throw new BoardError("NOT_FOUND", `${args.project ?? "the society"} has no knowledge topic ${args.topic}`);
4700
4924
  }
@@ -5745,7 +5969,7 @@ ${args.summary}`);
5745
5969
  throw new BoardError("VALIDATION", `invalid ${args.kind} charter: ${parsed.error.message}`);
5746
5970
  }
5747
5971
  return this.mutex.run(async () => {
5748
- const proposal = {
5972
+ let proposal = {
5749
5973
  ...ProposalFrontmatterSchema.parse({
5750
5974
  id: this.newId(),
5751
5975
  kind: args.kind,
@@ -5764,6 +5988,14 @@ ${args.summary}`);
5764
5988
  body: args.rationale
5765
5989
  };
5766
5990
  await this.validateProvision(proposal.kind, proposal.charter);
5991
+ const skill = skillFolderOf(proposal);
5992
+ if (skill !== null && proposal.filedFrom === void 0) {
5993
+ const taken = await this.takeSkillFolderUnlocked(proposal, skill);
5994
+ if (typeof taken === "string") {
5995
+ throw new BoardError("VALIDATION", taken);
5996
+ }
5997
+ proposal = { ...proposal, skillFiles: taken };
5998
+ }
5767
5999
  await this.writeProposal(proposal);
5768
6000
  await this.events.append("proposal.created", actor.name, {
5769
6001
  proposalId: proposal.id,
@@ -6101,7 +6333,7 @@ ${rationale}`}`);
6101
6333
  throw new BoardError("NOT_FOUND", `no transcript of turn ${turnId} for ${agent}`);
6102
6334
  }
6103
6335
  const entries = [];
6104
- for (const line of (await readFile5(file, "utf8")).split("\n")) {
6336
+ for (const line of (await readFile4(file, "utf8")).split("\n")) {
6105
6337
  if (line.trim() === "")
6106
6338
  continue;
6107
6339
  const parsed = TranscriptEntrySchema.safeParse(JSON.parse(line));
@@ -6580,7 +6812,7 @@ ${rationale}`}`);
6580
6812
  }
6581
6813
  const file = entry.isFile() ? conflictCopyOf(relative) : null;
6582
6814
  if (file !== null) {
6583
- const since = await this.homes.changedAt(home, relative) ?? (await stat2(path6.join(home, relative)).catch(() => null))?.mtime.toISOString();
6815
+ const since = await this.homes.changedAt(home, relative) ?? (await stat3(path6.join(home, relative)).catch(() => null))?.mtime.toISOString();
6584
6816
  if (since !== void 0) {
6585
6817
  found.push({ path: relative, file, since });
6586
6818
  }
@@ -6980,7 +7212,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6980
7212
  }
6981
7213
  case "skill": {
6982
7214
  const skill = SkillProposalSchema.parse(proposal.charter);
6983
- const replaced = await this.promoteSkillUnlocked(by, proposal.proposedBy, skill, meta);
7215
+ const replaced = await this.promoteSkillUnlocked(by, proposal, skill, meta);
6984
7216
  return { skill: skill.name, replaced };
6985
7217
  }
6986
7218
  case "archive": {
@@ -6994,26 +7226,180 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6994
7226
  return void 0;
6995
7227
  }
6996
7228
  }
6997
- /** Writes a promoted skill under the society's skills, where every citizen's skills index lists it. */
6998
- async promoteSkillUnlocked(by, proposedBy, skill, meta) {
7229
+ /**
7230
+ * Publishes a promoted skill under the society's skills, where every citizen's skills index lists
7231
+ * it: the folder the board took from its proposer's home, or the text proposed alone. A
7232
+ * replacement is the whole skill, so files the new version dropped go with the old one.
7233
+ */
7234
+ async promoteSkillUnlocked(by, proposal, skill, meta) {
7235
+ const dir = this.paths.societySkill(skill.name);
6999
7236
  const file = this.paths.societySkillFile(skill.name);
7000
7237
  const replaced = await exists(file);
7001
- await ensureDir(this.paths.societySkill(skill.name));
7238
+ const taken = proposal.skillFiles;
7239
+ const staged = path6.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7240
+ if (skill.body === void 0 && (taken === void 0 || !await exists(staged))) {
7241
+ throw new BoardError("INVALID_STATE", `proposal ${proposal.id} holds no skill files`);
7242
+ }
7243
+ await rm2(dir, { recursive: true, force: true });
7244
+ if (skill.body === void 0) {
7245
+ await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
7246
+ } else {
7247
+ await ensureDir(dir);
7248
+ }
7249
+ const body = skill.body ?? (await readLooseMarkdown(file)).body;
7002
7250
  await writeMarkdown(file, {
7003
7251
  name: skill.name,
7004
7252
  description: skill.summary,
7005
- metadata: { proposedBy, promotedBy: by, promotedAt: this.now().toISOString() }
7006
- }, skill.body);
7253
+ metadata: {
7254
+ proposedBy: proposal.proposedBy,
7255
+ promotedBy: by,
7256
+ promotedAt: this.now().toISOString(),
7257
+ ...taken === void 0 ? {} : { commit: taken.commit }
7258
+ }
7259
+ }, body);
7007
7260
  await this.events.append("skill.promoted", by, {
7008
7261
  name: skill.name,
7009
7262
  summary: skill.summary,
7010
- proposedBy,
7263
+ proposedBy: proposal.proposedBy,
7011
7264
  replaced,
7265
+ ...taken === void 0 ? {} : { files: taken.files.length },
7012
7266
  ...meta
7013
7267
  });
7014
7268
  await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${sentence(skill.summary)}. Every citizen's skills index now lists it.`);
7015
7269
  return replaced;
7016
7270
  }
7271
+ /**
7272
+ * Copies a skill's folder from its proposer's home, as the home's newest commit holds it, into the
7273
+ * proposal's folder in the projection, where its deciders read it and from where approval
7274
+ * publishes it. Returns what was taken, or why the home could not give it.
7275
+ */
7276
+ async takeSkillFolderUnlocked(proposal, name) {
7277
+ const home = this.paths.agent(proposal.proposedBy);
7278
+ const folder = `skills/${name}`;
7279
+ const where = `${folder} in ${proposal.proposedBy}'s home`;
7280
+ const taken = await this.homes.folder(home, folder);
7281
+ if (taken === null || !taken.files.some((entry) => entry.path === "SKILL.md")) {
7282
+ return `no ${folder}/SKILL.md in ${proposal.proposedBy}'s home`;
7283
+ }
7284
+ if (taken.files.length > SKILL_FILES_LIMIT) {
7285
+ return `${where} holds ${taken.files.length} files, more than the ${SKILL_FILES_LIMIT} a society skill may`;
7286
+ }
7287
+ const bytes = taken.files.reduce((sum, entry) => sum + entry.size, 0);
7288
+ if (bytes > SKILL_BYTES_LIMIT) {
7289
+ return `${where} holds ${megabytes(bytes)} MB, more than the ${megabytes(SKILL_BYTES_LIMIT)} MB a society skill may`;
7290
+ }
7291
+ const contents = /* @__PURE__ */ new Map();
7292
+ for (const entry of taken.files) {
7293
+ contents.set(entry.path, await this.homes.blob(home, entry.object));
7294
+ }
7295
+ const manual = parseLooseMarkdown(contents.get("SKILL.md")?.toString("utf8") ?? "");
7296
+ if (manual.error !== null) {
7297
+ return `the frontmatter of ${folder}/SKILL.md in ${proposal.proposedBy}'s home does not parse (${manual.error})`;
7298
+ }
7299
+ const dir = this.paths.proposalFiles(proposal.id);
7300
+ await rm2(dir, { recursive: true, force: true });
7301
+ for (const entry of taken.files) {
7302
+ const target = path6.join(dir, ...entry.path.split("/"));
7303
+ await ensureDir(path6.dirname(target));
7304
+ await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
7305
+ await chmod2(target, entry.executable ? 493 : 420);
7306
+ }
7307
+ return {
7308
+ commit: taken.commit,
7309
+ takenAt: this.now().toISOString(),
7310
+ files: taken.files.map((entry) => ({
7311
+ path: entry.path,
7312
+ size: entry.size,
7313
+ executable: entry.executable
7314
+ }))
7315
+ };
7316
+ }
7317
+ /** Keeps what was taken of a skill proposal's folder on the proposal, and logs it. */
7318
+ async recordSkillFiles(proposal, taken) {
7319
+ const next = { ...proposal, skillFiles: taken };
7320
+ await this.writeProposal(next);
7321
+ await this.events.append("proposal.files_taken", SYSTEM_ACTOR.name, {
7322
+ proposalId: proposal.id,
7323
+ kind: proposal.kind,
7324
+ proposedBy: proposal.proposedBy,
7325
+ commit: taken.commit,
7326
+ files: taken.files.length
7327
+ });
7328
+ return next;
7329
+ }
7330
+ /**
7331
+ * A skill proposal with its folder, for approval: taken now when the turn that filed it ended
7332
+ * without it, as when the server restarted before the turn's end was recorded, and refused while
7333
+ * that turn is still in flight, since the folder reaches the board only when it ends.
7334
+ */
7335
+ async skillFilesForApproval(proposal) {
7336
+ const name = skillFolderOf(proposal);
7337
+ if (name === null || proposal.skillFiles !== void 0) {
7338
+ return proposal;
7339
+ }
7340
+ const cannot = `proposal ${proposal.id} cannot be approved`;
7341
+ const filing = this.turnsInFlight().some((turn) => turn.agent === proposal.proposedBy && proposedFrom(proposal.filedFrom, turn));
7342
+ if (filing) {
7343
+ throw new BoardError("INVALID_STATE", `${cannot} until ${proposal.proposedBy}'s turn that proposed it ends, when the board takes the skill's folder from its home`);
7344
+ }
7345
+ const taken = await this.takeSkillFolderUnlocked(proposal, name);
7346
+ if (typeof taken === "string") {
7347
+ throw new BoardError("INVALID_STATE", `${cannot}: ${taken}; reject it with that reason`);
7348
+ }
7349
+ return this.recordSkillFiles(proposal, taken);
7350
+ }
7351
+ /**
7352
+ * Takes the folders of the skills a citizen proposed from a conversation, once the turn there has
7353
+ * ended and its runner has pushed the citizen's home. A proposal whose folder the home does not
7354
+ * give is rejected by the board with the reason, which wakes the proposer to put it right; while
7355
+ * someone is in a turn in the proposal's thread, it stays as it is, and approval meets the reason.
7356
+ */
7357
+ async takeSkillFiles(turn) {
7358
+ const where = {
7359
+ scope: turn.project,
7360
+ ...turn.thread === void 0 ? {} : { thread: turn.thread },
7361
+ ...turn.channel === void 0 ? {} : { channel: turn.channel }
7362
+ };
7363
+ await this.mutex.run(async () => {
7364
+ for (const proposal of await this.listProposals()) {
7365
+ const name = skillFolderOf(proposal);
7366
+ if (name === null || proposal.status !== "proposed" || proposal.skillFiles !== void 0 || proposal.proposedBy !== turn.agent || !proposedFrom(proposal.filedFrom, where)) {
7367
+ continue;
7368
+ }
7369
+ const taken = await this.takeSkillFolderUnlocked(proposal, name);
7370
+ if (typeof taken !== "string") {
7371
+ await this.recordSkillFiles(proposal, taken);
7372
+ continue;
7373
+ }
7374
+ try {
7375
+ await this.decideUnlocked(SYSTEM_ACTOR, proposal.id, "rejected", `${taken} when the turn that proposed it ended; propose it again once the folder is there`);
7376
+ } catch (error) {
7377
+ if (!(error instanceof BoardError)) {
7378
+ throw error;
7379
+ }
7380
+ }
7381
+ }
7382
+ });
7383
+ }
7384
+ /** A skill proposal's files with their text, as the board took them; none before then. */
7385
+ async readSkillFiles(proposalId) {
7386
+ const proposal = await this.readProposal(proposalId);
7387
+ const dir = this.paths.proposalFiles(proposal.id);
7388
+ const files = [];
7389
+ for (const entry of proposal.skillFiles?.files ?? []) {
7390
+ if (!isSafeRelativePath(entry.path)) {
7391
+ continue;
7392
+ }
7393
+ const bytes = await readFile4(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
7394
+ const text = bytes === null || bytes.includes(0) ? null : bytes.toString("utf8");
7395
+ files.push({
7396
+ ...entry,
7397
+ text: text === null ? null : text.slice(0, SKILL_TEXT_LIMIT_CHARS),
7398
+ truncated: text !== null && text.length > SKILL_TEXT_LIMIT_CHARS
7399
+ });
7400
+ }
7401
+ return files;
7402
+ }
7017
7403
  async validateAddAgent(input) {
7018
7404
  if (await exists(this.paths.agentFile(input.name))) {
7019
7405
  throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
@@ -7525,67 +7911,70 @@ ${charter.purpose}
7525
7911
  * retired. Validation runs before any write, so an impossible provision fails the decision.
7526
7912
  */
7527
7913
  async decide(actor, proposalId, outcome, reason) {
7528
- return this.mutex.run(async () => {
7529
- const proposal = await this.readProposal(proposalId);
7530
- if (proposal.status !== "proposed") {
7531
- throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${proposal.status}`);
7532
- }
7533
- if (proposal.proposedBy === actor.name) {
7534
- throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7535
- }
7536
- const approvers = ROLE_KIND_APPROVERS[proposal.kind];
7537
- if (!approvers.includes(actor.role)) {
7538
- throw new BoardError("FORBIDDEN", `a ${proposal.kind} proposal requires one of: ${approvers.join(", ")}`);
7539
- }
7540
- if (outcome === "approved") {
7541
- await this.validateProvision(proposal.kind, proposal.charter);
7542
- }
7543
- this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
7544
- if (outcome === "approved" && proposal.kind === "archive") {
7545
- await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
7546
- }
7547
- const ts = this.now().toISOString();
7548
- const decision = DecisionSchema.parse({
7549
- id: this.newId(),
7550
- proposalId,
7551
- decidedBy: actor.name,
7552
- outcome,
7553
- ...reason === void 0 ? {} : { reason },
7554
- ts
7555
- });
7556
- await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7557
- const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal) : void 0;
7558
- const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7559
- await this.writeProposal({
7560
- ...proposal,
7561
- status,
7562
- decidedBy: actor.name,
7563
- decidedAt: ts,
7564
- ...reason === void 0 ? {} : { reason },
7565
- ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7566
- });
7567
- await this.events.append("proposal.decided", actor.name, {
7914
+ return this.mutex.run(() => this.decideUnlocked(actor, proposalId, outcome, reason));
7915
+ }
7916
+ async decideUnlocked(actor, proposalId, outcome, reason) {
7917
+ const filed = await this.readProposal(proposalId);
7918
+ if (filed.status !== "proposed") {
7919
+ throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${filed.status}`);
7920
+ }
7921
+ if (filed.proposedBy === actor.name) {
7922
+ throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7923
+ }
7924
+ const approvers = ROLE_KIND_APPROVERS[filed.kind];
7925
+ if (!approvers.includes(actor.role)) {
7926
+ throw new BoardError("FORBIDDEN", `a ${filed.kind} proposal requires one of: ${approvers.join(", ")}`);
7927
+ }
7928
+ if (outcome === "approved") {
7929
+ await this.validateProvision(filed.kind, filed.charter);
7930
+ }
7931
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
7932
+ if (outcome === "approved" && filed.kind === "archive") {
7933
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(filed.charter).project);
7934
+ }
7935
+ const proposal = outcome === "approved" ? await this.skillFilesForApproval(filed) : filed;
7936
+ const ts = this.now().toISOString();
7937
+ const decision = DecisionSchema.parse({
7938
+ id: this.newId(),
7939
+ proposalId,
7940
+ decidedBy: actor.name,
7941
+ outcome,
7942
+ ...reason === void 0 ? {} : { reason },
7943
+ ts
7944
+ });
7945
+ await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7946
+ const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal) : void 0;
7947
+ const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7948
+ await this.writeProposal({
7949
+ ...proposal,
7950
+ status,
7951
+ decidedBy: actor.name,
7952
+ decidedAt: ts,
7953
+ ...reason === void 0 ? {} : { reason },
7954
+ ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7955
+ });
7956
+ await this.events.append("proposal.decided", actor.name, {
7957
+ proposalId,
7958
+ outcome,
7959
+ kind: proposal.kind,
7960
+ proposedBy: proposal.proposedBy,
7961
+ ...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
7962
+ });
7963
+ if (provision !== void 0) {
7964
+ await this.events.append("proposal.provisioned", actor.name, {
7568
7965
  proposalId,
7569
- outcome,
7570
7966
  kind: proposal.kind,
7571
- proposedBy: proposal.proposedBy,
7572
- ...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
7967
+ ...provision
7573
7968
  });
7574
- if (provision !== void 0) {
7575
- await this.events.append("proposal.provisioned", actor.name, {
7576
- proposalId,
7577
- kind: proposal.kind,
7578
- ...provision
7579
- });
7580
- }
7581
- const verdict = outcome === "approved" ? "Approved" : "Rejected";
7582
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7583
- const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7584
- const thread = await this.proposalThreadUnlocked(actor.name, proposal);
7585
- await this.appendThreadMessage(actor.name, thread.id, `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`, { step: { action: outcome } });
7586
- await this.endThreadOf(proposalId, actor.name, outcome);
7587
- return decision;
7588
- });
7969
+ }
7970
+ const verdict = outcome === "approved" ? "Approved" : "Rejected";
7971
+ const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7972
+ const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7973
+ const thread = await this.proposalThreadUnlocked(actor.name, proposal);
7974
+ const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`;
7975
+ await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
7976
+ await this.endThreadOf(proposalId, actor.name, outcome);
7977
+ return decision;
7589
7978
  }
7590
7979
  /** The opener, the people of its subject, and anyone who posted in it; mentions reach the rest. */
7591
7980
  async participatesInThread(actor, thread, threads) {
@@ -7700,6 +8089,23 @@ ${charter.purpose}
7700
8089
  yield* this.threadMessagesIn(this.paths.threads(slug), since);
7701
8090
  }
7702
8091
  }
8092
+ /** Every folder that holds messages: each channel's and each thread's, in the society and every project. */
8093
+ async *messageDirs() {
8094
+ for (const channel of await listDirs(this.paths.societyChannels())) {
8095
+ yield this.paths.societyChannel(channel);
8096
+ }
8097
+ for (const id of await listDirs(this.paths.societyThreads())) {
8098
+ yield this.paths.threadMessages(this.paths.societyThreads(), id);
8099
+ }
8100
+ for (const slug of await listDirs(this.paths.projects())) {
8101
+ for (const channel of await listDirs(this.paths.projectChannels(slug))) {
8102
+ yield this.paths.projectChannel(slug, channel);
8103
+ }
8104
+ for (const id of await listDirs(this.paths.threads(slug))) {
8105
+ yield this.paths.threadMessages(this.paths.threads(slug), id);
8106
+ }
8107
+ }
8108
+ }
7703
8109
  async *threadMessagesIn(threads, since) {
7704
8110
  for (const id of await listDirs(threads)) {
7705
8111
  yield* await this.readMessagesIn(this.paths.threadMessages(threads, id), since);
@@ -9336,7 +9742,8 @@ var TURN_CONTRACT = [
9336
9742
  "- 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.",
9337
9743
  "- 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.",
9338
9744
  "- 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.",
9339
- '- 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. Propose it with kind "skill" when the whole society would use it.',
9745
+ '- 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.',
9746
+ "- 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.",
9340
9747
  "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill, so a warm session restarts with the new instructions.",
9341
9748
  "- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
9342
9749
  "- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
@@ -9359,7 +9766,7 @@ var GOVERNANCE = [
9359
9766
  ' - retirement: {"agent", "reason"}',
9360
9767
  ' - 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',
9361
9768
  ' - reallocation: {"description"}',
9362
- ' - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, 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',
9769
+ ' - 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',
9363
9770
  "- 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.",
9364
9771
  "- 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.",
9365
9772
  "- 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."
@@ -9387,7 +9794,7 @@ function renderSkillsIndex(skills) {
9387
9794
  ];
9388
9795
  }
9389
9796
  return [
9390
- "Read a skill's file when its summary matches the work at hand; do not load them all.",
9797
+ "Read a skill's file when its summary matches the work at hand; do not load them all. Paths in it are relative to its folder, which holds whatever else it uses.",
9391
9798
  "",
9392
9799
  ...skills.map((skill) => `- ${skill.name} (${skill.scope === "own" ? "yours" : "society"}): ${skill.summary.trim().length === 0 ? "no summary" : skill.summary.trim().replace(/\.+$/, "")}. File: ${skill.path}`)
9393
9800
  ];
@@ -9412,7 +9819,7 @@ var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answ
9412
9819
  var RECONCILE = "- Reconcile first: merge each copy listed under Edits to reconcile in your home into the file beside it, then delete the copy.";
9413
9820
  var REFLECTION_STEPS = [
9414
9821
  "- Consolidate memory/core.md: keep it to lessons that still hold, about sixty lines at most, and move detail worth keeping to memory/<topic>.md in your home, which the board's search covers for you alone.",
9415
- '- Extract a skill: a procedure you have followed twice goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps. Propose it with kind "skill" when other citizens would use it.',
9822
+ '- Extract a skill: a procedure you have followed twice goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps, with any script or reference it uses in the same folder. Propose it with kind "skill" when other citizens would use it.',
9416
9823
  "- Refresh profile.md: one paragraph on what you do well and what to send your way; the roster the front desk routes with is built from it.",
9417
9824
  "- Share: a durable fact about this scope's codebase or process goes through write_knowledge; a norm the whole society should follow goes to the steward as a plain post in general.",
9418
9825
  "- Report memoryUpdated: true in the status object when core.md or a skill changed."
@@ -9627,7 +10034,7 @@ function buildTurnPrompt(input) {
9627
10034
  } else {
9628
10035
  lines.push(`Topics under ${knowledge.dir}; open one with your file tools when it is relevant.`, "");
9629
10036
  for (const topic of knowledge.topics) {
9630
- lines.push(`- ${topic.topic}: updated by ${topic.updatedBy} at ${topic.updatedAt}`);
10037
+ lines.push(`- ${topic.topic}: revision ${topic.revision}, updated by ${topic.updatedBy} at ${topic.updatedAt}`);
9631
10038
  }
9632
10039
  }
9633
10040
  }
@@ -10157,6 +10564,7 @@ var TurnHost = class {
10157
10564
  channel: job.channel,
10158
10565
  exitReason: finished.exitReason
10159
10566
  }, "turn finished");
10567
+ await this.takeSkillFiles(finished);
10160
10568
  turn.done(finished);
10161
10569
  }
10162
10570
  return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
@@ -10181,9 +10589,21 @@ var TurnHost = class {
10181
10589
  };
10182
10590
  if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
10183
10591
  this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
10592
+ await this.takeSkillFiles(finished);
10184
10593
  turn.done(finished);
10185
10594
  }
10186
10595
  }
10596
+ /**
10597
+ * Takes the folders of the skills the turn proposed, which reached the board with the home its
10598
+ * runner pushed before reporting. A failure only leaves them for approval to take.
10599
+ */
10600
+ async takeSkillFiles(record) {
10601
+ try {
10602
+ await this.board.takeSkillFiles(record);
10603
+ } catch (error) {
10604
+ this.log.warn({ agent: record.agent, project: record.project, error: String(error) }, "could not take the folders of the skills a turn proposed");
10605
+ }
10606
+ }
10187
10607
  /**
10188
10608
  * Records the end of a turn already taken out of `open`, and says whether it could. The scheduler
10189
10609
  * holds the turn's conversation until the turn ends, and nothing would end it once it has left
@@ -11553,7 +11973,7 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
11553
11973
  }
11554
11974
 
11555
11975
  // apps/server/dist/static.js
11556
- import { readFile as readFile6, stat as stat3 } from "node:fs/promises";
11976
+ import { readFile as readFile5, stat as stat4 } from "node:fs/promises";
11557
11977
  import path8 from "node:path";
11558
11978
  var CONTENT_TYPES = {
11559
11979
  ".html": "text/html; charset=utf-8",
@@ -11570,7 +11990,7 @@ var CONTENT_TYPES = {
11570
11990
  };
11571
11991
  async function isFile(file) {
11572
11992
  try {
11573
- return (await stat3(file)).isFile();
11993
+ return (await stat4(file)).isFile();
11574
11994
  } catch {
11575
11995
  return false;
11576
11996
  }
@@ -11595,7 +12015,7 @@ function spaHandler(staticDir) {
11595
12015
  const file = within(root, pathname);
11596
12016
  const headers = { "x-content-type-options": "nosniff" };
11597
12017
  if (file !== null && await isFile(file)) {
11598
- return c.body(await readFile6(file), 200, {
12018
+ return c.body(await readFile5(file), 200, {
11599
12019
  ...headers,
11600
12020
  "content-type": CONTENT_TYPES[path8.extname(file)] ?? "application/octet-stream",
11601
12021
  "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
@@ -11608,7 +12028,7 @@ function spaHandler(staticDir) {
11608
12028
  if (!await isFile(index)) {
11609
12029
  return c.text("The interface is not built: run pnpm build:web.", 404);
11610
12030
  }
11611
- return c.body(await readFile6(index), 200, {
12031
+ return c.body(await readFile5(index), 200, {
11612
12032
  ...headers,
11613
12033
  "content-type": CONTENT_TYPES[".html"] ?? "text/html",
11614
12034
  "cache-control": "no-cache"
@@ -11626,6 +12046,7 @@ var ERROR_STATUS = {
11626
12046
  INVALID_TRANSITION: 409,
11627
12047
  INVALID_STATE: 409
11628
12048
  };
12049
+ var EVENT_PAGE = 100;
11629
12050
  var RetireBodySchema = z16.object({ reason: z16.string().min(1) });
11630
12051
  var RunnerBodySchema = z16.object({ name: NameSchema });
11631
12052
  var EnrollmentApprovalSchema = z16.object({ name: NameSchema });
@@ -11633,6 +12054,7 @@ var HomeRunnerBodySchema = z16.object({ runner: NameSchema.nullable() });
11633
12054
  var ModelBodySchema = z16.object({ model: ModelNameSchema.nullable() });
11634
12055
  var EffortBodySchema = z16.object({ effort: EffortSchema.nullable() });
11635
12056
  var HistoryLimitSchema = z16.coerce.number().int().min(1).max(500);
12057
+ var MessageIdsSchema = z16.string().transform((ids) => ids.split(",").filter((id) => id !== "")).pipe(z16.array(UlidSchema).min(1).max(MESSAGE_LOOKUP_LIMIT));
11636
12058
  var TimezoneBodySchema = z16.object({ timezone: TimeZoneSchema });
11637
12059
  var ChannelBodySchema = z16.object({
11638
12060
  project: NameSchema.nullable().default(null),
@@ -11646,6 +12068,7 @@ function bearer2(c) {
11646
12068
  }
11647
12069
  function createApp(deps) {
11648
12070
  const { board: board2, version, turns: turns2, scheduler: scheduler2, models } = deps;
12071
+ const eventStreamPingMs = deps.eventStreamPingMs ?? 15e3;
11649
12072
  const app2 = new Hono3();
11650
12073
  app2.get("/health", (c) => c.json({ ok: true, version }));
11651
12074
  app2.onError((error, c) => {
@@ -11743,6 +12166,8 @@ function createApp(deps) {
11743
12166
  api.get("/proposals", async (c) => c.json(await board2.listProposals()));
11744
12167
  api.get("/crons", async (c) => c.json(await board2.listCrons()));
11745
12168
  api.get("/proposals/:id", async (c) => c.json(await board2.readProposal(c.req.param("id"))));
12169
+ api.get("/messages", async (c) => c.json(await board2.findMessages(MessageIdsSchema.parse(c.req.query("ids") ?? ""))));
12170
+ api.get("/proposals/:id/files", async (c) => c.json(await board2.readSkillFiles(UlidSchema.parse(c.req.param("id")))));
11746
12171
  api.get("/signals", async (c) => c.json(await board2.listSignals(Number(c.req.query("limit") ?? "100"))));
11747
12172
  api.get("/metrics", async (c) => {
11748
12173
  const window = MetricsWindowSchema.parse(c.req.query("window") ?? "7d");
@@ -11846,16 +12271,41 @@ function createApp(deps) {
11846
12271
  api.get("/events/stream", (c) => {
11847
12272
  let cursor = c.req.query("since") ?? null;
11848
12273
  return streamSSE2(c, async (stream) => {
11849
- if (cursor === "latest") {
11850
- cursor = await board2.latestEventId();
11851
- }
11852
- while (!stream.aborted && !stream.closed) {
11853
- const events = await board2.readEvents(cursor === "" ? null : cursor, 100);
11854
- for (const event of events) {
11855
- await stream.writeSSE({ id: event.id, event: event.type, data: JSON.stringify(event) });
11856
- cursor = event.id;
12274
+ let appended = false;
12275
+ let wake = null;
12276
+ const unsubscribe = board2.onEventAppended(() => {
12277
+ appended = true;
12278
+ wake?.();
12279
+ });
12280
+ stream.onAbort(() => wake?.());
12281
+ try {
12282
+ if (cursor === "latest") {
12283
+ cursor = await board2.latestEventId();
11857
12284
  }
11858
- await stream.sleep(500);
12285
+ while (!stream.aborted && !stream.closed) {
12286
+ appended = false;
12287
+ const events = await board2.readEvents(cursor === "" ? null : cursor, EVENT_PAGE);
12288
+ for (const event of events) {
12289
+ await stream.writeSSE({ id: event.id, event: event.type, data: JSON.stringify(event) });
12290
+ cursor = event.id;
12291
+ }
12292
+ if (appended || events.length === EVENT_PAGE) {
12293
+ continue;
12294
+ }
12295
+ const woken = await new Promise((resolve) => {
12296
+ const timer = setTimeout(() => resolve(false), eventStreamPingMs);
12297
+ wake = () => {
12298
+ clearTimeout(timer);
12299
+ resolve(true);
12300
+ };
12301
+ });
12302
+ wake = null;
12303
+ if (!woken && !stream.aborted) {
12304
+ await stream.writeSSE({ event: "ping", data: "" });
12305
+ }
12306
+ }
12307
+ } finally {
12308
+ unsubscribe();
11859
12309
  }
11860
12310
  });
11861
12311
  });