@kubitnodes/stellaris 0.3.18 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cli/index.js +393 -95
  2. package/dist/server/index.js +409 -97
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-CDrsO9Ls.js +1 -0
  5. package/web/dist/assets/{ChannelForms-DfG4vYvk.js → ChannelForms-BjD5Lp_u.js} +1 -1
  6. package/web/dist/assets/ChannelView-dI5EHpof.js +2 -0
  7. package/web/dist/assets/{CitizenView-q24CExsI.js → CitizenView-tvC6dNUB.js} +2 -2
  8. package/web/dist/assets/{CitizensView-bKbUaCab.js → CitizensView-DsUhIYtl.js} +1 -1
  9. package/web/dist/assets/{CliIcon-DTVk0pGU.js → CliIcon-TlyIqh7V.js} +1 -1
  10. package/web/dist/assets/{CronsView-DaG1xhQu.js → CronsView-BPlSyoAe.js} +1 -1
  11. package/web/dist/assets/{KnowledgeView-Dt7wE3FT.js → KnowledgeView-CgRGZrOK.js} +1 -1
  12. package/web/dist/assets/{Markdown-DeF9PKNm.js → Markdown-CePsfcUS.js} +3 -3
  13. package/web/dist/assets/MessageItem-kg5zWMDe.js +1 -0
  14. package/web/dist/assets/MetricsView-cJeDTfBE.js +1 -0
  15. package/web/dist/assets/NeedsYouView-Cd1TnY7W.js +1 -0
  16. package/web/dist/assets/Overview-B6aBmove.js +1 -0
  17. package/web/dist/assets/{Pane-v044FaP5.js → Pane-DGtHsANK.js} +1 -1
  18. package/web/dist/assets/{Picker-wWsO86nu.js → Picker-Dq5Vvxod.js} +1 -1
  19. package/web/dist/assets/{ProjectView--nzlqSDA.js → ProjectView-uskZHP8l.js} +1 -1
  20. package/web/dist/assets/ProposalView-DmjIL53_.js +2 -0
  21. package/web/dist/assets/{ProposalsView-dVA50yG1.js → ProposalsView-BjEfHBtx.js} +1 -1
  22. package/web/dist/assets/{RunnersView-C-l5QYIa.js → RunnersView-CM-q_AX9.js} +1 -1
  23. package/web/dist/assets/SocietyView-DZuGEqIl.js +1 -0
  24. package/web/dist/assets/SubjectThread-pCRDn42X.js +1 -0
  25. package/web/dist/assets/TaskFileView-BMAkbh-e.js +4 -0
  26. package/web/dist/assets/{TaskView-BgJcOmGj.js → TaskView-HDwqXuYy.js} +2 -2
  27. package/web/dist/assets/{TasksView-f8KIsif_.js → TasksView-Dt8M_z96.js} +1 -1
  28. package/web/dist/assets/{ThreadForms-CXsunS-N.js → ThreadForms-DbV3kPCi.js} +1 -1
  29. package/web/dist/assets/ThreadItem-BVlz2w6q.js +1 -0
  30. package/web/dist/assets/{ThreadView-DzpaXkik.js → ThreadView-CpBytSH7.js} +1 -1
  31. package/web/dist/assets/{citizen-VtLNxjtf.js → citizen-DQ0Bmk-r.js} +1 -1
  32. package/web/dist/assets/{compose-D-SO-HE-.js → compose-Cx9c9k90.js} +1 -1
  33. package/web/dist/assets/{format-CkySAdH1.js → format-SP8GnzAX.js} +1 -1
  34. package/web/dist/assets/governance-DX2ph-qr.js +1 -0
  35. package/web/dist/assets/index-BDKI0Rje.js +23 -0
  36. package/web/dist/assets/index-BPVeX6tV.css +2 -0
  37. package/web/dist/assets/{link-BN04dcBX.js → link-DxSeFzFf.js} +1 -1
  38. package/web/dist/assets/{tasks-CD70eybR.js → tasks-BeWF0Xy2.js} +1 -1
  39. package/web/dist/assets/useMatch-HNekgJrF.js +1 -0
  40. package/web/dist/assets/{useStickyScroll-WSVpAMrA.js → useStickyScroll-BmqddNx5.js} +1 -1
  41. package/web/dist/index.html +9 -9
  42. package/web/dist/assets/BackLink-DUOMS3fv.js +0 -1
  43. package/web/dist/assets/ChannelView-DTLhLXy4.js +0 -2
  44. package/web/dist/assets/MessageItem-DDs7m3-4.js +0 -1
  45. package/web/dist/assets/MetricsView-Bw7nL6Y9.js +0 -1
  46. package/web/dist/assets/NeedsYouView-DbESNtQT.js +0 -1
  47. package/web/dist/assets/Overview-Dv7SS4yW.js +0 -1
  48. package/web/dist/assets/ProposalView-Cdw_xzG1.js +0 -1
  49. package/web/dist/assets/SocietyView--mWRXRj_.js +0 -1
  50. package/web/dist/assets/SubjectThread-DvvsxHN9.js +0 -1
  51. package/web/dist/assets/TaskFileView-DYuPrCdk.js +0 -4
  52. package/web/dist/assets/ThreadCard-BRLvjdz3.js +0 -1
  53. package/web/dist/assets/governance-1UeTD2K_.js +0 -1
  54. package/web/dist/assets/index-CwkK8XtJ.js +0 -23
  55. package/web/dist/assets/index-DaES0xVj.css +0 -2
  56. package/web/dist/assets/useMatch-i33QT_GI.js +0 -1
@@ -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";
@@ -637,7 +637,7 @@ var ArchiveProposalSchema = z4.object({
637
637
  var SkillProposalSchema = z4.object({
638
638
  name: NameSchema,
639
639
  summary: z4.string().min(1).max(200),
640
- body: z4.string().min(1)
640
+ body: z4.string().min(1).optional()
641
641
  });
642
642
  var ProposalCharterSchemas = {
643
643
  role: RoleCharterSchema,
@@ -764,6 +764,7 @@ var HomeFileDiffSchema = HomeFileChangeSchema.pick({ path: true, status: true })
764
764
  var KnowledgeSchema = z4.object({
765
765
  topic: NameSchema,
766
766
  project: NameSchema.nullable(),
767
+ revision: z4.number().int().min(0),
767
768
  updatedBy: NameSchema,
768
769
  updatedAt: IsoDateTimeSchema
769
770
  });
@@ -786,6 +787,24 @@ var ProposalOriginSchema = z4.object({
786
787
  thread: UlidSchema.optional(),
787
788
  channel: NameSchema.optional()
788
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
+ });
789
808
  var ProposalFrontmatterSchema = z4.object({
790
809
  id: UlidSchema,
791
810
  kind: ProposalKindSchema,
@@ -794,6 +813,8 @@ var ProposalFrontmatterSchema = z4.object({
794
813
  createdAt: IsoDateTimeSchema,
795
814
  /** Absent for a proposal filed outside a turn, or before proposals recorded it. */
796
815
  filedFrom: ProposalOriginSchema.optional(),
816
+ /** A skill proposal's folder, once taken; absent for one proposed as text alone. */
817
+ skillFiles: SkillFilesSchema.optional(),
797
818
  decidedBy: NameSchema.optional(),
798
819
  decidedAt: IsoDateTimeSchema.optional(),
799
820
  reason: z4.string().optional(),
@@ -896,7 +917,9 @@ var VerbInputs = {
896
917
  /** A project you belong to, or null for society knowledge, which the steward and the user curate. */
897
918
  project: NameSchema.nullable().default(null),
898
919
  topic: NameSchema,
899
- 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)
900
923
  }),
901
924
  remove_knowledge: z5.object({
902
925
  /** As for write_knowledge: a project you belong to, or null for society knowledge. */
@@ -986,7 +1009,7 @@ var VERB_DESCRIPTIONS = {
986
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).",
987
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.",
988
1011
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
989
- write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
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.",
990
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.",
991
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.",
992
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.",
@@ -1030,6 +1053,7 @@ var BOARD_EVENT_TYPES = [
1030
1053
  "proposal.created",
1031
1054
  "proposal.decided",
1032
1055
  "proposal.provisioned",
1056
+ "proposal.files_taken",
1033
1057
  "channel.added",
1034
1058
  "channel.archived",
1035
1059
  "role.added",
@@ -1458,6 +1482,9 @@ var RunnerAnswerSchema = z7.discriminatedUnion("ok", [
1458
1482
  ]);
1459
1483
  var WarmSessionsSchema = z7.object({ warm: z7.array(z7.string()) });
1460
1484
  var FileManifestSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1485
+ function fileVersion(sha, executable) {
1486
+ return executable ? `${sha}+x` : sha;
1487
+ }
1461
1488
  var FileReadSchema = z7.object({ paths: z7.array(z7.string()) });
1462
1489
  var FileContentsSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
1463
1490
  function isSafeRelativePath(file) {
@@ -1834,7 +1861,7 @@ var RunnerConfigSchema = z11.object({
1834
1861
  });
1835
1862
 
1836
1863
  // packages/shared/dist/version.js
1837
- var STELLARIS_VERSION = "0.3.18";
1864
+ var STELLARIS_VERSION = "0.3.19";
1838
1865
 
1839
1866
  // packages/board-core/dist/errors.js
1840
1867
  var BoardError = class extends Error {
@@ -1891,7 +1918,9 @@ async function readMarkdown(file, schema) {
1891
1918
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1892
1919
  }
1893
1920
  async function readLooseMarkdown(file) {
1894
- const raw = await readFile(file, "utf8");
1921
+ return parseLooseMarkdown(await readFile(file, "utf8"));
1922
+ }
1923
+ function parseLooseMarkdown(raw) {
1895
1924
  try {
1896
1925
  const parsed = parseMatter(raw);
1897
1926
  return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
@@ -2078,13 +2107,14 @@ var FileTree = class {
2078
2107
  if (info.size > MAX_FILE_BYTES) {
2079
2108
  return null;
2080
2109
  }
2110
+ const executable = (info.mode & 73) !== 0;
2081
2111
  const known = this.hashes.get(absolute);
2082
2112
  if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
2083
- return known.sha;
2113
+ return fileVersion(known.sha, executable);
2084
2114
  }
2085
2115
  const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
2086
2116
  this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
2087
- return sha;
2117
+ return fileVersion(sha, executable);
2088
2118
  } catch (error) {
2089
2119
  if (isMissing2(error)) {
2090
2120
  return null;
@@ -2335,6 +2365,55 @@ var HomeRepos = class {
2335
2365
  }
2336
2366
  return files;
2337
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
+ }
2338
2417
  /** The conflict copies a merge added, against its first parent. */
2339
2418
  async keptCopies(home, parent, commit) {
2340
2419
  const added = await read(home, [
@@ -2750,6 +2829,10 @@ var BoardPaths = class {
2750
2829
  proposal(id) {
2751
2830
  return path5.join(this.proposals(), `${id}.md`);
2752
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
+ }
2753
2836
  decisions() {
2754
2837
  return path5.join(this.society, "decisions");
2755
2838
  }
@@ -3001,6 +3084,20 @@ var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
3001
3084
  function withoutMentions(text) {
3002
3085
  return text.replaceAll(MENTION_PATTERN, "$1$2");
3003
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
+ }
3004
3101
  var CURATING_ROLES = [USER_ROLE, "steward"];
3005
3102
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
3006
3103
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -3923,22 +4020,41 @@ ${charter.purpose}
3923
4020
  const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
3924
4021
  const topics = [];
3925
4022
  for (const file of await listFiles(dir)) {
3926
- const doc = await readMarkdown(path6.join(dir, file), z13.record(z13.string(), z13.unknown()));
3927
- const parsed = KnowledgeSchema.safeParse({
3928
- ...doc.data,
3929
- project,
3930
- topic: file.replace(/\.md$/, "")
3931
- });
3932
- const data = parsed.success ? parsed.data : {
3933
- topic: file.replace(/\.md$/, ""),
3934
- project,
3935
- updatedBy: SYSTEM_ACTOR.name,
3936
- updatedAt: (await stat2(path6.join(dir, file))).mtime.toISOString()
3937
- };
3938
- topics.push({ ...data, body: doc.body });
4023
+ topics.push(await this.knowledgeAt(path6.join(dir, file), project, file.replace(/\.md$/, "")));
3939
4024
  }
3940
4025
  return topics;
3941
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
+ }
3942
4058
  /** Skills promoted to the society: every citizen may read them. */
3943
4059
  async listSocietySkills() {
3944
4060
  return this.listSkillsIn(this.paths.societySkills(), "society");
@@ -4652,26 +4768,42 @@ ${task.body}`;
4652
4768
  }
4653
4769
  /**
4654
4770
  * Writes a topic: a project's by its members, the society's by the curating roles. The note in
4655
- * the matching general channel carries no mention, so nobody is woken.
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.
4656
4775
  */
4657
4776
  async writeKnowledge(actor, input) {
4658
4777
  const args = VerbInputs.write_knowledge.parse(input);
4659
4778
  await this.authorize(actor, "write_knowledge");
4660
4779
  return this.mutex.run(async () => {
4661
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);
4662
4793
  const data = KnowledgeSchema.parse({
4663
4794
  topic: args.topic,
4664
4795
  project: args.project,
4796
+ revision: base + 1,
4665
4797
  updatedBy: actor.name,
4666
4798
  updatedAt: this.now().toISOString()
4667
4799
  });
4668
- const file = args.project === null ? this.paths.societyKnowledgeFile(args.topic) : this.paths.projectKnowledgeFile(args.project, args.topic);
4669
- const replaced = await exists(file);
4800
+ const replaced = current !== null;
4670
4801
  await writeMarkdown(file, data, args.body);
4671
4802
  await this.events.append("knowledge.written", actor.name, {
4672
4803
  topic: args.topic,
4673
4804
  project: args.project,
4674
- replaced
4805
+ replaced,
4806
+ revision: data.revision
4675
4807
  });
4676
4808
  const where = args.project === null ? "general" : channelRef(args.project, "general");
4677
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" : ""}.`, {
@@ -4694,7 +4826,7 @@ ${task.body}`;
4694
4826
  await this.authorize(actor, "remove_knowledge");
4695
4827
  return this.mutex.run(async () => {
4696
4828
  await this.assertMayCurate(actor, args.project);
4697
- 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);
4698
4830
  if (!await exists(file)) {
4699
4831
  throw new BoardError("NOT_FOUND", `${args.project ?? "the society"} has no knowledge topic ${args.topic}`);
4700
4832
  }
@@ -5745,7 +5877,7 @@ ${args.summary}`);
5745
5877
  throw new BoardError("VALIDATION", `invalid ${args.kind} charter: ${parsed.error.message}`);
5746
5878
  }
5747
5879
  return this.mutex.run(async () => {
5748
- const proposal = {
5880
+ let proposal = {
5749
5881
  ...ProposalFrontmatterSchema.parse({
5750
5882
  id: this.newId(),
5751
5883
  kind: args.kind,
@@ -5764,6 +5896,14 @@ ${args.summary}`);
5764
5896
  body: args.rationale
5765
5897
  };
5766
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
+ }
5767
5907
  await this.writeProposal(proposal);
5768
5908
  await this.events.append("proposal.created", actor.name, {
5769
5909
  proposalId: proposal.id,
@@ -6980,7 +7120,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6980
7120
  }
6981
7121
  case "skill": {
6982
7122
  const skill = SkillProposalSchema.parse(proposal.charter);
6983
- const replaced = await this.promoteSkillUnlocked(by, proposal.proposedBy, skill, meta);
7123
+ const replaced = await this.promoteSkillUnlocked(by, proposal, skill, meta);
6984
7124
  return { skill: skill.name, replaced };
6985
7125
  }
6986
7126
  case "archive": {
@@ -6994,26 +7134,180 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
6994
7134
  return void 0;
6995
7135
  }
6996
7136
  }
6997
- /** Writes a promoted skill under the society's skills, where every citizen's skills index lists it. */
6998
- async promoteSkillUnlocked(by, proposedBy, skill, meta) {
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);
6999
7144
  const file = this.paths.societySkillFile(skill.name);
7000
7145
  const replaced = await exists(file);
7001
- 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;
7002
7158
  await writeMarkdown(file, {
7003
7159
  name: skill.name,
7004
7160
  description: skill.summary,
7005
- metadata: { proposedBy, promotedBy: by, promotedAt: this.now().toISOString() }
7006
- }, 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);
7007
7168
  await this.events.append("skill.promoted", by, {
7008
7169
  name: skill.name,
7009
7170
  summary: skill.summary,
7010
- proposedBy,
7171
+ proposedBy: proposal.proposedBy,
7011
7172
  replaced,
7173
+ ...taken === void 0 ? {} : { files: taken.files.length },
7012
7174
  ...meta
7013
7175
  });
7014
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.`);
7015
7177
  return replaced;
7016
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
+ }
7017
7311
  async validateAddAgent(input) {
7018
7312
  if (await exists(this.paths.agentFile(input.name))) {
7019
7313
  throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
@@ -7525,67 +7819,70 @@ ${charter.purpose}
7525
7819
  * retired. Validation runs before any write, so an impossible provision fails the decision.
7526
7820
  */
7527
7821
  async decide(actor, proposalId, outcome, reason) {
7528
- return this.mutex.run(async () => {
7529
- const proposal = await this.readProposal(proposalId);
7530
- if (proposal.status !== "proposed") {
7531
- throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${proposal.status}`);
7532
- }
7533
- if (proposal.proposedBy === actor.name) {
7534
- throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
7535
- }
7536
- const approvers = ROLE_KIND_APPROVERS[proposal.kind];
7537
- if (!approvers.includes(actor.role)) {
7538
- throw new BoardError("FORBIDDEN", `a ${proposal.kind} proposal requires one of: ${approvers.join(", ")}`);
7539
- }
7540
- if (outcome === "approved") {
7541
- await this.validateProvision(proposal.kind, proposal.charter);
7542
- }
7543
- this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
7544
- if (outcome === "approved" && proposal.kind === "archive") {
7545
- await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
7546
- }
7547
- const ts = this.now().toISOString();
7548
- const decision = DecisionSchema.parse({
7549
- id: this.newId(),
7550
- proposalId,
7551
- decidedBy: actor.name,
7552
- outcome,
7553
- ...reason === void 0 ? {} : { reason },
7554
- ts
7555
- });
7556
- await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
7557
- const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal) : void 0;
7558
- const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
7559
- await this.writeProposal({
7560
- ...proposal,
7561
- status,
7562
- decidedBy: actor.name,
7563
- decidedAt: ts,
7564
- ...reason === void 0 ? {} : { reason },
7565
- ...provision === void 0 ? {} : { provisionedAt: ts, provision }
7566
- });
7567
- await this.events.append("proposal.decided", actor.name, {
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, {
7568
7873
  proposalId,
7569
- outcome,
7570
7874
  kind: proposal.kind,
7571
- proposedBy: proposal.proposedBy,
7572
- ...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
7875
+ ...provision
7573
7876
  });
7574
- if (provision !== void 0) {
7575
- await this.events.append("proposal.provisioned", actor.name, {
7576
- proposalId,
7577
- kind: proposal.kind,
7578
- ...provision
7579
- });
7580
- }
7581
- const verdict = outcome === "approved" ? "Approved" : "Rejected";
7582
- const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
7583
- const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
7584
- const thread = await this.proposalThreadUnlocked(actor.name, proposal);
7585
- await this.appendThreadMessage(actor.name, thread.id, `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`, { step: { action: outcome } });
7586
- await this.endThreadOf(proposalId, actor.name, outcome);
7587
- return decision;
7588
- });
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;
7589
7886
  }
7590
7887
  /** The opener, the people of its subject, and anyone who posted in it; mentions reach the rest. */
7591
7888
  async participatesInThread(actor, thread, threads) {
@@ -9336,7 +9633,8 @@ var TURN_CONTRACT = [
9336
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.",
9337
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.",
9338
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.",
9339
- '- A procedure you have followed twice is a skill: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Propose it with kind "skill" when the whole society would use it.',
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.",
9340
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.",
9341
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.",
9342
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.",
@@ -9359,7 +9657,7 @@ var GOVERNANCE = [
9359
9657
  ' - retirement: {"agent", "reason"}',
9360
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',
9361
9659
  ' - reallocation: {"description"}',
9362
- ' - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded',
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',
9363
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.",
9364
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.",
9365
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."
@@ -9387,7 +9685,7 @@ function renderSkillsIndex(skills) {
9387
9685
  ];
9388
9686
  }
9389
9687
  return [
9390
- "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.",
9391
9689
  "",
9392
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}`)
9393
9691
  ];
@@ -9412,7 +9710,7 @@ var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answ
9412
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.";
9413
9711
  var REFLECTION_STEPS = [
9414
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.",
9415
- '- Extract a skill: a procedure you have followed twice goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps. Propose it with kind "skill" when other citizens would use it.',
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.',
9416
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.",
9417
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.",
9418
9716
  "- Report memoryUpdated: true in the status object when core.md or a skill changed."
@@ -9627,7 +9925,7 @@ function buildTurnPrompt(input) {
9627
9925
  } else {
9628
9926
  lines.push(`Topics under ${knowledge.dir}; open one with your file tools when it is relevant.`, "");
9629
9927
  for (const topic of knowledge.topics) {
9630
- 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}`);
9631
9929
  }
9632
9930
  }
9633
9931
  }
@@ -10157,6 +10455,7 @@ var TurnHost = class {
10157
10455
  channel: job.channel,
10158
10456
  exitReason: finished.exitReason
10159
10457
  }, "turn finished");
10458
+ await this.takeSkillFiles(finished);
10160
10459
  turn.done(finished);
10161
10460
  }
10162
10461
  return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
@@ -10181,9 +10480,21 @@ var TurnHost = class {
10181
10480
  };
10182
10481
  if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
10183
10482
  this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
10483
+ await this.takeSkillFiles(finished);
10184
10484
  turn.done(finished);
10185
10485
  }
10186
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
+ }
10187
10498
  /**
10188
10499
  * Records the end of a turn already taken out of `open`, and says whether it could. The scheduler
10189
10500
  * holds the turn's conversation until the turn ends, and nothing would end it once it has left
@@ -11743,6 +12054,7 @@ function createApp(deps) {
11743
12054
  api.get("/proposals", async (c) => c.json(await board2.listProposals()));
11744
12055
  api.get("/crons", async (c) => c.json(await board2.listCrons()));
11745
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")))));
11746
12058
  api.get("/signals", async (c) => c.json(await board2.listSignals(Number(c.req.query("limit") ?? "100"))));
11747
12059
  api.get("/metrics", async (c) => {
11748
12060
  const window = MetricsWindowSchema.parse(c.req.query("window") ?? "7d");