@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
@@ -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 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,
@@ -634,7 +637,7 @@ var ArchiveProposalSchema = z4.object({
634
637
  var SkillProposalSchema = z4.object({
635
638
  name: NameSchema,
636
639
  summary: z4.string().min(1).max(200),
637
- body: z4.string().min(1)
640
+ body: z4.string().min(1).optional()
638
641
  });
639
642
  var ProposalCharterSchemas = {
640
643
  role: RoleCharterSchema,
@@ -712,6 +715,12 @@ var SkillSchema = z4.object({
712
715
  scope: z4.enum(["own", "society"]),
713
716
  path: z4.string().min(1)
714
717
  });
718
+ var RemovedSkillSchema = z4.object({
719
+ name: NameSchema,
720
+ reason: z4.string().min(1),
721
+ removedBy: NameSchema,
722
+ removedAt: IsoDateTimeSchema
723
+ });
715
724
  var HomeConflictSchema = z4.object({
716
725
  /** The copy, relative to the home. */
717
726
  path: z4.string().min(1),
@@ -755,6 +764,7 @@ var HomeFileDiffSchema = HomeFileChangeSchema.pick({ path: true, status: true })
755
764
  var KnowledgeSchema = z4.object({
756
765
  topic: NameSchema,
757
766
  project: NameSchema.nullable(),
767
+ revision: z4.number().int().min(0),
758
768
  updatedBy: NameSchema,
759
769
  updatedAt: IsoDateTimeSchema
760
770
  });
@@ -777,6 +787,24 @@ var ProposalOriginSchema = z4.object({
777
787
  thread: UlidSchema.optional(),
778
788
  channel: NameSchema.optional()
779
789
  });
790
+ var SKILL_FILES_LIMIT = 100;
791
+ var SKILL_BYTES_LIMIT = 10 * 1024 * 1024;
792
+ var SkillFileSchema = z4.object({
793
+ path: z4.string().min(1),
794
+ size: z4.number().int().nonnegative(),
795
+ executable: z4.boolean()
796
+ });
797
+ var SkillFilesSchema = z4.object({
798
+ /** The commit of the proposer's home the files came from. */
799
+ commit: CommitIdSchema,
800
+ takenAt: IsoDateTimeSchema,
801
+ files: z4.array(SkillFileSchema)
802
+ });
803
+ var SkillFileContentSchema = SkillFileSchema.extend({
804
+ text: z4.string().nullable(),
805
+ /** The text was cut at its size limit. */
806
+ truncated: z4.boolean()
807
+ });
780
808
  var ProposalFrontmatterSchema = z4.object({
781
809
  id: UlidSchema,
782
810
  kind: ProposalKindSchema,
@@ -785,6 +813,8 @@ var ProposalFrontmatterSchema = z4.object({
785
813
  createdAt: IsoDateTimeSchema,
786
814
  /** Absent for a proposal filed outside a turn, or before proposals recorded it. */
787
815
  filedFrom: ProposalOriginSchema.optional(),
816
+ /** A skill proposal's folder, once taken; absent for one proposed as text alone. */
817
+ skillFiles: SkillFilesSchema.optional(),
788
818
  decidedBy: NameSchema.optional(),
789
819
  decidedAt: IsoDateTimeSchema.optional(),
790
820
  reason: z4.string().optional(),
@@ -887,7 +917,9 @@ var VerbInputs = {
887
917
  /** A project you belong to, or null for society knowledge, which the steward and the user curate. */
888
918
  project: NameSchema.nullable().default(null),
889
919
  topic: NameSchema,
890
- body: z5.string().min(1)
920
+ body: z5.string().min(1),
921
+ /** The `revision` in the frontmatter of the topic you read, 0 when it names none or for a new topic. */
922
+ revision: z5.number().int().min(0)
891
923
  }),
892
924
  remove_knowledge: z5.object({
893
925
  /** As for write_knowledge: a project you belong to, or null for society knowledge. */
@@ -955,7 +987,8 @@ var VerbInputs = {
955
987
  timezone: TimeZoneSchema.nullable().optional(),
956
988
  at: MomentSchema.optional()
957
989
  }),
958
- remove_cron: z5.object({ cron_id: UlidSchema, reason: z5.string().trim().min(1) })
990
+ remove_cron: z5.object({ cron_id: UlidSchema, reason: z5.string().trim().min(1) }),
991
+ remove_skill: z5.object({ name: NameSchema, reason: z5.string().trim().min(1) })
959
992
  };
960
993
  var VERB_DESCRIPTIONS = {
961
994
  post_message: "Post a markdown message to a channel, or into a thread when thread_id is given; a thread's message goes to the thread's channel.",
@@ -976,7 +1009,7 @@ var VERB_DESCRIPTIONS = {
976
1009
  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).",
977
1010
  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.",
978
1011
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
979
- 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.",
1012
+ 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.",
980
1013
  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.",
981
1014
  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.",
982
1015
  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.",
@@ -987,7 +1020,8 @@ var VERB_DESCRIPTIONS = {
987
1020
  update_dashboard: "Replace the dashboard of a project you are a member of, the status page the user reads on its view, with a whole markdown body. Pass the revision from the frontmatter of the dashboard you read; if it was updated since, the call is refused with the current text to merge yours into. dashboard.md in the board projection is a copy, so editing it changes nothing.",
988
1021
  create_cron: "Set a cron that wakes you later: a title, a note on what to do then, and either cron, a five-field expression (minute hour day-of-month month day-of-week) read in timezone or the society's, or at, one time. It fires in this turn's conversation unless you name a project, thread_id, or channel, at most every 15 minutes, and every fire is a turn. Only the user, the steward, and the concierge set one that wakes another citizen (agent).",
989
1022
  update_cron: "Pause or resume a cron, or change its title, note, or schedule; resuming or rescheduling starts it afresh from now. Yours, or anyone's for the user, the steward, and the concierge.",
990
- remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires."
1023
+ remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires.",
1024
+ remove_skill: "Remove a skill from the society's skills, with a reason, when it is wrong, superseded, or no longer used: no skills index lists it from the next turn, and a notice in general says so with the reason. Its proposer's own copy stays in its home. Steward and user only; the board keeps its text aside for the user to restore."
991
1025
  };
992
1026
 
993
1027
  // packages/shared/dist/events.js
@@ -1019,6 +1053,7 @@ var BOARD_EVENT_TYPES = [
1019
1053
  "proposal.created",
1020
1054
  "proposal.decided",
1021
1055
  "proposal.provisioned",
1056
+ "proposal.files_taken",
1022
1057
  "channel.added",
1023
1058
  "channel.archived",
1024
1059
  "role.added",
@@ -1029,6 +1064,7 @@ var BOARD_EVENT_TYPES = [
1029
1064
  "knowledge.removed",
1030
1065
  "dashboard.updated",
1031
1066
  "skill.promoted",
1067
+ "skill.removed",
1032
1068
  "runner.added",
1033
1069
  "user.signed_in",
1034
1070
  "user.signed_out",
@@ -1446,6 +1482,9 @@ var RunnerAnswerSchema = z7.discriminatedUnion("ok", [
1446
1482
  ]);
1447
1483
  var WarmSessionsSchema = z7.object({ warm: z7.array(z7.string()) });
1448
1484
  var FileManifestSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1485
+ function fileVersion(sha, executable) {
1486
+ return executable ? `${sha}+x` : sha;
1487
+ }
1449
1488
  var FileReadSchema = z7.object({ paths: z7.array(z7.string()) });
1450
1489
  var FileContentsSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1451
1490
  function isSafeRelativePath(file) {
@@ -1822,7 +1861,7 @@ var RunnerConfigSchema = z11.object({
1822
1861
  });
1823
1862
 
1824
1863
  // packages/shared/dist/version.js
1825
- var STELLARIS_VERSION = "0.3.17";
1864
+ var STELLARIS_VERSION = "0.3.19";
1826
1865
 
1827
1866
  // packages/board-core/dist/errors.js
1828
1867
  var BoardError = class extends Error {
@@ -1879,7 +1918,9 @@ async function readMarkdown(file, schema) {
1879
1918
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1880
1919
  }
1881
1920
  async function readLooseMarkdown(file) {
1882
- const raw = await readFile(file, "utf8");
1921
+ return parseLooseMarkdown(await readFile(file, "utf8"));
1922
+ }
1923
+ function parseLooseMarkdown(raw) {
1883
1924
  try {
1884
1925
  const parsed = parseMatter(raw);
1885
1926
  return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
@@ -2066,13 +2107,14 @@ var FileTree = class {
2066
2107
  if (info.size > MAX_FILE_BYTES) {
2067
2108
  return null;
2068
2109
  }
2110
+ const executable = (info.mode & 73) !== 0;
2069
2111
  const known = this.hashes.get(absolute);
2070
2112
  if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
2071
- return known.sha;
2113
+ return fileVersion(known.sha, executable);
2072
2114
  }
2073
2115
  const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
2074
2116
  this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
2075
- return sha;
2117
+ return fileVersion(sha, executable);
2076
2118
  } catch (error) {
2077
2119
  if (isMissing2(error)) {
2078
2120
  return null;
@@ -2323,6 +2365,55 @@ var HomeRepos = class {
2323
2365
  }
2324
2366
  return files;
2325
2367
  }
2368
+ /**
2369
+ * The files of a folder in a home's newest commit, by path within the folder, with the commit;
2370
+ * conflict copies and anything but a regular file are left out. Null when the home is not a
2371
+ * repository yet.
2372
+ */
2373
+ async folder(home, folder) {
2374
+ if (!await exists(path4.join(home, ".git"))) {
2375
+ return null;
2376
+ }
2377
+ const commit = (await read(home, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
2378
+ const prefix = `${folder}/`;
2379
+ const listed = await read(home, [
2380
+ "ls-tree",
2381
+ "-r",
2382
+ "-z",
2383
+ "--long",
2384
+ "--full-tree",
2385
+ commit,
2386
+ prefix
2387
+ ]);
2388
+ const files = [];
2389
+ for (const entry of listed.split("\0")) {
2390
+ const match = /^(100644|100755) blob ([0-9a-f]+) +(\d+)\t(.+)$/.exec(entry);
2391
+ const [, mode = "", object = "", size = "0", file = ""] = match ?? [];
2392
+ if (match === null || !file.startsWith(prefix) || !isSafeRelativePath(file) || conflictCopyOf(file) !== null) {
2393
+ continue;
2394
+ }
2395
+ files.push({
2396
+ path: file.slice(prefix.length),
2397
+ object,
2398
+ size: Number.parseInt(size, 10),
2399
+ executable: mode === "100755"
2400
+ });
2401
+ }
2402
+ return { commit, files };
2403
+ }
2404
+ /** A blob of a home's repository, as `folder` names it. */
2405
+ async blob(home, object) {
2406
+ const result = await execa("git", ["cat-file", "blob", object], {
2407
+ cwd: home,
2408
+ encoding: "buffer",
2409
+ stripFinalNewline: false,
2410
+ reject: false
2411
+ });
2412
+ if (result.exitCode !== 0) {
2413
+ throw new Error(`git cat-file failed: ${Buffer.from(result.stderr).toString().trim()}`);
2414
+ }
2415
+ return Buffer.from(result.stdout);
2416
+ }
2326
2417
  /** The conflict copies a merge added, against its first parent. */
2327
2418
  async keptCopies(home, parent, commit) {
2328
2419
  const added = await read(home, [
@@ -2738,6 +2829,10 @@ var BoardPaths = class {
2738
2829
  proposal(id) {
2739
2830
  return path5.join(this.proposals(), `${id}.md`);
2740
2831
  }
2832
+ /** A skill proposal's folder, as the board took it from its proposer's home. */
2833
+ proposalFiles(id) {
2834
+ return path5.join(this.proposals(), id);
2835
+ }
2741
2836
  decisions() {
2742
2837
  return path5.join(this.society, "decisions");
2743
2838
  }
@@ -2885,6 +2980,10 @@ var BoardPaths = class {
2885
2980
  removedKnowledge(scope) {
2886
2981
  return path5.join(this.state(), "removed-knowledge", scope);
2887
2982
  }
2983
+ /** Skills removed from the society, set aside outside the projection, one directory per removal. */
2984
+ removedSkills() {
2985
+ return path5.join(this.state(), "removed-skills");
2986
+ }
2888
2987
  };
2889
2988
 
2890
2989
  // packages/board-core/dist/tokens.js
@@ -2985,6 +3084,20 @@ var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2985
3084
  function withoutMentions(text) {
2986
3085
  return text.replaceAll(MENTION_PATTERN, "$1$2");
2987
3086
  }
3087
+ var SKILL_TEXT_LIMIT_CHARS = 1e5;
3088
+ function skillFolderOf(proposal) {
3089
+ if (proposal.kind !== "skill") {
3090
+ return null;
3091
+ }
3092
+ const parsed = SkillProposalSchema.safeParse(proposal.charter);
3093
+ return parsed.success && parsed.data.body === void 0 ? parsed.data.name : null;
3094
+ }
3095
+ function proposedFrom(origin, where) {
3096
+ return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel;
3097
+ }
3098
+ function megabytes(bytes) {
3099
+ return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
3100
+ }
2988
3101
  var CURATING_ROLES = [USER_ROLE, "steward"];
2989
3102
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2990
3103
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -3907,22 +4020,41 @@ ${charter.purpose}
3907
4020
  const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
3908
4021
  const topics = [];
3909
4022
  for (const file of await listFiles(dir)) {
3910
- const doc = await readMarkdown(path6.join(dir, file), z13.record(z13.string(), z13.unknown()));
3911
- const parsed = KnowledgeSchema.safeParse({
3912
- ...doc.data,
3913
- project,
3914
- topic: file.replace(/\.md$/, "")
3915
- });
3916
- const data = parsed.success ? parsed.data : {
3917
- topic: file.replace(/\.md$/, ""),
3918
- project,
3919
- updatedBy: SYSTEM_ACTOR.name,
3920
- updatedAt: (await stat2(path6.join(dir, file))).mtime.toISOString()
3921
- };
3922
- topics.push({ ...data, body: doc.body });
4023
+ topics.push(await this.knowledgeAt(path6.join(dir, file), project, file.replace(/\.md$/, "")));
3923
4024
  }
3924
4025
  return topics;
3925
4026
  }
4027
+ knowledgeFile(project, topic) {
4028
+ return project === null ? this.paths.societyKnowledgeFile(topic) : this.paths.projectKnowledgeFile(project, topic);
4029
+ }
4030
+ /**
4031
+ * Read loosely, as a dashboard is: a topic written before revisions counts as revision 0, and an
4032
+ * author or a time that does not parse is the board's at the file's mtime.
4033
+ */
4034
+ async knowledgeAt(file, project, topic) {
4035
+ const { data, body } = await readLooseMarkdown(file);
4036
+ const revision = z13.number().int().min(0).safeParse(data["revision"]);
4037
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
4038
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
4039
+ return {
4040
+ topic,
4041
+ project,
4042
+ revision: revision.success ? revision.data : 0,
4043
+ updatedBy: updatedBy.success ? updatedBy.data : SYSTEM_ACTOR.name,
4044
+ updatedAt: updatedAt.success ? updatedAt.data : (await stat2(file)).mtime.toISOString(),
4045
+ body
4046
+ };
4047
+ }
4048
+ /** The highest revision among a topic's removed copies, 0 when it has none. */
4049
+ async removedRevision(project, topic) {
4050
+ const aside = this.paths.removedKnowledge(project ?? SOCIETY_SCOPE);
4051
+ let highest = 0;
4052
+ for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
4053
+ const copy = await this.knowledgeAt(path6.join(aside, file), project, topic);
4054
+ highest = Math.max(highest, copy.revision);
4055
+ }
4056
+ return highest;
4057
+ }
3926
4058
  /** Skills promoted to the society: every citizen may read them. */
3927
4059
  async listSocietySkills() {
3928
4060
  return this.listSkillsIn(this.paths.societySkills(), "society");
@@ -4636,26 +4768,42 @@ ${task.body}`;
4636
4768
  }
4637
4769
  /**
4638
4770
  * Writes a topic: a project's by its members, the society's by the curating roles. The note in
4639
- * the matching general channel carries no mention, so nobody is woken.
4771
+ * the matching general channel carries no mention, so nobody is woken. A turn reads a topic from
4772
+ * the copy of the projection pulled when it started, so a revision other than the current one, 0
4773
+ * for a topic not there, means someone wrote or removed it since; the refusal carries the current
4774
+ * text, which that copy lacks.
4640
4775
  */
4641
4776
  async writeKnowledge(actor, input) {
4642
4777
  const args = VerbInputs.write_knowledge.parse(input);
4643
4778
  await this.authorize(actor, "write_knowledge");
4644
4779
  return this.mutex.run(async () => {
4645
4780
  await this.assertMayCurate(actor, args.project);
4781
+ const file = this.knowledgeFile(args.project, args.topic);
4782
+ const current = await exists(file) ? await this.knowledgeAt(file, args.project, args.topic) : null;
4783
+ const place = args.project ?? "the society";
4784
+ if (current === null && args.revision !== 0) {
4785
+ 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.`);
4786
+ }
4787
+ if (current !== null && args.revision !== current.revision) {
4788
+ 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}.
4789
+
4790
+ ${current.body}`);
4791
+ }
4792
+ const base = current?.revision ?? await this.removedRevision(args.project, args.topic);
4646
4793
  const data = KnowledgeSchema.parse({
4647
4794
  topic: args.topic,
4648
4795
  project: args.project,
4796
+ revision: base + 1,
4649
4797
  updatedBy: actor.name,
4650
4798
  updatedAt: this.now().toISOString()
4651
4799
  });
4652
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4653
- const replaced = await exists(file);
4800
+ const replaced = current !== null;
4654
4801
  await writeMarkdown(file, data, args.body);
4655
4802
  await this.events.append("knowledge.written", actor.name, {
4656
4803
  topic: args.topic,
4657
4804
  project: args.project,
4658
- replaced
4805
+ replaced,
4806
+ revision: data.revision
4659
4807
  });
4660
4808
  const where = args.project === null ? "general" : channelRef(args.project, "general");
4661
4809
  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" : ""}.`, {
@@ -4678,7 +4826,7 @@ ${task.body}`;
4678
4826
  await this.authorize(actor, "remove_knowledge");
4679
4827
  return this.mutex.run(async () => {
4680
4828
  await this.assertMayCurate(actor, args.project);
4681
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4829
+ const file = this.knowledgeFile(args.project, args.topic);
4682
4830
  if (!await exists(file)) {
4683
4831
  throw new BoardError("NOT_FOUND", `${args.project ?? "the society"} has no knowledge topic ${args.topic}`);
4684
4832
  }
@@ -4713,6 +4861,40 @@ ${task.body}`;
4713
4861
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project}`);
4714
4862
  }
4715
4863
  }
4864
+ /**
4865
+ * Takes a skill out of the society's skills, by the steward or the user. Its directory leaves
4866
+ * the projection, so no skills index or search lists it from the next turn, and is set aside
4867
+ * under `state/removed-skills` for the user to restore by hand. The notice is the board's, as a
4868
+ * promotion's is, so a removal by the user wakes no front desk, and quotes the reason without
4869
+ * its mentions. The proposer's own copy in its home is not touched.
4870
+ */
4871
+ async removeSkill(actor, input) {
4872
+ const args = VerbInputs.remove_skill.parse(input);
4873
+ await this.authorize(actor, "remove_skill");
4874
+ if (!CURATING_ROLES.includes(actor.role)) {
4875
+ throw new BoardError("FORBIDDEN", "the society's skills are curated by the steward and the user");
4876
+ }
4877
+ return this.mutex.run(async () => {
4878
+ if (!await exists(this.paths.societySkillFile(args.name))) {
4879
+ throw new BoardError("NOT_FOUND", `the society has no skill ${args.name}`);
4880
+ }
4881
+ const removed = RemovedSkillSchema.parse({
4882
+ name: args.name,
4883
+ reason: args.reason,
4884
+ removedBy: actor.name,
4885
+ removedAt: this.now().toISOString()
4886
+ });
4887
+ const aside = this.paths.removedSkills();
4888
+ await mkdir4(aside, { recursive: true });
4889
+ await rename3(this.paths.societySkill(args.name), path6.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
4890
+ await this.events.append("skill.removed", actor.name, {
4891
+ name: args.name,
4892
+ reason: args.reason
4893
+ });
4894
+ 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.`);
4895
+ return removed;
4896
+ });
4897
+ }
4716
4898
  /**
4717
4899
  * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4718
4900
  * copy of the projection pulled when it started, so a revision other than the current one means
@@ -5695,7 +5877,7 @@ ${args.summary}`);
5695
5877
  throw new BoardError("VALIDATION", `invalid ${args.kind} charter: ${parsed.error.message}`);
5696
5878
  }
5697
5879
  return this.mutex.run(async () => {
5698
- const proposal = {
5880
+ let proposal = {
5699
5881
  ...ProposalFrontmatterSchema.parse({
5700
5882
  id: this.newId(),
5701
5883
  kind: args.kind,
@@ -5714,6 +5896,14 @@ ${args.summary}`);
5714
5896
  body: args.rationale
5715
5897
  };
5716
5898
  await this.validateProvision(proposal.kind, proposal.charter);
5899
+ const skill = skillFolderOf(proposal);
5900
+ if (skill !== null && proposal.filedFrom === void 0) {
5901
+ const taken = await this.takeSkillFolderUnlocked(proposal, skill);
5902
+ if (typeof taken === "string") {
5903
+ throw new BoardError("VALIDATION", taken);
5904
+ }
5905
+ proposal = { ...proposal, skillFiles: taken };
5906
+ }
5717
5907
  await this.writeProposal(proposal);
5718
5908
  await this.events.append("proposal.created", actor.name, {
5719
5909
  proposalId: proposal.id,
@@ -5935,6 +6125,8 @@ ${rationale}`}`);
5935
6125
  return this.writeKnowledge(actor, VerbInputs.write_knowledge.parse(input));
5936
6126
  case "remove_knowledge":
5937
6127
  return this.removeKnowledge(actor, VerbInputs.remove_knowledge.parse(input));
6128
+ case "remove_skill":
6129
+ return this.removeSkill(actor, VerbInputs.remove_skill.parse(input));
5938
6130
  case "plan_task":
5939
6131
  return this.planTask(actor, VerbInputs.plan_task.parse(input));
5940
6132
  case "advance_task":
@@ -6928,7 +7120,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6928
7120
  }
6929
7121
  case "skill": {
6930
7122
  const skill = SkillProposalSchema.parse(proposal.charter);
6931
- const replaced = await this.promoteSkillUnlocked(by, proposal.proposedBy, skill, meta);
7123
+ const replaced = await this.promoteSkillUnlocked(by, proposal, skill, meta);
6932
7124
  return { skill: skill.name, replaced };
6933
7125
  }
6934
7126
  case "archive": {
@@ -6942,26 +7134,180 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6942
7134
  return void 0;
6943
7135
  }
6944
7136
  }
6945
- /** Writes a promoted skill under the society's skills, where every citizen's skills index lists it. */
6946
- async promoteSkillUnlocked(by, proposedBy, skill, meta) {
7137
+ /**
7138
+ * Publishes a promoted skill under the society's skills, where every citizen's skills index lists
7139
+ * it: the folder the board took from its proposer's home, or the text proposed alone. A
7140
+ * replacement is the whole skill, so files the new version dropped go with the old one.
7141
+ */
7142
+ async promoteSkillUnlocked(by, proposal, skill, meta) {
7143
+ const dir = this.paths.societySkill(skill.name);
6947
7144
  const file = this.paths.societySkillFile(skill.name);
6948
7145
  const replaced = await exists(file);
6949
- await ensureDir(this.paths.societySkill(skill.name));
7146
+ const taken = proposal.skillFiles;
7147
+ const staged = path6.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7148
+ if (skill.body === void 0 && (taken === void 0 || !await exists(staged))) {
7149
+ throw new BoardError("INVALID_STATE", `proposal ${proposal.id} holds no skill files`);
7150
+ }
7151
+ await rm2(dir, { recursive: true, force: true });
7152
+ if (skill.body === void 0) {
7153
+ await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
7154
+ } else {
7155
+ await ensureDir(dir);
7156
+ }
7157
+ const body = skill.body ?? (await readLooseMarkdown(file)).body;
6950
7158
  await writeMarkdown(file, {
6951
7159
  name: skill.name,
6952
7160
  description: skill.summary,
6953
- metadata: { proposedBy, promotedBy: by, promotedAt: this.now().toISOString() }
6954
- }, skill.body);
7161
+ metadata: {
7162
+ proposedBy: proposal.proposedBy,
7163
+ promotedBy: by,
7164
+ promotedAt: this.now().toISOString(),
7165
+ ...taken === void 0 ? {} : { commit: taken.commit }
7166
+ }
7167
+ }, body);
6955
7168
  await this.events.append("skill.promoted", by, {
6956
7169
  name: skill.name,
6957
7170
  summary: skill.summary,
6958
- proposedBy,
7171
+ proposedBy: proposal.proposedBy,
6959
7172
  replaced,
7173
+ ...taken === void 0 ? {} : { files: taken.files.length },
6960
7174
  ...meta
6961
7175
  });
6962
7176
  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.`);
6963
7177
  return replaced;
6964
7178
  }
7179
+ /**
7180
+ * Copies a skill's folder from its proposer's home, as the home's newest commit holds it, into the
7181
+ * proposal's folder in the projection, where its deciders read it and from where approval
7182
+ * publishes it. Returns what was taken, or why the home could not give it.
7183
+ */
7184
+ async takeSkillFolderUnlocked(proposal, name) {
7185
+ const home = this.paths.agent(proposal.proposedBy);
7186
+ const folder = `skills/${name}`;
7187
+ const where = `${folder} in ${proposal.proposedBy}'s home`;
7188
+ const taken = await this.homes.folder(home, folder);
7189
+ if (taken === null || !taken.files.some((entry) => entry.path === "SKILL.md")) {
7190
+ return `no ${folder}/SKILL.md in ${proposal.proposedBy}'s home`;
7191
+ }
7192
+ if (taken.files.length > SKILL_FILES_LIMIT) {
7193
+ return `${where} holds ${taken.files.length} files, more than the ${SKILL_FILES_LIMIT} a society skill may`;
7194
+ }
7195
+ const bytes = taken.files.reduce((sum, entry) => sum + entry.size, 0);
7196
+ if (bytes > SKILL_BYTES_LIMIT) {
7197
+ return `${where} holds ${megabytes(bytes)} MB, more than the ${megabytes(SKILL_BYTES_LIMIT)} MB a society skill may`;
7198
+ }
7199
+ const contents = /* @__PURE__ */ new Map();
7200
+ for (const entry of taken.files) {
7201
+ contents.set(entry.path, await this.homes.blob(home, entry.object));
7202
+ }
7203
+ const manual = parseLooseMarkdown(contents.get("SKILL.md")?.toString("utf8") ?? "");
7204
+ if (manual.error !== null) {
7205
+ return `the frontmatter of ${folder}/SKILL.md in ${proposal.proposedBy}'s home does not parse (${manual.error})`;
7206
+ }
7207
+ const dir = this.paths.proposalFiles(proposal.id);
7208
+ await rm2(dir, { recursive: true, force: true });
7209
+ for (const entry of taken.files) {
7210
+ const target = path6.join(dir, ...entry.path.split("/"));
7211
+ await ensureDir(path6.dirname(target));
7212
+ await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
7213
+ await chmod2(target, entry.executable ? 493 : 420);
7214
+ }
7215
+ return {
7216
+ commit: taken.commit,
7217
+ takenAt: this.now().toISOString(),
7218
+ files: taken.files.map((entry) => ({
7219
+ path: entry.path,
7220
+ size: entry.size,
7221
+ executable: entry.executable
7222
+ }))
7223
+ };
7224
+ }
7225
+ /** Keeps what was taken of a skill proposal's folder on the proposal, and logs it. */
7226
+ async recordSkillFiles(proposal, taken) {
7227
+ const next = { ...proposal, skillFiles: taken };
7228
+ await this.writeProposal(next);
7229
+ await this.events.append("proposal.files_taken", SYSTEM_ACTOR.name, {
7230
+ proposalId: proposal.id,
7231
+ kind: proposal.kind,
7232
+ proposedBy: proposal.proposedBy,
7233
+ commit: taken.commit,
7234
+ files: taken.files.length
7235
+ });
7236
+ return next;
7237
+ }
7238
+ /**
7239
+ * A skill proposal with its folder, for approval: taken now when the turn that filed it ended
7240
+ * without it, as when the server restarted before the turn's end was recorded, and refused while
7241
+ * that turn is still in flight, since the folder reaches the board only when it ends.
7242
+ */
7243
+ async skillFilesForApproval(proposal) {
7244
+ const name = skillFolderOf(proposal);
7245
+ if (name === null || proposal.skillFiles !== void 0) {
7246
+ return proposal;
7247
+ }
7248
+ const cannot = `proposal ${proposal.id} cannot be approved`;
7249
+ const filing = this.turnsInFlight().some((turn) => turn.agent === proposal.proposedBy && proposedFrom(proposal.filedFrom, turn));
7250
+ if (filing) {
7251
+ 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`);
7252
+ }
7253
+ const taken = await this.takeSkillFolderUnlocked(proposal, name);
7254
+ if (typeof taken === "string") {
7255
+ throw new BoardError("INVALID_STATE", `${cannot}: ${taken}; reject it with that reason`);
7256
+ }
7257
+ return this.recordSkillFiles(proposal, taken);
7258
+ }
7259
+ /**
7260
+ * Takes the folders of the skills a citizen proposed from a conversation, once the turn there has
7261
+ * ended and its runner has pushed the citizen's home. A proposal whose folder the home does not
7262
+ * give is rejected by the board with the reason, which wakes the proposer to put it right; while
7263
+ * someone is in a turn in the proposal's thread, it stays as it is, and approval meets the reason.
7264
+ */
7265
+ async takeSkillFiles(turn) {
7266
+ const where = {
7267
+ scope: turn.project,
7268
+ ...turn.thread === void 0 ? {} : { thread: turn.thread },
7269
+ ...turn.channel === void 0 ? {} : { channel: turn.channel }
7270
+ };
7271
+ await this.mutex.run(async () => {
7272
+ for (const proposal of await this.listProposals()) {
7273
+ const name = skillFolderOf(proposal);
7274
+ if (name === null || proposal.status !== "proposed" || proposal.skillFiles !== void 0 || proposal.proposedBy !== turn.agent || !proposedFrom(proposal.filedFrom, where)) {
7275
+ continue;
7276
+ }
7277
+ const taken = await this.takeSkillFolderUnlocked(proposal, name);
7278
+ if (typeof taken !== "string") {
7279
+ await this.recordSkillFiles(proposal, taken);
7280
+ continue;
7281
+ }
7282
+ try {
7283
+ await this.decideUnlocked(SYSTEM_ACTOR, proposal.id, "rejected", `${taken} when the turn that proposed it ended; propose it again once the folder is there`);
7284
+ } catch (error) {
7285
+ if (!(error instanceof BoardError)) {
7286
+ throw error;
7287
+ }
7288
+ }
7289
+ }
7290
+ });
7291
+ }
7292
+ /** A skill proposal's files with their text, as the board took them; none before then. */
7293
+ async readSkillFiles(proposalId) {
7294
+ const proposal = await this.readProposal(proposalId);
7295
+ const dir = this.paths.proposalFiles(proposal.id);
7296
+ const files = [];
7297
+ for (const entry of proposal.skillFiles?.files ?? []) {
7298
+ if (!isSafeRelativePath(entry.path)) {
7299
+ continue;
7300
+ }
7301
+ const bytes = await readFile5(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
7302
+ const text = bytes === null || bytes.includes(0) ? null : bytes.toString("utf8");
7303
+ files.push({
7304
+ ...entry,
7305
+ text: text === null ? null : text.slice(0, SKILL_TEXT_LIMIT_CHARS),
7306
+ truncated: text !== null && text.length > SKILL_TEXT_LIMIT_CHARS
7307
+ });
7308
+ }
7309
+ return files;
7310
+ }
6965
7311
  async validateAddAgent(input) {
6966
7312
  if (await exists(this.paths.agentFile(input.name))) {
6967
7313
  throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
@@ -7473,67 +7819,70 @@ ${charter.purpose}
7473
7819
  * retired. Validation runs before any write, so an impossible provision fails the decision.
7474
7820
  */
7475
7821
  async decide(actor, proposalId, outcome, reason) {
7476
- return this.mutex.run(async () => {
7477
- const proposal = await this.readProposal(proposalId);
7478
- if (proposal.status !== "proposed") {
7479
- throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${proposal.status}`);
7480
- }
7481
- if (proposal.proposedBy === actor.name) {
7482
- throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7483
- }
7484
- const approvers = ROLE_KIND_APPROVERS[proposal.kind];
7485
- if (!approvers.includes(actor.role)) {
7486
- throw new BoardError("FORBIDDEN", `a ${proposal.kind} proposal requires one of: ${approvers.join(", ")}`);
7487
- }
7488
- if (outcome === "approved") {
7489
- await this.validateProvision(proposal.kind, proposal.charter);
7490
- }
7491
- this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
7492
- if (outcome === "approved" && proposal.kind === "archive") {
7493
- await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
7494
- }
7495
- const ts = this.now().toISOString();
7496
- const decision = DecisionSchema.parse({
7497
- id: this.newId(),
7498
- proposalId,
7499
- decidedBy: actor.name,
7500
- outcome,
7501
- ...reason === void 0 ? {} : { reason },
7502
- ts
7503
- });
7504
- await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7505
- const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal) : void 0;
7506
- const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7507
- await this.writeProposal({
7508
- ...proposal,
7509
- status,
7510
- decidedBy: actor.name,
7511
- decidedAt: ts,
7512
- ...reason === void 0 ? {} : { reason },
7513
- ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7514
- });
7515
- await this.events.append("proposal.decided", actor.name, {
7822
+ return this.mutex.run(() => this.decideUnlocked(actor, proposalId, outcome, reason));
7823
+ }
7824
+ async decideUnlocked(actor, proposalId, outcome, reason) {
7825
+ const filed = await this.readProposal(proposalId);
7826
+ if (filed.status !== "proposed") {
7827
+ throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${filed.status}`);
7828
+ }
7829
+ if (filed.proposedBy === actor.name) {
7830
+ throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7831
+ }
7832
+ const approvers = ROLE_KIND_APPROVERS[filed.kind];
7833
+ if (!approvers.includes(actor.role)) {
7834
+ throw new BoardError("FORBIDDEN", `a ${filed.kind} proposal requires one of: ${approvers.join(", ")}`);
7835
+ }
7836
+ if (outcome === "approved") {
7837
+ await this.validateProvision(filed.kind, filed.charter);
7838
+ }
7839
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
7840
+ if (outcome === "approved" && filed.kind === "archive") {
7841
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(filed.charter).project);
7842
+ }
7843
+ const proposal = outcome === "approved" ? await this.skillFilesForApproval(filed) : filed;
7844
+ const ts = this.now().toISOString();
7845
+ const decision = DecisionSchema.parse({
7846
+ id: this.newId(),
7847
+ proposalId,
7848
+ decidedBy: actor.name,
7849
+ outcome,
7850
+ ...reason === void 0 ? {} : { reason },
7851
+ ts
7852
+ });
7853
+ await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7854
+ const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal) : void 0;
7855
+ const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7856
+ await this.writeProposal({
7857
+ ...proposal,
7858
+ status,
7859
+ decidedBy: actor.name,
7860
+ decidedAt: ts,
7861
+ ...reason === void 0 ? {} : { reason },
7862
+ ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7863
+ });
7864
+ await this.events.append("proposal.decided", actor.name, {
7865
+ proposalId,
7866
+ outcome,
7867
+ kind: proposal.kind,
7868
+ proposedBy: proposal.proposedBy,
7869
+ ...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
7870
+ });
7871
+ if (provision !== void 0) {
7872
+ await this.events.append("proposal.provisioned", actor.name, {
7516
7873
  proposalId,
7517
- outcome,
7518
7874
  kind: proposal.kind,
7519
- proposedBy: proposal.proposedBy,
7520
- ...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
7875
+ ...provision
7521
7876
  });
7522
- if (provision !== void 0) {
7523
- await this.events.append("proposal.provisioned", actor.name, {
7524
- proposalId,
7525
- kind: proposal.kind,
7526
- ...provision
7527
- });
7528
- }
7529
- const verdict = outcome === "approved" ? "Approved" : "Rejected";
7530
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7531
- const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7532
- const thread = await this.proposalThreadUnlocked(actor.name, proposal);
7533
- await this.appendThreadMessage(actor.name, thread.id, `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`, { step: { action: outcome } });
7534
- await this.endThreadOf(proposalId, actor.name, outcome);
7535
- return decision;
7536
- });
7877
+ }
7878
+ const verdict = outcome === "approved" ? "Approved" : "Rejected";
7879
+ const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7880
+ const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7881
+ const thread = await this.proposalThreadUnlocked(actor.name, proposal);
7882
+ const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`;
7883
+ await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
7884
+ await this.endThreadOf(proposalId, actor.name, outcome);
7885
+ return decision;
7537
7886
  }
7538
7887
  /** The opener, the people of its subject, and anyone who posted in it; mentions reach the rest. */
7539
7888
  async participatesInThread(actor, thread, threads) {
@@ -9284,7 +9633,8 @@ var TURN_CONTRACT = [
9284
9633
  "- 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.",
9285
9634
  "- 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.",
9286
9635
  "- 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.",
9287
- '- 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.',
9636
+ '- A procedure you have followed twice is a skill: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Files the steps use, such as scripts/ and references/, go in the same folder, named in SKILL.md by paths relative to it. Propose it with kind "skill" when the whole society would use it.',
9637
+ "- 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.",
9288
9638
  "- 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.",
9289
9639
  "- 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.",
9290
9640
  "- 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.",
@@ -9307,7 +9657,7 @@ var GOVERNANCE = [
9307
9657
  ' - retirement: {"agent", "reason"}',
9308
9658
  ' - 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',
9309
9659
  ' - reallocation: {"description"}',
9310
- ` - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, where every citizen's skills index lists it`,
9660
+ ' - skill: {"name", "summary"}: the folder skills/<name>/ in your home, SKILL.md and every file beside it, which the board takes as it stands when this turn ends into society/proposals/<id>/, where whoever decides reads it; a proposal whose folder has no SKILL.md then is rejected by the board. Approval publishes it under society/skills/<name>/, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded. A proposal from before folders carries "body", its SKILL.md text alone',
9311
9661
  "- 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.",
9312
9662
  "- Prefer scaling an existing role over inventing one; a new role is justified by work a project needs that no existing role covers. A role that needs a tool the board lacks is an engineering task, not a hiring request.",
9313
9663
  "- 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."
@@ -9335,7 +9685,7 @@ function renderSkillsIndex(skills) {
9335
9685
  ];
9336
9686
  }
9337
9687
  return [
9338
- "Read a skill's file when its summary matches the work at hand; do not load them all.",
9688
+ "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.",
9339
9689
  "",
9340
9690
  ...skills.map((skill) => `- ${skill.name} (${skill.scope === "own" ? "yours" : "society"}): ${skill.summary.trim().length === 0 ? "no summary" : skill.summary.trim().replace(/\.+$/, "")}. File: ${skill.path}`)
9341
9691
  ];
@@ -9360,7 +9710,7 @@ var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answ
9360
9710
  var RECONCILE = "- Reconcile first: merge each copy listed under Edits to reconcile in your home into the file beside it, then delete the copy.";
9361
9711
  var REFLECTION_STEPS = [
9362
9712
  "- 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.",
9363
- '- 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.',
9713
+ '- 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.',
9364
9714
  "- 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.",
9365
9715
  "- 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.",
9366
9716
  "- Report memoryUpdated: true in the status object when core.md or a skill changed."
@@ -9575,7 +9925,7 @@ function buildTurnPrompt(input) {
9575
9925
  } else {
9576
9926
  lines.push(`Topics under ${knowledge.dir}; open one with your file tools when it is relevant.`, "");
9577
9927
  for (const topic of knowledge.topics) {
9578
- lines.push(`- ${topic.topic}: updated by ${topic.updatedBy} at ${topic.updatedAt}`);
9928
+ lines.push(`- ${topic.topic}: revision ${topic.revision}, updated by ${topic.updatedBy} at ${topic.updatedAt}`);
9579
9929
  }
9580
9930
  }
9581
9931
  }
@@ -10105,6 +10455,7 @@ var TurnHost = class {
10105
10455
  channel: job.channel,
10106
10456
  exitReason: finished.exitReason
10107
10457
  }, "turn finished");
10458
+ await this.takeSkillFiles(finished);
10108
10459
  turn.done(finished);
10109
10460
  }
10110
10461
  return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
@@ -10129,9 +10480,21 @@ var TurnHost = class {
10129
10480
  };
10130
10481
  if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
10131
10482
  this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
10483
+ await this.takeSkillFiles(finished);
10132
10484
  turn.done(finished);
10133
10485
  }
10134
10486
  }
10487
+ /**
10488
+ * Takes the folders of the skills the turn proposed, which reached the board with the home its
10489
+ * runner pushed before reporting. A failure only leaves them for approval to take.
10490
+ */
10491
+ async takeSkillFiles(record) {
10492
+ try {
10493
+ await this.board.takeSkillFiles(record);
10494
+ } catch (error) {
10495
+ this.log.warn({ agent: record.agent, project: record.project, error: String(error) }, "could not take the folders of the skills a turn proposed");
10496
+ }
10497
+ }
10135
10498
  /**
10136
10499
  * Records the end of a turn already taken out of `open`, and says whether it could. The scheduler
10137
10500
  * holds the turn's conversation until the turn ends, and nothing would end it once it has left
@@ -11691,6 +12054,7 @@ function createApp(deps) {
11691
12054
  api.get("/proposals", async (c) => c.json(await board2.listProposals()));
11692
12055
  api.get("/crons", async (c) => c.json(await board2.listCrons()));
11693
12056
  api.get("/proposals/:id", async (c) => c.json(await board2.readProposal(c.req.param("id"))));
12057
+ api.get("/proposals/:id/files", async (c) => c.json(await board2.readSkillFiles(UlidSchema.parse(c.req.param("id")))));
11694
12058
  api.get("/signals", async (c) => c.json(await board2.listSignals(Number(c.req.query("limit") ?? "100"))));
11695
12059
  api.get("/metrics", async (c) => {
11696
12060
  const window = MetricsWindowSchema.parse(c.req.query("window") ?? "7d");