@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
package/dist/cli/index.js CHANGED
@@ -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(project2, name) {
168
+ const used = project2 === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
169
+ return used.includes(name);
170
+ }
159
171
  var SOCIETY_SCOPE = "society";
160
172
  function wakeScope(member, project2) {
161
173
  return project2 !== null && member.memberships.includes(project2) ? project2 : 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
 
670
763
  // packages/shared/dist/events.js
@@ -697,12 +790,14 @@ var BOARD_EVENT_TYPES = [
697
790
  "proposal.decided",
698
791
  "proposal.provisioned",
699
792
  "channel.added",
793
+ "channel.archived",
700
794
  "role.added",
701
795
  "agent.retired",
702
796
  "agent.joined",
703
797
  "agent.left",
704
798
  "knowledge.written",
705
799
  "knowledge.removed",
800
+ "dashboard.updated",
706
801
  "skill.promoted",
707
802
  "runner.added",
708
803
  "user.signed_in",
@@ -718,7 +813,8 @@ var BOARD_EVENT_TYPES = [
718
813
  "turn.completed",
719
814
  "turn.failed",
720
815
  "merge.completed",
721
- "merge.failed"
816
+ "merge.failed",
817
+ "workspace.leftovers"
722
818
  ];
723
819
  var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
724
820
  var BoardEventSchema = z5.object({
@@ -798,6 +894,8 @@ var LiveTurnEventSchema = z5.object({
798
894
  project: NameSchema,
799
895
  /** The thread whose conversation the turn is in; absent for the home conversation. */
800
896
  thread: UlidSchema.optional(),
897
+ /** The channel, never general, whose conversation the turn is in. */
898
+ channel: NameSchema.optional(),
801
899
  event: AgentEventSchema
802
900
  });
803
901
 
@@ -863,15 +961,25 @@ var PendingEnrollmentSchema = z6.object({
863
961
  var ProjectRepoSchema = z6.object({
864
962
  slug: NameSchema,
865
963
  origin: z6.string().nullable(),
866
- defaultBranch: z6.string().min(1)
964
+ defaultBranch: z6.string().min(1),
965
+ /**
966
+ * The board lands finished tasks on the default branch, itself or through their pull requests,
967
+ * so the runner lets nothing else move it.
968
+ */
969
+ boardLands: z6.boolean()
867
970
  });
868
971
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
869
- z6.object({ kind: z6.literal("home"), thread: UlidSchema.optional() }),
972
+ z6.object({
973
+ kind: z6.literal("home"),
974
+ thread: UlidSchema.optional(),
975
+ channel: NameSchema.optional()
976
+ }),
870
977
  z6.object({
871
978
  kind: z6.literal("project"),
872
979
  repo: ProjectRepoSchema,
873
980
  branches: z6.array(z6.string().min(1)),
874
- thread: UlidSchema.optional()
981
+ thread: UlidSchema.optional(),
982
+ channel: NameSchema.optional()
875
983
  }),
876
984
  z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
877
985
  ]);
@@ -887,6 +995,8 @@ var TurnJobSchema = z6.object({
887
995
  scope: NameSchema,
888
996
  /** The thread whose conversation the turn is in; absent for the home conversation. */
889
997
  thread: UlidSchema.optional(),
998
+ /** The channel, never general, whose conversation the turn is in. */
999
+ channel: NameSchema.optional(),
890
1000
  /**
891
1001
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
892
1002
  * the outcome reports it.
@@ -920,12 +1030,27 @@ var TurnOutcomeSchema = z6.object({
920
1030
  session: z6.string().min(1),
921
1031
  /** The model the CLI reported, when it reported one. */
922
1032
  model: z6.string().nullable(),
923
- work: TurnWorkSchema.nullable()
1033
+ work: TurnWorkSchema.nullable(),
1034
+ /**
1035
+ * Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
1036
+ * branch after the hand-back: uncommitted changes, or commits no branch contains.
1037
+ */
1038
+ leftovers: z6.boolean().default(false)
924
1039
  });
925
1040
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
926
1041
  var LandRequestSchema = z6.object({
927
1042
  repo: ProjectRepoSchema,
928
- branch: z6.string().min(1)
1043
+ branch: z6.string().min(1),
1044
+ /**
1045
+ * For a `ghpr` task, the pull request to merge in place of the branch, which must hold the
1046
+ * branch's files, and the merge commit's subject and body, the pull request's title and an
1047
+ * empty body when left out.
1048
+ */
1049
+ pullRequest: z6.object({
1050
+ url: z6.string().min(1),
1051
+ subject: z6.string().min(1).optional(),
1052
+ body: z6.string().optional()
1053
+ }).optional()
929
1054
  });
930
1055
  var MergeOutcomeSchema = z6.object({ ok: z6.boolean(), detail: z6.string() });
931
1056
  var ModelListSchema = z6.array(ModelOptionSchema);
@@ -979,6 +1104,17 @@ var BranchChangesSchema = z6.object({
979
1104
  files: z6.array(BranchFileChangeSchema),
980
1105
  total: z6.number().int().nonnegative()
981
1106
  });
1107
+ var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
1108
+ z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
1109
+ z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
1110
+ ]);
1111
+ var HeldWorkspaceSchema = z6.object({
1112
+ agent: NameSchema,
1113
+ scope: NameSchema,
1114
+ conversation: WorkspaceConversationSchema
1115
+ });
1116
+ var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
1117
+ var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
982
1118
  var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
983
1119
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
984
1120
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
@@ -1000,7 +1136,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
1000
1136
  steer: TurnSteerSchema
1001
1137
  }),
1002
1138
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1003
- z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
1139
+ z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
1140
+ /**
1141
+ * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1142
+ * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
1143
+ */
1144
+ z6.object({
1145
+ type: z6.literal("sweep"),
1146
+ request: z6.string().min(1),
1147
+ conversation: WorkspaceConversationSchema
1148
+ })
1004
1149
  ]);
1005
1150
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
1006
1151
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -1028,7 +1173,8 @@ var TriggerKindSchema = z7.enum([
1028
1173
  "onboarding",
1029
1174
  "manual",
1030
1175
  "ops_event",
1031
- "user_post"
1176
+ "user_post",
1177
+ "closing"
1032
1178
  ]);
