@kubitnodes/stellaris 0.3.17 → 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 (55) hide show
  1. package/dist/cli/index.js +475 -117
  2. package/dist/server/index.js +466 -102
  3. package/package.json +1 -1
  4. package/web/dist/assets/{BackLink-CssfJ-vJ.js → BackLink-CDrsO9Ls.js} +1 -1
  5. package/web/dist/assets/{ChannelForms-B3XwkJj_.js → ChannelForms-BjD5Lp_u.js} +1 -1
  6. package/web/dist/assets/ChannelView-dI5EHpof.js +2 -0
  7. package/web/dist/assets/{CitizenView-BUeC52q2.js → CitizenView-tvC6dNUB.js} +2 -2
  8. package/web/dist/assets/{CitizensView-BSWYcQ-Q.js → CitizensView-DsUhIYtl.js} +1 -1
  9. package/web/dist/assets/{CliIcon-CFObB9XW.js → CliIcon-TlyIqh7V.js} +1 -1
  10. package/web/dist/assets/{CronsView-BBIx84f7.js → CronsView-BPlSyoAe.js} +1 -1
  11. package/web/dist/assets/KnowledgeView-CgRGZrOK.js +1 -0
  12. package/web/dist/assets/{Markdown-BO_bF8WD.js → Markdown-CePsfcUS.js} +4 -4
  13. package/web/dist/assets/MessageItem-kg5zWMDe.js +1 -0
  14. package/web/dist/assets/{MetricsView-DqAQ4th0.js → MetricsView-cJeDTfBE.js} +1 -1
  15. package/web/dist/assets/{NeedsYouView-D2Z-iLdT.js → NeedsYouView-Cd1TnY7W.js} +1 -1
  16. package/web/dist/assets/{Overview-DThLwb0l.js → Overview-B6aBmove.js} +1 -1
  17. package/web/dist/assets/{Pane-DHXhVwfI.js → Pane-DGtHsANK.js} +1 -1
  18. package/web/dist/assets/{Picker-CQQ7hj8T.js → Picker-Dq5Vvxod.js} +1 -1
  19. package/web/dist/assets/ProjectView-uskZHP8l.js +1 -0
  20. package/web/dist/assets/ProposalView-DmjIL53_.js +2 -0
  21. package/web/dist/assets/{ProposalsView-BJg4ZsTy.js → ProposalsView-BjEfHBtx.js} +1 -1
  22. package/web/dist/assets/{RunnersView-DOW8xq-J.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-CPwEmR6p.js → TaskFileView-BMAkbh-e.js} +2 -2
  26. package/web/dist/assets/{TaskView-q6Kn30K7.js → TaskView-HDwqXuYy.js} +2 -2
  27. package/web/dist/assets/{TasksView-CT9Cui_C.js → TasksView-Dt8M_z96.js} +1 -1
  28. package/web/dist/assets/ThreadForms-DbV3kPCi.js +1 -0
  29. package/web/dist/assets/ThreadItem-BVlz2w6q.js +1 -0
  30. package/web/dist/assets/{ThreadView-BZ2nKfyD.js → ThreadView-CpBytSH7.js} +1 -1
  31. package/web/dist/assets/{citizen-DFaT61S1.js → citizen-DQ0Bmk-r.js} +1 -1
  32. package/web/dist/assets/compose-Cx9c9k90.js +1 -0
  33. package/web/dist/assets/{format-Cp_C4ygr.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-DIpiSYJj.js → link-DxSeFzFf.js} +1 -1
  38. package/web/dist/assets/{tasks-C1wf85BJ.js → tasks-BeWF0Xy2.js} +1 -1
  39. package/web/dist/assets/useMatch-HNekgJrF.js +1 -0
  40. package/web/dist/assets/{useStickyScroll-7ZZcyLI0.js → useStickyScroll-BmqddNx5.js} +1 -1
  41. package/web/dist/index.html +9 -9
  42. package/web/dist/assets/ChannelView-B3qNylz5.js +0 -2
  43. package/web/dist/assets/KnowledgeView-C8r5hAa_.js +0 -1
  44. package/web/dist/assets/MessageItem-BkDlfhQC.js +0 -1
  45. package/web/dist/assets/ProjectView-DOtPYF6z.js +0 -1
  46. package/web/dist/assets/ProposalView-BpeiCg46.js +0 -1
  47. package/web/dist/assets/SocietyView-CbIpCGhP.js +0 -1
  48. package/web/dist/assets/SubjectThread-Do5As1cO.js +0 -1
  49. package/web/dist/assets/ThreadCard-DsIkg4Br.js +0 -1
  50. package/web/dist/assets/ThreadForms-BPowHd4M.js +0 -1
  51. package/web/dist/assets/compose-96HAETM2.js +0 -1
  52. package/web/dist/assets/governance-BbogC_X-.js +0 -1
  53. package/web/dist/assets/index-BXHV32GA.js +0 -23
  54. package/web/dist/assets/index-BoTts74M.css +0 -2
  55. package/web/dist/assets/useMatch-DYOY33wl.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";
