@kubitnodes/stellaris 0.3.18 → 0.3.19

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 (56) hide show
  1. package/dist/cli/index.js +393 -95
  2. package/dist/server/index.js +409 -97
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-CDrsO9Ls.js +1 -0
  5. package/web/dist/assets/{ChannelForms-DfG4vYvk.js → ChannelForms-BjD5Lp_u.js} +1 -1
  6. package/web/dist/assets/ChannelView-dI5EHpof.js +2 -0
  7. package/web/dist/assets/{CitizenView-q24CExsI.js → CitizenView-tvC6dNUB.js} +2 -2
  8. package/web/dist/assets/{CitizensView-bKbUaCab.js → CitizensView-DsUhIYtl.js} +1 -1
  9. package/web/dist/assets/{CliIcon-DTVk0pGU.js → CliIcon-TlyIqh7V.js} +1 -1
  10. package/web/dist/assets/{CronsView-DaG1xhQu.js → CronsView-BPlSyoAe.js} +1 -1
  11. package/web/dist/assets/{KnowledgeView-Dt7wE3FT.js → KnowledgeView-CgRGZrOK.js} +1 -1
  12. package/web/dist/assets/{Markdown-DeF9PKNm.js → Markdown-CePsfcUS.js} +3 -3
  13. package/web/dist/assets/MessageItem-kg5zWMDe.js +1 -0
  14. package/web/dist/assets/MetricsView-cJeDTfBE.js +1 -0
  15. package/web/dist/assets/NeedsYouView-Cd1TnY7W.js +1 -0
  16. package/web/dist/assets/Overview-B6aBmove.js +1 -0
  17. package/web/dist/assets/{Pane-v044FaP5.js → Pane-DGtHsANK.js} +1 -1
  18. package/web/dist/assets/{Picker-wWsO86nu.js → Picker-Dq5Vvxod.js} +1 -1
  19. package/web/dist/assets/{ProjectView--nzlqSDA.js → ProjectView-uskZHP8l.js} +1 -1
  20. package/web/dist/assets/ProposalView-DmjIL53_.js +2 -0
  21. package/web/dist/assets/{ProposalsView-dVA50yG1.js → ProposalsView-BjEfHBtx.js} +1 -1
  22. package/web/dist/assets/{RunnersView-C-l5QYIa.js → RunnersView-CM-q_AX9.js} +1 -1
  23. package/web/dist/assets/SocietyView-DZuGEqIl.js +1 -0
  24. package/web/dist/assets/SubjectThread-pCRDn42X.js +1 -0
  25. package/web/dist/assets/TaskFileView-BMAkbh-e.js +4 -0
  26. package/web/dist/assets/{TaskView-BgJcOmGj.js → TaskView-HDwqXuYy.js} +2 -2
  27. package/web/dist/assets/{TasksView-f8KIsif_.js → TasksView-Dt8M_z96.js} +1 -1
  28. package/web/dist/assets/{ThreadForms-CXsunS-N.js → ThreadForms-DbV3kPCi.js} +1 -1
  29. package/web/dist/assets/ThreadItem-BVlz2w6q.js +1 -0
  30. package/web/dist/assets/{ThreadView-DzpaXkik.js → ThreadView-CpBytSH7.js} +1 -1
  31. package/web/dist/assets/{citizen-VtLNxjtf.js → citizen-DQ0Bmk-r.js} +1 -1
  32. package/web/dist/assets/{compose-D-SO-HE-.js → compose-Cx9c9k90.js} +1 -1
  33. package/web/dist/assets/{format-CkySAdH1.js → format-SP8GnzAX.js} +1 -1
  34. package/web/dist/assets/governance-DX2ph-qr.js +1 -0
  35. package/web/dist/assets/index-BDKI0Rje.js +23 -0
  36. package/web/dist/assets/index-BPVeX6tV.css +2 -0
  37. package/web/dist/assets/{link-BN04dcBX.js → link-DxSeFzFf.js} +1 -1
  38. package/web/dist/assets/{tasks-CD70eybR.js → tasks-BeWF0Xy2.js} +1 -1
  39. package/web/dist/assets/useMatch-HNekgJrF.js +1 -0
  40. package/web/dist/assets/{useStickyScroll-WSVpAMrA.js → useStickyScroll-BmqddNx5.js} +1 -1
  41. package/web/dist/index.html +9 -9
  42. package/web/dist/assets/BackLink-DUOMS3fv.js +0 -1
  43. package/web/dist/assets/ChannelView-DTLhLXy4.js +0 -2
  44. package/web/dist/assets/MessageItem-DDs7m3-4.js +0 -1
  45. package/web/dist/assets/MetricsView-Bw7nL6Y9.js +0 -1
  46. package/web/dist/assets/NeedsYouView-DbESNtQT.js +0 -1
  47. package/web/dist/assets/Overview-Dv7SS4yW.js +0 -1
  48. package/web/dist/assets/ProposalView-Cdw_xzG1.js +0 -1
  49. package/web/dist/assets/SocietyView--mWRXRj_.js +0 -1
  50. package/web/dist/assets/SubjectThread-DvvsxHN9.js +0 -1
  51. package/web/dist/assets/TaskFileView-DYuPrCdk.js +0 -4
  52. package/web/dist/assets/ThreadCard-BRLvjdz3.js +0 -1
  53. package/web/dist/assets/governance-1UeTD2K_.js +0 -1
  54. package/web/dist/assets/index-CwkK8XtJ.js +0 -23
  55. package/web/dist/assets/index-DaES0xVj.css +0 -2
  56. package/web/dist/assets/useMatch-i33QT_GI.js +0 -1
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import path7 from "node:path";
6
6
  import { Command } from "commander";
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 readFile5, rename as rename3, rm as rm2, stat as stat2, 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";
@@ -622,7 +622,7 @@ var ArchiveProposalSchema = z4.object({
622
622
  var SkillProposalSchema = z4.object({
623
623
  name: NameSchema,
624
624
  summary: z4.string().min(1).max(200),
625
- body: z4.string().min(1)
625
+ body: z4.string().min(1).optional()
626
626
  });
627
627
  var ProposalCharterSchemas = {
628
628
  role: RoleCharterSchema,
@@ -749,6 +749,7 @@ var HomeFileDiffSchema = HomeFileChangeSchema.pick({ path: true, status: true })
749
749
  var KnowledgeSchema = z4.object({
750
750
  topic: NameSchema,
751
751
  project: NameSchema.nullable(),
752
+ revision: z4.number().int().min(0),
752
753
  updatedBy: NameSchema,
753
754
  updatedAt: IsoDateTimeSchema
754
755
  });
@@ -771,6 +772,24 @@ var ProposalOriginSchema = z4.object({
771
772
  thread: UlidSchema.optional(),
772
773
  channel: NameSchema.optional()
773
774
  });
775
+ var SKILL_FILES_LIMIT = 100;
776
+ var SKILL_BYTES_LIMIT = 10 * 1024 * 1024;
777
+ var SkillFileSchema = z4.object({
778
+ path: z4.string().min(1),
779
+ size: z4.number().int().nonnegative(),
780
+ executable: z4.boolean()
781
+ });
782
+ var SkillFilesSchema = z4.object({
783
+ /** The commit of the proposer's home the files came from. */
784
+ commit: CommitIdSchema,
785
+ takenAt: IsoDateTimeSchema,
786
+ files: z4.array(SkillFileSchema)
787
+ });
788
+ var SkillFileContentSchema = SkillFileSchema.extend({
789
+ text: z4.string().nullable(),
790
+ /** The text was cut at its size limit. */
791
+ truncated: z4.boolean()
792
+ });
774
793
  var ProposalFrontmatterSchema = z4.object({
775
794
  id: UlidSchema,
776
795
  kind: ProposalKindSchema,
@@ -779,6 +798,8 @@ var ProposalFrontmatterSchema = z4.object({
779
798
  createdAt: IsoDateTimeSchema,
780
799
  /** Absent for a proposal filed outside a turn, or before proposals recorded it. */
781
800
  filedFrom: ProposalOriginSchema.optional(),
801
+ /** A skill proposal's folder, once taken; absent for one proposed as text alone. */
802
+ skillFiles: SkillFilesSchema.optional(),
782
803
  decidedBy: NameSchema.optional(),
783
804
  decidedAt: IsoDateTimeSchema.optional(),
784
805
  reason: z4.string().optional(),
@@ -881,7 +902,9 @@ var VerbInputs = {
881
902
  /** A project you belong to, or null for society knowledge, which the steward and the user curate. */
882
903
  project: NameSchema.nullable().default(null),
883
904
  topic: NameSchema,
884
- body: z5.string().min(1)
905
+ body: z5.string().min(1),
906
+ /** The `revision` in the frontmatter of the topic you read, 0 when it names none or for a new topic. */
907
+ revision: z5.number().int().min(0)
885
908
  }),
886
909
  remove_knowledge: z5.object({
887
910
  /** As for write_knowledge: a project you belong to, or null for society knowledge. */
@@ -982,6 +1005,7 @@ var BOARD_EVENT_TYPES = [
982
1005
  "proposal.created",
983
1006
  "proposal.decided",
984
1007
  "proposal.provisioned",
1008
+ "proposal.files_taken",
985
1009
  "channel.added",
986
1010
  "channel.archived",
987
1011
  "role.added",
@@ -1388,6 +1412,9 @@ var RunnerAnswerSchema = z7.discriminatedUnion("ok", [
1388
1412
  ]);
1389
1413
  var WarmSessionsSchema = z7.object({ warm: z7.array(z7.string()) });
1390
1414
  var FileManifestSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1415
+ function fileVersion(sha, executable) {
1416
+ return executable ? `${sha}+x` : sha;
1417
+ }
1391
1418
  var FileReadSchema = z7.object({ paths: z7.array(z7.string()) });
1392
1419
  var FileContentsSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1393
1420
  function isSafeRelativePath(file) {
@@ -1706,7 +1733,7 @@ var RunnerConfigSchema = z11.object({
1706
1733
  });
1707
1734
 
1708
1735
  // packages/shared/dist/version.js
1709
- var STELLARIS_VERSION = "0.3.18";
1736
+ var STELLARIS_VERSION = "0.3.19";
1710
1737
 
1711
1738
  // packages/board-core/dist/errors.js
1712
1739
  var BoardError = class extends Error {
@@ -1760,7 +1787,9 @@ async function readMarkdown(file, schema) {
1760
1787
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1761
1788
  }
1762
1789
  async function readLooseMarkdown(file) {
1763
- const raw = await readFile(file, "utf8");
1790
+ return parseLooseMarkdown(await readFile(file, "utf8"));
1791
+ }
1792
+ function parseLooseMarkdown(raw) {
1764
1793
  try {
1765
1794
  const parsed = parseMatter(raw);
1766
1795
  return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
@@ -1947,13 +1976,14 @@ var FileTree = class {
1947
1976
  if (info.size > MAX_FILE_BYTES) {
1948
1977
  return null;
1949
1978
  }
1979
+ const executable = (info.mode & 73) !== 0;
1950
1980
  const known = this.hashes.get(absolute);
1951
1981
  if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
1952
- return known.sha;
1982
+ return fileVersion(known.sha, executable);
1953
1983
  }
1954
1984
  const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
1955
1985
  this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
1956
- return sha;
1986
+ return fileVersion(sha, executable);
1957
1987
  } catch (error) {
1958
1988
  if (isMissing2(error)) {
1959
1989
  return null;
@@ -2204,6 +2234,55 @@ var HomeRepos = class {
2204
2234
  }
2205
2235
  return files;
2206
2236
  }
2237
+ /**
2238
+ * The files of a folder in a home's newest commit, by path within the folder, with the commit;
2239
+ * conflict copies and anything but a regular file are left out. Null when the home is not a
2240
+ * repository yet.
2241
+ */
2242
+ async folder(home, folder) {
2243
+ if (!await exists(path4.join(home, ".git"))) {
2244
+ return null;
2245
+ }
2246
+ const commit = (await read(home, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
2247
+ const prefix = `${folder}/`;
2248
+ const listed = await read(home, [
2249
+ "ls-tree",
2250
+ "-r",
2251
+ "-z",
2252
+ "--long",
2253
+ "--full-tree",
2254
+ commit,
2255
+ prefix
2256
+ ]);
2257
+ const files = [];
2258
+ for (const entry of listed.split("\0")) {
2259
+ const match = /^(100644|100755) blob ([0-9a-f]+) +(\d+)\t(.+)$/.exec(entry);
2260
+ const [, mode = "", object = "", size = "0", file = ""] = match ?? [];
2261
+ if (match === null || !file.startsWith(prefix) || !isSafeRelativePath(file) || conflictCopyOf(file) !== null) {
2262
+ continue;
2263
+ }
2264
+ files.push({
2265
+ path: file.slice(prefix.length),
2266
+ object,
2267
+ size: Number.parseInt(size, 10),
2268
+ executable: mode === "100755"
2269
+ });
2270
+ }
2271
+ return { commit, files };
2272
+ }
2273
+ /** A blob of a home's repository, as `folder` names it. */
2274
+ async blob(home, object) {
2275
+ const result = await execa("git", ["cat-file", "blob", object], {
2276
+ cwd: home,
2277
+ encoding: "buffer",
2278
+ stripFinalNewline: false,
2279
+ reject: false
2280
+ });
2281
+ if (result.exitCode !== 0) {
2282
+ throw new Error(`git cat-file failed: ${Buffer.from(result.stderr).toString().trim()}`);
2283
+ }
2284
+ return Buffer.from(result.stdout);
2285
+ }
2207
2286
  /** The conflict copies a merge added, against its first parent. */
2208
2287
  async keptCopies(home, parent, commit) {
2209
2288
  const added = await read(home, [
@@ -2619,6 +2698,10 @@ var BoardPaths = class {
2619
2698
  proposal(id) {
2620
2699
  return path5.join(this.proposals(), `${id}.md`);
2621
2700
  }
2701
+ /** A skill proposal's folder, as the board took it from its proposer's home. */
2702
+ proposalFiles(id) {
2703
+ return path5.join(this.proposals(), id);
2704
+ }
2622
2705
  decisions() {
2623
2706
  return path5.join(this.society, "decisions");
2624
2707
  }
@@ -2870,6 +2953,20 @@ var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2870
2953
  function withoutMentions(text) {
2871
2954
  return text.replaceAll(MENTION_PATTERN, "$1$2");
2872
2955
  }
2956
+ var SKILL_TEXT_LIMIT_CHARS = 1e5;
2957
+ function skillFolderOf(proposal2) {
2958
+ if (proposal2.kind !== "skill") {
2959
+ return null;
2960
+ }
2961
+ const parsed = SkillProposalSchema.safeParse(proposal2.charter);
2962
+ return parsed.success && parsed.data.body === void 0 ? parsed.data.name : null;
2963
+ }
2964
+ function proposedFrom(origin, where) {
2965
+ return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel;
2966
+ }
2967
+ function megabytes(bytes) {
2968
+ return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
2969
+ }
2873
2970
  var CURATING_ROLES = [USER_ROLE, "steward"];
2874
2971
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2875
2972
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -3792,22 +3889,41 @@ ${charter.purpose}
3792
3889
  const dir = project2 === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project2);
3793
3890
  const topics = [];
3794
3891
  for (const file of await listFiles(dir)) {
3795
- const doc = await readMarkdown(path6.join(dir, file), z13.record(z13.string(), z13.unknown()));
3796
- const parsed = KnowledgeSchema.safeParse({
3797
- ...doc.data,
3798
- project: project2,
3799
- topic: file.replace(/\.md$/, "")
3800
- });
3801
- const data = parsed.success ? parsed.data : {
3802
- topic: file.replace(/\.md$/, ""),
3803
- project: project2,
3804
- updatedBy: SYSTEM_ACTOR.name,
3805
- updatedAt: (await stat2(path6.join(dir, file))).mtime.toISOString()
3806
- };
3807
- topics.push({ ...data, body: doc.body });
3892
+ topics.push(await this.knowledgeAt(path6.join(dir, file), project2, file.replace(/\.md$/, "")));
3808
3893
  }
3809
3894
  return topics;
3810
3895
  }
3896
+ knowledgeFile(project2, topic) {
3897
+ return project2 === null ? this.paths.societyKnowledgeFile(topic) : this.paths.projectKnowledgeFile(project2, topic);
3898
+ }
3899
+ /**
3900
+ * Read loosely, as a dashboard is: a topic written before revisions counts as revision 0, and an
3901
+ * author or a time that does not parse is the board's at the file's mtime.
3902
+ */
3903
+ async knowledgeAt(file, project2, topic) {
3904
+ const { data, body } = await readLooseMarkdown(file);
3905
+ const revision = z13.number().int().min(0).safeParse(data["revision"]);
3906
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
3907
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
3908
+ return {
3909
+ topic,
3910
+ project: project2,
3911
+ revision: revision.success ? revision.data : 0,
3912
+ updatedBy: updatedBy.success ? updatedBy.data : SYSTEM_ACTOR.name,
3913
+ updatedAt: updatedAt.success ? updatedAt.data : (await stat2(file)).mtime.toISOString(),
3914
+ body
3915
+ };
3916
+ }
3917
+ /** The highest revision among a topic's removed copies, 0 when it has none. */
3918
+ async removedRevision(project2, topic) {
3919
+ const aside = this.paths.removedKnowledge(project2 ?? SOCIETY_SCOPE);
3920
+ let highest = 0;
3921
+ for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
3922
+ const copy = await this.knowledgeAt(path6.join(aside, file), project2, topic);
3923
+ highest = Math.max(highest, copy.revision);
3924
+ }
3925
+ return highest;
3926
+ }
3811
3927
  /** Skills promoted to the society: every citizen may read them. */
3812
3928
  async listSocietySkills() {
3813
3929
  return this.listSkillsIn(this.paths.societySkills(), "society");
@@ -4521,26 +4637,42 @@ ${task2.body}`;
4521
4637
  }
4522
4638
  /**
4523
4639
  * Writes a topic: a project's by its members, the society's by the curating roles. The note in
4524
- * the matching general channel carries no mention, so nobody is woken.
4640
+ * the matching general channel carries no mention, so nobody is woken. A turn reads a topic from
4641
+ * the copy of the projection pulled when it started, so a revision other than the current one, 0
4642
+ * for a topic not there, means someone wrote or removed it since; the refusal carries the current
4643
+ * text, which that copy lacks.
4525
4644
  */
4526
4645
  async writeKnowledge(actor, input) {
4527
4646
  const args = VerbInputs.write_knowledge.parse(input);
4528
4647
  await this.authorize(actor, "write_knowledge");
4529
4648
  return this.mutex.run(async () => {
4530
4649
  await this.assertMayCurate(actor, args.project);
4650
+ const file = this.knowledgeFile(args.project, args.topic);
4651
+ const current = await exists(file) ? await this.knowledgeAt(file, args.project, args.topic) : null;
4652
+ const place = args.project ?? "the society";
4653
+ if (current === null && args.revision !== 0) {
4654
+ 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.`);
4655
+ }
4656
+ if (current !== null && args.revision !== current.revision) {
4657
+ 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}.
4658
+
4659
+ ${current.body}`);
4660
+ }
4661
+ const base = current?.revision ?? await this.removedRevision(args.project, args.topic);
4531
4662
  const data = KnowledgeSchema.parse({
4532
4663
  topic: args.topic,
4533
4664
  project: args.project,
4665
+ revision: base + 1,
4534
4666
  updatedBy: actor.name,
4535
4667
  updatedAt: this.now().toISOString()
4536
4668
  });
4537
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4538
- const replaced = await exists(file);
4669
+ const replaced = current !== null;
4539
4670
  await writeMarkdown(file, data, args.body);
4540
4671
  await this.events.append("knowledge.written", actor.name, {
4541
4672
  topic: args.topic,
4542
4673
  project: args.project,
4543
- replaced
4674
+ replaced,
4675
+ revision: data.revision
4544
4676
  });
4545
4677
  const where = args.project === null ? "general" : channelRef(args.project, "general");
4546
4678
  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" : ""}.`, {
@@ -4563,7 +4695,7 @@ ${task2.body}`;
4563
4695
  await this.authorize(actor, "remove_knowledge");
4564
4696
  return this.mutex.run(async () => {
4565
4697
  await this.assertMayCurate(actor, args.project);
4566
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4698
+ const file = this.knowledgeFile(args.project, args.topic);
4567
4699
  if (!await exists(file)) {
4568
4700
  throw new BoardError("NOT_FOUND", `${args.project ?? "the society"} has no knowledge topic ${args.topic}`);
4569
4701
  }
@@ -5614,7 +5746,7 @@ ${args.summary}`);
5614
5746
  throw new BoardError("VALIDATION", `invalid ${args.kind} charter: ${parsed.error.message}`);
5615
5747
  }
5616
5748
  return this.mutex.run(async () => {
5617
- const proposal2 = {
5749
+ let proposal2 = {
5618
5750
  ...ProposalFrontmatterSchema.parse({
5619
5751
  id: this.newId(),
5620
5752
  kind: args.kind,
@@ -5633,6 +5765,14 @@ ${args.summary}`);
5633
5765
  body: args.rationale
5634
5766
  };
5635
5767
  await this.validateProvision(proposal2.kind, proposal2.charter);
5768
+ const skill2 = skillFolderOf(proposal2);
5769
+ if (skill2 !== null && proposal2.filedFrom === void 0) {
5770
+ const taken = await this.takeSkillFolderUnlocked(proposal2, skill2);
5771
+ if (typeof taken === "string") {
5772
+ throw new BoardError("VALIDATION", taken);
5773
+ }
5774
+ proposal2 = { ...proposal2, skillFiles: taken };
5775
+ }
5636
5776
  await this.writeProposal(proposal2);
5637
5777
  await this.events.append("proposal.created", actor.name, {
5638
5778
  proposalId: proposal2.id,
@@ -6849,7 +6989,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6849
6989
  }
6850
6990
  case "skill": {
6851
6991
  const skill2 = SkillProposalSchema.parse(proposal2.charter);
6852
- const replaced = await this.promoteSkillUnlocked(by, proposal2.proposedBy, skill2, meta);
6992
+ const replaced = await this.promoteSkillUnlocked(by, proposal2, skill2, meta);
6853
6993
  return { skill: skill2.name, replaced };
6854
6994
  }
6855
6995
  case "archive": {
@@ -6863,26 +7003,180 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6863
7003
  return void 0;
6864
7004
  }
6865
7005
  }
6866
- /** Writes a promoted skill under the society's skills, where every citizen's skills index lists it. */
6867
- async promoteSkillUnlocked(by, proposedBy, skill2, meta) {
7006
+ /**
7007
+ * Publishes a promoted skill under the society's skills, where every citizen's skills index lists
7008
+ * it: the folder the board took from its proposer's home, or the text proposed alone. A
7009
+ * replacement is the whole skill, so files the new version dropped go with the old one.
7010
+ */
7011
+ async promoteSkillUnlocked(by, proposal2, skill2, meta) {
7012
+ const dir = this.paths.societySkill(skill2.name);
6868
7013
  const file = this.paths.societySkillFile(skill2.name);
6869
7014
  const replaced = await exists(file);
6870
- await ensureDir(this.paths.societySkill(skill2.name));
7015
+ const taken = proposal2.skillFiles;
7016
+ const staged = path6.join(this.paths.proposalFiles(proposal2.id), "SKILL.md");
7017
+ if (skill2.body === void 0 && (taken === void 0 || !await exists(staged))) {
7018
+ throw new BoardError("INVALID_STATE", `proposal ${proposal2.id} holds no skill files`);
7019
+ }
7020
+ await rm2(dir, { recursive: true, force: true });
7021
+ if (skill2.body === void 0) {
7022
+ await cp(this.paths.proposalFiles(proposal2.id), dir, { recursive: true });
7023
+ } else {
7024
+ await ensureDir(dir);
7025
+ }
7026
+ const body = skill2.body ?? (await readLooseMarkdown(file)).body;
6871
7027
  await writeMarkdown(file, {
6872
7028
  name: skill2.name,
6873
7029
  description: skill2.summary,
6874
- metadata: { proposedBy, promotedBy: by, promotedAt: this.now().toISOString() }
6875
- }, skill2.body);
7030
+ metadata: {
7031
+ proposedBy: proposal2.proposedBy,
7032
+ promotedBy: by,
7033
+ promotedAt: this.now().toISOString(),
7034
+ ...taken === void 0 ? {} : { commit: taken.commit }
7035
+ }
7036
+ }, body);
6876
7037
  await this.events.append("skill.promoted", by, {
6877
7038
  name: skill2.name,
6878
7039
  summary: skill2.summary,
6879
- proposedBy,
7040
+ proposedBy: proposal2.proposedBy,
6880
7041
  replaced,
7042
+ ...taken === void 0 ? {} : { files: taken.files.length },
6881
7043
  ...meta
6882
7044
  });
6883
7045
  await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill2.name} ${replaced ? "updated in" : "promoted to"} the society: ${sentence(skill2.summary)}. Every citizen's skills index now lists it.`);
6884
7046
  return replaced;
6885
7047
  }
7048
+ /**
7049
+ * Copies a skill's folder from its proposer's home, as the home's newest commit holds it, into the
7050
+ * proposal's folder in the projection, where its deciders read it and from where approval
7051
+ * publishes it. Returns what was taken, or why the home could not give it.
7052
+ */
7053
+ async takeSkillFolderUnlocked(proposal2, name) {
7054
+ const home = this.paths.agent(proposal2.proposedBy);
7055
+ const folder = `skills/${name}`;
7056
+ const where = `${folder} in ${proposal2.proposedBy}'s home`;
7057
+ const taken = await this.homes.folder(home, folder);
7058
+ if (taken === null || !taken.files.some((entry) => entry.path === "SKILL.md")) {
7059
+ return `no ${folder}/SKILL.md in ${proposal2.proposedBy}'s home`;
7060
+ }
7061
+ if (taken.files.length > SKILL_FILES_LIMIT) {
7062
+ return `${where} holds ${taken.files.length} files, more than the ${SKILL_FILES_LIMIT} a society skill may`;
7063
+ }
7064
+ const bytes = taken.files.reduce((sum, entry) => sum + entry.size, 0);
7065
+ if (bytes > SKILL_BYTES_LIMIT) {
7066
+ return `${where} holds ${megabytes(bytes)} MB, more than the ${megabytes(SKILL_BYTES_LIMIT)} MB a society skill may`;
7067
+ }
7068
+ const contents = /* @__PURE__ */ new Map();
7069
+ for (const entry of taken.files) {
7070
+ contents.set(entry.path, await this.homes.blob(home, entry.object));
7071
+ }
7072
+ const manual = parseLooseMarkdown(contents.get("SKILL.md")?.toString("utf8") ?? "");
7073
+ if (manual.error !== null) {
7074
+ return `the frontmatter of ${folder}/SKILL.md in ${proposal2.proposedBy}'s home does not parse (${manual.error})`;
7075
+ }
7076
+ const dir = this.paths.proposalFiles(proposal2.id);
7077
+ await rm2(dir, { recursive: true, force: true });
7078
+ for (const entry of taken.files) {
7079
+ const target = path6.join(dir, ...entry.path.split("/"));
7080
+ await ensureDir(path6.dirname(target));
7081
+ await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
7082
+ await chmod2(target, entry.executable ? 493 : 420);
7083
+ }
7084
+ return {
7085
+ commit: taken.commit,
7086
+ takenAt: this.now().toISOString(),
7087
+ files: taken.files.map((entry) => ({
7088
+ path: entry.path,
7089
+ size: entry.size,
7090
+ executable: entry.executable
7091
+ }))
7092
+ };
7093
+ }
7094
+ /** Keeps what was taken of a skill proposal's folder on the proposal, and logs it. */
7095
+ async recordSkillFiles(proposal2, taken) {
7096
+ const next = { ...proposal2, skillFiles: taken };
7097
+ await this.writeProposal(next);
7098
+ await this.events.append("proposal.files_taken", SYSTEM_ACTOR.name, {
7099
+ proposalId: proposal2.id,
7100
+ kind: proposal2.kind,
7101
+ proposedBy: proposal2.proposedBy,
7102
+ commit: taken.commit,
7103
+ files: taken.files.length
7104
+ });
7105
+ return next;
7106
+ }
7107
+ /**
7108
+ * A skill proposal with its folder, for approval: taken now when the turn that filed it ended
7109
+ * without it, as when the server restarted before the turn's end was recorded, and refused while
7110
+ * that turn is still in flight, since the folder reaches the board only when it ends.
7111
+ */
7112
+ async skillFilesForApproval(proposal2) {
7113
+ const name = skillFolderOf(proposal2);
7114
+ if (name === null || proposal2.skillFiles !== void 0) {
7115
+ return proposal2;
7116
+ }
7117
+ const cannot = `proposal ${proposal2.id} cannot be approved`;
7118
+ const filing = this.turnsInFlight().some((turn2) => turn2.agent === proposal2.proposedBy && proposedFrom(proposal2.filedFrom, turn2));
7119
+ if (filing) {
7120
+ throw new BoardError("INVALID_STATE", `${cannot} until ${proposal2.proposedBy}'s turn that proposed it ends, when the board takes the skill's folder from its home`);
7121
+ }
7122
+ const taken = await this.takeSkillFolderUnlocked(proposal2, name);
7123
+ if (typeof taken === "string") {
7124
+ throw new BoardError("INVALID_STATE", `${cannot}: ${taken}; reject it with that reason`);
7125
+ }
7126
+ return this.recordSkillFiles(proposal2, taken);
7127
+ }
7128
+ /**
7129
+ * Takes the folders of the skills a citizen proposed from a conversation, once the turn there has
7130
+ * ended and its runner has pushed the citizen's home. A proposal whose folder the home does not
7131
+ * give is rejected by the board with the reason, which wakes the proposer to put it right; while
7132
+ * someone is in a turn in the proposal's thread, it stays as it is, and approval meets the reason.
7133
+ */
7134
+ async takeSkillFiles(turn2) {
7135
+ const where = {
7136
+ scope: turn2.project,
7137
+ ...turn2.thread === void 0 ? {} : { thread: turn2.thread },
7138
+ ...turn2.channel === void 0 ? {} : { channel: turn2.channel }
7139
+ };
7140
+ await this.mutex.run(async () => {
7141
+ for (const proposal2 of await this.listProposals()) {
7142
+ const name = skillFolderOf(proposal2);
7143
+ if (name === null || proposal2.status !== "proposed" || proposal2.skillFiles !== void 0 || proposal2.proposedBy !== turn2.agent || !proposedFrom(proposal2.filedFrom, where)) {
7144
+ continue;
7145
+ }
7146
+ const taken = await this.takeSkillFolderUnlocked(proposal2, name);
7147
+ if (typeof taken !== "string") {
7148
+ await this.recordSkillFiles(proposal2, taken);
7149
+ continue;
7150
+ }
7151
+ try {
7152
+ await this.decideUnlocked(SYSTEM_ACTOR, proposal2.id, "rejected", `${taken} when the turn that proposed it ended; propose it again once the folder is there`);
7153
+ } catch (error) {
7154
+ if (!(error instanceof BoardError)) {
7155
+ throw error;
7156
+ }
7157
+ }
7158
+ }
7159
+ });
7160
+ }
7161
+ /** A skill proposal's files with their text, as the board took them; none before then. */
7162
+ async readSkillFiles(proposalId) {
7163
+ const proposal2 = await this.readProposal(proposalId);
7164
+ const dir = this.paths.proposalFiles(proposal2.id);
7165
+ const files = [];
7166
+ for (const entry of proposal2.skillFiles?.files ?? []) {
7167
+ if (!isSafeRelativePath(entry.path)) {
7168
+ continue;
7169
+ }
7170
+ const bytes = await readFile5(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
7171
+ const text = bytes === null || bytes.includes(0) ? null : bytes.toString("utf8");
7172
+ files.push({
7173
+ ...entry,
7174
+ text: text === null ? null : text.slice(0, SKILL_TEXT_LIMIT_CHARS),
7175
+ truncated: text !== null && text.length > SKILL_TEXT_LIMIT_CHARS
7176
+ });
7177
+ }
7178
+ return files;
7179
+ }
6886
7180
  async validateAddAgent(input) {
6887
7181
  if (await exists(this.paths.agentFile(input.name))) {
6888
7182
  throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
@@ -7394,67 +7688,70 @@ ${charter.purpose}
7394
7688
  * retired. Validation runs before any write, so an impossible provision fails the decision.
7395
7689
  */
7396
7690
  async decide(actor, proposalId, outcome, reason) {
7397
- return this.mutex.run(async () => {
7398
- const proposal2 = await this.readProposal(proposalId);
7399
- if (proposal2.status !== "proposed") {
7400
- throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${proposal2.status}`);
7401
- }
7402
- if (proposal2.proposedBy === actor.name) {
7403
- throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7404
- }
7405
- const approvers = ROLE_KIND_APPROVERS[proposal2.kind];
7406
- if (!approvers.includes(actor.role)) {
7407
- throw new BoardError("FORBIDDEN", `a ${proposal2.kind} proposal requires one of: ${approvers.join(", ")}`);
7408
- }
7409
- if (outcome === "approved") {
7410
- await this.validateProvision(proposal2.kind, proposal2.charter);
7411
- }
7412
- this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn2) => turn2.thread === proposalId);
7413
- if (outcome === "approved" && proposal2.kind === "archive") {
7414
- await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal2.charter).project);
7415
- }
7416
- const ts = this.now().toISOString();
7417
- const decision = DecisionSchema.parse({
7418
- id: this.newId(),
7419
- proposalId,
7420
- decidedBy: actor.name,
7421
- outcome,
7422
- ...reason === void 0 ? {} : { reason },
7423
- ts
7424
- });
7425
- await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7426
- const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal2) : void 0;
7427
- const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7428
- await this.writeProposal({
7429
- ...proposal2,
7430
- status,
7431
- decidedBy: actor.name,
7432
- decidedAt: ts,
7433
- ...reason === void 0 ? {} : { reason },
7434
- ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7435
- });
7436
- await this.events.append("proposal.decided", actor.name, {
7691
+ return this.mutex.run(() => this.decideUnlocked(actor, proposalId, outcome, reason));
7692
+ }
7693
+ async decideUnlocked(actor, proposalId, outcome, reason) {
7694
+ const filed = await this.readProposal(proposalId);
7695
+ if (filed.status !== "proposed") {
7696
+ throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${filed.status}`);
7697
+ }
7698
+ if (filed.proposedBy === actor.name) {
7699
+ throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7700
+ }
7701
+ const approvers = ROLE_KIND_APPROVERS[filed.kind];
7702
+ if (!approvers.includes(actor.role)) {
7703
+ throw new BoardError("FORBIDDEN", `a ${filed.kind} proposal requires one of: ${approvers.join(", ")}`);
7704
+ }
7705
+ if (outcome === "approved") {
7706
+ await this.validateProvision(filed.kind, filed.charter);
7707
+ }
7708
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn2) => turn2.thread === proposalId);
7709
+ if (outcome === "approved" && filed.kind === "archive") {
7710
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(filed.charter).project);
7711
+ }
7712
+ const proposal2 = outcome === "approved" ? await this.skillFilesForApproval(filed) : filed;
7713
+ const ts = this.now().toISOString();
7714
+ const decision = DecisionSchema.parse({
7715
+ id: this.newId(),
7716
+ proposalId,
7717
+ decidedBy: actor.name,
7718
+ outcome,
7719
+ ...reason === void 0 ? {} : { reason },
7720
+ ts
7721
+ });
7722
+ await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7723
+ const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal2) : void 0;
7724
+ const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7725
+ await this.writeProposal({
7726
+ ...proposal2,
7727
+ status,
7728
+ decidedBy: actor.name,
7729
+ decidedAt: ts,
7730
+ ...reason === void 0 ? {} : { reason },
7731
+ ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7732
+ });
7733
+ await this.events.append("proposal.decided", actor.name, {
7734
+ proposalId,
7735
+ outcome,
7736
+ kind: proposal2.kind,
7737
+ proposedBy: proposal2.proposedBy,
7738
+ ...proposal2.filedFrom === void 0 ? {} : { filedFrom: proposal2.filedFrom }
7739
+ });
7740
+ if (provision !== void 0) {
7741
+ await this.events.append("proposal.provisioned", actor.name, {
7437
7742
  proposalId,
7438
- outcome,
7439
7743
  kind: proposal2.kind,
7440
- proposedBy: proposal2.proposedBy,
7441
- ...proposal2.filedFrom === void 0 ? {} : { filedFrom: proposal2.filedFrom }
7744
+ ...provision
7442
7745
  });
7443
- if (provision !== void 0) {
7444
- await this.events.append("proposal.provisioned", actor.name, {
7445
- proposalId,
7446
- kind: proposal2.kind,
7447
- ...provision
7448
- });
7449
- }
7450
- const verdict = outcome === "approved" ? "Approved" : "Rejected";
7451
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7452
- const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7453
- const thread2 = await this.proposalThreadUnlocked(actor.name, proposal2);
7454
- await this.appendThreadMessage(actor.name, thread2.id, `${verdict}: ${describeCharter(proposal2.kind, proposal2.charter)}.${why}${result}`, { step: { action: outcome } });
7455
- await this.endThreadOf(proposalId, actor.name, outcome);
7456
- return decision;
7457
- });
7746
+ }
7747
+ const verdict = outcome === "approved" ? "Approved" : "Rejected";
7748
+ const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7749
+ const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7750
+ const thread2 = await this.proposalThreadUnlocked(actor.name, proposal2);
7751
+ const post = `${verdict}: ${describeCharter(proposal2.kind, proposal2.charter)}.${why}${result}`;
7752
+ await this.appendThreadMessage(actor.name, thread2.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
7753
+ await this.endThreadOf(proposalId, actor.name, outcome);
7754
+ return decision;
7458
7755
  }
7459
7756
  /** The opener, the people of its subject, and anyone who posted in it; mentions reach the rest. */
7460
7757
  async participatesInThread(actor, thread2, threads) {
@@ -8039,7 +8336,7 @@ var knowledge = program.command("knowledge").description("Shared knowledge, by p
8039
8336
  knowledge.command("list [project]").description("List knowledge topics of a project, or of the society when no project is given").action(async (projectSlug) => {
8040
8337
  const board = await open();
8041
8338
  const topics = await board.listKnowledge(projectSlug ?? null);
8042
- print(topics, () => topics.length === 0 ? "No knowledge yet." : topics.map((t) => `${t.topic} updated by ${t.updatedBy} at ${t.updatedAt}`).join("\n"));
8339
+ print(topics, () => topics.length === 0 ? "No knowledge yet." : topics.map((t) => `${t.topic} revision ${t.revision}, updated by ${t.updatedBy} at ${t.updatedAt}`).join("\n"));
8043
8340
  });
8044
8341
  knowledge.command("show <topic>").description("Print a knowledge topic").option("--project <slug>", "the project's topic instead of the society's").action(async (topic, opts) => {
8045
8342
  const board = await open();
@@ -8049,15 +8346,16 @@ knowledge.command("show <topic>").description("Print a knowledge topic").option(
8049
8346
  }
8050
8347
  print(found, () => found.body);
8051
8348
  });
8052
- knowledge.command("write <topic>").description("Write a knowledge topic as the user, from a file or standard input").option("--project <slug>", "the project's topic instead of the society's").option("--file <path>", "read the body from this file instead of standard input").option("--as <agent>", "act as this agent instead of the user").action(async (topic, opts) => {
8349
+ knowledge.command("write <topic>").description("Write a knowledge topic as the user, from a file or standard input").requiredOption("--revision <n>", "the revision it replaces, as knowledge list shows it; 0 for a new topic").option("--project <slug>", "the project's topic instead of the society's").option("--file <path>", "read the body from this file instead of standard input").option("--as <agent>", "act as this agent instead of the user").action(async (topic, opts) => {
8053
8350
  const board = await open();
8054
8351
  const body = opts.file === void 0 ? await readStdin() : await readFile6(path7.resolve(opts.file), "utf8");
8055
8352
  const written = await board.writeKnowledge(await actorFor(board, opts.as), {
8056
8353
  project: opts.project ?? null,
8057
8354
  topic,
8058
- body
8355
+ body,
8356
+ revision: Number(opts.revision)
8059
8357
  });
8060
- print(written, () => `Wrote ${written.topic} for ${written.project ?? "the society"}`);
8358
+ print(written, () => `Wrote ${written.topic} for ${written.project ?? "the society"} at revision ${written.revision}`);
8061
8359
  });
8062
8360
  var skill = program.command("skill").description("Skills promoted to the society");
8063
8361
  skill.command("list").description("List the society's skills, and each member's own").action(async () => {