@kubitnodes/stellaris 0.3.10 → 0.3.12

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 (64) hide show
  1. package/dist/cli/index.js +763 -140
  2. package/dist/server/index.js +1263 -244
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-BlJvN2RA.js +1 -0
  5. package/web/dist/assets/ChannelForms-CmFxBXNK.js +1 -0
  6. package/web/dist/assets/ChannelView-4s--IwKC.js +1 -0
  7. package/web/dist/assets/CitizenView-B5-LVVCG.js +2 -0
  8. package/web/dist/assets/CitizensView-B8lVGSBD.js +1 -0
  9. package/web/dist/assets/CliIcon-CT5ljQDC.js +1 -0
  10. package/web/dist/assets/KnowledgeView-DbI8DIZb.js +1 -0
  11. package/web/dist/assets/{Markdown-4W4xjIq7.js → Markdown-B5zEdYuO.js} +4 -4
  12. package/web/dist/assets/MessageItem-DuxSZwn0.js +1 -0
  13. package/web/dist/assets/MetricsView-D8D-y-sz.js +1 -0
  14. package/web/dist/assets/NeedsYouView-Buwq8-_l.js +1 -0
  15. package/web/dist/assets/Overview-CrQ8ybpq.js +1 -0
  16. package/web/dist/assets/{Pane-xzIpwnBD.js → Pane-CFoSKOo5.js} +1 -1
  17. package/web/dist/assets/ProjectView-BBQg-ZxC.js +1 -0
  18. package/web/dist/assets/ProposalView-BjPHVxj4.js +1 -0
  19. package/web/dist/assets/ProposalsView-DjyZpZe0.js +1 -0
  20. package/web/dist/assets/RunnersView-2h5NWUuD.js +1 -0
  21. package/web/dist/assets/SocietyView-BuaSb_3d.js +1 -0
  22. package/web/dist/assets/SubjectThread-C1mHvHMI.js +1 -0
  23. package/web/dist/assets/TaskFileView-CeEzbSCL.js +4 -0
  24. package/web/dist/assets/TaskView-yL9hCM7G.js +3 -0
  25. package/web/dist/assets/TasksView-DCVU5oZn.js +1 -0
  26. package/web/dist/assets/ThreadCard-CXXf9qfA.js +1 -0
  27. package/web/dist/assets/ThreadForms-CTBaAJ57.js +1 -0
  28. package/web/dist/assets/ThreadView-zg8KqF3t.js +1 -0
  29. package/web/dist/assets/citizen-CImM6WB6.js +1 -0
  30. package/web/dist/assets/compose-BG_-Tp87.js +1 -0
  31. package/web/dist/assets/{format-lz-5NQWT.js → format-BWr3zKWg.js} +1 -1
  32. package/web/dist/assets/governance-cDTg2_iq.js +1 -0
  33. package/web/dist/assets/index-Cxqhrg2v.js +23 -0
  34. package/web/dist/assets/index-VVnGdAXc.css +2 -0
  35. package/web/dist/assets/link-CFdt17Sp.js +1 -0
  36. package/web/dist/assets/mentions-BCdV0bMK.js +1 -0
  37. package/web/dist/assets/{tasks-DK1cnXqs.js → tasks-C10zwkED.js} +1 -1
  38. package/web/dist/assets/useMatch-BpnmzT6s.js +1 -0
  39. package/web/dist/assets/{useStickyScroll-CfP3Yl_4.js → useStickyScroll-BjqdEt_f.js} +1 -1
  40. package/web/dist/index.html +10 -4
  41. package/web/dist/assets/BackLink-B3tz8OFm.js +0 -1
  42. package/web/dist/assets/ChannelView-Dkzr1zkF.js +0 -1
  43. package/web/dist/assets/CitizenView-BLgyEvYz.js +0 -2
  44. package/web/dist/assets/CitizensView-DttP5FqR.js +0 -1
  45. package/web/dist/assets/KnowledgeView-DFAC95aF.js +0 -1
  46. package/web/dist/assets/MessageItem-Bfpve1_Y.js +0 -1
  47. package/web/dist/assets/MetricsView-Bp486U0v.js +0 -1
  48. package/web/dist/assets/NeedsYouView-CJ4COcNn.js +0 -1
  49. package/web/dist/assets/Overview-BnO1K52h.js +0 -1
  50. package/web/dist/assets/ProjectView-w0mcF0KW.js +0 -1
  51. package/web/dist/assets/ProposalView-CyJCwUYK.js +0 -1
  52. package/web/dist/assets/ProposalsView-XrraDhwt.js +0 -1
  53. package/web/dist/assets/RunnersView-H3kCdSQB.js +0 -1
  54. package/web/dist/assets/SocietyView-Cf8ENDAw.js +0 -1
  55. package/web/dist/assets/SubjectThread-ClMmgVVd.js +0 -1
  56. package/web/dist/assets/TaskFileView-CRwwL5K8.js +0 -4
  57. package/web/dist/assets/TaskView-A9IgTxQ0.js +0 -1
  58. package/web/dist/assets/TasksView-CnhTIldG.js +0 -1
  59. package/web/dist/assets/ThreadCard-fvf8cY_l.js +0 -1
  60. package/web/dist/assets/ThreadForms-D09Ykgj9.js +0 -1
  61. package/web/dist/assets/ThreadView-BqNSssCf.js +0 -1
  62. package/web/dist/assets/citizen-C27Wvimj.js +0 -1
  63. package/web/dist/assets/index-CoKyPk79.js +0 -23
  64. package/web/dist/assets/index-t2fXHB5Y.css +0 -2
@@ -57,7 +57,10 @@ 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",
63
+ "update_dashboard"
61
64
  ];
62
65
  var VerbNameSchema = z2.enum(VERB_NAMES);
63
66
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -103,11 +106,16 @@ var MEMBER_VERBS = [
103
106
  "write_knowledge",
104
107
  "remove_knowledge",
105
108
  "plan_task",
106
- "advance_task"
109
+ "advance_task",
110
+ "update_dashboard"
107
111
  ];
108
112
  var GOVERNANCE_VERBS = ["approve", "reject"];
109
113
  var FRONT_DESK_VERBS = ["create_project"];
110
- var PLANNING_VERBS = ["configure_project"];
114
+ var PLANNING_VERBS = [
115
+ "configure_project",
116
+ "create_channel",
117
+ "archive_channel"
118
+ ];
111
119
  var ARCHIVE_VERBS = ["archive_project"];
