@kubitnodes/stellaris 0.3.11 → 0.3.13

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 (62) hide show
  1. package/dist/cli/index.js +287 -28
  2. package/dist/server/index.js +542 -63
  3. package/package.json +1 -1
  4. package/web/dist/assets/{BackLink-C-vad6K2.js → BackLink-d0xz7ZLb.js} +1 -1
  5. package/web/dist/assets/ChannelForms-BIRwT9CY.js +1 -0
  6. package/web/dist/assets/ChannelView-BvMlzMiN.js +1 -0
  7. package/web/dist/assets/CitizenView-DNQwjpIp.js +2 -0
  8. package/web/dist/assets/CitizensView-Bv04iH-y.js +1 -0
  9. package/web/dist/assets/CliIcon-tYTwI_RQ.js +1 -0
  10. package/web/dist/assets/KnowledgeView-L0pdaM_2.js +1 -0
  11. package/web/dist/assets/{Markdown-J2pAPfCa.js → Markdown-BLiIulgZ.js} +4 -4
  12. package/web/dist/assets/MessageItem-CHrmjF7P.js +1 -0
  13. package/web/dist/assets/MetricsView-DG1alTgs.js +1 -0
  14. package/web/dist/assets/NeedsYouView-Doc0fKPi.js +1 -0
  15. package/web/dist/assets/Overview-ChsOgIEW.js +1 -0
  16. package/web/dist/assets/{Pane-caYWCiyo.js → Pane-MJgGXQBb.js} +1 -1
  17. package/web/dist/assets/ProjectView-CQTn7yDo.js +1 -0
  18. package/web/dist/assets/ProposalView-C92OJ_w1.js +1 -0
  19. package/web/dist/assets/ProposalsView-CSA8neXR.js +1 -0
  20. package/web/dist/assets/RunnersView-Dx21LDFu.js +1 -0
  21. package/web/dist/assets/SocietyView-CdVW2DgG.js +1 -0
  22. package/web/dist/assets/{SubjectThread-D_L3vB-A.js → SubjectThread-egXeFhT1.js} +1 -1
  23. package/web/dist/assets/TaskFileView-BqqzxJKb.js +4 -0
  24. package/web/dist/assets/TaskView-rks26vuF.js +3 -0
  25. package/web/dist/assets/TasksView-Ba5fPAvy.js +1 -0
  26. package/web/dist/assets/ThreadCard-D6i69_15.js +1 -0
  27. package/web/dist/assets/ThreadForms-ChTFRu-P.js +1 -0
  28. package/web/dist/assets/ThreadView-CnJPNS_8.js +1 -0
  29. package/web/dist/assets/{citizen-BtOZq62_.js → citizen-D-dxX00V.js} +1 -1
  30. package/web/dist/assets/compose-BgpUWGYE.js +1 -0
  31. package/web/dist/assets/{format-EfoceZkm.js → format-C3aawMM6.js} +1 -1
  32. package/web/dist/assets/governance-Di4MijNG.js +1 -0
  33. package/web/dist/assets/index-VVnGdAXc.css +2 -0
  34. package/web/dist/assets/index-krHkeIbX.js +23 -0
  35. package/web/dist/assets/link-DAMxBItI.js +1 -0
  36. package/web/dist/assets/mentions-BCdV0bMK.js +1 -0
  37. package/web/dist/assets/{tasks-Duke4U7N.js → tasks-bwj_EdwF.js} +1 -1
  38. package/web/dist/assets/useMatch-B-8IcuU6.js +1 -0
  39. package/web/dist/assets/{useStickyScroll-n5d7sdPZ.js → useStickyScroll-BILPUgGj.js} +1 -1
  40. package/web/dist/index.html +10 -4
  41. package/web/dist/assets/ChannelForms-jNbbNnIC.js +0 -1
  42. package/web/dist/assets/ChannelView-kLoegYUQ.js +0 -1
  43. package/web/dist/assets/CitizenView-Cb8f2mB7.js +0 -2
  44. package/web/dist/assets/CitizensView-Ds2rojfa.js +0 -1
  45. package/web/dist/assets/KnowledgeView-k10TU0jc.js +0 -1
  46. package/web/dist/assets/MessageItem-BjM8BdpK.js +0 -1
  47. package/web/dist/assets/MetricsView-D9N54rV7.js +0 -1
  48. package/web/dist/assets/NeedsYouView-ML8i7Pl7.js +0 -1
  49. package/web/dist/assets/Overview-BBXvRhfR.js +0 -1
  50. package/web/dist/assets/ProjectView-sDpFNjcW.js +0 -1
  51. package/web/dist/assets/ProposalView-Bc91NjKC.js +0 -1
  52. package/web/dist/assets/ProposalsView-Dg_bEryX.js +0 -1
  53. package/web/dist/assets/RunnersView-Dv5nbwYv.js +0 -1
  54. package/web/dist/assets/SocietyView-ClaEWgOq.js +0 -1
  55. package/web/dist/assets/TaskFileView-B19YtrtW.js +0 -4
  56. package/web/dist/assets/TaskView-FYSoCb3T.js +0 -1
  57. package/web/dist/assets/TasksView-CniXTU3R.js +0 -1
  58. package/web/dist/assets/ThreadCard-DCpvhSlc.js +0 -1
  59. package/web/dist/assets/ThreadForms-DUGkAwTp.js +0 -1
  60. package/web/dist/assets/ThreadView-D8eXlVbC.js +0 -1
  61. package/web/dist/assets/index-BeOGtAz0.css +0 -2
  62. package/web/dist/assets/index-D75IxLme.js +0 -23
@@ -59,7 +59,8 @@ var VERB_NAMES = [
59
59
  "archive_project",
60
60
  "remove_knowledge",
61
61
  "create_channel",
62
- "archive_channel"
62
+ "archive_channel",
63
+ "update_dashboard"
63
64
  ];
64
65
  var VerbNameSchema = z2.enum(VERB_NAMES);
65
66
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -105,7 +106,8 @@ var MEMBER_VERBS = [
105
106
  "write_knowledge",
106
107
  "remove_knowledge",
107
108
  "plan_task",
108
- "advance_task"
109
+ "advance_task",
110
+ "update_dashboard"
109
111
  ];
110
112
  var GOVERNANCE_VERBS = ["approve", "reject"];
111
113
  var FRONT_DESK_VERBS = ["create_project"];
@@ -185,7 +187,44 @@ var SocietySchema = z3.object({
185
187
  channels: z3.array(NameSchema),
186
188
  archivedChannels: z3.array(ArchivedChannelSchema).default([])
187
189
  });
188
- var CompletionEffectSchema = z3.enum(["none", "merge"]);
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,
222
+ /**
223
+ * Once its `ghpr` task was abandoned, how the board's closing of it on GitHub went: `ok` false
224
+ * leaves it open, for the reason `detail` gives.
225
+ */
226
+ closed: z3.object({ at: IsoDateTimeSchema, ok: z3.boolean(), detail: z3.string() }).optional()
227
+ });
189
228
  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");
190
229
  var StageIdSchema = z3.string().regex(/^s[1-9][0-9]*$/, "expected a stage id such as s1");
191
230
  var stageFields = {
@@ -300,6 +339,8 @@ var TaskFrontmatterSchema = z3.object({
300
339
  /** The highest stage number issued, so a removed stage's id is never reused. */
301
340
  stageSeq: z3.number().int().positive(),
302
341
  onDone: CompletionEffectSchema.default("none"),
342
+ /** The pull request a `ghpr` task lands through. */
343
+ pullRequest: PullRequestLinkSchema.optional(),
303
344
  /** The last stage is complete and the completion effect is running. */
304
345
  completing: z3.boolean().default(false),
305
346
  /** The latest send-back while its rework lasts: cleared once the task reaches that stage again. */
@@ -392,6 +433,8 @@ var RunnerSchema = z3.object({
392
433
  os: RunnerOsSchema,
393
434
  clis: z3.array(CliKindSchema),
394
435
  capabilities: z3.array(z3.string()),
436
+ /** The release the runner reported when it last registered; absent until it has. */
437
+ version: z3.string().optional(),
395
438
  status: z3.enum(["connected", "disconnected"]),
396
439
  lastSeen: IsoDateTimeSchema.optional()
397
440
  });
@@ -570,12 +613,27 @@ var RemovedKnowledgeSchema = z3.object({
570
613
  removedBy: NameSchema,
571
614
  removedAt: IsoDateTimeSchema
572
615
  });
616
+ var DashboardSchema = z3.object({
617
+ project: NameSchema,
618
+ revision: z3.number().int().min(0),
619
+ /** Absent until someone updates the dashboard the project began with. */
620
+ updatedBy: NameSchema.optional(),
621
+ updatedAt: IsoDateTimeSchema.optional(),
622
+ body: z3.string()
623
+ });
624
+ var ProposalOriginSchema = z3.object({
625
+ scope: NameSchema,
626
+ thread: UlidSchema.optional(),
627
+ channel: NameSchema.optional()
628
+ });
573
629
  var ProposalFrontmatterSchema = z3.object({
574
630
  id: UlidSchema,
575
631
  kind: ProposalKindSchema,
576
632
  proposedBy: NameSchema,
577
633
  status: ProposalStatusSchema,
578
634
  createdAt: IsoDateTimeSchema,
635
+ /** Absent for a proposal filed outside a turn, or before proposals recorded it. */
636
+ filedFrom: ProposalOriginSchema.optional(),
579
637
  decidedBy: NameSchema.optional(),
580
638
  decidedAt: IsoDateTimeSchema.optional(),
581
639
  reason: z3.string().optional(),
@@ -641,7 +699,16 @@ var VerbInputs = {
641
699
  status: z4.enum(["abandoned"]).optional(),
642
700
  /** Posted to the task's thread as the caller, marked with the step when the task moved. */
643
701
  note: z4.string().min(1).optional(),
644
- blocked_by: z4.array(UlidSchema).optional()
702
+ blocked_by: z4.array(UlidSchema).optional(),
703
+ /**
704
+ * The pull request a `ghpr` task lands through, on its project's repository, with the merge
705
+ * commit's subject and body; null unlinks it.
706
+ */
707
+ pull_request: z4.object({
708
+ url: PullRequestUrlSchema,
709
+ merge_subject: MergeSubjectSchema.optional(),
710
+ merge_body: z4.string().max(2e4).optional()
711
+ }).nullable().optional()
645
712
  }),
646
713
  get_task: z4.object({ task_id: UlidSchema }),
647
714
  subscribe: z4.object({ channel: ChannelRefSchema }),
@@ -695,7 +762,14 @@ var VerbInputs = {
695
762
  name: NameSchema,
696
763
  purpose: z4.string().min(1)
697
764
  }),
698
- archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) })
765
+ archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) }),
766
+ update_dashboard: z4.object({
767
+ project: NameSchema,
768
+ /** The whole dashboard as markdown; the board keeps its frontmatter. */
769
+ body: z4.string().min(1),
770
+ /** The `revision` in the frontmatter of the dashboard you read, 0 when it names none. */
771
+ revision: z4.number().int().min(0)
772
+ })
699
773
  };
700
774
  var VERB_DESCRIPTIONS = {
701
775
  post_message: "Post a markdown message to a channel, or into a thread when thread_id is given; a thread's message goes to the thread's channel.",
@@ -706,24 +780,25 @@ var VERB_DESCRIPTIONS = {
706
780
  create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
707
781
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
708
782
  release_task: "Let go of the stage you hold so someone else can take it.",
709
- update_task: "Move a task back to an earlier stage, abandon it, or set what it is blocked by. A note is posted to the task's thread.",
783
+ update_task: "Move a task back to an earlier stage, abandon it, set what it is blocked by, or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
710
784
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
711
785
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
712
786
  unsubscribe: "Unsubscribe from a channel.",
713
787
  propose: "Propose a member, role, channel, reallocation, retirement, skill, or a project's archive. The charter shape per kind is in your instructions. Opens the proposal's thread in governance with your rationale; approval provisions it.",
714
788
  approve: "Approve a proposal; the board then provisions it, posts the decision in the proposal's thread, and closes it. User and steward only, never on your own proposal.",
715
789
  reject: "Reject a proposal with a reason, posted in the proposal's thread, which closes. User and steward only, never on your own proposal.",
716
- create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none or merge).",
790
+ create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none, merge, or ghpr for a GitHub remote).",
717
791
  join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn, and follows the project's general; the result lists its other open channels, to subscribe to when the work needs them.",
718
792
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
719
793
  write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
720
794
  remove_knowledge: "Remove a knowledge topic that is wrong, superseded, or folded into another, so no turn or search finds it again. Whoever may write the topic may remove it; the board keeps its text aside for the user to restore.",
721
795
  plan_task: "Reshape a task's plan from the current stage onward, or after it while someone holds it: keep a stage by passing its id, drop it by leaving it out, add one without an id. Gated stages and on_done: user, steward, concierge.",
722
796
  advance_task: "Finish the stage you hold, with a note for the task's thread on what you did. The next stage becomes current; past the last one the task is done.",
723
- configure_project: "Set a project's completion effect (none, or merge to land each finished task's branch on the default branch), its display name, or its default branch, any of them at once. The slug never changes.",
797
+ configure_project: "Set a project's completion effect (none; merge to land each finished task's branch on the default branch; ghpr, for a project on GitHub, to merge each finished task's linked pull request), its display name, or its default branch, any of them at once. Tasks already filed keep theirs. The slug never changes.",
724
798
  archive_project: "Archive a project whose work is finished or has moved: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay. Refused while a task there is in play.",
725
799
  create_channel: "Open a channel in a project, or with project null in the society, for a workstream such as a release, whose tasks are filed there. You follow it; a notice in the place's general announces it, and members subscribe when it concerns them. User, steward, concierge.",
726
- archive_channel: "Archive a channel whose workstream is done: its open threads close, its followers stop following it, and nothing more is posted or filed there, while its history stays readable. Refused while a task filed there is in play, and for the channels the board itself uses. User, steward, concierge."
800
+ archive_channel: "Archive a channel whose workstream is done: its open threads close, its followers stop following it, and nothing more is posted or filed there, while its history stays readable. Refused while a task filed there is in play, and for the channels the board itself uses. User, steward, concierge.",
801
+ update_dashboard: "Replace the dashboard of a project you are a member of, the status page the user reads on its view, with a whole markdown body. Pass the revision from the frontmatter of the dashboard you read; if it was updated since, the call is refused with the current text to merge yours into. dashboard.md in the board projection is a copy, so editing it changes nothing."
727
802
  };
728
803
 
729
804
  // packages/shared/dist/events.js
@@ -763,6 +838,7 @@ var BOARD_EVENT_TYPES = [
763
838
  "agent.left",
764
839
  "knowledge.written",
765
840
  "knowledge.removed",
841
+ "dashboard.updated",
766
842
  "skill.promoted",
767
843
  "runner.added",
768
844
  "user.signed_in",
@@ -779,6 +855,8 @@ var BOARD_EVENT_TYPES = [
779
855
  "turn.failed",
780
856
  "merge.completed",
781
857
  "merge.failed",
858
+ "pull_request.closed",
859
+ "pull_request.close_failed",
782
860
  "workspace.leftovers"
783
861
  ];
784
862
  var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
@@ -934,8 +1012,21 @@ var PendingEnrollmentSchema = z6.object({
934
1012
  var ProjectRepoSchema = z6.object({
935
1013
  slug: NameSchema,
936
1014
  origin: z6.string().nullable(),
937
- defaultBranch: z6.string().min(1)
1015
+ defaultBranch: z6.string().min(1),
1016
+ /**
1017
+ * The board lands finished tasks on the default branch, itself or through their pull requests,
1018
+ * so the runner lets nothing else move it.
1019
+ */
1020
+ boardLands: z6.boolean()
938
1021
  });
1022
+ function projectRepo(project) {
1023
+ return {
1024
+ slug: project.slug,
1025
+ origin: project.repo,
1026
+ defaultBranch: project.defaultBranch,
1027
+ boardLands: project.onDone !== "none"
1028
+ };
1029
+ }
939
1030
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
940
1031
  z6.object({
941
1032
  kind: z6.literal("home"),
@@ -1008,7 +1099,22 @@ var TurnOutcomeSchema = z6.object({
1008
1099
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
1009
1100
  var LandRequestSchema = z6.object({
1010
1101
  repo: ProjectRepoSchema,
1011
- branch: z6.string().min(1)
1102
+ branch: z6.string().min(1),
1103
+ /**
1104
+ * For a `ghpr` task, the pull request to merge in place of the branch, which must hold the
1105
+ * branch's files, and the merge commit's subject and body, the pull request's title and an
1106
+ * empty body when left out.
1107
+ */
1108
+ pullRequest: z6.object({
1109
+ url: z6.string().min(1),
1110
+ subject: z6.string().min(1).optional(),
1111
+ body: z6.string().optional()
1112
+ }).optional()
1113
+ });
1114
+ var CloseRequestSchema = z6.object({
1115
+ repo: ProjectRepoSchema,
1116
+ url: z6.string().min(1),
1117
+ comment: z6.string().min(1)
1012
1118
  });
1013
1119
  var MergeOutcomeSchema = z6.object({ ok: z6.boolean(), detail: z6.string() });
1014
1120
  var ModelListSchema = z6.array(ModelOptionSchema);
@@ -1083,6 +1189,8 @@ var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
1083
1189
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
1084
1190
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
1085
1191
  z6.object({ type: z6.literal("land"), request: z6.string().min(1), land: LandRequestSchema }),
1192
+ /** Answered with a `MergeOutcome`. */
1193
+ z6.object({ type: z6.literal("close"), request: z6.string().min(1), close: CloseRequestSchema }),
1086
1194
  z6.object({ type: z6.literal("models"), request: z6.string().min(1), cli: CliKindSchema }),
1087
1195
  /** Answered with a `BranchFile`, or null when the branch or the path does not exist. */
1088
1196
  z6.object({ type: z6.literal("file"), request: z6.string().min(1), read: BranchReadSchema }),
@@ -1485,7 +1593,7 @@ var RunnerConfigSchema = z10.object({
1485
1593
  });
1486
1594
 
1487
1595
  // packages/shared/dist/version.js
1488
- var STELLARIS_VERSION = "0.3.11";
1596
+ var STELLARIS_VERSION = "0.3.13";
1489
1597
 
1490
1598
  // packages/board-core/dist/errors.js
1491
1599
  var BoardError = class extends Error {
@@ -2669,6 +2777,9 @@ var THREAD_CONVERSATIONS = "thread-conversations";
2669
2777
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2670
2778
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2671
2779
  var CHANNEL_CONVERSATIONS = "channel-conversations";
2780
+ var DASHBOARD_UPDATES = "dashboard-updates-granted";
2781
+ var OLD_DASHBOARD_LINE = "Agents may edit this file. It is rendered by the board UI.";
2782
+ var DASHBOARD_LINE = "The project's members keep this page current with update_dashboard.";
2672
2783
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2673
2784
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2674
2785
  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";
@@ -2705,6 +2816,11 @@ function memberToAgentInput(member) {
2705
2816
  ...member.seedInstructions === void 0 ? {} : { seedInstructions: member.seedInstructions }
2706
2817
  };
2707
2818
  }
2819
+ function assertLandsByPullRequest(effect, repo, where) {
2820
+ if (effect === "ghpr" && githubRepository(repo) === null) {
2821
+ 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)`);
2822
+ }
2823
+ }
2708
2824
  function stageFrom(planned, id) {
2709
2825
  return {
2710
2826
  id,
@@ -2862,7 +2978,8 @@ var Board = class _Board {
2862
2978
  THREAD_CONVERSATIONS,
2863
2979
  ASKS_CHANNEL_OPENED,
2864
2980
  KNOWLEDGE_REMOVAL,
2865
- CHANNEL_CONVERSATIONS
2981
+ CHANNEL_CONVERSATIONS,
2982
+ DASHBOARD_UPDATES
2866
2983
  ].filter((name) => !applied.includes(name));
2867
2984
  if (pending.length === 0) {
2868
2985
  return;
@@ -2892,21 +3009,38 @@ var Board = class _Board {
2892
3009
  await this.openAsksChannel();
2893
3010
  }
2894
3011
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2895
- await this.grantKnowledgeRemoval();
3012
+ await this.grantBesideKnowledge("remove_knowledge");
2896
3013
  }
2897
3014
  if (pending.includes(CHANNEL_CONVERSATIONS)) {
2898
3015
  await this.startChannelCursors();
2899
3016
  }
3017
+ if (pending.includes(DASHBOARD_UPDATES)) {
3018
+ await this.grantBesideKnowledge("update_dashboard");
3019
+ await this.rewordDashboards();
3020
+ }
2900
3021
  await writeJson(file, { applied: [...applied, ...pending] });
2901
3022
  }
2902
3023
  /**
2903
- * Whoever may write a scope's knowledge may remove it, so every charter granting
2904
- * `write_knowledge` is granted `remove_knowledge` once; seed roles have it from their seed.
3024
+ * Grants `verb` once to every charter granting `write_knowledge`, the work roles' charters in
3025
+ * practice; seed roles have it from their seed.
2905
3026
  */
2906
- async grantKnowledgeRemoval() {
3027
+ async grantBesideKnowledge(verb) {
2907
3028
  for (const charter of await this.listRoles()) {
2908
- if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes("remove_knowledge")) {
2909
- await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, "remove_knowledge"] }, { verbsAdded: ["remove_knowledge"] });
3029
+ if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes(verb)) {
3030
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
3031
+ }
3032
+ }
3033
+ }
3034
+ /** A dashboard still saying agents may edit its file says how it changes now. */
3035
+ async rewordDashboards() {
3036
+ for (const slug of await listDirs(this.paths.projects())) {
3037
+ const file = this.paths.dashboard(slug);
3038
+ if (!await exists(file)) {
3039
+ continue;
3040
+ }
3041
+ const { data, body } = await readLooseMarkdown(file);
3042
+ if (body.includes(OLD_DASHBOARD_LINE)) {
3043
+ await writeMarkdown(file, data, body.replace(OLD_DASHBOARD_LINE, DASHBOARD_LINE));
2910
3044
  }
2911
3045
  }
2912
3046
  }
@@ -3668,6 +3802,7 @@ ${charter.purpose}
3668
3802
  if (await exists(file)) {
3669
3803
  throw new BoardError("ALREADY_EXISTS", `project ${input.slug} already exists`);
3670
3804
  }
3805
+ assertLandsByPullRequest(input.onDone ?? "none", input.repo ?? null, `project ${input.slug}`);
3671
3806
  const project = ProjectSchema.parse({
3672
3807
  slug: input.slug,
3673
3808
  name: input.name ?? input.slug,
@@ -3688,9 +3823,9 @@ ${charter.purpose}
3688
3823
  await ensureDir(this.paths.tasks(project.slug));
3689
3824
  await ensureDir(this.paths.threads(project.slug));
3690
3825
  await ensureDir(this.paths.projectKnowledge(project.slug));
3691
- await writeMarkdown(this.paths.dashboard(project.slug), { project: project.slug, updatedAt: this.now().toISOString() }, `# ${project.name} dashboard
3826
+ await writeMarkdown(this.paths.dashboard(project.slug), { project: project.slug, revision: 0, updatedAt: this.now().toISOString() }, `# ${project.name} dashboard
3692
3827
 
3693
- Agents may edit this file. It is rendered by the board UI.
3828
+ ${DASHBOARD_LINE}
3694
3829
  `);
3695
3830
  await this.updateAgent(USER_NAME, (user) => ({
3696
3831
  ...user,
@@ -3795,6 +3930,7 @@ Agents may edit this file. It is rendered by the board UI.
3795
3930
  ...patch.os === void 0 ? {} : { os: patch.os },
3796
3931
  ...patch.clis === void 0 ? {} : { clis: [...patch.clis] },
3797
3932
  ...patch.capabilities === void 0 ? {} : { capabilities: [...patch.capabilities] },
3933
+ ...patch.version === void 0 ? {} : { version: patch.version },
3798
3934
  lastSeen: this.now().toISOString()
3799
3935
  });
3800
3936
  await writeMarkdown(file, next, doc.body);
@@ -4269,6 +4405,42 @@ ${task.body}`;
4269
4405
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project}`);
4270
4406
  }
4271
4407
  }
4408
+ /**
4409
+ * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4410
+ * copy of the projection pulled when it started, so a revision other than the current one means
4411
+ * someone updated it since; the refusal carries the current text, which that copy lacks.
4412
+ */
4413
+ async updateDashboard(actor, input) {
4414
+ const args = VerbInputs.update_dashboard.parse(input);
4415
+ await this.authorize(actor, "update_dashboard");
4416
+ return this.mutex.run(async () => {
4417
+ const project = await this.readActiveProject(args.project);
4418
+ if (actor.role !== USER_ROLE && !project.members.includes(actor.name)) {
4419
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${args.project}`);
4420
+ }
4421
+ const current = await this.dashboardOf(args.project);
4422
+ if (args.revision !== current.revision) {
4423
+ const by = current.updatedBy === void 0 ? "" : `, updated by ${current.updatedBy} at ${current.updatedAt ?? "an unknown time"}`;
4424
+ 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}.
4425
+
4426
+ ${current.body}`);
4427
+ }
4428
+ const updated = DashboardSchema.parse({
4429
+ project: args.project,
4430
+ revision: current.revision + 1,
4431
+ updatedBy: actor.name,
4432
+ updatedAt: this.now().toISOString(),
4433
+ body: args.body
4434
+ });
4435
+ const { body, ...frontmatter } = updated;
4436
+ await writeMarkdown(this.paths.dashboard(args.project), frontmatter, body);
4437
+ await this.events.append("dashboard.updated", actor.name, {
4438
+ project: args.project,
4439
+ revision: updated.revision
4440
+ });
4441
+ return updated;
4442
+ });
4443
+ }
4272
4444
  async openThread(actor, input) {
4273
4445
  const args = VerbInputs.open_thread.parse(input);
4274
4446
  await this.authorize(actor, "open_thread");
@@ -4650,6 +4822,9 @@ ${args.summary}`);
4650
4822
  completedAt: ts
4651
4823
  };
4652
4824
  const next = current.stages[index + 1];
4825
+ if (next === void 0 && current.onDone === "ghpr" && current.pullRequest === void 0) {
4826
+ 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`);
4827
+ }
4653
4828
  if (next === void 0 && current.onDone === "none") {
4654
4829
  this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn) => turn.thread === current.id);
4655
4830
  }
@@ -4766,6 +4941,7 @@ ${args.summary}`);
4766
4941
  const guarded = gateChanges(ahead, replanned);
4767
4942
  if (onDone !== current.onDone) {
4768
4943
  guarded.push("change the completion effect");
4944
+ assertLandsByPullRequest(onDone, (await this.readProject(current.project)).repo, `project ${current.project}`);
4769
4945
  }
4770
4946
  if (guarded.length > 0) {
4771
4947
  this.assertMayGate(actor, guarded.join(", "));
@@ -4854,6 +5030,12 @@ ${args.summary}`);
4854
5030
  }
4855
5031
  next = { ...next, blockedBy: [...args.blocked_by] };
4856
5032
  }
5033
+ if (args.pull_request !== void 0) {
5034
+ next = {
5035
+ ...next,
5036
+ pullRequest: await this.pullRequestLink(actor, current, args.pull_request)
5037
+ };
5038
+ }
4857
5039
  if (args.note !== void 0) {
4858
5040
  const step = moved !== null ? { action: "returned", stage: moved.from, to: moved.to } : next.status === "abandoned" ? { action: "abandoned", stage: current.stage, to: null } : void 0;
4859
5041
  await this.postTaskNote(actor.name, current, args.note, step);
@@ -4879,12 +5061,46 @@ ${args.summary}`);
4879
5061
  project: location.project,
4880
5062
  from: current.status,
4881
5063
  to: task.status,
4882
- note: args.note ?? null
5064
+ note: args.note ?? null,
5065
+ ...args.pull_request === void 0 ? {} : { pullRequest: task.pullRequest?.url ?? null }
4883
5066
  });
4884
5067
  }
4885
5068
  return task;
4886
5069
  });
4887
5070
  }
5071
+ /**
5072
+ * The pull request a task is linked to, or none for `null`: one on the repository of its project,
5073
+ * by a member of the project or a planner, while the task is in play and before it lands.
5074
+ */
5075
+ async pullRequestLink(actor, task, link) {
5076
+ this.assertInPlay(task);
5077
+ if (task.completing) {
5078
+ throw new BoardError("INVALID_STATE", `task ${task.id} is landing, so its pull request no longer changes`);
5079
+ }
5080
+ if (!PLANNING_ROLES.includes(actor.role)) {
5081
+ const agent = await this.readAgent(actor.name);
5082
+ if (!agent.memberships.includes(task.project)) {
5083
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${task.project}`);
5084
+ }
5085
+ }
5086
+ if (link === null) {
5087
+ return void 0;
5088
+ }
5089
+ const project = await this.readProject(task.project);
5090
+ const repository = githubRepository(project.repo);
5091
+ const named = parsePullRequestUrl(link.url);
5092
+ if (repository === null || named === null || !sameRepository(repository, named)) {
5093
+ throw new BoardError("VALIDATION", `${link.url} is not a pull request of ${project.slug}'s repository${project.repo === null ? ", since it has no remote" : ` at ${project.repo}`}`);
5094
+ }
5095
+ return PullRequestLinkSchema.parse({
5096
+ url: link.url,
5097
+ number: named.number,
5098
+ ...link.merge_subject === void 0 ? {} : { subject: link.merge_subject },
5099
+ ...link.merge_body === void 0 ? {} : { body: link.merge_body },
5100
+ linkedBy: actor.name,
5101
+ linkedAt: this.now().toISOString()
5102
+ });
5103
+ }
4888
5104
  async getTask(actor, input) {
4889
5105
  const args = VerbInputs.get_task.parse(input);
4890
5106
  await this.authorize(actor, "get_task");
@@ -4942,6 +5158,13 @@ ${args.summary}`);
4942
5158
  proposedBy: actor.name,
4943
5159
  status: "proposed",
4944
5160
  createdAt: this.now().toISOString(),
5161
+ ...actor.scope === void 0 ? {} : {
5162
+ filedFrom: {
5163
+ scope: actor.scope,
5164
+ ...actor.thread === void 0 ? {} : { thread: actor.thread },
5165
+ ...actor.channel === void 0 ? {} : { channel: actor.channel }
5166
+ }
5167
+ },
4945
5168
  charter: parsed.data
4946
5169
  }),
4947
5170
  body: args.rationale
@@ -4995,6 +5218,9 @@ ${rationale}`}`);
4995
5218
  }
4996
5219
  return this.mutex.run(async () => {
4997
5220
  const previous = await this.readActiveProject(args.project);
5221
+ if (args.on_done !== void 0) {
5222
+ assertLandsByPullRequest(args.on_done, previous.repo, `project ${previous.slug}`);
5223
+ }
4998
5224
  const project = await this.updateProject(args.project, (current) => ({
4999
5225
  ...current,
5000
5226
  ...args.on_done === void 0 ? {} : { onDone: args.on_done },
@@ -5176,6 +5402,8 @@ ${rationale}`}`);
5176
5402
  return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5177
5403
  case "archive_channel":
5178
5404
  return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
5405
+ case "update_dashboard":
5406
+ return this.updateDashboard(actor, VerbInputs.update_dashboard.parse(input));
5179
5407
  default:
5180
5408
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
5181
5409
  }
@@ -5437,6 +5665,29 @@ ${rationale}`}`);
5437
5665
  });
5438
5666
  });
5439
5667
  }
5668
+ /**
5669
+ * Records how closing an abandoned `ghpr` task's pull request on GitHub went, once: the task's
5670
+ * link keeps it, so a restart does not try again.
5671
+ */
5672
+ async recordPullRequestClosed(actor, input) {
5673
+ return this.mutex.run(async () => {
5674
+ const location = await this.findTask(input.taskId);
5675
+ const current = location.task;
5676
+ const pull = current.pullRequest;
5677
+ if (pull === void 0 || pull.closed !== void 0) {
5678
+ return current;
5679
+ }
5680
+ const task = await this.writeTask(location.project, {
5681
+ ...current,
5682
+ pullRequest: {
5683
+ ...pull,
5684
+ closed: { at: this.now().toISOString(), ok: input.ok, detail: input.detail }
5685
+ }
5686
+ });
5687
+ await this.events.append(input.ok ? "pull_request.closed" : "pull_request.close_failed", actor.name, { project: location.project, taskId: task.id, url: pull.url, detail: input.detail });
5688
+ return task;
5689
+ });
5690
+ }
5440
5691
  /**
5441
5692
  * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
5442
5693
  * the scheduler gives the citizen a closing turn there to decide about it. False, recording
@@ -5480,8 +5731,8 @@ ${rationale}`}`);
5480
5731
  }
5481
5732
  /**
5482
5733
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5483
- * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
5484
- * landed one announced in the project's general channel.
5734
+ * its participants can reshape the plan. A landing's outcome, a merge's or a pull request's, is
5735
+ * posted to the task's thread, and a landed one announced in the task's channel.
5485
5736
  */
5486
5737
  async finishCompletion(actor, input) {
5487
5738
  return this.mutex.run(async () => {
@@ -5491,8 +5742,8 @@ ${rationale}`}`);
5491
5742
  throw new BoardError("INVALID_STATE", `task ${current.id} is not completing`);
5492
5743
  }
5493
5744
  const ts = this.now().toISOString();
5494
- const merged = current.onDone === "merge";
5495
- if (merged) {
5745
+ const landed = current.onDone !== "none";
5746
+ if (landed) {
5496
5747
  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 });
5497
5748
  }
5498
5749
  if (input.ok) {
@@ -5512,7 +5763,7 @@ ${rationale}`}`);
5512
5763
  for (const name of new Set(task2.stages.flatMap((stage) => stage.completedBy ?? []))) {
5513
5764
  await this.refreshMember(name);
5514
5765
  }
5515
- if (merged) {
5766
+ if (landed) {
5516
5767
  await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5517
5768
  }
5518
5769
  return task2;
@@ -5542,15 +5793,31 @@ ${rationale}`}`);
5542
5793
  async writeState(name, value) {
5543
5794
  await this.mutex.run(() => writeJson(path6.join(this.paths.state(), `${name}.json`), value));
5544
5795
  }
5545
- /** The project's editable dashboard: frontmatter plus markdown body. Empty when none exists. */
5796
+ /** The project's dashboard, empty at revision 0 when it has none. */
5546
5797
  async readDashboard(slug) {
5547
5798
  await this.readProject(slug);
5799
+ return this.dashboardOf(slug);
5800
+ }
5801
+ /**
5802
+ * Read loosely, since agents wrote dashboards by hand before they had a verb: a revision or an
5803
+ * author that does not parse counts as none.
5804
+ */
5805
+ async dashboardOf(slug) {
5548
5806
  const file = this.paths.dashboard(slug);
5549
5807
  if (!await exists(file)) {
5550
- return { data: { project: slug }, body: "" };
5808
+ return { project: slug, revision: 0, body: "" };
5551
5809
  }
5552
5810
  const { data, body } = await readLooseMarkdown(file);
5553
- return { data, body };
5811
+ const revision = z12.number().int().min(0).safeParse(data["revision"]);
5812
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
5813
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
5814
+ return {
5815
+ project: slug,
5816
+ revision: revision.success ? revision.data : 0,
5817
+ ...updatedBy.success ? { updatedBy: updatedBy.data } : {},
5818
+ ...updatedAt.success ? { updatedAt: updatedAt.data } : {},
5819
+ body
5820
+ };
5554
5821
  }
5555
5822
  async listRunners() {
5556
5823
  const runners2 = [];
@@ -6653,7 +6920,8 @@ ${charter.purpose}
6653
6920
  proposalId,
6654
6921
  outcome,
6655
6922
  kind: proposal.kind,
6656
- proposedBy: proposal.proposedBy
6923
+ proposedBy: proposal.proposedBy,
6924
+ ...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
6657
6925
  });
6658
6926
  if (provision !== void 0) {
6659
6927
  await this.events.append("proposal.provisioned", actor.name, {
@@ -6934,6 +7202,9 @@ function stringArray(value) {
6934
7202
  function stringOf(value) {
6935
7203
  return typeof value === "string" ? value : null;
6936
7204
  }
7205
+ function awaitsClosing(task) {
7206
+ return task.status === "abandoned" && task.onDone === "ghpr" && task.pullRequest !== void 0 && task.pullRequest.closed === void 0;
7207
+ }
6937
7208
  function inPlay(task) {
6938
7209
  return (task.status === "open" || task.status === "claimed") && !task.completing;
6939
7210
  }
@@ -6966,6 +7237,9 @@ var Scheduler = class {
6966
7237
  steering = /* @__PURE__ */ new Map();
6967
7238
  /** Tasks whose completion effect waits for the turn that finished their last stage to end. */
6968
7239
  completions = /* @__PURE__ */ new Map();
7240
+ /** Abandoned `ghpr` tasks whose pull request waits to be closed, by task id, with its project. */
7241
+ closings = /* @__PURE__ */ new Map();
7242
+ /** Completions and closings under way. */
6969
7243
  completing = /* @__PURE__ */ new Set();
6970
7244
  /** Running turns that must run alone among their citizen's, by session key: see `heldBack`. */
6971
7245
  exclusive = /* @__PURE__ */ new Map();
@@ -7065,6 +7339,7 @@ var Scheduler = class {
7065
7339
  this.lastSweep = now;
7066
7340
  }
7067
7341
  this.runCompletions();
7342
+ this.runClosings();
7068
7343
  if (!paused) {
7069
7344
  await this.dispatchReady(now);
7070
7345
  }
@@ -7092,6 +7367,9 @@ var Scheduler = class {
7092
7367
  if (task.completing) {
7093
7368
  this.completions.set(task.id, { project: project.slug, actor: USER_NAME });
7094
7369
  }
7370
+ if (awaitsClosing(task)) {
7371
+ this.closings.set(task.id, project.slug);
7372
+ }
7095
7373
  }
7096
7374
  }
7097
7375
  this.loaded = true;
@@ -7200,6 +7478,7 @@ var Scheduler = class {
7200
7478
  const taskId = stringOf(payload["taskId"]);
7201
7479
  if (taskId !== null && payload["to"] === "abandoned") {
7202
7480
  delete this.state.releaseCounts[taskId];
7481
+ await this.queueClosing(taskId);
7203
7482
  }
7204
7483
  if (taskId !== null) {
7205
7484
  await this.pruneStageWakes(taskId);
@@ -7539,13 +7818,16 @@ var Scheduler = class {
7539
7818
  reason: "a conversation you took part in has ended, and its workspace holds work on no branch"
7540
7819
  }, now, beside);
7541
7820
  }
7542
- /** A decided proposal wakes the citizen that made it, to carry on with what it enables. */
7821
+ /**
7822
+ * A decided proposal wakes the citizen that made it, to carry on with what it enables, in the
7823
+ * conversation it was filed from, where that work waits on the decision.
7824
+ */
7543
7825
  async wakeProposer(proposalId, proposer, event, now) {
7544
7826
  const agent = await this.tryReadAgent(proposer);
7545
7827
  if (agent === null) {
7546
7828
  return;
7547
7829
  }
7548
- const scope = this.scopeForProject(agent, null);
7830
+ const { scope, beside } = await this.filedFrom(agent, event.payload["filedFrom"]);
7549
7831
  const kind = stringOf(event.payload["kind"]) ?? "";
7550
7832
  const outcome = stringOf(event.payload["outcome"]) ?? "decided";
7551
7833
  this.enqueue(agent.name, scope, {
@@ -7553,7 +7835,27 @@ var Scheduler = class {
7553
7835
  from: event.actor,
7554
7836
  fromUser: event.actor === USER_NAME,
7555
7837
  reason: `your ${kind} proposal ${proposalId} was ${outcome} by ${event.actor}`
7556
- }, now);
7838
+ }, now, beside);
7839
+ }
7840
+ /**
7841
+ * The conversation a proposal was filed from while it is still open; its scope's home once it
7842
+ * has ended; and the home outside projects when the citizen has left that scope, or the proposal
7843
+ * names none, filed outside a turn or before proposals recorded it.
7844
+ */
7845
+ async filedFrom(agent, recorded) {
7846
+ const origin = ProposalOriginSchema.safeParse(recorded);
7847
+ if (!origin.success || origin.data.scope !== SOCIETY_SCOPE && !agent.memberships.includes(origin.data.scope)) {
7848
+ return { scope: this.scopeForProject(agent, null) };
7849
+ }
7850
+ const scope = origin.data.scope;
7851
+ const conversation = workspaceConversationOf(origin.data);
7852
+ if (conversation === null || await this.board.conversationEnded(conversation)) {
7853
+ return { scope };
7854
+ }
7855
+ return {
7856
+ scope,
7857
+ beside: conversation.kind === "thread" ? { thread: await this.conversationOf(conversation.id, scope) } : { channel: conversation.name }
7858
+ };
7557
7859
  }
7558
7860
  /**
7559
7861
  * A thread's conversation for a turn in `scope`, and whether it is a task's in that scope, whose
@@ -8203,6 +8505,40 @@ var Scheduler = class {
8203
8505
  }
8204
8506
  return others.some((other) => this.exclusive.get(other) === "reflection");
8205
8507
  }
8508
+ /** Queues closing an abandoned task's pull request, when it is a `ghpr` task with one open. */
8509
+ async queueClosing(taskId) {
8510
+ if (this.runner.closePullRequest === void 0) {
8511
+ return;
8512
+ }
8513
+ try {
8514
+ const { task } = await this.board.findTask(taskId);
8515
+ if (awaitsClosing(task)) {
8516
+ this.closings.set(taskId, task.project);
8517
+ }
8518
+ } catch {
8519
+ }
8520
+ }
8521
+ /** Starts closing each queued pull request; one whose runner is away waits in the queue. */
8522
+ runClosings() {
8523
+ const close = this.runner.closePullRequest?.bind(this.runner);
8524
+ if (close === void 0) {
8525
+ return;
8526
+ }
8527
+ for (const [taskId, project] of this.closings) {
8528
+ this.closings.delete(taskId);
8529
+ const promise = close(project, taskId).then((outcome) => {
8530
+ if (outcome === "deferred" && !this.closings.has(taskId)) {
8531
+ this.closings.set(taskId, project);
8532
+ }
8533
+ return void 0;
8534
+ }).catch((error) => {
8535
+ this.log.error({ project, taskId, error: String(error) }, "closing a pull request failed");
8536
+ }).finally(() => {
8537
+ this.completing.delete(promise);
8538
+ });
8539
+ this.completing.add(promise);
8540
+ }
8541
+ }
8206
8542
  /** Starts each queued completion once the turn that finished the task's last stage has ended. */
8207
8543
  runCompletions() {
8208
8544
  for (const [taskId, item] of this.completions) {
@@ -8305,7 +8641,7 @@ function renderInstructions(input) {
8305
8641
  if (norms.length > 0) {
8306
8642
  sections.push("", "## Society norms", "", norms);
8307
8643
  }
8308
- sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's or a channel's conversation there works in a folder of its own inside it, which goes when the thread closes or the channel is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
8644
+ sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's or a channel's conversation there works in a folder of its own inside it, which goes when the thread closes or the channel is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/; each project's dashboard, the status page the user reads on its view, at projects/<slug>/dashboard.md, which its members change with update_dashboard). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
8309
8645
  if (input.onboarding !== void 0) {
8310
8646
  sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
8311
8647
  }
@@ -8352,7 +8688,7 @@ function planLine(task, whose) {
8352
8688
  const index = stageIndex(task, task.stage);
8353
8689
  const stage = task.stages[index];
8354
8690
  const rest = task.stages.slice(index + 1).map((next) => `${next.name} (${next.gate ? "gate, " : ""}${assignee(next)})`);
8355
- const then = rest.length === 0 ? `then ${task.onDone === "merge" ? "the board merges it" : "done"}` : `next: ${rest.join(", ")}`;
8691
+ const then = rest.length === 0 ? `then ${task.onDone === "merge" ? "the board merges it" : task.onDone === "ghpr" ? "the board merges its pull request" : "done"}` : `next: ${rest.join(", ")}`;
8356
8692
  const gated = stage?.gate === true ? ", gated" : "";
8357
8693
  return `- ${task.id} "${task.title}": ${stage?.name ?? task.stage} (${whose}, ${index + 1} of ${task.stages.length}${gated}); ${then}`;
8358
8694
  }
@@ -8435,7 +8771,7 @@ function buildTurnPrompt(input) {
8435
8771
  lines.push("A task you created is done. Tell whoever asked for it, if anyone did.");
8436
8772
  }
8437
8773
  if (dispatch.trigger.kind === "proposal_decided") {
8438
- lines.push("A proposal you made was decided; the decision is the last post in its thread. Carry on with what an approval enables or a rejection asks, and stay silent if nothing follows.");
8774
+ lines.push("A proposal you made was decided; the decision is the last post in the proposal's thread. A decision wakes you in the conversation you filed the proposal from, or at home once that has ended. Carry on with what an approval enables or a rejection asks, and stay silent if nothing follows.");
8439
8775
  }
8440
8776
  if (dispatch.trigger.kind === "user_post") {
8441
8777
  lines.push("The user posted. Route it: answer where it was posted, in its thread when it came in one, or create what it needs: a task with a plan whose stages name who does them, a thread, or a project, adding citizens to a project first when they are not members. Stay silent when the user already addressed a citizen and nothing else is needed.");
@@ -8450,7 +8786,8 @@ function buildTurnPrompt(input) {
8450
8786
  } else {
8451
8787
  const { thread, task } = conversation;
8452
8788
  const about = thread.subject === void 0 ? "a topic" : `${thread.subject.kind} ${thread.subject.id}`;
8453
- lines.push(`This turn is in the thread "${thread.title}" on ${thread.channel}, about ${about}. Only this conversation is below: read anything else you need with get_task, search, or the board's files, and leave other tasks and threads to their own turns. Talk here with post_message and thread_id ${thread.id}.`);
8789
+ const talk = thread.state === "closed" ? `This thread is closed, so posts to it are refused: if something still needs saying, post it in ${conversation.replyIn ?? thread.channel}.` : `Talk here with post_message and thread_id ${thread.id}.`;
8790
+ lines.push(`This turn is in the thread "${thread.title}" on ${thread.channel}, about ${about}. Only this conversation is below: read anything else you need with get_task, search, or the board's files, and leave other tasks and threads to their own turns. ${talk}`);
8454
8791
  if (task !== null) {
8455
8792
  lines.push("", `Task ${task.id} "${task.title}", ${task.status}:`, planLine(task, "now"));
8456
8793
  if (task.body.trim() !== "") {
@@ -8514,8 +8851,13 @@ function buildTurnPrompt(input) {
8514
8851
  }
8515
8852
  }
8516
8853
  const project = input.project;
8517
- if (project !== void 0 && project !== null && project.onDone === "merge") {
8518
- lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its branch task/<id> into ${project.defaultBranch}. Never merge or fast-forward ${project.defaultBranch} yourself.`);
8854
+ const landing = conversation?.task?.onDone ?? project?.onDone;
8855
+ if (project !== void 0 && project !== null && landing === "merge") {
8856
+ lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its branch task/<id> into ${project.defaultBranch}. Never merge or fast-forward ${project.defaultBranch} yourself, or push other work to it on the remote; the runner refuses both.`);
8857
+ }
8858
+ if (project !== void 0 && project !== null && landing === "ghpr") {
8859
+ const base = project.defaultBranch;
8860
+ lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its pull request on GitHub into ${base}. Open the pull request yourself, from a branch named for what it does that holds the same files as task/<id>, as \`git push origin task/<id>:<branch>\` gives, and link it to the task with update_task pull_request before its last stage completes, with the merge commit's merge_subject and merge_body, which you may change by linking again until it lands. Never merge the pull request or ${base} yourself, nor push other work to ${base}: the board merges once the task's stages are done, and the runner refuses the pushes. Abandoning the task closes its linked pull request and deletes its branch.`);
8519
8861
  }
