@kubitnodes/stellaris 0.3.11 → 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 (62) hide show
  1. package/dist/cli/index.js +234 -27
  2. package/dist/server/index.js +374 -58
  3. package/package.json +1 -1
  4. package/web/dist/assets/{BackLink-C-vad6K2.js → BackLink-BlJvN2RA.js} +1 -1
  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-J2pAPfCa.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-caYWCiyo.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-D_L3vB-A.js → SubjectThread-C1mHvHMI.js} +1 -1
  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-BtOZq62_.js → citizen-CImM6WB6.js} +1 -1
  30. package/web/dist/assets/compose-BG_-Tp87.js +1 -0
  31. package/web/dist/assets/{format-EfoceZkm.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-Duke4U7N.js → tasks-C10zwkED.js} +1 -1
  38. package/web/dist/assets/useMatch-BpnmzT6s.js +1 -0
  39. package/web/dist/assets/{useStickyScroll-n5d7sdPZ.js → useStickyScroll-BjqdEt_f.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,39 @@ 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
+ });
189
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");
190
224
  var StageIdSchema = z3.string().regex(/^s[1-9][0-9]*$/, "expected a stage id such as s1");
191
225
  var stageFields = {
@@ -300,6 +334,8 @@ var TaskFrontmatterSchema = z3.object({
300
334
  /** The highest stage number issued, so a removed stage's id is never reused. */
301
335
  stageSeq: z3.number().int().positive(),
302
336
  onDone: CompletionEffectSchema.default("none"),
337
+ /** The pull request a `ghpr` task lands through. */
338
+ pullRequest: PullRequestLinkSchema.optional(),
303
339
  /** The last stage is complete and the completion effect is running. */
304
340
  completing: z3.boolean().default(false),
305
341
  /** The latest send-back while its rework lasts: cleared once the task reaches that stage again. */
@@ -392,6 +428,8 @@ var RunnerSchema = z3.object({
392
428
  os: RunnerOsSchema,
393
429
  clis: z3.array(CliKindSchema),
394
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(),
395
433
  status: z3.enum(["connected", "disconnected"]),
396
434
  lastSeen: IsoDateTimeSchema.optional()
397
435
  });
@@ -570,6 +608,14 @@ var RemovedKnowledgeSchema = z3.object({
570
608
  removedBy: NameSchema,
571
609
  removedAt: IsoDateTimeSchema
572
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
+ });
573
619
  var ProposalFrontmatterSchema = z3.object({
574
620
  id: UlidSchema,
575
621
  kind: ProposalKindSchema,
@@ -641,7 +687,16 @@ var VerbInputs = {
641
687
  status: z4.enum(["abandoned"]).optional(),
642
688
  /** Posted to the task's thread as the caller, marked with the step when the task moved. */
643
689
  note: z4.string().min(1).optional(),
644
- 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()
645
700
  }),
646
701
  get_task: z4.object({ task_id: UlidSchema }),
647
702
  subscribe: z4.object({ channel: ChannelRefSchema }),
@@ -695,7 +750,14 @@ var VerbInputs = {
695
750
  name: NameSchema,
696
751
  purpose: z4.string().min(1)
697
752
  }),
698
- archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) })
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
+ })
699
761
  };
700
762
  var VERB_DESCRIPTIONS = {
701
763
  post_message: "Post a markdown message to a channel, or into a thread when thread_id is given; a thread's message goes to the thread's channel.",
@@ -706,24 +768,25 @@ var VERB_DESCRIPTIONS = {
706
768
  create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
707
769
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
708
770
  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.",
771
+ update_task: "Move a task back to an earlier stage, abandon it, set what it is blocked by, or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
710
772
  get_task: "Read a task with its plan, its brief, and the messages of its thread.",
711
773
  subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
712
774
  unsubscribe: "Unsubscribe from a channel.",
713
775
  propose: "Propose a member, role, channel, reallocation, retirement, skill, or a project's archive. The charter shape per kind is in your instructions. Opens the proposal's thread in governance with your rationale; approval provisions it.",
714
776
  approve: "Approve a proposal; the board then provisions it, posts the decision in the proposal's thread, and closes it. User and steward only, never on your own proposal.",
715
777
  reject: "Reject a proposal with a reason, posted in the proposal's thread, which closes. User and steward only, never on your own proposal.",
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).",
778
+ create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none, merge, or ghpr for a GitHub remote).",
717
779
  join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn, and follows the project's general; the result lists its other open channels, to subscribe to when the work needs them.",
718
780
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
719
781
  write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
720
782
  remove_knowledge: "Remove a knowledge topic that is wrong, superseded, or folded into another, so no turn or search finds it again. Whoever may write the topic may remove it; the board keeps its text aside for the user to restore.",
721
783
  plan_task: "Reshape a task's plan from the current stage onward, or after it while someone holds it: keep a stage by passing its id, drop it by leaving it out, add one without an id. Gated stages and on_done: user, steward, concierge.",
722
784
  advance_task: "Finish the stage you hold, with a note for the task's thread on what you did. The next stage becomes current; past the last one the task is done.",
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.",
785
+ configure_project: "Set a project's completion effect (none; merge to land each finished task's branch on the default branch; ghpr, for a project on GitHub, to merge each finished task's linked pull request), its display name, or its default branch, any of them at once. Tasks already filed keep theirs. The slug never changes.",
724
786
  archive_project: "Archive a project whose work is finished or has moved: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay. Refused while a task there is in play.",
725
787
  create_channel: "Open a channel in a project, or with project null in the society, for a workstream such as a release, whose tasks are filed there. You follow it; a notice in the place's general announces it, and members subscribe when it concerns them. User, steward, concierge.",
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."
788
+ archive_channel: "Archive a channel whose workstream is done: its open threads close, its followers stop following it, and nothing more is posted or filed there, while its history stays readable. Refused while a task filed there is in play, and for the channels the board itself uses. User, steward, concierge.",
789
+ update_dashboard: "Replace the dashboard of a project you are a member of, the status page the user reads on its view, with a whole markdown body. Pass the revision from the frontmatter of the dashboard you read; if it was updated since, the call is refused with the current text to merge yours into. dashboard.md in the board projection is a copy, so editing it changes nothing."
727
790
  };
728
791
 
729
792
  // packages/shared/dist/events.js
@@ -763,6 +826,7 @@ var BOARD_EVENT_TYPES = [
763
826
  "agent.left",
764
827
  "knowledge.written",
765
828
  "knowledge.removed",
829
+ "dashboard.updated",
766
830
  "skill.promoted",
767
831
  "runner.added",
768
832
  "user.signed_in",
@@ -934,8 +998,21 @@ var PendingEnrollmentSchema = z6.object({
934
998
  var ProjectRepoSchema = z6.object({
935
999
  slug: NameSchema,
936
1000
  origin: z6.string().nullable(),
937
- defaultBranch: z6.string().min(1)
1001
+ defaultBranch: z6.string().min(1),
1002
+ /**
1003
+ * The board lands finished tasks on the default branch, itself or through their pull requests,
1004
+ * so the runner lets nothing else move it.
1005
+ */
1006
+ boardLands: z6.boolean()
938
1007
  });
1008
+ function projectRepo(project) {
1009
+ return {
1010
+ slug: project.slug,
1011
+ origin: project.repo,
1012
+ defaultBranch: project.defaultBranch,
1013
+ boardLands: project.onDone !== "none"
1014
+ };
1015
+ }
939
1016
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
940
1017
  z6.object({
941
1018
  kind: z6.literal("home"),
@@ -1008,7 +1085,17 @@ var TurnOutcomeSchema = z6.object({
1008
1085
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
1009
1086
  var LandRequestSchema = z6.object({
1010
1087
  repo: ProjectRepoSchema,
1011
- branch: z6.string().min(1)
1088
+ branch: z6.string().min(1),
1089
+ /**
1090
+ * For a `ghpr` task, the pull request to merge in place of the branch, which must hold the
1091
+ * branch's files, and the merge commit's subject and body, the pull request's title and an
1092
+ * empty body when left out.
1093
+ */
1094
+ pullRequest: z6.object({
1095
+ url: z6.string().min(1),
1096
+ subject: z6.string().min(1).optional(),
1097
+ body: z6.string().optional()
1098
+ }).optional()
1012
1099
  });
1013
1100
  var MergeOutcomeSchema = z6.object({ ok: z6.boolean(), detail: z6.string() });
1014
1101
  var ModelListSchema = z6.array(ModelOptionSchema);
@@ -1485,7 +1572,7 @@ var RunnerConfigSchema = z10.object({
1485
1572
  });
1486
1573
 
1487
1574
  // packages/shared/dist/version.js
1488
- var STELLARIS_VERSION = "0.3.11";
1575
+ var STELLARIS_VERSION = "0.3.12";
1489
1576
 
1490
1577
  // packages/board-core/dist/errors.js
1491
1578
  var BoardError = class extends Error {
@@ -2669,6 +2756,9 @@ var THREAD_CONVERSATIONS = "thread-conversations";
2669
2756
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2670
2757
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2671
2758
  var CHANNEL_CONVERSATIONS = "channel-conversations";
2759
+ var DASHBOARD_UPDATES = "dashboard-updates-granted";
2760
+ var OLD_DASHBOARD_LINE = "Agents may edit this file. It is rendered by the board UI.";
2761
+ var DASHBOARD_LINE = "The project's members keep this page current with update_dashboard.";
2672
2762
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2673
2763
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2674
2764
  var PROFILE_TEMPLATE = "# Profile\n\nOne short paragraph, kept current: what I do well, what I am working on, and what to send my way. The board projects this into the roster the front desk reads.\n";
@@ -2705,6 +2795,11 @@ function memberToAgentInput(member) {
2705
2795
  ...member.seedInstructions === void 0 ? {} : { seedInstructions: member.seedInstructions }
2706
2796
  };
2707
2797
  }
2798
+ function assertLandsByPullRequest(effect, repo, where) {
2799
+ if (effect === "ghpr" && githubRepository(repo) === null) {
2800
+ throw new BoardError("VALIDATION", `${where} has ${repo === null ? "no remote" : `its remote at ${repo}, not on GitHub`}, so its tasks cannot land through pull requests (ghpr)`);
2801
+ }
2802
+ }
2708
2803
  function stageFrom(planned, id) {
2709
2804
  return {
2710
2805
  id,
@@ -2862,7 +2957,8 @@ var Board = class _Board {
2862
2957
  THREAD_CONVERSATIONS,
2863
2958
  ASKS_CHANNEL_OPENED,
2864
2959
  KNOWLEDGE_REMOVAL,
2865
- CHANNEL_CONVERSATIONS
2960
+ CHANNEL_CONVERSATIONS,
2961
+ DASHBOARD_UPDATES
2866
2962
  ].filter((name) => !applied.includes(name));
2867
2963
  if (pending.length === 0) {
2868
2964
  return;
@@ -2892,21 +2988,38 @@ var Board = class _Board {
2892
2988
  await this.openAsksChannel();
2893
2989
  }
2894
2990
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2895
- await this.grantKnowledgeRemoval();
2991
+ await this.grantBesideKnowledge("remove_knowledge");
2896
2992
  }
2897
2993
  if (pending.includes(CHANNEL_CONVERSATIONS)) {
2898
2994
  await this.startChannelCursors();
2899
2995
  }
2996
+ if (pending.includes(DASHBOARD_UPDATES)) {
2997
+ await this.grantBesideKnowledge("update_dashboard");
2998
+ await this.rewordDashboards();
2999
+ }
2900
3000
  await writeJson(file, { applied: [...applied, ...pending] });
2901
3001
  }
2902
3002
  /**
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.
3003
+ * Grants `verb` once to every charter granting `write_knowledge`, the work roles' charters in
3004
+ * practice; seed roles have it from their seed.
2905
3005
  */
2906
- async grantKnowledgeRemoval() {
3006
+ async grantBesideKnowledge(verb) {
2907
3007
  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"] });
3008
+ if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes(verb)) {
3009
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
3010
+ }
3011
+ }
3012
+ }
3013
+ /** A dashboard still saying agents may edit its file says how it changes now. */
3014
+ async rewordDashboards() {
3015
+ for (const slug of await listDirs(this.paths.projects())) {
3016
+ const file = this.paths.dashboard(slug);
3017
+ if (!await exists(file)) {
3018
+ continue;
3019
+ }
3020
+ const { data, body } = await readLooseMarkdown(file);
3021
+ if (body.includes(OLD_DASHBOARD_LINE)) {
3022
+ await writeMarkdown(file, data, body.replace(OLD_DASHBOARD_LINE, DASHBOARD_LINE));
2910
3023
  }
2911
3024
  }
2912
3025
  }
@@ -3668,6 +3781,7 @@ ${charter.purpose}
3668
3781
  if (await exists(file)) {
3669
3782
  throw new BoardError("ALREADY_EXISTS", `project ${input.slug} already exists`);
3670
3783
  }
3784
+ assertLandsByPullRequest(input.onDone ?? "none", input.repo ?? null, `project ${input.slug}`);
3671
3785
  const project = ProjectSchema.parse({
3672
3786
  slug: input.slug,
3673
3787
  name: input.name ?? input.slug,
@@ -3688,9 +3802,9 @@ ${charter.purpose}
3688
3802
  await ensureDir(this.paths.tasks(project.slug));
3689
3803
  await ensureDir(this.paths.threads(project.slug));
3690
3804
  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
3805
+ await writeMarkdown(this.paths.dashboard(project.slug), { project: project.slug, revision: 0, updatedAt: this.now().toISOString() }, `# ${project.name} dashboard
3692
3806
 
3693
- Agents may edit this file. It is rendered by the board UI.
3807
+ ${DASHBOARD_LINE}
3694
3808
  `);
3695
3809
  await this.updateAgent(USER_NAME, (user) => ({
3696
3810
  ...user,
@@ -3795,6 +3909,7 @@ Agents may edit this file. It is rendered by the board UI.
3795
3909
  ...patch.os === void 0 ? {} : { os: patch.os },
3796
3910
  ...patch.clis === void 0 ? {} : { clis: [...patch.clis] },
3797
3911
  ...patch.capabilities === void 0 ? {} : { capabilities: [...patch.capabilities] },
3912
+ ...patch.version === void 0 ? {} : { version: patch.version },
3798
3913
  lastSeen: this.now().toISOString()
3799
3914
  });
3800
3915
  await writeMarkdown(file, next, doc.body);
@@ -4269,6 +4384,42 @@ ${task.body}`;
4269
4384
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project}`);
4270
4385
  }
4271
4386
  }
4387
+ /**
4388
+ * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4389
+ * copy of the projection pulled when it started, so a revision other than the current one means
4390
+ * someone updated it since; the refusal carries the current text, which that copy lacks.
4391
+ */
4392
+ async updateDashboard(actor, input) {
4393
+ const args = VerbInputs.update_dashboard.parse(input);
4394
+ await this.authorize(actor, "update_dashboard");
4395
+ return this.mutex.run(async () => {
4396
+ const project = await this.readActiveProject(args.project);
4397
+ if (actor.role !== USER_ROLE && !project.members.includes(actor.name)) {
4398
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${args.project}`);
4399
+ }
4400
+ const current = await this.dashboardOf(args.project);
4401
+ if (args.revision !== current.revision) {
4402
+ const by = current.updatedBy === void 0 ? "" : `, updated by ${current.updatedBy} at ${current.updatedAt ?? "an unknown time"}`;
4403
+ throw new BoardError("INVALID_STATE", `the dashboard of ${args.project} is at revision ${current.revision}${by}, not ${args.revision}. Merge your changes into its current text, below, and update again with revision ${current.revision}.
4404
+
4405
+ ${current.body}`);
4406
+ }
4407
+ const updated = DashboardSchema.parse({
4408
+ project: args.project,
4409
+ revision: current.revision + 1,
4410
+ updatedBy: actor.name,
4411
+ updatedAt: this.now().toISOString(),
4412
+ body: args.body
4413
+ });
4414
+ const { body, ...frontmatter } = updated;
4415
+ await writeMarkdown(this.paths.dashboard(args.project), frontmatter, body);
4416
+ await this.events.append("dashboard.updated", actor.name, {
4417
+ project: args.project,
4418
+ revision: updated.revision
4419
+ });
4420
+ return updated;
4421
+ });
4422
+ }
4272
4423
  async openThread(actor, input) {
4273
4424
  const args = VerbInputs.open_thread.parse(input);
4274
4425
  await this.authorize(actor, "open_thread");
@@ -4650,6 +4801,9 @@ ${args.summary}`);
4650
4801
  completedAt: ts
4651
4802
  };
4652
4803
  const next = current.stages[index + 1];
4804
+ if (next === void 0 && current.onDone === "ghpr" && current.pullRequest === void 0) {
4805
+ throw new BoardError("INVALID_STATE", `task ${current.id} lands through its pull request: link it with update_task pull_request before the last stage completes, or have the user, the steward, or the concierge set the task's on_done to none`);
4806
+ }
4653
4807
  if (next === void 0 && current.onDone === "none") {
4654
4808
  this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn) => turn.thread === current.id);
4655
4809
  }
@@ -4766,6 +4920,7 @@ ${args.summary}`);
4766
4920
  const guarded = gateChanges(ahead, replanned);
4767
4921
  if (onDone !== current.onDone) {
4768
4922
  guarded.push("change the completion effect");
4923
+ assertLandsByPullRequest(onDone, (await this.readProject(current.project)).repo, `project ${current.project}`);
4769
4924
  }
4770
4925
  if (guarded.length > 0) {
4771
4926
  this.assertMayGate(actor, guarded.join(", "));
@@ -4854,6 +5009,12 @@ ${args.summary}`);
4854
5009
  }
4855
5010
  next = { ...next, blockedBy: [...args.blocked_by] };
4856
5011
  }
5012
+ if (args.pull_request !== void 0) {
5013
+ next = {
5014
+ ...next,
5015
+ pullRequest: await this.pullRequestLink(actor, current, args.pull_request)
5016
+ };
5017
+ }
4857
5018
  if (args.note !== void 0) {
4858
5019
  const step = moved !== null ? { action: "returned", stage: moved.from, to: moved.to } : next.status === "abandoned" ? { action: "abandoned", stage: current.stage, to: null } : void 0;
4859
5020
  await this.postTaskNote(actor.name, current, args.note, step);
@@ -4879,12 +5040,46 @@ ${args.summary}`);
4879
5040
  project: location.project,
4880
5041
  from: current.status,
4881
5042
  to: task.status,
4882
- note: args.note ?? null
5043
+ note: args.note ?? null,
5044
+ ...args.pull_request === void 0 ? {} : { pullRequest: task.pullRequest?.url ?? null }
4883
5045
  });
4884
5046
  }
4885
5047
  return task;
4886
5048
  });
4887
5049
  }
5050
+ /**
5051
+ * The pull request a task is linked to, or none for `null`: one on the repository of its project,
5052
+ * by a member of the project or a planner, while the task is in play and before it lands.
5053
+ */
5054
+ async pullRequestLink(actor, task, link) {
5055
+ this.assertInPlay(task);
5056
+ if (task.completing) {
5057
+ throw new BoardError("INVALID_STATE", `task ${task.id} is landing, so its pull request no longer changes`);
5058
+ }
5059
+ if (!PLANNING_ROLES.includes(actor.role)) {
5060
+ const agent = await this.readAgent(actor.name);
5061
+ if (!agent.memberships.includes(task.project)) {
5062
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${task.project}`);
5063
+ }
5064
+ }
5065
+ if (link === null) {
5066
+ return void 0;
5067
+ }
5068
+ const project = await this.readProject(task.project);
5069
+ const repository = githubRepository(project.repo);
5070
+ const named = parsePullRequestUrl(link.url);
5071
+ if (repository === null || named === null || !sameRepository(repository, named)) {
5072
+ throw new BoardError("VALIDATION", `${link.url} is not a pull request of ${project.slug}'s repository${project.repo === null ? ", since it has no remote" : ` at ${project.repo}`}`);
5073
+ }
5074
+ return PullRequestLinkSchema.parse({
5075
+ url: link.url,
5076
+ number: named.number,
5077
+ ...link.merge_subject === void 0 ? {} : { subject: link.merge_subject },
5078
+ ...link.merge_body === void 0 ? {} : { body: link.merge_body },
5079
+ linkedBy: actor.name,
5080
+ linkedAt: this.now().toISOString()
5081
+ });
5082
+ }
4888
5083
  async getTask(actor, input) {
4889
5084
  const args = VerbInputs.get_task.parse(input);
4890
5085
  await this.authorize(actor, "get_task");
@@ -4995,6 +5190,9 @@ ${rationale}`}`);
4995
5190
  }
4996
5191
  return this.mutex.run(async () => {
4997
5192
  const previous = await this.readActiveProject(args.project);
5193
+ if (args.on_done !== void 0) {
5194
+ assertLandsByPullRequest(args.on_done, previous.repo, `project ${previous.slug}`);
5195
+ }
4998
5196
  const project = await this.updateProject(args.project, (current) => ({
4999
5197
  ...current,
5000
5198
  ...args.on_done === void 0 ? {} : { onDone: args.on_done },
@@ -5176,6 +5374,8 @@ ${rationale}`}`);
5176
5374
  return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5177
5375
  case "archive_channel":
5178
5376
  return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
5377
+ case "update_dashboard":
5378
+ return this.updateDashboard(actor, VerbInputs.update_dashboard.parse(input));
5179
5379
  default:
5180
5380
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
5181
5381
  }
@@ -5480,8 +5680,8 @@ ${rationale}`}`);
5480
5680
  }
5481
5681
  /**
5482
5682
  * 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.
5683
+ * its participants can reshape the plan. A landing's outcome, a merge's or a pull request's, is
5684
+ * posted to the task's thread, and a landed one announced in the task's channel.
5485
5685
  */
5486
5686
  async finishCompletion(actor, input) {
5487
5687
  return this.mutex.run(async () => {
@@ -5491,8 +5691,8 @@ ${rationale}`}`);
5491
5691
  throw new BoardError("INVALID_STATE", `task ${current.id} is not completing`);
5492
5692
  }
5493
5693
  const ts = this.now().toISOString();
5494
- const merged = current.onDone === "merge";
5495
- if (merged) {
5694
+ const landed = current.onDone !== "none";
5695
+ if (landed) {
5496
5696
  await this.postTaskNote(actor.name, current, input.ok ? `${sentence(input.detail)}.` : `Landing failed: ${sentence(input.detail)}. The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
5497
5697
  }
5498
5698
  if (input.ok) {
@@ -5512,7 +5712,7 @@ ${rationale}`}`);
5512
5712
  for (const name of new Set(task2.stages.flatMap((stage) => stage.completedBy ?? []))) {
5513
5713
  await this.refreshMember(name);
5514
5714
  }
5515
- if (merged) {
5715
+ if (landed) {
5516
5716
  await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5517
5717
  }
5518
5718
  return task2;
@@ -5542,15 +5742,31 @@ ${rationale}`}`);
5542
5742
  async writeState(name, value) {
5543
5743
  await this.mutex.run(() => writeJson(path6.join(this.paths.state(), `${name}.json`), value));
5544
5744
  }
5545
- /** The project's editable dashboard: frontmatter plus markdown body. Empty when none exists. */
5745
+ /** The project's dashboard, empty at revision 0 when it has none. */
5546
5746
  async readDashboard(slug) {
5547
5747
  await this.readProject(slug);
5748
+ return this.dashboardOf(slug);
5749
+ }
5750
+ /**
5751
+ * Read loosely, since agents wrote dashboards by hand before they had a verb: a revision or an
5752
+ * author that does not parse counts as none.
5753
+ */
5754
+ async dashboardOf(slug) {
5548
5755
  const file = this.paths.dashboard(slug);
5549
5756
  if (!await exists(file)) {
5550
- return { data: { project: slug }, body: "" };
5757
+ return { project: slug, revision: 0, body: "" };
5551
5758
  }
5552
5759
  const { data, body } = await readLooseMarkdown(file);
5553
- return { data, body };
5760
+ const revision = z12.number().int().min(0).safeParse(data["revision"]);
5761
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
5762
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
5763
+ return {
5764
+ project: slug,
5765
+ revision: revision.success ? revision.data : 0,
5766
+ ...updatedBy.success ? { updatedBy: updatedBy.data } : {},
5767
+ ...updatedAt.success ? { updatedAt: updatedAt.data } : {},
5768
+ body
5769
+ };
5554
5770
  }
5555
5771
  async listRunners() {
5556
5772
  const runners2 = [];
@@ -8305,7 +8521,7 @@ function renderInstructions(input) {
8305
8521
  if (norms.length > 0) {
8306
8522
  sections.push("", "## Society norms", "", norms);
8307
8523
  }
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);
8524
+ sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's or a channel's conversation there works in a folder of its own inside it, which goes when the thread closes or the channel is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/; each project's dashboard, the status page the user reads on its view, at projects/<slug>/dashboard.md, which its members change with update_dashboard). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
8309
8525
  if (input.onboarding !== void 0) {
8310
8526
  sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
8311
8527
  }
@@ -8352,7 +8568,7 @@ function planLine(task, whose) {
8352
8568
  const index = stageIndex(task, task.stage);
8353
8569
  const stage = task.stages[index];
8354
8570
  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(", ")}`;
8571
+ const then = rest.length === 0 ? `then ${task.onDone === "merge" ? "the board merges it" : task.onDone === "ghpr" ? "the board merges its pull request" : "done"}` : `next: ${rest.join(", ")}`;
8356
8572
  const gated = stage?.gate === true ? ", gated" : "";
8357
8573
  return `- ${task.id} "${task.title}": ${stage?.name ?? task.stage} (${whose}, ${index + 1} of ${task.stages.length}${gated}); ${then}`;
8358
8574
  }
@@ -8450,7 +8666,8 @@ function buildTurnPrompt(input) {
8450
8666
  } else {
8451
8667
  const { thread, task } = conversation;
8452
8668
  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}.`);
8669
+ const talk = thread.state === "closed" ? `This thread is closed, so posts to it are refused: if something still needs saying, post it in ${conversation.replyIn ?? thread.channel}.` : `Talk here with post_message and thread_id ${thread.id}.`;
8670
+ lines.push(`This turn is in the thread "${thread.title}" on ${thread.channel}, about ${about}. Only this conversation is below: read anything else you need with get_task, search, or the board's files, and leave other tasks and threads to their own turns. ${talk}`);
8454
8671
  if (task !== null) {
8455
8672
  lines.push("", `Task ${task.id} "${task.title}", ${task.status}:`, planLine(task, "now"));
8456
8673
  if (task.body.trim() !== "") {
@@ -8514,8 +8731,13 @@ function buildTurnPrompt(input) {
8514
8731
  }
8515
8732
  }
8516
8733
  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.`);
8734
+ const landing = conversation?.task?.onDone ?? project?.onDone;
8735
+ if (project !== void 0 && project !== null && landing === "merge") {
8736
+ lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its branch task/<id> into ${project.defaultBranch}. Never merge or fast-forward ${project.defaultBranch} yourself, or push other work to it on the remote; the runner refuses both.`);
8737
+ }
8738
+ if (project !== void 0 && project !== null && landing === "ghpr") {
8739
+ const base = project.defaultBranch;
8740
+ lines.push("", `## Project ${project.slug}`, "", `A finished task lands by the board merging its pull request on GitHub into ${base}. Open the pull request yourself, from a branch named for what it does that holds the same files as task/<id>, as \`git push origin task/<id>:<branch>\` gives, and link it to the task with update_task pull_request before its last stage completes, with the merge commit's merge_subject and merge_body, which you may change by linking again until it lands. Never merge the pull request or ${base} yourself, nor push other work to ${base}: the board merges once the task's stages are done, and the runner refuses the pushes.`);
8519
8741
  }
8520
8742
  if (conflicts.length > 0) {
8521
8743
  lines.push("", "## Edits to reconcile in your home", "", "Two of your turns changed these files at once on different runners. The file in place kept the other turn's version; yours is the copy named here, beside it. Merge what still holds into the file, then delete the copy.", "", ...conflicts.map((conflict) => `- ${conflict.path}, beside ${conflict.file}, since ${conflict.since}`));
@@ -8719,7 +8941,12 @@ var TurnHost = class {
8719
8941
  }
8720
8942
  }
8721
8943
  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 };
8944
+ const conversation = threadRecord === void 0 ? null : {
8945
+ thread: threadRecord,
8946
+ task,
8947
+ fresh: threadSoFar !== null,
8948
+ ...threadRecord.state === "closed" ? { replyIn: await this.replyIn(threadRecord) } : {}
8949
+ };
8723
8950
  const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
8724
8951
  const prompt = buildTurnPrompt({
8725
8952
  dispatch,
@@ -9122,11 +9349,7 @@ var TurnHost = class {
9122
9349
  if (project === null) {
9123
9350
  return { kind: "home", ...own };
9124
9351
  }
9125
- const repo = {
9126
- slug: project.slug,
9127
- origin: project.repo,
9128
- defaultBranch: project.defaultBranch
9129
- };
9352
+ const repo = projectRepo(project);
9130
9353
  if (thread?.task === true) {
9131
9354
  return { kind: "task", repo, taskId: thread.id };
9132
9355
  }
@@ -9141,6 +9364,10 @@ var TurnHost = class {
9141
9364
  const conversation = workspaceConversationOf(job);
9142
9365
  return conversation !== null && await this.board.conversationEnded(conversation);
9143
9366
  }
9367
+ /** Where a closed thread's citizens say what still needs saying: its channel, else its place's general. */
9368
+ async replyIn(thread) {
9369
+ return await this.board.channelOpen(thread.channel) ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general");
9370
+ }
9144
9371
  /**
9145
9372
  * How a closing turn's conversation ended, and where its citizen asks someone else to take over
9146
9373
  * work that is not its own: the thread's channel while it is open, else the place's general.
@@ -9149,11 +9376,7 @@ var TurnHost = class {
9149
9376
  const place = scope === SOCIETY_SCOPE ? null : scope;
9150
9377
  if (thread !== void 0) {
9151
9378
  const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
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
- };
9379
+ return { how: closed, askIn: await this.replyIn(thread) };
9157
9380
  }
9158
9381
  if (channel === void 0) {
9159
9382
  return null;
@@ -9246,12 +9469,14 @@ var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
9246
9469
  var CONTROL_TIMEOUT_MS = 15e3;
9247
9470
  var FILE_TIMEOUT_MS = 6e4;
9248
9471
  var SWEEP_TIMEOUT_MS = 2 * 6e4;
9472
+ var DEFAULT_ACK_TIMEOUT_MS = 3e4;
9249
9473
  var RunnerHub = class {
9250
9474
  board;
9251
9475
  host;
9252
9476
  version;
9253
9477
  graceMs;
9254
9478
  requestTimeoutMs;
9479
+ ackTimeoutMs;
9255
9480
  now;
9256
9481
  log;
9257
9482
  seats = /* @__PURE__ */ new Map();
@@ -9267,6 +9492,7 @@ var RunnerHub = class {
9267
9492
  this.version = options.version;
9268
9493
  this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
9269
9494
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
9495
+ this.ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
9270
9496
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
9271
9497
  this.log = options.log ?? SILENT2;
9272
9498
  this.startedAt = this.now().getTime();
@@ -9282,9 +9508,15 @@ var RunnerHub = class {
9282
9508
  // -------------------------------------------------------------------------------------------
9283
9509
  // The protocol, as the server's routes call it
9284
9510
  // -------------------------------------------------------------------------------------------
9511
+ /** The jobs sent to a runner that it has not acknowledged yet. */
9512
+ unacknowledged(runner) {
9513
+ return [...this.seats.get(runner)?.unacked.keys() ?? []];
9514
+ }
9285
9515
  /**
9286
- * A runner registers, first or again. Its turns in flight that it no longer runs, because it
9287
- * restarted, end as failed.
9516
+ * A runner registers, first or again. A turn in flight it still runs, or whose job it never
9517
+ * acknowledged, carries on, the latter sent again once its stream opens, since a job written
9518
+ * into a stream that had stopped delivering never arrived; one it took and no longer runs,
9519
+ * because it restarted, ends as failed.
9288
9520
  */
9289
9521
  async register(name, hello) {
9290
9522
  if (hello.protocol !== RUNNER_PROTOCOL) {
@@ -9299,25 +9531,31 @@ var RunnerHub = class {
9299
9531
  inUse: 0,
9300
9532
  warm: /* @__PURE__ */ new Set(),
9301
9533
  disconnectedAt: null,
9302
- grace: null
9534
+ grace: null,
9535
+ unacked: /* @__PURE__ */ new Map(),
9536
+ unconfirmed: false
9303
9537
  });
9304
9538
  } else {
9305
9539
  seat.hello = hello;
9306
9540
  seat.warm = /* @__PURE__ */ new Set();
9307
9541
  }
9308
9542
  const still = new Set(hello.turns);
9543
+ const unacked = this.seats.get(name)?.unacked ?? /* @__PURE__ */ new Map();
9309
9544
  for (const turnId of this.host.turnsOn(name)) {
9310
- if (!still.has(turnId)) {
9545
+ if (still.has(turnId)) {
9546
+ this.settle(unacked, turnId);
9547
+ } else if (!unacked.has(turnId)) {
9311
9548
  await this.host.abortTurn(turnId, `runner ${name} restarted during the turn`);
9312
9549
  }
9313
9550
  }
9314
9551
  await this.board.markRunner(name, {
9315
9552
  os: hello.os,
9316
9553
  clis: hello.clis,
9317
- capabilities: hello.capabilities
9554
+ capabilities: hello.capabilities,
9555
+ version: hello.version
9318
9556
  });
9319
9557
  this.runnerChanged(name);
9320
- this.log.info({ runner: name, clis: hello.clis, slots: hello.slots }, "runner registered");
9558
+ this.log.info({ runner: name, version: hello.version, clis: hello.clis, slots: hello.slots }, "runner registered");
9321
9559
  return { name, version: this.version };
9322
9560
  }
9323
9561
  /** A registered runner's stream opened. Returns what to call when it closes. */
@@ -9328,10 +9566,17 @@ var RunnerHub = class {
9328
9566
  }
9329
9567
  seat.send = send;
9330
9568
  seat.disconnectedAt = null;
9569
+ seat.unconfirmed = false;
9331
9570
  if (seat.grace !== null) {
9332
9571
  clearTimeout(seat.grace);
9333
9572
  seat.grace = null;
9334
9573
  }
9574
+ for (const { job } of seat.unacked.values()) {
9575
+ if (send({ type: "turn", job })) {
9576
+ this.expectAck(seat, job);
9577
+ this.log.info({ runner: name, turnId: job.turnId }, "sent again a job the runner never took");
9578
+ }
9579
+ }
9335
9580
  await this.board.markRunner(name, { status: "connected" });
9336
9581
  this.runnerChanged(name);
9337
9582
  return async () => {
@@ -9356,6 +9601,9 @@ var RunnerHub = class {
9356
9601
  clearTimeout(seat.grace);
9357
9602
  seat.grace = null;
9358
9603
  }
9604
+ for (const { timer } of seat.unacked.values()) {
9605
+ clearTimeout(timer);
9606
+ }
9359
9607
  }
9360
9608
  await Promise.allSettled(this.detaching);
9361
9609
  }
@@ -9363,6 +9611,18 @@ var RunnerHub = class {
9363
9611
  this.assertOwns(runner, turnId);
9364
9612
  await this.host.addEvents(turnId, entries);
9365
9613
  }
9614
+ /** A runner's word that a turn's job arrived, which also shows its stream delivers again. */
9615
+ received(runner, turnId) {
9616
+ const seat = this.seats.get(runner);
9617
+ if (seat === void 0 || !seat.unacked.has(turnId)) {
9618
+ return;
9619
+ }
9620
+ this.settle(seat.unacked, turnId);
9621
+ if (seat.unconfirmed) {
9622
+ seat.unconfirmed = false;
9623
+ this.log.info({ runner }, "runner is taking jobs again");
9624
+ }
9625
+ }
9366
9626
  async turnOutcome(runner, turnId, outcome) {
9367
9627
  this.assertOwns(runner, turnId);
9368
9628
  const ack = await this.host.closeTurn(turnId, outcome);
@@ -9506,7 +9766,7 @@ var RunnerHub = class {
9506
9766
  return { refused: `${agent.name} has no CLI binding` };
9507
9767
  }
9508
9768
  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);
9769
+ const free = (seat) => seat.send !== null && !seat.unconfirmed && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots);
9510
9770
  let chosen;
9511
9771
  if (dispatch.project === SOCIETY_SCOPE) {
9512
9772
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
@@ -9545,6 +9805,7 @@ var RunnerHub = class {
9545
9805
  return this.host.refuseTurn(dispatch, "none", assignment.refused);
9546
9806
  }
9547
9807
  const seat = this.seats.get(assignment.runner);
9808
+ let turnId = null;
9548
9809
  try {
9549
9810
  if (seat === void 0) {
9550
9811
  return await this.host.refuseTurn(dispatch, assignment.runner, "the runner is gone");
@@ -9555,11 +9816,17 @@ var RunnerHub = class {
9555
9816
  }
9556
9817
  if (seat.send === null || !seat.send({ type: "turn", job: opened.job })) {
9557
9818
  await this.host.abortTurn(opened.job.turnId, `runner ${seat.name} went away before the turn`);
9819
+ } else {
9820
+ this.expectAck(seat, opened.job);
9558
9821
  }
9822
+ turnId = opened.job.turnId;
9559
9823
  return await opened.ended;
9560
9824
  } finally {
9561
9825
  if (seat !== void 0) {
9562
9826
  seat.inUse = Math.max(0, seat.inUse - 1);
9827
+ if (turnId !== null) {
9828
+ this.settle(seat.unacked, turnId);
9829
+ }
9563
9830
  }
9564
9831
  }
9565
9832
  }
@@ -9568,7 +9835,7 @@ var RunnerHub = class {
9568
9835
  if (!task.completing) {
9569
9836
  return "done";
9570
9837
  }
9571
- if (task.onDone !== "merge") {
9838
+ if (task.onDone === "none") {
9572
9839
  await this.board.finishCompletion(SYSTEM_ACTOR, {
9573
9840
  taskId,
9574
9841
  ok: true,
@@ -9578,6 +9845,15 @@ var RunnerHub = class {
9578
9845
  }
9579
9846
  const record = await this.board.readProject(project);
9580
9847
  const branch = `task/${taskId}`;
9848
+ const pull = task.onDone === "ghpr" ? task.pullRequest : void 0;
9849
+ if (task.onDone === "ghpr" && pull === void 0) {
9850
+ await this.board.finishCompletion(SYSTEM_ACTOR, {
9851
+ taskId,
9852
+ ok: false,
9853
+ detail: "no pull request is linked to the task"
9854
+ });
9855
+ return "done";
9856
+ }
9581
9857
  let outcome = { ok: true, detail: "nothing to land, since the task left no branch" };
9582
9858
  if (record.runner !== void 0) {
9583
9859
  const seat = this.seats.get(record.runner);
@@ -9589,8 +9865,15 @@ var RunnerHub = class {
9589
9865
  type: "land",
9590
9866
  request,
9591
9867
  land: {
9592
- repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch },
9593
- branch
9868
+ repo: projectRepo(record),
9869
+ branch,
9870
+ ...pull === void 0 ? {} : {
9871
+ pullRequest: {
9872
+ url: pull.url,
9873
+ ...pull.subject === void 0 ? {} : { subject: pull.subject },
9874
+ ...pull.body === void 0 ? {} : { body: pull.body }
9875
+ }
9876
+ }
9594
9877
  }
9595
9878
  })));
9596
9879
  } catch (error) {
@@ -9666,6 +9949,34 @@ var RunnerHub = class {
9666
9949
  }
9667
9950
  }
9668
9951
  // -------------------------------------------------------------------------------------------
9952
+ /**
9953
+ * Waits for a runner to acknowledge a job. One that does not in time may be behind a stream that
9954
+ * looks open but delivers nothing, as when its machine's network stops passing traffic, so no
9955
+ * more turns go to it until it acknowledges or connects again; the turns it has carry on, since
9956
+ * they report by posts.
9957
+ */
9958
+ expectAck(seat, job) {
9959
+ const previous = seat.unacked.get(job.turnId);
9960
+ if (previous !== void 0) {
9961
+ clearTimeout(previous.timer);
9962
+ }
9963
+ const timer = setTimeout(() => {
9964
+ if (!seat.unacked.has(job.turnId) || seat.unconfirmed || seat.send === null) {
9965
+ return;
9966
+ }
9967
+ seat.unconfirmed = true;
9968
+ this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does or reconnects");
9969
+ }, this.ackTimeoutMs);
9970
+ timer.unref?.();
9971
+ seat.unacked.set(job.turnId, { job, timer });
9972
+ }
9973
+ settle(unacked, turnId) {
9974
+ const entry = unacked.get(turnId);
9975
+ if (entry !== void 0) {
9976
+ clearTimeout(entry.timer);
9977
+ unacked.delete(turnId);
9978
+ }
9979
+ }
9669
9980
  async detach(seat) {
9670
9981
  seat.send = null;
9671
9982
  this.runnerChanged(seat.name);
@@ -9686,7 +9997,7 @@ var RunnerHub = class {
9686
9997
  return;
9687
9998
  }
9688
9999
  for (const turnId of this.host.turnsOn(seat.name)) {
9689
- void this.host.abortTurn(turnId, `runner ${seat.name} went away during the turn`);
10000
+ void this.host.abortTurn(turnId, seat.unacked.has(turnId) ? `the turn never reached runner ${seat.name}, which went away` : `runner ${seat.name} went away during the turn`);
9690
10001
  }
9691
10002
  }, this.graceMs);
9692
10003
  seat.grace.unref?.();
@@ -9763,7 +10074,7 @@ var RunnerHub = class {
9763
10074
  }
9764
10075
  return {
9765
10076
  seat,
9766
- repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch }
10077
+ repo: projectRepo(record)
9767
10078
  };
9768
10079
  }
9769
10080
  assertOwns(runner, turnId) {
@@ -10228,6 +10539,10 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
10228
10539
  await hub.turnEvents(c.get("runner"), UlidSchema.parse(c.req.param("id")), entries);
10229
10540
  return c.json({ ok: true });
10230
10541
  });
10542
+ routes.post("/turns/:id/received", (c) => {
10543
+ hub.received(c.get("runner"), UlidSchema.parse(c.req.param("id")));
10544
+ return c.json({ ok: true });
10545
+ });
10231
10546
  routes.post("/turns/:id/outcome", async (c) => c.json(await hub.turnOutcome(c.get("runner"), UlidSchema.parse(c.req.param("id")), TurnOutcomeSchema.parse(await c.req.json()))));
10232
10547
  routes.post("/requests/:id", async (c) => {
10233
10548
  hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
@@ -10402,6 +10717,7 @@ function createApp(deps) {
10402
10717
  });
10403
10718
  api.delete("/sign-in", async (c) => c.json({ signedOut: await board2.signOut(bearer2(c) ?? "") }));
10404
10719
  api.get("/society", async (c) => c.json(await board2.society()));
10720
+ api.get("/version", (c) => c.json({ version }));
10405
10721
  api.get("/agents", async (c) => c.json((await board2.listAgents()).map(({ tokenHash: _hash, ...agent }) => agent)));
10406
10722
  api.get("/members", async (c) => c.json(await board2.listMembers()));
10407
10723
  api.get("/agents/:name/turns", async (c) => c.json(await board2.listTurns(c.req.param("name"), Number(c.req.query("limit") ?? "50"))));