112
120
  var SEED_ROLES = [
113
121
  {
@@ -129,7 +137,7 @@ var SEED_ROLES = [
129
137
  },
130
138
  {
131
139
  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.",
140
+ 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
141
  verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS],
134
142
  wakeTriggers: ["ops_event", "heartbeat"],
135
143
  maxReplicas: 1,
@@ -140,7 +148,7 @@ var SEED_ROLES = [
140
148
  },
141
149
  {
142
150
  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.",
151
+ 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
152
  verbs: [...MEMBER_VERBS, ...FRONT_DESK_VERBS, ...PLANNING_VERBS],
145
153
  wakeTriggers: ["user_post", "heartbeat"],
146
154
  maxReplicas: 1,
@@ -156,18 +164,62 @@ import { z as z3 } from "zod";
156
164
  var ASK_CHANNEL = "asks";
157
165
  var SOCIETY_CHANNELS = ["general", "governance", ASK_CHANNEL];
158
166
  var PROJECT_DEFAULT_CHANNELS = ["general"];
167
+ function isBoardChannel(project, name) {
168
+ const used = project === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
169
+ return used.includes(name);
170
+ }
159
171
  var SOCIETY_SCOPE = "society";
160
172
  function wakeScope(member, project) {
161
173
  return project !== null && member.memberships.includes(project) ? project : SOCIETY_SCOPE;
162
174
  }
163
175
  var CliKindSchema = z3.enum(["claude", "codex"]);
176
+ var ArchivedChannelSchema = z3.object({
177
+ name: NameSchema,
178
+ at: IsoDateTimeSchema,
179
+ by: NameSchema,
180
+ reason: z3.string().min(1)
181
+ });
164
182
  var SocietySchema = z3.object({
165
183
  name: z3.string().min(1),
166
184
  version: z3.literal(1),
167
185
  createdAt: IsoDateTimeSchema,
168
- channels: z3.array(NameSchema)
186
+ /** The channels that take posts; an archived one moves to `archivedChannels`. */
187
+ channels: z3.array(NameSchema),
188
+ archivedChannels: z3.array(ArchivedChannelSchema).default([])
189
+ });
190
+ var CompletionEffectSchema = z3.enum(["none", "merge", "ghpr"]);
191
+ var GITHUB_REMOTE = [
192
+ /^https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/,
193
+ /^git@github\.com:([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?$/,
194
+ /^ssh:\/\/git@github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/
195
+ ];
196
+ function githubRepository(remote) {
197
+ for (const pattern of GITHUB_REMOTE) {
198
+ const match = remote === null ? null : pattern.exec(remote);
199
+ if (match?.[1] !== void 0 && match[2] !== void 0) {
200
+ return { owner: match[1], name: match[2] };
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ var PULL_REQUEST_URL = /^https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)\/pull\/([1-9]\d*)\/?$/;
206
+ var PullRequestUrlSchema = z3.string().trim().regex(PULL_REQUEST_URL, "a pull request's address, https://github.com/<owner>/<repo>/pull/<number>");
207
+ function parsePullRequestUrl(url) {
208
+ const match = PULL_REQUEST_URL.exec(url.trim());
209
+ return match?.[1] === void 0 || match[2] === void 0 || match[3] === void 0 ? null : { owner: match[1], name: match[2], number: Number(match[3]) };
210
+ }
211
+ function sameRepository(a, b) {
212
+ return a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
213
+ }
214
+ var MergeSubjectSchema = z3.string().trim().min(1).max(300).regex(/^[^\n]+$/, "a merge commit's subject is one line");
215
+ var PullRequestLinkSchema = z3.object({
216
+ url: PullRequestUrlSchema,
217
+ number: z3.number().int().positive(),
218
+ subject: MergeSubjectSchema.optional(),
219
+ body: z3.string().max(2e4).optional(),
220
+ linkedBy: NameSchema,
221
+ linkedAt: IsoDateTimeSchema
169
222
  });
170
- var CompletionEffectSchema = z3.enum(["none", "merge"]);
171
223
  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");
172
224
  var StageIdSchema = z3.string().regex(/^s[1-9][0-9]*$/, "expected a stage id such as s1");
173
225
  var stageFields = {
@@ -189,7 +241,9 @@ var ProjectSchema = z3.object({
189
241
  name: z3.string().min(1),
190
242
  repo: z3.string().nullable(),
191
243
  defaultBranch: z3.string().min(1),
244
+ /** The channels that take posts; an archived one moves to `archivedChannels`. */
192
245
  channels: z3.array(NameSchema),
246
+ archivedChannels: z3.array(ArchivedChannelSchema).default([]),
193
247
  members: z3.array(NameSchema),
194
248
  approvers: z3.array(NameSchema),
195
249
  requiredCapabilities: z3.array(z3.string()),
@@ -260,6 +314,8 @@ var TaskReturnSchema = z3.object({
260
314
  var TaskFrontmatterSchema = z3.object({
261
315
  id: UlidSchema,
262
316
  project: NameSchema,
317
+ /** The channel of the project the task is filed in, where its thread hangs. */
318
+ channel: NameSchema.default("general"),
263
319
  title: z3.string().min(1),
264
320
  status: TaskStatusSchema,
265
321
  createdBy: NameSchema,
@@ -278,6 +334,8 @@ var TaskFrontmatterSchema = z3.object({
278
334
  /** The highest stage number issued, so a removed stage's id is never reused. */
279
335
  stageSeq: z3.number().int().positive(),
280
336
  onDone: CompletionEffectSchema.default("none"),
337
+ /** The pull request a `ghpr` task lands through. */
338
+ pullRequest: PullRequestLinkSchema.optional(),
281
339
  /** The last stage is complete and the completion effect is running. */
282
340
  completing: z3.boolean().default(false),
283
341
  /** The latest send-back while its rework lasts: cleared once the task reaches that stage again. */
@@ -370,6 +428,8 @@ var RunnerSchema = z3.object({
370
428
  os: RunnerOsSchema,
371
429
  clis: z3.array(CliKindSchema),
372
430
  capabilities: z3.array(z3.string()),
431
+ /** The release the runner reported when it last registered; absent until it has. */
432
+ version: z3.string().optional(),
373
433
  status: z3.enum(["connected", "disconnected"]),
374
434
  lastSeen: IsoDateTimeSchema.optional()
375
435
  });
@@ -548,6 +608,14 @@ var RemovedKnowledgeSchema = z3.object({
548
608
  removedBy: NameSchema,
549
609
  removedAt: IsoDateTimeSchema
550
610
  });
611
+ var DashboardSchema = z3.object({
612
+ project: NameSchema,
613
+ revision: z3.number().int().min(0),
614
+ /** Absent until someone updates the dashboard the project began with. */
615
+ updatedBy: NameSchema.optional(),
616
+ updatedAt: IsoDateTimeSchema.optional(),
617
+ body: z3.string()
618
+ });
551
619
  var ProposalFrontmatterSchema = z3.object({
552
620
  id: UlidSchema,
553
621
  kind: ProposalKindSchema,
@@ -606,7 +674,9 @@ var VerbInputs = {
606
674
  parent_id: UlidSchema.optional(),
607
675
  required_capabilities: z4.array(z4.string()).default([]),
608
676
  /** 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()
677
+ stages: z4.array(PlanStageSchema).min(1).optional(),
678
+ /** A channel of the project the task belongs to, such as a release's; its thread opens there. */
679
+ channel: NameSchema.default("general")
610
680
  }),
611
681
  claim_task: z4.object({ task_id: UlidSchema }),
612
682
  release_task: z4.object({ task_id: UlidSchema }),
@@ -617,7 +687,16 @@ var VerbInputs = {
617
687
  status: z4.enum(["abandoned"]).optional(),
618
688
  /** Posted to the task's thread as the caller, marked with the step when the task moved. */
619
689
  note: z4.string().min(1).optional(),
620
- blocked_by: z4.array(UlidSchema).optional()
690
+ blocked_by: z4.array(UlidSchema).optional(),
691
+ /**
692
+ * The pull request a `ghpr` task lands through, on its project's repository, with the merge
693
+ * commit's subject and body; null unlinks it.
694
+ */
695
+ pull_request: z4.object({
696
+ url: PullRequestUrlSchema,
697
+ merge_subject: MergeSubjectSchema.optional(),
698
+ merge_body: z4.string().max(2e4).optional()
699
+ }).nullable().optional()
621
700
  }),
622
701
  get_task: z4.object({ task_id: UlidSchema }),
623
702
  subscribe: z4.object({ channel: ChannelRefSchema }),
@@ -664,7 +743,21 @@ var VerbInputs = {
664
743
  /** The branch tasks start from and land on from now; tasks in play keep their branches. */
665
744
  default_branch: DefaultBranchSchema.optional()
666
745
  }),
667
- archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) })
746
+ archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) }),
747
+ create_channel: z4.object({
748
+ /** A project, or null for a channel of the society. */
749
+ project: NameSchema.nullable().default(null),
750
+ name: NameSchema,
751
+ purpose: z4.string().min(1)
752
+ }),
753
+ archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) }),
754
+ update_dashboard: z4.object({
755
+ project: NameSchema,
756
+ /** The whole dashboard as markdown; the board keeps its frontmatter. */
757
+ body: z4.string().min(1),
758
+ /** The `revision` in the frontmatter of the dashboard you read, 0 when it names none. */
759
+ revision: z4.number().int().min(0)
760
+ })
668
761
  };
669
762
  var VERB_DESCRIPTIONS = {
670
763
  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,25 +765,28 @@ var VERB_DESCRIPTIONS = {
672
765
  search: "Search messages, tasks, and knowledge by text, optionally within a project or channel.",
673
766
  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
767
  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.",
768
+ 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
769
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
677
770
  release_task: "Let go of the stage you hold so someone else can take it.",
678
- 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.",
771
+ update_task: "Move a task back to an earlier stage, abandon it, set what it is blocked by, or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
679
772
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
680
773
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
681
774
  unsubscribe: "Unsubscribe from a channel.",
682
775
  propose: "Propose a member, role, channel, reallocation, retirement, skill, or a project's archive. The charter shape per kind is in your instructions. Opens the proposal's thread in governance with your rationale; approval provisions it.",
683
776
  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
777
  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
- 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.",
778
+ create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none, merge, or ghpr for a GitHub remote).",
779
+ 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
780
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
688
781
  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
782
  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
783
  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
784
  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
- 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."
785
+ configure_project: "Set a project's completion effect (none; merge to land each finished task's branch on the default branch; ghpr, for a project on GitHub, to merge each finished task's linked pull request), its display name, or its default branch, any of them at once. Tasks already filed keep theirs. The slug never changes.",
786
+ 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.",
787
+ 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.",
788
+ 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.",
789
+ update_dashboard: "Replace the dashboard of a project you are a member of, the status page the user reads on its view, with a whole markdown body. Pass the revision from the frontmatter of the dashboard you read; if it was updated since, the call is refused with the current text to merge yours into. dashboard.md in the board projection is a copy, so editing it changes nothing."
694
790
  };
695
791
 
696
792
  // packages/shared/dist/events.js
@@ -723,12 +819,14 @@ var BOARD_EVENT_TYPES = [
723
819
  "proposal.decided",
724
820
  "proposal.provisioned",
725
821
  "channel.added",
822
+ "channel.archived",
726
823
  "role.added",
727
824
  "agent.retired",
728
825
  "agent.joined",
729
826
  "agent.left",
730
827
  "knowledge.written",
731
828
  "knowledge.removed",
829
+ "dashboard.updated",
732
830
  "skill.promoted",
733
831
  "runner.added",
734
832
  "user.signed_in",
@@ -744,7 +842,8 @@ var BOARD_EVENT_TYPES = [
744
842
  "turn.completed",
745
843
  "turn.failed",
746
844
  "merge.completed",
747
- "merge.failed"
845
+ "merge.failed",
846
+ "workspace.leftovers"
748
847
  ];
749
848
  var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
750
849
  var BoardEventSchema = z5.object({
@@ -824,6 +923,8 @@ var LiveTurnEventSchema = z5.object({
824
923
  project: NameSchema,
825
924
  /** The thread whose conversation the turn is in; absent for the home conversation. */
826
925
  thread: UlidSchema.optional(),
926
+ /** The channel, never general, whose conversation the turn is in. */
927
+ channel: NameSchema.optional(),
827
928
  event: AgentEventSchema
828
929
  });
829
930
 
@@ -839,6 +940,7 @@ var PATH_TOKENS = {
839
940
  worktree: "{{stellaris:worktree}}"
840
941
  };
841
942
  var SERVER_TOKEN = "{{stellaris:server}}";
943
+ var WORKSPACE_TOKEN = "{{stellaris:workspace}}";
842
944
  var HOME_SCRATCH = "scratch";
843
945
  var HOME_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
844
946
  var RunnerHelloSchema = z6.object({
@@ -896,15 +998,33 @@ var PendingEnrollmentSchema = z6.object({
896
998
  var ProjectRepoSchema = z6.object({
897
999
  slug: NameSchema,
898
1000
  origin: z6.string().nullable(),
899
- defaultBranch: z6.string().min(1)
1001
+ defaultBranch: z6.string().min(1),
1002
+ /**
1003
+ * The board lands finished tasks on the default branch, itself or through their pull requests,
1004
+ * so the runner lets nothing else move it.
1005
+ */
1006
+ boardLands: z6.boolean()
900
1007
  });
1008
+ function projectRepo(project) {
1009
+ return {
1010
+ slug: project.slug,
1011
+ origin: project.repo,
1012
+ defaultBranch: project.defaultBranch,
1013
+ boardLands: project.onDone !== "none"
1014
+ };
1015
+ }
901
1016
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
902
- z6.object({ kind: z6.literal("home"), thread: UlidSchema.optional() }),
1017
+ z6.object({
1018
+ kind: z6.literal("home"),
1019
+ thread: UlidSchema.optional(),
1020
+ channel: NameSchema.optional()
1021
+ }),
903
1022
  z6.object({
904
1023
  kind: z6.literal("project"),
905
1024
  repo: ProjectRepoSchema,
906
1025
  branches: z6.array(z6.string().min(1)),
907
- thread: UlidSchema.optional()
1026
+ thread: UlidSchema.optional(),
1027
+ channel: NameSchema.optional()
908
1028
  }),
909
1029
  z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
910
1030
  ]);
@@ -920,6 +1040,8 @@ var TurnJobSchema = z6.object({
920
1040
  scope: NameSchema,
921
1041
  /** The thread whose conversation the turn is in; absent for the home conversation. */
922
1042
  thread: UlidSchema.optional(),
1043
+ /** The channel, never general, whose conversation the turn is in. */
1044
+ channel: NameSchema.optional(),
923
1045
  /**
924
1046
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
925
1047
  * the outcome reports it.
@@ -953,12 +1075,27 @@ var TurnOutcomeSchema = z6.object({
953
1075
  session: z6.string().min(1),
954
1076
  /** The model the CLI reported, when it reported one. */
955
1077
  model: z6.string().nullable(),
956
- work: TurnWorkSchema.nullable()
1078
+ work: TurnWorkSchema.nullable(),
1079
+ /**
1080
+ * Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
1081
+ * branch after the hand-back: uncommitted changes, or commits no branch contains.
1082
+ */
1083
+ leftovers: z6.boolean().default(false)
957
1084
  });
958
1085
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
959
1086
  var LandRequestSchema = z6.object({
960
1087
  repo: ProjectRepoSchema,
961
- branch: z6.string().min(1)
1088
+ branch: z6.string().min(1),
1089
+ /**
1090
+ * For a `ghpr` task, the pull request to merge in place of the branch, which must hold the
1091
+ * branch's files, and the merge commit's subject and body, the pull request's title and an
1092
+ * empty body when left out.
1093
+ */
1094
+ pullRequest: z6.object({
1095
+ url: z6.string().min(1),
1096
+ subject: z6.string().min(1).optional(),
1097
+ body: z6.string().optional()
1098
+ }).optional()
962
1099
  });
963
1100
  var MergeOutcomeSchema = z6.object({ ok: z6.boolean(), detail: z6.string() });
964
1101
  var ModelListSchema = z6.array(ModelOptionSchema);
@@ -1012,6 +1149,23 @@ var BranchChangesSchema = z6.object({
1012
1149
  files: z6.array(BranchFileChangeSchema),
1013
1150
  total: z6.number().int().nonnegative()
1014
1151
  });
1152
+ var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
1153
+ z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
1154
+ z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
1155
+ ]);
1156
+ function workspaceConversationOf(turn) {
1157
+ if (turn.thread !== void 0) {
1158
+ return { kind: "thread", id: turn.thread };
1159
+ }
1160
+ return turn.channel === void 0 ? null : { kind: "channel", scope: turn.scope, name: turn.channel };
1161
+ }
1162
+ var HeldWorkspaceSchema = z6.object({
1163
+ agent: NameSchema,
1164
+ scope: NameSchema,
1165
+ conversation: WorkspaceConversationSchema
1166
+ });
1167
+ var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
1168
+ var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
1015
1169
  var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
1016
1170
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
1017
1171
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
@@ -1033,7 +1187,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
1033
1187
  steer: TurnSteerSchema
1034
1188
  }),
1035
1189
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1036
- z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
1190
+ z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
1191
+ /**
1192
+ * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1193
+ * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
1194
+ */
1195
+ z6.object({
1196
+ type: z6.literal("sweep"),
1197
+ request: z6.string().min(1),
1198
+ conversation: WorkspaceConversationSchema
1199
+ })
1037
1200
  ]);
1038
1201
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
1039
1202
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -1061,7 +1224,8 @@ var TriggerKindSchema = z7.enum([
1061
1224
  "onboarding",
1062
1225
  "manual",
1063
1226
  "ops_event",
1064
- "user_post"
1227
+ "user_post",
1228
+ "closing"
1065
1229
  ]);
1066
1230
  var TriggerSchema = z7.object({
1067
1231
  kind: TriggerKindSchema,
@@ -1078,12 +1242,32 @@ var TurnDispatchSchema = z7.object({
1078
1242
  agent: NameSchema,
1079
1243
  project: NameSchema,
1080
1244
  thread: TurnThreadSchema.optional(),
1245
+ channel: NameSchema.optional(),
1081
1246
  trigger: TriggerSchema,
1082
1247
  priority: z7.number().int().min(0).max(2),
1083
1248
  onboarding: z7.boolean().default(false)
1084
1249
  });
1085
- function sessionKey(agent, scope, thread) {
1086
- return thread === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${thread}`;
1250
+ function conversationPart(conversation) {
1251
+ return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
1252
+ }
1253
+ function sessionKey(agent, scope, part) {
1254
+ return part === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${part}`;
1255
+ }
1256
+ function parseSessionKey(key) {
1257
+ const [agent, scope, part, rest] = key.split("/");
1258
+ if (agent === void 0 || scope === void 0 || rest !== void 0) {
1259
+ return null;
1260
+ }
1261
+ if (part === void 0) {
1262
+ return { agent, scope };
1263
+ }
1264
+ return part.startsWith("#") ? { agent, scope, channel: part.slice(1) } : { agent, scope, thread: part };
1265
+ }
1266
+ function channelConversation(scope, channel) {
1267
+ const at = channel.indexOf("/");
1268
+ const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
1269
+ const name = at < 0 ? channel : channel.slice(at + 1);
1270
+ return place === scope && name !== "general" ? name : void 0;
1087
1271
  }
1088
1272
  var TurnRecordSchema = z7.object({
1089
1273
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
@@ -1092,6 +1276,8 @@ var TurnRecordSchema = z7.object({
1092
1276
  project: NameSchema,
1093
1277
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1094
1278
  thread: UlidSchema.optional(),
1279
+ /** The channel, never general, whose conversation the turn was in. */
1280
+ channel: NameSchema.optional(),
1095
1281
  runner: NameSchema,
1096
1282
  cli: CliKindSchema.nullable(),
1097
1283
  session: z7.string().nullable(),
@@ -1121,8 +1307,9 @@ var RunningTurnSchema = z7.object({
1121
1307
  agent: NameSchema,
1122
1308
  scope: NameSchema,
1123
1309
  thread: UlidSchema.optional(),
1310
+ channel: NameSchema.optional(),
1124
1311
  cli: CliKindSchema,
1125
- channel: ChannelRefSchema.optional(),
1312
+ askedIn: ChannelRefSchema.optional(),
1126
1313
  steerable: z7.boolean(),
1127
1314
  stoppable: z7.boolean()
1128
1315
  });
@@ -1149,6 +1336,8 @@ var TurnHistoryEntrySchema = z7.object({
1149
1336
  project: NameSchema,
1150
1337
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1151
1338
  thread: UlidSchema.optional(),
1339
+ /** The channel, never general, whose conversation the turn was in. */
1340
+ channel: NameSchema.optional(),
1152
1341
  trigger: z7.string(),
1153
1342
  exitReason: z7.string().nullable(),
1154
1343
  costUsd: z7.number(),
@@ -1175,7 +1364,8 @@ var OPS_SIGNAL_KINDS = [
1175
1364
  "turn_cost",
1176
1365
  "scaled",
1177
1366
  "runner",
1178
- "home_conflict"
1367
+ "home_conflict",
1368
+ "stuck_workspace"
1179
1369
  ];
1180
1370
  var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
1181
1371
  var WAKING_SIGNAL_KINDS = [
@@ -1382,7 +1572,7 @@ var RunnerConfigSchema = z10.object({
1382
1572
  });
1383
1573
 
1384
1574
  // packages/shared/dist/version.js
1385
- var STELLARIS_VERSION = "0.3.10";
1575
+ var STELLARIS_VERSION = "0.3.12";
1386
1576
 
1387
1577
  // packages/board-core/dist/errors.js
1388
1578
  var BoardError = class extends Error {
@@ -1656,6 +1846,7 @@ var HOME_GITIGNORE = [
1656
1846
  "/projects/*/sessions*.json",
1657
1847
  "/projects/*/last-turn.json",
1658
1848
  "/projects/*/threads/",
1849
+ "/projects/*/channels/",
1659
1850
  `/${HOME_SCRATCH}/`,
1660
1851
  "*.tmp",
1661
1852
  "node_modules/",
@@ -1673,7 +1864,7 @@ var HOME_GITIGNORE = [
1673
1864
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
1674
1865
  `;
1675
1866
  var PRE_RECEIVE = `#!/bin/sh
1676
- forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
1867
+ forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
1677
1868
  while read old new ref; do
1678
1869
  if [ "$ref" != "refs/heads/main" ]; then
1679
1870
  echo "a home takes pushes to main only" >&2
@@ -1934,6 +2125,7 @@ var TurnPayload = z11.object({
1934
2125
  turnId: z11.string().optional(),
1935
2126
  project: z11.string(),
1936
2127
  thread: z11.string().optional(),
2128
+ channel: z11.string().optional(),
1937
2129
  trigger: z11.string()
1938
2130
  });
1939
2131
  var TaskPayload = z11.object({
@@ -1976,7 +2168,14 @@ function answers(mention, turn) {
1976
2168
  if (turn.thread !== null) {
1977
2169
  return mention.thread === turn.thread;
1978
2170
  }
1979
- return mention.scopes.includes(turn.project) && (mention.thread === null || turn.trigger === "mention");
2171
+ if (!mention.scopes.includes(turn.project)) {
2172
+ return false;
2173
+ }
2174
+ const own = mention.thread === null ? channelConversation(turn.project, mention.channel) : void 0;
2175
+ if (turn.channel !== null) {
2176
+ return mention.thread === null && own === turn.channel;
2177
+ }
2178
+ return mention.thread === null && own === void 0 || turn.trigger === "mention";
1980
2179
  }
1981
2180
  function computeMetrics(input) {
1982
2181
  const { events, now, window } = input;
@@ -2041,6 +2240,7 @@ function computeMetrics(input) {
2041
2240
  agent: event.actor,
2042
2241
  at,
2043
2242
  thread: turn.data.thread ?? null,
2243
+ channel: turn.data.channel ?? null,
2044
2244
  project: turn.data.project,
2045
2245
  trigger: turn.data.trigger
2046
2246
  };
@@ -2137,6 +2337,7 @@ function computeMetrics(input) {
2137
2337
  at: Date.parse(event.ts),
2138
2338
  message: message.data.id ?? null,
2139
2339
  thread,
2340
+ channel: message.data.channel,
2140
2341
  scopes: [project, wakeScope(citizen, project)].filter((scope) => scope !== null)
2141
2342
  });
2142
2343
  }
@@ -2412,9 +2613,12 @@ var BoardPaths = class {
2412
2613
  agentProject(name, slug) {
2413
2614
  return path5.join(this.agentProjects(name), slug);
2414
2615
  }
2415
- /** A thread conversation's session and last turn, beside the scope's home conversation. */
2416
- agentThread(name, slug, thread) {
2417
- return path5.join(this.agentProject(name, slug), "threads", thread);
2616
+ /**
2617
+ * A thread's or a channel's conversation's session and last turn, beside the scope's home
2618
+ * conversation; `part` is a thread's id or `#channel`.
2619
+ */
2620
+ agentConversation(name, slug, part) {
2621
+ return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
2418
2622
  }
2419
2623
  agentCursors(name) {
2420
2624
  return path5.join(this.agent(name), "cursors.json");
@@ -2448,6 +2652,13 @@ function hashToken(token) {
2448
2652
 
2449
2653
  // packages/board-core/dist/board.js
2450
2654
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
2655
+ var CLOSING_ATTEMPTS = 3;
2656
+ function ownTurn(actor, turn) {
2657
+ return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel;
2658
+ }
2659
+ function namesList(names) {
2660
+ return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
2661
+ }
2451
2662
  var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
2452
2663
  var RunnerTokensSchema = z12.object({
2453
2664
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
@@ -2465,6 +2676,7 @@ var SignInsSchema = z12.object({
2465
2676
  var TurnHistoryPayloadSchema = z12.object({
2466
2677
  project: NameSchema,
2467
2678
  thread: UlidSchema.optional(),
2679
+ channel: NameSchema.optional(),
2468
2680
  trigger: z12.string().default("manual"),
2469
2681
  exitReason: z12.string().nullable().default(null),
2470
2682
  costUsd: z12.number().default(0),
@@ -2480,12 +2692,23 @@ var CursorsSchema = z12.object({
2480
2692
  digest: z12.string().nullable(),
2481
2693
  scopes: z12.record(z12.string(), z12.string()).default({}),
2482
2694
  threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2695
+ channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2483
2696
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
2484
2697
  news: z12.string().optional()
2485
2698
  });
2486
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
2487
- function conversationKey(scope, thread) {
2488
- return thread === null ? scope : `${scope}/${thread}`;
2699
+ var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
2700
+ function conversationKey(scope, part) {
2701
+ return part === null ? scope : `${scope}/${part}`;
2702
+ }
2703
+ function channelOfPart(part) {
2704
+ return part?.startsWith("#") === true ? part.slice(1) : void 0;
2705
+ }
2706
+ function threadOfPart(part) {
2707
+ return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
2708
+ }
2709
+ function channelStart(cursors, scope) {
2710
+ const from = cursors.channelsFrom[scope];
2711
+ return from === void 0 ? cursors.digest : from;
2489
2712
  }
2490
2713
  function threadStart(cursors, scope) {
2491
2714
  const from = cursors.threadsFrom[scope];
@@ -2498,11 +2721,11 @@ function newsStart(cursors) {
2498
2721
  const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
2499
2722
  return homes.at(-1) ?? cursors.digest;
2500
2723
  }
2501
- function cursorOf(cursors, scope, thread = null) {
2502
- if (thread === null) {
2724
+ function cursorOf(cursors, scope, part = null) {
2725
+ if (part === null) {
2503
2726
  return cursors.scopes[scope] ?? cursors.digest;
2504
2727
  }
2505
- return cursors.scopes[conversationKey(scope, thread)] ?? threadStart(cursors, scope);
2728
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
2506
2729
  }
2507
2730
  function byId(a, b) {
2508
2731
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -2513,6 +2736,9 @@ function turnTime(record) {
2513
2736
  var PausedSchema = z12.object({ paused: z12.boolean() });
2514
2737
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
2515
2738
  var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2739
+ function withoutMentions(text) {
2740
+ return text.replaceAll(MENTION_PATTERN, "$1$2");
2741
+ }
2516
2742
  var CURATING_ROLES = [USER_ROLE, "steward"];
2517
2743
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2518
2744
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -2529,6 +2755,10 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
2529
2755
  var THREAD_CONVERSATIONS = "thread-conversations";
2530
2756
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2531
2757
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2758
+ var CHANNEL_CONVERSATIONS = "channel-conversations";
2759
+ var DASHBOARD_UPDATES = "dashboard-updates-granted";
2760
+ var OLD_DASHBOARD_LINE = "Agents may edit this file. It is rendered by the board UI.";
2761
+ var DASHBOARD_LINE = "The project's members keep this page current with update_dashboard.";
2532
2762
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2533
2763
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2534
2764
  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";
@@ -2565,6 +2795,11 @@ function memberToAgentInput(member) {
2565
2795
  ...member.seedInstructions === void 0 ? {} : { seedInstructions: member.seedInstructions }
2566
2796
  };
2567
2797
  }
2798
+ function assertLandsByPullRequest(effect, repo, where) {
2799
+ if (effect === "ghpr" && githubRepository(repo) === null) {
2800
+ throw new BoardError("VALIDATION", `${where} has ${repo === null ? "no remote" : `its remote at ${repo}, not on GitHub`}, so its tasks cannot land through pull requests (ghpr)`);
2801
+ }
2802
+ }
2568
2803
  function stageFrom(planned, id) {
2569
2804
  return {
2570
2805
  id,
@@ -2659,6 +2894,8 @@ function snippetAround(text, query, radius = 80) {
2659
2894
  var Board = class _Board {
2660
2895
  paths;
2661
2896
  mutex = new Mutex();
2897
+ /** The turns in flight, which the server hosting them supplies; none until it does. */
2898
+ turnsInFlight = () => [];
2662
2899
  newId = monotonicFactory();
2663
2900
  events;
2664
2901
  actionCounts = /* @__PURE__ */ new Map();
@@ -2719,7 +2956,9 @@ var Board = class _Board {
2719
2956
  DECISIONS_CHANNEL_RETIRED,
2720
2957
  THREAD_CONVERSATIONS,
2721
2958
  ASKS_CHANNEL_OPENED,
2722
- KNOWLEDGE_REMOVAL
2959
+ KNOWLEDGE_REMOVAL,
2960
+ CHANNEL_CONVERSATIONS,
2961
+ DASHBOARD_UPDATES
2723
2962
  ].filter((name) => !applied.includes(name));
2724
2963
  if (pending.length === 0) {
2725
2964
  return;
@@ -2749,18 +2988,38 @@ var Board = class _Board {
2749
2988
  await this.openAsksChannel();
2750
2989
  }
2751
2990
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2752
- await this.grantKnowledgeRemoval();
2991
+ await this.grantBesideKnowledge("remove_knowledge");
2992
+ }
2993
+ if (pending.includes(CHANNEL_CONVERSATIONS)) {
2994
+ await this.startChannelCursors();
2995
+ }
2996
+ if (pending.includes(DASHBOARD_UPDATES)) {
2997
+ await this.grantBesideKnowledge("update_dashboard");
2998
+ await this.rewordDashboards();
2753
2999
  }
2754
3000
  await writeJson(file, { applied: [...applied, ...pending] });
2755
3001
  }
2756
3002
  /**
2757
- * Whoever may write a scope's knowledge may remove it, so every charter granting
2758
- * `write_knowledge` is granted `remove_knowledge` once; seed roles have it from their seed.
3003
+ * Grants `verb` once to every charter granting `write_knowledge`, the work roles' charters in
3004
+ * practice; seed roles have it from their seed.
2759
3005
  */
2760
- async grantKnowledgeRemoval() {
3006
+ async grantBesideKnowledge(verb) {
2761
3007
  for (const charter of await this.listRoles()) {
2762
- if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes("remove_knowledge")) {
2763
- await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, "remove_knowledge"] }, { verbsAdded: ["remove_knowledge"] });
3008
+ if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes(verb)) {
3009
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
3010
+ }
3011
+ }
3012
+ }
3013
+ /** A dashboard still saying agents may edit its file says how it changes now. */
3014
+ async rewordDashboards() {
3015
+ for (const slug of await listDirs(this.paths.projects())) {
3016
+ const file = this.paths.dashboard(slug);
3017
+ if (!await exists(file)) {
3018
+ continue;
3019
+ }
3020
+ const { data, body } = await readLooseMarkdown(file);
3021
+ if (body.includes(OLD_DASHBOARD_LINE)) {
3022
+ await writeMarkdown(file, data, body.replace(OLD_DASHBOARD_LINE, DASHBOARD_LINE));
2764
3023
  }
2765
3024
  }
2766
3025
  }
@@ -2925,6 +3184,27 @@ var Board = class _Board {
2925
3184
  }
2926
3185
  }
2927
3186
  }
3187
+ /**
3188
+ * Every channel of a scope was once read in its home conversation, so a member had read each up
3189
+ * to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
3190
+ * conversation of its own.
3191
+ */
3192
+ async startChannelCursors() {
3193
+ for (const agent of await this.listAgents()) {
3194
+ const file = this.paths.agentCursors(agent.name);
3195
+ if (!await exists(file)) {
3196
+ continue;
3197
+ }
3198
+ const cursors = await readJson(file, CursorsSchema);
3199
+ const scopes = /* @__PURE__ */ new Set([
3200
+ ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
3201
+ ...agent.memberships,
3202
+ SOCIETY_SCOPE
3203
+ ]);
3204
+ const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
3205
+ await writeJson(file, { ...cursors, channelsFrom });
3206
+ }
3207
+ }
2928
3208
  /**
2929
3209
  * Threads were once read in the scope's single conversation, so a member had read every thread
2930
3210
  * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
@@ -3058,17 +3338,20 @@ ${charter.purpose}
3058
3338
  /**
3059
3339
  * A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
3060
3340
  * in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
3061
- * scope and thread ride on the actor it resolves to, so `read_inbox` reads that conversation's
3062
- * digest.
3341
+ * scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
3342
+ * `read_inbox` reads that conversation's digest.
3063
3343
  */
3064
- issueTurnToken(agent, role, ttlMs, scope, thread) {
3344
+ issueTurnToken(agent, role, ttlMs, scope, part) {
3065
3345
  const token = mintToken();
3346
+ const thread = threadOfPart(part);
3347
+ const channel = channelOfPart(part);
3066
3348
  this.turnTokens.set(hashToken(token), {
3067
3349
  actor: {
3068
3350
  name: agent,
3069
3351
  role,
3070
3352
  ...scope === void 0 ? {} : { scope },
3071
- ...thread === void 0 ? {} : { thread }
3353
+ ...thread === void 0 ? {} : { thread },
3354
+ ...channel === void 0 ? {} : { channel }
3072
3355
  },
3073
3356
  expiresAt: this.now().getTime() + ttlMs
3074
3357
  });
@@ -3368,34 +3651,44 @@ ${charter.purpose}
3368
3651
  }
3369
3652
  return (await readJson(file, PausedSchema)).paused;
3370
3653
  }
3371
- /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3372
- /** Every channel, the society's first, with how many messages it holds and its newest one. */
3654
+ /**
3655
+ * Every channel, the society's first, with how many messages it holds and its newest one; each
3656
+ * place's archived channels follow its open ones.
3657
+ */
3373
3658
  async listChannels() {
3374
3659
  const summaries = [];
3375
- const scopes = [
3376
- { project: null, names: (await this.society()).channels },
3660
+ const society = await this.society();
3661
+ const places = [
3662
+ { project: null, names: society.channels, archived: society.archivedChannels },
3377
3663
  ...(await this.listProjects()).map((project) => ({
3378
3664
  project: project.slug,
3379
- names: project.channels
3665
+ names: project.channels,
3666
+ archived: project.archivedChannels
3380
3667
  }))
3381
3668
  ];
3382
- for (const { project, names } of scopes) {
3383
- for (const name of names) {
3384
- const ref = channelRef(project, name);
3669
+ for (const { project, names, archived } of places) {
3670
+ const entries = [
3671
+ ...names.map((name) => ({ name, archived: void 0 })),
3672
+ ...archived.map((each) => ({ name: each.name, archived: each }))
3673
+ ];
3674
+ for (const entry of entries) {
3675
+ const ref = channelRef(project, entry.name);
3385
3676
  const files = await listFiles(this.paths.channelDir(ref));
3386
3677
  const last = files.at(-1)?.slice(0, 26) ?? null;
3387
3678
  summaries.push({
3388
3679
  ref,
3389
3680
  project,
3390
- name,
3681
+ name: entry.name,
3391
3682
  messages: files.length,
3392
3683
  lastMessageId: last,
3393
- lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
3684
+ lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
3685
+ ...entry.archived === void 0 ? {} : { archived: entry.archived }
3394
3686
  });
3395
3687
  }
3396
3688
  }
3397
3689
  return summaries;
3398
3690
  }
3691
+ /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3399
3692
  async listChannel(ref, limit2 = 200) {
3400
3693
  await this.assertChannelExists(ref);
3401
3694
  const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
@@ -3488,6 +3781,7 @@ ${charter.purpose}
3488
3781
  if (await exists(file)) {
3489
3782
  throw new BoardError("ALREADY_EXISTS", `project ${input.slug} already exists`);
3490
3783
  }
3784
+ assertLandsByPullRequest(input.onDone ?? "none", input.repo ?? null, `project ${input.slug}`);
3491
3785
  const project = ProjectSchema.parse({
3492
3786
  slug: input.slug,
3493
3787
  name: input.name ?? input.slug,
@@ -3508,9 +3802,9 @@ ${charter.purpose}
3508
3802
  await ensureDir(this.paths.tasks(project.slug));
3509
3803
  await ensureDir(this.paths.threads(project.slug));
3510
3804
  await ensureDir(this.paths.projectKnowledge(project.slug));
3511
- await writeMarkdown(this.paths.dashboard(project.slug), { project: project.slug, updatedAt: this.now().toISOString() }, `# ${project.name} dashboard
3805
+ await writeMarkdown(this.paths.dashboard(project.slug), { project: project.slug, revision: 0, updatedAt: this.now().toISOString() }, `# ${project.name} dashboard
3512
3806
 
3513
- Agents may edit this file. It is rendered by the board UI.
3807
+ ${DASHBOARD_LINE}
3514
3808
  `);
3515
3809
  await this.updateAgent(USER_NAME, (user) => ({
3516
3810
  ...user,
@@ -3548,12 +3842,27 @@ Agents may edit this file. It is rendered by the board UI.
3548
3842
  return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
3549
3843
  });
3550
3844
  }
3845
+ /** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
3846
+ async createChannel(actor, input) {
3847
+ const args = VerbInputs.create_channel.parse(input);
3848
+ await this.authorize(actor, "create_channel");
3849
+ return this.mutex.run(async () => {
3850
+ await this.validateAddChannel(args);
3851
+ return this.addChannelUnlocked(actor.name, args, {}, actor.name);
3852
+ });
3853
+ }
3854
+ /** Archives a channel whose workstream is done; refused while a task filed there is in play. */
3855
+ async archiveChannel(actor, input) {
3856
+ const args = VerbInputs.archive_channel.parse(input);
3857
+ await this.authorize(actor, "archive_channel");
3858
+ return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
3859
+ }
3551
3860
  /** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
3552
3861
  async addChannel(actor, input) {
3553
3862
  this.assertAdmin(actor);
3554
3863
  return this.mutex.run(async () => {
3555
3864
  await this.validateAddChannel(input);
3556
- return this.addChannelUnlocked(actor.name, input, {});
3865
+ return this.addChannelUnlocked(actor.name, input, {}, actor.name);
3557
3866
  });
3558
3867
  }
3559
3868
  /**
@@ -3600,6 +3909,7 @@ Agents may edit this file. It is rendered by the board UI.
3600
3909
  ...patch.os === void 0 ? {} : { os: patch.os },
3601
3910
  ...patch.clis === void 0 ? {} : { clis: [...patch.clis] },
3602
3911
  ...patch.capabilities === void 0 ? {} : { capabilities: [...patch.capabilities] },
3912
+ ...patch.version === void 0 ? {} : { version: patch.version },
3603
3913
  lastSeen: this.now().toISOString()
3604
3914
  });
3605
3915
  await writeMarkdown(file, next, doc.body);
@@ -3751,19 +4061,19 @@ Agents may edit this file. It is rendered by the board UI.
3751
4061
  return this.mutex.run(async () => {
3752
4062
  const stored = await this.readCursors(actor.name);
3753
4063
  const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
3754
- const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, thread: actor.thread ?? null };
4064
+ const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
3755
4065
  const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
3756
4066
  if (args.advance) {
3757
4067
  await this.advanceCursors(actor.name, filed);
3758
4068
  }
3759
4069
  const last = filed.at(-1)?.message.id;
3760
- const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.thread));
4070
+ const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
3761
4071
  return { messages: filed.map((entry) => entry.message), cursor };
3762
4072
  });
3763
4073
  }
3764
4074
  /**
3765
4075
  * How many unread digest messages a member has in each conversation: a scope's home, for its
3766
- * channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
4076
+ * general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3767
4077
  * messages are and nowhere else. Society channels count only for a member woken in the society
3768
4078
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
3769
4079
  * counted, and are read when the member wakes for something else: a task step's, which wakes
@@ -3774,7 +4084,7 @@ Agents may edit this file. It is rendered by the board UI.
3774
4084
  return this.mutex.run(async () => {
3775
4085
  const counts = /* @__PURE__ */ new Map();
3776
4086
  const unscoped = { name: actor.name, role: actor.role };
3777
- for (const { scope, thread, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
4087
+ for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3778
4088
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
3779
4089
  continue;
3780
4090
  }
@@ -3782,8 +4092,13 @@ Agents may edit this file. It is rendered by the board UI.
3782
4092
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
3783
4093
  continue;
3784
4094
  }
3785
- const key = conversationKey(scope, thread);
3786
- const entry = counts.get(key) ?? { scope, thread, count: 0 };
4095
+ const key = conversationKey(scope, part);
4096
+ const entry = counts.get(key) ?? {
4097
+ scope,
4098
+ thread: threadOfPart(part) ?? null,
4099
+ channel: channelOfPart(part) ?? null,
4100
+ count: 0
4101
+ };
3787
4102
  counts.set(key, { ...entry, count: entry.count + 1 });
3788
4103
  }
3789
4104
  return [...counts.values()];
@@ -3799,7 +4114,7 @@ Agents may edit this file. It is rendered by the board UI.
3799
4114
  const scopes = { ...cursors.scopes };
3800
4115
  let news = newsStart(cursors);
3801
4116
  let moved = false;
3802
- for (const { scope, thread, message, news: isNews } of filed) {
4117
+ for (const { scope, part, message, news: isNews } of filed) {
3803
4118
  if (isNews === true) {
3804
4119
  if (news === null || message.id > news) {
3805
4120
  news = message.id;
@@ -3807,8 +4122,8 @@ Agents may edit this file. It is rendered by the board UI.
3807
4122
  }
3808
4123
  continue;
3809
4124
  }
3810
- const key = conversationKey(scope, thread);
3811
- const current = scopes[key] ?? cursorOf(cursors, scope, thread);
4125
+ const key = conversationKey(scope, part);
4126
+ const current = scopes[key] ?? cursorOf(cursors, scope, part);
3812
4127
  if (current === null || message.id > current) {
3813
4128
  scopes[key] = message.id;
3814
4129
  moved = true;
@@ -3827,29 +4142,32 @@ Agents may edit this file. It is rendered by the board UI.
3827
4142
  * message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
3828
4143
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
3829
4144
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
3830
- * message of the thread, since the conversation is about it; for a scope's home, its channel
3831
- * messages, since each thread is a conversation of its own.
4145
+ * message of the thread, since the conversation is about it; for a channel's, that channel's
4146
+ * top-level messages; for a scope's home, general's and those of a project the member is not in,
4147
+ * since each thread and each other channel is a conversation of its own.
3832
4148
  */
3833
4149
  async digestSince(actor, cursors, conversation) {
3834
- if (conversation !== void 0 && conversation.thread !== null) {
3835
- const { scope, thread } = conversation;
3836
- const found = await this.tryFindThread(thread);
4150
+ const conversationThread = threadOfPart(conversation?.part);
4151
+ if (conversation !== void 0 && conversationThread !== void 0) {
4152
+ const { scope, part } = conversation;
4153
+ const found = await this.tryFindThread(conversationThread);
3837
4154
  if (found === null) {
3838
4155
  return [];
3839
4156
  }
3840
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, thread), cursorOf(cursors, scope, thread));
3841
- return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, thread, message })).toSorted(byId);
4157
+ const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
4158
+ return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
3842
4159
  }
3843
4160
  const agent = await this.readAgent(actor.name);
3844
4161
  const charter = await this.readRole(agent.role);
3845
4162
  const subscribed = new Set(agent.subscriptions);
3846
4163
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
3847
4164
  const threadParticipation = /* @__PURE__ */ new Map();
4165
+ const openChannels = /* @__PURE__ */ new Map();
3848
4166
  const collected = [];
3849
4167
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
3850
4168
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent.memberships, SOCIETY_SCOPE];
3851
4169
  const starts = [
3852
- ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
4170
+ ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
3853
4171
  ...newsFrom === void 0 ? [] : [newsFrom]
3854
4172
  ];
3855
4173
  const known = starts.filter((start) => start !== null);
@@ -3862,20 +4180,24 @@ Agents may edit this file. It is rendered by the board UI.
3862
4180
  if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
3863
4181
  collected.push({
3864
4182
  scope: conversation?.scope ?? SOCIETY_SCOPE,
3865
- thread: null,
4183
+ part: null,
3866
4184
  message,
3867
4185
  news: true
3868
4186
  });
3869
4187
  }
3870
4188
  continue;
3871
4189
  }
3872
- if (conversation !== void 0 && filedIn !== conversation.scope) {
3873
- continue;
4190
+ const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
4191
+ let open = named === void 0 ? void 0 : openChannels.get(message.channel);
4192
+ if (named !== void 0 && open === void 0) {
4193
+ open = await this.channelOpen(message.channel);
4194
+ openChannels.set(message.channel, open);
3874
4195
  }
3875
- if (conversation !== void 0 && thread !== null) {
4196
+ const part = thread ?? (open === true ? `#${named}` : null);
4197
+ if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
3876
4198
  continue;
3877
4199
  }
3878
- const cursor = cursorOf(cursors, filedIn, thread);
4200
+ const cursor = cursorOf(cursors, filedIn, part);
3879
4201
  if (cursor !== null && message.id <= cursor) {
3880
4202
  continue;
3881
4203
  }
@@ -3893,7 +4215,7 @@ Agents may edit this file. It is rendered by the board UI.
3893
4215
  include = participates;
3894
4216
  }
3895
4217
  if (include && message.author !== actor.name) {
3896
- collected.push({ scope: filedIn, thread, message });
4218
+ collected.push({ scope: filedIn, part, message });
3897
4219
  }
3898
4220
  }
3899
4221
  return collected.toSorted(byId);
@@ -4062,6 +4384,42 @@ ${task.body}`;
4062
4384
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project}`);
4063
4385
  }
4064
4386
  }
4387
+ /**
4388
+ * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4389
+ * copy of the projection pulled when it started, so a revision other than the current one means
4390
+ * someone updated it since; the refusal carries the current text, which that copy lacks.
4391
+ */
4392
+ async updateDashboard(actor, input) {
4393
+ const args = VerbInputs.update_dashboard.parse(input);
4394
+ await this.authorize(actor, "update_dashboard");
4395
+ return this.mutex.run(async () => {
4396
+ const project = await this.readActiveProject(args.project);
4397
+ if (actor.role !== USER_ROLE && !project.members.includes(actor.name)) {
4398
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${args.project}`);
4399
+ }
4400
+ const current = await this.dashboardOf(args.project);
4401
+ if (args.revision !== current.revision) {
4402
+ const by = current.updatedBy === void 0 ? "" : `, updated by ${current.updatedBy} at ${current.updatedAt ?? "an unknown time"}`;
4403
+ throw new BoardError("INVALID_STATE", `the dashboard of ${args.project} is at revision ${current.revision}${by}, not ${args.revision}. Merge your changes into its current text, below, and update again with revision ${current.revision}.
4404
+
4405
+ ${current.body}`);
4406
+ }
4407
+ const updated = DashboardSchema.parse({
4408
+ project: args.project,
4409
+ revision: current.revision + 1,
4410
+ updatedBy: actor.name,
4411
+ updatedAt: this.now().toISOString(),
4412
+ body: args.body
4413
+ });
4414
+ const { body, ...frontmatter } = updated;
4415
+ await writeMarkdown(this.paths.dashboard(args.project), frontmatter, body);
4416
+ await this.events.append("dashboard.updated", actor.name, {
4417
+ project: args.project,
4418
+ revision: updated.revision
4419
+ });
4420
+ return updated;
4421
+ });
4422
+ }
4065
4423
  async openThread(actor, input) {
4066
4424
  const args = VerbInputs.open_thread.parse(input);
4067
4425
  await this.authorize(actor, "open_thread");
@@ -4132,7 +4490,7 @@ ${task.body}`;
4132
4490
  taskThreadOpening(task) {
4133
4491
  return {
4134
4492
  id: task.id,
4135
- channel: channelRef(task.project, "general"),
4493
+ channel: channelRef(task.project, task.channel),
4136
4494
  title: task.title,
4137
4495
  subject: { kind: "task", id: task.id }
4138
4496
  };
@@ -4178,7 +4536,7 @@ ${task.body}`;
4178
4536
  if (task.status === "done" || task.status === "abandoned") {
4179
4537
  throw new BoardError("INVALID_STATE", `task ${task.id} is ${task.status}`);
4180
4538
  }
4181
- const channel = args.channel ?? channelRef(project, "general");
4539
+ const channel = args.channel ?? channelRef(project, task.channel);
4182
4540
  if (parseChannelRef(channel).project !== project) {
4183
4541
  throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project}`);
4184
4542
  }
@@ -4211,6 +4569,44 @@ ${task.body}`;
4211
4569
  await this.assertChannelOpen(args.channel);
4212
4570
  return { id: this.newId(), channel: args.channel, title: args.title };
4213
4571
  }
4572
+ /**
4573
+ * Where the board learns which turns are in flight, from the server that hosts them, so it
4574
+ * refuses to end a conversation under someone else's turn.
4575
+ */
4576
+ watchTurns(source) {
4577
+ this.turnsInFlight = source;
4578
+ }
4579
+ /**
4580
+ * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
4581
+ * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
4582
+ * from inside it is how a citizen finishes there.
4583
+ */
4584
+ assertNoTurnCut(actor, what, cut) {
4585
+ const busy = [
4586
+ ...new Set(this.turnsInFlight().filter((turn) => cut(turn) && !ownTurn(actor, turn)).map((turn) => turn.agent))
4587
+ ].toSorted((a, b) => a.localeCompare(b));
4588
+ if (busy.length === 0) {
4589
+ return;
4590
+ }
4591
+ const one = busy.length === 1;
4592
+ 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"}`);
4593
+ }
4594
+ /** The open threads in a folder of threads that `keep` picks. */
4595
+ async openThreadIds(dir, keep) {
4596
+ const ids = /* @__PURE__ */ new Set();
4597
+ for (const file of await listFiles(dir)) {
4598
+ const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
4599
+ if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
4600
+ ids.add(doc.data.id);
4601
+ }
4602
+ }
4603
+ return ids;
4604
+ }
4605
+ /** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
4606
+ async assertProjectQuiet(actor, slug) {
4607
+ const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
4608
+ this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.thread !== void 0 && threads.has(turn.thread));
4609
+ }
4214
4610
  async closeThread(actor, input) {
4215
4611
  const args = VerbInputs.close_thread.parse(input);
4216
4612
  await this.authorize(actor, "close_thread");
@@ -4225,6 +4621,7 @@ ${task.body}`;
4225
4621
  if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread, threads)) {
4226
4622
  throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
4227
4623
  }
4624
+ this.assertNoTurnCut(actor, `thread ${thread.id} cannot close`, (turn) => turn.thread === thread.id);
4228
4625
  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}).
4229
4626
 
4230
4627
  ${args.summary}`);
@@ -4263,10 +4660,12 @@ ${args.summary}`);
4263
4660
  if (first === void 0) {
4264
4661
  throw new BoardError("VALIDATION", "a plan needs at least one stage");
4265
4662
  }
4663
+ await this.assertChannelOpen(channelRef(args.project, args.channel));
4266
4664
  const ts = this.now().toISOString();
4267
4665
  const frontmatter = TaskFrontmatterSchema.parse({
4268
4666
  id: this.newId(),
4269
4667
  project: args.project,
4668
+ channel: args.channel,
4270
4669
  title: args.title,
4271
4670
  status: "open",
4272
4671
  createdBy: actor.name,
@@ -4282,11 +4681,11 @@ ${args.summary}`);
4282
4681
  onDone: project.onDone,
4283
4682
  completing: false
4284
4683
  });
4285
- await this.assertChannelExists(channelRef(args.project, "general"));
4286
4684
  const task = await this.writeTask(args.project, { ...frontmatter, body: args.body });
4287
4685
  await this.events.append("task.created", actor.name, {
4288
4686
  taskId: task.id,
4289
4687
  project: task.project,
4688
+ channel: task.channel,
4290
4689
  title: task.title,
4291
4690
  parentId: task.parentId ?? null,
4292
4691
  stage: task.stage
@@ -4402,6 +4801,12 @@ ${args.summary}`);
4402
4801
  completedAt: ts
4403
4802
  };
4404
4803
  const next = current.stages[index + 1];
4804
+ if (next === void 0 && current.onDone === "ghpr" && current.pullRequest === void 0) {
4805
+ throw new BoardError("INVALID_STATE", `task ${current.id} lands through its pull request: link it with update_task pull_request before the last stage completes, or have the user, the steward, or the concierge set the task's on_done to none`);
4806
+ }
4807
+ if (next === void 0 && current.onDone === "none") {
4808
+ this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn) => turn.thread === current.id);
4809
+ }
4405
4810
  const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
4406
4811
  const base = {
4407
4812
  ...current,
@@ -4515,6 +4920,7 @@ ${args.summary}`);
4515
4920
  const guarded = gateChanges(ahead, replanned);
4516
4921
  if (onDone !== current.onDone) {
4517
4922
  guarded.push("change the completion effect");
4923
+ assertLandsByPullRequest(onDone, (await this.readProject(current.project)).repo, `project ${current.project}`);
4518
4924
  }
4519
4925
  if (guarded.length > 0) {
4520
4926
  this.assertMayGate(actor, guarded.join(", "));
@@ -4565,6 +4971,7 @@ ${args.summary}`);
4565
4971
  if (!mayAbandon) {
4566
4972
  throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
4567
4973
  }
4974
+ this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn) => turn.thread === current.id);
4568
4975
  next = {
4569
4976
  ...next,
4570
4977
  status: "abandoned",
@@ -4602,6 +5009,12 @@ ${args.summary}`);
4602
5009
  }
4603
5010
  next = { ...next, blockedBy: [...args.blocked_by] };
4604
5011
  }
5012
+ if (args.pull_request !== void 0) {
5013
+ next = {
5014
+ ...next,
5015
+ pullRequest: await this.pullRequestLink(actor, current, args.pull_request)
5016
+ };
5017
+ }
4605
5018
  if (args.note !== void 0) {
4606
5019
  const step = moved !== null ? { action: "returned", stage: moved.from, to: moved.to } : next.status === "abandoned" ? { action: "abandoned", stage: current.stage, to: null } : void 0;
4607
5020
  await this.postTaskNote(actor.name, current, args.note, step);
@@ -4627,12 +5040,46 @@ ${args.summary}`);
4627
5040
  project: location.project,
4628
5041
  from: current.status,
4629
5042
  to: task.status,
4630
- note: args.note ?? null
5043
+ note: args.note ?? null,
5044
+ ...args.pull_request === void 0 ? {} : { pullRequest: task.pullRequest?.url ?? null }
4631
5045
  });
4632
5046
  }
4633
5047
  return task;
4634
5048
  });
4635
5049
  }
5050
+ /**
5051
+ * The pull request a task is linked to, or none for `null`: one on the repository of its project,
5052
+ * by a member of the project or a planner, while the task is in play and before it lands.
5053
+ */
5054
+ async pullRequestLink(actor, task, link) {
5055
+ this.assertInPlay(task);
5056
+ if (task.completing) {
5057
+ throw new BoardError("INVALID_STATE", `task ${task.id} is landing, so its pull request no longer changes`);
5058
+ }
5059
+ if (!PLANNING_ROLES.includes(actor.role)) {
5060
+ const agent = await this.readAgent(actor.name);
5061
+ if (!agent.memberships.includes(task.project)) {
5062
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${task.project}`);
5063
+ }
5064
+ }
5065
+ if (link === null) {
5066
+ return void 0;
5067
+ }
5068
+ const project = await this.readProject(task.project);
5069
+ const repository = githubRepository(project.repo);
5070
+ const named = parsePullRequestUrl(link.url);
5071
+ if (repository === null || named === null || !sameRepository(repository, named)) {
5072
+ throw new BoardError("VALIDATION", `${link.url} is not a pull request of ${project.slug}'s repository${project.repo === null ? ", since it has no remote" : ` at ${project.repo}`}`);
5073
+ }
5074
+ return PullRequestLinkSchema.parse({
5075
+ url: link.url,
5076
+ number: named.number,
5077
+ ...link.merge_subject === void 0 ? {} : { subject: link.merge_subject },
5078
+ ...link.merge_body === void 0 ? {} : { body: link.merge_body },
5079
+ linkedBy: actor.name,
5080
+ linkedAt: this.now().toISOString()
5081
+ });
5082
+ }
4636
5083
  async getTask(actor, input) {
4637
5084
  const args = VerbInputs.get_task.parse(input);
4638
5085
  await this.authorize(actor, "get_task");
@@ -4743,6 +5190,9 @@ ${rationale}`}`);
4743
5190
  }
4744
5191
  return this.mutex.run(async () => {
4745
5192
  const previous = await this.readActiveProject(args.project);
5193
+ if (args.on_done !== void 0) {
5194
+ assertLandsByPullRequest(args.on_done, previous.repo, `project ${previous.slug}`);
5195
+ }
4746
5196
  const project = await this.updateProject(args.project, (current) => ({
4747
5197
  ...current,
4748
5198
  ...args.on_done === void 0 ? {} : { onDone: args.on_done },
@@ -4766,6 +5216,7 @@ ${rationale}`}`);
4766
5216
  await this.authorize(actor, "archive_project");
4767
5217
  return this.mutex.run(async () => {
4768
5218
  await this.validateArchive(args.project);
5219
+ await this.assertProjectQuiet(actor, args.project);
4769
5220
  return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
4770
5221
  });
4771
5222
  }
@@ -4773,6 +5224,10 @@ ${rationale}`}`);
4773
5224
  * A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
4774
5225
  * directory now and, through the `agent.joined` event, an onboarding turn.
4775
5226
  */
5227
+ /**
5228
+ * A member follows the project's general from the start; the result names the project's other
5229
+ * open channels, which it subscribes to when its work there needs them.
5230
+ */
4776
5231
  async joinProject(actor, input) {
4777
5232
  const args = VerbInputs.join_project.parse(input);
4778
5233
  await this.authorize(actor, "join_project");
@@ -4785,7 +5240,7 @@ ${rationale}`}`);
4785
5240
  throw new BoardError("INVALID_STATE", `${target} is retired`);
4786
5241
  }
4787
5242
  if (current.memberships.includes(args.project)) {
4788
- return current;
5243
+ return { ...current, otherChannels: await this.otherChannels(args.project, current) };
4789
5244
  }
4790
5245
  await this.assertMayJoinProjects(target, current.role);
4791
5246
  const agent = await this.updateAgent(target, (a) => ({
@@ -4804,9 +5259,13 @@ ${rationale}`}`);
4804
5259
  project: args.project,
4805
5260
  cli: agent.cli
4806
5261
  });
4807
- return agent;
5262
+ return { ...agent, otherChannels: await this.otherChannels(args.project, agent) };
4808
5263
  });
4809
5264
  }
5265
+ /** The open channels of a project a citizen does not follow. */
5266
+ async otherChannels(project, agent) {
5267
+ return (await this.readProject(project)).channels.map((name) => channelRef(project, name)).filter((ref) => !agent.subscriptions.includes(ref));
5268
+ }
4810
5269
  async leaveProject(actor, input) {
4811
5270
  const args = VerbInputs.leave_project.parse(input);
4812
5271
  await this.authorize(actor, "leave_project");
@@ -4911,6 +5370,12 @@ ${rationale}`}`);
4911
5370
  return this.configureProject(actor, VerbInputs.configure_project.parse(input));
4912
5371
  case "archive_project":
4913
5372
  return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
5373
+ case "create_channel":
5374
+ return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5375
+ case "archive_channel":
5376
+ return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
5377
+ case "update_dashboard":
5378
+ return this.updateDashboard(actor, VerbInputs.update_dashboard.parse(input));
4914
5379
  default:
4915
5380
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
4916
5381
  }
@@ -5021,29 +5486,30 @@ ${rationale}`}`);
5021
5486
  /**
5022
5487
  * Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
5023
5488
  * never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
5489
+ * `part` names the conversation beside the scope's home: a thread's id or `#channel`.
5024
5490
  */
5025
- async setDigestCursor(agent, scope, cursor, thread) {
5491
+ async setDigestCursor(agent, scope, cursor, part) {
5026
5492
  await this.mutex.run(async () => {
5027
5493
  if (cursor === null) {
5028
5494
  return;
5029
5495
  }
5030
5496
  const cursors = await this.readCursors(agent);
5031
- const current = cursorOf(cursors, scope, thread ?? null);
5497
+ const current = cursorOf(cursors, scope, part ?? null);
5032
5498
  const news = newsStart(cursors);
5033
- const readNews = thread === void 0 && (news === null || cursor > news);
5499
+ const readNews = part === void 0 && (news === null || cursor > news);
5034
5500
  if (current !== null && cursor <= current && !readNews) {
5035
5501
  return;
5036
5502
  }
5037
5503
  await writeJson(this.paths.agentCursors(agent), {
5038
5504
  ...cursors,
5039
- scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, thread ?? null)]: cursor },
5505
+ scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
5040
5506
  ...readNews ? { news: cursor } : {}
5041
5507
  });
5042
5508
  });
5043
5509
  }
5044
5510
  /** Where a conversation's digest resumes: the newest message a turn there was shown. */
5045
- async digestCursor(agent, scope, thread) {
5046
- return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, thread ?? null));
5511
+ async digestCursor(agent, scope, part) {
5512
+ return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, part ?? null));
5047
5513
  }
5048
5514
  /** Records that a running turn took posts delivered into it, which wake latency counts to. */
5049
5515
  async recordSteered(input) {
@@ -5052,25 +5518,29 @@ ${rationale}`}`);
5052
5518
  turnId: input.turnId,
5053
5519
  project: input.project,
5054
5520
  ...input.thread === void 0 ? {} : { thread: input.thread },
5521
+ ...input.channel === void 0 ? {} : { channel: input.channel },
5055
5522
  messages: [...input.messages]
5056
5523
  });
5057
5524
  });
5058
5525
  }
5059
- /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
5060
- sessionDir(agent, scope, thread) {
5061
- return thread === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentThread(agent, scope, thread);
5526
+ /**
5527
+ * Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
5528
+ * thread's or a channel's, named by `part` as `conversationPart` gives it.
5529
+ */
5530
+ sessionDir(agent, scope, part) {
5531
+ return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
5062
5532
  }
5063
- async readSessions(agent, project, thread) {
5064
- const file = path6.join(this.sessionDir(agent, project, thread), "sessions.json");
5533
+ async readSessions(agent, project, part) {
5534
+ const file = path6.join(this.sessionDir(agent, project, part), "sessions.json");
5065
5535
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
5066
5536
  }
5067
5537
  /**
5068
5538
  * Records a conversation's session for a CLI and the runner it began on. A session started on
5069
5539
  * another runner replaces the record, since sessions do not move between machines.
5070
5540
  */
5071
- async writeSession(agent, project, cli, sessionId, runner, thread) {
5541
+ async writeSession(agent, project, cli, sessionId, runner, part) {
5072
5542
  await this.mutex.run(async () => {
5073
- const dir = this.sessionDir(agent, project, thread);
5543
+ const dir = this.sessionDir(agent, project, part);
5074
5544
  await ensureDir(dir);
5075
5545
  const file = path6.join(dir, "sessions.json");
5076
5546
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
@@ -5078,17 +5548,21 @@ ${rationale}`}`);
5078
5548
  await writeJson(file, { ...kept, [cli]: sessionId, runner });
5079
5549
  });
5080
5550
  }
5081
- /** The last turn of one conversation: the scope's home, or a thread's. */
5082
- async readLastTurn(agent, project, thread) {
5083
- const file = path6.join(this.sessionDir(agent, project, thread), "last-turn.json");
5551
+ /** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
5552
+ async readLastTurn(agent, project, part) {
5553
+ const file = path6.join(this.sessionDir(agent, project, part), "last-turn.json");
5084
5554
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
5085
5555
  }
5086
5556
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
5087
5557
  async readLatestTurn(agent, project) {
5088
5558
  let latest = await this.readLastTurn(agent, project);
5089
- const threads = path6.join(this.paths.agentProject(agent, project), "threads");
5090
- for (const thread of await listDirs(threads)) {
5091
- const turn = await this.readLastTurn(agent, project, thread);
5559
+ const place = this.paths.agentProject(agent, project);
5560
+ const parts = [
5561
+ ...await listDirs(path6.join(place, "threads")),
5562
+ ...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
5563
+ ];
5564
+ for (const part of parts) {
5565
+ const turn = await this.readLastTurn(agent, project, part);
5092
5566
  if (turn !== null && (latest === null || turnTime(turn) > turnTime(latest))) {
5093
5567
  latest = turn;
5094
5568
  }
@@ -5105,6 +5579,7 @@ ${rationale}`}`);
5105
5579
  turnId: parsed.id,
5106
5580
  project: parsed.project,
5107
5581
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5582
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5108
5583
  trigger: parsed.trigger.kind,
5109
5584
  session: parsed.session,
5110
5585
  runner: parsed.runner
@@ -5130,13 +5605,15 @@ ${rationale}`}`);
5130
5605
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5131
5606
  agent: parsed.agent,
5132
5607
  project: parsed.project,
5133
- ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
5608
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5609
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
5134
5610
  });
5135
5611
  }
5136
5612
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
5137
5613
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5138
5614
  project: parsed.project,
5139
5615
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5616
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5140
5617
  trigger: parsed.trigger.kind,
5141
5618
  session: parsed.session,
5142
5619
  exitReason: parsed.exitReason,
@@ -5160,10 +5637,51 @@ ${rationale}`}`);
5160
5637
  });
5161
5638
  });
5162
5639
  }
5640
+ /**
5641
+ * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
5642
+ * the scheduler gives the citizen a closing turn there to decide about it. False, recording
5643
+ * nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
5644
+ */
5645
+ async recordLeftovers(input) {
5646
+ const { conversation } = input;
5647
+ const payload = {
5648
+ agent: input.agent,
5649
+ project: input.scope,
5650
+ ...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
5651
+ };
5652
+ return this.mutex.run(async () => {
5653
+ 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;
5654
+ if (earlier >= CLOSING_ATTEMPTS) {
5655
+ return false;
5656
+ }
5657
+ await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
5658
+ return true;
5659
+ });
5660
+ }
5661
+ /**
5662
+ * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
5663
+ * (for one from before tasks had threads), or a channel archived.
5664
+ */
5665
+ async conversationEnded(conversation) {
5666
+ if (conversation.kind === "channel") {
5667
+ const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
5668
+ return !await this.channelOpen(channelRef(place, conversation.name));
5669
+ }
5670
+ const found = await this.tryFindThread(conversation.id);
5671
+ if (found !== null) {
5672
+ return found.thread.state === "closed";
5673
+ }
5674
+ try {
5675
+ const { task } = await this.findTask(conversation.id);
5676
+ return task.status === "done" || task.status === "abandoned";
5677
+ } catch {
5678
+ return false;
5679
+ }
5680
+ }
5163
5681
  /**
5164
5682
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5165
- * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
5166
- * landed one announced in the project's general channel.
5683
+ * its participants can reshape the plan. A landing's outcome, a merge's or a pull request's, is
5684
+ * posted to the task's thread, and a landed one announced in the task's channel.
5167
5685
  */
5168
5686
  async finishCompletion(actor, input) {
5169
5687
  return this.mutex.run(async () => {
@@ -5173,8 +5691,8 @@ ${rationale}`}`);
5173
5691
  throw new BoardError("INVALID_STATE", `task ${current.id} is not completing`);
5174
5692
  }
5175
5693
  const ts = this.now().toISOString();
5176
- const merged = current.onDone === "merge";
5177
- if (merged) {
5694
+ const landed = current.onDone !== "none";
5695
+ if (landed) {
5178
5696
  await this.postTaskNote(actor.name, current, input.ok ? `${sentence(input.detail)}.` : `Landing failed: ${sentence(input.detail)}. The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
5179
5697
  }
5180
5698
  if (input.ok) {
@@ -5194,8 +5712,8 @@ ${rationale}`}`);
5194
5712
  for (const name of new Set(task2.stages.flatMap((stage) => stage.completedBy ?? []))) {
5195
5713
  await this.refreshMember(name);
5196
5714
  }
5197
- if (merged) {
5198
- await this.appendMessage(actor.name, channelRef(location.project, "general"), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5715
+ if (landed) {
5716
+ await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5199
5717
  }
5200
5718
  return task2;
5201
5719
  }
@@ -5224,15 +5742,31 @@ ${rationale}`}`);
5224
5742
  async writeState(name, value) {
5225
5743
  await this.mutex.run(() => writeJson(path6.join(this.paths.state(), `${name}.json`), value));
5226
5744
  }
5227
- /** The project's editable dashboard: frontmatter plus markdown body. Empty when none exists. */
5745
+ /** The project's dashboard, empty at revision 0 when it has none. */
5228
5746
  async readDashboard(slug) {
5229
5747
  await this.readProject(slug);
5748
+ return this.dashboardOf(slug);
5749
+ }
5750
+ /**
5751
+ * Read loosely, since agents wrote dashboards by hand before they had a verb: a revision or an
5752
+ * author that does not parse counts as none.
5753
+ */
5754
+ async dashboardOf(slug) {
5230
5755
  const file = this.paths.dashboard(slug);
5231
5756
  if (!await exists(file)) {
5232
- return { data: { project: slug }, body: "" };
5757
+ return { project: slug, revision: 0, body: "" };
5233
5758
  }
5234
5759
  const { data, body } = await readLooseMarkdown(file);
5235
- return { data, body };
5760
+ const revision = z12.number().int().min(0).safeParse(data["revision"]);
5761
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
5762
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
5763
+ return {
5764
+ project: slug,
5765
+ revision: revision.success ? revision.data : 0,
5766
+ ...updatedBy.success ? { updatedBy: updatedBy.data } : {},
5767
+ ...updatedAt.success ? { updatedAt: updatedAt.data } : {},
5768
+ body
5769
+ };
5236
5770
  }
5237
5771
  async listRunners() {
5238
5772
  const runners2 = [];
@@ -5432,7 +5966,7 @@ ${rationale}`}`);
5432
5966
  return this.files.read(this.paths.board, paths, () => true);
5433
5967
  }
5434
5968
  async writeTurnRecord(record) {
5435
- const dir = this.sessionDir(record.agent, record.project, record.thread);
5969
+ const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
5436
5970
  await ensureDir(dir);
5437
5971
  await writeJson(path6.join(dir, "last-turn.json"), record);
5438
5972
  }
@@ -5736,7 +6270,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5736
6270
  };
5737
6271
  }
5738
6272
  case "channel": {
5739
- const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta);
6273
+ const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta, proposal.proposedBy);
5740
6274
  return { channel };
5741
6275
  }
5742
6276
  case "role": {
@@ -5842,18 +6376,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5842
6376
  return { agent, token };
5843
6377
  }
5844
6378
  async validateAddChannel(input) {
5845
- if (input.project === null) {
5846
- if ((await this.society()).channels.includes(input.name)) {
5847
- throw new BoardError("ALREADY_EXISTS", `society channel ${input.name} already exists`);
5848
- }
5849
- return;
6379
+ const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
6380
+ const ref = channelRef(input.project, input.name);
6381
+ if (place.channels.includes(input.name)) {
6382
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
5850
6383
  }
5851
- const project = await this.readActiveProject(input.project);
5852
- if (project.channels.includes(input.name)) {
5853
- throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
6384
+ if (place.archivedChannels.some((each) => each.name === input.name)) {
6385
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
5854
6386
  }
5855
6387
  }
5856
- async addChannelUnlocked(by, input, meta) {
6388
+ /**
6389
+ * Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
6390
+ * it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
6391
+ */
6392
+ async addChannelUnlocked(by, input, meta, opener) {
5857
6393
  const ref = channelRef(input.project, input.name);
5858
6394
  if (input.project === null) {
5859
6395
  const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
@@ -5867,14 +6403,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5867
6403
  }));
5868
6404
  await ensureDir(this.paths.projectChannel(project, input.name));
5869
6405
  }
6406
+ const follower = await this.readAgent(opener).catch(() => null);
6407
+ const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
6408
+ if (follows) {
6409
+ await this.updateAgent(opener, (agent) => ({
6410
+ ...agent,
6411
+ subscriptions: [.../* @__PURE__ */ new Set([...agent.subscriptions, ref])]
6412
+ }));
6413
+ await this.refreshMember(opener);
6414
+ }
5870
6415
  await this.events.append("channel.added", by, {
5871
6416
  channel: ref,
5872
6417
  purpose: input.purpose,
6418
+ ...follows ? { followedBy: opener } : {},
5873
6419
  ...meta
5874
6420
  });
5875
- await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${input.purpose}`);
6421
+ const purpose = withoutMentions(input.purpose);
6422
+ await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
6423
+ 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.`);
5876
6424
  return ref;
5877
6425
  }
6426
+ /**
6427
+ * Ends a channel: its open threads close, its followers stop following it, and it leaves the
6428
+ * place's channels for its archived ones, where its history stays readable. The notice is the
6429
+ * board's and leaves the reason out, since a board-authored mention would wake whoever it names.
6430
+ */
6431
+ async archiveChannelUnlocked(actor, ref, reason) {
6432
+ const by = actor.name;
6433
+ const { project, channel } = parseChannelRef(ref);
6434
+ if (isBoardChannel(project, channel)) {
6435
+ throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
6436
+ }
6437
+ await this.assertChannelOpen(ref);
6438
+ if (project !== null) {
6439
+ const inPlay2 = (await this.listTasks(project)).filter((task) => task.channel === channel && task.status !== "done" && task.status !== "abandoned");
6440
+ if (inPlay2.length > 0) {
6441
+ throw new BoardError("INVALID_STATE", `${inPlay2.length} task(s) filed in ${ref} are still in play: ${inPlay2.map((task) => task.id).join(", ")}`);
6442
+ }
6443
+ }
6444
+ const ts = this.now().toISOString();
6445
+ const threads = project === null ? this.paths.societyThreads() : this.paths.threads(project);
6446
+ const closing = await this.openThreadIds(threads, (thread) => thread.channel === ref);
6447
+ const scope = project ?? SOCIETY_SCOPE;
6448
+ this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
6449
+ const threadsClosed = [];
6450
+ for (const file of await listFiles(threads)) {
6451
+ const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
6452
+ if (doc.data.channel !== ref || doc.data.state !== "open") {
6453
+ continue;
6454
+ }
6455
+ await this.writeThread(threads, {
6456
+ ...doc.data,
6457
+ body: doc.body,
6458
+ state: "closed",
6459
+ closedBy: by,
6460
+ closedAt: ts
6461
+ });
6462
+ await this.events.append("thread.closed", by, {
6463
+ threadId: doc.data.id,
6464
+ channel: ref,
6465
+ ended: "archived"
6466
+ });
6467
+ threadsClosed.push(doc.data.id);
6468
+ }
6469
+ const unsubscribed = [];
6470
+ for (const agent of await this.listAgents()) {
6471
+ if (agent.subscriptions.includes(ref)) {
6472
+ await this.updateAgent(agent.name, (a) => ({
6473
+ ...a,
6474
+ subscriptions: a.subscriptions.filter((each) => each !== ref)
6475
+ }));
6476
+ await this.refreshMember(agent.name);
6477
+ unsubscribed.push(agent.name);
6478
+ }
6479
+ }
6480
+ const archived = { name: channel, at: ts, by, reason };
6481
+ if (project === null) {
6482
+ const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
6483
+ await writeMarkdown(this.paths.societyFile(), {
6484
+ ...doc.data,
6485
+ channels: doc.data.channels.filter((name) => name !== channel),
6486
+ archivedChannels: [...doc.data.archivedChannels, archived]
6487
+ }, doc.body);
6488
+ } else {
6489
+ await this.updateProject(project, (current) => ({
6490
+ ...current,
6491
+ channels: current.channels.filter((name) => name !== channel),
6492
+ archivedChannels: [...current.archivedChannels, archived]
6493
+ }));
6494
+ }
6495
+ await this.events.append("channel.archived", by, {
6496
+ channel: ref,
6497
+ reason,
6498
+ threadsClosed,
6499
+ unsubscribed
6500
+ });
6501
+ 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.`);
6502
+ return archived;
6503
+ }
5878
6504
  /** A role becomes a society role only while none of its members is in a project. */
5879
6505
  async validateRole(charter) {
5880
6506
  if (charter.name === USER_ROLE) {
@@ -6087,23 +6713,35 @@ ${charter.purpose}
6087
6713
  async publishSignalUnlocked(signal) {
6088
6714
  return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
6089
6715
  }
6716
+ /** A channel that exists, open or archived, and the record of its archive when it has one. */
6090
6717
  async assertChannelExists(ref) {
6091
6718
  const parsed = parseChannelRef(ref);
6092
- if (parsed.project === null) {
6093
- const society = await this.society();
6094
- if (!society.channels.includes(parsed.channel)) {
6095
- throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
6096
- }
6097
- return;
6719
+ const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
6720
+ if (place.channels.includes(parsed.channel)) {
6721
+ return void 0;
6722
+ }
6723
+ const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
6724
+ if (archived === void 0) {
6725
+ throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
6098
6726
  }
6099
- const project = await this.readProject(parsed.project);
6100
- if (!project.channels.includes(parsed.channel)) {
6101
- throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
6727
+ return archived;
6728
+ }
6729
+ /** Whether a channel exists and is not archived, its conversations still going. */
6730
+ async channelOpen(ref) {
6731
+ try {
6732
+ return await this.assertChannelExists(ref) === void 0;
6733
+ } catch {
6734
+ return false;
6102
6735
  }
6103
6736
  }
6104
- /** A channel that takes new posts, threads, and followers: it exists, and its project is not archived. */
6737
+ /**
6738
+ * A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
6739
+ * and its project is not archived.
6740
+ */
6105
6741
  async assertChannelOpen(ref) {
6106
- await this.assertChannelExists(ref);
6742
+ if (await this.assertChannelExists(ref) !== void 0) {
6743
+ throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
6744
+ }
6107
6745
  const { project } = parseChannelRef(ref);
6108
6746
  if (project !== null) {
6109
6747
  await this.readActiveProject(project);
@@ -6203,6 +6841,10 @@ ${charter.purpose}
6203
6841
  if (outcome === "approved") {
6204
6842
  await this.validateProvision(proposal.kind, proposal.charter);
6205
6843
  }
6844
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
6845
+ if (outcome === "approved" && proposal.kind === "archive") {
6846
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
6847
+ }
6206
6848
  const ts = this.now().toISOString();
6207
6849
  const decision = DecisionSchema.parse({
6208
6850
  id: this.newId(),
@@ -6389,6 +7031,8 @@ function decideWake(input) {
6389
7031
  return { wake: true, reason: "operations signal", priority: 0 };
6390
7032
  case "user_post":
6391
7033
  return { wake: true, reason: "user post", priority: 2 };
7034
+ case "closing":
7035
+ return { wake: true, reason: "an ended conversation left work on no branch", priority: 1 };
6392
7036
  case "heartbeat":
6393
7037
  break;
6394
7038
  }
@@ -6404,6 +7048,16 @@ function decideWake(input) {
6404
7048
 
6405
7049
  // packages/scheduler/dist/scheduler.js
6406
7050
  import { z as z13 } from "zod";
7051
+ function besideFields(beside) {
7052
+ if (beside === void 0) {
7053
+ return {};
7054
+ }
7055
+ return "thread" in beside ? { thread: beside.thread } : { channel: beside.channel };
7056
+ }
7057
+ function conversationKey2(agent, scope, beside) {
7058
+ const fields = besideFields(beside);
7059
+ return sessionKey(agent, scope, conversationPart({ thread: fields.thread?.id, channel: fields.channel }));
7060
+ }
6407
7061
  var DEFAULT_TIMINGS = Object.freeze({
6408
7062
  pollMs: 1e3,
6409
7063
  debounceMs: 3e4,
@@ -6529,6 +7183,12 @@ var Scheduler = class {
6529
7183
  /** Tasks whose completion effect waits for the turn that finished their last stage to end. */
6530
7184
  completions = /* @__PURE__ */ new Map();
6531
7185
  completing = /* @__PURE__ */ new Set();
7186
+ /** Running turns that must run alone among their citizen's, by session key: see `heldBack`. */
7187
+ exclusive = /* @__PURE__ */ new Map();
7188
+ /** Tasks being landed now; a turn in a task's thread waits until its landing is done. */
7189
+ landing = /* @__PURE__ */ new Set();
7190
+ /** Sweeps of ended conversations' workspaces under way, which `drain` and `stop` wait for. */
7191
+ sweeping = /* @__PURE__ */ new Set();
6532
7192
  state = StateSchema.parse({
6533
7193
  cursor: null,
6534
7194
  lastHeartbeat: {}
@@ -6581,7 +7241,8 @@ var Scheduler = class {
6581
7241
  await Promise.allSettled([
6582
7242
  ...this.running.values(),
6583
7243
  ...this.completing,
6584
- ...this.steering.values()
7244
+ ...this.steering.values(),
7245
+ ...this.sweeping
6585
7246
  ]);
6586
7247
  }
6587
7248
  /** One pass, unless one is under way already. */
@@ -6630,7 +7291,8 @@ var Scheduler = class {
6630
7291
  await Promise.allSettled([
6631
7292
  ...this.steering.values(),
6632
7293
  ...this.running.values(),
6633
- ...this.completing
7294
+ ...this.completing,
7295
+ ...this.sweeping
6634
7296
  ]);
6635
7297
  }
6636
7298
  async load() {
@@ -6678,7 +7340,7 @@ var Scheduler = class {
6678
7340
  reason: `mentioned by ${event.actor}`,
6679
7341
  ...messageId === void 0 ? {} : { messageId },
6680
7342
  ...channel === null ? {} : { channel }
6681
- }, now, threadId === void 0 ? void 0 : await this.conversationOf(threadId, scope));
7343
+ }, now, await this.postedIn(scope, channel, threadId));
6682
7344
  }
6683
7345
  const step = payload["step"];
6684
7346
  if (event.actor === USER_NAME && (step === void 0 || step === null)) {
@@ -6733,6 +7395,21 @@ var Scheduler = class {
6733
7395
  if (creator !== null && creator !== event.actor) {
6734
7396
  await this.wakeCreator(taskId, project, creator, event.actor, now);
6735
7397
  }
7398
+ const threaded = await this.board.readThread(taskId).then(() => true, () => false);
7399
+ if (!threaded) {
7400
+ this.sweep({ kind: "thread", id: taskId });
7401
+ }
7402
+ return;
7403
+ }
7404
+ case "thread.closed": {
7405
+ const threadId = stringOf(payload["threadId"]);
7406
+ if (threadId !== null) {
7407
+ this.sweep({ kind: "thread", id: threadId });
7408
+ }
7409
+ return;
7410
+ }
7411
+ case "workspace.leftovers": {
7412
+ await this.wakeClosing(payload, now);
6736
7413
  return;
6737
7414
  }
6738
7415
  case "task.updated": {
@@ -6810,6 +7487,26 @@ var Scheduler = class {
6810
7487
  await this.wakeNewcomer(name, project, now);
6811
7488
  return;
6812
7489
  }
7490
+ case "channel.archived": {
7491
+ const channel = stringOf(payload["channel"]);
7492
+ if (channel === null) {
7493
+ return;
7494
+ }
7495
+ const archived = parseChannelRef(channel);
7496
+ this.sweep({
7497
+ kind: "channel",
7498
+ scope: archived.project ?? SOCIETY_SCOPE,
7499
+ name: archived.channel
7500
+ });
7501
+ for (const [key, item] of this.pending) {
7502
+ const { dispatch } = item;
7503
+ if (dispatch.channel !== void 0 && channelConversation(dispatch.project, channel) === dispatch.channel) {
7504
+ this.pending.delete(key);
7505
+ this.log.info({ agent: dispatch.agent, project: dispatch.project, channel }, "dropping channel wake, the channel is archived");
7506
+ }
7507
+ }
7508
+ return;
7509
+ }
6813
7510
  case "agent.left": {
6814
7511
  const name = stringOf(payload["name"]);
6815
7512
  const project = stringOf(payload["project"]);
@@ -6880,7 +7577,7 @@ var Scheduler = class {
6880
7577
  reason: `the user posted in ${channel ?? "a channel"}`,
6881
7578
  ...messageId === void 0 ? {} : { messageId },
6882
7579
  ...channel === null ? {} : { channel }
6883
- }, now, threadId === void 0 ? void 0 : await this.conversationOf(threadId, scope));
7580
+ }, now, await this.postedIn(scope, channel, threadId));
6884
7581
  }
6885
7582
  }
6886
7583
  /**
@@ -6954,7 +7651,7 @@ var Scheduler = class {
6954
7651
  from,
6955
7652
  reason: `stage "${name}" of task ${task.id} "${task.title}" ${state}`,
6956
7653
  taskId: task.id
6957
- }, now, { id: task.id, task: scope === task.project });
7654
+ }, now, { thread: { id: task.id, task: scope === task.project } });
6958
7655
  return sessionKey(agent.name, scope, task.id);
6959
7656
  }
6960
7657
  /**
@@ -7015,6 +7712,49 @@ var Scheduler = class {
7015
7712
  const scope = this.scopeForProject(agent, project);
7016
7713
  this.enqueue(agent.name, scope, { kind: "task_done", from, reason: `task ${taskId} you created is done`, taskId }, now);
7017
7714
  }
7715
+ /**
7716
+ * Whether a dispatch is in the thread of a task whose landing is queued or under way: it waits,
7717
+ * so a stream of turns there cannot hold the landing off, nor commit to the branch during it.
7718
+ */
7719
+ landingIn(dispatch) {
7720
+ const thread = dispatch.thread?.id;
7721
+ return thread !== void 0 && (this.completions.has(thread) || this.landing.has(thread));
7722
+ }
7723
+ /** Asks the runners to remove an ended conversation's workspaces, without holding up the pass. */
7724
+ sweep(conversation) {
7725
+ const sweep = this.runner.sweep?.bind(this.runner);
7726
+ if (sweep === void 0) {
7727
+ return;
7728
+ }
7729
+ const promise = sweep(conversation).catch((error) => {
7730
+ this.log.warn({ conversation, error: String(error) }, "sweeping workspaces failed");
7731
+ }).finally(() => {
7732
+ this.sweeping.delete(promise);
7733
+ });
7734
+ this.sweeping.add(promise);
7735
+ }
7736
+ /**
7737
+ * A citizen's workspace in a conversation that has ended holds work on no branch: one closing turn
7738
+ * there, in the same session and workspace, decides whether it matters and whose it is.
7739
+ */
7740
+ async wakeClosing(payload, now) {
7741
+ const name = stringOf(payload["agent"]);
7742
+ const scope = stringOf(payload["project"]);
7743
+ const thread = stringOf(payload["thread"]);
7744
+ const channel = stringOf(payload["channel"]);
7745
+ const agent = name === null ? null : await this.tryReadAgent(name);
7746
+ if (agent === null || scope === null) {
7747
+ return;
7748
+ }
7749
+ const beside = thread !== null ? { thread: await this.conversationOf(thread, scope) } : channel !== null ? { channel } : null;
7750
+ if (beside === null) {
7751
+ return;
7752
+ }
7753
+ this.enqueue(agent.name, scope, {
7754
+ kind: "closing",
7755
+ reason: "a conversation you took part in has ended, and its workspace holds work on no branch"
7756
+ }, now, beside);
7757
+ }
7018
7758
  /** A decided proposal wakes the citizen that made it, to carry on with what it enables. */
7019
7759
  async wakeProposer(proposalId, proposer, event, now) {
7020
7760
  const agent = await this.tryReadAgent(proposer);
@@ -7044,6 +7784,17 @@ var Scheduler = class {
7044
7784
  return { id: threadId, task: false };
7045
7785
  }
7046
7786
  }
7787
+ /**
7788
+ * The conversation a post wakes a citizen in, for a turn in `scope`: its thread's, else its
7789
+ * channel's when that is a channel of the scope other than general, else the scope's home.
7790
+ */
7791
+ async postedIn(scope, channel, threadId) {
7792
+ if (threadId !== void 0) {
7793
+ return { thread: await this.conversationOf(threadId, scope) };
7794
+ }
7795
+ const name = channel === null ? void 0 : channelConversation(scope, channel);
7796
+ return name === void 0 ? void 0 : { channel: name };
7797
+ }
7047
7798
  async tryReadAgent(name) {
7048
7799
  try {
7049
7800
  const agent = await this.board.readAgent(name);
@@ -7059,8 +7810,8 @@ var Scheduler = class {
7059
7810
  scopeForProject(agent, project) {
7060
7811
  return wakeScope(agent, project);
7061
7812
  }
7062
- /** Queues a wake in one conversation: a thread's, or without one, the scope's home. */
7063
- enqueue(agent, project, input, now, thread) {
7813
+ /** Queues a wake in one conversation: a thread's, a channel's, or without either, the scope's home. */
7814
+ enqueue(agent, project, input, now, beside) {
7064
7815
  const trigger = TriggerSchema.parse(input);
7065
7816
  const decision = decideWake({
7066
7817
  trigger,
@@ -7073,7 +7824,7 @@ var Scheduler = class {
7073
7824
  return;
7074
7825
  }
7075
7826
  const debounce = this.debounceFor(trigger);
7076
- const key = sessionKey(agent, project, thread?.id);
7827
+ const key = conversationKey2(agent, project, beside);
7077
7828
  const readyAt = now + debounce;
7078
7829
  const existing = this.pending.get(key);
7079
7830
  const message = messageOf(trigger);
@@ -7082,7 +7833,7 @@ var Scheduler = class {
7082
7833
  dispatch: {
7083
7834
  agent,
7084
7835
  project,
7085
- ...thread === void 0 ? {} : { thread },
7836
+ ...besideFields(beside),
7086
7837
  trigger,
7087
7838
  priority: decision.priority,
7088
7839
  onboarding: trigger.kind === "onboarding"
@@ -7132,7 +7883,8 @@ var Scheduler = class {
7132
7883
  }
7133
7884
  /**
7134
7885
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
7135
- * scope: the unread messages of its home and of each thread, a stage the member holds, and a stage
7886
+ * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
7887
+ * member holds, and a stage
7136
7888
  * waiting that it may take, each in its task's thread. Each conversation with something wakes on
7137
7889
  * its own, so a heartbeat never brings one task's business into another's turn.
7138
7890
  */
@@ -7163,31 +7915,31 @@ var Scheduler = class {
7163
7915
  unread ??= await this.board.unreadByConversation(actor);
7164
7916
  held ??= await this.board.heldClaims(agent.name);
7165
7917
  const reasons = /* @__PURE__ */ new Map();
7166
- const reasonsIn = (thread) => {
7167
- const key = sessionKey(agent.name, project, thread?.id);
7918
+ const reasonsIn = (beside) => {
7919
+ const key = conversationKey2(agent.name, project, beside);
7168
7920
  const existing = reasons.get(key);
7169
7921
  if (existing !== void 0) {
7170
7922
  return existing;
7171
7923
  }
7172
- const fresh = { thread, digestSize: 0, claimsHeld: 0, waiting: 0 };
7924
+ const fresh = { beside, digestSize: 0, claimsHeld: 0, waiting: 0 };
7173
7925
  reasons.set(key, fresh);
7174
7926
  return fresh;
7175
7927
  };
7176
7928
  for (const entry of unread) {
7177
7929
  if (entry.scope === project) {
7178
- const thread = entry.thread === null ? void 0 : await this.conversationOf(entry.thread, project);
7179
- reasonsIn(thread).digestSize += entry.count;
7930
+ const beside = entry.thread !== null ? { thread: await this.conversationOf(entry.thread, project) } : entry.channel !== null ? { channel: entry.channel } : void 0;
7931
+ reasonsIn(beside).digestSize += entry.count;
7180
7932
  }
7181
7933
  }
7182
7934
  for (const task of held) {
7183
7935
  if (task.project === project) {
7184
- reasonsIn({ id: task.id, task: true }).claimsHeld += 1;
7936
+ reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
7185
7937
  }
7186
7938
  }
7187
7939
  if (project !== SOCIETY_SCOPE) {
7188
7940
  for (const task of await this.board.openTasks(project)) {
7189
7941
  if (mayHoldStage(agent, task)) {
7190
- reasonsIn({ id: task.id, task: true }).waiting += 1;
7942
+ reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
7191
7943
  }
7192
7944
  }
7193
7945
  }
@@ -7208,7 +7960,7 @@ var Scheduler = class {
7208
7960
  dispatch: {
7209
7961
  agent: agent.name,
7210
7962
  project,
7211
- ...reason.thread === void 0 ? {} : { thread: reason.thread },
7963
+ ...besideFields(reason.beside),
7212
7964
  trigger,
7213
7965
  priority: decision.priority,
7214
7966
  onboarding: false
@@ -7246,8 +7998,8 @@ var Scheduler = class {
7246
7998
  this.state.lastReflection[agent.name] = iso(now);
7247
7999
  continue;
7248
8000
  }
7249
- const key = sessionKey(agent.name, latest.scope);
7250
- if (this.pending.has(key) || this.running.has(key)) {
8001
+ const busy = [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent.name);
8002
+ if (busy) {
7251
8003
  continue;
7252
8004
  }
7253
8005
  this.state.lastReflection[agent.name] = iso(now);
@@ -7564,7 +8316,8 @@ var Scheduler = class {
7564
8316
  const attempt = steer({
7565
8317
  agent: dispatch.agent,
7566
8318
  project: dispatch.project,
7567
- ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id }
8319
+ ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
8320
+ ...dispatch.channel === void 0 ? {} : { channel: dispatch.channel }
7568
8321
  }).then((outcome) => {
7569
8322
  const current = this.pending.get(key);
7570
8323
  if (outcome !== "busy" && current !== void 0) {
@@ -7585,16 +8338,19 @@ var Scheduler = class {
7585
8338
  return false;
7586
8339
  }
7587
8340
  const { dispatch } = item;
7588
- const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, dispatch.thread?.id);
8341
+ const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, conversationPart({ thread: dispatch.thread?.id, channel: dispatch.channel }));
7589
8342
  return cursor !== null && cursor >= item.newestMessage;
7590
8343
  }
7591
8344
  async dispatchReady(now) {
7592
8345
  this.steerRunning(now);
7593
- 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);
8346
+ 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);
7594
8347
  for (const [key, item] of ready) {
7595
8348
  if (this.running.size >= this.concurrency) {
7596
8349
  break;
7597
8350
  }
8351
+ if (this.heldBack(key, item.dispatch)) {
8352
+ continue;
8353
+ }
7598
8354
  this.pending.delete(key);
7599
8355
  if (await this.alreadyRead(item)) {
7600
8356
  this.log.info({ agent: item.dispatch.agent, project: item.dispatch.project }, "dropping message wake, its conversation has read the message");
@@ -7618,6 +8374,7 @@ var Scheduler = class {
7618
8374
  agent: dispatch.agent,
7619
8375
  project: dispatch.project,
7620
8376
  thread: dispatch.thread?.id,
8377
+ channel: dispatch.channel,
7621
8378
  trigger: dispatch.trigger.kind
7622
8379
  }, "dispatching turn");
7623
8380
  const promise = (async () => {
@@ -7633,19 +8390,45 @@ var Scheduler = class {
7633
8390
  this.log.error({ agent: dispatch.agent, project: dispatch.project, error: String(error) }, "turn crashed");
7634
8391
  } finally {
7635
8392
  this.running.delete(key);
8393
+ this.exclusive.delete(key);
7636
8394
  }
7637
8395
  })();
7638
8396
  this.running.set(key, promise);
8397
+ if (dispatch.onboarding) {
8398
+ this.exclusive.set(key, "onboarding");
8399
+ } else if (dispatch.trigger.kind === "reflection") {
8400
+ this.exclusive.set(key, "reflection");
8401
+ }
8402
+ }
8403
+ }
8404
+ /**
8405
+ * Whether a ready turn waits for another of its citizen's turns that must run alone: its
8406
+ * onboarding in the project, queued or running, which comes before any other work of its there;
8407
+ * and a reflection, which consolidates the memory the citizen's other turns would be writing, so
8408
+ * it starts only once none of them runs and they wait while it does.
8409
+ */
8410
+ heldBack(key, dispatch) {
8411
+ const { agent, project } = dispatch;
8412
+ const home = sessionKey(agent, project);
8413
+ if (key !== home && (this.pending.get(home)?.dispatch.onboarding === true || this.exclusive.get(home) === "onboarding")) {
8414
+ return true;
8415
+ }
8416
+ const others = [...this.running.keys()].filter((other) => other !== key && parseSessionKey(other)?.agent === agent);
8417
+ if (dispatch.trigger.kind === "reflection") {
8418
+ return others.length > 0;
7639
8419
  }
8420
+ return others.some((other) => this.exclusive.get(other) === "reflection");
7640
8421
  }
7641
8422
  /** Starts each queued completion once the turn that finished the task's last stage has ended. */
7642
8423
  runCompletions() {
7643
8424
  for (const [taskId, item] of this.completions) {
7644
8425
  const home = sessionKey(item.actor, item.project);
7645
- if ([...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`))) {
8426
+ const busy = [...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`) || parseSessionKey(key)?.thread === taskId);
8427
+ if (busy) {
7646
8428
  continue;
7647
8429
  }
7648
8430
  this.completions.delete(taskId);
8431
+ this.landing.add(taskId);
7649
8432
  const promise = this.runner.completeTask(item.project, taskId).then((outcome) => {
7650
8433
  if (outcome === "deferred" && !this.completions.has(taskId)) {
7651
8434
  this.completions.set(taskId, item);
@@ -7655,6 +8438,7 @@ var Scheduler = class {
7655
8438
  this.log.error({ project: item.project, taskId, error: String(error) }, "completion failed");
7656
8439
  }).finally(() => {
7657
8440
  this.completing.delete(promise);
8441
+ this.landing.delete(taskId);
7658
8442
  });
7659
8443
  this.completing.add(promise);
7660
8444
  }
@@ -7666,16 +8450,17 @@ import { randomUUID } from "node:crypto";
7666
8450
 
7667
8451
  // packages/turn-host/dist/render.js
7668
8452
  var TURN_CONTRACT = [
7669
- "- 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.",
8453
+ "- 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.",
7670
8454
  "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
7671
- "- 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. In a proposal's or a topic's conversation it is a worktree of its own, detached at your own branch's tip, which goes when the thread closes, so work that should last goes on a task's branch.",
8455
+ "- 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.",
7672
8456
  "- 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.",
7673
8457
  "- Silence is allowed. If the digest needs no reply, post nothing.",
7674
8458
  "- 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.",
7675
- "- 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.",
8459
+ "- 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.",
7676
8460
  "- 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.",
7677
8461
  '- 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.',
7678
8462
  "- 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.",
8463
+ "- 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.",
7679
8464
  "- 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.",
7680
8465
  "- 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."
7681
8466
  ].join("\n");
@@ -7697,7 +8482,7 @@ var GOVERNANCE = [
7697
8482
  ' - 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',
7698
8483
  ' - reallocation: {"description"}',
7699
8484
  ` - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, where every citizen's skills index lists it`,
7700
- "- 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.",
8485
+ "- 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.",
7701
8486
  "- 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.",
7702
8487
  "- 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."
7703
8488
  ].join("\n");
@@ -7708,6 +8493,9 @@ function renderOnboardingPreamble(context) {
7708
8493
  `Your role in one paragraph: ${context.roleSummary}`,
7709
8494
  "",
7710
8495
  `You are working on project "${context.project}" in the worktree at ${context.worktree}.`,
8496
+ ...(context.otherChannels ?? []).length === 0 ? [] : [
8497
+ `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.`
8498
+ ],
7711
8499
  "Your memory is empty. Write durable lessons about yourself, your craft, or the user to memory/core.md in your home directory.",
7712
8500
  "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.",
7713
8501
  "Read the project's instructions file, if it has one, before doing anything else.",
@@ -7733,7 +8521,7 @@ function renderInstructions(input) {
7733
8521
  if (norms.length > 0) {
7734
8522
  sections.push("", "## Society norms", "", norms);
7735
8523
  }
7736
- 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 conversation there works in a folder of its own inside it, which goes when the thread closes: 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);
8524
+ 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/; each project's dashboard, the status page the user reads on its view, at projects/<slug>/dashboard.md, which its members change with update_dashboard). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
7737
8525
  if (input.onboarding !== void 0) {
7738
8526
  sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
7739
8527
  }
@@ -7780,7 +8568,7 @@ function planLine(task, whose) {
7780
8568
  const index = stageIndex(task, task.stage);
7781
8569
  const stage = task.stages[index];
7782
8570
  const rest = task.stages.slice(index + 1).map((next) => `${next.name} (${next.gate ? "gate, " : ""}${assignee(next)})`);
7783
- const then = rest.length === 0 ? `then ${task.onDone === "merge" ? "the board merges it" : "done"}` : `next: ${rest.join(", ")}`;
8571
+ const then = rest.length === 0 ? `then ${task.onDone === "merge" ? "the board merges it" : task.onDone === "ghpr" ? "the board merges its pull request" : "done"}` : `next: ${rest.join(", ")}`;
7784
8572
  const gated = stage?.gate === true ? ", gated" : "";
7785
8573
  return `- ${task.id} "${task.title}": ${stage?.name ?? task.stage} (${whose}, ${index + 1} of ${task.stages.length}${gated}); ${then}`;
7786
8574
  }
@@ -7870,12 +8658,16 @@ function buildTurnPrompt(input) {
7870
8658
  }
7871
8659
  const conversation = input.conversation ?? null;
7872
8660
  lines.push("", "## This conversation", "");
7873
- if (conversation === null) {
7874
- 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.`);
8661
+ if (conversation === null && dispatch.channel !== void 0) {
8662
+ const ref = channelRef(dispatch.project === SOCIETY_SCOPE ? null : dispatch.project, dispatch.channel);
8663
+ 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}.`);
8664
+ } else if (conversation === null) {
8665
+ 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.`);
7875
8666
  } else {
7876
8667
  const { thread, task } = conversation;
7877
8668
  const about = thread.subject === void 0 ? "a topic" : `${thread.subject.kind} ${thread.subject.id}`;
7878
- lines.push(`This turn is in the thread "${thread.title}" on ${thread.channel}, about ${about}. Only this conversation is below: read anything else you need with get_task, search, or the board's files, and leave other tasks and threads to their own turns. Talk here with post_message and thread_id ${thread.id}.`);
8669
+ const talk = thread.state === "closed" ? `This thread is closed, so posts to it are refused: if something still needs saying, post it in ${conversation.replyIn ?? thread.channel}.` : `Talk here with post_message and thread_id ${thread.id}.`;
8670
+ lines.push(`This turn is in the thread "${thread.title}" on ${thread.channel}, about ${about}. Only this conversation is below: read anything else you need with get_task, search, or the board's files, and leave other tasks and threads to their own turns. ${talk}`);
7879
8671
  if (task !== null) {
7880
8672
  lines.push("", `Task ${task.id} "${task.title}", ${task.status}:`, planLine(task, "now"));
7881
8673
  if (task.body.trim() !== "") {
@@ -7883,6 +8675,11 @@ function buildTurnPrompt(input) {
7883
8675
  }
7884
8676
  }
7885
8677
  }
8678
+ const ending = input.ending ?? null;
8679
+ if (ending !== null) {
8680
+ 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.");
8681
+ }
8682
+ lines.push("", WORKSPACE_TOKEN);
7886
8683
  const conflicts = input.conflicts ?? [];
7887
8684
  if (dispatch.trigger.kind === "reflection") {
7888
8685
  lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS);
@@ -7899,7 +8696,7 @@ function buildTurnPrompt(input) {
7899
8696
  }
7900
8697
  const society = input.societyView;
7901
8698
  if (society !== void 0 && society !== null) {
7902
- lines.push("", "## The society", "", "### Projects", "");
8699
+ 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", "");
7903
8700
  if (society.projects.length === 0) {
7904
8701
  lines.push("None yet.");
7905
8702
  }
@@ -7934,8 +8731,13 @@ function buildTurnPrompt(input) {
7934
8731
  }
7935
8732
  }
7936
8733
  const project = input.project;
7937
- if (project !== void 0 && project !== null && project.onDone === "merge") {
7938
- lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its branch task/<id> into ${project.defaultBranch}. Never merge or fast-forward ${project.defaultBranch} yourself.`);
8734
+ const landing = conversation?.task?.onDone ?? project?.onDone;
8735
+ if (project !== void 0 && project !== null && landing === "merge") {
8736
+ lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its branch task/<id> into ${project.defaultBranch}. Never merge or fast-forward ${project.defaultBranch} yourself, or push other work to it on the remote; the runner refuses both.`);
8737
+ }
8738
+ if (project !== void 0 && project !== null && landing === "ghpr") {
8739
+ const base = project.defaultBranch;
8740
+ lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its pull request on GitHub into ${base}. Open the pull request yourself, from a branch named for what it does that holds the same files as task/<id>, as \`git push origin task/<id>:<branch>\` gives, and link it to the task with update_task pull_request before its last stage completes, with the merge commit's merge_subject and merge_body, which you may change by linking again until it lands. Never merge the pull request or ${base} yourself, nor push other work to ${base}: the board merges once the task's stages are done, and the runner refuses the pushes.`);
7939
8741
  }
7940
8742
  if (conflicts.length > 0) {
7941
8743
  lines.push("", "## Edits to reconcile in your home", "", "Two of your turns changed these files at once on different runners. The file in place kept the other turn's version; yours is the copy named here, beside it. Merge what still holds into the file, then delete the copy.", "", ...conflicts.map((conflict) => `- ${conflict.path}, beside ${conflict.file}, since ${conflict.since}`));
@@ -7981,6 +8783,9 @@ function buildTurnPrompt(input) {
7981
8783
  return `${lines.join("\n")}
7982
8784
  `;
7983
8785
  }
8786
+ function capitalized(text) {
8787
+ return text.charAt(0).toUpperCase() + text.slice(1);
8788
+ }
7984
8789
 
7985
8790
  // packages/turn-host/dist/host.js
7986
8791
  var SILENT = { info() {
@@ -8036,11 +8841,14 @@ var TurnHost = class {
8036
8841
  const agent = await this.board.readAgent(dispatch.agent);
8037
8842
  const actor = { name: agent.name, role: agent.role };
8038
8843
  const thread = dispatch.thread;
8844
+ const channel = thread === void 0 ? dispatch.channel : void 0;
8845
+ const part = conversationPart({ thread: thread?.id, channel });
8039
8846
  const startedAt = this.now().toISOString();
8040
8847
  const base = {
8041
8848
  agent: agent.name,
8042
8849
  project: dispatch.project,
8043
8850
  ...thread === void 0 ? {} : { thread: thread.id },
8851
+ ...channel === void 0 ? {} : { channel },
8044
8852
  runner: runner.name,
8045
8853
  cli: agent.cli,
8046
8854
  session: null,
@@ -8065,23 +8873,29 @@ var TurnHost = class {
8065
8873
  const societyScope = dispatch.project === SOCIETY_SCOPE;
8066
8874
  const taskId = thread?.task === true ? thread.id : void 0;
8067
8875
  const project = societyScope ? null : await this.board.readProject(dispatch.project);
8068
- const workspace = await this.workspaceOf(project, thread);
8069
- const sessions = await this.board.readSessions(agent.name, dispatch.project, thread?.id);
8876
+ const workspace = await this.workspaceOf(project, thread, channel);
8877
+ const sessions = await this.board.readSessions(agent.name, dispatch.project, part);
8070
8878
  const recorded = sessions.runner === runner.name ? sessions[agent.cli] : void 0;
8071
8879
  const newSession = recorded === void 0;
8072
- const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, thread?.id);
8880
+ const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
8073
8881
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
8074
- const digest = await this.board.readDigest({ ...actor, scope: dispatch.project, ...thread === void 0 ? {} : { thread: thread.id } }, { advance: false, limit: thread === void 0 ? 50 : 100 });
8882
+ const digest = await this.board.readDigest({
8883
+ ...actor,
8884
+ scope: dispatch.project,
8885
+ ...thread === void 0 ? {} : { thread: thread.id },
8886
+ ...channel === void 0 ? {} : { channel }
8887
+ }, { advance: false, limit: thread === void 0 ? 50 : 100 });
8075
8888
  const threadSoFar = thread !== void 0 && newSession ? (await this.board.listThread(thread.id).catch(() => [])).slice(-100) : null;
8076
8889
  const messages = threadSoFar ?? digest.messages;
8077
8890
  const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
8078
8891
  const task = taskId === void 0 ? null : await this.board.findTask(taskId).then((found) => found.task).catch(() => null);
8079
8892
  const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
8080
- const onboarding = dispatch.onboarding || newSession && thread === void 0 ? {
8893
+ const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
8081
8894
  agentName: agent.name,
8082
8895
  roleSummary: charter.purpose,
8083
8896
  project: dispatch.project,
8084
- worktree: PATH_TOKENS.worktree
8897
+ worktree: PATH_TOKENS.worktree,
8898
+ otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
8085
8899
  } : null;
8086
8900
  const instructions = renderInstructions({
8087
8901
  agentName: agent.name,
@@ -8117,7 +8931,7 @@ var TurnHost = class {
8117
8931
  dir: `${PATH_TOKENS.board}/projects/${dispatch.project}/knowledge`,
8118
8932
  topics: await this.board.listKnowledge(dispatch.project)
8119
8933
  };
8120
- 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;
8934
+ 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;
8121
8935
  const waiting = task !== null && task.status === "open" && !task.completing && mayHoldStage(actor, task) ? [task] : [];
8122
8936
  const threads = /* @__PURE__ */ new Map();
8123
8937
  for (const id of new Set(messages.flatMap((message) => message.thread ?? []))) {
@@ -8127,12 +8941,19 @@ var TurnHost = class {
8127
8941
  }
8128
8942
  }
8129
8943
  const threadRecord = thread === void 0 ? void 0 : threads.get(thread.id) ?? await this.board.readThread(thread.id).catch(() => void 0);
8130
- const conversation = threadRecord === void 0 ? null : { thread: threadRecord, task, fresh: threadSoFar !== null };
8944
+ const conversation = threadRecord === void 0 ? null : {
8945
+ thread: threadRecord,
8946
+ task,
8947
+ fresh: threadSoFar !== null,
8948
+ ...threadRecord.state === "closed" ? { replyIn: await this.replyIn(threadRecord) } : {}
8949
+ };
8950
+ const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
8131
8951
  const prompt = buildTurnPrompt({
8132
8952
  dispatch,
8133
8953
  messages,
8134
8954
  threads,
8135
8955
  conversation,
8956
+ ending,
8136
8957
  heldClaims: held,
8137
8958
  waitingStages: waiting,
8138
8959
  project,
@@ -8144,9 +8965,9 @@ var TurnHost = class {
8144
8965
  signals,
8145
8966
  conflicts: await this.board.listHomeConflicts(agent.name)
8146
8967
  });
8147
- const resident = charter.resident && thread === void 0 && runner.residentClis.includes(agent.cli);
8968
+ const resident = charter.resident && part === void 0 && runner.residentClis.includes(agent.cli);
8148
8969
  const residentKey = resident ? sessionKey(agent.name, dispatch.project) : null;
8149
- 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);
8970
+ 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);
8150
8971
  const record = await this.board.beginTurn({
8151
8972
  ...base,
8152
8973
  session: recorded ?? null,
@@ -8164,6 +8985,7 @@ var TurnHost = class {
8164
8985
  ...agent.effort === void 0 ? {} : { effort: agent.effort },
8165
8986
  scope: dispatch.project,
8166
8987
  ...thread === void 0 ? {} : { thread: thread.id },
8988
+ ...channel === void 0 ? {} : { channel },
8167
8989
  session: recorded ?? null,
8168
8990
  costSoFarUsd,
8169
8991
  instructions,
@@ -8202,6 +9024,7 @@ var TurnHost = class {
8202
9024
  agent: agent.name,
8203
9025
  project: dispatch.project,
8204
9026
  thread: thread?.id,
9027
+ channel,
8205
9028
  runner: runner.name,
8206
9029
  session: recorded,
8207
9030
  newSession,
@@ -8239,6 +9062,7 @@ var TurnHost = class {
8239
9062
  agent: turn.job.agent,
8240
9063
  project: turn.job.scope,
8241
9064
  thread: turn.job.thread,
9065
+ channel: turn.job.channel,
8242
9066
  messages: sent.messages
8243
9067
  }).catch((error) => {
8244
9068
  this.log.warn({ turnId, error: String(error) }, "could not record a steer");
@@ -8246,19 +9070,31 @@ var TurnHost = class {
8246
9070
  }
8247
9071
  }
8248
9072
  turn.transcript.push(entry);
8249
- this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, turn.job.thread);
9073
+ this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, {
9074
+ thread: turn.job.thread,
9075
+ channel: turn.job.channel
9076
+ });
8250
9077
  }
8251
9078
  }
8252
9079
  /** The turn in flight in a conversation, if any. */
8253
- turnIn(agent, scope, thread) {
9080
+ turnIn(agent, scope, conversation = {}) {
8254
9081
  for (const turn of this.open.values()) {
8255
9082
  const { job } = turn;
8256
- if (job.agent === agent && job.scope === scope && job.thread === thread) {
9083
+ if (job.agent === agent && job.scope === scope && job.thread === conversation.thread && job.channel === conversation.channel) {
8257
9084
  return { turnId: job.turnId, runner: turn.runner, cli: job.cli };
8258
9085
  }
8259
9086
  }
8260
9087
  return null;
8261
9088
  }
9089
+ /** The turns in flight and their conversations, which the board checks before ending one. */
9090
+ inFlight() {
9091
+ return [...this.open.values()].map(({ job }) => ({
9092
+ agent: job.agent,
9093
+ scope: job.scope,
9094
+ ...job.thread === void 0 ? {} : { thread: job.thread },
9095
+ ...job.channel === void 0 ? {} : { channel: job.channel }
9096
+ }));
9097
+ }
8262
9098
  /** The turns in flight, for the interface: their conversations and where a mention reaches them. */
8263
9099
  runningTurns() {
8264
9100
  return [...this.open.values()].map((turn) => ({
@@ -8266,8 +9102,9 @@ var TurnHost = class {
8266
9102
  agent: turn.job.agent,
8267
9103
  scope: turn.job.scope,
8268
9104
  ...turn.job.thread === void 0 ? {} : { thread: turn.job.thread },
9105
+ ...turn.job.channel === void 0 ? {} : { channel: turn.job.channel },
8269
9106
  cli: turn.job.cli,
8270
- ...turn.record.trigger.channel === void 0 ? {} : { channel: turn.record.trigger.channel },
9107
+ ...turn.record.trigger.channel === void 0 ? {} : { askedIn: turn.record.trigger.channel },
8271
9108
  runner: turn.runner
8272
9109
  }));
8273
9110
  }
@@ -8288,7 +9125,8 @@ var TurnHost = class {
8288
9125
  const digest = await this.board.readDigest({
8289
9126
  ...turn.actor,
8290
9127
  scope: job.scope,
8291
- ...job.thread === void 0 ? {} : { thread: job.thread }
9128
+ ...job.thread === void 0 ? {} : { thread: job.thread },
9129
+ ...job.channel === void 0 ? {} : { channel: job.channel }
8292
9130
  }, {
8293
9131
  ...turn.delivered === null ? {} : { since_cursor: turn.delivered },
8294
9132
  advance: false,
@@ -8348,6 +9186,7 @@ var TurnHost = class {
8348
9186
  this.board.revokeTurnToken(turn.coldToken);
8349
9187
  }
8350
9188
  const { job } = turn;
9189
+ const part = conversationPart(job);
8351
9190
  const finished = {
8352
9191
  ...turn.record,
8353
9192
  session: outcome.session,
@@ -8363,29 +9202,52 @@ var TurnHost = class {
8363
9202
  ...outcome.work === null ? {} : { work: outcome.work },
8364
9203
  ...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {}
8365
9204
  };
9205
+ const ended = await this.conversationEnded(job);
9206
+ const closing = turn.record.trigger.kind === "closing";
9207
+ const decided = closing && outcome.exitReason !== "error" && outcome.exitReason !== "timeout";
8366
9208
  const recorded = await this.recorded(turn, async () => {
8367
9209
  if (outcome.session !== job.session) {
8368
- await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, job.thread);
9210
+ await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
8369
9211
  }
8370
9212
  if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
8371
- await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, job.thread);
9213
+ await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
8372
9214
  await this.renewLeases(turn.actor, turn.held);
8373
9215
  if (outcome.status?.needsUserDecision === true) {
8374
- await this.askUser(turn.actor, job.scope, job.thread, turn.record.startedAt, outcome.status.summary);
9216
+ await this.askUser(turn.actor, job, turn.record.startedAt, outcome.status.summary);
8375
9217
  }
8376
9218
  }
8377
9219
  await this.board.finishTurn(finished, turn.transcript);
9220
+ const conversation = ended ? workspaceConversationOf(job) : null;
9221
+ if (conversation !== null && (closing ? !decided : outcome.leftovers)) {
9222
+ const again = await this.board.recordLeftovers({
9223
+ agent: job.agent,
9224
+ scope: job.scope,
9225
+ conversation
9226
+ });
9227
+ if (!again) {
9228
+ const where = conversation.kind === "thread" ? `thread ${conversation.id}` : `channel ${conversation.name}`;
9229
+ await this.board.publishSignal({
9230
+ kind: "stuck_workspace",
9231
+ key: `stuck_workspace:${job.agent}:${job.scope}:${conversationPart(job) ?? ""}`,
9232
+ 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}`,
9233
+ value: CLOSING_ATTEMPTS,
9234
+ agent: job.agent,
9235
+ ...job.scope === SOCIETY_SCOPE ? {} : { project: job.scope }
9236
+ });
9237
+ }
9238
+ }
8378
9239
  });
8379
9240
  if (recorded) {
8380
9241
  this.log.info({
8381
9242
  agent: job.agent,
8382
9243
  project: job.scope,
8383
9244
  thread: job.thread,
9245
+ channel: job.channel,
8384
9246
  exitReason: finished.exitReason
8385
9247
  }, "turn finished");
8386
9248
  turn.done(finished);
8387
9249
  }
8388
- return { dropWorkspace: await this.conversationEnded(job) };
9250
+ return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
8389
9251
  }
8390
9252
  /** Ends a turn whose outcome will never come, as when its runner went away, as failed. */
8391
9253
  async abortTurn(turnId, reason) {
@@ -8433,6 +9295,7 @@ var TurnHost = class {
8433
9295
  agent: dispatch.agent,
8434
9296
  project: dispatch.project,
8435
9297
  ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
9298
+ ...dispatch.thread !== void 0 || dispatch.channel === void 0 ? {} : { channel: dispatch.channel },
8436
9299
  runner,
8437
9300
  cli: agent?.cli ?? null,
8438
9301
  session: null,
@@ -8477,48 +9340,66 @@ var TurnHost = class {
8477
9340
  this.residentTokens.set(slot, token);
8478
9341
  return token;
8479
9342
  }
8480
- /** Where a conversation works: a task's on its branch, any other thread's in a place of its own. */
8481
- async workspaceOf(project, thread) {
8482
- const own = thread === void 0 || thread.task ? {} : { thread: thread.id };
9343
+ /**
9344
+ * Where a conversation works: a task's on its branch, any other thread's and a channel's in a
9345
+ * place of its own, and the home in the citizen's own.
9346
+ */
9347
+ async workspaceOf(project, thread, channel) {
9348
+ const own = thread !== void 0 ? thread.task ? {} : { thread: thread.id } : channel === void 0 ? {} : { channel };
8483
9349
  if (project === null) {
8484
9350
  return { kind: "home", ...own };
8485
9351
  }
8486
- const repo = {
8487
- slug: project.slug,
8488
- origin: project.repo,
8489
- defaultBranch: project.defaultBranch
8490
- };
9352
+ const repo = projectRepo(project);
8491
9353
  if (thread?.task === true) {
8492
9354
  return { kind: "task", repo, taskId: thread.id };
8493
9355
  }
8494
9356
  const branches = (await this.board.listTasks(project.slug)).filter((task) => task.status === "open" || task.status === "claimed").map((task) => `task/${task.id}`);
8495
9357
  return { kind: "project", repo, branches, ...own };
8496
9358
  }
8497
- /** Whether a thread conversation's task or thread has ended, which frees its workspace. */
9359
+ /**
9360
+ * Whether a thread conversation's task or thread has ended, or a channel conversation's channel
9361
+ * was archived, which frees its workspace.
9362
+ */
8498
9363
  async conversationEnded(job) {
8499
- if (job.thread === void 0) {
8500
- return false;
9364
+ const conversation = workspaceConversationOf(job);
9365
+ return conversation !== null && await this.board.conversationEnded(conversation);
9366
+ }
9367
+ /** Where a closed thread's citizens say what still needs saying: its channel, else its place's general. */
9368
+ async replyIn(thread) {
9369
+ return await this.board.channelOpen(thread.channel) ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general");
9370
+ }
9371
+ /**
9372
+ * How a closing turn's conversation ended, and where its citizen asks someone else to take over
9373
+ * work that is not its own: the thread's channel while it is open, else the place's general.
9374
+ */
9375
+ async endingOf(scope, thread, task, channel) {
9376
+ const place = scope === SOCIETY_SCOPE ? null : scope;
9377
+ if (thread !== void 0) {
9378
+ const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
9379
+ return { how: closed, askIn: await this.replyIn(thread) };
8501
9380
  }
8502
- try {
8503
- if (job.workspace.kind === "task") {
8504
- const { task } = await this.board.findTask(job.thread);
8505
- return task.status === "done" || task.status === "abandoned";
8506
- }
8507
- return (await this.board.readThread(job.thread)).state === "closed";
8508
- } catch {
8509
- return false;
9381
+ if (channel === void 0) {
9382
+ return null;
8510
9383
  }
9384
+ const ref = channelRef(place, channel);
9385
+ const archived = (await this.board.listChannels()).find((each) => each.ref === ref)?.archived;
9386
+ return {
9387
+ how: archived === void 0 ? `${ref} was archived` : `${ref} was archived by ${archived.by}: ${archived.reason}`,
9388
+ askIn: channelRef(place, "general")
9389
+ };
8511
9390
  }
8512
9391
  /**
8513
9392
  * A turn that reported the user must decide asks where the question belongs, with a mention of
8514
9393
  * the user, which is what puts it before them. One that mentioned the user nowhere asks in its
8515
9394
  * own thread when it was in one that is still open, so the answer comes back to the same
8516
- * conversation, and otherwise in a thread of its own on its scope's general channel, opened as the
8517
- * citizen with its summary. One that proposed something the user decides has put its decision
8518
- * before the user already, where deciding it clears it; a mention would stay until the user
8519
- * posted beside it.
9395
+ * conversation, and otherwise in a thread of its own on its conversation's channel, or its scope's
9396
+ * general for the home, opened as the citizen with its summary. One that proposed something the
9397
+ * user decides has put its decision before the user already, where deciding it clears it; a
9398
+ * mention would stay until the user posted beside it.
8520
9399
  */
8521
- async askUser(actor, scope, threadId, since, summary) {
9400
+ async askUser(actor, job, since, summary) {
9401
+ const threadId = job.thread;
9402
+ const place = job.scope === SOCIETY_SCOPE ? null : job.scope;
8522
9403
  try {
8523
9404
  if (await this.board.hasMentioned(actor.name, USER_NAME, since)) {
8524
9405
  return;
@@ -8536,7 +9417,7 @@ var TurnHost = class {
8536
9417
  return;
8537
9418
  }
8538
9419
  const thread = await this.board.openThread(actor, {
8539
- channel: scope === SOCIETY_SCOPE ? "general" : `${scope}/general`,
9420
+ channel: channelRef(place, job.channel ?? "general"),
8540
9421
  title: `${actor.name} asks for a decision: ${summary}`.slice(0, 200)
8541
9422
  });
8542
9423
  await this.board.postMessage(actor, {
@@ -8587,12 +9468,15 @@ var DEFAULT_GRACE_MS = 2 * 6e4;
8587
9468
  var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
8588
9469
  var CONTROL_TIMEOUT_MS = 15e3;
8589
9470
  var FILE_TIMEOUT_MS = 6e4;
9471
+ var SWEEP_TIMEOUT_MS = 2 * 6e4;
9472
+ var DEFAULT_ACK_TIMEOUT_MS = 3e4;
8590
9473
  var RunnerHub = class {
8591
9474
  board;
8592
9475
  host;
8593
9476
  version;
8594
9477
  graceMs;
8595
9478
  requestTimeoutMs;
9479
+ ackTimeoutMs;
8596
9480
  now;
8597
9481
  log;
8598
9482
  seats = /* @__PURE__ */ new Map();
@@ -8608,6 +9492,7 @@ var RunnerHub = class {
8608
9492
  this.version = options.version;
8609
9493
  this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
8610
9494
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
9495
+ this.ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
8611
9496
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
8612
9497
  this.log = options.log ?? SILENT2;
8613
9498
  this.startedAt = this.now().getTime();
@@ -8623,9 +9508,15 @@ var RunnerHub = class {
8623
9508
  // -------------------------------------------------------------------------------------------
8624
9509
  // The protocol, as the server's routes call it
8625
9510
  // -------------------------------------------------------------------------------------------
9511
+ /** The jobs sent to a runner that it has not acknowledged yet. */
9512
+ unacknowledged(runner) {
9513
+ return [...this.seats.get(runner)?.unacked.keys() ?? []];
9514
+ }
8626
9515
  /**
8627
- * A runner registers, first or again. Its turns in flight that it no longer runs, because it
8628
- * restarted, end as failed.
9516
+ * A runner registers, first or again. A turn in flight it still runs, or whose job it never
9517
+ * acknowledged, carries on, the latter sent again once its stream opens, since a job written
9518
+ * into a stream that had stopped delivering never arrived; one it took and no longer runs,
9519
+ * because it restarted, ends as failed.
8629
9520
  */
8630
9521
  async register(name, hello) {
8631
9522
  if (hello.protocol !== RUNNER_PROTOCOL) {
@@ -8640,25 +9531,31 @@ var RunnerHub = class {
8640
9531
  inUse: 0,
8641
9532
  warm: /* @__PURE__ */ new Set(),
8642
9533
  disconnectedAt: null,
8643
- grace: null
9534
+ grace: null,
9535
+ unacked: /* @__PURE__ */ new Map(),
9536
+ unconfirmed: false
8644
9537
  });
8645
9538
  } else {
8646
9539
  seat.hello = hello;
8647
9540
  seat.warm = /* @__PURE__ */ new Set();
8648
9541
  }
8649
9542
  const still = new Set(hello.turns);
9543
+ const unacked = this.seats.get(name)?.unacked ?? /* @__PURE__ */ new Map();
8650
9544
  for (const turnId of this.host.turnsOn(name)) {
8651
- if (!still.has(turnId)) {
9545
+ if (still.has(turnId)) {
9546
+ this.settle(unacked, turnId);
9547
+ } else if (!unacked.has(turnId)) {
8652
9548
  await this.host.abortTurn(turnId, `runner ${name} restarted during the turn`);
8653
9549
  }
8654
9550
  }
8655
9551
  await this.board.markRunner(name, {
8656
9552
  os: hello.os,
8657
9553
  clis: hello.clis,
8658
- capabilities: hello.capabilities
9554
+ capabilities: hello.capabilities,
9555
+ version: hello.version
8659
9556
  });
8660
9557
  this.runnerChanged(name);
8661
- this.log.info({ runner: name, clis: hello.clis, slots: hello.slots }, "runner registered");
9558
+ this.log.info({ runner: name, version: hello.version, clis: hello.clis, slots: hello.slots }, "runner registered");
8662
9559
  return { name, version: this.version };
8663
9560
  }
8664
9561
  /** A registered runner's stream opened. Returns what to call when it closes. */
@@ -8669,10 +9566,17 @@ var RunnerHub = class {
8669
9566
  }
8670
9567
  seat.send = send;
8671
9568
  seat.disconnectedAt = null;
9569
+ seat.unconfirmed = false;
8672
9570
  if (seat.grace !== null) {
8673
9571
  clearTimeout(seat.grace);
8674
9572
  seat.grace = null;
8675
9573
  }
9574
+ for (const { job } of seat.unacked.values()) {
9575
+ if (send({ type: "turn", job })) {
9576
+ this.expectAck(seat, job);
9577
+ this.log.info({ runner: name, turnId: job.turnId }, "sent again a job the runner never took");
9578
+ }
9579
+ }
8676
9580
  await this.board.markRunner(name, { status: "connected" });
8677
9581
  this.runnerChanged(name);
8678
9582
  return async () => {
@@ -8697,6 +9601,9 @@ var RunnerHub = class {
8697
9601
  clearTimeout(seat.grace);
8698
9602
  seat.grace = null;
8699
9603
  }
9604
+ for (const { timer } of seat.unacked.values()) {
9605
+ clearTimeout(timer);
9606
+ }
8700
9607
  }
8701
9608
  await Promise.allSettled(this.detaching);
8702
9609
  }
@@ -8704,6 +9611,18 @@ var RunnerHub = class {
8704
9611
  this.assertOwns(runner, turnId);
8705
9612
  await this.host.addEvents(turnId, entries);
8706
9613
  }
9614
+ /** A runner's word that a turn's job arrived, which also shows its stream delivers again. */
9615
+ received(runner, turnId) {
9616
+ const seat = this.seats.get(runner);
9617
+ if (seat === void 0 || !seat.unacked.has(turnId)) {
9618
+ return;
9619
+ }
9620
+ this.settle(seat.unacked, turnId);
9621
+ if (seat.unconfirmed) {
9622
+ seat.unconfirmed = false;
9623
+ this.log.info({ runner }, "runner is taking jobs again");
9624
+ }
9625
+ }
8707
9626
  async turnOutcome(runner, turnId, outcome) {
8708
9627
  this.assertOwns(runner, turnId);
8709
9628
  const ack = await this.host.closeTurn(turnId, outcome);
@@ -8722,6 +9641,25 @@ var RunnerHub = class {
8722
9641
  pending.reject(new Error(answer.error));
8723
9642
  }
8724
9643
  }
9644
+ /**
9645
+ * The workspaces a runner holds, reported when its stream opens: those of conversations that
9646
+ * ended while it was away are swept there now, as a sweep at their end would have.
9647
+ */
9648
+ heldWorkspaces(runner, workspaces) {
9649
+ const seat = this.seats.get(runner);
9650
+ if (seat === void 0) {
9651
+ return;
9652
+ }
9653
+ const conversations = /* @__PURE__ */ new Map();
9654
+ for (const { conversation } of workspaces) {
9655
+ conversations.set(JSON.stringify(conversation), conversation);
9656
+ }
9657
+ for (const conversation of conversations.values()) {
9658
+ void this.board.conversationEnded(conversation).then((ended) => ended ? this.sweepOn(seat, conversation) : void 0).catch((error) => {
9659
+ this.log.warn({ runner, conversation, error: String(error) }, "could not sweep");
9660
+ });
9661
+ }
9662
+ }
8725
9663
  warmChanged(runner, warm) {
8726
9664
  const seat = this.seats.get(runner);
8727
9665
  if (seat !== void 0) {
@@ -8771,12 +9709,25 @@ var RunnerHub = class {
8771
9709
  // -------------------------------------------------------------------------------------------
8772
9710
  // The scheduler's runner
8773
9711
  // -------------------------------------------------------------------------------------------
9712
+ /**
9713
+ * A conversation ended: every connected runner removes its citizens' workspaces for it, and
9714
+ * those kept for holding work on no branch are recorded, which gives their citizens a closing
9715
+ * turn. A runner away now sweeps when it reports its workspaces on reconnecting.
9716
+ */
9717
+ async sweep(conversation) {
9718
+ await Promise.all([...this.seats.values()].filter((seat) => seat.send !== null).map((seat) => this.sweepOn(seat, conversation).catch((error) => {
9719
+ this.log.warn({ runner: seat.name, conversation, error: String(error) }, "could not sweep");
9720
+ })));
9721
+ }
8774
9722
  /**
8775
9723
  * Delivers what arrived in a conversation into its turn in flight, when that turn's runner can
8776
9724
  * steer its CLI. `refused` leaves the wake to a turn of its own once this one ends.
8777
9725
  */
8778
9726
  async steer(conversation) {
8779
- const turn = this.host.turnIn(conversation.agent, conversation.project, conversation.thread);
9727
+ const turn = this.host.turnIn(conversation.agent, conversation.project, {
9728
+ thread: conversation.thread,
9729
+ channel: conversation.channel
9730
+ });
8780
9731
  const seat = turn === null ? void 0 : this.seats.get(turn.runner);
8781
9732
  if (turn === null || seat === void 0 || seat.send === null) {
8782
9733
  return "refused";
@@ -8815,7 +9766,7 @@ var RunnerHub = class {
8815
9766
  return { refused: `${agent.name} has no CLI binding` };
8816
9767
  }
8817
9768
  const cli = agent.cli;
8818
- const free = (seat) => seat.send !== null && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
9769
+ const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
8819
9770
  let chosen;
8820
9771
  if (dispatch.project === SOCIETY_SCOPE) {
8821
9772
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
@@ -8854,6 +9805,7 @@ var RunnerHub = class {
8854
9805
  return this.host.refuseTurn(dispatch, "none", assignment.refused);
8855
9806
  }
8856
9807
  const seat = this.seats.get(assignment.runner);
9808
+ let turnId = null;
8857
9809
  try {
8858
9810
  if (seat === void 0) {
8859
9811
  return await this.host.refuseTurn(dispatch, assignment.runner, "the runner is gone");
@@ -8864,11 +9816,17 @@ var RunnerHub = class {
8864
9816
  }
8865
9817
  if (seat.send === null || !seat.send({ type: "turn", job: opened.job })) {
8866
9818
  await this.host.abortTurn(opened.job.turnId, `runner ${seat.name} went away before the turn`);
9819
+ } else {
9820
+ this.expectAck(seat, opened.job);
8867
9821
  }
9822
+ turnId = opened.job.turnId;
8868
9823
  return await opened.ended;
8869
9824
  } finally {
8870
9825
  if (seat !== void 0) {
8871
9826
  seat.inUse = Math.max(0, seat.inUse - 1);
9827
+ if (turnId !== null) {
9828
+ this.settle(seat.unacked, turnId);
9829
+ }
8872
9830
  }
8873
9831
  }
8874
9832
  }
@@ -8877,7 +9835,7 @@ var RunnerHub = class {
8877
9835
  if (!task.completing) {
8878
9836
  return "done";
8879
9837
  }
8880
- if (task.onDone !== "merge") {
9838
+ if (task.onDone === "none") {
8881
9839
  await this.board.finishCompletion(SYSTEM_ACTOR, {
8882
9840
  taskId,
8883
9841
  ok: true,
@@ -8887,6 +9845,15 @@ var RunnerHub = class {
8887
9845
  }
8888
9846
  const record = await this.board.readProject(project);
8889
9847
  const branch = `task/${taskId}`;
9848
+ const pull = task.onDone === "ghpr" ? task.pullRequest : void 0;
9849
+ if (task.onDone === "ghpr" && pull === void 0) {
9850
+ await this.board.finishCompletion(SYSTEM_ACTOR, {
9851
+ taskId,
9852
+ ok: false,
9853
+ detail: "no pull request is linked to the task"
9854
+ });
9855
+ return "done";
9856
+ }
8890
9857
  let outcome = { ok: true, detail: "nothing to land, since the task left no branch" };
8891
9858
  if (record.runner !== void 0) {
8892
9859
  const seat = this.seats.get(record.runner);
@@ -8898,8 +9865,15 @@ var RunnerHub = class {
8898
9865
  type: "land",
8899
9866
  request,
8900
9867
  land: {
8901
- repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch },
8902
- branch
9868
+ repo: projectRepo(record),
9869
+ branch,
9870
+ ...pull === void 0 ? {} : {
9871
+ pullRequest: {
9872
+ url: pull.url,
9873
+ ...pull.subject === void 0 ? {} : { subject: pull.subject },
9874
+ ...pull.body === void 0 ? {} : { body: pull.body }
9875
+ }
9876
+ }
8903
9877
  }
8904
9878
  })));
8905
9879
  } catch (error) {
@@ -8975,6 +9949,34 @@ var RunnerHub = class {
8975
9949
  }
8976
9950
  }
8977
9951
  // -------------------------------------------------------------------------------------------
9952
+ /**
9953
+ * Waits for a runner to acknowledge a job. One that does not in time may be behind a stream that
9954
+ * looks open but delivers nothing, as when its machine's network stops passing traffic, so no
9955
+ * more turns go to it until it acknowledges or connects again; the turns it has carry on, since
9956
+ * they report by posts.
9957
+ */
9958
+ expectAck(seat, job) {
9959
+ const previous = seat.unacked.get(job.turnId);
9960
+ if (previous !== void 0) {
9961
+ clearTimeout(previous.timer);
9962
+ }
9963
+ const timer = setTimeout(() => {
9964
+ if (!seat.unacked.has(job.turnId) || seat.unconfirmed || seat.send === null) {
9965
+ return;
9966
+ }
9967
+ seat.unconfirmed = true;
9968
+ this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does or reconnects");
9969
+ }, this.ackTimeoutMs);
9970
+ timer.unref?.();
9971
+ seat.unacked.set(job.turnId, { job, timer });
9972
+ }
9973
+ settle(unacked, turnId) {
9974
+ const entry = unacked.get(turnId);
9975
+ if (entry !== void 0) {
9976
+ clearTimeout(entry.timer);
9977
+ unacked.delete(turnId);
9978
+ }
9979
+ }
8978
9980
  async detach(seat) {
8979
9981
  seat.send = null;
8980
9982
  this.runnerChanged(seat.name);
@@ -8995,7 +9997,7 @@ var RunnerHub = class {
8995
9997
  return;
8996
9998
  }
8997
9999
  for (const turnId of this.host.turnsOn(seat.name)) {
8998
- void this.host.abortTurn(turnId, `runner ${seat.name} went away during the turn`);
10000
+ void this.host.abortTurn(turnId, seat.unacked.has(turnId) ? `the turn never reached runner ${seat.name}, which went away` : `runner ${seat.name} went away during the turn`);
8999
10001
  }
9000
10002
  }, this.graceMs);
9001
10003
  seat.grace.unref?.();
@@ -9072,7 +10074,7 @@ var RunnerHub = class {
9072
10074
  }
9073
10075
  return {
9074
10076
  seat,
9075
- repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch }
10077
+ repo: projectRepo(record)
9076
10078
  };
9077
10079
  }
9078
10080
  assertOwns(runner, turnId) {
@@ -9081,6 +10083,12 @@ var RunnerHub = class {
9081
10083
  throw new RunnerProtocolError(`turn ${turnId} is not ${runner}'s`);
9082
10084
  }
9083
10085
  }
10086
+ async sweepOn(seat, conversation) {
10087
+ const { kept } = SweepResultSchema.parse(await this.request(seat, (request) => ({ type: "sweep", request, conversation }), SWEEP_TIMEOUT_MS));
10088
+ for (const workspace of kept) {
10089
+ await this.board.recordLeftovers(workspace);
10090
+ }
10091
+ }
9084
10092
  request(seat, message, timeoutMs = this.requestTimeoutMs) {
9085
10093
  const request = randomUUID2();
9086
10094
  return new Promise((resolve, reject) => {
@@ -9531,11 +10539,19 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
9531
10539
  await hub.turnEvents(c.get("runner"), UlidSchema.parse(c.req.param("id")), entries);
9532
10540
  return c.json({ ok: true });
9533
10541
  });
10542
+ routes.post("/turns/:id/received", (c) => {
10543
+ hub.received(c.get("runner"), UlidSchema.parse(c.req.param("id")));
10544
+ return c.json({ ok: true });
10545
+ });
9534
10546
  routes.post("/turns/:id/outcome", async (c) => c.json(await hub.turnOutcome(c.get("runner"), UlidSchema.parse(c.req.param("id")), TurnOutcomeSchema.parse(await c.req.json()))));
9535
10547
  routes.post("/requests/:id", async (c) => {
9536
10548
  hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
9537
10549
  return c.json({ ok: true });
9538
10550
  });
10551
+ routes.post("/workspaces", async (c) => {
10552
+ hub.heldWorkspaces(c.get("runner"), HeldWorkspacesSchema.parse(await c.req.json()).workspaces);
10553
+ return c.json({ ok: true });
10554
+ });
9539
10555
  routes.post("/warm", async (c) => {
9540
10556
  hub.warmChanged(c.get("runner"), WarmSessionsSchema.parse(await c.req.json()).warm);
9541
10557
  return c.json({ ok: true });
@@ -9701,6 +10717,7 @@ function createApp(deps) {
9701
10717
  });
9702
10718
  api.delete("/sign-in", async (c) => c.json({ signedOut: await board2.signOut(bearer2(c) ?? "") }));
9703
10719
  api.get("/society", async (c) => c.json(await board2.society()));
10720
+ api.get("/version", (c) => c.json({ version }));
9704
10721
  api.get("/agents", async (c) => c.json((await board2.listAgents()).map(({ tokenHash: _hash, ...agent }) => agent)));
9705
10722
  api.get("/members", async (c) => c.json(await board2.listMembers()));
9706
10723
  api.get("/agents/:name/turns", async (c) => c.json(await board2.listTurns(c.req.param("name"), Number(c.req.query("limit") ?? "50"))));
@@ -10132,15 +11149,16 @@ var TurnHub = class {
10132
11149
  get lastSeq() {
10133
11150
  return this.seq;
10134
11151
  }
10135
- /** Records one event of a turn, in its scope and, for a thread's conversation, its thread. */
10136
- push(agent, project, event, thread) {
11152
+ /** Records one event of a turn, in its scope and its conversation there: a thread's, a channel's, or home. */
11153
+ push(agent, project, event, conversation = {}) {
10137
11154
  this.seq += 1;
10138
11155
  const item = {
10139
11156
  seq: this.seq,
10140
11157
  ts: this.now().toISOString(),
10141
11158
  agent,
10142
11159
  project,
10143
- ...thread === void 0 ? {} : { thread },
11160
+ ...conversation.thread === void 0 ? {} : { thread: conversation.thread },
11161
+ ...conversation.channel === void 0 ? {} : { channel: conversation.channel },
10144
11162
  event
10145
11163
  };
10146
11164
  this.buffer.push(item);
@@ -10192,12 +11210,13 @@ var host = new TurnHost({
10192
11210
  maxTurns: config.toolRounds,
10193
11211
  residentIdleMs: config.residentIdleMs,
10194
11212
  log,
10195
- onEvent: (agent, scope, event, thread) => {
10196
- turns.push(agent, scope, event, thread);
10197
- log.debug({ agent, scope, thread, event }, "agent event");
11213
+ onEvent: (agent, scope, event, conversation) => {
11214
+ turns.push(agent, scope, event, conversation);
11215
+ log.debug({ agent, scope, ...conversation, event }, "agent event");
10198
11216
  }
10199
11217
  });
10200
11218
  var runners = new RunnerHub({ board, host, version: VERSION, log });
11219
+ board.watchTurns(() => host.inFlight());
10201
11220
  var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
10202
11221
  var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
10203
11222
  var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });