@kubitnodes/stellaris 0.3.10 → 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 +533 -117
  2. package/dist/server/index.js +900 -197
  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-DttP5FqR.js → CitizensView-Ds2rojfa.js} +1 -1
  9. package/web/dist/assets/KnowledgeView-k10TU0jc.js +1 -0
  10. package/web/dist/assets/{Markdown-4W4xjIq7.js → Markdown-J2pAPfCa.js} +3 -3
  11. package/web/dist/assets/{MessageItem-Bfpve1_Y.js → MessageItem-BjM8BdpK.js} +1 -1
  12. package/web/dist/assets/{MetricsView-Bp486U0v.js → MetricsView-D9N54rV7.js} +1 -1
  13. package/web/dist/assets/{NeedsYouView-CJ4COcNn.js → NeedsYouView-ML8i7Pl7.js} +1 -1
  14. package/web/dist/assets/{Overview-BnO1K52h.js → Overview-BBXvRhfR.js} +1 -1
  15. package/web/dist/assets/{Pane-xzIpwnBD.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-XrraDhwt.js → ProposalsView-Dg_bEryX.js} +1 -1
  19. package/web/dist/assets/{RunnersView-H3kCdSQB.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-CRwwL5K8.js → TaskFileView-B19YtrtW.js} +2 -2
  23. package/web/dist/assets/{TaskView-A9IgTxQ0.js → TaskView-FYSoCb3T.js} +1 -1
  24. package/web/dist/assets/{TasksView-CnhTIldG.js → TasksView-CniXTU3R.js} +1 -1
  25. package/web/dist/assets/{ThreadCard-fvf8cY_l.js → ThreadCard-DCpvhSlc.js} +1 -1
  26. package/web/dist/assets/{ThreadForms-D09Ykgj9.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-lz-5NQWT.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-DK1cnXqs.js → tasks-Duke4U7N.js} +1 -1
  33. package/web/dist/assets/{useStickyScroll-CfP3Yl_4.js → useStickyScroll-n5d7sdPZ.js} +1 -1
  34. package/web/dist/index.html +4 -4
  35. package/web/dist/assets/BackLink-B3tz8OFm.js +0 -1
  36. package/web/dist/assets/ChannelView-Dkzr1zkF.js +0 -1
  37. package/web/dist/assets/CitizenView-BLgyEvYz.js +0 -2
  38. package/web/dist/assets/KnowledgeView-DFAC95aF.js +0 -1
  39. package/web/dist/assets/ProjectView-w0mcF0KW.js +0 -1
  40. package/web/dist/assets/ProposalView-CyJCwUYK.js +0 -1
  41. package/web/dist/assets/SocietyView-Cf8ENDAw.js +0 -1
  42. package/web/dist/assets/SubjectThread-ClMmgVVd.js +0 -1
  43. package/web/dist/assets/ThreadView-BqNSssCf.js +0 -1
  44. package/web/dist/assets/citizen-C27Wvimj.js +0 -1
  45. package/web/dist/assets/index-CoKyPk79.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,12 +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"), thread: UlidSchema.optional() }),
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
912
  branches: z6.array(z6.string().min(1)),
874
- thread: UlidSchema.optional()
913
+ thread: UlidSchema.optional(),
914
+ channel: NameSchema.optional()
875
915
  }),
876
916
  z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
877
917
  ]);
@@ -887,6 +927,8 @@ var TurnJobSchema = z6.object({
887
927
  scope: NameSchema,
888
928
  /** The thread whose conversation the turn is in; absent for the home conversation. */
889
929
  thread: UlidSchema.optional(),
930
+ /** The channel, never general, whose conversation the turn is in. */
931
+ channel: NameSchema.optional(),
890
932
  /**
891
933
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
892
934
  * the outcome reports it.
@@ -920,7 +962,12 @@ var TurnOutcomeSchema = z6.object({
920
962
  session: z6.string().min(1),
921
963
  /** The model the CLI reported, when it reported one. */
922
964
  model: z6.string().nullable(),
923
- 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)
924
971
  });
925
972
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
926
973
  var LandRequestSchema = z6.object({
@@ -979,6 +1026,17 @@ var BranchChangesSchema = z6.object({
979
1026
  files: z6.array(BranchFileChangeSchema),
980
1027
  total: z6.number().int().nonnegative()
981
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) });
982
1040
  var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
983
1041
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
984
1042
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
@@ -1000,7 +1058,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
1000
1058
  steer: TurnSteerSchema
1001
1059
  }),
1002
1060
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1003
- 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
+ })
1004
1071
  ]);
1005
1072
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
1006
1073
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -1028,7 +1095,8 @@ var TriggerKindSchema = z7.enum([
1028
1095
  "onboarding",
1029
1096
  "manual",
1030
1097
  "ops_event",
1031
- "user_post"
1098
+ "user_post",
1099
+ "closing"
1032
1100
  ]);
1033
1101
  var TriggerSchema = z7.object({
1034
1102
  kind: TriggerKindSchema,
@@ -1045,10 +1113,20 @@ var TurnDispatchSchema = z7.object({
1045
1113
  agent: NameSchema,
1046
1114
  project: NameSchema,
1047
1115
  thread: TurnThreadSchema.optional(),
1116
+ channel: NameSchema.optional(),
1048
1117
  trigger: TriggerSchema,
1049
1118
  priority: z7.number().int().min(0).max(2),
1050
1119
  onboarding: z7.boolean().default(false)
1051
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
+ }
1052
1130
  var TurnRecordSchema = z7.object({
1053
1131
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
1054
1132
  id: UlidSchema.optional(),
@@ -1056,6 +1134,8 @@ var TurnRecordSchema = z7.object({
1056
1134
  project: NameSchema,
1057
1135
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1058
1136
  thread: UlidSchema.optional(),
1137
+ /** The channel, never general, whose conversation the turn was in. */
1138
+ channel: NameSchema.optional(),
1059
1139
  runner: NameSchema,
1060
1140
  cli: CliKindSchema.nullable(),
1061
1141
  session: z7.string().nullable(),
@@ -1085,8 +1165,9 @@ var RunningTurnSchema = z7.object({
1085
1165
  agent: NameSchema,
1086
1166
  scope: NameSchema,
1087
1167
  thread: UlidSchema.optional(),
1168
+ channel: NameSchema.optional(),
1088
1169
  cli: CliKindSchema,
1089
- channel: ChannelRefSchema.optional(),
1170
+ askedIn: ChannelRefSchema.optional(),
1090
1171
  steerable: z7.boolean(),
1091
1172
  stoppable: z7.boolean()
1092
1173
  });
@@ -1113,6 +1194,8 @@ var TurnHistoryEntrySchema = z7.object({
1113
1194
  project: NameSchema,
1114
1195
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1115
1196
  thread: UlidSchema.optional(),
1197
+ /** The channel, never general, whose conversation the turn was in. */
1198
+ channel: NameSchema.optional(),
1116
1199
  trigger: z7.string(),
1117
1200
  exitReason: z7.string().nullable(),
1118
1201
  costUsd: z7.number(),
@@ -1139,7 +1222,8 @@ var OPS_SIGNAL_KINDS = [
1139
1222
  "turn_cost",
1140
1223
  "scaled",
1141
1224
  "runner",
1142
- "home_conflict"
1225
+ "home_conflict",
1226
+ "stuck_workspace"
1143
1227
  ];
1144
1228
  var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
1145
1229
  var OpsSignalSchema = z8.object({
@@ -1301,7 +1385,7 @@ var RunnerConfigSchema = z10.object({
1301
1385
  });
1302
1386
 
1303
1387
  // packages/shared/dist/version.js
1304
- var STELLARIS_VERSION = "0.3.10";
1388
+ var STELLARIS_VERSION = "0.3.11";
1305
1389
 
1306
1390
  // packages/board-core/dist/errors.js
1307
1391
  var BoardError = class extends Error {
@@ -1572,6 +1656,7 @@ var HOME_GITIGNORE = [
1572
1656
  "/projects/*/sessions*.json",
1573
1657
  "/projects/*/last-turn.json",
1574
1658
  "/projects/*/threads/",
1659
+ "/projects/*/channels/",
1575
1660
  `/${HOME_SCRATCH}/`,
1576
1661
  "*.tmp",
1577
1662
  "node_modules/",
@@ -1589,7 +1674,7 @@ var HOME_GITIGNORE = [
1589
1674
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
1590
1675
  `;
1591
1676
  var PRE_RECEIVE = `#!/bin/sh
1592
- 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/))'
1593
1678
  while read old new ref; do
1594
1679
  if [ "$ref" != "refs/heads/main" ]; then
1595
1680
  echo "a home takes pushes to main only" >&2
@@ -1850,6 +1935,7 @@ var TurnPayload = z11.object({
1850
1935
  turnId: z11.string().optional(),
1851
1936
  project: z11.string(),
1852
1937
  thread: z11.string().optional(),
1938
+ channel: z11.string().optional(),
1853
1939
  trigger: z11.string()
1854
1940
  });
1855
1941
  var TaskPayload = z11.object({
@@ -1892,7 +1978,14 @@ function answers(mention, turn2) {
1892
1978
  if (turn2.thread !== null) {
1893
1979
  return mention.thread === turn2.thread;
1894
1980
  }
1895
- 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";
1896
1989
  }
1897
1990
  function computeMetrics(input) {
1898
1991
  const { events, now, window } = input;
@@ -1957,6 +2050,7 @@ function computeMetrics(input) {
1957
2050
  agent: event.actor,
1958
2051
  at,
1959
2052
  thread: turn2.data.thread ?? null,
2053
+ channel: turn2.data.channel ?? null,
1960
2054
  project: turn2.data.project,
1961
2055
  trigger: turn2.data.trigger
1962
2056
  };
@@ -2053,6 +2147,7 @@ function computeMetrics(input) {
2053
2147
  at: Date.parse(event.ts),
2054
2148
  message: message.data.id ?? null,
2055
2149
  thread: thread2,
2150
+ channel: message.data.channel,
2056
2151
  scopes: [project2, wakeScope(citizen, project2)].filter((scope) => scope !== null)
2057
2152
  });
2058
2153
  }
@@ -2328,9 +2423,12 @@ var BoardPaths = class {
2328
2423
  agentProject(name, slug) {
2329
2424
  return path5.join(this.agentProjects(name), slug);
2330
2425
  }
2331
- /** A thread conversation's session and last turn, beside the scope's home conversation. */
2332
- agentThread(name, slug, thread2) {
2333
- 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);
2334
2432
  }
2335
2433
  agentCursors(name) {
2336
2434
  return path5.join(this.agent(name), "cursors.json");
@@ -2364,6 +2462,13 @@ function hashToken(token) {
2364
2462
 
2365
2463
  // packages/board-core/dist/board.js
2366
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
+ }
2367
2472
  var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
2368
2473
  var RunnerTokensSchema = z12.object({
2369
2474
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
@@ -2381,6 +2486,7 @@ var SignInsSchema = z12.object({
2381
2486
  var TurnHistoryPayloadSchema = z12.object({
2382
2487
  project: NameSchema,
2383
2488
  thread: UlidSchema.optional(),
2489
+ channel: NameSchema.optional(),
2384
2490
  trigger: z12.string().default("manual"),
2385
2491
  exitReason: z12.string().nullable().default(null),
2386
2492
  costUsd: z12.number().default(0),
@@ -2396,12 +2502,23 @@ var CursorsSchema = z12.object({
2396
2502
  digest: z12.string().nullable(),
2397
2503
  scopes: z12.record(z12.string(), z12.string()).default({}),
2398
2504
  threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2505
+ channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2399
2506
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
2400
2507
  news: z12.string().optional()
2401
2508
  });
2402
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
2403
- function conversationKey(scope, thread2) {
2404
- 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;
2405
2522
  }
2406
2523
  function threadStart(cursors, scope) {
2407
2524
  const from = cursors.threadsFrom[scope];
@@ -2414,11 +2531,11 @@ function newsStart(cursors) {
2414
2531
  const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
2415
2532
  return homes.at(-1) ?? cursors.digest;
2416
2533
  }
2417
- function cursorOf(cursors, scope, thread2 = null) {
2418
- if (thread2 === null) {
2534
+ function cursorOf(cursors, scope, part = null) {
2535
+ if (part === null) {
2419
2536
  return cursors.scopes[scope] ?? cursors.digest;
2420
2537
  }
2421
- return cursors.scopes[conversationKey(scope, thread2)] ?? threadStart(cursors, scope);
2538
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
2422
2539
  }
2423
2540
  function byId(a, b) {
2424
2541
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -2429,6 +2546,9 @@ function turnTime(record) {
2429
2546
  var PausedSchema = z12.object({ paused: z12.boolean() });
2430
2547
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
2431
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
+ }
2432
2552
  var CURATING_ROLES = [USER_ROLE, "steward"];
2433
2553
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2434
2554
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -2445,6 +2565,7 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
2445
2565
  var THREAD_CONVERSATIONS = "thread-conversations";
2446
2566
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2447
2567
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2568
+ var CHANNEL_CONVERSATIONS = "channel-conversations";
2448
2569
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2449
2570
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2450
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";
@@ -2575,6 +2696,8 @@ function snippetAround(text, query, radius = 80) {
2575
2696
  var Board = class _Board {
2576
2697
  paths;
2577
2698
  mutex = new Mutex();
2699
+ /** The turns in flight, which the server hosting them supplies; none until it does. */
2700
+ turnsInFlight = () => [];
2578
2701
  newId = monotonicFactory();
2579
2702
  events;
2580
2703
  actionCounts = /* @__PURE__ */ new Map();
@@ -2635,7 +2758,8 @@ var Board = class _Board {
2635
2758
  DECISIONS_CHANNEL_RETIRED,
2636
2759
  THREAD_CONVERSATIONS,
2637
2760
  ASKS_CHANNEL_OPENED,
2638
- KNOWLEDGE_REMOVAL
2761
+ KNOWLEDGE_REMOVAL,
2762
+ CHANNEL_CONVERSATIONS
2639
2763
  ].filter((name) => !applied.includes(name));
2640
2764
  if (pending.length === 0) {
2641
2765
  return;
@@ -2667,6 +2791,9 @@ var Board = class _Board {
2667
2791
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2668
2792
  await this.grantKnowledgeRemoval();
2669
2793
  }
2794
+ if (pending.includes(CHANNEL_CONVERSATIONS)) {
2795
+ await this.startChannelCursors();
2796
+ }
2670
2797
  await writeJson(file, { applied: [...applied, ...pending] });
2671
2798
  }
2672
2799
  /**
@@ -2841,6 +2968,27 @@ var Board = class _Board {
2841
2968
  }
2842
2969
  }
2843
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
+ }
2844
2992
  /**
2845
2993
  * Threads were once read in the scope's single conversation, so a member had read every thread
2846
2994
  * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
@@ -2974,17 +3122,20 @@ ${charter.purpose}
2974
3122
  /**
2975
3123
  * A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
2976
3124
  * in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
2977
- * scope and thread ride on the actor it resolves to, so `read_inbox` reads that conversation's
2978
- * 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.
2979
3127
  */
2980
- issueTurnToken(agent2, role2, ttlMs, scope, thread2) {
3128
+ issueTurnToken(agent2, role2, ttlMs, scope, part) {
2981
3129
  const token = mintToken();
3130
+ const thread2 = threadOfPart(part);
3131
+ const channel = channelOfPart(part);
2982
3132
  this.turnTokens.set(hashToken(token), {
2983
3133
  actor: {
2984
3134
  name: agent2,
2985
3135
  role: role2,
2986
3136
  ...scope === void 0 ? {} : { scope },
2987
- ...thread2 === void 0 ? {} : { thread: thread2 }
3137
+ ...thread2 === void 0 ? {} : { thread: thread2 },
3138
+ ...channel === void 0 ? {} : { channel }
2988
3139
  },
2989
3140
  expiresAt: this.now().getTime() + ttlMs
2990
3141
  });
@@ -3284,34 +3435,44 @@ ${charter.purpose}
3284
3435
  }
3285
3436
  return (await readJson(file, PausedSchema)).paused;
3286
3437
  }
3287
- /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3288
- /** 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
+ */
3289
3442
  async listChannels() {
3290
3443
  const summaries = [];
3291
- const scopes = [
3292
- { 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 },
3293
3447
  ...(await this.listProjects()).map((project2) => ({
3294
3448
  project: project2.slug,
3295
- names: project2.channels
3449
+ names: project2.channels,
3450
+ archived: project2.archivedChannels
3296
3451
  }))
3297
3452
  ];
3298
- for (const { project: project2, names } of scopes) {
3299
- for (const name of names) {
3300
- 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);
3301
3460
  const files = await listFiles(this.paths.channelDir(ref));
3302
3461
  const last = files.at(-1)?.slice(0, 26) ?? null;
3303
3462
  summaries.push({
3304
3463
  ref,
3305
3464
  project: project2,
3306
- name,
3465
+ name: entry.name,
3307
3466
  messages: files.length,
3308
3467
  lastMessageId: last,
3309
- 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 }
3310
3470
  });
3311
3471
  }
3312
3472
  }
3313
3473
  return summaries;
3314
3474
  }
3475
+ /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3315
3476
  async listChannel(ref, limit = 200) {
3316
3477
  await this.assertChannelExists(ref);
3317
3478
  const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
@@ -3464,12 +3625,27 @@ Agents may edit this file. It is rendered by the board UI.
3464
3625
  return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
3465
3626
  });
3466
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
+ }
3467
3643
  /** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
3468
3644
  async addChannel(actor, input) {
3469
3645
  this.assertAdmin(actor);
3470
3646
  return this.mutex.run(async () => {
3471
3647
  await this.validateAddChannel(input);
3472
- return this.addChannelUnlocked(actor.name, input, {});
3648
+ return this.addChannelUnlocked(actor.name, input, {}, actor.name);
3473
3649
  });
3474
3650
  }
3475
3651
  /**
@@ -3667,19 +3843,19 @@ Agents may edit this file. It is rendered by the board UI.
3667
3843
  return this.mutex.run(async () => {
3668
3844
  const stored = await this.readCursors(actor.name);
3669
3845
  const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
3670
- 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 };
3671
3847
  const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
3672
3848
  if (args.advance) {
3673
3849
  await this.advanceCursors(actor.name, filed);
3674
3850
  }
3675
3851
  const last = filed.at(-1)?.message.id;
3676
- 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));
3677
3853
  return { messages: filed.map((entry) => entry.message), cursor };
3678
3854
  });
3679
3855
  }
3680
3856
  /**
3681
3857
  * How many unread digest messages a member has in each conversation: a scope's home, for its
3682
- * 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
3683
3859
  * messages are and nowhere else. Society channels count only for a member woken in the society
3684
3860
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
3685
3861
  * counted, and are read when the member wakes for something else: a task step's, which wakes
@@ -3690,7 +3866,7 @@ Agents may edit this file. It is rendered by the board UI.
3690
3866
  return this.mutex.run(async () => {
3691
3867
  const counts = /* @__PURE__ */ new Map();
3692
3868
  const unscoped = { name: actor.name, role: actor.role };
3693
- 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))) {
3694
3870
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
3695
3871
  continue;
3696
3872
  }
@@ -3698,8 +3874,13 @@ Agents may edit this file. It is rendered by the board UI.
3698
3874
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
3699
3875
  continue;
3700
3876
  }
3701
- const key = conversationKey(scope, thread2);
3702
- 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
+ };
3703
3884
  counts.set(key, { ...entry, count: entry.count + 1 });
3704
3885
  }
3705
3886
  return [...counts.values()];
@@ -3715,7 +3896,7 @@ Agents may edit this file. It is rendered by the board UI.
3715
3896
  const scopes = { ...cursors.scopes };
3716
3897
  let news = newsStart(cursors);
3717
3898
  let moved = false;
3718
- for (const { scope, thread: thread2, message, news: isNews } of filed) {
3899
+ for (const { scope, part, message, news: isNews } of filed) {
3719
3900
  if (isNews === true) {
3720
3901
  if (news === null || message.id > news) {
3721
3902
  news = message.id;
@@ -3723,8 +3904,8 @@ Agents may edit this file. It is rendered by the board UI.
3723
3904
  }
3724
3905
  continue;
3725
3906
  }
3726
- const key = conversationKey(scope, thread2);
3727
- const current = scopes[key] ?? cursorOf(cursors, scope, thread2);
3907
+ const key = conversationKey(scope, part);
3908
+ const current = scopes[key] ?? cursorOf(cursors, scope, part);
3728
3909
  if (current === null || message.id > current) {
3729
3910
  scopes[key] = message.id;
3730
3911
  moved = true;
@@ -3743,29 +3924,32 @@ Agents may edit this file. It is rendered by the board UI.
3743
3924
  * message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
3744
3925
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
3745
3926
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
3746
- * message of the thread, since the conversation is about it; for a scope's home, its channel
3747
- * 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.
3748
3930
  */
3749
3931
  async digestSince(actor, cursors, conversation) {
3750
- if (conversation !== void 0 && conversation.thread !== null) {
3751
- const { scope, thread: thread2 } = conversation;
3752
- 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);
3753
3936
  if (found === null) {
3754
3937
  return [];
3755
3938
  }
3756
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, thread2), cursorOf(cursors, scope, thread2));
3757
- 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);
3758
3941
  }
3759
3942
  const agent2 = await this.readAgent(actor.name);
3760
3943
  const charter = await this.readRole(agent2.role);
3761
3944
  const subscribed = new Set(agent2.subscriptions);
3762
3945
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
3763
3946
  const threadParticipation = /* @__PURE__ */ new Map();
3947
+ const openChannels = /* @__PURE__ */ new Map();
3764
3948
  const collected = [];
3765
3949
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
3766
3950
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent2.memberships, SOCIETY_SCOPE];
3767
3951
  const starts = [
3768
- ...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)]),
3769
3953
  ...newsFrom === void 0 ? [] : [newsFrom]
3770
3954
  ];
3771
3955
  const known = starts.filter((start) => start !== null);
@@ -3778,20 +3962,24 @@ Agents may edit this file. It is rendered by the board UI.
3778
3962
  if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
3779
3963
  collected.push({
3780
3964
  scope: conversation?.scope ?? SOCIETY_SCOPE,
3781
- thread: null,
3965
+ part: null,
3782
3966
  message,
3783
3967
  news: true
3784
3968
  });
3785
3969
  }
3786
3970
  continue;
3787
3971
  }
3788
- if (conversation !== void 0 && filedIn !== conversation.scope) {
3789
- 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);
3790
3977
  }
3791
- 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)) {
3792
3980
  continue;
3793
3981
  }
3794
- const cursor = cursorOf(cursors, filedIn, thread2);
3982
+ const cursor = cursorOf(cursors, filedIn, part);
3795
3983
  if (cursor !== null && message.id <= cursor) {
3796
3984
  continue;
3797
3985
  }
@@ -3809,7 +3997,7 @@ Agents may edit this file. It is rendered by the board UI.
3809
3997
  include = participates;
3810
3998
  }
3811
3999
  if (include && message.author !== actor.name) {
3812
- collected.push({ scope: filedIn, thread: thread2, message });
4000
+ collected.push({ scope: filedIn, part, message });
3813
4001
  }
3814
4002
  }
3815
4003
  return collected.toSorted(byId);
@@ -4048,7 +4236,7 @@ ${task2.body}`;
4048
4236
  taskThreadOpening(task2) {
4049
4237
  return {
4050
4238
  id: task2.id,
4051
- channel: channelRef(task2.project, "general"),
4239
+ channel: channelRef(task2.project, task2.channel),
4052
4240
  title: task2.title,
4053
4241
  subject: { kind: "task", id: task2.id }
4054
4242
  };
@@ -4094,7 +4282,7 @@ ${task2.body}`;
4094
4282
  if (task2.status === "done" || task2.status === "abandoned") {
4095
4283
  throw new BoardError("INVALID_STATE", `task ${task2.id} is ${task2.status}`);
4096
4284
  }
4097
- const channel = args.channel ?? channelRef(project2, "general");
4285
+ const channel = args.channel ?? channelRef(project2, task2.channel);
4098
4286
  if (parseChannelRef(channel).project !== project2) {
4099
4287
  throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project2}`);
4100
4288
  }
@@ -4127,6 +4315,44 @@ ${task2.body}`;
4127
4315
  await this.assertChannelOpen(args.channel);
4128
4316
  return { id: this.newId(), channel: args.channel, title: args.title };
4129
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
+ }
4130
4356
  async closeThread(actor, input) {
4131
4357
  const args = VerbInputs.close_thread.parse(input);
4132
4358
  await this.authorize(actor, "close_thread");
@@ -4141,6 +4367,7 @@ ${task2.body}`;
4141
4367
  if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread2, threads)) {
4142
4368
  throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
4143
4369
  }
4370
+ this.assertNoTurnCut(actor, `thread ${thread2.id} cannot close`, (turn2) => turn2.thread === thread2.id);
4144
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}).
4145
4372
 
4146
4373
  ${args.summary}`);
@@ -4179,10 +4406,12 @@ ${args.summary}`);
4179
4406
  if (first === void 0) {
4180
4407
  throw new BoardError("VALIDATION", "a plan needs at least one stage");
4181
4408
  }
4409
+ await this.assertChannelOpen(channelRef(args.project, args.channel));
4182
4410
  const ts = this.now().toISOString();
4183
4411
  const frontmatter = TaskFrontmatterSchema.parse({
4184
4412
  id: this.newId(),
4185
4413
  project: args.project,
4414
+ channel: args.channel,
4186
4415
  title: args.title,
4187
4416
  status: "open",
4188
4417
  createdBy: actor.name,
@@ -4198,11 +4427,11 @@ ${args.summary}`);
4198
4427
  onDone: project2.onDone,
4199
4428
  completing: false
4200
4429
  });
4201
- await this.assertChannelExists(channelRef(args.project, "general"));
4202
4430
  const task2 = await this.writeTask(args.project, { ...frontmatter, body: args.body });
4203
4431
  await this.events.append("task.created", actor.name, {
4204
4432
  taskId: task2.id,
4205
4433
  project: task2.project,
4434
+ channel: task2.channel,
4206
4435
  title: task2.title,
4207
4436
  parentId: task2.parentId ?? null,
4208
4437
  stage: task2.stage
@@ -4318,6 +4547,9 @@ ${args.summary}`);
4318
4547
  completedAt: ts
4319
4548
  };
4320
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
+ }
4321
4553
  const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
4322
4554
  const base = {
4323
4555
  ...current,
@@ -4481,6 +4713,7 @@ ${args.summary}`);
4481
4713
  if (!mayAbandon) {
4482
4714
  throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
4483
4715
  }
4716
+ this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn2) => turn2.thread === current.id);
4484
4717
  next = {
4485
4718
  ...next,
4486
4719
  status: "abandoned",
@@ -4682,6 +4915,7 @@ ${rationale}`}`);
4682
4915
  await this.authorize(actor, "archive_project");
