@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
@@ -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(project, name) {
166
+ const used = project === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
167
+ return used.includes(name);
168
+ }
159
169
  var SOCIETY_SCOPE = "society";
160
170
  function wakeScope(member, project) {
161
171
  return project !== null && member.memberships.includes(project) ? project : 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
  var VERB_DESCRIPTIONS = {
670
701
  post_message: "Post a markdown message to a channel, or into a thread when thread_id is given; a thread's message goes to the thread's channel.",
@@ -672,7 +703,7 @@ var VERB_DESCRIPTIONS = {
672
703
  search: "Search messages, tasks, and knowledge by text, optionally within a project or channel.",
673
704
  open_thread: "Open a thread on a channel with a title, for a topic of its own. Tasks and proposals have theirs already. Its messages reach only its participants and anyone mentioned.",
674
705
  close_thread: "Close a thread with a summary that is posted to the thread's channel. A task's thread closes with its task, a proposal's with its decision.",
675
- create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. Its thread opens with it, under the task's id, for everything said about the work.",
706
+ create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
676
707
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
677
708
  release_task: "Let go of the stage you hold so someone else can take it.",
678
709
  update_task: "Move a task back to an earlier stage, abandon it, or set what it is blocked by. A note is posted to the task's thread.",
@@ -683,14 +714,16 @@ var VERB_DESCRIPTIONS = {
683
714
  approve: "Approve a proposal; the board then provisions it, posts the decision in the proposal's thread, and closes it. User and steward only, never on your own proposal.",
684
715
  reject: "Reject a proposal with a reason, posted in the proposal's thread, which closes. User and steward only, never on your own proposal.",
685
716
  create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none or merge).",
686
- join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn.",
717
+ join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn, and follows the project's general; the result lists its other open channels, to subscribe to when the work needs them.",
687
718
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
688
719
  write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
689
720
  remove_knowledge: "Remove a knowledge topic that is wrong, superseded, or folded into another, so no turn or search finds it again. Whoever may write the topic may remove it; the board keeps its text aside for the user to restore.",
690
721
  plan_task: "Reshape a task's plan from the current stage onward, or after it while someone holds it: keep a stage by passing its id, drop it by leaving it out, add one without an id. Gated stages and on_done: user, steward, concierge.",
691
722
  advance_task: "Finish the stage you hold, with a note for the task's thread on what you did. The next stage becomes current; past the last one the task is done.",
692
723
  configure_project: "Set a project's completion effect (none, or merge to land each finished task's branch on the default branch), its display name, or its default branch, any of them at once. The slug never changes.",
693
- archive_project: "Archive a project whose work is finished or has moved: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay. Refused while a task there is in play."
724
+ archive_project: "Archive a project whose work is finished or has moved: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay. Refused while a task there is in play.",
725
+ create_channel: "Open a channel in a project, or with project null in the society, for a workstream such as a release, whose tasks are filed there. You follow it; a notice in the place's general announces it, and members subscribe when it concerns them. User, steward, concierge.",
726
+ archive_channel: "Archive a channel whose workstream is done: its open threads close, its followers stop following it, and nothing more is posted or filed there, while its history stays readable. Refused while a task filed there is in play, and for the channels the board itself uses. User, steward, concierge."
694
727
  };
695
728
 
696
729
  // packages/shared/dist/events.js
@@ -723,6 +756,7 @@ var BOARD_EVENT_TYPES = [
723
756
  "proposal.decided",
724
757
  "proposal.provisioned",
725
758
  "channel.added",
759
+ "channel.archived",
726
760
  "role.added",
727
761
  "agent.retired",
728
762
  "agent.joined",
@@ -744,7 +778,8 @@ var BOARD_EVENT_TYPES = [
744
778
  "turn.completed",
745
779
  "turn.failed",
746
780
  "merge.completed",
747
- "merge.failed"
781
+ "merge.failed",
782
+ "workspace.leftovers"
748
783
  ];
749
784
  var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
750
785
  var BoardEventSchema = z5.object({
@@ -824,6 +859,8 @@ var LiveTurnEventSchema = z5.object({
824
859
  project: NameSchema,
825
860
  /** The thread whose conversation the turn is in; absent for the home conversation. */
826
861
  thread: UlidSchema.optional(),
862
+ /** The channel, never general, whose conversation the turn is in. */
863
+ channel: NameSchema.optional(),
827
864
  event: AgentEventSchema
828
865
  });
829
866
 
@@ -839,6 +876,7 @@ var PATH_TOKENS = {
839
876
  worktree: "{{stellaris:worktree}}"
840
877
  };
841
878
  var SERVER_TOKEN = "{{stellaris:server}}";
879
+ var WORKSPACE_TOKEN = "{{stellaris:workspace}}";
842
880
  var HOME_SCRATCH = "scratch";
843
881
  var HOME_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
844
882
  var RunnerHelloSchema = z6.object({
@@ -899,11 +937,17 @@ var ProjectRepoSchema = z6.object({
899
937
  defaultBranch: z6.string().min(1)
900
938
  });
901
939
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
902
- z6.object({ kind: z6.literal("home") }),
940
+ z6.object({
941
+ kind: z6.literal("home"),
942
+ thread: UlidSchema.optional(),
943
+ channel: NameSchema.optional()
944
+ }),
903
945
  z6.object({
904
946
  kind: z6.literal("project"),
905
947
  repo: ProjectRepoSchema,
906
- branches: z6.array(z6.string().min(1))
948
+ branches: z6.array(z6.string().min(1)),
949
+ thread: UlidSchema.optional(),
950
+ channel: NameSchema.optional()
907
951
  }),
908
952
  z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
909
953
  ]);
@@ -919,6 +963,8 @@ var TurnJobSchema = z6.object({
919
963
  scope: NameSchema,
920
964
  /** The thread whose conversation the turn is in; absent for the home conversation. */
921
965
  thread: UlidSchema.optional(),
966
+ /** The channel, never general, whose conversation the turn is in. */
967
+ channel: NameSchema.optional(),
922
968
  /**
923
969
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
924
970
  * the outcome reports it.
@@ -952,9 +998,14 @@ var TurnOutcomeSchema = z6.object({
952
998
  session: z6.string().min(1),
953
999
  /** The model the CLI reported, when it reported one. */
954
1000
  model: z6.string().nullable(),
955
- work: TurnWorkSchema.nullable()
1001
+ work: TurnWorkSchema.nullable(),
1002
+ /**
1003
+ * Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
1004
+ * branch after the hand-back: uncommitted changes, or commits no branch contains.
1005
+ */
1006
+ leftovers: z6.boolean().default(false)
956
1007
  });
957
- var TurnAckSchema = z6.object({ dropWorktree: z6.boolean() });
1008
+ var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
958
1009
  var LandRequestSchema = z6.object({
959
1010
  repo: ProjectRepoSchema,
960
1011
  branch: z6.string().min(1)
@@ -1011,6 +1062,23 @@ var BranchChangesSchema = z6.object({
1011
1062
  files: z6.array(BranchFileChangeSchema),
1012
1063
  total: z6.number().int().nonnegative()
1013
1064
  });
1065
+ var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
1066
+ z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
1067
+ z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
1068
+ ]);
1069
+ function workspaceConversationOf(turn) {
1070
+ if (turn.thread !== void 0) {
1071
+ return { kind: "thread", id: turn.thread };
1072
+ }
1073
+ return turn.channel === void 0 ? null : { kind: "channel", scope: turn.scope, name: turn.channel };
1074
+ }
1075
+ var HeldWorkspaceSchema = z6.object({
1076
+ agent: NameSchema,
1077
+ scope: NameSchema,
1078
+ conversation: WorkspaceConversationSchema
1079
+ });
1080
+ var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
1081
+ var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
1014
1082
  var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
1015
1083
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
1016
1084
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
@@ -1032,7 +1100,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
1032
1100
  steer: TurnSteerSchema
1033
1101
  }),
1034
1102
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1035
- z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
1103
+ z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
1104
+ /**
1105
+ * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1106
+ * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
1107
+ */
1108
+ z6.object({
1109
+ type: z6.literal("sweep"),
1110
+ request: z6.string().min(1),
1111
+ conversation: WorkspaceConversationSchema
1112
+ })
1036
1113
  ]);
1037
1114
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
1038
1115
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -1060,7 +1137,8 @@ var TriggerKindSchema = z7.enum([
1060
1137
  "onboarding",
1061
1138
  "manual",
1062
1139
  "ops_event",
1063
- "user_post"
1140
+ "user_post",
1141
+ "closing"
1064
1142
  ]);
1065
1143
  var TriggerSchema = z7.object({
1066
1144
  kind: TriggerKindSchema,
@@ -1077,12 +1155,32 @@ var TurnDispatchSchema = z7.object({
1077
1155
  agent: NameSchema,
1078
1156
  project: NameSchema,
1079
1157
  thread: TurnThreadSchema.optional(),
1158
+ channel: NameSchema.optional(),
1080
1159
  trigger: TriggerSchema,
1081
1160
  priority: z7.number().int().min(0).max(2),
1082
1161
  onboarding: z7.boolean().default(false)
1083
1162
  });
1084
- function sessionKey(agent, scope, thread) {
1085
- return thread === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${thread}`;
1163
+ function conversationPart(conversation) {
1164
+ return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
1165
+ }
1166
+ function sessionKey(agent, scope, part) {
1167
+ return part === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${part}`;
1168
+ }
1169
+ function parseSessionKey(key) {
1170
+ const [agent, scope, part, rest] = key.split("/");
1171
+ if (agent === void 0 || scope === void 0 || rest !== void 0) {
1172
+ return null;
1173
+ }
1174
+ if (part === void 0) {
1175
+ return { agent, scope };
1176
+ }
1177
+ return part.startsWith("#") ? { agent, scope, channel: part.slice(1) } : { agent, scope, thread: part };
1178
+ }
1179
+ function channelConversation(scope, channel) {
1180
+ const at = channel.indexOf("/");
1181
+ const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
1182
+ const name = at < 0 ? channel : channel.slice(at + 1);
1183
+ return place === scope && name !== "general" ? name : void 0;
1086
1184
  }
1087
1185
  var TurnRecordSchema = z7.object({
1088
1186
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
@@ -1091,6 +1189,8 @@ var TurnRecordSchema = z7.object({
1091
1189
  project: NameSchema,
1092
1190
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1093
1191
  thread: UlidSchema.optional(),
1192
+ /** The channel, never general, whose conversation the turn was in. */
1193
+ channel: NameSchema.optional(),
1094
1194
  runner: NameSchema,
1095
1195
  cli: CliKindSchema.nullable(),
1096
1196
  session: z7.string().nullable(),
@@ -1120,8 +1220,9 @@ var RunningTurnSchema = z7.object({
1120
1220
  agent: NameSchema,
1121
1221
  scope: NameSchema,
1122
1222
  thread: UlidSchema.optional(),
1223
+ channel: NameSchema.optional(),
1123
1224
  cli: CliKindSchema,
1124
- channel: ChannelRefSchema.optional(),
1225
+ askedIn: ChannelRefSchema.optional(),
1125
1226
  steerable: z7.boolean(),
1126
1227
  stoppable: z7.boolean()
1127
1228
  });
@@ -1148,6 +1249,8 @@ var TurnHistoryEntrySchema = z7.object({
1148
1249
  project: NameSchema,
1149
1250
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1150
1251
  thread: UlidSchema.optional(),
1252
+ /** The channel, never general, whose conversation the turn was in. */
1253
+ channel: NameSchema.optional(),
1151
1254
  trigger: z7.string(),
1152
1255
  exitReason: z7.string().nullable(),
1153
1256
  costUsd: z7.number(),
@@ -1174,7 +1277,8 @@ var OPS_SIGNAL_KINDS = [
1174
1277
  "turn_cost",
1175
1278
  "scaled",
1176
1279
  "runner",
1177
- "home_conflict"
1280
+ "home_conflict",
1281
+ "stuck_workspace"
1178
1282
  ];
1179
1283
  var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
1180
1284
  var WAKING_SIGNAL_KINDS = [
@@ -1381,7 +1485,7 @@ var RunnerConfigSchema = z10.object({
1381
1485
  });
1382
1486
 
1383
1487
  // packages/shared/dist/version.js
1384
- var STELLARIS_VERSION = "0.3.9";
1488
+ var STELLARIS_VERSION = "0.3.11";
1385
1489
 
1386
1490
  // packages/board-core/dist/errors.js
1387
1491
  var BoardError = class extends Error {
@@ -1429,15 +1533,28 @@ async function writeJson(file, value) {
1429
1533
  await writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
1430
1534
  `);
1431
1535
  }
1536
+ function parseMatter(raw) {
1537
+ return matter(raw, {});
1538
+ }
1432
1539
  async function readMarkdown(file, schema) {
1433
1540
  const raw = await readFile(file, "utf8");
1434
- const parsed = matter(raw);
1541
+ const parsed = parseMatter(raw);
1435
1542
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1436
1543
  }
1544
+ async function readLooseMarkdown(file) {
1545
+ const raw = await readFile(file, "utf8");
1546
+ try {
1547
+ const parsed = parseMatter(raw);
1548
+ return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
1549
+ } catch (error) {
1550
+ const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
1551
+ return { data: {}, body: raw, error: reason.replace(/:$/, "") };
1552
+ }
1553
+ }
1437
1554
  async function writeMarkdown(file, data, body) {
1438
1555
  const normalized2 = body.endsWith("\n") || body.length === 0 ? body : `${body}
1439
1556
  `;
1440
- await writeFileAtomic(file, matter.stringify(normalized2, data));
1557
+ await writeFileAtomic(file, matter.stringify({ content: normalized2 }, data, {}));
1441
1558
  }
1442
1559
  function isMissing(error) {
1443
1560
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -1642,6 +1759,7 @@ var HOME_GITIGNORE = [
1642
1759
  "/projects/*/sessions*.json",
1643
1760
  "/projects/*/last-turn.json",
1644
1761
  "/projects/*/threads/",
1762
+ "/projects/*/channels/",
1645
1763
  `/${HOME_SCRATCH}/`,
1646
1764
  "*.tmp",
1647
1765
  "node_modules/",
@@ -1659,7 +1777,7 @@ var HOME_GITIGNORE = [
1659
1777
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
1660
1778
  `;
1661
1779
  var PRE_RECEIVE = `#!/bin/sh
1662
- forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
1780
+ forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
1663
1781
  while read old new ref; do
1664
1782
  if [ "$ref" != "refs/heads/main" ]; then
1665
1783
  echo "a home takes pushes to main only" >&2
@@ -1920,6 +2038,7 @@ var TurnPayload = z11.object({
1920
2038
  turnId: z11.string().optional(),
1921
2039
  project: z11.string(),
1922
2040
  thread: z11.string().optional(),
2041
+ channel: z11.string().optional(),
1923
2042
  trigger: z11.string()
1924
2043
  });
1925
2044
  var TaskPayload = z11.object({
@@ -1962,7 +2081,14 @@ function answers(mention, turn) {
1962
2081
  if (turn.thread !== null) {
1963
2082
  return mention.thread === turn.thread;
1964
2083
  }
1965
- return mention.scopes.includes(turn.project) && (mention.thread === null || turn.trigger === "mention");
2084
+ if (!mention.scopes.includes(turn.project)) {
2085
+ return false;
2086
+ }
2087
+ const own = mention.thread === null ? channelConversation(turn.project, mention.channel) : void 0;
2088
+ if (turn.channel !== null) {
2089
+ return mention.thread === null && own === turn.channel;
2090
+ }
2091
+ return mention.thread === null && own === void 0 || turn.trigger === "mention";
1966
2092
  }
1967
2093
  function computeMetrics(input) {
1968
2094
  const { events, now, window } = input;
@@ -2027,6 +2153,7 @@ function computeMetrics(input) {
2027
2153
  agent: event.actor,
2028
2154
  at,
2029
2155
  thread: turn.data.thread ?? null,
2156
+ channel: turn.data.channel ?? null,
2030
2157
  project: turn.data.project,
2031
2158
  trigger: turn.data.trigger
2032
2159
  };
@@ -2123,6 +2250,7 @@ function computeMetrics(input) {
2123
2250
  at: Date.parse(event.ts),
2124
2251
  message: message.data.id ?? null,
2125
2252
  thread,
2253
+ channel: message.data.channel,
2126
2254
  scopes: [project, wakeScope(citizen, project)].filter((scope) => scope !== null)
2127
2255
  });
2128
2256
  }
@@ -2398,9 +2526,12 @@ var BoardPaths = class {
2398
2526
  agentProject(name, slug) {
2399
2527
  return path5.join(this.agentProjects(name), slug);
2400
2528
  }
2401
- /** A thread conversation's session and last turn, beside the scope's home conversation. */
2402
- agentThread(name, slug, thread) {
2403
- return path5.join(this.agentProject(name, slug), "threads", thread);
2529
+ /**
2530
+ * A thread's or a channel's conversation's session and last turn, beside the scope's home
2531
+ * conversation; `part` is a thread's id or `#channel`.
2532
+ */
2533
+ agentConversation(name, slug, part) {
2534
+ return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
2404
2535
  }
2405
2536
  agentCursors(name) {
2406
2537
  return path5.join(this.agent(name), "cursors.json");
@@ -2434,6 +2565,13 @@ function hashToken(token) {
2434
2565
 
2435
2566
  // packages/board-core/dist/board.js
2436
2567
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
2568
+ var CLOSING_ATTEMPTS = 3;
2569
+ function ownTurn(actor, turn) {
2570
+ return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel;
2571
+ }
2572
+ function namesList(names) {
2573
+ return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
2574
+ }
2437
2575
  var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
2438
2576
  var RunnerTokensSchema = z12.object({
2439
2577
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
@@ -2451,6 +2589,7 @@ var SignInsSchema = z12.object({
2451
2589
  var TurnHistoryPayloadSchema = z12.object({
2452
2590
  project: NameSchema,
2453
2591
  thread: UlidSchema.optional(),
2592
+ channel: NameSchema.optional(),
2454
2593
  trigger: z12.string().default("manual"),
2455
2594
  exitReason: z12.string().nullable().default(null),
2456
2595
  costUsd: z12.number().default(0),
@@ -2466,12 +2605,23 @@ var CursorsSchema = z12.object({
2466
2605
  digest: z12.string().nullable(),
2467
2606
  scopes: z12.record(z12.string(), z12.string()).default({}),
2468
2607
  threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2608
+ channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2469
2609
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
2470
2610
  news: z12.string().optional()
2471
2611
  });
2472
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
2473
- function conversationKey(scope, thread) {
2474
- return thread === null ? scope : `${scope}/${thread}`;
2612
+ var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
2613
+ function conversationKey(scope, part) {
2614
+ return part === null ? scope : `${scope}/${part}`;
2615
+ }
2616
+ function channelOfPart(part) {
2617
+ return part?.startsWith("#") === true ? part.slice(1) : void 0;
2618
+ }
2619
+ function threadOfPart(part) {
2620
+ return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
2621
+ }
2622
+ function channelStart(cursors, scope) {
2623
+ const from = cursors.channelsFrom[scope];
2624
+ return from === void 0 ? cursors.digest : from;
2475
2625
  }
2476
2626
  function threadStart(cursors, scope) {
2477
2627
  const from = cursors.threadsFrom[scope];
@@ -2484,11 +2634,11 @@ function newsStart(cursors) {
2484
2634
  const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
2485
2635
  return homes.at(-1) ?? cursors.digest;
2486
2636
  }
2487
- function cursorOf(cursors, scope, thread = null) {
2488
- if (thread === null) {
2637
+ function cursorOf(cursors, scope, part = null) {
2638
+ if (part === null) {
2489
2639
  return cursors.scopes[scope] ?? cursors.digest;
2490
2640
  }
2491
- return cursors.scopes[conversationKey(scope, thread)] ?? threadStart(cursors, scope);
2641
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
2492
2642
  }
2493
2643
  function byId(a, b) {
2494
2644
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -2499,6 +2649,9 @@ function turnTime(record) {
2499
2649
  var PausedSchema = z12.object({ paused: z12.boolean() });
2500
2650
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
2501
2651
  var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2652
+ function withoutMentions(text) {
2653
+ return text.replaceAll(MENTION_PATTERN, "$1$2");
2654
+ }
2502
2655
  var CURATING_ROLES = [USER_ROLE, "steward"];
2503
2656
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2504
2657
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -2515,6 +2668,7 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
2515
2668
  var THREAD_CONVERSATIONS = "thread-conversations";
2516
2669
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2517
2670
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2671
+ var CHANNEL_CONVERSATIONS = "channel-conversations";
2518
2672
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2519
2673
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2520
2674
  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";
@@ -2645,6 +2799,8 @@ function snippetAround(text, query, radius = 80) {
2645
2799
  var Board = class _Board {
2646
2800
  paths;
2647
2801
  mutex = new Mutex();
2802
+ /** The turns in flight, which the server hosting them supplies; none until it does. */
2803
+ turnsInFlight = () => [];
2648
2804
  newId = monotonicFactory();
2649
2805
  events;
2650
2806
  actionCounts = /* @__PURE__ */ new Map();
@@ -2705,7 +2861,8 @@ var Board = class _Board {
2705
2861
  DECISIONS_CHANNEL_RETIRED,
2706
2862
  THREAD_CONVERSATIONS,
2707
2863
  ASKS_CHANNEL_OPENED,
2708
- KNOWLEDGE_REMOVAL
2864
+ KNOWLEDGE_REMOVAL,
2865
+ CHANNEL_CONVERSATIONS
2709
2866
  ].filter((name) => !applied.includes(name));
2710
2867
  if (pending.length === 0) {
2711
2868
  return;
@@ -2737,6 +2894,9 @@ var Board = class _Board {
2737
2894
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2738
2895
  await this.grantKnowledgeRemoval();
2739
2896
  }
2897
+ if (pending.includes(CHANNEL_CONVERSATIONS)) {
2898
+ await this.startChannelCursors();
2899
+ }
2740
2900
  await writeJson(file, { applied: [...applied, ...pending] });
2741
2901
  }
2742
2902
  /**
@@ -2911,6 +3071,27 @@ var Board = class _Board {
2911
3071
  }
2912
3072
  }
2913
3073
  }
3074
+ /**
3075
+ * Every channel of a scope was once read in its home conversation, so a member had read each up
3076
+ * to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
3077
+ * conversation of its own.
3078
+ */
3079
+ async startChannelCursors() {
3080
+ for (const agent of await this.listAgents()) {
3081
+ const file = this.paths.agentCursors(agent.name);
3082
+ if (!await exists(file)) {
3083
+ continue;
3084
+ }
3085
+ const cursors = await readJson(file, CursorsSchema);
3086
+ const scopes = /* @__PURE__ */ new Set([
3087
+ ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
3088
+ ...agent.memberships,
3089
+ SOCIETY_SCOPE
3090
+ ]);
3091
+ const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
3092
+ await writeJson(file, { ...cursors, channelsFrom });
3093
+ }
3094
+ }
2914
3095
  /**
2915
3096
  * Threads were once read in the scope's single conversation, so a member had read every thread
2916
3097
  * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
@@ -3044,17 +3225,20 @@ ${charter.purpose}
3044
3225
  /**
3045
3226
  * A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
3046
3227
  * in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
3047
- * scope and thread ride on the actor it resolves to, so `read_inbox` reads that conversation's
3048
- * digest.
3228
+ * scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
3229
+ * `read_inbox` reads that conversation's digest.
3049
3230
  */
3050
- issueTurnToken(agent, role, ttlMs, scope, thread) {
3231
+ issueTurnToken(agent, role, ttlMs, scope, part) {
3051
3232
  const token = mintToken();
3233
+ const thread = threadOfPart(part);
3234
+ const channel = channelOfPart(part);
3052
3235
  this.turnTokens.set(hashToken(token), {
3053
3236
  actor: {
3054
3237
  name: agent,
3055
3238
  role,
3056
3239
  ...scope === void 0 ? {} : { scope },
3057
- ...thread === void 0 ? {} : { thread }
3240
+ ...thread === void 0 ? {} : { thread },
3241
+ ...channel === void 0 ? {} : { channel }
3058
3242
  },
3059
3243
  expiresAt: this.now().getTime() + ttlMs
3060
3244
  });
@@ -3289,7 +3473,7 @@ ${charter.purpose}
3289
3473
  }
3290
3474
  async readProfile(name) {
3291
3475
  const file = this.paths.agentProfile(name);
3292
- return await exists(file) ? (await readMarkdown(file, z12.record(z12.string(), z12.unknown()))).body : "";
3476
+ return await exists(file) ? (await readLooseMarkdown(file)).body : "";
3293
3477
  }
3294
3478
  /** The society's norms, the one knowledge topic every turn loads. Empty until the steward writes it. */
3295
3479
  async readSocietyNorms() {
@@ -3336,10 +3520,11 @@ ${charter.purpose}
3336
3520
  if (!name.success) {
3337
3521
  continue;
3338
3522
  }
3339
- const doc = await readMarkdown(file, z12.record(z12.string(), z12.unknown()));
3523
+ const doc = await readLooseMarkdown(file);
3340
3524
  skills.push({
3341
3525
  name: name.data,
3342
- summary: skillSummary(doc.data, doc.body),
3526
+ // The index is where its writer sees that the file needs fixing.
3527
+ summary: doc.error === null ? skillSummary(doc.data, doc.body) : `its frontmatter does not parse (${doc.error}), so fix the file`,
3343
3528
  scope,
3344
3529
  path: file
3345
3530
  });
@@ -3353,34 +3538,44 @@ ${charter.purpose}
3353
3538
  }
3354
3539
  return (await readJson(file, PausedSchema)).paused;
3355
3540
  }
3356
- /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3357
- /** Every channel, the society's first, with how many messages it holds and its newest one. */
3541
+ /**
3542
+ * Every channel, the society's first, with how many messages it holds and its newest one; each
3543
+ * place's archived channels follow its open ones.
3544
+ */
3358
3545
  async listChannels() {
3359
3546
  const summaries = [];
3360
- const scopes = [
3361
- { project: null, names: (await this.society()).channels },
3547
+ const society = await this.society();
3548
+ const places = [
3549
+ { project: null, names: society.channels, archived: society.archivedChannels },
3362
3550
  ...(await this.listProjects()).map((project) => ({
3363
3551
  project: project.slug,
3364
- names: project.channels
3552
+ names: project.channels,
3553
+ archived: project.archivedChannels
3365
3554
  }))
3366
3555
  ];
3367
- for (const { project, names } of scopes) {
3368
- for (const name of names) {
3369
- const ref = channelRef(project, name);
3556
+ for (const { project, names, archived } of places) {
3557
+ const entries = [
3558
+ ...names.map((name) => ({ name, archived: void 0 })),
3559
+ ...archived.map((each) => ({ name: each.name, archived: each }))
3560
+ ];
3561
+ for (const entry of entries) {
3562
+ const ref = channelRef(project, entry.name);
3370
3563
  const files = await listFiles(this.paths.channelDir(ref));
3371
3564
  const last = files.at(-1)?.slice(0, 26) ?? null;
3372
3565
  summaries.push({
3373
3566
  ref,
3374
3567
  project,
3375
- name,
3568
+ name: entry.name,
3376
3569
  messages: files.length,
3377
3570
  lastMessageId: last,
3378
- lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
3571
+ lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
3572
+ ...entry.archived === void 0 ? {} : { archived: entry.archived }
3379
3573
  });
3380
3574
  }
3381
3575
  }
3382
3576
  return summaries;
3383
3577
  }
3578
+ /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3384
3579
  async listChannel(ref, limit2 = 200) {
3385
3580
  await this.assertChannelExists(ref);
3386
3581
  const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
@@ -3533,12 +3728,27 @@ Agents may edit this file. It is rendered by the board UI.
3533
3728
  return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
3534
3729
  });
3535
3730
  }
3731
+ /** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
3732
+ async createChannel(actor, input) {
3733
+ const args = VerbInputs.create_channel.parse(input);
3734
+ await this.authorize(actor, "create_channel");
3735
+ return this.mutex.run(async () => {
3736
+ await this.validateAddChannel(args);
3737
+ return this.addChannelUnlocked(actor.name, args, {}, actor.name);
3738
+ });
3739
+ }
3740
+ /** Archives a channel whose workstream is done; refused while a task filed there is in play. */
3741
+ async archiveChannel(actor, input) {
3742
+ const args = VerbInputs.archive_channel.parse(input);
3743
+ await this.authorize(actor, "archive_channel");
3744
+ return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
3745
+ }
3536
3746
  /** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
3537
3747
  async addChannel(actor, input) {
3538
3748
  this.assertAdmin(actor);
3539
3749
  return this.mutex.run(async () => {
3540
3750
  await this.validateAddChannel(input);
3541
- return this.addChannelUnlocked(actor.name, input, {});
3751
+ return this.addChannelUnlocked(actor.name, input, {}, actor.name);
3542
3752
  });
3543
3753
  }
3544
3754
  /**
@@ -3736,19 +3946,19 @@ Agents may edit this file. It is rendered by the board UI.
3736
3946
  return this.mutex.run(async () => {
3737
3947
  const stored = await this.readCursors(actor.name);
3738
3948
  const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
3739
- const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, thread: actor.thread ?? null };
3949
+ const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
3740
3950
  const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
3741
3951
  if (args.advance) {
3742
3952
  await this.advanceCursors(actor.name, filed);
3743
3953
  }
3744
3954
  const last = filed.at(-1)?.message.id;
3745
- const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.thread));
3955
+ const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
3746
3956
  return { messages: filed.map((entry) => entry.message), cursor };
3747
3957
  });
3748
3958
  }
3749
3959
  /**
3750
3960
  * How many unread digest messages a member has in each conversation: a scope's home, for its
3751
- * channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3961
+ * general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3752
3962
  * messages are and nowhere else. Society channels count only for a member woken in the society
3753
3963
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
3754
3964
  * counted, and are read when the member wakes for something else: a task step's, which wakes
@@ -3759,7 +3969,7 @@ Agents may edit this file. It is rendered by the board UI.
3759
3969
  return this.mutex.run(async () => {
3760
3970
  const counts = /* @__PURE__ */ new Map();
3761
3971
  const unscoped = { name: actor.name, role: actor.role };
3762
- for (const { scope, thread, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3972
+ for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3763
3973
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
3764
3974
  continue;
3765
3975
  }
@@ -3767,8 +3977,13 @@ Agents may edit this file. It is rendered by the board UI.
3767
3977
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
3768
3978
  continue;
3769
3979
  }
3770
- const key = conversationKey(scope, thread);
3771
- const entry = counts.get(key) ?? { scope, thread, count: 0 };
3980
+ const key = conversationKey(scope, part);
3981
+ const entry = counts.get(key) ?? {
3982
+ scope,
3983
+ thread: threadOfPart(part) ?? null,
3984
+ channel: channelOfPart(part) ?? null,
3985
+ count: 0
3986
+ };
3772
3987
  counts.set(key, { ...entry, count: entry.count + 1 });
3773
3988
  }
3774
3989
  return [...counts.values()];
@@ -3784,7 +3999,7 @@ Agents may edit this file. It is rendered by the board UI.
3784
3999
  const scopes = { ...cursors.scopes };
3785
4000
  let news = newsStart(cursors);
3786
4001
  let moved = false;
3787
- for (const { scope, thread, message, news: isNews } of filed) {
4002
+ for (const { scope, part, message, news: isNews } of filed) {
3788
4003
  if (isNews === true) {
3789
4004
  if (news === null || message.id > news) {
3790
4005
  news = message.id;
@@ -3792,8 +4007,8 @@ Agents may edit this file. It is rendered by the board UI.
3792
4007
  }
3793
4008
  continue;
3794
4009
  }
3795
- const key = conversationKey(scope, thread);
3796
- const current = scopes[key] ?? cursorOf(cursors, scope, thread);
4010
+ const key = conversationKey(scope, part);
4011
+ const current = scopes[key] ?? cursorOf(cursors, scope, part);
3797
4012
  if (current === null || message.id > current) {
3798
4013
  scopes[key] = message.id;
3799
4014
  moved = true;
@@ -3812,29 +4027,32 @@ Agents may edit this file. It is rendered by the board UI.
3812
4027
  * message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
3813
4028
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
3814
4029
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
3815
- * message of the thread, since the conversation is about it; for a scope's home, its channel
3816
- * messages, since each thread is a conversation of its own.
4030
+ * message of the thread, since the conversation is about it; for a channel's, that channel's
4031
+ * top-level messages; for a scope's home, general's and those of a project the member is not in,
4032
+ * since each thread and each other channel is a conversation of its own.
3817
4033
  */
3818
4034
  async digestSince(actor, cursors, conversation) {
3819
- if (conversation !== void 0 && conversation.thread !== null) {
3820
- const { scope, thread } = conversation;
3821
- const found = await this.tryFindThread(thread);
4035
+ const conversationThread = threadOfPart(conversation?.part);
4036
+ if (conversation !== void 0 && conversationThread !== void 0) {
4037
+ const { scope, part } = conversation;
4038
+ const found = await this.tryFindThread(conversationThread);
3822
4039
  if (found === null) {
3823
4040
  return [];
3824
4041
  }
3825
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, thread), cursorOf(cursors, scope, thread));
3826
- return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, thread, message })).toSorted(byId);
4042
+ const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
4043
+ return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
3827
4044
  }
3828
4045
  const agent = await this.readAgent(actor.name);
3829
4046
  const charter = await this.readRole(agent.role);
3830
4047
  const subscribed = new Set(agent.subscriptions);
3831
4048
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
3832
4049
  const threadParticipation = /* @__PURE__ */ new Map();
4050
+ const openChannels = /* @__PURE__ */ new Map();
3833
4051
  const collected = [];
3834
4052
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
3835
4053
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent.memberships, SOCIETY_SCOPE];
3836
4054
  const starts = [
3837
- ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
4055
+ ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
3838
4056
  ...newsFrom === void 0 ? [] : [newsFrom]
3839
4057
  ];
3840
4058
  const known = starts.filter((start) => start !== null);
@@ -3847,20 +4065,24 @@ Agents may edit this file. It is rendered by the board UI.
3847
4065
  if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
3848
4066
  collected.push({
3849
4067
  scope: conversation?.scope ?? SOCIETY_SCOPE,
3850
- thread: null,
4068
+ part: null,
3851
4069
  message,
3852
4070
  news: true
3853
4071
  });
3854
4072
  }
3855
4073
  continue;
3856
4074
  }
3857
- if (conversation !== void 0 && filedIn !== conversation.scope) {
3858
- continue;
4075
+ const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
4076
+ let open = named === void 0 ? void 0 : openChannels.get(message.channel);
4077
+ if (named !== void 0 && open === void 0) {
4078
+ open = await this.channelOpen(message.channel);
4079
+ openChannels.set(message.channel, open);
3859
4080
  }
3860
- if (conversation !== void 0 && thread !== null) {
4081
+ const part = thread ?? (open === true ? `#${named}` : null);
4082
+ if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
3861
4083
  continue;
3862
4084
  }
3863
- const cursor = cursorOf(cursors, filedIn, thread);
4085
+ const cursor = cursorOf(cursors, filedIn, part);
3864
4086
  if (cursor !== null && message.id <= cursor) {
3865
4087
  continue;
3866
4088
  }
@@ -3878,7 +4100,7 @@ Agents may edit this file. It is rendered by the board UI.
3878
4100
  include = participates;
3879
4101
  }
3880
4102
  if (include && message.author !== actor.name) {
3881
- collected.push({ scope: filedIn, thread, message });
4103
+ collected.push({ scope: filedIn, part, message });
3882
4104
  }
3883
4105
  }
3884
4106
  return collected.toSorted(byId);
@@ -4117,7 +4339,7 @@ ${task.body}`;
4117
4339
  taskThreadOpening(task) {
4118
4340
  return {
4119
4341
  id: task.id,
4120
- channel: channelRef(task.project, "general"),
4342
+ channel: channelRef(task.project, task.channel),
4121
4343
  title: task.title,
4122
4344
  subject: { kind: "task", id: task.id }
4123
4345
  };
@@ -4163,7 +4385,7 @@ ${task.body}`;
4163
4385
  if (task.status === "done" || task.status === "abandoned") {
4164
4386
  throw new BoardError("INVALID_STATE", `task ${task.id} is ${task.status}`);
4165
4387
  }
4166
- const channel = args.channel ?? channelRef(project, "general");
4388
+ const channel = args.channel ?? channelRef(project, task.channel);
4167
4389
  if (parseChannelRef(channel).project !== project) {
4168
4390
  throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project}`);
4169
4391
  }
@@ -4196,6 +4418,44 @@ ${task.body}`;
4196
4418
  await this.assertChannelOpen(args.channel);
4197
4419
  return { id: this.newId(), channel: args.channel, title: args.title };
4198
4420
  }
4421
+ /**
4422
+ * Where the board learns which turns are in flight, from the server that hosts them, so it
4423
+ * refuses to end a conversation under someone else's turn.
4424
+ */
4425
+ watchTurns(source) {
4426
+ this.turnsInFlight = source;
4427
+ }
4428
+ /**
4429
+ * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
4430
+ * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
4431
+ * from inside it is how a citizen finishes there.
4432
+ */
4433
+ assertNoTurnCut(actor, what, cut) {
4434
+ const busy = [
4435
+ ...new Set(this.turnsInFlight().filter((turn) => cut(turn) && !ownTurn(actor, turn)).map((turn) => turn.agent))
4436
+ ].toSorted((a, b) => a.localeCompare(b));
4437
+ if (busy.length === 0) {
4438
+ return;
4439
+ }
4440
+ const one = busy.length === 1;
4441
+ 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"}`);
4442
+ }
4443
+ /** The open threads in a folder of threads that `keep` picks. */
4444
+ async openThreadIds(dir, keep) {
4445
+ const ids = /* @__PURE__ */ new Set();
4446
+ for (const file of await listFiles(dir)) {
4447
+ const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
4448
+ if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
4449
+ ids.add(doc.data.id);
4450
+ }
4451
+ }
4452
+ return ids;
4453
+ }
4454
+ /** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
4455
+ async assertProjectQuiet(actor, slug) {
4456
+ const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
4457
+ this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.thread !== void 0 && threads.has(turn.thread));
4458
+ }
4199
4459
  async closeThread(actor, input) {
4200
4460
  const args = VerbInputs.close_thread.parse(input);
4201
4461
  await this.authorize(actor, "close_thread");
@@ -4210,6 +4470,7 @@ ${task.body}`;
4210
4470
  if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread, threads)) {
4211
4471
  throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
4212
4472
  }
4473
+ this.assertNoTurnCut(actor, `thread ${thread.id} cannot close`, (turn) => turn.thread === thread.id);
4213
4474
  const summary = await this.writeMessage(this.paths.channelDir(thread.channel), { author: actor.name, channel: thread.channel, closes: thread.id }, `Thread closed: "${thread.title}" (${thread.id}).
4214
4475
 
4215
4476
  ${args.summary}`);
@@ -4248,10 +4509,12 @@ ${args.summary}`);
4248
4509
  if (first === void 0) {
4249
4510
  throw new BoardError("VALIDATION", "a plan needs at least one stage");
4250
4511
  }
4512
+ await this.assertChannelOpen(channelRef(args.project, args.channel));
4251
4513
  const ts = this.now().toISOString();
4252
4514
  const frontmatter = TaskFrontmatterSchema.parse({
4253
4515
  id: this.newId(),
4254
4516
  project: args.project,
4517
+ channel: args.channel,
4255
4518
  title: args.title,
4256
4519
  status: "open",
4257
4520
  createdBy: actor.name,
@@ -4267,11 +4530,11 @@ ${args.summary}`);
4267
4530
  onDone: project.onDone,
4268
4531
  completing: false
4269
4532
  });
4270
- await this.assertChannelExists(channelRef(args.project, "general"));
4271
4533
  const task = await this.writeTask(args.project, { ...frontmatter, body: args.body });
4272
4534
  await this.events.append("task.created", actor.name, {
4273
4535
  taskId: task.id,
4274
4536
  project: task.project,
4537
+ channel: task.channel,
4275
4538
  title: task.title,
4276
4539
  parentId: task.parentId ?? null,
4277
4540
  stage: task.stage
@@ -4387,6 +4650,9 @@ ${args.summary}`);
4387
4650
  completedAt: ts
4388
4651
  };
4389
4652
  const next = current.stages[index + 1];
4653
+ if (next === void 0 && current.onDone === "none") {
4654
+ this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn) => turn.thread === current.id);
4655
+ }
4390
4656
  const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
4391
4657
  const base = {
4392
4658
  ...current,
@@ -4550,6 +4816,7 @@ ${args.summary}`);
4550
4816
  if (!mayAbandon) {
4551
4817
  throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
4552
4818
  }
4819
+ this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn) => turn.thread === current.id);
4553
4820
  next = {
4554
4821
  ...next,
4555
4822
  status: "abandoned",
@@ -4751,6 +5018,7 @@ ${rationale}`}`);
4751
5018
  await this.authorize(actor, "archive_project");
4752
5019
  return this.mutex.run(async () => {
4753
5020
  await this.validateArchive(args.project);
5021
+ await this.assertProjectQuiet(actor, args.project);
4754
5022
  return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
4755
5023
  });
4756
5024
  }
@@ -4758,6 +5026,10 @@ ${rationale}`}`);
4758
5026
  * A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
4759
5027
  * directory now and, through the `agent.joined` event, an onboarding turn.
4760
5028
  */
5029
+ /**
5030
+ * A member follows the project's general from the start; the result names the project's other
5031
+ * open channels, which it subscribes to when its work there needs them.
5032
+ */
4761
5033
  async joinProject(actor, input) {
4762
5034
  const args = VerbInputs.join_project.parse(input);
4763
5035
  await this.authorize(actor, "join_project");
@@ -4770,7 +5042,7 @@ ${rationale}`}`);
4770
5042
  throw new BoardError("INVALID_STATE", `${target} is retired`);
4771
5043
  }
4772
5044
  if (current.memberships.includes(args.project)) {
4773
- return current;
5045
+ return { ...current, otherChannels: await this.otherChannels(args.project, current) };
4774
5046
  }
4775
5047
  await this.assertMayJoinProjects(target, current.role);
4776
5048
  const agent = await this.updateAgent(target, (a) => ({
@@ -4789,9 +5061,13 @@ ${rationale}`}`);
4789
5061
  project: args.project,
4790
5062
  cli: agent.cli
4791
5063
  });
4792
- return agent;
5064
+ return { ...agent, otherChannels: await this.otherChannels(args.project, agent) };
4793
5065
  });
4794
5066
  }
5067
+ /** The open channels of a project a citizen does not follow. */
5068
+ async otherChannels(project, agent) {
5069
+ return (await this.readProject(project)).channels.map((name) => channelRef(project, name)).filter((ref) => !agent.subscriptions.includes(ref));
5070
+ }
4795
5071
  async leaveProject(actor, input) {
4796
5072
  const args = VerbInputs.leave_project.parse(input);
4797
5073
  await this.authorize(actor, "leave_project");
@@ -4896,6 +5172,10 @@ ${rationale}`}`);
4896
5172
  return this.configureProject(actor, VerbInputs.configure_project.parse(input));
4897
5173
  case "archive_project":
4898
5174
  return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
5175
+ case "create_channel":
5176
+ return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5177
+ case "archive_channel":
5178
+ return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
4899
5179
  default:
4900
5180
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
4901
5181
  }
@@ -5001,34 +5281,35 @@ ${rationale}`}`);
5001
5281
  }
5002
5282
  async readMemoryCore(agent) {
5003
5283
  const file = this.paths.agentMemoryCore(agent);
5004
- return await exists(file) ? (await readMarkdown(file, z12.record(z12.string(), z12.unknown()))).body : "";
5284
+ return await exists(file) ? (await readLooseMarkdown(file)).body : "";
5005
5285
  }
5006
5286
  /**
5007
5287
  * Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
5008
5288
  * never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
5289
+ * `part` names the conversation beside the scope's home: a thread's id or `#channel`.
5009
5290
  */
5010
- async setDigestCursor(agent, scope, cursor, thread) {
5291
+ async setDigestCursor(agent, scope, cursor, part) {
5011
5292
  await this.mutex.run(async () => {
5012
5293
  if (cursor === null) {
5013
5294
  return;
5014
5295
  }
5015
5296
  const cursors = await this.readCursors(agent);
5016
- const current = cursorOf(cursors, scope, thread ?? null);
5297
+ const current = cursorOf(cursors, scope, part ?? null);
5017
5298
  const news = newsStart(cursors);
5018
- const readNews = thread === void 0 && (news === null || cursor > news);
5299
+ const readNews = part === void 0 && (news === null || cursor > news);
5019
5300
  if (current !== null && cursor <= current && !readNews) {
5020
5301
  return;
5021
5302
  }
5022
5303
  await writeJson(this.paths.agentCursors(agent), {
5023
5304
  ...cursors,
5024
- scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, thread ?? null)]: cursor },
5305
+ scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
5025
5306
  ...readNews ? { news: cursor } : {}
5026
5307
  });
5027
5308
  });
5028
5309
  }
5029
5310
  /** Where a conversation's digest resumes: the newest message a turn there was shown. */
5030
- async digestCursor(agent, scope, thread) {
5031
- return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, thread ?? null));
5311
+ async digestCursor(agent, scope, part) {
5312
+ return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, part ?? null));
5032
5313
  }
5033
5314
  /** Records that a running turn took posts delivered into it, which wake latency counts to. */
5034
5315
  async recordSteered(input) {
@@ -5037,25 +5318,29 @@ ${rationale}`}`);
5037
5318
  turnId: input.turnId,
5038
5319
  project: input.project,
5039
5320
  ...input.thread === void 0 ? {} : { thread: input.thread },
5321
+ ...input.channel === void 0 ? {} : { channel: input.channel },
5040
5322
  messages: [...input.messages]
5041
5323
  });
5042
5324
  });
5043
5325
  }
5044
- /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
5045
- sessionDir(agent, scope, thread) {
5046
- return thread === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentThread(agent, scope, thread);
5326
+ /**
5327
+ * Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
5328
+ * thread's or a channel's, named by `part` as `conversationPart` gives it.
5329
+ */
5330
+ sessionDir(agent, scope, part) {
5331
+ return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
5047
5332
  }
5048
- async readSessions(agent, project, thread) {
5049
- const file = path6.join(this.sessionDir(agent, project, thread), "sessions.json");
5333
+ async readSessions(agent, project, part) {
5334
+ const file = path6.join(this.sessionDir(agent, project, part), "sessions.json");
5050
5335
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
5051
5336
  }
5052
5337
  /**
5053
5338
  * Records a conversation's session for a CLI and the runner it began on. A session started on
5054
5339
  * another runner replaces the record, since sessions do not move between machines.
5055
5340
  */
5056
- async writeSession(agent, project, cli, sessionId, runner, thread) {
5341
+ async writeSession(agent, project, cli, sessionId, runner, part) {
5057
5342
  await this.mutex.run(async () => {
5058
- const dir = this.sessionDir(agent, project, thread);
5343
+ const dir = this.sessionDir(agent, project, part);
5059
5344
  await ensureDir(dir);
5060
5345
  const file = path6.join(dir, "sessions.json");
5061
5346
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
@@ -5063,17 +5348,21 @@ ${rationale}`}`);
5063
5348
  await writeJson(file, { ...kept, [cli]: sessionId, runner });
5064
5349
  });
5065
5350
  }
5066
- /** The last turn of one conversation: the scope's home, or a thread's. */
5067
- async readLastTurn(agent, project, thread) {
5068
- const file = path6.join(this.sessionDir(agent, project, thread), "last-turn.json");
5351
+ /** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
5352
+ async readLastTurn(agent, project, part) {
5353
+ const file = path6.join(this.sessionDir(agent, project, part), "last-turn.json");
5069
5354
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
5070
5355
  }
5071
5356
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
5072
5357
  async readLatestTurn(agent, project) {
5073
5358
  let latest = await this.readLastTurn(agent, project);
5074
- const threads = path6.join(this.paths.agentProject(agent, project), "threads");
5075
- for (const thread of await listDirs(threads)) {
5076
- const turn = await this.readLastTurn(agent, project, thread);
5359
+ const place = this.paths.agentProject(agent, project);
5360
+ const parts = [
5361
+ ...await listDirs(path6.join(place, "threads")),
5362
+ ...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
5363
+ ];
5364
+ for (const part of parts) {
5365
+ const turn = await this.readLastTurn(agent, project, part);
5077
5366
  if (turn !== null && (latest === null || turnTime(turn) > turnTime(latest))) {
5078
5367
  latest = turn;
5079
5368
  }
@@ -5090,6 +5379,7 @@ ${rationale}`}`);
5090
5379
  turnId: parsed.id,
5091
5380
  project: parsed.project,
5092
5381
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5382
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5093
5383
  trigger: parsed.trigger.kind,
5094
5384
  session: parsed.session,
5095
5385
  runner: parsed.runner
@@ -5115,13 +5405,15 @@ ${rationale}`}`);
5115
5405
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5116
5406
  agent: parsed.agent,
5117
5407
  project: parsed.project,
5118
- ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
5408
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5409
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
5119
5410
  });
5120
5411
  }
5121
5412
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
5122
5413
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5123
5414
  project: parsed.project,
5124
5415
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5416
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5125
5417
  trigger: parsed.trigger.kind,
5126
5418
  session: parsed.session,
5127
5419
  exitReason: parsed.exitReason,
@@ -5145,6 +5437,47 @@ ${rationale}`}`);
5145
5437
  });
5146
5438
  });
5147
5439
  }
5440
+ /**
5441
+ * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
5442
+ * the scheduler gives the citizen a closing turn there to decide about it. False, recording
5443
+ * nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
5444
+ */
5445
+ async recordLeftovers(input) {
5446
+ const { conversation } = input;
5447
+ const payload = {
5448
+ agent: input.agent,
5449
+ project: input.scope,
5450
+ ...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
5451
+ };
5452
+ return this.mutex.run(async () => {
5453
+ 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;
5454
+ if (earlier >= CLOSING_ATTEMPTS) {
5455
+ return false;
5456
+ }
5457
+ await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
5458
+ return true;
5459
+ });
5460
+ }
5461
+ /**
5462
+ * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
5463
+ * (for one from before tasks had threads), or a channel archived.
5464
+ */
5465
+ async conversationEnded(conversation) {
5466
+ if (conversation.kind === "channel") {
5467
+ const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
5468
+ return !await this.channelOpen(channelRef(place, conversation.name));
5469
+ }
5470
+ const found = await this.tryFindThread(conversation.id);
5471
+ if (found !== null) {
5472
+ return found.thread.state === "closed";
5473
+ }
5474
+ try {
5475
+ const { task } = await this.findTask(conversation.id);
5476
+ return task.status === "done" || task.status === "abandoned";
5477
+ } catch {
5478
+ return false;
5479
+ }
5480
+ }
5148
5481
  /**
5149
5482
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5150
5483
  * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
@@ -5180,7 +5513,7 @@ ${rationale}`}`);
5180
5513
  await this.refreshMember(name);
5181
5514
  }
5182
5515
  if (merged) {
5183
- await this.appendMessage(actor.name, channelRef(location.project, "general"), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5516
+ await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5184
5517
  }
5185
5518
  return task2;
5186
5519
  }
@@ -5216,7 +5549,8 @@ ${rationale}`}`);
5216
5549
  if (!await exists(file)) {
5217
5550
  return { data: { project: slug }, body: "" };
5218
5551
  }
5219
- return readMarkdown(file, z12.record(z12.string(), z12.unknown()));
5552
+ const { data, body } = await readLooseMarkdown(file);
5553
+ return { data, body };
5220
5554
  }
5221
5555
  async listRunners() {
5222
5556
  const runners2 = [];
@@ -5416,7 +5750,7 @@ ${rationale}`}`);
5416
5750
  return this.files.read(this.paths.board, paths, () => true);
5417
5751
  }
5418
5752
  async writeTurnRecord(record) {
5419
- const dir = this.sessionDir(record.agent, record.project, record.thread);
5753
+ const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
5420
5754
  await ensureDir(dir);
5421
5755
  await writeJson(path6.join(dir, "last-turn.json"), record);
5422
5756
  }
@@ -5720,7 +6054,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5720
6054
  };
5721
6055
  }
5722
6056
  case "channel": {
5723
- const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta);
6057
+ const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta, proposal.proposedBy);
5724
6058
  return { channel };
5725
6059
  }
5726
6060
  case "role": {
@@ -5826,18 +6160,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5826
6160
  return { agent, token };
5827
6161
  }
5828
6162
  async validateAddChannel(input) {
5829
- if (input.project === null) {
5830
- if ((await this.society()).channels.includes(input.name)) {
5831
- throw new BoardError("ALREADY_EXISTS", `society channel ${input.name} already exists`);
5832
- }
5833
- return;
6163
+ const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
6164
+ const ref = channelRef(input.project, input.name);
6165
+ if (place.channels.includes(input.name)) {
6166
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
5834
6167
  }
5835
- const project = await this.readActiveProject(input.project);
5836
- if (project.channels.includes(input.name)) {
5837
- throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
6168
+ if (place.archivedChannels.some((each) => each.name === input.name)) {
6169
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
5838
6170
  }
5839
6171
  }
5840
- async addChannelUnlocked(by, input, meta) {
6172
+ /**
6173
+ * Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
6174
+ * it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
6175
+ */
6176
+ async addChannelUnlocked(by, input, meta, opener) {
5841
6177
  const ref = channelRef(input.project, input.name);
5842
6178
  if (input.project === null) {
5843
6179
  const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
@@ -5851,14 +6187,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5851
6187
  }));
5852
6188
  await ensureDir(this.paths.projectChannel(project, input.name));
5853
6189
  }
6190
+ const follower = await this.readAgent(opener).catch(() => null);
6191
+ const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
6192
+ if (follows) {
6193
+ await this.updateAgent(opener, (agent) => ({
6194
+ ...agent,
6195
+ subscriptions: [.../* @__PURE__ */ new Set([...agent.subscriptions, ref])]
6196
+ }));
6197
+ await this.refreshMember(opener);
6198
+ }
5854
6199
  await this.events.append("channel.added", by, {
5855
6200
  channel: ref,
5856
6201
  purpose: input.purpose,
6202
+ ...follows ? { followedBy: opener } : {},
5857
6203
  ...meta
5858
6204
  });
5859
- await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${input.purpose}`);
6205
+ const purpose = withoutMentions(input.purpose);
6206
+ await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
6207
+ 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.`);
5860
6208
  return ref;
5861
6209
  }
6210
+ /**
6211
+ * Ends a channel: its open threads close, its followers stop following it, and it leaves the
6212
+ * place's channels for its archived ones, where its history stays readable. The notice is the
6213
+ * board's and leaves the reason out, since a board-authored mention would wake whoever it names.
6214
+ */
6215
+ async archiveChannelUnlocked(actor, ref, reason) {
6216
+ const by = actor.name;
6217
+ const { project, channel } = parseChannelRef(ref);
6218
+ if (isBoardChannel(project, channel)) {
6219
+ throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
6220
+ }
6221
+ await this.assertChannelOpen(ref);
6222
+ if (project !== null) {
6223
+ const inPlay2 = (await this.listTasks(project)).filter((task) => task.channel === channel && task.status !== "done" && task.status !== "abandoned");
6224
+ if (inPlay2.length > 0) {
6225
+ throw new BoardError("INVALID_STATE", `${inPlay2.length} task(s) filed in ${ref} are still in play: ${inPlay2.map((task) => task.id).join(", ")}`);
6226
+ }
6227
+ }
6228
+ const ts = this.now().toISOString();
6229
+ const threads = project === null ? this.paths.societyThreads() : this.paths.threads(project);
6230
+ const closing = await this.openThreadIds(threads, (thread) => thread.channel === ref);
6231
+ const scope = project ?? SOCIETY_SCOPE;
6232
+ this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
6233
+ const threadsClosed = [];
6234
+ for (const file of await listFiles(threads)) {
6235
+ const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
6236
+ if (doc.data.channel !== ref || doc.data.state !== "open") {
6237
+ continue;
6238
+ }
6239
+ await this.writeThread(threads, {
6240
+ ...doc.data,
6241
+ body: doc.body,
6242
+ state: "closed",
6243
+ closedBy: by,
6244
+ closedAt: ts
6245
+ });
6246
+ await this.events.append("thread.closed", by, {
6247
+ threadId: doc.data.id,
6248
+ channel: ref,
6249
+ ended: "archived"
6250
+ });
6251
+ threadsClosed.push(doc.data.id);
6252
+ }
6253
+ const unsubscribed = [];
6254
+ for (const agent of await this.listAgents()) {
6255
+ if (agent.subscriptions.includes(ref)) {
6256
+ await this.updateAgent(agent.name, (a) => ({
6257
+ ...a,
6258
+ subscriptions: a.subscriptions.filter((each) => each !== ref)
6259
+ }));
6260
+ await this.refreshMember(agent.name);
6261
+ unsubscribed.push(agent.name);
6262
+ }
6263
+ }
6264
+ const archived = { name: channel, at: ts, by, reason };
6265
+ if (project === null) {
6266
+ const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
6267
+ await writeMarkdown(this.paths.societyFile(), {
6268
+ ...doc.data,
6269
+ channels: doc.data.channels.filter((name) => name !== channel),
6270
+ archivedChannels: [...doc.data.archivedChannels, archived]
6271
+ }, doc.body);
6272
+ } else {
6273
+ await this.updateProject(project, (current) => ({
6274
+ ...current,
6275
+ channels: current.channels.filter((name) => name !== channel),
6276
+ archivedChannels: [...current.archivedChannels, archived]
6277
+ }));
6278
+ }
6279
+ await this.events.append("channel.archived", by, {
6280
+ channel: ref,
6281
+ reason,
6282
+ threadsClosed,
6283
+ unsubscribed
6284
+ });
6285
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(project, "general"), `Channel ${ref} is archived by ${by}. Its posts, threads, and tasks stay readable; nothing more is posted or filed there.`);
6286
+ return archived;
6287
+ }
5862
6288
  /** A role becomes a society role only while none of its members is in a project. */
5863
6289
  async validateRole(charter) {
5864
6290
  if (charter.name === USER_ROLE) {
@@ -6071,23 +6497,35 @@ ${charter.purpose}
6071
6497
  async publishSignalUnlocked(signal) {
6072
6498
  return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
6073
6499
  }
6500
+ /** A channel that exists, open or archived, and the record of its archive when it has one. */
6074
6501
  async assertChannelExists(ref) {
6075
6502
  const parsed = parseChannelRef(ref);
6076
- if (parsed.project === null) {
6077
- const society = await this.society();
6078
- if (!society.channels.includes(parsed.channel)) {
6079
- throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
6080
- }
6081
- return;
6503
+ const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
6504
+ if (place.channels.includes(parsed.channel)) {
6505
+ return void 0;
6506
+ }
6507
+ const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
6508
+ if (archived === void 0) {
6509
+ throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
6082
6510
  }
6083
- const project = await this.readProject(parsed.project);
6084
- if (!project.channels.includes(parsed.channel)) {
6085
- throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
6511
+ return archived;
6512
+ }
6513
+ /** Whether a channel exists and is not archived, its conversations still going. */
6514
+ async channelOpen(ref) {
6515
+ try {
6516
+ return await this.assertChannelExists(ref) === void 0;
6517
+ } catch {
6518
+ return false;
6086
6519
  }
6087
6520
  }
6088
- /** A channel that takes new posts, threads, and followers: it exists, and its project is not archived. */
6521
+ /**
6522
+ * A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
6523
+ * and its project is not archived.
6524
+ */
6089
6525
  async assertChannelOpen(ref) {
6090
- await this.assertChannelExists(ref);
6526
+ if (await this.assertChannelExists(ref) !== void 0) {
6527
+ throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
6528
+ }
6091
6529
  const { project } = parseChannelRef(ref);
6092
6530
  if (project !== null) {
6093
6531
  await this.readActiveProject(project);
@@ -6187,6 +6625,10 @@ ${charter.purpose}
6187
6625
  if (outcome === "approved") {
6188
6626
  await this.validateProvision(proposal.kind, proposal.charter);
6189
6627
  }
6628
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
6629
+ if (outcome === "approved" && proposal.kind === "archive") {
6630
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
6631
+ }
6190
6632
  const ts = this.now().toISOString();
6191
6633
  const decision = DecisionSchema.parse({
6192
6634
  id: this.newId(),
@@ -6373,6 +6815,8 @@ function decideWake(input) {
6373
6815
  return { wake: true, reason: "operations signal", priority: 0 };
6374
6816
  case "user_post":
6375
6817
  return { wake: true, reason: "user post", priority: 2 };
6818
+ case "closing":
6819
+ return { wake: true, reason: "an ended conversation left work on no branch", priority: 1 };
6376
6820
  case "heartbeat":
6377
6821
  break;
6378
6822
  }
@@ -6388,6 +6832,16 @@ function decideWake(input) {
6388
6832
 
6389
6833
  // packages/scheduler/dist/scheduler.js
6390
6834
  import { z as z13 } from "zod";
6835
+ function besideFields(beside) {
6836
+ if (beside === void 0) {
6837
+ return {};
6838
+ }
6839
+ return "thread" in beside ? { thread: beside.thread } : { channel: beside.channel };
6840
+ }
6841
+ function conversationKey2(agent, scope, beside) {
6842
+ const fields = besideFields(beside);
6843
+ return sessionKey(agent, scope, conversationPart({ thread: fields.thread?.id, channel: fields.channel }));
6844
+ }
6391
6845
  var DEFAULT_TIMINGS = Object.freeze({
6392
6846
  pollMs: 1e3,
6393
6847
  debounceMs: 3e4,
@@ -6503,13 +6957,22 @@ var Scheduler = class {
6503
6957
  now;
6504
6958
  log;
6505
6959
  pending = /* @__PURE__ */ new Map();
6506
- /** Turns in flight by session key, each with the lane it holds (see `laneOf`). */
6960
+ /**
6961
+ * Turns in flight by session key. A conversation runs one turn at a time, and different
6962
+ * conversations side by side, since each thread's works in a place of its own.
6963
+ */
6507
6964
  running = /* @__PURE__ */ new Map();
6508
6965
  /** Steers under way, by session key; a pass does not wait for a runner to answer one. */
6509
6966
  steering = /* @__PURE__ */ new Map();
6510
6967
  /** Tasks whose completion effect waits for the turn that finished their last stage to end. */
6511
6968
  completions = /* @__PURE__ */ new Map();
6512
6969
  completing = /* @__PURE__ */ new Set();
6970
+ /** Running turns that must run alone among their citizen's, by session key: see `heldBack`. */
6971
+ exclusive = /* @__PURE__ */ new Map();
6972
+ /** Tasks being landed now; a turn in a task's thread waits until its landing is done. */
6973
+ landing = /* @__PURE__ */ new Set();
6974
+ /** Sweeps of ended conversations' workspaces under way, which `drain` and `stop` wait for. */
6975
+ sweeping = /* @__PURE__ */ new Set();
6513
6976
  state = StateSchema.parse({
6514
6977
  cursor: null,
6515
6978
  lastHeartbeat: {}
@@ -6560,9 +7023,10 @@ var Scheduler = class {
6560
7023
  }
6561
7024
  await this.passing?.catch(() => void 0);
6562
7025
  await Promise.allSettled([
6563
- ...[...this.running.values()].map((turn) => turn.promise),
7026
+ ...this.running.values(),
6564
7027
  ...this.completing,
6565
- ...this.steering.values()
7028
+ ...this.steering.values(),
7029
+ ...this.sweeping
6566
7030
  ]);
6567
7031
  }
6568
7032
  /** One pass, unless one is under way already. */
@@ -6610,8 +7074,9 @@ var Scheduler = class {
6610
7074
  async drain() {
6611
7075
  await Promise.allSettled([
6612
7076
  ...this.steering.values(),
6613
- ...[...this.running.values()].map((turn) => turn.promise),
6614
- ...this.completing
7077
+ ...this.running.values(),
7078
+ ...this.completing,
7079
+ ...this.sweeping
6615
7080
  ]);
6616
7081
  }
6617
7082
  async load() {
@@ -6659,7 +7124,7 @@ var Scheduler = class {
6659
7124
  reason: `mentioned by ${event.actor}`,
6660
7125
  ...messageId === void 0 ? {} : { messageId },
6661
7126
  ...channel === null ? {} : { channel }
6662
- }, now, threadId === void 0 ? void 0 : await this.conversationOf(threadId, scope));
7127
+ }, now, await this.postedIn(scope, channel, threadId));
6663
7128
  }
6664
7129
  const step = payload["step"];
6665
7130
  if (event.actor === USER_NAME && (step === void 0 || step === null)) {
@@ -6714,6 +7179,21 @@ var Scheduler = class {
6714
7179
  if (creator !== null && creator !== event.actor) {
6715
7180
  await this.wakeCreator(taskId, project, creator, event.actor, now);
6716
7181
  }
7182
+ const threaded = await this.board.readThread(taskId).then(() => true, () => false);
7183
+ if (!threaded) {
7184
+ this.sweep({ kind: "thread", id: taskId });
7185
+ }
7186
+ return;
7187
+ }
7188
+ case "thread.closed": {
7189
+ const threadId = stringOf(payload["threadId"]);
7190
+ if (threadId !== null) {
7191
+ this.sweep({ kind: "thread", id: threadId });
7192
+ }
7193
+ return;
7194
+ }
7195
+ case "workspace.leftovers": {
7196
+ await this.wakeClosing(payload, now);
6717
7197
  return;
6718
7198
  }
6719
7199
  case "task.updated": {
@@ -6791,6 +7271,26 @@ var Scheduler = class {
6791
7271
  await this.wakeNewcomer(name, project, now);
6792
7272
  return;
6793
7273
  }
7274
+ case "channel.archived": {
7275
+ const channel = stringOf(payload["channel"]);
7276
+ if (channel === null) {
7277
+ return;
7278
+ }
7279
+ const archived = parseChannelRef(channel);
7280
+ this.sweep({
7281
+ kind: "channel",
7282
+ scope: archived.project ?? SOCIETY_SCOPE,
7283
+ name: archived.channel
7284
+ });
7285
+ for (const [key, item] of this.pending) {
7286
+ const { dispatch } = item;
7287
+ if (dispatch.channel !== void 0 && channelConversation(dispatch.project, channel) === dispatch.channel) {
7288
+ this.pending.delete(key);
7289
+ this.log.info({ agent: dispatch.agent, project: dispatch.project, channel }, "dropping channel wake, the channel is archived");
7290
+ }
7291
+ }
7292
+ return;
7293
+ }
6794
7294
  case "agent.left": {
6795
7295
  const name = stringOf(payload["name"]);
6796
7296
  const project = stringOf(payload["project"]);
@@ -6861,7 +7361,7 @@ var Scheduler = class {
6861
7361
  reason: `the user posted in ${channel ?? "a channel"}`,
6862
7362
  ...messageId === void 0 ? {} : { messageId },
6863
7363
  ...channel === null ? {} : { channel }
6864
- }, now, threadId === void 0 ? void 0 : await this.conversationOf(threadId, scope));
7364
+ }, now, await this.postedIn(scope, channel, threadId));
6865
7365
  }
6866
7366
  }
6867
7367
  /**
@@ -6935,7 +7435,7 @@ var Scheduler = class {
6935
7435
  from,
6936
7436
  reason: `stage "${name}" of task ${task.id} "${task.title}" ${state}`,
6937
7437
  taskId: task.id
6938
- }, now, { id: task.id, task: scope === task.project });
7438
+ }, now, { thread: { id: task.id, task: scope === task.project } });
6939
7439
  return sessionKey(agent.name, scope, task.id);
6940
7440
  }
6941
7441
  /**
@@ -6996,6 +7496,49 @@ var Scheduler = class {
6996
7496
  const scope = this.scopeForProject(agent, project);
6997
7497
  this.enqueue(agent.name, scope, { kind: "task_done", from, reason: `task ${taskId} you created is done`, taskId }, now);
6998
7498
  }
7499
+ /**
7500
+ * Whether a dispatch is in the thread of a task whose landing is queued or under way: it waits,
7501
+ * so a stream of turns there cannot hold the landing off, nor commit to the branch during it.
7502
+ */
7503
+ landingIn(dispatch) {
7504
+ const thread = dispatch.thread?.id;
7505
+ return thread !== void 0 && (this.completions.has(thread) || this.landing.has(thread));
7506
+ }
7507
+ /** Asks the runners to remove an ended conversation's workspaces, without holding up the pass. */
7508
+ sweep(conversation) {
7509
+ const sweep = this.runner.sweep?.bind(this.runner);
7510
+ if (sweep === void 0) {
7511
+ return;
7512
+ }
7513
+ const promise = sweep(conversation).catch((error) => {
7514
+ this.log.warn({ conversation, error: String(error) }, "sweeping workspaces failed");
7515
+ }).finally(() => {
7516
+ this.sweeping.delete(promise);
7517
+ });
7518
+ this.sweeping.add(promise);
7519
+ }
7520
+ /**
7521
+ * A citizen's workspace in a conversation that has ended holds work on no branch: one closing turn
7522
+ * there, in the same session and workspace, decides whether it matters and whose it is.
7523
+ */
7524
+ async wakeClosing(payload, now) {
7525
+ const name = stringOf(payload["agent"]);
7526
+ const scope = stringOf(payload["project"]);
7527
+ const thread = stringOf(payload["thread"]);
7528
+ const channel = stringOf(payload["channel"]);
7529
+ const agent = name === null ? null : await this.tryReadAgent(name);
7530
+ if (agent === null || scope === null) {
7531
+ return;
7532
+ }
7533
+ const beside = thread !== null ? { thread: await this.conversationOf(thread, scope) } : channel !== null ? { channel } : null;
7534
+ if (beside === null) {
7535
+ return;
7536
+ }
7537
+ this.enqueue(agent.name, scope, {
7538
+ kind: "closing",
7539
+ reason: "a conversation you took part in has ended, and its workspace holds work on no branch"
7540
+ }, now, beside);
7541
+ }
6999
7542
  /** A decided proposal wakes the citizen that made it, to carry on with what it enables. */
7000
7543
  async wakeProposer(proposalId, proposer, event, now) {
7001
7544
  const agent = await this.tryReadAgent(proposer);
@@ -7025,6 +7568,17 @@ var Scheduler = class {
7025
7568
  return { id: threadId, task: false };
7026
7569
  }
7027
7570
  }
7571
+ /**
7572
+ * The conversation a post wakes a citizen in, for a turn in `scope`: its thread's, else its
7573
+ * channel's when that is a channel of the scope other than general, else the scope's home.
7574
+ */
7575
+ async postedIn(scope, channel, threadId) {
7576
+ if (threadId !== void 0) {
7577
+ return { thread: await this.conversationOf(threadId, scope) };
7578
+ }
7579
+ const name = channel === null ? void 0 : channelConversation(scope, channel);
7580
+ return name === void 0 ? void 0 : { channel: name };
7581
+ }
7028
7582
  async tryReadAgent(name) {
7029
7583
  try {
7030
7584
  const agent = await this.board.readAgent(name);
@@ -7040,8 +7594,8 @@ var Scheduler = class {
7040
7594
  scopeForProject(agent, project) {
7041
7595
  return wakeScope(agent, project);
7042
7596
  }
7043
- /** Queues a wake in one conversation: a thread's, or without one, the scope's home. */
7044
- enqueue(agent, project, input, now, thread) {
7597
+ /** Queues a wake in one conversation: a thread's, a channel's, or without either, the scope's home. */
7598
+ enqueue(agent, project, input, now, beside) {
7045
7599
  const trigger = TriggerSchema.parse(input);
7046
7600
  const decision = decideWake({
7047
7601
  trigger,
@@ -7054,7 +7608,7 @@ var Scheduler = class {
7054
7608
  return;
7055
7609
  }
7056
7610
  const debounce = this.debounceFor(trigger);
7057
- const key = sessionKey(agent, project, thread?.id);
7611
+ const key = conversationKey2(agent, project, beside);
7058
7612
  const readyAt = now + debounce;
7059
7613
  const existing = this.pending.get(key);
7060
7614
  const message = messageOf(trigger);
@@ -7063,7 +7617,7 @@ var Scheduler = class {
7063
7617
  dispatch: {
7064
7618
  agent,
7065
7619
  project,
7066
- ...thread === void 0 ? {} : { thread },
7620
+ ...besideFields(beside),
7067
7621
  trigger,
7068
7622
  priority: decision.priority,
7069
7623
  onboarding: trigger.kind === "onboarding"
@@ -7113,7 +7667,8 @@ var Scheduler = class {
7113
7667
  }
7114
7668
  /**
7115
7669
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
7116
- * scope: the unread messages of its home and of each thread, a stage the member holds, and a stage
7670
+ * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
7671
+ * member holds, and a stage
7117
7672
  * waiting that it may take, each in its task's thread. Each conversation with something wakes on
7118
7673
  * its own, so a heartbeat never brings one task's business into another's turn.
7119
7674
  */
@@ -7144,31 +7699,31 @@ var Scheduler = class {
7144
7699
  unread ??= await this.board.unreadByConversation(actor);
7145
7700
  held ??= await this.board.heldClaims(agent.name);
7146
7701
  const reasons = /* @__PURE__ */ new Map();
7147
- const reasonsIn = (thread) => {
7148
- const key = sessionKey(agent.name, project, thread?.id);
7702
+ const reasonsIn = (beside) => {
7703
+ const key = conversationKey2(agent.name, project, beside);
7149
7704
  const existing = reasons.get(key);
7150
7705
  if (existing !== void 0) {
7151
7706
  return existing;
7152
7707
  }
7153
- const fresh = { thread, digestSize: 0, claimsHeld: 0, waiting: 0 };
7708
+ const fresh = { beside, digestSize: 0, claimsHeld: 0, waiting: 0 };
7154
7709
  reasons.set(key, fresh);
7155
7710
  return fresh;
7156
7711
  };
7157
7712
  for (const entry of unread) {
7158
7713
  if (entry.scope === project) {
7159
- const thread = entry.thread === null ? void 0 : await this.conversationOf(entry.thread, project);
7160
- reasonsIn(thread).digestSize += entry.count;
7714
+ const beside = entry.thread !== null ? { thread: await this.conversationOf(entry.thread, project) } : entry.channel !== null ? { channel: entry.channel } : void 0;
7715
+ reasonsIn(beside).digestSize += entry.count;
7161
7716
  }
7162
7717
  }
7163
7718
  for (const task of held) {
7164
7719
  if (task.project === project) {
7165
- reasonsIn({ id: task.id, task: true }).claimsHeld += 1;
7720
+ reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
7166
7721
  }
7167
7722
  }
7168
7723
  if (project !== SOCIETY_SCOPE) {
7169
7724
  for (const task of await this.board.openTasks(project)) {
7170
7725
  if (mayHoldStage(agent, task)) {
7171
- reasonsIn({ id: task.id, task: true }).waiting += 1;
7726
+ reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
7172
7727
  }
7173
7728
  }
7174
7729
  }
@@ -7189,7 +7744,7 @@ var Scheduler = class {
7189
7744
  dispatch: {
7190
7745
  agent: agent.name,
7191
7746
  project,
7192
- ...reason.thread === void 0 ? {} : { thread: reason.thread },
7747
+ ...besideFields(reason.beside),
7193
7748
  trigger,
7194
7749
  priority: decision.priority,
7195
7750
  onboarding: false
@@ -7227,8 +7782,8 @@ var Scheduler = class {
7227
7782
  this.state.lastReflection[agent.name] = iso(now);
7228
7783
  continue;
7229
7784
  }
7230
- const key = sessionKey(agent.name, latest.scope);
7231
- if (this.pending.has(key) || this.laneBusy(key)) {
7785
+ const busy = [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent.name);
7786
+ if (busy) {
7232
7787
  continue;
7233
7788
  }
7234
7789
  this.state.lastReflection[agent.name] = iso(now);
@@ -7526,22 +8081,6 @@ var Scheduler = class {
7526
8081
  this.state.costSinceReport = 0;
7527
8082
  this.state.turnsSinceReport = 0;
7528
8083
  }
7529
- /**
7530
- * The lane a turn runs in: a task's conversation has its own, with a worktree of its own; a
7531
- * member's home conversation and its proposal and topic threads in a scope share the scope's
7532
- * worktree, and so one lane, one turn at a time.
7533
- */
7534
- laneOf(dispatch) {
7535
- return dispatch.thread?.task === true ? sessionKey(dispatch.agent, dispatch.project, dispatch.thread.id) : sessionKey(dispatch.agent, dispatch.project);
7536
- }
7537
- laneBusy(lane) {
7538
- for (const turn of this.running.values()) {
7539
- if (turn.lane === lane) {
7540
- return true;
7541
- }
7542
- }
7543
- return false;
7544
- }
7545
8084
  /**
7546
8085
  * Delivers ready message wakes into the turn already running in their conversation, rather than
7547
8086
  * keeping them for a turn of their own. Each stays queued until that turn ends and is checked
@@ -7561,7 +8100,8 @@ var Scheduler = class {
7561
8100
  const attempt = steer({
7562
8101
  agent: dispatch.agent,
7563
8102
  project: dispatch.project,
7564
- ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id }
8103
+ ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
8104
+ ...dispatch.channel === void 0 ? {} : { channel: dispatch.channel }
7565
8105
  }).then((outcome) => {
7566
8106
  const current = this.pending.get(key);
7567
8107
  if (outcome !== "busy" && current !== void 0) {
@@ -7582,18 +8122,17 @@ var Scheduler = class {
7582
8122
  return false;
7583
8123
  }
7584
8124
  const { dispatch } = item;
7585
- const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, dispatch.thread?.id);
8125
+ const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, conversationPart({ thread: dispatch.thread?.id, channel: dispatch.channel }));
7586
8126
  return cursor !== null && cursor >= item.newestMessage;
7587
8127
  }
7588
8128
  async dispatchReady(now) {
7589
8129
  this.steerRunning(now);
7590
- const ready = [...this.pending.entries()].filter(([key, item]) => item.readyAt <= now && !this.running.has(key)).toSorted(([, a], [, b]) => b.dispatch.priority - a.dispatch.priority || a.readyAt - b.readyAt);
8130
+ const ready = [...this.pending.entries()].filter(([key, item]) => item.readyAt <= now && !this.running.has(key) && !this.landingIn(item.dispatch)).toSorted(([, a], [, b]) => b.dispatch.priority - a.dispatch.priority || a.readyAt - b.readyAt);
7591
8131
  for (const [key, item] of ready) {
7592
8132
  if (this.running.size >= this.concurrency) {
7593
8133
  break;
7594
8134
  }
7595
- const lane = this.laneOf(item.dispatch);
7596
- if (this.laneBusy(lane)) {
8135
+ if (this.heldBack(key, item.dispatch)) {
7597
8136
  continue;
7598
8137
  }
7599
8138
  this.pending.delete(key);
@@ -7619,6 +8158,7 @@ var Scheduler = class {
7619
8158
  agent: dispatch.agent,
7620
8159
  project: dispatch.project,
7621
8160
  thread: dispatch.thread?.id,
8161
+ channel: dispatch.channel,
7622
8162
  trigger: dispatch.trigger.kind
7623
8163
  }, "dispatching turn");
7624
8164
  const promise = (async () => {
@@ -7634,19 +8174,45 @@ var Scheduler = class {
7634
8174
  this.log.error({ agent: dispatch.agent, project: dispatch.project, error: String(error) }, "turn crashed");
7635
8175
  } finally {
7636
8176
  this.running.delete(key);
8177
+ this.exclusive.delete(key);
7637
8178
  }
7638
8179
  })();
7639
- this.running.set(key, { lane, promise });
8180
+ this.running.set(key, promise);
8181
+ if (dispatch.onboarding) {
8182
+ this.exclusive.set(key, "onboarding");
8183
+ } else if (dispatch.trigger.kind === "reflection") {
8184
+ this.exclusive.set(key, "reflection");
8185
+ }
8186
+ }
8187
+ }
8188
+ /**
8189
+ * Whether a ready turn waits for another of its citizen's turns that must run alone: its
8190
+ * onboarding in the project, queued or running, which comes before any other work of its there;
8191
+ * and a reflection, which consolidates the memory the citizen's other turns would be writing, so
8192
+ * it starts only once none of them runs and they wait while it does.
8193
+ */
8194
+ heldBack(key, dispatch) {
8195
+ const { agent, project } = dispatch;
8196
+ const home = sessionKey(agent, project);
8197
+ if (key !== home && (this.pending.get(home)?.dispatch.onboarding === true || this.exclusive.get(home) === "onboarding")) {
8198
+ return true;
8199
+ }
8200
+ const others = [...this.running.keys()].filter((other) => other !== key && parseSessionKey(other)?.agent === agent);
8201
+ if (dispatch.trigger.kind === "reflection") {
8202
+ return others.length > 0;
7640
8203
  }
8204
+ return others.some((other) => this.exclusive.get(other) === "reflection");
7641
8205
  }
7642
8206
  /** Starts each queued completion once the turn that finished the task's last stage has ended. */
7643
8207
  runCompletions() {
7644
8208
  for (const [taskId, item] of this.completions) {
7645
8209
  const home = sessionKey(item.actor, item.project);
7646
- if ([...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`))) {
8210
+ const busy = [...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`) || parseSessionKey(key)?.thread === taskId);
8211
+ if (busy) {
7647
8212
  continue;
7648
8213
  }
7649
8214
  this.completions.delete(taskId);
8215
+ this.landing.add(taskId);
7650
8216
  const promise = this.runner.completeTask(item.project, taskId).then((outcome) => {
7651
8217
  if (outcome === "deferred" && !this.completions.has(taskId)) {
7652
8218
  this.completions.set(taskId, item);
@@ -7656,6 +8222,7 @@ var Scheduler = class {
7656
8222
  this.log.error({ project: item.project, taskId, error: String(error) }, "completion failed");
7657
8223
  }).finally(() => {
7658
8224
  this.completing.delete(promise);
8225
+ this.landing.delete(taskId);
7659
8226
  });
7660
8227
  this.completing.add(promise);
7661
8228
  }
@@ -7667,16 +8234,17 @@ import { randomUUID } from "node:crypto";
7667
8234
 
7668
8235
  // packages/turn-host/dist/render.js
7669
8236
  var TURN_CONTRACT = [
7670
- "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its channels and for work tied to no thread, or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; at home, the channel messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other threads' and other projects' work to their own turns.",
8237
+ "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's or at home, that channel's or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
7671
8238
  "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
7672
- "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task.",
8239
+ "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task; nothing lands from it by itself. In a proposal's, a topic's, or a channel's conversation it is a worktree of its own, detached at the project's default branch, which goes when the thread closes or the channel is archived, so work that should last goes on a task's branch. Before each turn the runner fetches the project's remote and brings what holds no work of yours up to the default branch: your own branch when it has no commits of its own, a task's branch nobody has worked on, and a detached worktree with nothing uncommitted; what it leaves where it is, and why, is under Your workspace in the prompt. It never merges or commits for you.",
7673
8240
  "- Posts that would wake this conversation may arrive while you work, delivered into the turn under a heading that says so and formatted like the digest. They are part of this turn: act on them now, after the step in hand, or not at all, as with the digest. The user may also stop a turn; the next turn is told.",
7674
8241
  "- Silence is allowed. If the digest needs no reply, post nothing.",
7675
8242
  "- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
7676
- "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on its project's general channel: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
8243
+ "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
7677
8244
  "- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; a durable fact about a project's codebase or process goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
7678
8245
  '- A procedure you have followed twice is a skill: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Propose it with kind "skill" when the whole society would use it.',
7679
8246
  "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill, so a warm session restarts with the new instructions.",
8247
+ "- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
7680
8248
  "- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
7681
8249
  "- End every turn with the status object: summary, claims held, what is blocked, and whether the user must decide. If you report that the user must decide, mentioned @user nowhere this turn, and proposed nothing for the user to decide, the runner asks in this turn's thread when it has one, and otherwise opens a thread on your scope's general channel with your summary as the question."
7682
8250
  ].join("\n");
@@ -7698,7 +8266,7 @@ var GOVERNANCE = [
7698
8266
  ' - archive: {"project", "reason"}: approval archives the project: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay; refused while a task there is in play',
7699
8267
  ' - reallocation: {"description"}',
7700
8268
  ` - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, where every citizen's skills index lists it`,
7701
- "- Projects and membership: a project is a lasting area of work, not one request; `create_project` opens one with its general channel (front desk and user), and `configure_project` sets its completion effect, its display name, or its default branch, never its slug (user, steward, concierge). A project whose work is finished or has moved to another project is archived by proposal, once no task there is in play. `join_project` and `leave_project` move yourself, or another citizen when you are the concierge, the steward, or the user; joining gives the pair a worktree and an onboarding turn. A society role, one that keeps watch over the whole society as the concierge and the steward do, is never in a project: it moves others, never itself.",
8269
+ "- Projects and membership: a project is a lasting area of work, not one request; `create_project` opens one with its general channel (front desk and user), and `configure_project` sets its completion effect, its display name, or its default branch, never its slug (user, steward, concierge). A workstream within a project or the society, such as a release, gets a channel of its own: `create_channel` opens it, which its opener follows, and announces it in the place's general, where members subscribe when it concerns them, `create_task` with `channel` files the workstream's tasks there, and `archive_channel` ends it once none of its tasks is in play, leaving its history readable (user, steward, concierge; others propose a channel). A project whose work is finished or has moved to another project is archived by proposal, once no task there is in play. `join_project` and `leave_project` move yourself, or another citizen when you are the concierge, the steward, or the user; joining gives the pair a worktree and an onboarding turn. A society role, one that keeps watch over the whole society as the concierge and the steward do, is never in a project: it moves others, never itself.",
7702
8270
  "- Prefer scaling an existing role over inventing one; a new role is justified by work a project needs that no existing role covers. A role that needs a tool the board lacks is an engineering task, not a hiring request.",
7703
8271
  "- Operations signals are counters and timers the board logs, listed in the prompt of a role that reads them: stages waiting for a holder, backlog per role, stages waiting on a role nobody fills, churn, stale threads, idle members, missing capabilities, replicas added, spend. They are not posts in any channel; interpreting them is your judgment."
7704
8272
  ].join("\n");
@@ -7709,6 +8277,9 @@ function renderOnboardingPreamble(context) {
7709
8277
  `Your role in one paragraph: ${context.roleSummary}`,
7710
8278
  "",
7711
8279
  `You are working on project "${context.project}" in the worktree at ${context.worktree}.`,
8280
+ ...(context.otherChannels ?? []).length === 0 ? [] : [
8281
+ `You follow its general channel. It also has ${(context.otherChannels ?? []).map((ref) => `#${ref}`).join(", ")}, each a workstream such as a release: subscribe to one when your work there needs its posts. Its tasks and mentions reach you either way.`
8282
+ ],
7712
8283
  "Your memory is empty. Write durable lessons about yourself, your craft, or the user to memory/core.md in your home directory.",
7713
8284
  "Write profile.md in your home directory: one paragraph on what you do well and what to send your way. The roster the front desk routes with is built from it.",
7714
8285
  "Read the project's instructions file, if it has one, before doing anything else.",
@@ -7734,7 +8305,7 @@ function renderInstructions(input) {
7734
8305
  if (norms.length > 0) {
7735
8306
  sections.push("", "## Society norms", "", norms);
7736
8307
  }
7737
- sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project: it is never kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
8308
+ sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's or a channel's conversation there works in a folder of its own inside it, which goes when the thread closes or the channel is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
7738
8309
  if (input.onboarding !== void 0) {
7739
8310
  sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
7740
8311
  }
@@ -7871,8 +8442,11 @@ function buildTurnPrompt(input) {
7871
8442
  }
7872
8443
  const conversation = input.conversation ?? null;
7873
8444
  lines.push("", "## This conversation", "");
7874
- if (conversation === null) {
7875
- lines.push(`This turn is in your home conversation for ${dispatch.project}: its channels, and work tied to no thread. Each thread you take part in, a task's, a proposal's, or a topic's, is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn.`);
8445
+ if (conversation === null && dispatch.channel !== void 0) {
8446
+ const ref = channelRef(dispatch.project === SOCIETY_SCOPE ? null : dispatch.project, dispatch.channel);
8447
+ lines.push(`This turn is in your conversation of ${ref}, a workstream with turns of its own: its posts, and the work it asks for. Your home conversation for ${dispatch.project} carries its general channel, and each other channel and each thread you take part in is a conversation of its own; read one when you need it, and leave its work to its turn. Post here with post_message and channel ${ref}.`);
8448
+ } else if (conversation === null) {
8449
+ lines.push(`This turn is in your home conversation for ${dispatch.project}: its general channel, and work tied to no other channel and no thread. Each other channel, a workstream, and each thread you take part in, a task's, a proposal's, or a topic's, is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn.`);
7876
8450
  } else {
7877
8451
  const { thread, task } = conversation;
7878
8452
  const about = thread.subject === void 0 ? "a topic" : `${thread.subject.kind} ${thread.subject.id}`;
@@ -7884,6 +8458,11 @@ function buildTurnPrompt(input) {
7884
8458
  }
7885
8459
  }
7886
8460
  }
8461
+ const ending = input.ending ?? null;
8462
+ if (ending !== null) {
8463
+ lines.push("", "## This conversation has ended", "", `${capitalized(ending.how)}. You are woken once more because your workspace here still holds work on no branch, listed under Your workspace, and it is removed when this turn ends, whatever it still holds. You may not be the one who ended the conversation, and the work may not be yours to finish, so decide for each piece:`, "- It is yours and it matters: land it through a task. File one, or use one you hold, switch this worktree to its branch task/<id> (git switch -c task/<id> for a task filed in this turn), and commit there.", `- It matters but is someone else's to finish: put it on a task's branch the same way, then hand that task to whoever should own it and ask them in ${ending.askIn} with a mention, since this conversation takes no more posts.`, "- It is disposable: leave it, and it goes with the workspace.");
8464
+ }
8465
+ lines.push("", WORKSPACE_TOKEN);
7887
8466
  const conflicts = input.conflicts ?? [];
7888
8467
  if (dispatch.trigger.kind === "reflection") {
7889
8468
  lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS);
@@ -7900,7 +8479,7 @@ function buildTurnPrompt(input) {
7900
8479
  }
7901
8480
  const society = input.societyView;
7902
8481
  if (society !== void 0 && society !== null) {
7903
- lines.push("", "## The society", "", "### Projects", "");
8482
+ lines.push("", "## The society", "", `Each ask is a conversation of its own, and others may be in a turn at the same time. Before proposing a citizen, a role, or a project, look at the proposals still waiting under ${PATH_TOKENS.board}/society/proposals/: another ask may have proposed it already.`, "", "### Projects", "");
7904
8483
  if (society.projects.length === 0) {
7905
8484
  lines.push("None yet.");
7906
8485
  }
@@ -7982,6 +8561,9 @@ function buildTurnPrompt(input) {
7982
8561
  return `${lines.join("\n")}
7983
8562
  `;
7984
8563
  }
8564
+ function capitalized(text) {
8565
+ return text.charAt(0).toUpperCase() + text.slice(1);
8566
+ }
7985
8567
 
7986
8568
  // packages/turn-host/dist/host.js
7987
8569
  var SILENT = { info() {
@@ -8037,11 +8619,14 @@ var TurnHost = class {
8037
8619
  const agent = await this.board.readAgent(dispatch.agent);
8038
8620
  const actor = { name: agent.name, role: agent.role };
8039
8621
  const thread = dispatch.thread;
8622
+ const channel = thread === void 0 ? dispatch.channel : void 0;
8623
+ const part = conversationPart({ thread: thread?.id, channel });
8040
8624
  const startedAt = this.now().toISOString();
8041
8625
  const base = {
8042
8626
  agent: agent.name,
8043
8627
  project: dispatch.project,
8044
8628
  ...thread === void 0 ? {} : { thread: thread.id },
8629
+ ...channel === void 0 ? {} : { channel },
8045
8630
  runner: runner.name,
8046
8631
  cli: agent.cli,
8047
8632
  session: null,
@@ -8066,23 +8651,29 @@ var TurnHost = class {
8066
8651
  const societyScope = dispatch.project === SOCIETY_SCOPE;
8067
8652
  const taskId = thread?.task === true ? thread.id : void 0;
8068
8653
  const project = societyScope ? null : await this.board.readProject(dispatch.project);
8069
- const workspace = await this.workspaceOf(project, taskId);
8070
- const sessions = await this.board.readSessions(agent.name, dispatch.project, thread?.id);
8654
+ const workspace = await this.workspaceOf(project, thread, channel);
8655
+ const sessions = await this.board.readSessions(agent.name, dispatch.project, part);
8071
8656
  const recorded = sessions.runner === runner.name ? sessions[agent.cli] : void 0;
8072
8657
  const newSession = recorded === void 0;
8073
- const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, thread?.id);
8658
+ const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
8074
8659
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
8075
- const digest = await this.board.readDigest({ ...actor, scope: dispatch.project, ...thread === void 0 ? {} : { thread: thread.id } }, { advance: false, limit: thread === void 0 ? 50 : 100 });
8660
+ const digest = await this.board.readDigest({
8661
+ ...actor,
8662
+ scope: dispatch.project,
8663
+ ...thread === void 0 ? {} : { thread: thread.id },
8664
+ ...channel === void 0 ? {} : { channel }
8665
+ }, { advance: false, limit: thread === void 0 ? 50 : 100 });
8076
8666
  const threadSoFar = thread !== void 0 && newSession ? (await this.board.listThread(thread.id).catch(() => [])).slice(-100) : null;
8077
8667
  const messages = threadSoFar ?? digest.messages;
8078
8668
  const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
8079
8669
  const task = taskId === void 0 ? null : await this.board.findTask(taskId).then((found) => found.task).catch(() => null);
8080
8670
  const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
8081
- const onboarding = dispatch.onboarding || newSession && thread === void 0 ? {
8671
+ const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
8082
8672
  agentName: agent.name,
8083
8673
  roleSummary: charter.purpose,
8084
8674
  project: dispatch.project,
8085
- worktree: PATH_TOKENS.worktree
8675
+ worktree: PATH_TOKENS.worktree,
8676
+ otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
8086
8677
  } : null;
8087
8678
  const instructions = renderInstructions({
8088
8679
  agentName: agent.name,
@@ -8118,7 +8709,7 @@ var TurnHost = class {
8118
8709
  dir: `${PATH_TOKENS.board}/projects/${dispatch.project}/knowledge`,
8119
8710
  topics: await this.board.listKnowledge(dispatch.project)
8120
8711
  };
8121
- const signals = thread === void 0 && charter.wakeTriggers.includes(OPS_TRIGGER2) ? (await this.board.listSignals(500)).filter((record2) => wakeScope(agent, record2.signal.project ?? null) === dispatch.project && (lastTurn === null || record2.ts > lastTurn.startedAt)).slice(-MAX_SIGNALS) : null;
8712
+ const signals = part === void 0 && charter.wakeTriggers.includes(OPS_TRIGGER2) ? (await this.board.listSignals(500)).filter((record2) => wakeScope(agent, record2.signal.project ?? null) === dispatch.project && (lastTurn === null || record2.ts > lastTurn.startedAt)).slice(-MAX_SIGNALS) : null;
8122
8713
  const waiting = task !== null && task.status === "open" && !task.completing && mayHoldStage(actor, task) ? [task] : [];
8123
8714
  const threads = /* @__PURE__ */ new Map();
8124
8715
  for (const id of new Set(messages.flatMap((message) => message.thread ?? []))) {
@@ -8129,11 +8720,13 @@ var TurnHost = class {
8129
8720
  }
8130
8721
  const threadRecord = thread === void 0 ? void 0 : threads.get(thread.id) ?? await this.board.readThread(thread.id).catch(() => void 0);
8131
8722
  const conversation = threadRecord === void 0 ? null : { thread: threadRecord, task, fresh: threadSoFar !== null };
8723
+ const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
8132
8724
  const prompt = buildTurnPrompt({
8133
8725
  dispatch,
8134
8726
  messages,
8135
8727
  threads,
8136
8728
  conversation,
8729
+ ending,
8137
8730
  heldClaims: held,
8138
8731
  waitingStages: waiting,
8139
8732
  project,
@@ -8145,9 +8738,9 @@ var TurnHost = class {
8145
8738
  signals,
8146
8739
  conflicts: await this.board.listHomeConflicts(agent.name)
8147
8740
  });
8148
- const resident = charter.resident && thread === void 0 && runner.residentClis.includes(agent.cli);
8741
+ const resident = charter.resident && part === void 0 && runner.residentClis.includes(agent.cli);
8149
8742
  const residentKey = resident ? sessionKey(agent.name, dispatch.project) : null;
8150
- const token = residentKey === null ? this.board.issueTurnToken(agent.name, agent.role, this.tokenLifetime(5 * 6e4), dispatch.project, thread?.id) : this.residentToken(runner, residentKey, actor, dispatch.project);
8743
+ const token = residentKey === null ? this.board.issueTurnToken(agent.name, agent.role, this.tokenLifetime(5 * 6e4), dispatch.project, part) : this.residentToken(runner, residentKey, actor, dispatch.project);
8151
8744
  const record = await this.board.beginTurn({
8152
8745
  ...base,
8153
8746
  session: recorded ?? null,
@@ -8165,6 +8758,7 @@ var TurnHost = class {
8165
8758
  ...agent.effort === void 0 ? {} : { effort: agent.effort },
8166
8759
  scope: dispatch.project,
8167
8760
  ...thread === void 0 ? {} : { thread: thread.id },
8761
+ ...channel === void 0 ? {} : { channel },
8168
8762
  session: recorded ?? null,
8169
8763
  costSoFarUsd,
8170
8764
  instructions,
@@ -8178,7 +8772,8 @@ var TurnHost = class {
8178
8772
  void this.board.heldClaims(agent.name).then((claims) => this.renewLeases(actor, claims.filter((each) => each.project === dispatch.project).map((each) => each.id))).catch(() => void 0);
8179
8773
  }, LEASE_RENEW_MS);
8180
8774
  keepLeases.unref?.();
8181
- const { promise: ended, resolve: done } = Promise.withResolvers();
8775
+ const { promise: ended, resolve: done, reject: fail } = Promise.withResolvers();
8776
+ ended.catch(() => void 0);
8182
8777
  this.open.set(record.id, {
8183
8778
  job,
8184
8779
  runner: runner.name,
@@ -8189,19 +8784,20 @@ var TurnHost = class {
8189
8784
  outstanding: null,
8190
8785
  stopRequestedBy: null,
8191
8786
  held: held.map((each) => each.id),
8192
- taskId,
8193
8787
  costSoFarUsd,
8194
8788
  configuredModel: agent.model ?? null,
8195
8789
  transcript: [],
8196
8790
  coldToken: residentKey === null ? token : null,
8197
8791
  residentKey: residentKey === null ? null : `${runner.name}|${residentKey}`,
8198
8792
  keepLeases,
8199
- done
8793
+ done,
8794
+ fail
8200
8795
  });
8201
8796
  this.log.info({
8202
8797
  agent: agent.name,
8203
8798
  project: dispatch.project,
8204
8799
  thread: thread?.id,
8800
+ channel,
8205
8801
  runner: runner.name,
8206
8802
  session: recorded,
8207
8803
  newSession,
@@ -8239,6 +8835,7 @@ var TurnHost = class {
8239
8835
  agent: turn.job.agent,
8240
8836
  project: turn.job.scope,
8241
8837
  thread: turn.job.thread,
8838
+ channel: turn.job.channel,
8242
8839
  messages: sent.messages
8243
8840
  }).catch((error) => {
8244
8841
  this.log.warn({ turnId, error: String(error) }, "could not record a steer");
@@ -8246,19 +8843,31 @@ var TurnHost = class {
8246
8843
  }
8247
8844
  }
8248
8845
  turn.transcript.push(entry);
8249
- this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, turn.job.thread);
8846
+ this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, {
8847
+ thread: turn.job.thread,
8848
+ channel: turn.job.channel
8849
+ });
8250
8850
  }
8251
8851
  }
8252
8852
  /** The turn in flight in a conversation, if any. */
8253
- turnIn(agent, scope, thread) {
8853
+ turnIn(agent, scope, conversation = {}) {
8254
8854
  for (const turn of this.open.values()) {
8255
8855
  const { job } = turn;
8256
- if (job.agent === agent && job.scope === scope && job.thread === thread) {
8856
+ if (job.agent === agent && job.scope === scope && job.thread === conversation.thread && job.channel === conversation.channel) {
8257
8857
  return { turnId: job.turnId, runner: turn.runner, cli: job.cli };
8258
8858
  }
8259
8859
  }
8260
8860
  return null;
8261
8861
  }
8862
+ /** The turns in flight and their conversations, which the board checks before ending one. */
8863
+ inFlight() {
8864
+ return [...this.open.values()].map(({ job }) => ({
8865
+ agent: job.agent,
8866
+ scope: job.scope,
8867
+ ...job.thread === void 0 ? {} : { thread: job.thread },
8868
+ ...job.channel === void 0 ? {} : { channel: job.channel }
8869
+ }));
8870
+ }
8262
8871
  /** The turns in flight, for the interface: their conversations and where a mention reaches them. */
8263
8872
  runningTurns() {
8264
8873
  return [...this.open.values()].map((turn) => ({
@@ -8266,8 +8875,9 @@ var TurnHost = class {
8266
8875
  agent: turn.job.agent,
8267
8876
  scope: turn.job.scope,
8268
8877
  ...turn.job.thread === void 0 ? {} : { thread: turn.job.thread },
8878
+ ...turn.job.channel === void 0 ? {} : { channel: turn.job.channel },
8269
8879
  cli: turn.job.cli,
8270
- ...turn.record.trigger.channel === void 0 ? {} : { channel: turn.record.trigger.channel },
8880
+ ...turn.record.trigger.channel === void 0 ? {} : { askedIn: turn.record.trigger.channel },
8271
8881
  runner: turn.runner
8272
8882
  }));
8273
8883
  }
@@ -8288,7 +8898,8 @@ var TurnHost = class {
8288
8898
  const digest = await this.board.readDigest({
8289
8899
  ...turn.actor,
8290
8900
  scope: job.scope,
8291
- ...job.thread === void 0 ? {} : { thread: job.thread }
8901
+ ...job.thread === void 0 ? {} : { thread: job.thread },
8902
+ ...job.channel === void 0 ? {} : { channel: job.channel }
8292
8903
  }, {
8293
8904
  ...turn.delivered === null ? {} : { since_cursor: turn.delivered },
8294
8905
  advance: false,
@@ -8335,7 +8946,7 @@ var TurnHost = class {
8335
8946
  }
8336
8947
  /**
8337
8948
  * Records a turn's end from its outcome. Returns null for a turn the host does not know, else
8338
- * whether the task the turn worked on has ended, so the runner may drop its worktree.
8949
+ * whether the task or thread the turn worked in has ended, so the runner may drop its workspace.
8339
8950
  */
8340
8951
  async closeTurn(turnId, outcome) {
8341
8952
  const turn = this.open.get(turnId);
@@ -8348,9 +8959,7 @@ var TurnHost = class {
8348
8959
  this.board.revokeTurnToken(turn.coldToken);
8349
8960
  }
8350
8961
  const { job } = turn;
8351
- if (outcome.session !== job.session) {
8352
- await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, job.thread);
8353
- }
8962
+ const part = conversationPart(job);
8354
8963
  const finished = {
8355
8964
  ...turn.record,
8356
8965
  session: outcome.session,
@@ -8366,17 +8975,52 @@ var TurnHost = class {
8366
8975
  ...outcome.work === null ? {} : { work: outcome.work },
8367
8976
  ...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {}
8368
8977
  };
8369
- if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
8370
- await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, job.thread);
8371
- await this.renewLeases(turn.actor, turn.held);
8372
- if (outcome.status?.needsUserDecision === true) {
8373
- await this.askUser(turn.actor, job.scope, job.thread, turn.record.startedAt, outcome.status.summary);
8978
+ const ended = await this.conversationEnded(job);
8979
+ const closing = turn.record.trigger.kind === "closing";
8980
+ const decided = closing && outcome.exitReason !== "error" && outcome.exitReason !== "timeout";
8981
+ const recorded = await this.recorded(turn, async () => {
8982
+ if (outcome.session !== job.session) {
8983
+ await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
8984
+ }
8985
+ if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
8986
+ await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
8987
+ await this.renewLeases(turn.actor, turn.held);
8988
+ if (outcome.status?.needsUserDecision === true) {
8989
+ await this.askUser(turn.actor, job, turn.record.startedAt, outcome.status.summary);
8990
+ }
8374
8991
  }
8992
+ await this.board.finishTurn(finished, turn.transcript);
8993
+ const conversation = ended ? workspaceConversationOf(job) : null;
8994
+ if (conversation !== null && (closing ? !decided : outcome.leftovers)) {
8995
+ const again = await this.board.recordLeftovers({
8996
+ agent: job.agent,
8997
+ scope: job.scope,
8998
+ conversation
8999
+ });
9000
+ if (!again) {
9001
+ const where = conversation.kind === "thread" ? `thread ${conversation.id}` : `channel ${conversation.name}`;
9002
+ await this.board.publishSignal({
9003
+ kind: "stuck_workspace",
9004
+ key: `stuck_workspace:${job.agent}:${job.scope}:${conversationPart(job) ?? ""}`,
9005
+ summary: `${job.agent}'s workspace in ${where} still holds work on no branch after ${CLOSING_ATTEMPTS} closing turns failed, so it stays on runner ${turn.runner}`,
9006
+ value: CLOSING_ATTEMPTS,
9007
+ agent: job.agent,
9008
+ ...job.scope === SOCIETY_SCOPE ? {} : { project: job.scope }
9009
+ });
9010
+ }
9011
+ }
9012
+ });
9013
+ if (recorded) {
9014
+ this.log.info({
9015
+ agent: job.agent,
9016
+ project: job.scope,
9017
+ thread: job.thread,
9018
+ channel: job.channel,
9019
+ exitReason: finished.exitReason
9020
+ }, "turn finished");
9021
+ turn.done(finished);
8375
9022
  }
8376
- await this.board.finishTurn(finished, turn.transcript);
8377
- this.log.info({ agent: job.agent, project: job.scope, thread: job.thread, exitReason: finished.exitReason }, "turn finished");
8378
- turn.done(finished);
8379
- return { dropWorktree: await this.taskEnded(turn.taskId) };
9023
+ return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
8380
9024
  }
8381
9025
  /** Ends a turn whose outcome will never come, as when its runner went away, as failed. */
8382
9026
  async abortTurn(turnId, reason) {
@@ -8396,9 +9040,26 @@ var TurnHost = class {
8396
9040
  error: reason,
8397
9041
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length
8398
9042
  };
8399
- await this.board.finishTurn(finished, turn.transcript);
8400
- this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
8401
- turn.done(finished);
9043
+ if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
9044
+ this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
9045
+ turn.done(finished);
9046
+ }
9047
+ }
9048
+ /**
9049
+ * Records the end of a turn already taken out of `open`, and says whether it could. The scheduler
9050
+ * holds the turn's conversation until the turn ends, and nothing would end it once it has left
9051
+ * `open`, so a turn whose end could not be recorded ends with the error rather than holding its
9052
+ * conversation for good.
9053
+ */
9054
+ async recorded(turn, record) {
9055
+ try {
9056
+ await record();
9057
+ return true;
9058
+ } catch (error) {
9059
+ this.log.error({ agent: turn.job.agent, project: turn.job.scope, error: String(error) }, "could not record the end of a turn");
9060
+ turn.fail(error);
9061
+ return false;
9062
+ }
8402
9063
  }
8403
9064
  /** Records a turn that could never start, as when no runner could take its agent's CLI. */
8404
9065
  async refuseTurn(dispatch, runner, reason) {
@@ -8407,6 +9068,7 @@ var TurnHost = class {
8407
9068
  agent: dispatch.agent,
8408
9069
  project: dispatch.project,
8409
9070
  ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
9071
+ ...dispatch.thread !== void 0 || dispatch.channel === void 0 ? {} : { channel: dispatch.channel },
8410
9072
  runner,
8411
9073
  cli: agent?.cli ?? null,
8412
9074
  session: null,
@@ -8451,43 +9113,70 @@ var TurnHost = class {
8451
9113
  this.residentTokens.set(slot, token);
8452
9114
  return token;
8453
9115
  }
8454
- async workspaceOf(project, taskId) {
9116
+ /**
9117
+ * Where a conversation works: a task's on its branch, any other thread's and a channel's in a
9118
+ * place of its own, and the home in the citizen's own.
9119
+ */
9120
+ async workspaceOf(project, thread, channel) {
9121
+ const own = thread !== void 0 ? thread.task ? {} : { thread: thread.id } : channel === void 0 ? {} : { channel };
8455
9122
  if (project === null) {
8456
- return { kind: "home" };
9123
+ return { kind: "home", ...own };
8457
9124
  }
8458
9125
  const repo = {
8459
9126
  slug: project.slug,
8460
9127
  origin: project.repo,
8461
9128
  defaultBranch: project.defaultBranch
8462
9129
  };
8463
- if (taskId !== void 0) {
8464
- return { kind: "task", repo, taskId };
9130
+ if (thread?.task === true) {
9131
+ return { kind: "task", repo, taskId: thread.id };
8465
9132
  }
8466
9133
  const branches = (await this.board.listTasks(project.slug)).filter((task) => task.status === "open" || task.status === "claimed").map((task) => `task/${task.id}`);
8467
- return { kind: "project", repo, branches };
9134
+ return { kind: "project", repo, branches, ...own };
8468
9135
  }
8469
- /** Whether a task conversation's task has ended, which frees its worktree. */
8470
- async taskEnded(taskId) {
8471
- if (taskId === void 0) {
8472
- return false;
9136
+ /**
9137
+ * Whether a thread conversation's task or thread has ended, or a channel conversation's channel
9138
+ * was archived, which frees its workspace.
9139
+ */
9140
+ async conversationEnded(job) {
9141
+ const conversation = workspaceConversationOf(job);
9142
+ return conversation !== null && await this.board.conversationEnded(conversation);
9143
+ }
9144
+ /**
9145
+ * How a closing turn's conversation ended, and where its citizen asks someone else to take over
9146
+ * work that is not its own: the thread's channel while it is open, else the place's general.
9147
+ */
9148
+ async endingOf(scope, thread, task, channel) {
9149
+ const place = scope === SOCIETY_SCOPE ? null : scope;
9150
+ if (thread !== void 0) {
9151
+ const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
9152
+ const open = await this.board.channelOpen(thread.channel);
9153
+ return {
9154
+ how: closed,
9155
+ askIn: open ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general")
9156
+ };
8473
9157
  }
8474
- try {
8475
- const { task } = await this.board.findTask(taskId);
8476
- return task.status === "done" || task.status === "abandoned";
8477
- } catch {
8478
- return false;
9158
+ if (channel === void 0) {
9159
+ return null;
8479
9160
  }
9161
+ const ref = channelRef(place, channel);
9162
+ const archived = (await this.board.listChannels()).find((each) => each.ref === ref)?.archived;
9163
+ return {
9164
+ how: archived === void 0 ? `${ref} was archived` : `${ref} was archived by ${archived.by}: ${archived.reason}`,
9165
+ askIn: channelRef(place, "general")
9166
+ };
8480
9167
  }
8481
9168
  /**
8482
9169
  * A turn that reported the user must decide asks where the question belongs, with a mention of
8483
9170
  * the user, which is what puts it before them. One that mentioned the user nowhere asks in its
8484
9171
  * own thread when it was in one that is still open, so the answer comes back to the same
8485
- * conversation, and otherwise in a thread of its own on its scope's general channel, opened as the
8486
- * citizen with its summary. One that proposed something the user decides has put its decision
8487
- * before the user already, where deciding it clears it; a mention would stay until the user
8488
- * posted beside it.
9172
+ * conversation, and otherwise in a thread of its own on its conversation's channel, or its scope's
9173
+ * general for the home, opened as the citizen with its summary. One that proposed something the
9174
+ * user decides has put its decision before the user already, where deciding it clears it; a
9175
+ * mention would stay until the user posted beside it.
8489
9176
  */
8490
- async askUser(actor, scope, threadId, since, summary) {
9177
+ async askUser(actor, job, since, summary) {
9178
+ const threadId = job.thread;
9179
+ const place = job.scope === SOCIETY_SCOPE ? null : job.scope;
8491
9180
  try {
8492
9181
  if (await this.board.hasMentioned(actor.name, USER_NAME, since)) {
8493
9182
  return;
@@ -8505,7 +9194,7 @@ var TurnHost = class {
8505
9194
  return;
8506
9195
  }
8507
9196
  const thread = await this.board.openThread(actor, {
8508
- channel: scope === SOCIETY_SCOPE ? "general" : `${scope}/general`,
9197
+ channel: channelRef(place, job.channel ?? "general"),
8509
9198
  title: `${actor.name} asks for a decision: ${summary}`.slice(0, 200)
8510
9199
  });
8511
9200
  await this.board.postMessage(actor, {
@@ -8556,6 +9245,7 @@ var DEFAULT_GRACE_MS = 2 * 6e4;
8556
9245
  var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
8557
9246
  var CONTROL_TIMEOUT_MS = 15e3;
8558
9247
  var FILE_TIMEOUT_MS = 6e4;
9248
+ var SWEEP_TIMEOUT_MS = 2 * 6e4;
8559
9249
  var RunnerHub = class {
8560
9250
  board;
8561
9251
  host;
@@ -8676,7 +9366,7 @@ var RunnerHub = class {
8676
9366
  async turnOutcome(runner, turnId, outcome) {
8677
9367
  this.assertOwns(runner, turnId);
8678
9368
  const ack = await this.host.closeTurn(turnId, outcome);
8679
- return ack ?? { dropWorktree: false };
9369
+ return ack ?? { dropWorkspace: false };
8680
9370
  }
8681
9371
  answer(runner, request, answer) {
8682
9372
  const pending = this.pending.get(request);
@@ -8691,6 +9381,25 @@ var RunnerHub = class {
8691
9381
  pending.reject(new Error(answer.error));
8692
9382
  }
8693
9383
  }
9384
+ /**
9385
+ * The workspaces a runner holds, reported when its stream opens: those of conversations that
9386
+ * ended while it was away are swept there now, as a sweep at their end would have.
9387
+ */
9388
+ heldWorkspaces(runner, workspaces) {
9389
+ const seat = this.seats.get(runner);
9390
+ if (seat === void 0) {
9391
+ return;
9392
+ }
9393
+ const conversations = /* @__PURE__ */ new Map();
9394
+ for (const { conversation } of workspaces) {
9395
+ conversations.set(JSON.stringify(conversation), conversation);
9396
+ }
9397
+ for (const conversation of conversations.values()) {
9398
+ void this.board.conversationEnded(conversation).then((ended) => ended ? this.sweepOn(seat, conversation) : void 0).catch((error) => {
9399
+ this.log.warn({ runner, conversation, error: String(error) }, "could not sweep");
9400
+ });
9401
+ }
9402
+ }
8694
9403
  warmChanged(runner, warm) {
8695
9404
  const seat = this.seats.get(runner);
8696
9405
  if (seat !== void 0) {
@@ -8740,12 +9449,25 @@ var RunnerHub = class {
8740
9449
  // -------------------------------------------------------------------------------------------
8741
9450
  // The scheduler's runner
8742
9451
  // -------------------------------------------------------------------------------------------
9452
+ /**
9453
+ * A conversation ended: every connected runner removes its citizens' workspaces for it, and
9454
+ * those kept for holding work on no branch are recorded, which gives their citizens a closing
9455
+ * turn. A runner away now sweeps when it reports its workspaces on reconnecting.
9456
+ */
9457
+ async sweep(conversation) {
9458
+ await Promise.all([...this.seats.values()].filter((seat) => seat.send !== null).map((seat) => this.sweepOn(seat, conversation).catch((error) => {
9459
+ this.log.warn({ runner: seat.name, conversation, error: String(error) }, "could not sweep");
9460
+ })));
9461
+ }
8743
9462
  /**
8744
9463
  * Delivers what arrived in a conversation into its turn in flight, when that turn's runner can
8745
9464
  * steer its CLI. `refused` leaves the wake to a turn of its own once this one ends.
8746
9465
  */
8747
9466
  async steer(conversation) {
8748
- const turn = this.host.turnIn(conversation.agent, conversation.project, conversation.thread);
9467
+ const turn = this.host.turnIn(conversation.agent, conversation.project, {
9468
+ thread: conversation.thread,
9469
+ channel: conversation.channel
9470
+ });
8749
9471
  const seat = turn === null ? void 0 : this.seats.get(turn.runner);
8750
9472
  if (turn === null || seat === void 0 || seat.send === null) {
8751
9473
  return "refused";
@@ -9050,6 +9772,12 @@ var RunnerHub = class {
9050
9772
  throw new RunnerProtocolError(`turn ${turnId} is not ${runner}'s`);
9051
9773
  }
9052
9774
  }
9775
+ async sweepOn(seat, conversation) {
9776
+ const { kept } = SweepResultSchema.parse(await this.request(seat, (request) => ({ type: "sweep", request, conversation }), SWEEP_TIMEOUT_MS));
9777
+ for (const workspace of kept) {
9778
+ await this.board.recordLeftovers(workspace);
9779
+ }
9780
+ }
9053
9781
  request(seat, message, timeoutMs = this.requestTimeoutMs) {
9054
9782
  const request = randomUUID2();
9055
9783
  return new Promise((resolve, reject) => {
@@ -9505,6 +10233,10 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
9505
10233
  hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
9506
10234
  return c.json({ ok: true });
9507
10235
  });
10236
+ routes.post("/workspaces", async (c) => {
10237
+ hub.heldWorkspaces(c.get("runner"), HeldWorkspacesSchema.parse(await c.req.json()).workspaces);
10238
+ return c.json({ ok: true });
10239
+ });
9508
10240
  routes.post("/warm", async (c) => {
9509
10241
  hub.warmChanged(c.get("runner"), WarmSessionsSchema.parse(await c.req.json()).warm);
9510
10242
  return c.json({ ok: true });
@@ -10101,15 +10833,16 @@ var TurnHub = class {
10101
10833
  get lastSeq() {
10102
10834
  return this.seq;
10103
10835
  }
10104
- /** Records one event of a turn, in its scope and, for a thread's conversation, its thread. */
10105
- push(agent, project, event, thread) {
10836
+ /** Records one event of a turn, in its scope and its conversation there: a thread's, a channel's, or home. */
10837
+ push(agent, project, event, conversation = {}) {
10106
10838
  this.seq += 1;
10107
10839
  const item = {
10108
10840
  seq: this.seq,
10109
10841
  ts: this.now().toISOString(),
10110
10842
  agent,
10111
10843
  project,
10112
- ...thread === void 0 ? {} : { thread },
10844
+ ...conversation.thread === void 0 ? {} : { thread: conversation.thread },
10845
+ ...conversation.channel === void 0 ? {} : { channel: conversation.channel },
10113
10846
  event
10114
10847
  };
10115
10848
  this.buffer.push(item);
@@ -10161,12 +10894,13 @@ var host = new TurnHost({
10161
10894
  maxTurns: config.toolRounds,
10162
10895
  residentIdleMs: config.residentIdleMs,
10163
10896
  log,
10164
- onEvent: (agent, scope, event, thread) => {
10165
- turns.push(agent, scope, event, thread);
10166
- log.debug({ agent, scope, thread, event }, "agent event");
10897
+ onEvent: (agent, scope, event, conversation) => {
10898
+ turns.push(agent, scope, event, conversation);
10899
+ log.debug({ agent, scope, ...conversation, event }, "agent event");
10167
10900
  }
10168
10901
  });
10169
10902
  var runners = new RunnerHub({ board, host, version: VERSION, log });
10903
+ board.watchTurns(() => host.inFlight());
10170
10904
  var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
10171
10905
  var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
10172
10906
  var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });