@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
package/dist/cli/index.js CHANGED
@@ -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
 
701
763
  // packages/shared/dist/events.js
@@ -735,6 +797,7 @@ var BOARD_EVENT_TYPES = [
735
797
  "agent.left",
736
798
  "knowledge.written",
737
799
  "knowledge.removed",
800
+ "dashboard.updated",
738
801
  "skill.promoted",
739
802
  "runner.added",
740
803
  "user.signed_in",
@@ -898,7 +961,12 @@ var PendingEnrollmentSchema = z6.object({
898
961
  var ProjectRepoSchema = z6.object({
899
962
  slug: NameSchema,
900
963
  origin: z6.string().nullable(),
901
- defaultBranch: z6.string().min(1)
964
+ defaultBranch: z6.string().min(1),
965
+ /**
966
+ * The board lands finished tasks on the default branch, itself or through their pull requests,
967
+ * so the runner lets nothing else move it.
968
+ */
969
+ boardLands: z6.boolean()
902
970
  });
903
971
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
904
972
  z6.object({
@@ -972,7 +1040,17 @@ var TurnOutcomeSchema = z6.object({
972
1040
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
973
1041
  var LandRequestSchema = z6.object({
974
1042
  repo: ProjectRepoSchema,
975
- branch: z6.string().min(1)
1043
+ branch: z6.string().min(1),
1044
+ /**
1045
+ * For a `ghpr` task, the pull request to merge in place of the branch, which must hold the
1046
+ * branch's files, and the merge commit's subject and body, the pull request's title and an
1047
+ * empty body when left out.
1048
+ */
1049
+ pullRequest: z6.object({
1050
+ url: z6.string().min(1),
1051
+ subject: z6.string().min(1).optional(),
1052
+ body: z6.string().optional()
1053
+ }).optional()
976
1054
  });
977
1055
  var MergeOutcomeSchema = z6.object({ ok: z6.boolean(), detail: z6.string() });
978
1056
  var ModelListSchema = z6.array(ModelOptionSchema);
@@ -1385,7 +1463,7 @@ var RunnerConfigSchema = z10.object({
1385
1463
  });
1386
1464
 
1387
1465
  // packages/shared/dist/version.js
1388
- var STELLARIS_VERSION = "0.3.11";
1466
+ var STELLARIS_VERSION = "0.3.12";
1389
1467
 
1390
1468
  // packages/board-core/dist/errors.js
1391
1469
  var BoardError = class extends Error {
@@ -2566,6 +2644,9 @@ var THREAD_CONVERSATIONS = "thread-conversations";
2566
2644
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2567
2645
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2568
2646
  var CHANNEL_CONVERSATIONS = "channel-conversations";
2647
+ var DASHBOARD_UPDATES = "dashboard-updates-granted";
2648
+ var OLD_DASHBOARD_LINE = "Agents may edit this file. It is rendered by the board UI.";
2649
+ var DASHBOARD_LINE = "The project's members keep this page current with update_dashboard.";
2569
2650
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2570
2651
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2571
2652
  var PROFILE_TEMPLATE = "# Profile\n\nOne short paragraph, kept current: what I do well, what I am working on, and what to send my way. The board projects this into the roster the front desk reads.\n";
@@ -2602,6 +2683,11 @@ function memberToAgentInput(member) {
2602
2683
  ...member.seedInstructions === void 0 ? {} : { seedInstructions: member.seedInstructions }
2603
2684
  };
2604
2685
  }
2686
+ function assertLandsByPullRequest(effect, repo, where) {
2687
+ if (effect === "ghpr" && githubRepository(repo) === null) {
2688
+ throw new BoardError("VALIDATION", `${where} has ${repo === null ? "no remote" : `its remote at ${repo}, not on GitHub`}, so its tasks cannot land through pull requests (ghpr)`);
2689
+ }
2690
+ }
2605
2691
  function stageFrom(planned, id) {
2606
2692
  return {
2607
2693
  id,
@@ -2759,7 +2845,8 @@ var Board = class _Board {
2759
2845
  THREAD_CONVERSATIONS,
2760
2846
  ASKS_CHANNEL_OPENED,
2761
2847
  KNOWLEDGE_REMOVAL,
2762
- CHANNEL_CONVERSATIONS
2848
+ CHANNEL_CONVERSATIONS,
2849
+ DASHBOARD_UPDATES
2763
2850
  ].filter((name) => !applied.includes(name));
2764
2851
  if (pending.length === 0) {
2765
2852
  return;
@@ -2789,21 +2876,38 @@ var Board = class _Board {
2789
2876
  await this.openAsksChannel();
2790
2877
  }
2791
2878
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2792
- await this.grantKnowledgeRemoval();
2879
+ await this.grantBesideKnowledge("remove_knowledge");
2793
2880
  }
2794
2881
  if (pending.includes(CHANNEL_CONVERSATIONS)) {
2795
2882
  await this.startChannelCursors();
2796
2883
  }
2884
+ if (pending.includes(DASHBOARD_UPDATES)) {
2885
+ await this.grantBesideKnowledge("update_dashboard");
2886
+ await this.rewordDashboards();
2887
+ }
2797
2888
  await writeJson(file, { applied: [...applied, ...pending] });
2798
2889
  }
2799
2890
  /**
2800
- * Whoever may write a scope's knowledge may remove it, so every charter granting
2801
- * `write_knowledge` is granted `remove_knowledge` once; seed roles have it from their seed.
2891
+ * Grants `verb` once to every charter granting `write_knowledge`, the work roles' charters in
2892
+ * practice; seed roles have it from their seed.
2802
2893
  */
2803
- async grantKnowledgeRemoval() {
2894
+ async grantBesideKnowledge(verb) {
2804
2895
  for (const charter of await this.listRoles()) {
2805
- if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes("remove_knowledge")) {
2806
- await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, "remove_knowledge"] }, { verbsAdded: ["remove_knowledge"] });
2896
+ if (charter.verbs.includes("write_knowledge") && !charter.verbs.includes(verb)) {
2897
+ await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
2898
+ }
2899
+ }
2900
+ }
2901
+ /** A dashboard still saying agents may edit its file says how it changes now. */
2902
+ async rewordDashboards() {
2903
+ for (const slug of await listDirs(this.paths.projects())) {
2904
+ const file = this.paths.dashboard(slug);
2905
+ if (!await exists(file)) {
2906
+ continue;
2907
+ }
2908
+ const { data, body } = await readLooseMarkdown(file);
2909
+ if (body.includes(OLD_DASHBOARD_LINE)) {
2910
+ await writeMarkdown(file, data, body.replace(OLD_DASHBOARD_LINE, DASHBOARD_LINE));
2807
2911
  }
2808
2912
  }
2809
2913
  }
@@ -3565,6 +3669,7 @@ ${charter.purpose}
3565
3669
  if (await exists(file)) {
3566
3670
  throw new BoardError("ALREADY_EXISTS", `project ${input.slug} already exists`);
3567
3671
  }
3672
+ assertLandsByPullRequest(input.onDone ?? "none", input.repo ?? null, `project ${input.slug}`);
3568
3673
  const project2 = ProjectSchema.parse({
3569
3674
  slug: input.slug,
3570
3675
  name: input.name ?? input.slug,
@@ -3585,9 +3690,9 @@ ${charter.purpose}
3585
3690
  await ensureDir(this.paths.tasks(project2.slug));
3586
3691
  await ensureDir(this.paths.threads(project2.slug));
3587
3692
  await ensureDir(this.paths.projectKnowledge(project2.slug));
3588
- await writeMarkdown(this.paths.dashboard(project2.slug), { project: project2.slug, updatedAt: this.now().toISOString() }, `# ${project2.name} dashboard
3693
+ await writeMarkdown(this.paths.dashboard(project2.slug), { project: project2.slug, revision: 0, updatedAt: this.now().toISOString() }, `# ${project2.name} dashboard
3589
3694
 
3590
- Agents may edit this file. It is rendered by the board UI.
3695
+ ${DASHBOARD_LINE}
3591
3696
  `);
3592
3697
  await this.updateAgent(USER_NAME, (user2) => ({
3593
3698
  ...user2,
@@ -3692,6 +3797,7 @@ Agents may edit this file. It is rendered by the board UI.
3692
3797
  ...patch.os === void 0 ? {} : { os: patch.os },
3693
3798
  ...patch.clis === void 0 ? {} : { clis: [...patch.clis] },
3694
3799
  ...patch.capabilities === void 0 ? {} : { capabilities: [...patch.capabilities] },
3800
+ ...patch.version === void 0 ? {} : { version: patch.version },
3695
3801
  lastSeen: this.now().toISOString()
3696
3802
  });
3697
3803
  await writeMarkdown(file, next, doc.body);
@@ -4166,6 +4272,42 @@ ${task2.body}`;
4166
4272
  throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project2}`);
4167
4273
  }
4168
4274
  }
4275
+ /**
4276
+ * Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
4277
+ * copy of the projection pulled when it started, so a revision other than the current one means
4278
+ * someone updated it since; the refusal carries the current text, which that copy lacks.
4279
+ */
4280
+ async updateDashboard(actor, input) {
4281
+ const args = VerbInputs.update_dashboard.parse(input);
4282
+ await this.authorize(actor, "update_dashboard");
4283
+ return this.mutex.run(async () => {
4284
+ const project2 = await this.readActiveProject(args.project);
4285
+ if (actor.role !== USER_ROLE && !project2.members.includes(actor.name)) {
4286
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${args.project}`);
4287
+ }
4288
+ const current = await this.dashboardOf(args.project);
4289
+ if (args.revision !== current.revision) {
4290
+ const by = current.updatedBy === void 0 ? "" : `, updated by ${current.updatedBy} at ${current.updatedAt ?? "an unknown time"}`;
4291
+ throw new BoardError("INVALID_STATE", `the dashboard of ${args.project} is at revision ${current.revision}${by}, not ${args.revision}. Merge your changes into its current text, below, and update again with revision ${current.revision}.
4292
+
4293
+ ${current.body}`);
4294
+ }
4295
+ const updated = DashboardSchema.parse({
4296
+ project: args.project,
4297
+ revision: current.revision + 1,
4298
+ updatedBy: actor.name,
4299
+ updatedAt: this.now().toISOString(),
4300
+ body: args.body
4301
+ });
4302
+ const { body, ...frontmatter } = updated;
4303
+ await writeMarkdown(this.paths.dashboard(args.project), frontmatter, body);
4304
+ await this.events.append("dashboard.updated", actor.name, {
4305
+ project: args.project,
4306
+ revision: updated.revision
4307
+ });
4308
+ return updated;
4309
+ });
4310
+ }
4169
4311
  async openThread(actor, input) {
4170
4312
  const args = VerbInputs.open_thread.parse(input);
4171
4313
  await this.authorize(actor, "open_thread");
@@ -4547,6 +4689,9 @@ ${args.summary}`);
4547
4689
  completedAt: ts
4548
4690
  };
4549
4691
  const next = current.stages[index + 1];
4692
+ if (next === void 0 && current.onDone === "ghpr" && current.pullRequest === void 0) {
4693
+ throw new BoardError("INVALID_STATE", `task ${current.id} lands through its pull request: link it with update_task pull_request before the last stage completes, or have the user, the steward, or the concierge set the task's on_done to none`);
4694
+ }
4550
4695
  if (next === void 0 && current.onDone === "none") {
4551
4696
  this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn2) => turn2.thread === current.id);
4552
4697
  }
@@ -4663,6 +4808,7 @@ ${args.summary}`);
4663
4808
  const guarded = gateChanges(ahead, replanned);
4664
4809
  if (onDone !== current.onDone) {
4665
4810
  guarded.push("change the completion effect");
4811
+ assertLandsByPullRequest(onDone, (await this.readProject(current.project)).repo, `project ${current.project}`);
4666
4812
  }
4667
4813
  if (guarded.length > 0) {
4668
4814
  this.assertMayGate(actor, guarded.join(", "));
@@ -4751,6 +4897,12 @@ ${args.summary}`);
4751
4897
  }
4752
4898
  next = { ...next, blockedBy: [...args.blocked_by] };
4753
4899
  }
4900
+ if (args.pull_request !== void 0) {
4901
+ next = {
4902
+ ...next,
4903
+ pullRequest: await this.pullRequestLink(actor, current, args.pull_request)
4904
+ };
4905
+ }
4754
4906
  if (args.note !== void 0) {
4755
4907
  const step = moved !== null ? { action: "returned", stage: moved.from, to: moved.to } : next.status === "abandoned" ? { action: "abandoned", stage: current.stage, to: null } : void 0;
4756
4908
  await this.postTaskNote(actor.name, current, args.note, step);
@@ -4776,12 +4928,46 @@ ${args.summary}`);
4776
4928
  project: location.project,
4777
4929
  from: current.status,
4778
4930
  to: task2.status,
4779
- note: args.note ?? null
4931
+ note: args.note ?? null,
4932
+ ...args.pull_request === void 0 ? {} : { pullRequest: task2.pullRequest?.url ?? null }
4780
4933
  });
4781
4934
  }
4782
4935
  return task2;
4783
4936
  });
4784
4937
  }
4938
+ /**
4939
+ * The pull request a task is linked to, or none for `null`: one on the repository of its project,
4940
+ * by a member of the project or a planner, while the task is in play and before it lands.
4941
+ */
4942
+ async pullRequestLink(actor, task2, link) {
4943
+ this.assertInPlay(task2);
4944
+ if (task2.completing) {
4945
+ throw new BoardError("INVALID_STATE", `task ${task2.id} is landing, so its pull request no longer changes`);
4946
+ }
4947
+ if (!PLANNING_ROLES.includes(actor.role)) {
4948
+ const agent2 = await this.readAgent(actor.name);
4949
+ if (!agent2.memberships.includes(task2.project)) {
4950
+ throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${task2.project}`);
4951
+ }
4952
+ }
4953
+ if (link === null) {
4954
+ return void 0;
4955
+ }
4956
+ const project2 = await this.readProject(task2.project);
4957
+ const repository = githubRepository(project2.repo);
4958
+ const named = parsePullRequestUrl(link.url);
4959
+ if (repository === null || named === null || !sameRepository(repository, named)) {
4960
+ throw new BoardError("VALIDATION", `${link.url} is not a pull request of ${project2.slug}'s repository${project2.repo === null ? ", since it has no remote" : ` at ${project2.repo}`}`);
4961
+ }
4962
+ return PullRequestLinkSchema.parse({
4963
+ url: link.url,
4964
+ number: named.number,
4965
+ ...link.merge_subject === void 0 ? {} : { subject: link.merge_subject },
4966
+ ...link.merge_body === void 0 ? {} : { body: link.merge_body },
4967
+ linkedBy: actor.name,
4968
+ linkedAt: this.now().toISOString()
4969
+ });
4970
+ }
4785
4971
  async getTask(actor, input) {
4786
4972
  const args = VerbInputs.get_task.parse(input);
4787
4973
  await this.authorize(actor, "get_task");
@@ -4892,6 +5078,9 @@ ${rationale}`}`);
4892
5078
  }
4893
5079
  return this.mutex.run(async () => {
4894
5080
  const previous = await this.readActiveProject(args.project);
5081
+ if (args.on_done !== void 0) {
5082
+ assertLandsByPullRequest(args.on_done, previous.repo, `project ${previous.slug}`);
5083
+ }
4895
5084
  const project2 = await this.updateProject(args.project, (current) => ({
4896
5085
  ...current,
4897
5086
  ...args.on_done === void 0 ? {} : { onDone: args.on_done },
@@ -5073,6 +5262,8 @@ ${rationale}`}`);
5073
5262
  return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5074
5263
  case "archive_channel":
5075
5264
  return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
5265
+ case "update_dashboard":
5266
+ return this.updateDashboard(actor, VerbInputs.update_dashboard.parse(input));
5076
5267
  default:
5077
5268
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
5078
5269
  }
@@ -5377,8 +5568,8 @@ ${rationale}`}`);
5377
5568
  }
5378
5569
  /**
5379
5570
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5380
- * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
5381
- * landed one announced in the project's general channel.
5571
+ * its participants can reshape the plan. A landing's outcome, a merge's or a pull request's, is
5572
+ * posted to the task's thread, and a landed one announced in the task's channel.
5382
5573
  */
5383
5574
  async finishCompletion(actor, input) {
5384
5575
  return this.mutex.run(async () => {
@@ -5388,8 +5579,8 @@ ${rationale}`}`);
5388
5579
  throw new BoardError("INVALID_STATE", `task ${current.id} is not completing`);
5389
5580
  }
5390
5581
  const ts = this.now().toISOString();
5391
- const merged = current.onDone === "merge";
5392
- if (merged) {
5582
+ const landed = current.onDone !== "none";
5583
+ if (landed) {
5393
5584
  await this.postTaskNote(actor.name, current, input.ok ? `${sentence(input.detail)}.` : `Landing failed: ${sentence(input.detail)}. The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
5394
5585
  }
5395
5586
  if (input.ok) {
@@ -5409,7 +5600,7 @@ ${rationale}`}`);
5409
5600
  for (const name of new Set(task3.stages.flatMap((stage) => stage.completedBy ?? []))) {
5410
5601
  await this.refreshMember(name);
5411
5602
  }
5412
- if (merged) {
5603
+ if (landed) {
5413
5604
  await this.appendMessage(actor.name, channelRef(location.project, task3.channel), `Task ${task3.id} "${task3.title}" is done: ${sentence(input.detail)}.`);
5414
5605
  }
5415
5606
  return task3;
@@ -5439,15 +5630,31 @@ ${rationale}`}`);
5439
5630
  async writeState(name, value) {
5440
5631
  await this.mutex.run(() => writeJson(path6.join(this.paths.state(), `${name}.json`), value));
5441
5632
  }
5442
- /** The project's editable dashboard: frontmatter plus markdown body. Empty when none exists. */
5633
+ /** The project's dashboard, empty at revision 0 when it has none. */
5443
5634
  async readDashboard(slug) {
5444
5635
  await this.readProject(slug);
5636
+ return this.dashboardOf(slug);
5637
+ }
5638
+ /**
5639
+ * Read loosely, since agents wrote dashboards by hand before they had a verb: a revision or an
5640
+ * author that does not parse counts as none.
5641
+ */
5642
+ async dashboardOf(slug) {
5445
5643
  const file = this.paths.dashboard(slug);
5446
5644
  if (!await exists(file)) {
5447
- return { data: { project: slug }, body: "" };
5645
+ return { project: slug, revision: 0, body: "" };
5448
5646
  }
5449
5647
  const { data, body } = await readLooseMarkdown(file);
5450
- return { data, body };
5648
+ const revision = z12.number().int().min(0).safeParse(data["revision"]);
5649
+ const updatedBy = NameSchema.safeParse(data["updatedBy"]);
5650
+ const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
5651
+ return {
5652
+ project: slug,
5653
+ revision: revision.success ? revision.data : 0,
5654
+ ...updatedBy.success ? { updatedBy: updatedBy.data } : {},
5655
+ ...updatedAt.success ? { updatedAt: updatedAt.data } : {},
5656
+ body
5657
+ };
5451
5658
  }
5452
5659
  async listRunners() {
5453
5660
  const runners = [];
@@ -6794,7 +7001,7 @@ runner.command("add <name>").description("Register a runner and print its token
6794
7001
  runner.command("list").description("List runners and what they offer").action(async () => {
6795
7002
  const board = await open();
6796
7003
  const runners = await board.listRunners();
6797
- print(runners, () => runners.length === 0 ? "No runners yet; add one with `runner add <name>`." : runners.map((each) => `${each.name} ${each.status} ${each.os} ${each.clis.join(",") || "no CLIs yet"}${each.capabilities.length === 0 ? "" : ` ${each.capabilities.join(",")}`}`).join("\n"));
7004
+ print(runners, () => runners.length === 0 ? "No runners yet; add one with `runner add <name>`." : runners.map((each) => `${each.name} ${each.status} ${each.os}${each.version === void 0 ? "" : ` v${each.version}`} ${each.clis.join(",") || "no CLIs yet"}${each.capabilities.length === 0 ? "" : ` ${each.capabilities.join(",")}`}`).join("\n"));
6798
7005
  });
6799
7006
  var agent = program.command("agent").description("Manage agents");
6800
7007
  agent.command("add <name>").description("Add an agent and print its board token once").requiredOption("--role <role>", "role charter name").option("--cli <cli>", "claude or codex").option("--model <model>", "model to run the CLI with; the CLI's own default otherwise").option("--runner <name>", "runner preferred for its turns; any with its CLI otherwise").option("-p, --project <slug...>", "project memberships").action(async (name, opts) => {