@kubitnodes/stellaris 0.3.9 → 0.3.11

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 (46) hide show
  1. package/dist/cli/index.js +557 -125
  2. package/dist/server/index.js +977 -243
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-C-vad6K2.js +1 -0
  5. package/web/dist/assets/ChannelForms-jNbbNnIC.js +1 -0
  6. package/web/dist/assets/ChannelView-kLoegYUQ.js +1 -0
  7. package/web/dist/assets/CitizenView-Cb8f2mB7.js +2 -0
  8. package/web/dist/assets/{CitizensView-DdIHzcx3.js → CitizensView-Ds2rojfa.js} +1 -1
  9. package/web/dist/assets/KnowledgeView-k10TU0jc.js +1 -0
  10. package/web/dist/assets/{Markdown-B2tp7ki-.js → Markdown-J2pAPfCa.js} +3 -3
  11. package/web/dist/assets/{MessageItem-CWZ0X2Vr.js → MessageItem-BjM8BdpK.js} +1 -1
  12. package/web/dist/assets/{MetricsView-7X3Cmybh.js → MetricsView-D9N54rV7.js} +1 -1
  13. package/web/dist/assets/{NeedsYouView-Pi1jBF8V.js → NeedsYouView-ML8i7Pl7.js} +1 -1
  14. package/web/dist/assets/{Overview-C1leALP3.js → Overview-BBXvRhfR.js} +1 -1
  15. package/web/dist/assets/{Pane-DxXjKLAj.js → Pane-caYWCiyo.js} +1 -1
  16. package/web/dist/assets/ProjectView-sDpFNjcW.js +1 -0
  17. package/web/dist/assets/ProposalView-Bc91NjKC.js +1 -0
  18. package/web/dist/assets/{ProposalsView-Uc6fp4bD.js → ProposalsView-Dg_bEryX.js} +1 -1
  19. package/web/dist/assets/{RunnersView-D4pd3ArT.js → RunnersView-Dv5nbwYv.js} +1 -1
  20. package/web/dist/assets/SocietyView-ClaEWgOq.js +1 -0
  21. package/web/dist/assets/SubjectThread-D_L3vB-A.js +1 -0
  22. package/web/dist/assets/{TaskFileView-dvFssKHp.js → TaskFileView-B19YtrtW.js} +2 -2
  23. package/web/dist/assets/{TaskView-C-VIKoFp.js → TaskView-FYSoCb3T.js} +1 -1
  24. package/web/dist/assets/{TasksView-BXqfwU5R.js → TasksView-CniXTU3R.js} +1 -1
  25. package/web/dist/assets/{ThreadCard-Bn7HfgDI.js → ThreadCard-DCpvhSlc.js} +1 -1
  26. package/web/dist/assets/{ThreadForms-CzYPjU1-.js → ThreadForms-DUGkAwTp.js} +1 -1
  27. package/web/dist/assets/ThreadView-D8eXlVbC.js +1 -0
  28. package/web/dist/assets/citizen-BtOZq62_.js +1 -0
  29. package/web/dist/assets/{format-Cc6KjiYZ.js → format-EfoceZkm.js} +1 -1
  30. package/web/dist/assets/index-BeOGtAz0.css +2 -0
  31. package/web/dist/assets/index-D75IxLme.js +23 -0
  32. package/web/dist/assets/{tasks-CbQrkZ1C.js → tasks-Duke4U7N.js} +1 -1
  33. package/web/dist/assets/{useStickyScroll-NvlGfk6Z.js → useStickyScroll-n5d7sdPZ.js} +1 -1
  34. package/web/dist/index.html +4 -4
  35. package/web/dist/assets/BackLink-1EUHNOu3.js +0 -1
  36. package/web/dist/assets/ChannelView-A47Rvstg.js +0 -1
  37. package/web/dist/assets/CitizenView-B_jG9N5w.js +0 -2
  38. package/web/dist/assets/KnowledgeView-CXRQ4igk.js +0 -1
  39. package/web/dist/assets/ProjectView-DFReelVz.js +0 -1
  40. package/web/dist/assets/ProposalView-3L5osVUL.js +0 -1
  41. package/web/dist/assets/SocietyView--buUCciX.js +0 -1
  42. package/web/dist/assets/SubjectThread-C811fit3.js +0 -1
  43. package/web/dist/assets/ThreadView-BioBQeqV.js +0 -1
  44. package/web/dist/assets/citizen-BqHxQY9q.js +0 -1
  45. package/web/dist/assets/index-WB0PqHlY.js +0 -23
  46. package/web/dist/assets/index-t2fXHB5Y.css +0 -2
package/dist/cli/index.js CHANGED
@@ -57,7 +57,9 @@ var VERB_NAMES = [
57
57
  "advance_task",
58
58
  "configure_project",
59
59
  "archive_project",
60
- "remove_knowledge"
60
+ "remove_knowledge",
61
+ "create_channel",
62
+ "archive_channel"
61
63
  ];
62
64
  var VerbNameSchema = z2.enum(VERB_NAMES);
63
65
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -107,7 +109,11 @@ var MEMBER_VERBS = [
107
109
  ];
108
110
  var GOVERNANCE_VERBS = ["approve", "reject"];
109
111
  var FRONT_DESK_VERBS = ["create_project"];
110
- var PLANNING_VERBS = ["configure_project"];
112
+ var PLANNING_VERBS = [
113
+ "configure_project",
114
+ "create_channel",
115
+ "archive_channel"
116
+ ];
111
117
  var ARCHIVE_VERBS = ["archive_project"];
112
118
  var SEED_ROLES = [
113
119
  {
@@ -129,7 +135,7 @@ var SEED_ROLES = [
129
135
  },
130
136
  {
131
137
  name: "steward",
132
- 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, a channel when a topic needs one. 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.",
138
+ 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.",
133
139
  verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS],
134
140
  wakeTriggers: ["ops_event", "heartbeat"],
135
141
  maxReplicas: 1,
@@ -140,7 +146,7 @@ var SEED_ROLES = [
140
146
  },
141
147
  {
142
148
  name: "concierge",
143
- purpose: "The society's front desk. Wakes on every post the user makes and routes it: a question gets an answer where it was asked, in its thread when it came in one; work in an area an existing project covers gets a task there, planned when it is created, since a project is a lasting area of work and not one request; work in a new area gets a project, created on the spot, and the member or role proposals it needs for the user to approve. Reorganizes projects when the user asks: creates or picks the project the work belongs in, adds the citizens it needs, files a task there to bring over what the old projects hold (their task branches and their knowledge), and once that task is done proposes archiving each old project for the user to approve. Plans every task it routes: names stages by the work, assigns each to a role or a citizen, and gates where a second pair of eyes is worth it: before anything lands in a shared deliverable, so always before a merge; before effects outside the society; and before results are presented as findings. When a task it created is done, tells the user. Reads the roster in every digest to choose citizens by role, reach, availability, and profile, and adds a citizen to a project when the work needs it. Mentions a citizen with @ only to hand it work outside a plan, since a mention wakes it and costs a turn; lists and describes citizens by plain name. Stays silent when the user already addressed a citizen and nothing else is needed. Never does the work itself and never decides hiring.",
149
+ purpose: "The society's front desk. Wakes on every post the user makes and routes it: a question gets an answer where it was asked, in its thread when it came in one; work in an area an existing project covers gets a task there, planned when it is created and filed in the channel of its workstream when it belongs to one, such as a release, which the concierge opens with create_channel and archives once the workstream is done, since a project is a lasting area of work and not one request; work in a new area gets a project, created on the spot, and the member or role proposals it needs for the user to approve. Reorganizes projects when the user asks: creates or picks the project the work belongs in, adds the citizens it needs, files a task there to bring over what the old projects hold (their task branches and their knowledge), and once that task is done proposes archiving each old project for the user to approve. Plans every task it routes: names stages by the work, assigns each to a role or a citizen, and gates where a second pair of eyes is worth it: before anything lands in a shared deliverable, so always before a merge; before effects outside the society; and before results are presented as findings. When a task it created is done, tells the user. Reads the roster in every digest to choose citizens by role, reach, availability, and profile, and adds a citizen to a project when the work needs it. Mentions a citizen with @ only to hand it work outside a plan, since a mention wakes it and costs a turn; lists and describes citizens by plain name. Stays silent when the user already addressed a citizen and nothing else is needed. Never does the work itself and never decides hiring.",
144
150
  verbs: [...MEMBER_VERBS, ...FRONT_DESK_VERBS, ...PLANNING_VERBS],
145
151
  wakeTriggers: ["user_post", "heartbeat"],
146
152
  maxReplicas: 1,
@@ -156,16 +162,28 @@ import { z as z3 } from "zod";
156
162
  var ASK_CHANNEL = "asks";
157
163
  var SOCIETY_CHANNELS = ["general", "governance", ASK_CHANNEL];
158
164
  var PROJECT_DEFAULT_CHANNELS = ["general"];
165
+ function isBoardChannel(project2, name) {
166
+ const used = project2 === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
167
+ return used.includes(name);
168
+ }
159
169
  var SOCIETY_SCOPE = "society";
160
170
  function wakeScope(member, project2) {
161
171
  return project2 !== null && member.memberships.includes(project2) ? project2 : SOCIETY_SCOPE;
162
172
  }
163
173
  var CliKindSchema = z3.enum(["claude", "codex"]);
174
+ var ArchivedChannelSchema = z3.object({
175
+ name: NameSchema,
176
+ at: IsoDateTimeSchema,
177
+ by: NameSchema,
178
+ reason: z3.string().min(1)
179
+ });
164
180
  var SocietySchema = z3.object({
165
181
  name: z3.string().min(1),
166
182
  version: z3.literal(1),
167
183
  createdAt: IsoDateTimeSchema,
168
- channels: z3.array(NameSchema)
184
+ /** The channels that take posts; an archived one moves to `archivedChannels`. */
185
+ channels: z3.array(NameSchema),
186
+ archivedChannels: z3.array(ArchivedChannelSchema).default([])
169
187
  });
170
188
  var CompletionEffectSchema = z3.enum(["none", "merge"]);
171
189
  var DefaultBranchSchema = z3.string().trim().min(1).max(100).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "a branch name is letters, digits, and . _ / -").refine((branch) => !branch.includes("..") && !branch.includes("//") && !branch.endsWith("/") && !branch.endsWith(".") && !branch.endsWith(".lock"), "not a branch name git takes").refine((branch) => !branch.startsWith("task/") && !branch.startsWith("agent/"), "task/ and agent/ hold the board's own branches");
@@ -189,7 +207,9 @@ var ProjectSchema = z3.object({
189
207
  name: z3.string().min(1),
190
208
  repo: z3.string().nullable(),
191
209
  defaultBranch: z3.string().min(1),
210
+ /** The channels that take posts; an archived one moves to `archivedChannels`. */
192
211
  channels: z3.array(NameSchema),
212
+ archivedChannels: z3.array(ArchivedChannelSchema).default([]),
193
213
  members: z3.array(NameSchema),
194
214
  approvers: z3.array(NameSchema),
195
215
  requiredCapabilities: z3.array(z3.string()),
@@ -260,6 +280,8 @@ var TaskReturnSchema = z3.object({
260
280
  var TaskFrontmatterSchema = z3.object({
261
281
  id: UlidSchema,
262
282
  project: NameSchema,
283
+ /** The channel of the project the task is filed in, where its thread hangs. */
284
+ channel: NameSchema.default("general"),
263
285
  title: z3.string().min(1),
264
286
  status: TaskStatusSchema,
265
287
  createdBy: NameSchema,
@@ -606,7 +628,9 @@ var VerbInputs = {
606
628
  parent_id: UlidSchema.optional(),
607
629
  required_capabilities: z4.array(z4.string()).default([]),
608
630
  /** The plan; without it the task gets one stage, work, that anyone in the project may take. */
609
- stages: z4.array(PlanStageSchema).min(1).optional()
631
+ stages: z4.array(PlanStageSchema).min(1).optional(),
632
+ /** A channel of the project the task belongs to, such as a release's; its thread opens there. */
633
+ channel: NameSchema.default("general")
610
634
  }),
611
635
  claim_task: z4.object({ task_id: UlidSchema }),
612
636
  release_task: z4.object({ task_id: UlidSchema }),
@@ -664,7 +688,14 @@ var VerbInputs = {
664
688
  /** The branch tasks start from and land on from now; tasks in play keep their branches. */
665
689
  default_branch: DefaultBranchSchema.optional()
666
690
  }),
667
- archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) })
691
+ archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) }),
692
+ create_channel: z4.object({
693
+ /** A project, or null for a channel of the society. */
694
+ project: NameSchema.nullable().default(null),
695
+ name: NameSchema,
696
+ purpose: z4.string().min(1)
697
+ }),
698
+ archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) })
668
699
  };
669
700
 
670
701
  // packages/shared/dist/events.js
@@ -697,6 +728,7 @@ var BOARD_EVENT_TYPES = [
697
728
  "proposal.decided",
698
729
  "proposal.provisioned",
699
730
  "channel.added",
731
+ "channel.archived",
700
732
  "role.added",
701
733
  "agent.retired",
702
734
  "agent.joined",
@@ -718,7 +750,8 @@ var BOARD_EVENT_TYPES = [
718
750
  "turn.completed",
719
751
  "turn.failed",
720
752
  "merge.completed",
721
- "merge.failed"
753
+ "merge.failed",
754
+ "workspace.leftovers"
722
755
  ];
723
756
  var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
724
757
  var BoardEventSchema = z5.object({
@@ -798,6 +831,8 @@ var LiveTurnEventSchema = z5.object({
798
831
  project: NameSchema,
799
832
  /** The thread whose conversation the turn is in; absent for the home conversation. */
800
833
  thread: UlidSchema.optional(),
834
+ /** The channel, never general, whose conversation the turn is in. */
835
+ channel: NameSchema.optional(),
801
836
  event: AgentEventSchema
802
837
  });
803
838
 
@@ -866,11 +901,17 @@ var ProjectRepoSchema = z6.object({
866
901
  defaultBranch: z6.string().min(1)
867
902
  });
868
903
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
869
- z6.object({ kind: z6.literal("home") }),
904
+ z6.object({
905
+ kind: z6.literal("home"),
906
+ thread: UlidSchema.optional(),
907
+ channel: NameSchema.optional()
908
+ }),
870
909
  z6.object({
871
910
  kind: z6.literal("project"),
872
911
  repo: ProjectRepoSchema,
873
- branches: z6.array(z6.string().min(1))
912
+ branches: z6.array(z6.string().min(1)),
913
+ thread: UlidSchema.optional(),
914
+ channel: NameSchema.optional()
874
915
  }),
875
916
  z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
876
917
  ]);
@@ -886,6 +927,8 @@ var TurnJobSchema = z6.object({
886
927
  scope: NameSchema,
887
928
  /** The thread whose conversation the turn is in; absent for the home conversation. */
888
929
  thread: UlidSchema.optional(),
930
+ /** The channel, never general, whose conversation the turn is in. */
931
+ channel: NameSchema.optional(),
889
932
  /**
890
933
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
891
934
  * the outcome reports it.
@@ -919,9 +962,14 @@ var TurnOutcomeSchema = z6.object({
919
962
  session: z6.string().min(1),
920
963
  /** The model the CLI reported, when it reported one. */
921
964
  model: z6.string().nullable(),
922
- work: TurnWorkSchema.nullable()
965
+ work: TurnWorkSchema.nullable(),
966
+ /**
967
+ * Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
968
+ * branch after the hand-back: uncommitted changes, or commits no branch contains.
969
+ */
970
+ leftovers: z6.boolean().default(false)
923
971
  });
924
- var TurnAckSchema = z6.object({ dropWorktree: z6.boolean() });
972
+ var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
925
973
  var LandRequestSchema = z6.object({
926
974
  repo: ProjectRepoSchema,
927
975
  branch: z6.string().min(1)
@@ -978,6 +1026,17 @@ var BranchChangesSchema = z6.object({
978
1026
  files: z6.array(BranchFileChangeSchema),
979
1027
  total: z6.number().int().nonnegative()
980
1028
  });
1029
+ var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
1030
+ z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
1031
+ z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
1032
+ ]);
1033
+ var HeldWorkspaceSchema = z6.object({
1034
+ agent: NameSchema,
1035
+ scope: NameSchema,
1036
+ conversation: WorkspaceConversationSchema
1037
+ });
1038
+ var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
1039
+ var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
981
1040
  var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
982
1041
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
983
1042
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
@@ -999,7 +1058,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
999
1058
  steer: TurnSteerSchema
1000
1059
  }),
1001
1060
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1002
- z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
1061
+ z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
1062
+ /**
1063
+ * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1064
+ * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
1065
+ */
1066
+ z6.object({
1067
+ type: z6.literal("sweep"),
1068
+ request: z6.string().min(1),
1069
+ conversation: WorkspaceConversationSchema
1070
+ })
1003
1071
  ]);
1004
1072
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
1005
1073
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -1027,7 +1095,8 @@ var TriggerKindSchema = z7.enum([
1027
1095
  "onboarding",
1028
1096
  "manual",
1029
1097
  "ops_event",
1030
- "user_post"
1098
+ "user_post",
1099
+ "closing"
1031
1100
  ]);