4683
4916
  return this.mutex.run(async () => {
4684
4917
  await this.validateArchive(args.project);
4918
+ await this.assertProjectQuiet(actor, args.project);
4685
4919
  return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
4686
4920
  });
4687
4921
  }
@@ -4689,6 +4923,10 @@ ${rationale}`}`);
4689
4923
  * A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
4690
4924
  * directory now and, through the `agent.joined` event, an onboarding turn.
4691
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
+ */
4692
4930
  async joinProject(actor, input) {
4693
4931
  const args = VerbInputs.join_project.parse(input);
4694
4932
  await this.authorize(actor, "join_project");
@@ -4701,7 +4939,7 @@ ${rationale}`}`);
4701
4939
  throw new BoardError("INVALID_STATE", `${target} is retired`);
4702
4940
  }
4703
4941
  if (current.memberships.includes(args.project)) {
4704
- return current;
4942
+ return { ...current, otherChannels: await this.otherChannels(args.project, current) };
4705
4943
  }
4706
4944
  await this.assertMayJoinProjects(target, current.role);
4707
4945
  const agent2 = await this.updateAgent(target, (a) => ({
@@ -4720,9 +4958,13 @@ ${rationale}`}`);
4720
4958
  project: args.project,
4721
4959
  cli: agent2.cli
4722
4960
  });
4723
- return agent2;
4961
+ return { ...agent2, otherChannels: await this.otherChannels(args.project, agent2) };
4724
4962
  });
4725
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
+ }
4726
4968
  async leaveProject(actor, input) {
4727
4969
  const args = VerbInputs.leave_project.parse(input);
4728
4970
  await this.authorize(actor, "leave_project");
@@ -4827,6 +5069,10 @@ ${rationale}`}`);
4827
5069
  return this.configureProject(actor, VerbInputs.configure_project.parse(input));
4828
5070
  case "archive_project":
4829
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));
4830
5076
  default:
4831
5077
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
4832
5078
  }
@@ -4937,29 +5183,30 @@ ${rationale}`}`);
4937
5183
  /**
4938
5184
  * Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
4939
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`.
4940
5187
  */
4941
- async setDigestCursor(agent2, scope, cursor, thread2) {
5188
+ async setDigestCursor(agent2, scope, cursor, part) {
4942
5189
  await this.mutex.run(async () => {
4943
5190
  if (cursor === null) {
4944
5191
  return;
4945
5192
  }
4946
5193
  const cursors = await this.readCursors(agent2);
4947
- const current = cursorOf(cursors, scope, thread2 ?? null);
5194
+ const current = cursorOf(cursors, scope, part ?? null);
4948
5195
  const news = newsStart(cursors);
4949
- const readNews = thread2 === void 0 && (news === null || cursor > news);
5196
+ const readNews = part === void 0 && (news === null || cursor > news);
4950
5197
  if (current !== null && cursor <= current && !readNews) {
4951
5198
  return;
4952
5199
  }
4953
5200
  await writeJson(this.paths.agentCursors(agent2), {
4954
5201
  ...cursors,
4955
- 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 },
4956
5203
  ...readNews ? { news: cursor } : {}
4957
5204
  });
4958
5205
  });
4959
5206
  }
4960
5207
  /** Where a conversation's digest resumes: the newest message a turn there was shown. */
4961
- async digestCursor(agent2, scope, thread2) {
4962
- 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));
4963
5210
  }
4964
5211
  /** Records that a running turn took posts delivered into it, which wake latency counts to. */
4965
5212
  async recordSteered(input) {
@@ -4968,25 +5215,29 @@ ${rationale}`}`);
4968
5215
  turnId: input.turnId,
4969
5216
  project: input.project,
4970
5217
  ...input.thread === void 0 ? {} : { thread: input.thread },
5218
+ ...input.channel === void 0 ? {} : { channel: input.channel },
4971
5219
  messages: [...input.messages]
4972
5220
  });
4973
5221
  });
4974
5222
  }
4975
- /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
4976
- sessionDir(agent2, scope, thread2) {
4977
- 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);
4978
5229
  }
4979
- async readSessions(agent2, project2, thread2) {
4980
- 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");
4981
5232
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
4982
5233
  }
4983
5234
  /**
4984
5235
  * Records a conversation's session for a CLI and the runner it began on. A session started on
4985
5236
  * another runner replaces the record, since sessions do not move between machines.
4986
5237
  */
4987
- async writeSession(agent2, project2, cli, sessionId, runner2, thread2) {
5238
+ async writeSession(agent2, project2, cli, sessionId, runner2, part) {
4988
5239
  await this.mutex.run(async () => {
4989
- const dir = this.sessionDir(agent2, project2, thread2);
5240
+ const dir = this.sessionDir(agent2, project2, part);
4990
5241
  await ensureDir(dir);
4991
5242
  const file = path6.join(dir, "sessions.json");
4992
5243
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
@@ -4994,17 +5245,21 @@ ${rationale}`}`);
4994
5245
  await writeJson(file, { ...kept, [cli]: sessionId, runner: runner2 });
4995
5246
  });
4996
5247
  }
4997
- /** The last turn of one conversation: the scope's home, or a thread's. */
4998
- async readLastTurn(agent2, project2, thread2) {
4999
- 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");
5000
5251
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
5001
5252
  }
5002
5253
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
5003
5254
  async readLatestTurn(agent2, project2) {
5004
5255
  let latest = await this.readLastTurn(agent2, project2);
5005
- const threads = path6.join(this.paths.agentProject(agent2, project2), "threads");
5006
- for (const thread2 of await listDirs(threads)) {
5007
- 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);
5008
5263
  if (turn2 !== null && (latest === null || turnTime(turn2) > turnTime(latest))) {
5009
5264
  latest = turn2;
5010
5265
  }
@@ -5021,6 +5276,7 @@ ${rationale}`}`);
5021
5276
  turnId: parsed.id,
5022
5277
  project: parsed.project,
5023
5278
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5279
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5024
5280
  trigger: parsed.trigger.kind,
5025
5281
  session: parsed.session,
5026
5282
  runner: parsed.runner
@@ -5046,13 +5302,15 @@ ${rationale}`}`);
5046
5302
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5047
5303
  agent: parsed.agent,
5048
5304
  project: parsed.project,
5049
- ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
5305
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5306
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
5050
5307
  });
5051
5308
  }
5052
5309
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
5053
5310
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5054
5311
  project: parsed.project,
5055
5312
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5313
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5056
5314
  trigger: parsed.trigger.kind,
5057
5315
  session: parsed.session,
5058
5316
  exitReason: parsed.exitReason,
@@ -5076,6 +5334,47 @@ ${rationale}`}`);
5076
5334
  });
5077
5335
  });
5078
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
+ }
5079
5378
  /**
5080
5379
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5081
5380
  * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
@@ -5111,7 +5410,7 @@ ${rationale}`}`);
5111
5410
  await this.refreshMember(name);
5112
5411
  }
5113
5412
  if (merged) {
5114
- 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)}.`);
5115
5414
  }
5116
5415
  return task3;
5117
5416
  }
@@ -5348,7 +5647,7 @@ ${rationale}`}`);
5348
5647
  return this.files.read(this.paths.board, paths, () => true);
5349
5648
  }
5350
5649
  async writeTurnRecord(record) {
5351
- const dir = this.sessionDir(record.agent, record.project, record.thread);
5650
+ const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
5352
5651
  await ensureDir(dir);
5353
5652
  await writeJson(path6.join(dir, "last-turn.json"), record);
5354
5653
  }
@@ -5652,7 +5951,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5652
5951
  };
5653
5952
  }
5654
5953
  case "channel": {
5655
- 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);
5656
5955
  return { channel };
5657
5956
  }
5658
5957
  case "role": {
@@ -5758,18 +6057,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5758
6057
  return { agent: agent2, token };
5759
6058
  }
5760
6059
  async validateAddChannel(input) {
5761
- if (input.project === null) {
5762
- if ((await this.society()).channels.includes(input.name)) {
5763
- throw new BoardError("ALREADY_EXISTS", `society channel ${input.name} already exists`);
5764
- }
5765
- 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`);
5766
6064
  }
5767
- const project2 = await this.readActiveProject(input.project);
5768
- if (project2.channels.includes(input.name)) {
5769
- 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`);
5770
6067
  }
5771
6068
  }
5772
- 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) {
5773
6074
  const ref = channelRef(input.project, input.name);
5774
6075
  if (input.project === null) {
5775
6076
  const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
@@ -5783,14 +6084,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5783
6084
  }));
5784
6085
  await ensureDir(this.paths.projectChannel(project2, input.name));
5785
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
+ }
5786
6096
  await this.events.append("channel.added", by, {
5787
6097
  channel: ref,
5788
6098
  purpose: input.purpose,
6099
+ ...follows ? { followedBy: opener } : {},
5789
6100
  ...meta
5790
6101
  });
5791
- 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.`);
5792
6105
  return ref;
5793
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
+ }
5794
6185
  /** A role becomes a society role only while none of its members is in a project. */
5795
6186
  async validateRole(charter) {
5796
6187
  if (charter.name === USER_ROLE) {
@@ -6003,23 +6394,35 @@ ${charter.purpose}
6003
6394
  async publishSignalUnlocked(signal) {
6004
6395
  return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
6005
6396
  }
6397
+ /** A channel that exists, open or archived, and the record of its archive when it has one. */
6006
6398
  async assertChannelExists(ref) {
6007
6399
  const parsed = parseChannelRef(ref);
6008
- if (parsed.project === null) {
6009
- const society = await this.society();
6010
- if (!society.channels.includes(parsed.channel)) {
6011
- throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
6012
- }
6013
- 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;
6403
+ }
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`);
6014
6407
  }
6015
- const project2 = await this.readProject(parsed.project);
6016
- if (!project2.channels.includes(parsed.channel)) {
6017
- throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
6408
+ return archived;
6409
+ }
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;
6018
6416
  }
6019
6417
  }
6020
- /** A channel that takes new posts, threads, and followers: it exists, and its project is not archived. */
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
+ */
6021
6422
  async assertChannelOpen(ref) {
6022
- await this.assertChannelExists(ref);
6423
+ if (await this.assertChannelExists(ref) !== void 0) {
6424
+ throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
6425
+ }
6023
6426
  const { project: project2 } = parseChannelRef(ref);
6024
6427
  if (project2 !== null) {
6025
6428
  await this.readActiveProject(project2);
@@ -6119,6 +6522,10 @@ ${charter.purpose}
6119
6522
  if (outcome === "approved") {
6120
6523
  await this.validateProvision(proposal2.kind, proposal2.charter);
6121
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
+ }
6122
6529
  const ts = this.now().toISOString();
6123
6530
  const decision = DecisionSchema.parse({
6124
6531
  id: this.newId(),
@@ -6519,6 +6926,14 @@ channelCommand.command("add <ref>").description("Open a channel: <name> for the
6519
6926
  });
6520
6927
  print({ channel: added }, () => `Channel ${added} opened`);
6521
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
+ });
6522
6937
  program.command("signals").description("Recent operations signals, oldest first").option("--limit <n>", "how many", "50").action(async (opts) => {
6523
6938
  const board = await open();
6524
6939
  const signals = await board.listSignals(Number(opts.limit));
@@ -6540,18 +6955,19 @@ program.command("ask <text>").description("Post to the society's general channel
6540
6955
  print(message, () => `Posted ${message.id} to general; the concierge wakes on it`);
6541
6956
  });
6542
6957
  var task = program.command("task").description("Manage tasks");
6543
- 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) => {
6544
6959
  const board = await open();
6545
6960
  const who = await actorFor(board, opts.as);
6546
6961
  const created = await board.createTask(who, {
6547
6962
  project: projectSlug,
6548
6963
  title,
6549
6964
  body: opts.body,
6965
+ channel: opts.channel,
6550
6966
  ...opts.parent === void 0 ? {} : { parent_id: opts.parent },
6551
6967
  ...opts.plan === void 0 ? {} : { stages: parsePlan(opts.plan) }
6552
6968
  });
6553
6969
  print(created, () => [
6554
- `Task ${created.id} created in ${created.project}: ${created.title}`,
6970
+ `Task ${created.id} created in ${created.project}/${created.channel}: ${created.title}`,
6555
6971
  describePlan(created)
6556
6972
  ].join("\n"));
6557
6973
  });