8520
8862
  if (conflicts.length > 0) {
8521
8863
  lines.push("", "## Edits to reconcile in your home", "", "Two of your turns changed these files at once on different runners. The file in place kept the other turn's version; yours is the copy named here, beside it. Merge what still holds into the file, then delete the copy.", "", ...conflicts.map((conflict) => `- ${conflict.path}, beside ${conflict.file}, since ${conflict.since}`));
@@ -8719,7 +9061,12 @@ var TurnHost = class {
8719
9061
  }
8720
9062
  }
8721
9063
  const threadRecord = thread === void 0 ? void 0 : threads.get(thread.id) ?? await this.board.readThread(thread.id).catch(() => void 0);
8722
- const conversation = threadRecord === void 0 ? null : { thread: threadRecord, task, fresh: threadSoFar !== null };
9064
+ const conversation = threadRecord === void 0 ? null : {
9065
+ thread: threadRecord,
9066
+ task,
9067
+ fresh: threadSoFar !== null,
9068
+ ...threadRecord.state === "closed" ? { replyIn: await this.replyIn(threadRecord) } : {}
9069
+ };
8723
9070
  const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
8724
9071
  const prompt = buildTurnPrompt({
8725
9072
  dispatch,
@@ -9122,11 +9469,7 @@ var TurnHost = class {
9122
9469
  if (project === null) {
9123
9470
  return { kind: "home", ...own };
9124
9471
  }
9125
- const repo = {
9126
- slug: project.slug,
9127
- origin: project.repo,
9128
- defaultBranch: project.defaultBranch
9129
- };
9472
+ const repo = projectRepo(project);
9130
9473
  if (thread?.task === true) {
9131
9474
  return { kind: "task", repo, taskId: thread.id };
9132
9475
  }
@@ -9141,6 +9484,10 @@ var TurnHost = class {
9141
9484
  const conversation = workspaceConversationOf(job);
9142
9485
  return conversation !== null && await this.board.conversationEnded(conversation);
9143
9486
  }
9487
+ /** Where a closed thread's citizens say what still needs saying: its channel, else its place's general. */
9488
+ async replyIn(thread) {
9489
+ return await this.board.channelOpen(thread.channel) ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general");
9490
+ }
9144
9491
  /**
9145
9492
  * How a closing turn's conversation ended, and where its citizen asks someone else to take over
9146
9493
  * work that is not its own: the thread's channel while it is open, else the place's general.
@@ -9149,11 +9496,7 @@ var TurnHost = class {
9149
9496
  const place = scope === SOCIETY_SCOPE ? null : scope;
9150
9497
  if (thread !== void 0) {
9151
9498
  const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
9152
- const open = await this.board.channelOpen(thread.channel);
9153
- return {
9154
- how: closed,
9155
- askIn: open ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general")
9156
- };
9499
+ return { how: closed, askIn: await this.replyIn(thread) };
9157
9500
  }
9158
9501
  if (channel === void 0) {
9159
9502
  return null;
@@ -9246,12 +9589,14 @@ var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
9246
9589
  var CONTROL_TIMEOUT_MS = 15e3;
9247
9590
  var FILE_TIMEOUT_MS = 6e4;
9248
9591
  var SWEEP_TIMEOUT_MS = 2 * 6e4;
9592
+ var DEFAULT_ACK_TIMEOUT_MS = 3e4;
9249
9593
  var RunnerHub = class {
9250
9594
  board;
9251
9595
  host;
9252
9596
  version;
9253
9597
  graceMs;
9254
9598
  requestTimeoutMs;
9599
+ ackTimeoutMs;
9255
9600
  now;
9256
9601
  log;
9257
9602
  seats = /* @__PURE__ */ new Map();
@@ -9267,6 +9612,7 @@ var RunnerHub = class {
9267
9612
  this.version = options.version;
9268
9613
  this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
9269
9614
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
9615
+ this.ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
9270
9616
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
9271
9617
  this.log = options.log ?? SILENT2;
9272
9618
  this.startedAt = this.now().getTime();
@@ -9282,9 +9628,15 @@ var RunnerHub = class {
9282
9628
  // -------------------------------------------------------------------------------------------
9283
9629
  // The protocol, as the server's routes call it
9284
9630
  // -------------------------------------------------------------------------------------------
9631
+ /** The jobs sent to a runner that it has not acknowledged yet. */
9632
+ unacknowledged(runner) {
9633
+ return [...this.seats.get(runner)?.unacked.keys() ?? []];
9634
+ }
9285
9635
  /**
9286
- * A runner registers, first or again. Its turns in flight that it no longer runs, because it
9287
- * restarted, end as failed.
9636
+ * A runner registers, first or again. A turn in flight it still runs, or whose job it never
9637
+ * acknowledged, carries on, the latter sent again once its stream opens, since a job written
9638
+ * into a stream that had stopped delivering never arrived; one it took and no longer runs,
9639
+ * because it restarted, ends as failed.
9288
9640
  */
9289
9641
  async register(name, hello) {
9290
9642
  if (hello.protocol !== RUNNER_PROTOCOL) {
@@ -9299,25 +9651,31 @@ var RunnerHub = class {
9299
9651
  inUse: 0,
9300
9652
  warm: /* @__PURE__ */ new Set(),
9301
9653
  disconnectedAt: null,
9302
- grace: null
9654
+ grace: null,
9655
+ unacked: /* @__PURE__ */ new Map(),
9656
+ unconfirmed: false
9303
9657
  });
9304
9658
  } else {
9305
9659
  seat.hello = hello;
9306
9660
  seat.warm = /* @__PURE__ */ new Set();
9307
9661
  }
9308
9662
  const still = new Set(hello.turns);
9663
+ const unacked = this.seats.get(name)?.unacked ?? /* @__PURE__ */ new Map();
9309
9664
  for (const turnId of this.host.turnsOn(name)) {
9310
- if (!still.has(turnId)) {
9665
+ if (still.has(turnId)) {
9666
+ this.settle(unacked, turnId);
9667
+ } else if (!unacked.has(turnId)) {
9311
9668
  await this.host.abortTurn(turnId, `runner ${name} restarted during the turn`);
9312
9669
  }
9313
9670
  }
9314
9671
  await this.board.markRunner(name, {
9315
9672
  os: hello.os,
9316
9673
  clis: hello.clis,
9317
- capabilities: hello.capabilities
9674
+ capabilities: hello.capabilities,
9675
+ version: hello.version
9318
9676
  });
9319
9677
  this.runnerChanged(name);
9320
- this.log.info({ runner: name, clis: hello.clis, slots: hello.slots }, "runner registered");
9678
+ this.log.info({ runner: name, version: hello.version, clis: hello.clis, slots: hello.slots }, "runner registered");
9321
9679
  return { name, version: this.version };
9322
9680
  }
9323
9681
  /** A registered runner's stream opened. Returns what to call when it closes. */
@@ -9328,10 +9686,17 @@ var RunnerHub = class {
9328
9686
  }
9329
9687
  seat.send = send;
9330
9688
  seat.disconnectedAt = null;
9689
+ seat.unconfirmed = false;
9331
9690
  if (seat.grace !== null) {
9332
9691
  clearTimeout(seat.grace);
9333
9692
  seat.grace = null;
9334
9693
  }
9694
+ for (const { job } of seat.unacked.values()) {
9695
+ if (send({ type: "turn", job })) {
9696
+ this.expectAck(seat, job);
9697
+ this.log.info({ runner: name, turnId: job.turnId }, "sent again a job the runner never took");
9698
+ }
9699
+ }
9335
9700
  await this.board.markRunner(name, { status: "connected" });
9336
9701
  this.runnerChanged(name);
9337
9702
  return async () => {
@@ -9356,6 +9721,9 @@ var RunnerHub = class {
9356
9721
  clearTimeout(seat.grace);
9357
9722
  seat.grace = null;
9358
9723
  }
9724
+ for (const { timer } of seat.unacked.values()) {
9725
+ clearTimeout(timer);
9726
+ }
9359
9727
  }
9360
9728
  await Promise.allSettled(this.detaching);
9361
9729
  }
@@ -9363,6 +9731,18 @@ var RunnerHub = class {
9363
9731
  this.assertOwns(runner, turnId);
9364
9732
  await this.host.addEvents(turnId, entries);
9365
9733
  }
9734
+ /** A runner's word that a turn's job arrived, which also shows its stream delivers again. */
9735
+ received(runner, turnId) {
9736
+ const seat = this.seats.get(runner);
9737
+ if (seat === void 0 || !seat.unacked.has(turnId)) {
9738
+ return;
9739
+ }
9740
+ this.settle(seat.unacked, turnId);
9741
+ if (seat.unconfirmed) {
9742
+ seat.unconfirmed = false;
9743
+ this.log.info({ runner }, "runner is taking jobs again");
9744
+ }
9745
+ }
9366
9746
  async turnOutcome(runner, turnId, outcome) {
9367
9747
  this.assertOwns(runner, turnId);
9368
9748
  const ack = await this.host.closeTurn(turnId, outcome);
@@ -9506,7 +9886,7 @@ var RunnerHub = class {
9506
9886
  return { refused: `${agent.name} has no CLI binding` };
9507
9887
  }
9508
9888
  const cli = agent.cli;
9509
- const free = (seat) => seat.send !== null && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
9889
+ const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
9510
9890
  let chosen;
9511
9891
  if (dispatch.project === SOCIETY_SCOPE) {
9512
9892
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
@@ -9545,6 +9925,7 @@ var RunnerHub = class {
9545
9925
  return this.host.refuseTurn(dispatch, "none", assignment.refused);
9546
9926
  }
9547
9927
  const seat = this.seats.get(assignment.runner);
9928
+ let turnId = null;
9548
9929
  try {
9549
9930
  if (seat === void 0) {
9550
9931
  return await this.host.refuseTurn(dispatch, assignment.runner, "the runner is gone");
@@ -9555,11 +9936,17 @@ var RunnerHub = class {
9555
9936
  }
9556
9937
  if (seat.send === null || !seat.send({ type: "turn", job: opened.job })) {
9557
9938
  await this.host.abortTurn(opened.job.turnId, `runner ${seat.name} went away before the turn`);
9939
+ } else {
9940
+ this.expectAck(seat, opened.job);
9558
9941
  }
9942
+ turnId = opened.job.turnId;
9559
9943
  return await opened.ended;
9560
9944
  } finally {
9561
9945
  if (seat !== void 0) {
9562
9946
  seat.inUse = Math.max(0, seat.inUse - 1);
9947
+ if (turnId !== null) {
9948
+ this.settle(seat.unacked, turnId);
9949
+ }
9563
9950
  }
9564
9951
  }
9565
9952
  }
@@ -9568,7 +9955,7 @@ var RunnerHub = class {
9568
9955
  if (!task.completing) {
9569
9956
  return "done";
9570
9957
  }
9571
- if (task.onDone !== "merge") {
9958
+ if (task.onDone === "none") {
9572
9959
  await this.board.finishCompletion(SYSTEM_ACTOR, {
9573
9960
  taskId,
9574
9961
  ok: true,
@@ -9578,6 +9965,15 @@ var RunnerHub = class {
9578
9965
  }
9579
9966
  const record = await this.board.readProject(project);
9580
9967
  const branch = `task/${taskId}`;
9968
+ const pull = task.onDone === "ghpr" ? task.pullRequest : void 0;
9969
+ if (task.onDone === "ghpr" && pull === void 0) {
9970
+ await this.board.finishCompletion(SYSTEM_ACTOR, {
9971
+ taskId,
9972
+ ok: false,
9973
+ detail: "no pull request is linked to the task"
9974
+ });
9975
+ return "done";
9976
+ }
9581
9977
  let outcome = { ok: true, detail: "nothing to land, since the task left no branch" };
9582
9978
  if (record.runner !== void 0) {
9583
9979
  const seat = this.seats.get(record.runner);
@@ -9589,8 +9985,15 @@ var RunnerHub = class {
9589
9985
  type: "land",
9590
9986
  request,
9591
9987
  land: {
9592
- repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch },
9593
- branch
9988
+ repo: projectRepo(record),
9989
+ branch,
9990
+ ...pull === void 0 ? {} : {
9991
+ pullRequest: {
9992
+ url: pull.url,
9993
+ ...pull.subject === void 0 ? {} : { subject: pull.subject },
9994
+ ...pull.body === void 0 ? {} : { body: pull.body }
9995
+ }
9996
+ }
9594
9997
  }
9595
9998
  })));
9596
9999
  } catch (error) {
@@ -9612,6 +10015,49 @@ var RunnerHub = class {
9612
10015
  });
9613
10016
  return "done";
9614
10017
  }
10018
+ /**
10019
+ * Closes the pull request of a `ghpr` task abandoned with one linked, on the runner its project
10020
+ * lives on, whose `gh` login reaches GitHub, and records how it went on the task; `deferred`
10021
+ * while that runner is away.
10022
+ */
10023
+ async closePullRequest(project, taskId) {
10024
+ const { task } = await this.board.findTask(taskId);
10025
+ const pull = task.pullRequest;
10026
+ if (task.status !== "abandoned" || task.onDone !== "ghpr" || pull === void 0 || pull.closed !== void 0) {
10027
+ return "done";
10028
+ }
10029
+ const record = await this.board.readProject(project);
10030
+ let outcome = {
10031
+ ok: false,
10032
+ detail: `${project} lives on no runner yet, so none could close ${pull.url}`
10033
+ };
10034
+ if (record.runner !== void 0) {
10035
+ const seat = this.seats.get(record.runner);
10036
+ if (seat === void 0 || seat.send === null) {
10037
+ return "deferred";
10038
+ }
10039
+ try {
10040
+ outcome = MergeOutcomeSchema.parse(await this.request(seat, (request) => ({
10041
+ type: "close",
10042
+ request,
10043
+ close: {
10044
+ repo: projectRepo(record),
10045
+ url: pull.url,
10046
+ comment: `Closed by Stellaris: the task this pull request was opened for, "${task.title}", was abandoned.`
10047
+ }
10048
+ })));
10049
+ } catch (error) {
10050
+ this.log.warn({ project, taskId, error: String(error) }, "closing a pull request deferred");
10051
+ return "deferred";
10052
+ }
10053
+ }
10054
+ await this.board.recordPullRequestClosed(SYSTEM_ACTOR, {
10055
+ taskId,
10056
+ ok: outcome.ok,
10057
+ detail: outcome.detail
10058
+ });
10059
+ return "done";
10060
+ }
9615
10061
  /**
9616
10062
  * One path on a branch of a project's repository, read on the runner the project lives on.
9617
10063
  * NOT_FOUND when the project has no runner yet or the branch or the path does not exist, and
@@ -9666,6 +10112,34 @@ var RunnerHub = class {
9666
10112
  }
9667
10113
  }
9668
10114
  // -------------------------------------------------------------------------------------------
10115
+ /**
10116
+ * Waits for a runner to acknowledge a job. One that does not in time may be behind a stream that
10117
+ * looks open but delivers nothing, as when its machine's network stops passing traffic, so no
10118
+ * more turns go to it until it acknowledges or connects again; the turns it has carry on, since
10119
+ * they report by posts.
10120
+ */
10121
+ expectAck(seat, job) {
10122
+ const previous = seat.unacked.get(job.turnId);
10123
+ if (previous !== void 0) {
10124
+ clearTimeout(previous.timer);
10125
+ }
10126
+ const timer = setTimeout(() => {
10127
+ if (!seat.unacked.has(job.turnId) || seat.unconfirmed || seat.send === null) {
10128
+ return;
10129
+ }
10130
+ seat.unconfirmed = true;
10131
+ this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does or reconnects");
10132
+ }, this.ackTimeoutMs);
10133
+ timer.unref?.();
10134
+ seat.unacked.set(job.turnId, { job, timer });
10135
+ }
10136
+ settle(unacked, turnId) {
10137
+ const entry = unacked.get(turnId);
10138
+ if (entry !== void 0) {
10139
+ clearTimeout(entry.timer);
10140
+ unacked.delete(turnId);
10141
+ }
10142
+ }
9669
10143
  async detach(seat) {
9670
10144
  seat.send = null;
9671
10145
  this.runnerChanged(seat.name);
@@ -9686,7 +10160,7 @@ var RunnerHub = class {
9686
10160
  return;
9687
10161
  }
9688
10162
  for (const turnId of this.host.turnsOn(seat.name)) {
9689
- void this.host.abortTurn(turnId, `runner ${seat.name} went away during the turn`);
10163
+ void this.host.abortTurn(turnId, seat.unacked.has(turnId) ? `the turn never reached runner ${seat.name}, which went away` : `runner ${seat.name} went away during the turn`);
9690
10164
  }
9691
10165
  }, this.graceMs);
9692
10166
  seat.grace.unref?.();
@@ -9763,7 +10237,7 @@ var RunnerHub = class {
9763
10237
  }
9764
10238
  return {
9765
10239
  seat,
9766
- repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch }
10240
+ repo: projectRepo(record)
9767
10241
  };
9768
10242
  }
9769
10243
  assertOwns(runner, turnId) {
@@ -10228,6 +10702,10 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
10228
10702
  await hub.turnEvents(c.get("runner"), UlidSchema.parse(c.req.param("id")), entries);
10229
10703
  return c.json({ ok: true });
10230
10704
  });
10705
+ routes.post("/turns/:id/received", (c) => {
10706
+ hub.received(c.get("runner"), UlidSchema.parse(c.req.param("id")));
10707
+ return c.json({ ok: true });
10708
+ });
10231
10709
  routes.post("/turns/:id/outcome", async (c) => c.json(await hub.turnOutcome(c.get("runner"), UlidSchema.parse(c.req.param("id")), TurnOutcomeSchema.parse(await c.req.json()))));
10232
10710
  routes.post("/requests/:id", async (c) => {
10233
10711
  hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
@@ -10402,6 +10880,7 @@ function createApp(deps) {
10402
10880
  });
10403
10881
  api.delete("/sign-in", async (c) => c.json({ signedOut: await board2.signOut(bearer2(c) ?? "") }));
10404
10882
  api.get("/society", async (c) => c.json(await board2.society()));
10883
+ api.get("/version", (c) => c.json({ version }));
10405
10884
  api.get("/agents", async (c) => c.json((await board2.listAgents()).map(({ tokenHash: _hash, ...agent }) => agent)));
10406
10885
  api.get("/members", async (c) => c.json(await board2.listMembers()));
10407
10886
  api.get("/agents/:name/turns", async (c) => c.json(await board2.listTurns(c.req.param("name"), Number(c.req.query("limit") ?? "50"))));