1032
1101
  var TriggerSchema = z7.object({
1033
1102
  kind: TriggerKindSchema,
@@ -1044,10 +1113,20 @@ var TurnDispatchSchema = z7.object({
1044
1113
  agent: NameSchema,
1045
1114
  project: NameSchema,
1046
1115
  thread: TurnThreadSchema.optional(),
1116
+ channel: NameSchema.optional(),
1047
1117
  trigger: TriggerSchema,
1048
1118
  priority: z7.number().int().min(0).max(2),
1049
1119
  onboarding: z7.boolean().default(false)
1050
1120
  });
1121
+ function conversationPart(conversation) {
1122
+ return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
1123
+ }
1124
+ function channelConversation(scope, channel) {
1125
+ const at = channel.indexOf("/");
1126
+ const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
1127
+ const name = at < 0 ? channel : channel.slice(at + 1);
1128
+ return place === scope && name !== "general" ? name : void 0;
1129
+ }
1051
1130
  var TurnRecordSchema = z7.object({
1052
1131
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
1053
1132
  id: UlidSchema.optional(),
@@ -1055,6 +1134,8 @@ var TurnRecordSchema = z7.object({
1055
1134
  project: NameSchema,
1056
1135
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1057
1136
  thread: UlidSchema.optional(),
1137
+ /** The channel, never general, whose conversation the turn was in. */
1138
+ channel: NameSchema.optional(),
1058
1139
  runner: NameSchema,
1059
1140
  cli: CliKindSchema.nullable(),
1060
1141
  session: z7.string().nullable(),
@@ -1084,8 +1165,9 @@ var RunningTurnSchema = z7.object({
1084
1165
  agent: NameSchema,
1085
1166
  scope: NameSchema,
1086
1167
  thread: UlidSchema.optional(),
1168
+ channel: NameSchema.optional(),
1087
1169
  cli: CliKindSchema,
1088
- channel: ChannelRefSchema.optional(),
1170
+ askedIn: ChannelRefSchema.optional(),
1089
1171
  steerable: z7.boolean(),
1090
1172
  stoppable: z7.boolean()
1091
1173
  });
@@ -1112,6 +1194,8 @@ var TurnHistoryEntrySchema = z7.object({
1112
1194
  project: NameSchema,
1113
1195
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1114
1196
  thread: UlidSchema.optional(),
1197
+ /** The channel, never general, whose conversation the turn was in. */
1198
+ channel: NameSchema.optional(),
1115
1199
  trigger: z7.string(),
1116
1200
  exitReason: z7.string().nullable(),
1117
1201
  costUsd: z7.number(),
@@ -1138,7 +1222,8 @@ var OPS_SIGNAL_KINDS = [
1138
1222
  "turn_cost",
1139
1223
  "scaled",
1140
1224
  "runner",
1141
- "home_conflict"
1225
+ "home_conflict",
1226
+ "stuck_workspace"
1142
1227
  ];
1143
1228
  var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
1144
1229
  var OpsSignalSchema = z8.object({
@@ -1300,7 +1385,7 @@ var RunnerConfigSchema = z10.object({
1300
1385
  });
1301
1386
 
1302
1387
  // packages/shared/dist/version.js
1303
- var STELLARIS_VERSION = "0.3.9";
1388
+ var STELLARIS_VERSION = "0.3.11";
1304
1389
 
1305
1390
  // packages/board-core/dist/errors.js
1306
1391
  var BoardError = class extends Error {
@@ -1345,15 +1430,28 @@ async function writeJson(file, value) {
1345
1430
  await writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
1346
1431
  `);
1347
1432
  }
1433
+ function parseMatter(raw) {
1434
+ return matter(raw, {});
1435
+ }
1348
1436
  async function readMarkdown(file, schema) {
1349
1437
  const raw = await readFile(file, "utf8");
1350
- const parsed = matter(raw);
1438
+ const parsed = parseMatter(raw);
1351
1439
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1352
1440
  }
1441
+ async function readLooseMarkdown(file) {
1442
+ const raw = await readFile(file, "utf8");
1443
+ try {
1444
+ const parsed = parseMatter(raw);
1445
+ return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
1446
+ } catch (error) {
1447
+ const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
1448
+ return { data: {}, body: raw, error: reason.replace(/:$/, "") };
1449
+ }
1450
+ }
1353
1451
  async function writeMarkdown(file, data, body) {
1354
1452
  const normalized = body.endsWith("\n") || body.length === 0 ? body : `${body}
1355
1453
  `;
1356
- await writeFileAtomic(file, matter.stringify(normalized, data));
1454
+ await writeFileAtomic(file, matter.stringify({ content: normalized }, data, {}));
1357
1455
  }
1358
1456
  function isMissing(error) {
1359
1457
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -1558,6 +1656,7 @@ var HOME_GITIGNORE = [
1558
1656
  "/projects/*/sessions*.json",
1559
1657
  "/projects/*/last-turn.json",
1560
1658
  "/projects/*/threads/",
1659
+ "/projects/*/channels/",
1561
1660
  `/${HOME_SCRATCH}/`,
1562
1661
  "*.tmp",
1563
1662
  "node_modules/",
@@ -1575,7 +1674,7 @@ var HOME_GITIGNORE = [
1575
1674
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
1576
1675
  `;
1577
1676
  var PRE_RECEIVE = `#!/bin/sh
1578
- forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
1677
+ forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
1579
1678
  while read old new ref; do
1580
1679
  if [ "$ref" != "refs/heads/main" ]; then
1581
1680
  echo "a home takes pushes to main only" >&2
@@ -1836,6 +1935,7 @@ var TurnPayload = z11.object({
1836
1935
  turnId: z11.string().optional(),
1837
1936
  project: z11.string(),
1838
1937
  thread: z11.string().optional(),
1938
+ channel: z11.string().optional(),
1839
1939
  trigger: z11.string()
1840
1940
  });
1841
1941
  var TaskPayload = z11.object({
@@ -1878,7 +1978,14 @@ function answers(mention, turn2) {
1878
1978
  if (turn2.thread !== null) {
1879
1979
  return mention.thread === turn2.thread;
1880
1980
  }
1881
- return mention.scopes.includes(turn2.project) && (mention.thread === null || turn2.trigger === "mention");
1981
+ if (!mention.scopes.includes(turn2.project)) {
1982
+ return false;
1983
+ }
1984
+ const own = mention.thread === null ? channelConversation(turn2.project, mention.channel) : void 0;
1985
+ if (turn2.channel !== null) {
1986
+ return mention.thread === null && own === turn2.channel;
1987
+ }
1988
+ return mention.thread === null && own === void 0 || turn2.trigger === "mention";
1882
1989
  }
1883
1990
  function computeMetrics(input) {
1884
1991
  const { events, now, window } = input;
@@ -1943,6 +2050,7 @@ function computeMetrics(input) {
1943
2050
  agent: event.actor,
1944
2051
  at,
1945
2052
  thread: turn2.data.thread ?? null,
2053
+ channel: turn2.data.channel ?? null,
1946
2054
  project: turn2.data.project,
1947
2055
  trigger: turn2.data.trigger
1948
2056
  };
@@ -2039,6 +2147,7 @@ function computeMetrics(input) {
2039
2147
  at: Date.parse(event.ts),
2040
2148
  message: message.data.id ?? null,
2041
2149
  thread: thread2,
2150
+ channel: message.data.channel,
2042
2151
  scopes: [project2, wakeScope(citizen, project2)].filter((scope) => scope !== null)
2043
2152
  });
2044
2153
  }
@@ -2314,9 +2423,12 @@ var BoardPaths = class {
2314
2423
  agentProject(name, slug) {
2315
2424
  return path5.join(this.agentProjects(name), slug);
2316
2425
  }
2317
- /** A thread conversation's session and last turn, beside the scope's home conversation. */
2318
- agentThread(name, slug, thread2) {
2319
- return path5.join(this.agentProject(name, slug), "threads", thread2);
2426
+ /**
2427
+ * A thread's or a channel's conversation's session and last turn, beside the scope's home
2428
+ * conversation; `part` is a thread's id or `#channel`.
2429
+ */
2430
+ agentConversation(name, slug, part) {
2431
+ return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
2320
2432
  }
2321
2433
  agentCursors(name) {
2322
2434
  return path5.join(this.agent(name), "cursors.json");
@@ -2350,6 +2462,13 @@ function hashToken(token) {
2350
2462
 
2351
2463
  // packages/board-core/dist/board.js
2352
2464
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
2465
+ var CLOSING_ATTEMPTS = 3;
2466
+ function ownTurn(actor, turn2) {
2467
+ return turn2.agent === actor.name && turn2.scope === actor.scope && turn2.thread === actor.thread && turn2.channel === actor.channel;
2468
+ }
2469
+ function namesList(names) {
2470
+ return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
2471
+ }
2353
2472
  var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
2354
2473
  var RunnerTokensSchema = z12.object({
2355
2474
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
@@ -2367,6 +2486,7 @@ var SignInsSchema = z12.object({
2367
2486
  var TurnHistoryPayloadSchema = z12.object({
2368
2487
  project: NameSchema,
2369
2488
  thread: UlidSchema.optional(),
2489
+ channel: NameSchema.optional(),
2370
2490
  trigger: z12.string().default("manual"),
2371
2491
  exitReason: z12.string().nullable().default(null),
2372
2492
  costUsd: z12.number().default(0),
@@ -2382,12 +2502,23 @@ var CursorsSchema = z12.object({
2382
2502
  digest: z12.string().nullable(),
2383
2503
  scopes: z12.record(z12.string(), z12.string()).default({}),
2384
2504
  threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2505
+ channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2385
2506
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
2386
2507
  news: z12.string().optional()
2387
2508
  });
2388
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
2389
- function conversationKey(scope, thread2) {
2390
- return thread2 === null ? scope : `${scope}/${thread2}`;
2509
+ var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
2510
+ function conversationKey(scope, part) {
2511
+ return part === null ? scope : `${scope}/${part}`;
2512
+ }
2513
+ function channelOfPart(part) {
2514
+ return part?.startsWith("#") === true ? part.slice(1) : void 0;
2515
+ }
2516
+ function threadOfPart(part) {
2517
+ return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
2518
+ }
2519
+ function channelStart(cursors, scope) {
2520
+ const from = cursors.channelsFrom[scope];
2521
+ return from === void 0 ? cursors.digest : from;
2391
2522
  }
2392
2523
  function threadStart(cursors, scope) {
2393
2524
  const from = cursors.threadsFrom[scope];
@@ -2400,11 +2531,11 @@ function newsStart(cursors) {
2400
2531
  const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
2401
2532
  return homes.at(-1) ?? cursors.digest;
2402
2533
  }
2403
- function cursorOf(cursors, scope, thread2 = null) {
2404
- if (thread2 === null) {
2534
+ function cursorOf(cursors, scope, part = null) {
2535
+ if (part === null) {
2405
2536
  return cursors.scopes[scope] ?? cursors.digest;
2406
2537
  }
2407
- return cursors.scopes[conversationKey(scope, thread2)] ?? threadStart(cursors, scope);
2538
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
2408
2539
  }
2409
2540
  function byId(a, b) {
2410
2541
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -2415,6 +2546,9 @@ function turnTime(record) {
2415
2546
  var PausedSchema = z12.object({ paused: z12.boolean() });
2416
2547
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
2417
2548
  var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2549
+ function withoutMentions(text) {
2550
+ return text.replaceAll(MENTION_PATTERN, "$1$2");
2551
+ }
2418
2552
  var CURATING_ROLES = [USER_ROLE, "steward"];
2419
2553
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2420
2554
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -2431,6 +2565,7 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
2431
2565
  var THREAD_CONVERSATIONS = "thread-conversations";
2432
2566
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2433
2567
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2568
+ var CHANNEL_CONVERSATIONS = "channel-conversations";
2434
2569
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2435
2570
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2436
2571
  var PROFILE_TEMPLATE = "# Profile\n\nOne short paragraph, kept current: what I do well, what I am working on, and what to send my way. The board projects this into the roster the front desk reads.\n";
@@ -2561,6 +2696,8 @@ function snippetAround(text, query, radius = 80) {
2561
2696
  var Board = class _Board {
2562
2697
  paths;
2563
2698
  mutex = new Mutex();
2699
+ /** The turns in flight, which the server hosting them supplies; none until it does. */
2700
+ turnsInFlight = () => [];
2564
2701
  newId = monotonicFactory();
2565
2702
  events;
2566
2703
  actionCounts = /* @__PURE__ */ new Map();
@@ -2621,7 +2758,8 @@ var Board = class _Board {
2621
2758
  DECISIONS_CHANNEL_RETIRED,
2622
2759
  THREAD_CONVERSATIONS,
2623
2760
  ASKS_CHANNEL_OPENED,
2624
- KNOWLEDGE_REMOVAL
2761
+ KNOWLEDGE_REMOVAL,
2762
+ CHANNEL_CONVERSATIONS
2625
2763
  ].filter((name) => !applied.includes(name));
2626
2764
  if (pending.length === 0) {
2627
2765
  return;
@@ -2653,6 +2791,9 @@ var Board = class _Board {
2653
2791
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2654
2792
  await this.grantKnowledgeRemoval();
2655
2793
  }
2794
+ if (pending.includes(CHANNEL_CONVERSATIONS)) {
2795
+ await this.startChannelCursors();
2796
+ }
2656
2797
  await writeJson(file, { applied: [...applied, ...pending] });
2657
2798
  }
2658
2799
  /**
@@ -2827,6 +2968,27 @@ var Board = class _Board {
2827
2968
  }
2828
2969
  }
2829
2970
  }
2971
+ /**
2972
+ * Every channel of a scope was once read in its home conversation, so a member had read each up
2973
+ * to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
2974
+ * conversation of its own.
2975
+ */
2976
+ async startChannelCursors() {
2977
+ for (const agent2 of await this.listAgents()) {
2978
+ const file = this.paths.agentCursors(agent2.name);
2979
+ if (!await exists(file)) {
2980
+ continue;
2981
+ }
2982
+ const cursors = await readJson(file, CursorsSchema);
2983
+ const scopes = /* @__PURE__ */ new Set([
2984
+ ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
2985
+ ...agent2.memberships,
2986
+ SOCIETY_SCOPE
2987
+ ]);
2988
+ const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
2989
+ await writeJson(file, { ...cursors, channelsFrom });
2990
+ }
2991
+ }
2830
2992
  /**
2831
2993
  * Threads were once read in the scope's single conversation, so a member had read every thread
2832
2994
  * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
@@ -2960,17 +3122,20 @@ ${charter.purpose}
2960
3122
  /**
2961
3123
  * A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
2962
3124
  * in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
2963
- * scope and thread ride on the actor it resolves to, so `read_inbox` reads that conversation's
2964
- * digest.
3125
+ * scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
3126
+ * `read_inbox` reads that conversation's digest.
2965
3127
  */
2966
- issueTurnToken(agent2, role2, ttlMs, scope, thread2) {
3128
+ issueTurnToken(agent2, role2, ttlMs, scope, part) {
2967
3129
  const token = mintToken();
3130
+ const thread2 = threadOfPart(part);
3131
+ const channel = channelOfPart(part);
2968
3132
  this.turnTokens.set(hashToken(token), {
2969
3133
  actor: {
2970
3134
  name: agent2,
2971
3135
  role: role2,
2972
3136
  ...scope === void 0 ? {} : { scope },
2973
- ...thread2 === void 0 ? {} : { thread: thread2 }
3137
+ ...thread2 === void 0 ? {} : { thread: thread2 },
3138
+ ...channel === void 0 ? {} : { channel }
2974
3139
  },
2975
3140
  expiresAt: this.now().getTime() + ttlMs
2976
3141
  });
@@ -3205,7 +3370,7 @@ ${charter.purpose}
3205
3370
  }
3206
3371
  async readProfile(name) {
3207
3372
  const file = this.paths.agentProfile(name);
3208
- return await exists(file) ? (await readMarkdown(file, z12.record(z12.string(), z12.unknown()))).body : "";
3373
+ return await exists(file) ? (await readLooseMarkdown(file)).body : "";
3209
3374
  }
3210
3375
  /** The society's norms, the one knowledge topic every turn loads. Empty until the steward writes it. */
3211
3376
  async readSocietyNorms() {
@@ -3252,10 +3417,11 @@ ${charter.purpose}
3252
3417
  if (!name.success) {
3253
3418
  continue;
3254
3419
  }
3255
- const doc = await readMarkdown(file, z12.record(z12.string(), z12.unknown()));
3420
+ const doc = await readLooseMarkdown(file);
3256
3421
  skills.push({
3257
3422
  name: name.data,
3258
- summary: skillSummary(doc.data, doc.body),
3423
+ // The index is where its writer sees that the file needs fixing.
3424
+ summary: doc.error === null ? skillSummary(doc.data, doc.body) : `its frontmatter does not parse (${doc.error}), so fix the file`,
3259
3425
  scope,
3260
3426
  path: file
3261
3427
  });
@@ -3269,34 +3435,44 @@ ${charter.purpose}
3269
3435
  }
3270
3436
  return (await readJson(file, PausedSchema)).paused;
3271
3437
  }
3272
- /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3273
- /** Every channel, the society's first, with how many messages it holds and its newest one. */
3438
+ /**
3439
+ * Every channel, the society's first, with how many messages it holds and its newest one; each
3440
+ * place's archived channels follow its open ones.
3441
+ */
3274
3442
  async listChannels() {
3275
3443
  const summaries = [];
3276
- const scopes = [
3277
- { project: null, names: (await this.society()).channels },
3444
+ const society = await this.society();
3445
+ const places = [
3446
+ { project: null, names: society.channels, archived: society.archivedChannels },
3278
3447
  ...(await this.listProjects()).map((project2) => ({
3279
3448
  project: project2.slug,
3280
- names: project2.channels
3449
+ names: project2.channels,
3450
+ archived: project2.archivedChannels
3281
3451
  }))
3282
3452
  ];
3283
- for (const { project: project2, names } of scopes) {
3284
- for (const name of names) {
3285
- const ref = channelRef(project2, name);
3453
+ for (const { project: project2, names, archived } of places) {
3454
+ const entries = [
3455
+ ...names.map((name) => ({ name, archived: void 0 })),
3456
+ ...archived.map((each) => ({ name: each.name, archived: each }))
3457
+ ];
3458
+ for (const entry of entries) {
3459
+ const ref = channelRef(project2, entry.name);
3286
3460
  const files = await listFiles(this.paths.channelDir(ref));
3287
3461
  const last = files.at(-1)?.slice(0, 26) ?? null;
3288
3462
  summaries.push({
3289
3463
  ref,
3290
3464
  project: project2,
3291
- name,
3465
+ name: entry.name,
3292
3466
  messages: files.length,
3293
3467
  lastMessageId: last,
3294
- lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
3468
+ lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
3469
+ ...entry.archived === void 0 ? {} : { archived: entry.archived }
3295
3470
  });
3296
3471
  }
3297
3472
  }
3298
3473
  return summaries;
3299
3474
  }
3475
+ /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3300
3476
  async listChannel(ref, limit = 200) {
3301
3477
  await this.assertChannelExists(ref);
3302
3478
  const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
@@ -3449,12 +3625,27 @@ Agents may edit this file. It is rendered by the board UI.
3449
3625
  return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
3450
3626
  });
3451
3627
  }
3628
+ /** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
3629
+ async createChannel(actor, input) {
3630
+ const args = VerbInputs.create_channel.parse(input);
3631
+ await this.authorize(actor, "create_channel");
3632
+ return this.mutex.run(async () => {
3633
+ await this.validateAddChannel(args);
3634
+ return this.addChannelUnlocked(actor.name, args, {}, actor.name);
3635
+ });
3636
+ }
3637
+ /** Archives a channel whose workstream is done; refused while a task filed there is in play. */
3638
+ async archiveChannel(actor, input) {
3639
+ const args = VerbInputs.archive_channel.parse(input);
3640
+ await this.authorize(actor, "archive_channel");
3641
+ return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
3642
+ }
3452
3643
  /** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
3453
3644
  async addChannel(actor, input) {
3454
3645
  this.assertAdmin(actor);
3455
3646
  return this.mutex.run(async () => {
3456
3647
  await this.validateAddChannel(input);
3457
- return this.addChannelUnlocked(actor.name, input, {});
3648
+ return this.addChannelUnlocked(actor.name, input, {}, actor.name);
3458
3649
  });
3459
3650
  }
3460
3651
  /**
@@ -3652,19 +3843,19 @@ Agents may edit this file. It is rendered by the board UI.
3652
3843
  return this.mutex.run(async () => {
3653
3844
  const stored = await this.readCursors(actor.name);
3654
3845
  const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
3655
- const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, thread: actor.thread ?? null };
3846
+ const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
3656
3847
  const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
3657
3848
  if (args.advance) {
3658
3849
  await this.advanceCursors(actor.name, filed);
3659
3850
  }
3660
3851
  const last = filed.at(-1)?.message.id;
3661
- const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.thread));
3852
+ const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
3662
3853
  return { messages: filed.map((entry) => entry.message), cursor };
3663
3854
  });
3664
3855
  }
3665
3856
  /**
3666
3857
  * How many unread digest messages a member has in each conversation: a scope's home, for its
3667
- * channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3858
+ * general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3668
3859
  * messages are and nowhere else. Society channels count only for a member woken in the society
3669
3860
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
3670
3861
  * counted, and are read when the member wakes for something else: a task step's, which wakes
@@ -3675,7 +3866,7 @@ Agents may edit this file. It is rendered by the board UI.
3675
3866
  return this.mutex.run(async () => {
3676
3867
  const counts = /* @__PURE__ */ new Map();
3677
3868
  const unscoped = { name: actor.name, role: actor.role };
3678
- for (const { scope, thread: thread2, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3869
+ for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3679
3870
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
3680
3871
  continue;
3681
3872
  }
@@ -3683,8 +3874,13 @@ Agents may edit this file. It is rendered by the board UI.
3683
3874
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
3684
3875
  continue;
3685
3876
  }
3686
- const key = conversationKey(scope, thread2);
3687
- const entry = counts.get(key) ?? { scope, thread: thread2, count: 0 };
3877
+ const key = conversationKey(scope, part);
3878
+ const entry = counts.get(key) ?? {
3879
+ scope,
3880
+ thread: threadOfPart(part) ?? null,
3881
+ channel: channelOfPart(part) ?? null,
3882
+ count: 0
3883
+ };
3688
3884
  counts.set(key, { ...entry, count: entry.count + 1 });
3689
3885
  }
3690
3886
  return [...counts.values()];
@@ -3700,7 +3896,7 @@ Agents may edit this file. It is rendered by the board UI.
3700
3896
  const scopes = { ...cursors.scopes };
3701
3897
  let news = newsStart(cursors);
3702
3898
  let moved = false;
3703
- for (const { scope, thread: thread2, message, news: isNews } of filed) {
3899
+ for (const { scope, part, message, news: isNews } of filed) {
3704
3900
  if (isNews === true) {
3705
3901
  if (news === null || message.id > news) {
3706
3902
  news = message.id;
@@ -3708,8 +3904,8 @@ Agents may edit this file. It is rendered by the board UI.
3708
3904
  }
3709
3905
  continue;
3710
3906
  }
3711
- const key = conversationKey(scope, thread2);
3712
- const current = scopes[key] ?? cursorOf(cursors, scope, thread2);
3907
+ const key = conversationKey(scope, part);
3908
+ const current = scopes[key] ?? cursorOf(cursors, scope, part);
3713
3909
  if (current === null || message.id > current) {
3714
3910
  scopes[key] = message.id;
3715
3911
  moved = true;
@@ -3728,29 +3924,32 @@ Agents may edit this file. It is rendered by the board UI.
3728
3924
  * message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
3729
3925
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
3730
3926
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
3731
- * message of the thread, since the conversation is about it; for a scope's home, its channel
3732
- * messages, since each thread is a conversation of its own.
3927
+ * message of the thread, since the conversation is about it; for a channel's, that channel's
3928
+ * top-level messages; for a scope's home, general's and those of a project the member is not in,
3929
+ * since each thread and each other channel is a conversation of its own.
3733
3930
  */
3734
3931
  async digestSince(actor, cursors, conversation) {
3735
- if (conversation !== void 0 && conversation.thread !== null) {
3736
- const { scope, thread: thread2 } = conversation;
3737
- const found = await this.tryFindThread(thread2);
3932
+ const conversationThread = threadOfPart(conversation?.part);
3933
+ if (conversation !== void 0 && conversationThread !== void 0) {
3934
+ const { scope, part } = conversation;
3935
+ const found = await this.tryFindThread(conversationThread);
3738
3936
  if (found === null) {
3739
3937
  return [];
3740
3938
  }
3741
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, thread2), cursorOf(cursors, scope, thread2));
3742
- return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, thread: thread2, message })).toSorted(byId);
3939
+ const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
3940
+ return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
3743
3941
  }
3744
3942
  const agent2 = await this.readAgent(actor.name);
3745
3943
  const charter = await this.readRole(agent2.role);
3746
3944
  const subscribed = new Set(agent2.subscriptions);
3747
3945
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
3748
3946
  const threadParticipation = /* @__PURE__ */ new Map();
3947
+ const openChannels = /* @__PURE__ */ new Map();
3749
3948
  const collected = [];
3750
3949
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
3751
3950
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent2.memberships, SOCIETY_SCOPE];
3752
3951
  const starts = [
3753
- ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
3952
+ ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
3754
3953
  ...newsFrom === void 0 ? [] : [newsFrom]
3755
3954
  ];
3756
3955
  const known = starts.filter((start) => start !== null);
@@ -3763,20 +3962,24 @@ Agents may edit this file. It is rendered by the board UI.
3763
3962
  if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
3764
3963
  collected.push({
3765
3964
  scope: conversation?.scope ?? SOCIETY_SCOPE,
3766
- thread: null,
3965
+ part: null,
3767
3966
  message,
3768
3967
  news: true
3769
3968
  });
3770
3969
  }
3771
3970
  continue;
3772
3971
  }
3773
- if (conversation !== void 0 && filedIn !== conversation.scope) {
3774
- continue;
3972
+ const named = thread2 === null ? channelConversation(filedIn, message.channel) : void 0;
3973
+ let open2 = named === void 0 ? void 0 : openChannels.get(message.channel);
3974
+ if (named !== void 0 && open2 === void 0) {
3975
+ open2 = await this.channelOpen(message.channel);
3976
+ openChannels.set(message.channel, open2);
3775
3977
  }
3776
- if (conversation !== void 0 && thread2 !== null) {
3978
+ const part = thread2 ?? (open2 === true ? `#${named}` : null);
3979
+ if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
3777
3980
  continue;
3778
3981
  }
3779
- const cursor = cursorOf(cursors, filedIn, thread2);
3982
+ const cursor = cursorOf(cursors, filedIn, part);
3780
3983
  if (cursor !== null && message.id <= cursor) {
3781
3984
  continue;
3782
3985
  }
@@ -3794,7 +3997,7 @@ Agents may edit this file. It is rendered by the board UI.
3794
3997
  include = participates;
3795
3998
  }
3796
3999
  if (include && message.author !== actor.name) {
3797
- collected.push({ scope: filedIn, thread: thread2, message });
4000
+ collected.push({ scope: filedIn, part, message });
3798
4001
  }
3799
4002
  }
3800
4003
  return collected.toSorted(byId);
@@ -4033,7 +4236,7 @@ ${task2.body}`;
4033
4236
  taskThreadOpening(task2) {
4034
4237
  return {
4035
4238
  id: task2.id,
4036
- channel: channelRef(task2.project, "general"),
4239
+ channel: channelRef(task2.project, task2.channel),
4037
4240
  title: task2.title,
4038
4241
  subject: { kind: "task", id: task2.id }
4039
4242
  };
@@ -4079,7 +4282,7 @@ ${task2.body}`;
4079
4282
  if (task2.status === "done" || task2.status === "abandoned") {
4080
4283
  throw new BoardError("INVALID_STATE", `task ${task2.id} is ${task2.status}`);
4081
4284
  }
4082
- const channel = args.channel ?? channelRef(project2, "general");
4285
+ const channel = args.channel ?? channelRef(project2, task2.channel);
4083
4286
  if (parseChannelRef(channel).project !== project2) {
4084
4287
  throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project2}`);
4085
4288
  }
@@ -4112,6 +4315,44 @@ ${task2.body}`;
4112
4315
  await this.assertChannelOpen(args.channel);
4113
4316
  return { id: this.newId(), channel: args.channel, title: args.title };
4114
4317
  }
4318
+ /**
4319
+ * Where the board learns which turns are in flight, from the server that hosts them, so it
4320
+ * refuses to end a conversation under someone else's turn.
4321
+ */
4322
+ watchTurns(source) {
4323
+ this.turnsInFlight = source;
4324
+ }
4325
+ /**
4326
+ * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
4327
+ * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
4328
+ * from inside it is how a citizen finishes there.
4329
+ */
4330
+ assertNoTurnCut(actor, what, cut) {
4331
+ const busy = [
4332
+ ...new Set(this.turnsInFlight().filter((turn2) => cut(turn2) && !ownTurn(actor, turn2)).map((turn2) => turn2.agent))
4333
+ ].toSorted((a, b) => a.localeCompare(b));
4334
+ if (busy.length === 0) {
4335
+ return;
4336
+ }
4337
+ const one = busy.length === 1;
4338
+ throw new BoardError("INVALID_STATE", `${what} while ${namesList(busy)} ${one ? "is in a turn" : "are in turns"} there; try again once ${one ? "it ends" : "they end"}, or stop ${one ? "it" : "them"}`);
4339
+ }
4340
+ /** The open threads in a folder of threads that `keep` picks. */
4341
+ async openThreadIds(dir, keep) {
4342
+ const ids = /* @__PURE__ */ new Set();
4343
+ for (const file of await listFiles(dir)) {
4344
+ const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
4345
+ if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
4346
+ ids.add(doc.data.id);
4347
+ }
4348
+ }
4349
+ return ids;
4350
+ }
4351
+ /** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
4352
+ async assertProjectQuiet(actor, slug) {
4353
+ const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
4354
+ this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn2) => turn2.scope === slug || turn2.thread !== void 0 && threads.has(turn2.thread));
4355
+ }
4115
4356
  async closeThread(actor, input) {
4116
4357
  const args = VerbInputs.close_thread.parse(input);
4117
4358
  await this.authorize(actor, "close_thread");
@@ -4126,6 +4367,7 @@ ${task2.body}`;
4126
4367
  if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread2, threads)) {
4127
4368
  throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
4128
4369
  }
4370
+ this.assertNoTurnCut(actor, `thread ${thread2.id} cannot close`, (turn2) => turn2.thread === thread2.id);
4129
4371
  const summary = await this.writeMessage(this.paths.channelDir(thread2.channel), { author: actor.name, channel: thread2.channel, closes: thread2.id }, `Thread closed: "${thread2.title}" (${thread2.id}).
4130
4372
 
4131
4373
  ${args.summary}`);
@@ -4164,10 +4406,12 @@ ${args.summary}`);
4164
4406
  if (first === void 0) {
4165
4407
  throw new BoardError("VALIDATION", "a plan needs at least one stage");
4166
4408
  }
4409
+ await this.assertChannelOpen(channelRef(args.project, args.channel));
4167
4410
  const ts = this.now().toISOString();
4168
4411
  const frontmatter = TaskFrontmatterSchema.parse({
4169
4412
  id: this.newId(),
4170
4413
  project: args.project,
4414
+ channel: args.channel,
4171
4415
  title: args.title,
4172
4416
  status: "open",
4173
4417
  createdBy: actor.name,
@@ -4183,11 +4427,11 @@ ${args.summary}`);
4183
4427
  onDone: project2.onDone,
4184
4428
  completing: false
4185
4429
  });
4186
- await this.assertChannelExists(channelRef(args.project, "general"));
4187
4430
  const task2 = await this.writeTask(args.project, { ...frontmatter, body: args.body });
4188
4431
  await this.events.append("task.created", actor.name, {
4189
4432
  taskId: task2.id,
4190
4433
  project: task2.project,
4434
+ channel: task2.channel,
4191
4435
  title: task2.title,
4192
4436
  parentId: task2.parentId ?? null,
4193
4437
  stage: task2.stage
@@ -4303,6 +4547,9 @@ ${args.summary}`);
4303
4547
  completedAt: ts
4304
4548
  };
4305
4549
  const next = current.stages[index + 1];
4550
+ if (next === void 0 && current.onDone === "none") {
4551
+ this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn2) => turn2.thread === current.id);
4552
+ }
4306
4553
  const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
4307
4554
  const base = {
4308
4555
  ...current,
@@ -4466,6 +4713,7 @@ ${args.summary}`);
4466
4713
  if (!mayAbandon) {
4467
4714
  throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
4468
4715
  }
4716
+ this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn2) => turn2.thread === current.id);
4469
4717
  next = {
4470
4718
  ...next,
4471
4719
  status: "abandoned",
@@ -4667,6 +4915,7 @@ ${rationale}`}`);
4667
4915
  await this.authorize(actor, "archive_project");
4668
4916
  return this.mutex.run(async () => {
4669
4917
  await this.validateArchive(args.project);
4918
+ await this.assertProjectQuiet(actor, args.project);
4670
4919
  return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
4671
4920
  });
4672
4921
  }
@@ -4674,6 +4923,10 @@ ${rationale}`}`);
4674
4923
  * A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
4675
4924
  * directory now and, through the `agent.joined` event, an onboarding turn.
4676
4925
  */
4926
+ /**
4927
+ * A member follows the project's general from the start; the result names the project's other
4928
+ * open channels, which it subscribes to when its work there needs them.
4929
+ */
4677
4930
  async joinProject(actor, input) {
4678
4931
  const args = VerbInputs.join_project.parse(input);
4679
4932
  await this.authorize(actor, "join_project");
@@ -4686,7 +4939,7 @@ ${rationale}`}`);
4686
4939
  throw new BoardError("INVALID_STATE", `${target} is retired`);
4687
4940
  }
4688
4941
  if (current.memberships.includes(args.project)) {
4689
- return current;
4942
+ return { ...current, otherChannels: await this.otherChannels(args.project, current) };
4690
4943
  }
4691
4944
  await this.assertMayJoinProjects(target, current.role);
4692
4945
  const agent2 = await this.updateAgent(target, (a) => ({
@@ -4705,9 +4958,13 @@ ${rationale}`}`);
4705
4958
  project: args.project,
4706
4959
  cli: agent2.cli
4707
4960
  });
4708
- return agent2;
4961
+ return { ...agent2, otherChannels: await this.otherChannels(args.project, agent2) };
4709
4962
  });
4710
4963
  }
4964
+ /** The open channels of a project a citizen does not follow. */
4965
+ async otherChannels(project2, agent2) {
4966
+ return (await this.readProject(project2)).channels.map((name) => channelRef(project2, name)).filter((ref) => !agent2.subscriptions.includes(ref));
4967
+ }
4711
4968
  async leaveProject(actor, input) {
4712
4969
  const args = VerbInputs.leave_project.parse(input);
4713
4970
  await this.authorize(actor, "leave_project");
@@ -4812,6 +5069,10 @@ ${rationale}`}`);
4812
5069
  return this.configureProject(actor, VerbInputs.configure_project.parse(input));
4813
5070
  case "archive_project":
4814
5071
  return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
5072
+ case "create_channel":
5073
+ return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5074
+ case "archive_channel":
5075
+ return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
4815
5076
  default:
4816
5077
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
4817
5078
  }
@@ -4917,34 +5178,35 @@ ${rationale}`}`);
4917
5178
  }
4918
5179
  async readMemoryCore(agent2) {
4919
5180
  const file = this.paths.agentMemoryCore(agent2);
4920
- return await exists(file) ? (await readMarkdown(file, z12.record(z12.string(), z12.unknown()))).body : "";
5181
+ return await exists(file) ? (await readLooseMarkdown(file)).body : "";
4921
5182
  }
4922
5183
  /**
4923
5184
  * Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
4924
5185
  * never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
5186
+ * `part` names the conversation beside the scope's home: a thread's id or `#channel`.
4925
5187
  */
4926
- async setDigestCursor(agent2, scope, cursor, thread2) {
5188
+ async setDigestCursor(agent2, scope, cursor, part) {
4927
5189
  await this.mutex.run(async () => {
4928
5190
  if (cursor === null) {
4929
5191
  return;
4930
5192
  }
4931
5193
  const cursors = await this.readCursors(agent2);
4932
- const current = cursorOf(cursors, scope, thread2 ?? null);
5194
+ const current = cursorOf(cursors, scope, part ?? null);
4933
5195
  const news = newsStart(cursors);
4934
- const readNews = thread2 === void 0 && (news === null || cursor > news);
5196
+ const readNews = part === void 0 && (news === null || cursor > news);
4935
5197
  if (current !== null && cursor <= current && !readNews) {
4936
5198
  return;
4937
5199
  }
4938
5200
  await writeJson(this.paths.agentCursors(agent2), {
4939
5201
  ...cursors,
4940
- scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, thread2 ?? null)]: cursor },
5202
+ scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
4941
5203
  ...readNews ? { news: cursor } : {}
4942
5204
  });
4943
5205
  });
4944
5206
  }
4945
5207
  /** Where a conversation's digest resumes: the newest message a turn there was shown. */
4946
- async digestCursor(agent2, scope, thread2) {
4947
- return this.mutex.run(async () => cursorOf(await this.readCursors(agent2), scope, thread2 ?? null));
5208
+ async digestCursor(agent2, scope, part) {
5209
+ return this.mutex.run(async () => cursorOf(await this.readCursors(agent2), scope, part ?? null));
4948
5210
  }
4949
5211
  /** Records that a running turn took posts delivered into it, which wake latency counts to. */
4950
5212
  async recordSteered(input) {
@@ -4953,25 +5215,29 @@ ${rationale}`}`);
4953
5215
  turnId: input.turnId,
4954
5216
  project: input.project,
4955
5217
  ...input.thread === void 0 ? {} : { thread: input.thread },
5218
+ ...input.channel === void 0 ? {} : { channel: input.channel },
4956
5219
  messages: [...input.messages]
4957
5220
  });
4958
5221
  });
4959
5222
  }
4960
- /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
4961
- sessionDir(agent2, scope, thread2) {
4962
- return thread2 === void 0 ? this.paths.agentProject(agent2, scope) : this.paths.agentThread(agent2, scope, thread2);
5223
+ /**
5224
+ * Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
5225
+ * thread's or a channel's, named by `part` as `conversationPart` gives it.
5226
+ */
5227
+ sessionDir(agent2, scope, part) {
5228
+ return part === void 0 ? this.paths.agentProject(agent2, scope) : this.paths.agentConversation(agent2, scope, part);
4963
5229
  }
4964
- async readSessions(agent2, project2, thread2) {
4965
- const file = path6.join(this.sessionDir(agent2, project2, thread2), "sessions.json");
5230
+ async readSessions(agent2, project2, part) {
5231
+ const file = path6.join(this.sessionDir(agent2, project2, part), "sessions.json");
4966
5232
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
4967
5233
  }
4968
5234
  /**
4969
5235
  * Records a conversation's session for a CLI and the runner it began on. A session started on
4970
5236
  * another runner replaces the record, since sessions do not move between machines.
4971
5237
  */
4972
- async writeSession(agent2, project2, cli, sessionId, runner2, thread2) {
5238
+ async writeSession(agent2, project2, cli, sessionId, runner2, part) {
4973
5239
  await this.mutex.run(async () => {
4974
- const dir = this.sessionDir(agent2, project2, thread2);
5240
+ const dir = this.sessionDir(agent2, project2, part);
4975
5241
  await ensureDir(dir);
4976
5242
  const file = path6.join(dir, "sessions.json");
4977
5243
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
@@ -4979,17 +5245,21 @@ ${rationale}`}`);
4979
5245
  await writeJson(file, { ...kept, [cli]: sessionId, runner: runner2 });
4980
5246
  });
4981
5247
  }
4982
- /** The last turn of one conversation: the scope's home, or a thread's. */
4983
- async readLastTurn(agent2, project2, thread2) {
4984
- const file = path6.join(this.sessionDir(agent2, project2, thread2), "last-turn.json");
5248
+ /** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
5249
+ async readLastTurn(agent2, project2, part) {
5250
+ const file = path6.join(this.sessionDir(agent2, project2, part), "last-turn.json");
4985
5251
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
4986
5252
  }
4987
5253
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
4988
5254
  async readLatestTurn(agent2, project2) {
4989
5255
  let latest = await this.readLastTurn(agent2, project2);
4990
- const threads = path6.join(this.paths.agentProject(agent2, project2), "threads");
4991
- for (const thread2 of await listDirs(threads)) {
4992
- const turn2 = await this.readLastTurn(agent2, project2, thread2);
5256
+ const place = this.paths.agentProject(agent2, project2);
5257
+ const parts = [
5258
+ ...await listDirs(path6.join(place, "threads")),
5259
+ ...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
5260
+ ];
5261
+ for (const part of parts) {
5262
+ const turn2 = await this.readLastTurn(agent2, project2, part);
4993
5263
  if (turn2 !== null && (latest === null || turnTime(turn2) > turnTime(latest))) {
4994
5264
  latest = turn2;
4995
5265
  }
@@ -5006,6 +5276,7 @@ ${rationale}`}`);
5006
5276
  turnId: parsed.id,
5007
5277
  project: parsed.project,
5008
5278
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5279
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5009
5280
  trigger: parsed.trigger.kind,
5010
5281
  session: parsed.session,
5011
5282
  runner: parsed.runner
@@ -5031,13 +5302,15 @@ ${rationale}`}`);
5031
5302
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5032
5303
  agent: parsed.agent,
5033
5304
  project: parsed.project,
5034
- ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
5305
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5306
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
5035
5307
  });
5036
5308
  }
5037
5309
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
5038
5310
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5039
5311
  project: parsed.project,
5040
5312
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5313
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5041
5314
  trigger: parsed.trigger.kind,
5042
5315
  session: parsed.session,
5043
5316
  exitReason: parsed.exitReason,
@@ -5061,6 +5334,47 @@ ${rationale}`}`);
5061
5334
  });
5062
5335
  });
5063
5336
  }
5337
+ /**
5338
+ * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
5339
+ * the scheduler gives the citizen a closing turn there to decide about it. False, recording
5340
+ * nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
5341
+ */
5342
+ async recordLeftovers(input) {
5343
+ const { conversation } = input;
5344
+ const payload = {
5345
+ agent: input.agent,
5346
+ project: input.scope,
5347
+ ...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
5348
+ };
5349
+ return this.mutex.run(async () => {
5350
+ const earlier = (await this.events.readSince(null, Number.MAX_SAFE_INTEGER)).filter((event) => event.type === "workspace.leftovers" && Object.entries(payload).every(([key, value]) => event.payload[key] === value)).length;
5351
+ if (earlier >= CLOSING_ATTEMPTS) {
5352
+ return false;
5353
+ }
5354
+ await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
5355
+ return true;
5356
+ });
5357
+ }
5358
+ /**
5359
+ * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
5360
+ * (for one from before tasks had threads), or a channel archived.
5361
+ */
5362
+ async conversationEnded(conversation) {
5363
+ if (conversation.kind === "channel") {
5364
+ const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
5365
+ return !await this.channelOpen(channelRef(place, conversation.name));
5366
+ }
5367
+ const found = await this.tryFindThread(conversation.id);
5368
+ if (found !== null) {
5369
+ return found.thread.state === "closed";
5370
+ }
5371
+ try {
5372
+ const { task: task2 } = await this.findTask(conversation.id);
5373
+ return task2.status === "done" || task2.status === "abandoned";
5374
+ } catch {
5375
+ return false;
5376
+ }
5377
+ }
5064
5378
  /**
5065
5379
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5066
5380
  * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
@@ -5096,7 +5410,7 @@ ${rationale}`}`);
5096
5410
  await this.refreshMember(name);
5097
5411
  }
5098
5412
  if (merged) {
5099
- await this.appendMessage(actor.name, channelRef(location.project, "general"), `Task ${task3.id} "${task3.title}" is done: ${sentence(input.detail)}.`);
5413
+ await this.appendMessage(actor.name, channelRef(location.project, task3.channel), `Task ${task3.id} "${task3.title}" is done: ${sentence(input.detail)}.`);
5100
5414
  }
5101
5415
  return task3;
5102
5416
  }
@@ -5132,7 +5446,8 @@ ${rationale}`}`);
5132
5446
  if (!await exists(file)) {
5133
5447
  return { data: { project: slug }, body: "" };
5134
5448
  }
5135
- return readMarkdown(file, z12.record(z12.string(), z12.unknown()));
5449
+ const { data, body } = await readLooseMarkdown(file);
5450
+ return { data, body };
5136
5451
  }
5137
5452
  async listRunners() {
5138
5453
  const runners = [];
@@ -5332,7 +5647,7 @@ ${rationale}`}`);
5332
5647
  return this.files.read(this.paths.board, paths, () => true);
5333
5648
  }
5334
5649
  async writeTurnRecord(record) {
5335
- const dir = this.sessionDir(record.agent, record.project, record.thread);
5650
+ const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
5336
5651
  await ensureDir(dir);
5337
5652
  await writeJson(path6.join(dir, "last-turn.json"), record);
5338
5653
  }
@@ -5636,7 +5951,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5636
5951
  };
5637
5952
  }
5638
5953
  case "channel": {
5639
- const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal2.charter), meta);
5954
+ const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal2.charter), meta, proposal2.proposedBy);
5640
5955
  return { channel };
5641
5956
  }
5642
5957
  case "role": {
@@ -5742,18 +6057,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5742
6057
  return { agent: agent2, token };
5743
6058
  }
5744
6059
  async validateAddChannel(input) {
5745
- if (input.project === null) {
5746
- if ((await this.society()).channels.includes(input.name)) {
5747
- throw new BoardError("ALREADY_EXISTS", `society channel ${input.name} already exists`);
5748
- }
5749
- return;
6060
+ const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
6061
+ const ref = channelRef(input.project, input.name);
6062
+ if (place.channels.includes(input.name)) {
6063
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
5750
6064
  }
5751
- const project2 = await this.readActiveProject(input.project);
5752
- if (project2.channels.includes(input.name)) {
5753
- throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
6065
+ if (place.archivedChannels.some((each) => each.name === input.name)) {
6066
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
5754
6067
  }
5755
6068
  }
5756
- async addChannelUnlocked(by, input, meta) {
6069
+ /**
6070
+ * Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
6071
+ * it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
6072
+ */
6073
+ async addChannelUnlocked(by, input, meta, opener) {
5757
6074
  const ref = channelRef(input.project, input.name);
5758
6075
  if (input.project === null) {
5759
6076
  const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
@@ -5767,14 +6084,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5767
6084
  }));
5768
6085
  await ensureDir(this.paths.projectChannel(project2, input.name));
5769
6086
  }
6087
+ const follower = await this.readAgent(opener).catch(() => null);
6088
+ const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
6089
+ if (follows) {
6090
+ await this.updateAgent(opener, (agent2) => ({
6091
+ ...agent2,
6092
+ subscriptions: [.../* @__PURE__ */ new Set([...agent2.subscriptions, ref])]
6093
+ }));
6094
+ await this.refreshMember(opener);
6095
+ }
5770
6096
  await this.events.append("channel.added", by, {
5771
6097
  channel: ref,
5772
6098
  purpose: input.purpose,
6099
+ ...follows ? { followedBy: opener } : {},
5773
6100
  ...meta
5774
6101
  });
5775
- await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${input.purpose}`);
6102
+ const purpose = withoutMentions(input.purpose);
6103
+ await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
6104
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}. Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
5776
6105
  return ref;
5777
6106
  }
6107
+ /**
6108
+ * Ends a channel: its open threads close, its followers stop following it, and it leaves the
6109
+ * place's channels for its archived ones, where its history stays readable. The notice is the
6110
+ * board's and leaves the reason out, since a board-authored mention would wake whoever it names.
6111
+ */
6112
+ async archiveChannelUnlocked(actor, ref, reason) {
6113
+ const by = actor.name;
6114
+ const { project: project2, channel } = parseChannelRef(ref);
6115
+ if (isBoardChannel(project2, channel)) {
6116
+ throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
6117
+ }
6118
+ await this.assertChannelOpen(ref);
6119
+ if (project2 !== null) {
6120
+ const inPlay = (await this.listTasks(project2)).filter((task2) => task2.channel === channel && task2.status !== "done" && task2.status !== "abandoned");
6121
+ if (inPlay.length > 0) {
6122
+ throw new BoardError("INVALID_STATE", `${inPlay.length} task(s) filed in ${ref} are still in play: ${inPlay.map((task2) => task2.id).join(", ")}`);
6123
+ }
6124
+ }
6125
+ const ts = this.now().toISOString();
6126
+ const threads = project2 === null ? this.paths.societyThreads() : this.paths.threads(project2);
6127
+ const closing = await this.openThreadIds(threads, (thread2) => thread2.channel === ref);
6128
+ const scope = project2 ?? SOCIETY_SCOPE;
6129
+ this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn2) => turn2.channel === channel && turn2.scope === scope || turn2.thread !== void 0 && closing.has(turn2.thread));
6130
+ const threadsClosed = [];
6131
+ for (const file of await listFiles(threads)) {
6132
+ const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
6133
+ if (doc.data.channel !== ref || doc.data.state !== "open") {
6134
+ continue;
6135
+ }
6136
+ await this.writeThread(threads, {
6137
+ ...doc.data,
6138
+ body: doc.body,
6139
+ state: "closed",
6140
+ closedBy: by,
6141
+ closedAt: ts
6142
+ });
6143
+ await this.events.append("thread.closed", by, {
6144
+ threadId: doc.data.id,
6145
+ channel: ref,
6146
+ ended: "archived"
6147
+ });
6148
+ threadsClosed.push(doc.data.id);
6149
+ }
6150
+ const unsubscribed = [];
6151
+ for (const agent2 of await this.listAgents()) {
6152
+ if (agent2.subscriptions.includes(ref)) {
6153
+ await this.updateAgent(agent2.name, (a) => ({
6154
+ ...a,
6155
+ subscriptions: a.subscriptions.filter((each) => each !== ref)
6156
+ }));
6157
+ await this.refreshMember(agent2.name);
6158
+ unsubscribed.push(agent2.name);
6159
+ }
6160
+ }
6161
+ const archived = { name: channel, at: ts, by, reason };
6162
+ if (project2 === null) {
6163
+ const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
6164
+ await writeMarkdown(this.paths.societyFile(), {
6165
+ ...doc.data,
6166
+ channels: doc.data.channels.filter((name) => name !== channel),
6167
+ archivedChannels: [...doc.data.archivedChannels, archived]
6168
+ }, doc.body);
6169
+ } else {
6170
+ await this.updateProject(project2, (current) => ({
6171
+ ...current,
6172
+ channels: current.channels.filter((name) => name !== channel),
6173
+ archivedChannels: [...current.archivedChannels, archived]
6174
+ }));
6175
+ }
6176
+ await this.events.append("channel.archived", by, {
6177
+ channel: ref,
6178
+ reason,
6179
+ threadsClosed,
6180
+ unsubscribed
6181
+ });
6182
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(project2, "general"), `Channel ${ref} is archived by ${by}. Its posts, threads, and tasks stay readable; nothing more is posted or filed there.`);
6183
+ return archived;
6184
+ }
5778
6185
  /** A role becomes a society role only while none of its members is in a project. */
5779
6186
  async validateRole(charter) {
5780
6187
  if (charter.name === USER_ROLE) {
@@ -5987,23 +6394,35 @@ ${charter.purpose}
5987
6394
  async publishSignalUnlocked(signal) {
5988
6395
  return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
5989
6396
  }
6397
+ /** A channel that exists, open or archived, and the record of its archive when it has one. */
5990
6398
  async assertChannelExists(ref) {
5991
6399
  const parsed = parseChannelRef(ref);
5992
- if (parsed.project === null) {
5993
- const society = await this.society();
5994
- if (!society.channels.includes(parsed.channel)) {
5995
- throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
5996
- }
5997
- return;
6400
+ const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
6401
+ if (place.channels.includes(parsed.channel)) {
6402
+ return void 0;
5998
6403
  }
5999
- const project2 = await this.readProject(parsed.project);
6000
- if (!project2.channels.includes(parsed.channel)) {
6001
- throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
6404
+ const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
6405
+ if (archived === void 0) {
6406
+ throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
6002
6407
  }
6408
+ return archived;
6003
6409
  }
6004
- /** A channel that takes new posts, threads, and followers: it exists, and its project is not archived. */
6410
+ /** Whether a channel exists and is not archived, its conversations still going. */
6411
+ async channelOpen(ref) {
6412
+ try {
6413
+ return await this.assertChannelExists(ref) === void 0;
6414
+ } catch {
6415
+ return false;
6416
+ }
6417
+ }
6418
+ /**
6419
+ * A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
6420
+ * and its project is not archived.
6421
+ */
6005
6422
  async assertChannelOpen(ref) {
6006
- await this.assertChannelExists(ref);
6423
+ if (await this.assertChannelExists(ref) !== void 0) {
6424
+ throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
6425
+ }
6007
6426
  const { project: project2 } = parseChannelRef(ref);
6008
6427
  if (project2 !== null) {
6009
6428
  await this.readActiveProject(project2);
@@ -6103,6 +6522,10 @@ ${charter.purpose}
6103
6522
  if (outcome === "approved") {
6104
6523
  await this.validateProvision(proposal2.kind, proposal2.charter);
6105
6524
  }
6525
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn2) => turn2.thread === proposalId);
6526
+ if (outcome === "approved" && proposal2.kind === "archive") {
6527
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal2.charter).project);
6528
+ }
6106
6529
  const ts = this.now().toISOString();
6107
6530
  const decision = DecisionSchema.parse({
6108
6531
  id: this.newId(),
@@ -6503,6 +6926,14 @@ channelCommand.command("add <ref>").description("Open a channel: <name> for the
6503
6926
  });
6504
6927
  print({ channel: added }, () => `Channel ${added} opened`);
6505
6928
  });
6929
+ channelCommand.command("archive <ref>").description("Archive a channel whose workstream is done, once no task filed there is in play").requiredOption("--reason <text>", "why its work is done").option("--as <agent>", "act as this agent instead of the user").action(async (ref, opts) => {
6930
+ const board = await open();
6931
+ const archived = await board.archiveChannel(await actorFor(board, opts.as), {
6932
+ channel: ref,
6933
+ reason: opts.reason
6934
+ });
6935
+ print(archived, () => `Channel ${ref} archived: ${archived.reason}`);
6936
+ });
6506
6937
  program.command("signals").description("Recent operations signals, oldest first").option("--limit <n>", "how many", "50").action(async (opts) => {
6507
6938
  const board = await open();
6508
6939
  const signals = await board.listSignals(Number(opts.limit));
@@ -6524,18 +6955,19 @@ program.command("ask <text>").description("Post to the society's general channel
6524
6955
  print(message, () => `Posted ${message.id} to general; the concierge wakes on it`);
6525
6956
  });
6526
6957
  var task = program.command("task").description("Manage tasks");
6527
- task.command("create <project> <title>").description("Create a task with a plan, or with one stage anyone in the project may take").option("--body <text>", "task body", "").option("--parent <id>", "parent task id").option("--plan <json>", `stages, for example '[{"name":"analysis","role":"analyst"}]'`).option("--as <agent>", "act as this agent instead of the user").action(async (projectSlug, title, opts) => {
6958
+ task.command("create <project> <title>").description("Create a task with a plan, or with one stage anyone in the project may take").option("--body <text>", "task body", "").option("--parent <id>", "parent task id").option("--plan <json>", `stages, for example '[{"name":"analysis","role":"analyst"}]'`).option("--channel <name>", "the project's channel to file it in, such as a release's", "general").option("--as <agent>", "act as this agent instead of the user").action(async (projectSlug, title, opts) => {
6528
6959
  const board = await open();
6529
6960
  const who = await actorFor(board, opts.as);
6530
6961
  const created = await board.createTask(who, {
6531
6962
  project: projectSlug,
6532
6963
  title,
6533
6964
  body: opts.body,
6965
+ channel: opts.channel,
6534
6966
  ...opts.parent === void 0 ? {} : { parent_id: opts.parent },
6535
6967
  ...opts.plan === void 0 ? {} : { stages: parsePlan(opts.plan) }
6536
6968
  });
6537
6969
  print(created, () => [
6538
- `Task ${created.id} created in ${created.project}: ${created.title}`,
6970
+ `Task ${created.id} created in ${created.project}/${created.channel}: ${created.title}`,
6539
6971
  describePlan(created)
6540
6972
  ].join("\n"));
6541
6973
  });