1033
1179
  var TriggerSchema = z7.object({
1034
1180
  kind: TriggerKindSchema,
@@ -1045,10 +1191,20 @@ var TurnDispatchSchema = z7.object({
1045
1191
  agent: NameSchema,
1046
1192
  project: NameSchema,
1047
1193
  thread: TurnThreadSchema.optional(),
1194
+ channel: NameSchema.optional(),
1048
1195
  trigger: TriggerSchema,
1049
1196
  priority: z7.number().int().min(0).max(2),
1050
1197
  onboarding: z7.boolean().default(false)
1051
1198
  });
1199
+ function conversationPart(conversation) {
1200
+ return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
1201
+ }
1202
+ function channelConversation(scope, channel) {
1203
+ const at = channel.indexOf("/");
1204
+ const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
1205
+ const name = at < 0 ? channel : channel.slice(at + 1);
1206
+ return place === scope && name !== "general" ? name : void 0;
1207
+ }
1052
1208
  var TurnRecordSchema = z7.object({
1053
1209
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
1054
1210
  id: UlidSchema.optional(),
@@ -1056,6 +1212,8 @@ var TurnRecordSchema = z7.object({
1056
1212
  project: NameSchema,
1057
1213
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1058
1214
  thread: UlidSchema.optional(),
1215
+ /** The channel, never general, whose conversation the turn was in. */
1216
+ channel: NameSchema.optional(),
1059
1217
  runner: NameSchema,
1060
1218
  cli: CliKindSchema.nullable(),
1061
1219
  session: z7.string().nullable(),
@@ -1085,8 +1243,9 @@ var RunningTurnSchema = z7.object({
1085
1243
  agent: NameSchema,
1086
1244
  scope: NameSchema,
1087
1245
  thread: UlidSchema.optional(),
1246
+ channel: NameSchema.optional(),
1088
1247
  cli: CliKindSchema,
1089
- channel: ChannelRefSchema.optional(),
1248
+ askedIn: ChannelRefSchema.optional(),
1090
1249
  steerable: z7.boolean(),
1091
1250
  stoppable: z7.boolean()
1092
1251
  });
@@ -1113,6 +1272,8 @@ var TurnHistoryEntrySchema = z7.object({
1113
1272
  project: NameSchema,
1114
1273
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1115
1274
  thread: UlidSchema.optional(),
1275
+ /** The channel, never general, whose conversation the turn was in. */
1276
+ channel: NameSchema.optional(),
1116
1277
  trigger: z7.string(),
1117
1278
  exitReason: z7.string().nullable(),
1118
1279
  costUsd: z7.number(),
@@ -1139,7 +1300,8 @@ var OPS_SIGNAL_KINDS = [
1139
1300
  "turn_cost",
1140
1301
  "scaled",
1141
1302
  "runner",
1142
- "home_conflict"
1303
+ "home_conflict",
1304
+ "stuck_workspace"
1143
1305
  ];
1144
1306
  var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
1145
1307
  var OpsSignalSchema = z8.object({
@@ -1301,7 +1463,7 @@ var RunnerConfigSchema = z10.object({
1301
1463
  });
1302
1464
 
1303
1465
  // packages/shared/dist/version.js
1304
- var STELLARIS_VERSION = "0.3.10";
1466
+ var STELLARIS_VERSION = "0.3.12";
1305
1467
 
1306
1468
  // packages/board-core/dist/errors.js
1307
1469
  var BoardError = class extends Error {
@@ -1572,6 +1734,7 @@ var HOME_GITIGNORE = [
1572
1734
  "/projects/*/sessions*.json",
1573
1735
  "/projects/*/last-turn.json",
1574
1736
  "/projects/*/threads/",
1737
+ "/projects/*/channels/",
1575
1738
  `/${HOME_SCRATCH}/`,
1576
1739
  "*.tmp",
1577
1740
  "node_modules/",
@@ -1589,7 +1752,7 @@ var HOME_GITIGNORE = [
1589
1752
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
1590
1753
  `;
1591
1754
  var PRE_RECEIVE = `#!/bin/sh
1592
- forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
1755
+ forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
1593
1756
  while read old new ref; do
1594
1757
  if [ "$ref" != "refs/heads/main" ]; then
1595
1758
  echo "a home takes pushes to main only" >&2
@@ -1850,6 +2013,7 @@ var TurnPayload = z11.object({
1850
2013
  turnId: z11.string().optional(),
1851
2014
  project: z11.string(),
1852
2015
  thread: z11.string().optional(),
2016
+ channel: z11.string().optional(),
1853
2017
  trigger: z11.string()
1854
2018
  });
1855
2019
  var TaskPayload = z11.object({
@@ -1892,7 +2056,14 @@ function answers(mention, turn2) {
1892
2056
  if (turn2.thread !== null) {
1893
2057
  return mention.thread === turn2.thread;
1894
2058
  }
1895
- return mention.scopes.includes(turn2.project) && (mention.thread === null || turn2.trigger === "mention");
2059
+ if (!mention.scopes.includes(turn2.project)) {
2060
+ return false;
2061
+ }
2062
+ const own = mention.thread === null ? channelConversation(turn2.project, mention.channel) : void 0;
2063
+ if (turn2.channel !== null) {
2064
+ return mention.thread === null && own === turn2.channel;
2065
+ }
2066
+ return mention.thread === null && own === void 0 || turn2.trigger === "mention";
1896
2067
  }
1897
2068
  function computeMetrics(input) {
1898
2069
  const { events, now, window } = input;
@@ -1957,6 +2128,7 @@ function computeMetrics(input) {
1957
2128
  agent: event.actor,
1958
2129
  at,
1959
2130
  thread: turn2.data.thread ?? null,
2131
+ channel: turn2.data.channel ?? null,
1960
2132
  project: turn2.data.project,
1961
2133
  trigger: turn2.data.trigger
1962
2134
  };
@@ -2053,6 +2225,7 @@ function computeMetrics(input) {
2053
2225
  at: Date.parse(event.ts),
2054
2226
  message: message.data.id ?? null,
2055
2227
  thread: thread2,
2228
+ channel: message.data.channel,
2056
2229
  scopes: [project2, wakeScope(citizen, project2)].filter((scope) => scope !== null)
2057
2230
  });
2058
2231
  }
@@ -2328,9 +2501,12 @@ var BoardPaths = class {
2328
2501
  agentProject(name, slug) {
2329
2502
  return path5.join(this.agentProjects(name), slug);
2330
2503
  }
2331
- /** A thread conversation's session and last turn, beside the scope's home conversation. */
2332
- agentThread(name, slug, thread2) {
2333
- return path5.join(this.agentProject(name, slug), "threads", thread2);
2504
+ /**
2505
+ * A thread's or a channel's conversation's session and last turn, beside the scope's home
2506
+ * conversation; `part` is a thread's id or `#channel`.
2507
+ */
2508
+ agentConversation(name, slug, part) {
2509
+ return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
2334
2510
  }
2335
2511
  agentCursors(name) {
2336
2512
  return path5.join(this.agent(name), "cursors.json");
@@ -2364,6 +2540,13 @@ function hashToken(token) {
2364
2540
 
2365
2541
  // packages/board-core/dist/board.js
2366
2542
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
2543
+ var CLOSING_ATTEMPTS = 3;
2544
+ function ownTurn(actor, turn2) {
2545
+ return turn2.agent === actor.name && turn2.scope === actor.scope && turn2.thread === actor.thread && turn2.channel === actor.channel;
2546
+ }
2547
+ function namesList(names) {
2548
+ return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
2549
+ }
2367
2550
  var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
2368
2551
  var RunnerTokensSchema = z12.object({
2369
2552
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
@@ -2381,6 +2564,7 @@ var SignInsSchema = z12.object({
2381
2564
  var TurnHistoryPayloadSchema = z12.object({
2382
2565
  project: NameSchema,
2383
2566
  thread: UlidSchema.optional(),
2567
+ channel: NameSchema.optional(),
2384
2568
  trigger: z12.string().default("manual"),
2385
2569
  exitReason: z12.string().nullable().default(null),
2386
2570
  costUsd: z12.number().default(0),
@@ -2396,12 +2580,23 @@ var CursorsSchema = z12.object({
2396
2580
  digest: z12.string().nullable(),
2397
2581
  scopes: z12.record(z12.string(), z12.string()).default({}),
2398
2582
  threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2583
+ channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2399
2584
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
2400
2585
  news: z12.string().optional()
2401
2586
  });
2402
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
2403
- function conversationKey(scope, thread2) {
2404
- return thread2 === null ? scope : `${scope}/${thread2}`;
2587
+ var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
2588
+ function conversationKey(scope, part) {
2589
+ return part === null ? scope : `${scope}/${part}`;
2590
+ }
2591
+ function channelOfPart(part) {
2592
+ return part?.startsWith("#") === true ? part.slice(1) : void 0;
2593
+ }
2594
+ function threadOfPart(part) {
2595
+ return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
2596
+ }
2597
+ function channelStart(cursors, scope) {
2598
+ const from = cursors.channelsFrom[scope];
2599
+ return from === void 0 ? cursors.digest : from;
2405
2600
  }
2406
2601
  function threadStart(cursors, scope) {
2407
2602
  const from = cursors.threadsFrom[scope];
@@ -2414,11 +2609,11 @@ function newsStart(cursors) {
2414
2609
  const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
2415
2610
  return homes.at(-1) ?? cursors.digest;
2416
2611
  }
2417
- function cursorOf(cursors, scope, thread2 = null) {
2418
- if (thread2 === null) {
2612
+ function cursorOf(cursors, scope, part = null) {
2613
+ if (part === null) {
2419
2614
  return cursors.scopes[scope] ?? cursors.digest;
2420
2615
  }
2421
- return cursors.scopes[conversationKey(scope, thread2)] ?? threadStart(cursors, scope);
2616
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
2422
2617
  }
2423
2618
  function byId(a, b) {
2424
2619
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -2429,6 +2624,9 @@ function turnTime(record) {
2429
2624
  var PausedSchema = z12.object({ paused: z12.boolean() });
2430
2625
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
2431
2626
  var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2627
+ function withoutMentions(text) {
2628
+ return text.replaceAll(MENTION_PATTERN, "$1$2");
2629
+ }
2432
2630
  var CURATING_ROLES = [USER_ROLE, "steward"];
2433
2631
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2434
2632
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -2445,6 +2643,10 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
2445
2643
  var THREAD_CONVERSATIONS = "thread-conversations";
2446
2644
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2447
2645
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2646
+ var CHANNEL_CONVERSATIONS = "channel-conversations";
2647
+ var DASHBOARD_UPDATES = "dashboard-updates-granted";
2648
+ var OLD_DASHBOARD_LINE = "Agents may edit this file. It is rendered by the board UI.";
2649
+ var DASHBOARD_LINE = "The project's members keep this page current with update_dashboard.";
2448
2650
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2449
2651
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2450
2652
  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";
@@ -2481,6 +2683,11 @@ function memberToAgentInput(member) {
2481
2683
  ...member.seedInstructions === void 0 ? {} : { seedInstructions: member.seedInstructions }
2482
2684
  };
2483
2685
  }
2686
+ function assertLandsByPullRequest(effect, repo, where) {
2687
+ if (effect === "ghpr" && githubRepository(repo) === null) {
2688
+ 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)`);
2689
+ }
2690
+ }
2484
2691
  function stageFrom(planned, id) {
2485
2692
  return {
2486
2693
  id,
@@ -2575,6 +2782,8 @@ function snippetAround(text, query, radius = 80) {
2575
2782
  var Board = class _Board {
2576
2783
  paths;
2577
2784
  mutex = new Mutex();
2785
+ /** The turns in flight, which the server hosting them supplies; none until it does. */
2786
+ turnsInFlight = () => [];
2578
2787
  newId = monotonicFactory();
2579
2788
  events;
2580
2789
  actionCounts = /* @__PURE__ */ new Map();
@@ -2635,7 +2844,9 @@ var Board = class _Board {
2635
2844
  DECISIONS_CHANNEL_RETIRED,
2636
2845
  THREAD_CONVERSATIONS,
2637
2846
  ASKS_CHANNEL_OPENED,
2638
- KNOWLEDGE_REMOVAL
2847
+ KNOWLEDGE_REMOVAL,
2848
+ CHANNEL_CONVERSATIONS,
2849
+ DASHBOARD_UPDATES
2639
2850
  ].filter((name) => !applied.includes(name));
2640
2851
  if (pending.length === 0) {
2641
2852
  return;
@@ -2665,18 +2876,38 @@ var Board = class _Board {
2665
2876
  await this.openAsksChannel();
2666
2877
  }
2667
2878
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2668
- await this.grantKnowledgeRemoval();
2879
+ await this.grantBesideKnowledge("remove_knowledge");
2880
+ }
2881
+ if (pending.includes(CHANNEL_CONVERSATIONS)) {
2882
+ await this.startChannelCursors();
2883
+ }
2884
+ if (pending.includes(DASHBOARD_UPDATES)) {
2885
+ await this.grantBesideKnowledge("update_dashboard");
2886
+ await this.rewordDashboards();
2669
2887
  }
2670
2888
  await writeJson(file, { applied: [...applied, ...pending] });
2671
2889
  }
2672
2890
  /**
2673
- * Whoever may write a scope's knowledge may remove it, so every charter granting
2674
- * `write_knowledge` is granted `remove_knowledge` once; seed roles have it from their seed.
2891
+ * Grants `verb` once to every charter granting `write_knowledge`, the work roles' charters in
2892
+ * practice; seed roles have it from their seed.
2675
2893
  */
2676
- async grantKnowledgeRemoval() {
2894
+ async grantBesideKnowledge(verb) {
2677
2895
  for (const charter of await this.listRoles()) {
2678
- if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes("remove_knowledge")) {
2679
- await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, "remove_knowledge"] }, { verbsAdded: ["remove_knowledge"] });
2896
+ if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes(verb)) {
2897
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
2898
+ }
2899
+ }
2900
+ }
2901
+ /** A dashboard still saying agents may edit its file says how it changes now. */
2902
+ async rewordDashboards() {
2903
+ for (const slug of await listDirs(this.paths.projects())) {
2904
+ const file = this.paths.dashboard(slug);
2905
+ if (!await exists(file)) {
2906
+ continue;
2907
+ }
2908
+ const { data, body } = await readLooseMarkdown(file);
2909
+ if (body.includes(OLD_DASHBOARD_LINE)) {
2910
+ await writeMarkdown(file, data, body.replace(OLD_DASHBOARD_LINE, DASHBOARD_LINE));
2680
2911
  }
2681
2912
  }
2682
2913
  }
@@ -2841,6 +3072,27 @@ var Board = class _Board {
2841
3072
  }
2842
3073
  }
2843
3074
  }
3075
+ /**
3076
+ * Every channel of a scope was once read in its home conversation, so a member had read each up
3077
+ * to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
3078
+ * conversation of its own.
3079
+ */
3080
+ async startChannelCursors() {
3081
+ for (const agent2 of await this.listAgents()) {
3082
+ const file = this.paths.agentCursors(agent2.name);
3083
+ if (!await exists(file)) {
3084
+ continue;
3085
+ }
3086
+ const cursors = await readJson(file, CursorsSchema);
3087
+ const scopes = /* @__PURE__ */ new Set([
3088
+ ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
3089
+ ...agent2.memberships,
3090
+ SOCIETY_SCOPE
3091
+ ]);
3092
+ const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
3093
+ await writeJson(file, { ...cursors, channelsFrom });
3094
+ }
3095
+ }
2844
3096
  /**
2845
3097
  * Threads were once read in the scope's single conversation, so a member had read every thread
2846
3098
  * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
@@ -2974,17 +3226,20 @@ ${charter.purpose}
2974
3226
  /**
2975
3227
  * A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
2976
3228
  * in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
2977
- * scope and thread ride on the actor it resolves to, so `read_inbox` reads that conversation's
2978
- * digest.
3229
+ * scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
3230
+ * `read_inbox` reads that conversation's digest.
2979
3231
  */
2980
- issueTurnToken(agent2, role2, ttlMs, scope, thread2) {
3232
+ issueTurnToken(agent2, role2, ttlMs, scope, part) {
2981
3233
  const token = mintToken();
3234
+ const thread2 = threadOfPart(part);
3235
+ const channel = channelOfPart(part);
2982
3236
  this.turnTokens.set(hashToken(token), {
2983
3237
  actor: {
2984
3238
  name: agent2,
2985
3239
  role: role2,
2986
3240
  ...scope === void 0 ? {} : { scope },
2987
- ...thread2 === void 0 ? {} : { thread: thread2 }
3241
+ ...thread2 === void 0 ? {} : { thread: thread2 },
3242
+ ...channel === void 0 ? {} : { channel }
2988
3243
  },
2989
3244
  expiresAt: this.now().getTime() + ttlMs
2990
3245
  });
@@ -3284,34 +3539,44 @@ ${charter.purpose}
3284
3539
  }
3285
3540
  return (await readJson(file, PausedSchema)).paused;
3286
3541
  }
3287
- /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3288
- /** Every channel, the society's first, with how many messages it holds and its newest one. */
3542
+ /**
3543
+ * Every channel, the society's first, with how many messages it holds and its newest one; each
3544
+ * place's archived channels follow its open ones.
3545
+ */
3289
3546
  async listChannels() {
3290
3547
  const summaries = [];
3291
- const scopes = [
3292
- { project: null, names: (await this.society()).channels },
3548
+ const society = await this.society();
3549
+ const places = [
3550
+ { project: null, names: society.channels, archived: society.archivedChannels },
3293
3551
  ...(await this.listProjects()).map((project2) => ({
3294
3552
  project: project2.slug,
3295
- names: project2.channels
3553
+ names: project2.channels,
3554
+ archived: project2.archivedChannels
3296
3555
  }))
3297
3556
  ];
3298
- for (const { project: project2, names } of scopes) {
3299
- for (const name of names) {
3300
- const ref = channelRef(project2, name);
3557
+ for (const { project: project2, names, archived } of places) {
3558
+ const entries = [
3559
+ ...names.map((name) => ({ name, archived: void 0 })),
3560
+ ...archived.map((each) => ({ name: each.name, archived: each }))
3561
+ ];
3562
+ for (const entry of entries) {
3563
+ const ref = channelRef(project2, entry.name);
3301
3564
  const files = await listFiles(this.paths.channelDir(ref));
3302
3565
  const last = files.at(-1)?.slice(0, 26) ?? null;
3303
3566
  summaries.push({
3304
3567
  ref,
3305
3568
  project: project2,
3306
- name,
3569
+ name: entry.name,
3307
3570
  messages: files.length,
3308
3571
  lastMessageId: last,
3309
- lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
3572
+ lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
3573
+ ...entry.archived === void 0 ? {} : { archived: entry.archived }
3310
3574
  });
3311
3575
  }
3312
3576
  }
3313
3577
  return summaries;
3314
3578
  }
3579
+ /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3315
3580
  async listChannel(ref, limit = 200) {
3316
3581
  await this.assertChannelExists(ref);
3317
3582
  const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
@@ -3404,6 +3669,7 @@ ${charter.purpose}
3404
3669
  if (await exists(file)) {
3405
3670
  throw new BoardError("ALREADY_EXISTS", `project ${input.slug} already exists`);
3406
3671
  }
3672
+ assertLandsByPullRequest(input.onDone ?? "none", input.repo ?? null, `project ${input.slug}`);
3407
3673
  const project2 = ProjectSchema.parse({
3408
3674
  slug: input.slug,
3409
3675
  name: input.name ?? input.slug,
@@ -3424,9 +3690,9 @@ ${charter.purpose}
3424
3690
  await ensureDir(this.paths.tasks(project2.slug));
3425
3691
  await ensureDir(this.paths.threads(project2.slug));
3426
3692
  await ensureDir(this.paths.projectKnowledge(project2.slug));
3427
- await writeMarkdown(this.paths.dashboard(project2.slug), { project: project2.slug, updatedAt: this.now().toISOString() }, `# ${project2.name} dashboard
3693
+ await writeMarkdown(this.paths.dashboard(project2.slug), { project: project2.slug, revision: 0, updatedAt: this.now().toISOString() }, `# ${project2.name} dashboard
3428
3694
 
3429
- Agents may edit this file. It is rendered by the board UI.
3695
+ ${DASHBOARD_LINE}
3430
3696
  `);
3431
3697
  await this.updateAgent(USER_NAME, (user2) => ({
3432
3698
  ...user2,
@@ -3464,12 +3730,27 @@ Agents may edit this file. It is rendered by the board UI.
3464
3730
  return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
3465
3731
  });
3466
3732
  }
3733
+ /** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
3734
+ async createChannel(actor, input) {
3735
+ const args = VerbInputs.create_channel.parse(input);
3736
+ await this.authorize(actor, "create_channel");
3737
+ return this.mutex.run(async () => {
3738
+ await this.validateAddChannel(args);
3739
+ return this.addChannelUnlocked(actor.name, args, {}, actor.name);
3740
+ });
3741
+ }
3742
+ /** Archives a channel whose workstream is done; refused while a task filed there is in play. */
3743
+ async archiveChannel(actor, input) {
3744
+ const args = VerbInputs.archive_channel.parse(input);
3745
+ await this.authorize(actor, "archive_channel");
3746
+ return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
3747
+ }
3467
3748
  /** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
3468
3749
  async addChannel(actor, input) {
3469
3750
  this.assertAdmin(actor);
3470
3751
  return this.mutex.run(async () => {
3471
3752
  await this.validateAddChannel(input);
3472
- return this.addChannelUnlocked(actor.name, input, {});
3753
+ return this.addChannelUnlocked(actor.name, input, {}, actor.name);
3473
3754
  });
3474
3755
  }
3475
3756
  /**
@@ -3516,6 +3797,7 @@ Agents may edit this file. It is rendered by the board UI.
3516
3797
  ...patch.os === void 0 ? {} : { os: patch.os },
3517
3798
  ...patch.clis === void 0 ? {} : { clis: [...patch.clis] },
3518
3799
  ...patch.capabilities === void 0 ? {} : { capabilities: [...patch.capabilities] },
3800
+ ...patch.version === void 0 ? {} : { version: patch.version },
3519
3801
  lastSeen: this.now().toISOString()
3520
3802
  });
3521
3803
  await writeMarkdown(file, next, doc.body);
@@ -3667,19 +3949,19 @@ Agents may edit this file. It is rendered by the board UI.
3667
3949
  return this.mutex.run(async () => {
3668
3950
  const stored = await this.readCursors(actor.name);
3669
3951
  const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
3670
- const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, thread: actor.thread ?? null };
3952
+ const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
3671
3953
  const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
3672
3954
  if (args.advance) {
3673
3955
  await this.advanceCursors(actor.name, filed);
3674
3956
  }
3675
3957
  const last = filed.at(-1)?.message.id;
3676
- const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.thread));
3958
+ const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
3677
3959
  return { messages: filed.map((entry) => entry.message), cursor };
3678
3960
  });
3679
3961
  }
3680
3962
  /**
3681
3963
  * How many unread digest messages a member has in each conversation: a scope's home, for its
3682
- * channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3964
+ * general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3683
3965
  * messages are and nowhere else. Society channels count only for a member woken in the society
3684
3966
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
3685
3967
  * counted, and are read when the member wakes for something else: a task step's, which wakes
@@ -3690,7 +3972,7 @@ Agents may edit this file. It is rendered by the board UI.
3690
3972
  return this.mutex.run(async () => {
3691
3973
  const counts = /* @__PURE__ */ new Map();
3692
3974
  const unscoped = { name: actor.name, role: actor.role };
3693
- for (const { scope, thread: thread2, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3975
+ for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3694
3976
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
3695
3977
  continue;
3696
3978
  }
@@ -3698,8 +3980,13 @@ Agents may edit this file. It is rendered by the board UI.
3698
3980
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
3699
3981
  continue;
3700
3982
  }
3701
- const key = conversationKey(scope, thread2);
3702
- const entry = counts.get(key) ?? { scope, thread: thread2, count: 0 };
3983
+ const key = conversationKey(scope, part);
3984
+ const entry = counts.get(key) ?? {
3985
+ scope,
3986
+ thread: threadOfPart(part) ?? null,
3987
+ channel: channelOfPart(part) ?? null,
3988
+ count: 0
3989
+ };
3703
3990
  counts.set(key, { ...entry, count: entry.count + 1 });
3704
3991
  }
3705
3992
  return [...counts.values()];
@@ -3715,7 +4002,7 @@ Agents may edit this file. It is rendered by the board UI.
3715
4002
  const scopes = { ...cursors.scopes };
3716
4003
  let news = newsStart(cursors);
3717
4004
  let moved = false;
3718
- for (const { scope, thread: thread2, message, news: isNews } of filed) {
4005
+ for (const { scope, part, message, news: isNews } of filed) {
3719
4006
  if (isNews === true) {
3720
4007
  if (news === null || message.id > news) {
3721
4008
  news = message.id;
@@ -3723,8 +4010,8 @@ Agents may edit this file. It is rendered by the board UI.
3723
4010
  }
3724
4011
  continue;
3725
4012
  }
3726
- const key = conversationKey(scope, thread2);
3727
- const current = scopes[key] ?? cursorOf(cursors, scope, thread2);
4013
+ const key = conversationKey(scope, part);
4014
+ const current = scopes[key] ?? cursorOf(cursors, scope, part);
3728
4015
  if (current === null || message.id > current) {
3729
4016
  scopes[key] = message.id;
3730
4017
  moved = true;
@@ -3743,29 +4030,32 @@ Agents may edit this file. It is rendered by the board UI.
3743
4030
  * message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
3744
4031
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
3745
4032
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
3746
- * message of the thread, since the conversation is about it; for a scope's home, its channel
3747
- * messages, since each thread is a conversation of its own.
4033
+ * message of the thread, since the conversation is about it; for a channel's, that channel's
4034
+ * top-level messages; for a scope's home, general's and those of a project the member is not in,
4035
+ * since each thread and each other channel is a conversation of its own.
3748
4036
  */
3749
4037
  async digestSince(actor, cursors, conversation) {
3750
- if (conversation !== void 0 && conversation.thread !== null) {
3751
- const { scope, thread: thread2 } = conversation;
3752
- const found = await this.tryFindThread(thread2);
4038
+ const conversationThread = threadOfPart(conversation?.part);
4039
+ if (conversation !== void 0 && conversationThread !== void 0) {
4040
+ const { scope, part } = conversation;
4041
+ const found = await this.tryFindThread(conversationThread);
3753
4042
  if (found === null) {
3754
4043
  return [];
3755
4044
  }
3756
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, thread2), cursorOf(cursors, scope, thread2));
3757
- return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, thread: thread2, message })).toSorted(byId);
4045
+ const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
4046
+ return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
3758
4047
  }
3759
4048
  const agent2 = await this.readAgent(actor.name);
3760
4049
  const charter = await this.readRole(agent2.role);
3761
4050
  const subscribed = new Set(agent2.subscriptions);
3762
4051
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
3763
4052
  const threadParticipation = /* @__PURE__ */ new Map();
4053
+ const openChannels = /* @__PURE__ */ new Map();
3764
4054
  const collected = [];
3765
4055
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
3766
4056
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent2.memberships, SOCIETY_SCOPE];
3767
4057
  const starts = [
3768
- ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
4058
+ ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
3769
4059
  ...newsFrom === void 0 ? [] : [newsFrom]
3770
4060
  ];
3771
4061
  const known = starts.filter((start) => start !== null);
@@ -3778,20 +4068,24 @@ Agents may edit this file. It is rendered by the board UI.
3778
4068
  if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
3779
4069
  collected.push({
3780
4070
  scope: conversation?.scope ?? SOCIETY_SCOPE,
3781
- thread: null,
4071
+ part: null,
3782
4072
  message,
3783
4073
  news: true
3784
4074
  });
3785
4075
  }
3786
4076
  continue;
3787
4077
  }
3788
- if (conversation !== void 0 && filedIn !== conversation.scope) {
3789
- continue;
4078
+ const named = thread2 === null ? channelConversation(filedIn, message.channel) : void 0;
4079
+ let open2 = named === void 0 ? void 0 : openChannels.get(message.channel);
4080
+ if (named !== void 0 && open2 === void 0) {
4081
+ open2 = await this.channelOpen(message.channel);
4082
+ openChannels.set(message.channel, open2);
3790
4083
  }
3791
- if (conversation !== void 0 && thread2 !== null) {
4084
+ const part = thread2 ?? (open2 === true ? `#${named}` : null);
4085
+ if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
3792
4086
  continue;
3793
4087
  }
3794
- const cursor = cursorOf(cursors, filedIn, thread2);
4088
+ const cursor = cursorOf(cursors, filedIn, part);
3795
4089
  if (cursor !== null && message.id <= cursor) {
3796
4090
  continue;
3797
4091
  }
@@ -3809,7 +4103,7 @@ Agents may edit this file. It is rendered by the board UI.
3809
4103
  include = participates;
3810
4104
  }
3811
4105
  if (include && message.author !== actor.name) {
3812
- collected.push({ scope: filedIn, thread: thread2, message });
4106
+ collected.push({ scope: filedIn, part, message });
3813
4107
  }
3814
4108
  }
3815
4109
  return collected.toSorted(byId);
@@ -3978,6 +4272,42 @@ ${task2.body}`;
3978
4272
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project2}`);
3979
4273
  }
3980
4274
  }
4275
+ /**
4276
+ * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4277
+ * copy of the projection pulled when it started, so a revision other than the current one means
4278
+ * someone updated it since; the refusal carries the current text, which that copy lacks.
4279
+ */
4280
+ async updateDashboard(actor, input) {
4281
+ const args = VerbInputs.update_dashboard.parse(input);
4282
+ await this.authorize(actor, "update_dashboard");
4283
+ return this.mutex.run(async () => {
4284
+ const project2 = await this.readActiveProject(args.project);
4285
+ if (actor.role !== USER_ROLE && !project2.members.includes(actor.name)) {
4286
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${args.project}`);
4287
+ }
4288
+ const current = await this.dashboardOf(args.project);
4289
+ if (args.revision !== current.revision) {
4290
+ const by = current.updatedBy === void 0 ? "" : `, updated by ${current.updatedBy} at ${current.updatedAt ?? "an unknown time"}`;
4291
+ 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}.
4292
+
4293
+ ${current.body}`);
4294
+ }
4295
+ const updated = DashboardSchema.parse({
4296
+ project: args.project,
4297
+ revision: current.revision + 1,
4298
+ updatedBy: actor.name,
4299
+ updatedAt: this.now().toISOString(),
4300
+ body: args.body
4301
+ });
4302
+ const { body, ...frontmatter } = updated;
4303
+ await writeMarkdown(this.paths.dashboard(args.project), frontmatter, body);
4304
+ await this.events.append("dashboard.updated", actor.name, {
4305
+ project: args.project,
4306
+ revision: updated.revision
4307
+ });
4308
+ return updated;
4309
+ });
4310
+ }
3981
4311
  async openThread(actor, input) {
3982
4312
  const args = VerbInputs.open_thread.parse(input);
3983
4313
  await this.authorize(actor, "open_thread");
@@ -4048,7 +4378,7 @@ ${task2.body}`;
4048
4378
  taskThreadOpening(task2) {
4049
4379
  return {
4050
4380
  id: task2.id,
4051
- channel: channelRef(task2.project, "general"),
4381
+ channel: channelRef(task2.project, task2.channel),
4052
4382
  title: task2.title,
4053
4383
  subject: { kind: "task", id: task2.id }
4054
4384
  };
@@ -4094,7 +4424,7 @@ ${task2.body}`;
4094
4424
  if (task2.status === "done" || task2.status === "abandoned") {
4095
4425
  throw new BoardError("INVALID_STATE", `task ${task2.id} is ${task2.status}`);
4096
4426
  }
4097
- const channel = args.channel ?? channelRef(project2, "general");
4427
+ const channel = args.channel ?? channelRef(project2, task2.channel);
4098
4428
  if (parseChannelRef(channel).project !== project2) {
4099
4429
  throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project2}`);
4100
4430
  }
@@ -4127,6 +4457,44 @@ ${task2.body}`;
4127
4457
  await this.assertChannelOpen(args.channel);
4128
4458
  return { id: this.newId(), channel: args.channel, title: args.title };
4129
4459
  }
4460
+ /**
4461
+ * Where the board learns which turns are in flight, from the server that hosts them, so it
4462
+ * refuses to end a conversation under someone else's turn.
4463
+ */
4464
+ watchTurns(source) {
4465
+ this.turnsInFlight = source;
4466
+ }
4467
+ /**
4468
+ * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
4469
+ * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
4470
+ * from inside it is how a citizen finishes there.
4471
+ */
4472
+ assertNoTurnCut(actor, what, cut) {
4473
+ const busy = [
4474
+ ...new Set(this.turnsInFlight().filter((turn2) => cut(turn2) && !ownTurn(actor, turn2)).map((turn2) => turn2.agent))
4475
+ ].toSorted((a, b) => a.localeCompare(b));
4476
+ if (busy.length === 0) {
4477
+ return;
4478
+ }
4479
+ const one = busy.length === 1;
4480
+ 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"}`);
4481
+ }
4482
+ /** The open threads in a folder of threads that `keep` picks. */
4483
+ async openThreadIds(dir, keep) {
4484
+ const ids = /* @__PURE__ */ new Set();
4485
+ for (const file of await listFiles(dir)) {
4486
+ const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
4487
+ if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
4488
+ ids.add(doc.data.id);
4489
+ }
4490
+ }
4491
+ return ids;
4492
+ }
4493
+ /** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
4494
+ async assertProjectQuiet(actor, slug) {
4495
+ const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
4496
+ this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn2) => turn2.scope === slug || turn2.thread !== void 0 && threads.has(turn2.thread));
4497
+ }
4130
4498
  async closeThread(actor, input) {
4131
4499
  const args = VerbInputs.close_thread.parse(input);
4132
4500
  await this.authorize(actor, "close_thread");
@@ -4141,6 +4509,7 @@ ${task2.body}`;
4141
4509
  if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread2, threads)) {
4142
4510
  throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
4143
4511
  }
4512
+ this.assertNoTurnCut(actor, `thread ${thread2.id} cannot close`, (turn2) => turn2.thread === thread2.id);
4144
4513
  const summary = await this.writeMessage(this.paths.channelDir(thread2.channel), { author: actor.name, channel: thread2.channel, closes: thread2.id }, `Thread closed: "${thread2.title}" (${thread2.id}).
4145
4514
 
4146
4515
  ${args.summary}`);
@@ -4179,10 +4548,12 @@ ${args.summary}`);
4179
4548
  if (first === void 0) {
4180
4549
  throw new BoardError("VALIDATION", "a plan needs at least one stage");
4181
4550
  }
4551
+ await this.assertChannelOpen(channelRef(args.project, args.channel));
4182
4552
  const ts = this.now().toISOString();
4183
4553
  const frontmatter = TaskFrontmatterSchema.parse({
4184
4554
  id: this.newId(),
4185
4555
  project: args.project,
4556
+ channel: args.channel,
4186
4557
  title: args.title,
4187
4558
  status: "open",
4188
4559
  createdBy: actor.name,
@@ -4198,11 +4569,11 @@ ${args.summary}`);
4198
4569
  onDone: project2.onDone,
4199
4570
  completing: false
4200
4571
  });
4201
- await this.assertChannelExists(channelRef(args.project, "general"));
4202
4572
  const task2 = await this.writeTask(args.project, { ...frontmatter, body: args.body });
4203
4573
  await this.events.append("task.created", actor.name, {
4204
4574
  taskId: task2.id,
4205
4575
  project: task2.project,
4576
+ channel: task2.channel,
4206
4577
  title: task2.title,
4207
4578
  parentId: task2.parentId ?? null,
4208
4579
  stage: task2.stage
@@ -4318,6 +4689,12 @@ ${args.summary}`);
4318
4689
  completedAt: ts
4319
4690
  };
4320
4691
  const next = current.stages[index + 1];
4692
+ if (next === void 0 && current.onDone === "ghpr" && current.pullRequest === void 0) {
4693
+ 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`);
4694
+ }
4695
+ if (next === void 0 && current.onDone === "none") {
4696
+ this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn2) => turn2.thread === current.id);
4697
+ }
4321
4698
  const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
4322
4699
  const base = {
4323
4700
  ...current,
@@ -4431,6 +4808,7 @@ ${args.summary}`);
4431
4808
  const guarded = gateChanges(ahead, replanned);
4432
4809
  if (onDone !== current.onDone) {
4433
4810
  guarded.push("change the completion effect");
4811
+ assertLandsByPullRequest(onDone, (await this.readProject(current.project)).repo, `project ${current.project}`);
4434
4812
  }
4435
4813
  if (guarded.length > 0) {
4436
4814
  this.assertMayGate(actor, guarded.join(", "));
@@ -4481,6 +4859,7 @@ ${args.summary}`);
4481
4859
  if (!mayAbandon) {
4482
4860
  throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
4483
4861
  }
4862
+ this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn2) => turn2.thread === current.id);
4484
4863
  next = {
4485
4864
  ...next,
4486
4865
  status: "abandoned",
@@ -4518,6 +4897,12 @@ ${args.summary}`);
4518
4897
  }
4519
4898
  next = { ...next, blockedBy: [...args.blocked_by] };
4520
4899
  }
4900
+ if (args.pull_request !== void 0) {
4901
+ next = {
4902
+ ...next,
4903
+ pullRequest: await this.pullRequestLink(actor, current, args.pull_request)
4904
+ };
4905
+ }
4521
4906
  if (args.note !== void 0) {
4522
4907
  const step = moved !== null ? { action: "returned", stage: moved.from, to: moved.to } : next.status === "abandoned" ? { action: "abandoned", stage: current.stage, to: null } : void 0;
4523
4908
  await this.postTaskNote(actor.name, current, args.note, step);
@@ -4543,12 +4928,46 @@ ${args.summary}`);
4543
4928
  project: location.project,
4544
4929
  from: current.status,
4545
4930
  to: task2.status,
4546
- note: args.note ?? null
4931
+ note: args.note ?? null,
4932
+ ...args.pull_request === void 0 ? {} : { pullRequest: task2.pullRequest?.url ?? null }
4547
4933
  });
4548
4934
  }
4549
4935
  return task2;
4550
4936
  });
4551
4937
  }
4938
+ /**
4939
+ * The pull request a task is linked to, or none for `null`: one on the repository of its project,
4940
+ * by a member of the project or a planner, while the task is in play and before it lands.
4941
+ */
4942
+ async pullRequestLink(actor, task2, link) {
4943
+ this.assertInPlay(task2);
4944
+ if (task2.completing) {
4945
+ throw new BoardError("INVALID_STATE", `task ${task2.id} is landing, so its pull request no longer changes`);
4946
+ }
4947
+ if (!PLANNING_ROLES.includes(actor.role)) {
4948
+ const agent2 = await this.readAgent(actor.name);
4949
+ if (!agent2.memberships.includes(task2.project)) {
4950
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${task2.project}`);
4951
+ }
4952
+ }
4953
+ if (link === null) {
4954
+ return void 0;
4955
+ }
4956
+ const project2 = await this.readProject(task2.project);
4957
+ const repository = githubRepository(project2.repo);
4958
+ const named = parsePullRequestUrl(link.url);
4959
+ if (repository === null || named === null || !sameRepository(repository, named)) {
4960
+ throw new BoardError("VALIDATION", `${link.url} is not a pull request of ${project2.slug}'s repository${project2.repo === null ? ", since it has no remote" : ` at ${project2.repo}`}`);
4961
+ }
4962
+ return PullRequestLinkSchema.parse({
4963
+ url: link.url,
4964
+ number: named.number,
4965
+ ...link.merge_subject === void 0 ? {} : { subject: link.merge_subject },
4966
+ ...link.merge_body === void 0 ? {} : { body: link.merge_body },
4967
+ linkedBy: actor.name,
4968
+ linkedAt: this.now().toISOString()
4969
+ });
4970
+ }
4552
4971
  async getTask(actor, input) {
4553
4972
  const args = VerbInputs.get_task.parse(input);
4554
4973
  await this.authorize(actor, "get_task");
@@ -4659,6 +5078,9 @@ ${rationale}`}`);
4659
5078
  }
4660
5079
  return this.mutex.run(async () => {
4661
5080
  const previous = await this.readActiveProject(args.project);
5081
+ if (args.on_done !== void 0) {
5082
+ assertLandsByPullRequest(args.on_done, previous.repo, `project ${previous.slug}`);
5083
+ }
4662
5084
  const project2 = await this.updateProject(args.project, (current) => ({
4663
5085
  ...current,
4664
5086
  ...args.on_done === void 0 ? {} : { onDone: args.on_done },
@@ -4682,6 +5104,7 @@ ${rationale}`}`);
4682
5104
  await this.authorize(actor, "archive_project");
4683
5105
  return this.mutex.run(async () => {
4684
5106
  await this.validateArchive(args.project);
5107
+ await this.assertProjectQuiet(actor, args.project);
4685
5108
  return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
4686
5109
  });
4687
5110
  }
@@ -4689,6 +5112,10 @@ ${rationale}`}`);
4689
5112
  * A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
4690
5113
  * directory now and, through the `agent.joined` event, an onboarding turn.
4691
5114
  */
5115
+ /**
5116
+ * A member follows the project's general from the start; the result names the project's other
5117
+ * open channels, which it subscribes to when its work there needs them.
5118
+ */
4692
5119
  async joinProject(actor, input) {
4693
5120
  const args = VerbInputs.join_project.parse(input);
4694
5121
  await this.authorize(actor, "join_project");
@@ -4701,7 +5128,7 @@ ${rationale}`}`);
4701
5128
  throw new BoardError("INVALID_STATE", `${target} is retired`);
4702
5129
  }
4703
5130
  if (current.memberships.includes(args.project)) {
4704
- return current;
5131
+ return { ...current, otherChannels: await this.otherChannels(args.project, current) };
4705
5132
  }
4706
5133
  await this.assertMayJoinProjects(target, current.role);
4707
5134
  const agent2 = await this.updateAgent(target, (a) => ({
@@ -4720,9 +5147,13 @@ ${rationale}`}`);
4720
5147
  project: args.project,
4721
5148
  cli: agent2.cli
4722
5149
  });
4723
- return agent2;
5150
+ return { ...agent2, otherChannels: await this.otherChannels(args.project, agent2) };
4724
5151
  });
4725
5152
  }
5153
+ /** The open channels of a project a citizen does not follow. */
5154
+ async otherChannels(project2, agent2) {
5155
+ return (await this.readProject(project2)).channels.map((name) => channelRef(project2, name)).filter((ref) => !agent2.subscriptions.includes(ref));
5156
+ }
4726
5157
  async leaveProject(actor, input) {
4727
5158
  const args = VerbInputs.leave_project.parse(input);
4728
5159
  await this.authorize(actor, "leave_project");
@@ -4827,6 +5258,12 @@ ${rationale}`}`);
4827
5258
  return this.configureProject(actor, VerbInputs.configure_project.parse(input));
4828
5259
  case "archive_project":
4829
5260
  return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
5261
+ case "create_channel":
5262
+ return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5263
+ case "archive_channel":
5264
+ return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
5265
+ case "update_dashboard":
5266
+ return this.updateDashboard(actor, VerbInputs.update_dashboard.parse(input));
4830
5267
  default:
4831
5268
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
4832
5269
  }
@@ -4937,29 +5374,30 @@ ${rationale}`}`);
4937
5374
  /**
4938
5375
  * Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
4939
5376
  * never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
5377
+ * `part` names the conversation beside the scope's home: a thread's id or `#channel`.
4940
5378
  */
4941
- async setDigestCursor(agent2, scope, cursor, thread2) {
5379
+ async setDigestCursor(agent2, scope, cursor, part) {
4942
5380
  await this.mutex.run(async () => {
4943
5381
  if (cursor === null) {
4944
5382
  return;
4945
5383
  }
4946
5384
  const cursors = await this.readCursors(agent2);
4947
- const current = cursorOf(cursors, scope, thread2 ?? null);
5385
+ const current = cursorOf(cursors, scope, part ?? null);
4948
5386
  const news = newsStart(cursors);
4949
- const readNews = thread2 === void 0 && (news === null || cursor > news);
5387
+ const readNews = part === void 0 && (news === null || cursor > news);
4950
5388
  if (current !== null && cursor <= current && !readNews) {
4951
5389
  return;
4952
5390
  }
4953
5391
  await writeJson(this.paths.agentCursors(agent2), {
4954
5392
  ...cursors,
4955
- scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, thread2 ?? null)]: cursor },
5393
+ scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
4956
5394
  ...readNews ? { news: cursor } : {}
4957
5395
  });
4958
5396
  });
4959
5397
  }
4960
5398
  /** Where a conversation's digest resumes: the newest message a turn there was shown. */
4961
- async digestCursor(agent2, scope, thread2) {
4962
- return this.mutex.run(async () => cursorOf(await this.readCursors(agent2), scope, thread2 ?? null));
5399
+ async digestCursor(agent2, scope, part) {
5400
+ return this.mutex.run(async () => cursorOf(await this.readCursors(agent2), scope, part ?? null));
4963
5401
  }
4964
5402
  /** Records that a running turn took posts delivered into it, which wake latency counts to. */
4965
5403
  async recordSteered(input) {
@@ -4968,25 +5406,29 @@ ${rationale}`}`);
4968
5406
  turnId: input.turnId,
4969
5407
  project: input.project,
4970
5408
  ...input.thread === void 0 ? {} : { thread: input.thread },
5409
+ ...input.channel === void 0 ? {} : { channel: input.channel },
4971
5410
  messages: [...input.messages]
4972
5411
  });
4973
5412
  });
4974
5413
  }
4975
- /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
4976
- sessionDir(agent2, scope, thread2) {
4977
- return thread2 === void 0 ? this.paths.agentProject(agent2, scope) : this.paths.agentThread(agent2, scope, thread2);
5414
+ /**
5415
+ * Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
5416
+ * thread's or a channel's, named by `part` as `conversationPart` gives it.
5417
+ */
5418
+ sessionDir(agent2, scope, part) {
5419
+ return part === void 0 ? this.paths.agentProject(agent2, scope) : this.paths.agentConversation(agent2, scope, part);
4978
5420
  }
4979
- async readSessions(agent2, project2, thread2) {
4980
- const file = path6.join(this.sessionDir(agent2, project2, thread2), "sessions.json");
5421
+ async readSessions(agent2, project2, part) {
5422
+ const file = path6.join(this.sessionDir(agent2, project2, part), "sessions.json");
4981
5423
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
4982
5424
  }
4983
5425
  /**
4984
5426
  * Records a conversation's session for a CLI and the runner it began on. A session started on
4985
5427
  * another runner replaces the record, since sessions do not move between machines.
4986
5428
  */
4987
- async writeSession(agent2, project2, cli, sessionId, runner2, thread2) {
5429
+ async writeSession(agent2, project2, cli, sessionId, runner2, part) {
4988
5430
  await this.mutex.run(async () => {
4989
- const dir = this.sessionDir(agent2, project2, thread2);
5431
+ const dir = this.sessionDir(agent2, project2, part);
4990
5432
  await ensureDir(dir);
4991
5433
  const file = path6.join(dir, "sessions.json");
4992
5434
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
@@ -4994,17 +5436,21 @@ ${rationale}`}`);
4994
5436
  await writeJson(file, { ...kept, [cli]: sessionId, runner: runner2 });
4995
5437
  });
4996
5438
  }
4997
- /** The last turn of one conversation: the scope's home, or a thread's. */
4998
- async readLastTurn(agent2, project2, thread2) {
4999
- const file = path6.join(this.sessionDir(agent2, project2, thread2), "last-turn.json");
5439
+ /** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
5440
+ async readLastTurn(agent2, project2, part) {
5441
+ const file = path6.join(this.sessionDir(agent2, project2, part), "last-turn.json");
5000
5442
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
5001
5443
  }
5002
5444
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
5003
5445
  async readLatestTurn(agent2, project2) {
5004
5446
  let latest = await this.readLastTurn(agent2, project2);
5005
- const threads = path6.join(this.paths.agentProject(agent2, project2), "threads");
5006
- for (const thread2 of await listDirs(threads)) {
5007
- const turn2 = await this.readLastTurn(agent2, project2, thread2);
5447
+ const place = this.paths.agentProject(agent2, project2);
5448
+ const parts = [
5449
+ ...await listDirs(path6.join(place, "threads")),
5450
+ ...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
5451
+ ];
5452
+ for (const part of parts) {
5453
+ const turn2 = await this.readLastTurn(agent2, project2, part);
5008
5454
  if (turn2 !== null && (latest === null || turnTime(turn2) > turnTime(latest))) {
5009
5455
  latest = turn2;
5010
5456
  }
@@ -5021,6 +5467,7 @@ ${rationale}`}`);
5021
5467
  turnId: parsed.id,
5022
5468
  project: parsed.project,
5023
5469
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5470
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5024
5471
  trigger: parsed.trigger.kind,
5025
5472
  session: parsed.session,
5026
5473
  runner: parsed.runner
@@ -5046,13 +5493,15 @@ ${rationale}`}`);
5046
5493
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5047
5494
  agent: parsed.agent,
5048
5495
  project: parsed.project,
5049
- ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
5496
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5497
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
5050
5498
  });
5051
5499
  }
5052
5500
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
5053
5501
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5054
5502
  project: parsed.project,
5055
5503
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5504
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5056
5505
  trigger: parsed.trigger.kind,
5057
5506
  session: parsed.session,
5058
5507
  exitReason: parsed.exitReason,
@@ -5076,10 +5525,51 @@ ${rationale}`}`);
5076
5525
  });
5077
5526
  });
5078
5527
  }
5528
+ /**
5529
+ * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
5530
+ * the scheduler gives the citizen a closing turn there to decide about it. False, recording
5531
+ * nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
5532
+ */
5533
+ async recordLeftovers(input) {
5534
+ const { conversation } = input;
5535
+ const payload = {
5536
+ agent: input.agent,
5537
+ project: input.scope,
5538
+ ...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
5539
+ };
5540
+ return this.mutex.run(async () => {
5541
+ 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;
5542
+ if (earlier >= CLOSING_ATTEMPTS) {
5543
+ return false;
5544
+ }
5545
+ await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
5546
+ return true;
5547
+ });
5548
+ }
5549
+ /**
5550
+ * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
5551
+ * (for one from before tasks had threads), or a channel archived.
5552
+ */
5553
+ async conversationEnded(conversation) {
5554
+ if (conversation.kind === "channel") {
5555
+ const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
5556
+ return !await this.channelOpen(channelRef(place, conversation.name));
5557
+ }
5558
+ const found = await this.tryFindThread(conversation.id);
5559
+ if (found !== null) {
5560
+ return found.thread.state === "closed";
5561
+ }
5562
+ try {
5563
+ const { task: task2 } = await this.findTask(conversation.id);
5564
+ return task2.status === "done" || task2.status === "abandoned";
5565
+ } catch {
5566
+ return false;
5567
+ }
5568
+ }
5079
5569
  /**
5080
5570
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5081
- * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
5082
- * landed one announced in the project's general channel.
5571
+ * its participants can reshape the plan. A landing's outcome, a merge's or a pull request's, is
5572
+ * posted to the task's thread, and a landed one announced in the task's channel.
5083
5573
  */
5084
5574
  async finishCompletion(actor, input) {
5085
5575
  return this.mutex.run(async () => {
@@ -5089,8 +5579,8 @@ ${rationale}`}`);
5089
5579
  throw new BoardError("INVALID_STATE", `task ${current.id} is not completing`);
5090
5580
  }
5091
5581
  const ts = this.now().toISOString();
5092
- const merged = current.onDone === "merge";
5093
- if (merged) {
5582
+ const landed = current.onDone !== "none";
5583
+ if (landed) {
5094
5584
  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 });
5095
5585
  }
5096
5586
  if (input.ok) {
@@ -5110,8 +5600,8 @@ ${rationale}`}`);
5110
5600
  for (const name of new Set(task3.stages.flatMap((stage) => stage.completedBy ?? []))) {
5111
5601
  await this.refreshMember(name);
5112
5602
  }
5113
- if (merged) {
5114
- await this.appendMessage(actor.name, channelRef(location.project, "general"), `Task ${task3.id} "${task3.title}" is done: ${sentence(input.detail)}.`);
5603
+ if (landed) {
5604
+ await this.appendMessage(actor.name, channelRef(location.project, task3.channel), `Task ${task3.id} "${task3.title}" is done: ${sentence(input.detail)}.`);
5115
5605
  }
5116
5606
  return task3;
5117
5607
  }
@@ -5140,15 +5630,31 @@ ${rationale}`}`);
5140
5630
  async writeState(name, value) {
5141
5631
  await this.mutex.run(() => writeJson(path6.join(this.paths.state(), `${name}.json`), value));
5142
5632
  }
5143
- /** The project's editable dashboard: frontmatter plus markdown body. Empty when none exists. */
5633
+ /** The project's dashboard, empty at revision 0 when it has none. */
5144
5634
  async readDashboard(slug) {
5145
5635
  await this.readProject(slug);
5636
+ return this.dashboardOf(slug);
5637
+ }
5638
+ /**
5639
+ * Read loosely, since agents wrote dashboards by hand before they had a verb: a revision or an
5640
+ * author that does not parse counts as none.
5641
+ */
5642
+ async dashboardOf(slug) {
5146
5643
  const file = this.paths.dashboard(slug);
5147
5644
  if (!await exists(file)) {
5148
- return { data: { project: slug }, body: "" };
5645
+ return { project: slug, revision: 0, body: "" };
5149
5646
  }
5150
5647
  const { data, body } = await readLooseMarkdown(file);
5151
- return { data, body };
5648
+ const revision = z12.number().int().min(0).safeParse(data["revision"]);
5649
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
5650
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
5651
+ return {
5652
+ project: slug,
5653
+ revision: revision.success ? revision.data : 0,
5654
+ ...updatedBy.success ? { updatedBy: updatedBy.data } : {},
5655
+ ...updatedAt.success ? { updatedAt: updatedAt.data } : {},
5656
+ body
5657
+ };
5152
5658
  }
5153
5659
  async listRunners() {
5154
5660
  const runners = [];
@@ -5348,7 +5854,7 @@ ${rationale}`}`);
5348
5854
  return this.files.read(this.paths.board, paths, () => true);
5349
5855
  }
5350
5856
  async writeTurnRecord(record) {
5351
- const dir = this.sessionDir(record.agent, record.project, record.thread);
5857
+ const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
5352
5858
  await ensureDir(dir);
5353
5859
  await writeJson(path6.join(dir, "last-turn.json"), record);
5354
5860
  }
@@ -5652,7 +6158,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5652
6158
  };
5653
6159
  }
5654
6160
  case "channel": {
5655
- const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal2.charter), meta);
6161
+ const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal2.charter), meta, proposal2.proposedBy);
5656
6162
  return { channel };
5657
6163
  }
5658
6164
  case "role": {
@@ -5758,18 +6264,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5758
6264
  return { agent: agent2, token };
5759
6265
  }
5760
6266
  async validateAddChannel(input) {
5761
- if (input.project === null) {
5762
- if ((await this.society()).channels.includes(input.name)) {
5763
- throw new BoardError("ALREADY_EXISTS", `society channel ${input.name} already exists`);
5764
- }
5765
- return;
6267
+ const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
6268
+ const ref = channelRef(input.project, input.name);
6269
+ if (place.channels.includes(input.name)) {
6270
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
5766
6271
  }
5767
- const project2 = await this.readActiveProject(input.project);
5768
- if (project2.channels.includes(input.name)) {
5769
- throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
6272
+ if (place.archivedChannels.some((each) => each.name === input.name)) {
6273
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
5770
6274
  }
5771
6275
  }
5772
- async addChannelUnlocked(by, input, meta) {
6276
+ /**
6277
+ * Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
6278
+ * it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
6279
+ */
6280
+ async addChannelUnlocked(by, input, meta, opener) {
5773
6281
  const ref = channelRef(input.project, input.name);
5774
6282
  if (input.project === null) {
5775
6283
  const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
@@ -5783,14 +6291,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5783
6291
  }));
5784
6292
  await ensureDir(this.paths.projectChannel(project2, input.name));
5785
6293
  }
6294
+ const follower = await this.readAgent(opener).catch(() => null);
6295
+ const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
6296
+ if (follows) {
6297
+ await this.updateAgent(opener, (agent2) => ({
6298
+ ...agent2,
6299
+ subscriptions: [.../* @__PURE__ */ new Set([...agent2.subscriptions, ref])]
6300
+ }));
6301
+ await this.refreshMember(opener);
6302
+ }
5786
6303
  await this.events.append("channel.added", by, {
5787
6304
  channel: ref,
5788
6305
  purpose: input.purpose,
6306
+ ...follows ? { followedBy: opener } : {},
5789
6307
  ...meta
5790
6308
  });
5791
- await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${input.purpose}`);
6309
+ const purpose = withoutMentions(input.purpose);
6310
+ await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
6311
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}. Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
5792
6312
  return ref;
5793
6313
  }
6314
+ /**
6315
+ * Ends a channel: its open threads close, its followers stop following it, and it leaves the
6316
+ * place's channels for its archived ones, where its history stays readable. The notice is the
6317
+ * board's and leaves the reason out, since a board-authored mention would wake whoever it names.
6318
+ */
6319
+ async archiveChannelUnlocked(actor, ref, reason) {
6320
+ const by = actor.name;
6321
+ const { project: project2, channel } = parseChannelRef(ref);
6322
+ if (isBoardChannel(project2, channel)) {
6323
+ throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
6324
+ }
6325
+ await this.assertChannelOpen(ref);
6326
+ if (project2 !== null) {
6327
+ const inPlay = (await this.listTasks(project2)).filter((task2) => task2.channel === channel && task2.status !== "done" && task2.status !== "abandoned");
6328
+ if (inPlay.length > 0) {
6329
+ throw new BoardError("INVALID_STATE", `${inPlay.length} task(s) filed in ${ref} are still in play: ${inPlay.map((task2) => task2.id).join(", ")}`);
6330
+ }
6331
+ }
6332
+ const ts = this.now().toISOString();
6333
+ const threads = project2 === null ? this.paths.societyThreads() : this.paths.threads(project2);
6334
+ const closing = await this.openThreadIds(threads, (thread2) => thread2.channel === ref);
6335
+ const scope = project2 ?? SOCIETY_SCOPE;
6336
+ this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn2) => turn2.channel === channel && turn2.scope === scope || turn2.thread !== void 0 && closing.has(turn2.thread));
6337
+ const threadsClosed = [];
6338
+ for (const file of await listFiles(threads)) {
6339
+ const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
6340
+ if (doc.data.channel !== ref || doc.data.state !== "open") {
6341
+ continue;
6342
+ }
6343
+ await this.writeThread(threads, {
6344
+ ...doc.data,
6345
+ body: doc.body,
6346
+ state: "closed",
6347
+ closedBy: by,
6348
+ closedAt: ts
6349
+ });
6350
+ await this.events.append("thread.closed", by, {
6351
+ threadId: doc.data.id,
6352
+ channel: ref,
6353
+ ended: "archived"
6354
+ });
6355
+ threadsClosed.push(doc.data.id);
6356
+ }
6357
+ const unsubscribed = [];
6358
+ for (const agent2 of await this.listAgents()) {
6359
+ if (agent2.subscriptions.includes(ref)) {
6360
+ await this.updateAgent(agent2.name, (a) => ({
6361
+ ...a,
6362
+ subscriptions: a.subscriptions.filter((each) => each !== ref)
6363
+ }));
6364
+ await this.refreshMember(agent2.name);
6365
+ unsubscribed.push(agent2.name);
6366
+ }
6367
+ }
6368
+ const archived = { name: channel, at: ts, by, reason };
6369
+ if (project2 === null) {
6370
+ const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
6371
+ await writeMarkdown(this.paths.societyFile(), {
6372
+ ...doc.data,
6373
+ channels: doc.data.channels.filter((name) => name !== channel),
6374
+ archivedChannels: [...doc.data.archivedChannels, archived]
6375
+ }, doc.body);
6376
+ } else {
6377
+ await this.updateProject(project2, (current) => ({
6378
+ ...current,
6379
+ channels: current.channels.filter((name) => name !== channel),
6380
+ archivedChannels: [...current.archivedChannels, archived]
6381
+ }));
6382
+ }
6383
+ await this.events.append("channel.archived", by, {
6384
+ channel: ref,
6385
+ reason,
6386
+ threadsClosed,
6387
+ unsubscribed
6388
+ });
6389
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(project2, "general"), `Channel ${ref} is archived by ${by}. Its posts, threads, and tasks stay readable; nothing more is posted or filed there.`);
6390
+ return archived;
6391
+ }
5794
6392
  /** A role becomes a society role only while none of its members is in a project. */
5795
6393
  async validateRole(charter) {
5796
6394
  if (charter.name === USER_ROLE) {
@@ -6003,23 +6601,35 @@ ${charter.purpose}
6003
6601
  async publishSignalUnlocked(signal) {
6004
6602
  return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
6005
6603
  }
6604
+ /** A channel that exists, open or archived, and the record of its archive when it has one. */
6006
6605
  async assertChannelExists(ref) {
6007
6606
  const parsed = parseChannelRef(ref);
6008
- if (parsed.project === null) {
6009
- const society = await this.society();
6010
- if (!society.channels.includes(parsed.channel)) {
6011
- throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
6012
- }
6013
- return;
6607
+ const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
6608
+ if (place.channels.includes(parsed.channel)) {
6609
+ return void 0;
6014
6610
  }
6015
- const project2 = await this.readProject(parsed.project);
6016
- if (!project2.channels.includes(parsed.channel)) {
6017
- throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
6611
+ const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
6612
+ if (archived === void 0) {
6613
+ throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
6018
6614
  }
6615
+ return archived;
6019
6616
  }
6020
- /** A channel that takes new posts, threads, and followers: it exists, and its project is not archived. */
6617
+ /** Whether a channel exists and is not archived, its conversations still going. */
6618
+ async channelOpen(ref) {
6619
+ try {
6620
+ return await this.assertChannelExists(ref) === void 0;
6621
+ } catch {
6622
+ return false;
6623
+ }
6624
+ }
6625
+ /**
6626
+ * A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
6627
+ * and its project is not archived.
6628
+ */
6021
6629
  async assertChannelOpen(ref) {
6022
- await this.assertChannelExists(ref);
6630
+ if (await this.assertChannelExists(ref) !== void 0) {
6631
+ throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
6632
+ }
6023
6633
  const { project: project2 } = parseChannelRef(ref);
6024
6634
  if (project2 !== null) {
6025
6635
  await this.readActiveProject(project2);
@@ -6119,6 +6729,10 @@ ${charter.purpose}
6119
6729
  if (outcome === "approved") {
6120
6730
  await this.validateProvision(proposal2.kind, proposal2.charter);
6121
6731
  }
6732
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn2) => turn2.thread === proposalId);
6733
+ if (outcome === "approved" && proposal2.kind === "archive") {
6734
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal2.charter).project);
6735
+ }
6122
6736
  const ts = this.now().toISOString();
6123
6737
  const decision = DecisionSchema.parse({
6124
6738
  id: this.newId(),
@@ -6387,7 +7001,7 @@ runner.command("add <name>").description("Register a runner and print its token
6387
7001
  runner.command("list").description("List runners and what they offer").action(async () => {
6388
7002
  const board = await open();
6389
7003
  const runners = await board.listRunners();
6390
- print(runners, () => runners.length === 0 ? "No runners yet; add one with `runner add <name>`." : runners.map((each) => `${each.name} ${each.status} ${each.os} ${each.clis.join(",") || "no CLIs yet"}${each.capabilities.length === 0 ? "" : ` ${each.capabilities.join(",")}`}`).join("\n"));
7004
+ print(runners, () => runners.length === 0 ? "No runners yet; add one with `runner add <name>`." : runners.map((each) => `${each.name} ${each.status} ${each.os}${each.version === void 0 ? "" : ` v${each.version}`} ${each.clis.join(",") || "no CLIs yet"}${each.capabilities.length === 0 ? "" : ` ${each.capabilities.join(",")}`}`).join("\n"));
6391
7005
  });
6392
7006
  var agent = program.command("agent").description("Manage agents");
6393
7007
  agent.command("add <name>").description("Add an agent and print its board token once").requiredOption("--role <role>", "role charter name").option("--cli <cli>", "claude or codex").option("--model <model>", "model to run the CLI with; the CLI's own default otherwise").option("--runner <name>", "runner preferred for its turns; any with its CLI otherwise").option("-p, --project <slug...>", "project memberships").action(async (name, opts) => {
@@ -6519,6 +7133,14 @@ channelCommand.command("add <ref>").description("Open a channel: <name> for the
6519
7133
  });
6520
7134
  print({ channel: added }, () => `Channel ${added} opened`);
6521
7135
  });
7136
+ channelCommand.command("archive <ref>").description("Archive a channel whose workstream is done, once no task filed there is in play").requiredOption("--reason <text>", "why its work is done").option("--as <agent>", "act as this agent instead of the user").action(async (ref, opts) => {
7137
+ const board = await open();
7138
+ const archived = await board.archiveChannel(await actorFor(board, opts.as), {
7139
+ channel: ref,
7140
+ reason: opts.reason
7141
+ });
7142
+ print(archived, () => `Channel ${ref} archived: ${archived.reason}`);
7143
+ });
6522
7144
  program.command("signals").description("Recent operations signals, oldest first").option("--limit <n>", "how many", "50").action(async (opts) => {
6523
7145
  const board = await open();
6524
7146
  const signals = await board.listSignals(Number(opts.limit));
@@ -6540,18 +7162,19 @@ program.command("ask <text>").description("Post to the society's general channel
6540
7162
  print(message, () => `Posted ${message.id} to general; the concierge wakes on it`);
6541
7163
  });
6542
7164
  var task = program.command("task").description("Manage tasks");
6543
- task.command("create <project> <title>").description("Create a task with a plan, or with one stage anyone in the project may take").option("--body <text>", "task body", "").option("--parent <id>", "parent task id").option("--plan <json>", `stages, for example '[{"name":"analysis","role":"analyst"}]'`).option("--as <agent>", "act as this agent instead of the user").action(async (projectSlug, title, opts) => {
7165
+ task.command("create <project> <title>").description("Create a task with a plan, or with one stage anyone in the project may take").option("--body <text>", "task body", "").option("--parent <id>", "parent task id").option("--plan <json>", `stages, for example '[{"name":"analysis","role":"analyst"}]'`).option("--channel <name>", "the project's channel to file it in, such as a release's", "general").option("--as <agent>", "act as this agent instead of the user").action(async (projectSlug, title, opts) => {
6544
7166
  const board = await open();
6545
7167
  const who = await actorFor(board, opts.as);
6546
7168
  const created = await board.createTask(who, {
6547
7169
  project: projectSlug,
6548
7170
  title,
6549
7171
  body: opts.body,
7172
+ channel: opts.channel,
6550
7173
  ...opts.parent === void 0 ? {} : { parent_id: opts.parent },
6551
7174
  ...opts.plan === void 0 ? {} : { stages: parsePlan(opts.plan) }
6552
7175
  });
6553
7176
  print(created, () => [
6554
- `Task ${created.id} created in ${created.project}: ${created.title}`,
7177
+ `Task ${created.id} created in ${created.project}/${created.channel}: ${created.title}`,
6555
7178
  describePlan(created)
6556
7179
  ].join("\n"));
6557
7180
  });