@@ -63,7 +63,8 @@ var VERB_NAMES = [
63
63
  "update_dashboard",
64
64
  "create_cron",
65
65
  "update_cron",
66
- "remove_cron"
66
+ "remove_cron",
67
+ "remove_skill"
67
68
  ];
68
69
  var VerbNameSchema = z2.enum(VERB_NAMES);
69
70
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -123,6 +124,7 @@ var PLANNING_VERBS = [
123
124
  "archive_channel"
124
125
  ];
125
126
  var ARCHIVE_VERBS = ["archive_project"];
127
+ var CURATING_VERBS = ["remove_skill"];
126
128
  var SEED_ROLES = [
127
129
  {
128
130
  name: USER_ROLE,
@@ -132,7 +134,8 @@ var SEED_ROLES = [
132
134
  ...GOVERNANCE_VERBS,
133
135
  ...FRONT_DESK_VERBS,
134
136
  ...PLANNING_VERBS,
135
- ...ARCHIVE_VERBS
137
+ ...ARCHIVE_VERBS,
138
+ ...CURATING_VERBS
136
139
  ],
137
140
  wakeTriggers: [],
138
141
  maxReplicas: 1,
@@ -144,7 +147,7 @@ var SEED_ROLES = [
144
147
  {
145
148
  name: "steward",
146
149
  purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time. Opens a channel with create_channel when a topic or a workstream needs one, and archives it once its work is done. Owns how work is shaped over time: answers stages waiting too long, stages assigned to roles nobody fills, and work sent back again and again by replanning with plan_task, adjusting gates, or proposing the role a plan needs; sets each project's completion effect with configure_project; turns recurring plan shapes into society skills and planning norms into the society's norms. Prefers scaling an existing role over inventing a new one. Never approves its own proposals; hiring, roles, and retirements are decided by the user, and channels, reallocations, and skills the steward may decide. Curates society knowledge and skills.",
147
- verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS],
150
+ verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS, ...CURATING_VERBS],
148
151
  wakeTriggers: ["ops_event", "heartbeat"],
149
152
  maxReplicas: 1,
150
153
  backlogThreshold: 3,
@@ -619,7 +622,7 @@ var ArchiveProposalSchema = z4.object({
619
622
  var SkillProposalSchema = z4.object({
620
623
  name: NameSchema,
621
624
  summary: z4.string().min(1).max(200),
622
- body: z4.string().min(1)
625
+ body: z4.string().min(1).optional()
623
626
  });
624
627
  var ProposalCharterSchemas = {
625
628
  role: RoleCharterSchema,
@@ -697,6 +700,12 @@ var SkillSchema = z4.object({
697
700
  scope: z4.enum(["own", "society"]),
698
701
  path: z4.string().min(1)
699
702
  });
703
+ var RemovedSkillSchema = z4.object({
704
+ name: NameSchema,
705
+ reason: z4.string().min(1),
706
+ removedBy: NameSchema,
707
+ removedAt: IsoDateTimeSchema
708
+ });
700
709
  var HomeConflictSchema = z4.object({
701
710
  /** The copy, relative to the home. */
702
711
  path: z4.string().min(1),
@@ -740,6 +749,7 @@ var HomeFileDiffSchema = HomeFileChangeSchema.pick({ path: true, status: true })
740
749
  var KnowledgeSchema = z4.object({
741
750
  topic: NameSchema,
742
751
  project: NameSchema.nullable(),
752
+ revision: z4.number().int().min(0),
743
753
  updatedBy: NameSchema,
744
754
  updatedAt: IsoDateTimeSchema
745
755
  });
@@ -762,6 +772,24 @@ var ProposalOriginSchema = z4.object({
762
772
  thread: UlidSchema.optional(),
763
773
  channel: NameSchema.optional()
764
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
+ });
765
793
  var ProposalFrontmatterSchema = z4.object({
766
794
  id: UlidSchema,
767
795
  kind: ProposalKindSchema,
@@ -770,6 +798,8 @@ var ProposalFrontmatterSchema = z4.object({
770
798
  createdAt: IsoDateTimeSchema,
771
799
  /** Absent for a proposal filed outside a turn, or before proposals recorded it. */
772
800
  filedFrom: ProposalOriginSchema.optional(),
801
+ /** A skill proposal's folder, once taken; absent for one proposed as text alone. */
802
+ skillFiles: SkillFilesSchema.optional(),
773
803
  decidedBy: NameSchema.optional(),
774
804
  decidedAt: IsoDateTimeSchema.optional(),
775
805
  reason: z4.string().optional(),
@@ -872,7 +902,9 @@ var VerbInputs = {
872
902
  /** A project you belong to, or null for society knowledge, which the steward and the user curate. */
873
903
  project: NameSchema.nullable().default(null),
874
904
  topic: NameSchema,
875
- 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)
876
908
  }),
877
909
  remove_knowledge: z5.object({
878
910
  /** As for write_knowledge: a project you belong to, or null for society knowledge. */
@@ -940,7 +972,8 @@ var VerbInputs = {
940
972
  timezone: TimeZoneSchema.nullable().optional(),
941
973
  at: MomentSchema.optional()
942
974
  }),
943
- remove_cron: z5.object({ cron_id: UlidSchema, reason: z5.string().trim().min(1) })
975
+ remove_cron: z5.object({ cron_id: UlidSchema, reason: z5.string().trim().min(1) }),
976
+ remove_skill: z5.object({ name: NameSchema, reason: z5.string().trim().min(1) })
944
977
  };
945
978
 
946
979
  // packages/shared/dist/events.js
@@ -972,6 +1005,7 @@ var BOARD_EVENT_TYPES = [
972
1005
  "proposal.created",
973
1006
  "proposal.decided",
974
1007
  "proposal.provisioned",
1008
+ "proposal.files_taken",
975
1009
  "channel.added",
976
1010
  "channel.archived",
977
1011
  "role.added",
@@ -982,6 +1016,7 @@ var BOARD_EVENT_TYPES = [
982
1016
  "knowledge.removed",
983
1017
  "dashboard.updated",
984
1018
  "skill.promoted",
1019
+ "skill.removed",
985
1020
  "runner.added",
986
1021
  "user.signed_in",
987
1022
  "user.signed_out",
@@ -1377,6 +1412,9 @@ var RunnerAnswerSchema = z7.discriminatedUnion("ok", [
1377
1412
  ]);
1378
1413
  var WarmSessionsSchema = z7.object({ warm: z7.array(z7.string()) });
1379
1414
  var FileManifestSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1415
+ function fileVersion(sha, executable) {
1416
+ return executable ? `${sha}+x` : sha;
1417
+ }
1380
1418
  var FileReadSchema = z7.object({ paths: z7.array(z7.string()) });
1381
1419
  var FileContentsSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1382
1420
  function isSafeRelativePath(file) {
@@ -1695,7 +1733,7 @@ var RunnerConfigSchema = z11.object({
1695
1733
  });
1696
1734
 
1697
1735
  // packages/shared/dist/version.js
1698
- var STELLARIS_VERSION = "0.3.17";
1736
+ var STELLARIS_VERSION = "0.3.19";
1699
1737
 
1700
1738
  // packages/board-core/dist/errors.js
1701
1739
  var BoardError = class extends Error {
@@ -1749,7 +1787,9 @@ async function readMarkdown(file, schema) {
1749
1787
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1750
1788
  }
1751
1789
  async function readLooseMarkdown(file) {
1752
- const raw = await readFile(file, "utf8");
1790
+ return parseLooseMarkdown(await readFile(file, "utf8"));
1791
+ }
1792
+ function parseLooseMarkdown(raw) {
1753
1793
  try {
1754
1794
  const parsed = parseMatter(raw);
1755
1795
  return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
@@ -1936,13 +1976,14 @@ var FileTree = class {
1936
1976
  if (info.size > MAX_FILE_BYTES) {
1937
1977
  return null;
1938
1978
  }
1979
+ const executable = (info.mode & 73) !== 0;
1939
1980
  const known = this.hashes.get(absolute);
1940
1981
  if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
1941
- return known.sha;
1982
+ return fileVersion(known.sha, executable);
1942
1983
  }
1943
1984
  const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
1944
1985
  this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
1945
- return sha;
1986
+ return fileVersion(sha, executable);
1946
1987
  } catch (error) {
1947
1988
  if (isMissing2(error)) {
1948
1989
  return null;
@@ -2193,6 +2234,55 @@ var HomeRepos = class {
2193
2234
  }
2194
2235
  return files;
2195
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
+ }
2196
2286
  /** The conflict copies a merge added, against its first parent. */
2197
2287
  async keptCopies(home, parent, commit) {
2198
2288
  const added = await read(home, [
@@ -2608,6 +2698,10 @@ var BoardPaths = class {
2608
2698
  proposal(id) {
2609
2699
  return path5.join(this.proposals(), `${id}.md`);
2610
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
+ }
2611
2705
  decisions() {
2612
2706
  return path5.join(this.society, "decisions");
2613
2707
  }
@@ -2717,11 +2811,11 @@ var BoardPaths = class {
2717
2811
  agentSkills(name) {
2718
2812
  return path5.join(this.agent(name), "skills");
2719
2813
  }
2720
- agentSkill(name, skill) {
2721
- return path5.join(this.agentSkills(name), skill);
2814
+ agentSkill(name, skill2) {
2815
+ return path5.join(this.agentSkills(name), skill2);
2722
2816
  }
2723
- agentSkillFile(name, skill) {
2724
- return path5.join(this.agentSkill(name, skill), "SKILL.md");
2817
+ agentSkillFile(name, skill2) {
2818
+ return path5.join(this.agentSkill(name, skill2), "SKILL.md");
2725
2819
  }
2726
2820
  agentProjects(name) {
2727
2821
  return path5.join(this.agent(name), "projects");
@@ -2755,6 +2849,10 @@ var BoardPaths = class {
2755
2849
  removedKnowledge(scope) {
2756
2850
  return path5.join(this.state(), "removed-knowledge", scope);
2757
2851
  }
2852
+ /** Skills removed from the society, set aside outside the projection, one directory per removal. */
2853
+ removedSkills() {
2854
+ return path5.join(this.state(), "removed-skills");
2855
+ }
2758
2856
  };
2759
2857
 
2760
2858
  // packages/board-core/dist/tokens.js
@@ -2855,6 +2953,20 @@ var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2855
2953
  function withoutMentions(text) {
2856
2954
  return text.replaceAll(MENTION_PATTERN, "$1$2");
2857
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
+ }
2858
2970
  var CURATING_ROLES = [USER_ROLE, "steward"];
2859
2971
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2860
2972
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -3777,22 +3889,41 @@ ${charter.purpose}
3777
3889
  const dir = project2 === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project2);
3778
3890
  const topics = [];
3779
3891
  for (const file of await listFiles(dir)) {
3780
- const doc = await readMarkdown(path6.join(dir, file), z13.record(z13.string(), z13.unknown()));
3781
- const parsed = KnowledgeSchema.safeParse({
3782
- ...doc.data,
3783
- project: project2,
3784
- topic: file.replace(/\.md$/, "")
3785
- });
3786
- const data = parsed.success ? parsed.data : {
3787
- topic: file.replace(/\.md$/, ""),
3788
- project: project2,
3789
- updatedBy: SYSTEM_ACTOR.name,
3790
- updatedAt: (await stat2(path6.join(dir, file))).mtime.toISOString()
3791
- };
3792
- topics.push({ ...data, body: doc.body });
3892
+ topics.push(await this.knowledgeAt(path6.join(dir, file), project2, file.replace(/\.md$/, "")));
3793
3893
  }
3794
3894
  return topics;
3795
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
+ }
3796
3927
  /** Skills promoted to the society: every citizen may read them. */
3797
3928
  async listSocietySkills() {
3798
3929
  return this.listSkillsIn(this.paths.societySkills(), "society");
@@ -4476,11 +4607,11 @@ ${task2.body}`;
4476
4607
  file: path6.join(this.paths.societyKnowledge(), file)
4477
4608
  });
4478
4609
  }
4479
- for (const skill of await this.listSocietySkills()) {
4480
- shared.push({ kind: "skill", ref: `society/skills/${skill.name}`, file: skill.path });
4610
+ for (const skill2 of await this.listSocietySkills()) {
4611
+ shared.push({ kind: "skill", ref: `society/skills/${skill2.name}`, file: skill2.path });
4481
4612
  }
4482
- for (const skill of await this.listAgentSkills(actor.name)) {
4483
- shared.push({ kind: "skill", ref: `skills/${skill.name}`, file: skill.path });
4613
+ for (const skill2 of await this.listAgentSkills(actor.name)) {
4614
+ shared.push({ kind: "skill", ref: `skills/${skill2.name}`, file: skill2.path });
4484
4615
  }
4485
4616
  for (const file of await listFiles(this.paths.agentMemory(actor.name))) {
4486
4617
  shared.push({
@@ -4506,26 +4637,42 @@ ${task2.body}`;
4506
4637
  }
4507
4638
  /**
4508
4639
  * Writes a topic: a project's by its members, the society's by the curating roles. The note in
4509
- * 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.
4510
4644
  */
4511
4645
  async writeKnowledge(actor, input) {
4512
4646
  const args = VerbInputs.write_knowledge.parse(input);
4513
4647
  await this.authorize(actor, "write_knowledge");
4514
4648
  return this.mutex.run(async () => {
4515
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);
4516
4662
  const data = KnowledgeSchema.parse({
4517
4663
  topic: args.topic,
4518
4664
  project: args.project,
4665
+ revision: base + 1,
4519
4666
  updatedBy: actor.name,
4520
4667
  updatedAt: this.now().toISOString()
4521
4668
  });
4522
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4523
- const replaced = await exists(file);
4669
+ const replaced = current !== null;
4524
4670
  await writeMarkdown(file, data, args.body);
4525
4671
  await this.events.append("knowledge.written", actor.name, {
4526
4672
  topic: args.topic,
4527
4673
  project: args.project,
4528
- replaced
4674
+ replaced,
4675
+ revision: data.revision
4529
4676
  });
4530
4677
  const where = args.project === null ? "general" : channelRef(args.project, "general");
4531
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" : ""}.`, {
@@ -4548,7 +4695,7 @@ ${task2.body}`;
4548
4695
  await this.authorize(actor, "remove_knowledge");
4549
4696
  return this.mutex.run(async () => {
4550
4697
  await this.assertMayCurate(actor, args.project);
4551
- 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);
4552
4699
  if (!await exists(file)) {
4553
4700
  throw new BoardError("NOT_FOUND", `${args.project ?? "the society"} has no knowledge topic ${args.topic}`);
4554
4701
  }
@@ -4583,6 +4730,40 @@ ${task2.body}`;
4583
4730
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project2}`);
4584
4731
  }
4585
4732
  }
4733
+ /**
4734
+ * Takes a skill out of the society's skills, by the steward or the user. Its directory leaves
4735
+ * the projection, so no skills index or search lists it from the next turn, and is set aside
4736
+ * under `state/removed-skills` for the user to restore by hand. The notice is the board's, as a
4737
+ * promotion's is, so a removal by the user wakes no front desk, and quotes the reason without
4738
+ * its mentions. The proposer's own copy in its home is not touched.
4739
+ */
4740
+ async removeSkill(actor, input) {
4741
+ const args = VerbInputs.remove_skill.parse(input);
4742
+ await this.authorize(actor, "remove_skill");
4743
+ if (!CURATING_ROLES.includes(actor.role)) {
4744
+ throw new BoardError("FORBIDDEN", "the society's skills are curated by the steward and the user");
4745
+ }
4746
+ return this.mutex.run(async () => {
4747
+ if (!await exists(this.paths.societySkillFile(args.name))) {
4748
+ throw new BoardError("NOT_FOUND", `the society has no skill ${args.name}`);
4749
+ }
4750
+ const removed = RemovedSkillSchema.parse({
4751
+ name: args.name,
4752
+ reason: args.reason,
4753
+ removedBy: actor.name,
4754
+ removedAt: this.now().toISOString()
4755
+ });
4756
+ const aside = this.paths.removedSkills();
4757
+ await mkdir4(aside, { recursive: true });
4758
+ await rename3(this.paths.societySkill(args.name), path6.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
4759
+ await this.events.append("skill.removed", actor.name, {
4760
+ name: args.name,
4761
+ reason: args.reason
4762
+ });
4763
+ await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${sentence(withoutMentions(args.reason))}. Every citizen's skills index stops listing it from its next turn.`);
4764
+ return removed;
4765
+ });
4766
+ }
4586
4767
  /**
4587
4768
  * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4588
4769
  * copy of the projection pulled when it started, so a revision other than the current one means
@@ -5565,7 +5746,7 @@ ${args.summary}`);
5565
5746
  throw new BoardError("VALIDATION", `invalid ${args.kind} charter: ${parsed.error.message}`);
5566
5747
  }
5567
5748
  return this.mutex.run(async () => {
5568
- const proposal2 = {
5749
+ let proposal2 = {
5569
5750
  ...ProposalFrontmatterSchema.parse({
5570
5751
  id: this.newId(),
5571
5752
  kind: args.kind,
@@ -5584,6 +5765,14 @@ ${args.summary}`);
5584
5765
  body: args.rationale
5585
5766
  };
5586
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
+ }
5587
5776
  await this.writeProposal(proposal2);
5588
5777
  await this.events.append("proposal.created", actor.name, {
5589
5778
  proposalId: proposal2.id,
@@ -5805,6 +5994,8 @@ ${rationale}`}`);
5805
5994
  return this.writeKnowledge(actor, VerbInputs.write_knowledge.parse(input));
5806
5995
  case "remove_knowledge":
5807
5996
  return this.removeKnowledge(actor, VerbInputs.remove_knowledge.parse(input));
5997
+ case "remove_skill":
5998
+ return this.removeSkill(actor, VerbInputs.remove_skill.parse(input));
5808
5999
  case "plan_task":
5809
6000
  return this.planTask(actor, VerbInputs.plan_task.parse(input));
5810
6001
  case "advance_task":
@@ -6648,7 +6839,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6648
6839
  ...agent2.model === void 0 ? {} : { model: agent2.model },
6649
6840
  ...lastModel === void 0 ? {} : { lastModel },
6650
6841
  ...agent2.effort === void 0 ? {} : { effort: agent2.effort },
6651
- skills: (await this.listAgentSkills(name)).map((skill) => skill.name),
6842
+ skills: (await this.listAgentSkills(name)).map((skill2) => skill2.name),
6652
6843
  memberships: agent2.memberships,
6653
6844
  subscriptions: agent2.subscriptions,
6654
6845
  claimsHeld,
@@ -6797,9 +6988,9 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6797
6988
  return { agent: agent2.name, releasedTasks };
6798
6989
  }
6799
6990
  case "skill": {
6800
- const skill = SkillProposalSchema.parse(proposal2.charter);
6801
- const replaced = await this.promoteSkillUnlocked(by, proposal2.proposedBy, skill, meta);
6802
- return { skill: skill.name, replaced };
6991
+ const skill2 = SkillProposalSchema.parse(proposal2.charter);
6992
+ const replaced = await this.promoteSkillUnlocked(by, proposal2, skill2, meta);
6993
+ return { skill: skill2.name, replaced };
6803
6994
  }
6804
6995
  case "archive": {
6805
6996
  const input = ArchiveProposalSchema.parse(proposal2.charter);
@@ -6812,26 +7003,180 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6812
7003
  return void 0;
6813
7004
  }
6814
7005
  }
6815
- /** Writes a promoted skill under the society's skills, where every citizen's skills index lists it. */
6816
- async promoteSkillUnlocked(by, proposedBy, skill, meta) {
6817
- const file = this.paths.societySkillFile(skill.name);
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);
7013
+ const file = this.paths.societySkillFile(skill2.name);
6818
7014
  const replaced = await exists(file);
6819
- await ensureDir(this.paths.societySkill(skill.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;
6820
7027
  await writeMarkdown(file, {
6821
- name: skill.name,
6822
- description: skill.summary,
6823
- metadata: { proposedBy, promotedBy: by, promotedAt: this.now().toISOString() }
6824
- }, skill.body);
7028
+ name: skill2.name,
7029
+ description: skill2.summary,
7030
+ metadata: {
7031
+ proposedBy: proposal2.proposedBy,
7032
+ promotedBy: by,
7033
+ promotedAt: this.now().toISOString(),
7034
+ ...taken === void 0 ? {} : { commit: taken.commit }
7035
+ }
7036
+ }, body);
6825
7037
  await this.events.append("skill.promoted", by, {
6826
- name: skill.name,
6827
- summary: skill.summary,
6828
- proposedBy,
7038
+ name: skill2.name,
7039
+ summary: skill2.summary,
7040
+ proposedBy: proposal2.proposedBy,
6829
7041
  replaced,
7042
+ ...taken === void 0 ? {} : { files: taken.files.length },
6830
7043
  ...meta
6831
7044
  });
6832
- 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.`);
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.`);
6833
7046
  return replaced;
6834
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
+ }
6835
7180
  async validateAddAgent(input) {
6836
7181
  if (await exists(this.paths.agentFile(input.name))) {
6837
7182
  throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
@@ -7343,67 +7688,70 @@ ${charter.purpose}
7343
7688
  * retired. Validation runs before any write, so an impossible provision fails the decision.
7344
7689
  */
7345
7690
  async decide(actor, proposalId, outcome, reason) {
7346
- return this.mutex.run(async () => {
7347
- const proposal2 = await this.readProposal(proposalId);
7348
- if (proposal2.status !== "proposed") {
7349
- throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${proposal2.status}`);
7350
- }
7351
- if (proposal2.proposedBy === actor.name) {
7352
- throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7353
- }
7354
- const approvers = ROLE_KIND_APPROVERS[proposal2.kind];
7355
- if (!approvers.includes(actor.role)) {
7356
- throw new BoardError("FORBIDDEN", `a ${proposal2.kind} proposal requires one of: ${approvers.join(", ")}`);
7357
- }
7358
- if (outcome === "approved") {
7359
- await this.validateProvision(proposal2.kind, proposal2.charter);
7360
- }
7361
- this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn2) => turn2.thread === proposalId);
7362
- if (outcome === "approved" && proposal2.kind === "archive") {
7363
- await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal2.charter).project);
7364
- }
7365
- const ts = this.now().toISOString();
7366
- const decision = DecisionSchema.parse({
7367
- id: this.newId(),
7368
- proposalId,
7369
- decidedBy: actor.name,
7370
- outcome,
7371
- ...reason === void 0 ? {} : { reason },
7372
- ts
7373
- });
7374
- await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7375
- const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal2) : void 0;
7376
- const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7377
- await this.writeProposal({
7378
- ...proposal2,
7379
- status,
7380
- decidedBy: actor.name,
7381
- decidedAt: ts,
7382
- ...reason === void 0 ? {} : { reason },
7383
- ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7384
- });
7385
- 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, {
7386
7742
  proposalId,
7387
- outcome,
7388
7743
  kind: proposal2.kind,
7389
- proposedBy: proposal2.proposedBy,
7390
- ...proposal2.filedFrom === void 0 ? {} : { filedFrom: proposal2.filedFrom }
7744
+ ...provision
7391
7745
  });
7392
- if (provision !== void 0) {
7393
- await this.events.append("proposal.provisioned", actor.name, {
7394
- proposalId,
7395
- kind: proposal2.kind,
7396
- ...provision
7397
- });
7398
- }
7399
- const verdict = outcome === "approved" ? "Approved" : "Rejected";
7400
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7401
- const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7402
- const thread2 = await this.proposalThreadUnlocked(actor.name, proposal2);
7403
- await this.appendThreadMessage(actor.name, thread2.id, `${verdict}: ${describeCharter(proposal2.kind, proposal2.charter)}.${why}${result}`, { step: { action: outcome } });
7404
- await this.endThreadOf(proposalId, actor.name, outcome);
7405
- return decision;
7406
- });
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;
7407
7755
  }
7408
7756
  /** The opener, the people of its subject, and anyone who posted in it; mentions reach the rest. */
7409
7757
  async participatesInThread(actor, thread2, threads) {
@@ -7988,7 +8336,7 @@ var knowledge = program.command("knowledge").description("Shared knowledge, by p
7988
8336
  knowledge.command("list [project]").description("List knowledge topics of a project, or of the society when no project is given").action(async (projectSlug) => {
7989
8337
  const board = await open();
7990
8338
  const topics = await board.listKnowledge(projectSlug ?? null);
7991
- 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"));
7992
8340
  });
7993
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) => {
7994
8342
  const board = await open();
@@ -7998,17 +8346,19 @@ knowledge.command("show <topic>").description("Print a knowledge topic").option(
7998
8346
  }
7999
8347
  print(found, () => found.body);
8000
8348
  });
8001
- 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) => {
8002
8350
  const board = await open();
8003
8351
  const body = opts.file === void 0 ? await readStdin() : await readFile6(path7.resolve(opts.file), "utf8");
8004
8352
  const written = await board.writeKnowledge(await actorFor(board, opts.as), {
8005
8353
  project: opts.project ?? null,
8006
8354
  topic,
8007
- body
8355
+ body,
8356
+ revision: Number(opts.revision)
8008
8357
  });
8009
- 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}`);
8010
8359
  });
8011
- program.command("skill").description("Skills promoted to the society").command("list").description("List the society's skills, and each member's own").action(async () => {
8360
+ var skill = program.command("skill").description("Skills promoted to the society");
8361
+ skill.command("list").description("List the society's skills, and each member's own").action(async () => {
8012
8362
  const board = await open();
8013
8363
  const society = await board.listSocietySkills();
8014
8364
  const own = [];
@@ -8023,6 +8373,14 @@ program.command("skill").description("Skills promoted to the society").command("
8023
8373
  ...own.flatMap(({ agent: name, skills }) => skills.map((s) => `${name} ${s.name} ${s.summary}`))
8024
8374
  ].join("\n") || "No skills yet.");
8025
8375
  });
8376
+ skill.command("remove <name>").description("Remove a skill from the society's skills; its text is set aside, not destroyed").requiredOption("--reason <text>", "why it is wrong, superseded, or no longer used").option("--as <agent>", "act as this agent instead of the user").action(async (name, opts) => {
8377
+ const board = await open();
8378
+ const removed = await board.removeSkill(await actorFor(board, opts.as), {
8379
+ name,
8380
+ reason: opts.reason
8381
+ });
8382
+ print(removed, () => `Removed ${removed.name} from the society's skills: ${removed.reason}`);
8383
+ });
8026
8384
  var turn = program.command("turn").description("Turn operations");
8027
8385
  turn.command("digest <agent>").description("Show the unread messages the agent's next turn opens with, in one scope or all; the cursors stay put").option("--limit <n>", "maximum messages", "50").option("--project <slug>", "the scope whose turn to show: a project, or society").action(async (agentName, opts) => {
8028
8386
  const board = await open();