@desplega.ai/agent-swarm 1.56.2 → 1.56.4

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.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.55.0",
5
+ "version": "1.56.4",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -509,6 +509,10 @@
509
509
  "type": "string",
510
510
  "maxLength": 65536
511
511
  },
512
+ "heartbeatMd": {
513
+ "type": "string",
514
+ "maxLength": 65536
515
+ },
512
516
  "changeSource": {
513
517
  "type": "string"
514
518
  },
@@ -1911,6 +1915,27 @@
1911
1915
  }
1912
1916
  }
1913
1917
  },
1918
+ "/api/heartbeat/checklist": {
1919
+ "post": {
1920
+ "summary": "Trigger an immediate heartbeat checklist check",
1921
+ "tags": [
1922
+ "Heartbeat"
1923
+ ],
1924
+ "security": [
1925
+ {
1926
+ "bearerAuth": []
1927
+ }
1928
+ ],
1929
+ "responses": {
1930
+ "200": {
1931
+ "description": "Checklist check completed successfully"
1932
+ },
1933
+ "401": {
1934
+ "description": "Unauthorized"
1935
+ }
1936
+ }
1937
+ }
1938
+ },
1914
1939
  "/api/memory/index": {
1915
1940
  "post": {
1916
1941
  "summary": "Ingest content into memory system (async embedding)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.56.2",
3
+ "version": "1.56.4",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -1,6 +1,12 @@
1
1
  import { createTaskExtended, failTask, findTaskByVcs, getAllAgents } from "../be/db";
2
2
  import { resolveTemplate } from "../prompts/resolver";
3
- import { detectMention, extractMentionContext, GITHUB_BOT_NAME, isBotAssignee } from "./mentions";
3
+ import {
4
+ detectMention,
5
+ extractMentionContext,
6
+ GITHUB_BOT_NAME,
7
+ isBotAssignee,
8
+ isSwarmLabel,
9
+ } from "./mentions";
4
10
  import { addIssueReaction, addReaction } from "./reactions";
5
11
  // Side-effect import: registers all GitHub event templates in the in-memory registry
6
12
  import "./templates";
@@ -315,42 +321,32 @@ export async function handlePullRequest(
315
321
  return { created: false };
316
322
  }
317
323
 
318
- // Handle closed action - PR was merged or closed without merge
319
- if (action === "closed") {
320
- // Find the related task
321
- const task = findTaskByVcs(repository.full_name, pr.number);
322
- if (!task) {
323
- // No task for this PR, nothing to notify
324
+ // Handle labeled action - swarm label added to PR
325
+ if (action === "labeled") {
326
+ const labelName = event.label?.name;
327
+ if (!labelName || !isSwarmLabel(labelName)) {
324
328
  return { created: false };
325
329
  }
326
330
 
327
331
  // Deduplicate
328
- const eventKey = `pr-closed:${repository.full_name}:${pr.number}`;
332
+ const eventKey = `pr-labeled:${repository.full_name}:${pr.number}:${labelName}`;
329
333
  if (isDuplicate(eventKey)) {
330
334
  return { created: false };
331
335
  }
332
336
 
333
337
  const lead = findLeadAgent();
334
- const wasMerged = pr.merged;
335
- const emoji = wasMerged ? "🎉" : "❌";
336
- const status = wasMerged ? "MERGED" : "CLOSED";
337
- const mergedBy = wasMerged && pr.merged_by ? ` by ${pr.merged_by.login}` : "";
338
- const followUpSuggestion = wasMerged
339
- ? "💡 PR successfully merged! Update any related issues or documentation."
340
- : "💡 PR was closed without merging. Review if follow-up is needed.";
341
-
342
338
  const result = resolveTemplate(
343
- "github.pull_request.closed",
339
+ "github.pull_request.labeled",
344
340
  {
345
- status_emoji: emoji,
346
341
  pr_number: pr.number,
347
- status,
348
- merged_by: mergedBy,
349
342
  pr_title: pr.title,
343
+ label_name: labelName,
344
+ sender_login: sender.login,
350
345
  repo_full_name: repository.full_name,
346
+ head_ref: pr.head.ref,
347
+ base_ref: pr.base.ref,
351
348
  pr_url: pr.html_url,
352
- related_task_id: task.id,
353
- follow_up_suggestion: followUpSuggestion,
349
+ context: pr.body || pr.title,
354
350
  },
355
351
  { agentId: lead?.id, repoId: repository.full_name },
356
352
  );
@@ -359,11 +355,11 @@ export async function handlePullRequest(
359
355
  return { created: false };
360
356
  }
361
357
 
362
- const notifyTask = createTaskExtended(result.text, {
358
+ const task = createTaskExtended(result.text, {
363
359
  agentId: lead?.id ?? "",
364
360
  source: "github",
365
361
  vcsProvider: "github",
366
- taskType: "github-pr-status",
362
+ taskType: "github-pr",
367
363
  vcsRepo: repository.full_name,
368
364
  vcsEventType: "pull_request",
369
365
  vcsNumber: pr.number,
@@ -371,64 +367,37 @@ export async function handlePullRequest(
371
367
  vcsUrl: pr.html_url,
372
368
  });
373
369
 
374
- console.log(
375
- `[GitHub] Created task ${notifyTask.id} for PR #${pr.number} (${status}) -> ${lead?.name ?? "unassigned"}`,
376
- );
377
-
378
- return { created: true, taskId: notifyTask.id };
379
- }
380
-
381
- // Handle synchronize action - new commits pushed to PR
382
- if (action === "synchronize") {
383
- // Find the related task
384
- const task = findTaskByVcs(repository.full_name, pr.number);
385
- if (!task) {
386
- // No task for this PR, nothing to notify
387
- return { created: false };
388
- }
389
-
390
- // Deduplicate using SHA to avoid duplicate notifications for same push
391
- const eventKey = `pr-sync:${repository.full_name}:${pr.number}:${pr.head.sha}`;
392
- if (isDuplicate(eventKey)) {
393
- return { created: false };
370
+ if (lead) {
371
+ console.log(
372
+ `[GitHub] Created task ${task.id} for PR #${pr.number} (labeled: ${labelName}) -> ${lead.name}`,
373
+ );
374
+ } else {
375
+ console.log(
376
+ `[GitHub] Created unassigned task ${task.id} for PR #${pr.number} (labeled: ${labelName}, no lead available)`,
377
+ );
394
378
  }
395
379
 
396
- const lead = findLeadAgent();
397
- const result = resolveTemplate(
398
- "github.pull_request.synchronize",
399
- {
400
- pr_number: pr.number,
401
- pr_title: pr.title,
402
- repo_full_name: repository.full_name,
403
- head_ref: pr.head.ref,
404
- head_sha_short: pr.head.sha.substring(0, 7),
405
- pr_url: pr.html_url,
406
- related_task_id: task.id,
407
- },
408
- { agentId: lead?.id, repoId: repository.full_name },
409
- );
410
-
411
- if (result.skipped) {
412
- return { created: false };
380
+ if (installation?.id) {
381
+ addIssueReaction(repository.full_name, pr.number, "eyes", installation.id);
413
382
  }
414
383
 
415
- const notifyTask = createTaskExtended(result.text, {
416
- agentId: lead?.id ?? "",
417
- source: "github",
418
- vcsProvider: "github",
419
- taskType: "github-pr-update",
420
- vcsRepo: repository.full_name,
421
- vcsEventType: "pull_request",
422
- vcsNumber: pr.number,
423
- vcsAuthor: sender.login,
424
- vcsUrl: pr.html_url,
425
- });
384
+ return { created: true, taskId: task.id };
385
+ }
426
386
 
387
+ // Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
388
+ if (action === "closed") {
427
389
  console.log(
428
- `[GitHub] Created task ${notifyTask.id} for PR #${pr.number} (synchronize) -> ${lead?.name ?? "unassigned"}`,
390
+ `[GitHub:suppressed] pull_request.closed on ${repository.full_name}#${pr.number} lifecycle events disabled by default`,
429
391
  );
392
+ return { created: false };
393
+ }
430
394
 
431
- return { created: true, taskId: notifyTask.id };
395
+ // Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
396
+ if (action === "synchronize") {
397
+ console.log(
398
+ `[GitHub:suppressed] pull_request.synchronize on ${repository.full_name}#${pr.number} — lifecycle events disabled by default`,
399
+ );
400
+ return { created: false };
432
401
  }
433
402
 
434
403
  // Only handle opened/edited actions for mention-based flow
@@ -595,6 +564,67 @@ export async function handleIssue(
595
564
  return { created: false };
596
565
  }
597
566
 
567
+ // Handle labeled action - swarm label added to issue
568
+ if (action === "labeled") {
569
+ const labelName = event.label?.name;
570
+ if (!labelName || !isSwarmLabel(labelName)) {
571
+ return { created: false };
572
+ }
573
+
574
+ // Deduplicate
575
+ const eventKey = `issue-labeled:${repository.full_name}:${issue.number}:${labelName}`;
576
+ if (isDuplicate(eventKey)) {
577
+ return { created: false };
578
+ }
579
+
580
+ const lead = findLeadAgent();
581
+ const result = resolveTemplate(
582
+ "github.issue.labeled",
583
+ {
584
+ issue_number: issue.number,
585
+ issue_title: issue.title,
586
+ label_name: labelName,
587
+ sender_login: sender.login,
588
+ repo_full_name: repository.full_name,
589
+ issue_url: issue.html_url,
590
+ context: issue.body || issue.title,
591
+ },
592
+ { agentId: lead?.id, repoId: repository.full_name },
593
+ );
594
+
595
+ if (result.skipped) {
596
+ return { created: false };
597
+ }
598
+
599
+ const task = createTaskExtended(result.text, {
600
+ agentId: lead?.id ?? "",
601
+ source: "github",
602
+ vcsProvider: "github",
603
+ taskType: "github-issue",
604
+ vcsRepo: repository.full_name,
605
+ vcsEventType: "issues",
606
+ vcsNumber: issue.number,
607
+ vcsAuthor: sender.login,
608
+ vcsUrl: issue.html_url,
609
+ });
610
+
611
+ if (lead) {
612
+ console.log(
613
+ `[GitHub] Created task ${task.id} for issue #${issue.number} (labeled: ${labelName}) -> ${lead.name}`,
614
+ );
615
+ } else {
616
+ console.log(
617
+ `[GitHub] Created unassigned task ${task.id} for issue #${issue.number} (labeled: ${labelName}, no lead available)`,
618
+ );
619
+ }
620
+
621
+ if (installation?.id) {
622
+ addIssueReaction(repository.full_name, issue.number, "eyes", installation.id);
623
+ }
624
+
625
+ return { created: true, taskId: task.id };
626
+ }
627
+
598
628
  // Only handle opened/edited actions for mention-based flow
599
629
  if (action !== "opened" && action !== "edited") {
600
630
  return { created: false };
@@ -879,89 +909,12 @@ export async function handleCheckRun(
879
909
  ): Promise<{ created: boolean; taskId?: string }> {
880
910
  const { action, check_run, repository } = event;
881
911
 
882
- // Only handle completed check runs
883
- if (action !== "completed") {
884
- return { created: false };
885
- }
886
-
887
- // Only notify on failure or action_required - success is less critical
888
- // Skip neutral/skipped/cancelled as they're usually not actionable
889
- const conclusion = check_run.conclusion;
890
- if (conclusion !== "failure" && conclusion !== "action_required") {
891
- return { created: false };
892
- }
893
-
894
- // Must be associated with at least one PR
895
- if (!check_run.pull_requests || check_run.pull_requests.length === 0) {
896
- return { created: false };
897
- }
898
-
899
- // Check if we have a task for any of these PRs
900
- let relatedTask = null;
901
- let prNumber = 0;
902
- for (const pr of check_run.pull_requests) {
903
- const task = findTaskByVcs(repository.full_name, pr.number);
904
- if (task) {
905
- relatedTask = task;
906
- prNumber = pr.number;
907
- break;
908
- }
909
- }
910
-
911
- if (!relatedTask) {
912
- // No task for any of the associated PRs
913
- return { created: false };
914
- }
915
-
916
- // Deduplicate
917
- const eventKey = `check-run:${repository.full_name}:${check_run.id}`;
918
- if (isDuplicate(eventKey)) {
919
- return { created: false };
920
- }
921
-
922
- const lead = findLeadAgent();
923
- const { emoji, label } = getCheckConclusionInfo(conclusion);
924
-
925
- const outputSummarySection = check_run.output.summary
926
- ? `\n\nSummary:\n${check_run.output.summary.substring(0, 500)}`
927
- : "";
928
-
929
- const result = resolveTemplate(
930
- "github.check_run.failed",
931
- {
932
- conclusion_emoji: emoji,
933
- pr_number: prNumber,
934
- check_name: check_run.name,
935
- conclusion_label: label,
936
- repo_full_name: repository.full_name,
937
- check_url: check_run.html_url,
938
- output_summary_section: outputSummarySection,
939
- related_task_id: relatedTask.id,
940
- },
941
- { agentId: lead?.id, repoId: repository.full_name },
942
- );
943
-
944
- if (result.skipped) {
945
- return { created: false };
946
- }
947
-
948
- const task = createTaskExtended(result.text, {
949
- agentId: lead?.id ?? "",
950
- source: "github",
951
- vcsProvider: "github",
952
- taskType: "github-ci",
953
- vcsRepo: repository.full_name,
954
- vcsEventType: "check_run",
955
- vcsNumber: prNumber,
956
- vcsAuthor: "",
957
- vcsUrl: check_run.html_url,
958
- });
959
-
912
+ // Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
913
+ const conclusion = check_run.conclusion ?? "unknown";
960
914
  console.log(
961
- `[GitHub] Created task ${task.id} for check_run ${check_run.name} (${conclusion}) on PR #${prNumber} -> ${lead?.name ?? "unassigned"}`,
915
+ `[GitHub:suppressed] check_run.${action} (${conclusion}) on ${repository.full_name} CI events disabled by default`,
962
916
  );
963
-
964
- return { created: true, taskId: task.id };
917
+ return { created: false };
965
918
  }
966
919
 
967
920
  /**
@@ -974,84 +927,12 @@ export async function handleCheckSuite(
974
927
  ): Promise<{ created: boolean; taskId?: string }> {
975
928
  const { action, check_suite, repository } = event;
976
929
 
977
- // Only handle completed check suites
978
- if (action !== "completed") {
979
- return { created: false };
980
- }
981
-
982
- // Only notify on failure - success notifications would be too noisy
983
- const conclusion = check_suite.conclusion;
984
- if (conclusion !== "failure") {
985
- return { created: false };
986
- }
987
-
988
- // Must be associated with at least one PR
989
- if (!check_suite.pull_requests || check_suite.pull_requests.length === 0) {
990
- return { created: false };
991
- }
992
-
993
- // Check if we have a task for any of these PRs
994
- let relatedTask = null;
995
- let prNumber = 0;
996
- for (const pr of check_suite.pull_requests) {
997
- const task = findTaskByVcs(repository.full_name, pr.number);
998
- if (task) {
999
- relatedTask = task;
1000
- prNumber = pr.number;
1001
- break;
1002
- }
1003
- }
1004
-
1005
- if (!relatedTask) {
1006
- // No task for any of the associated PRs
1007
- return { created: false };
1008
- }
1009
-
1010
- // Deduplicate
1011
- const eventKey = `check-suite:${repository.full_name}:${check_suite.id}`;
1012
- if (isDuplicate(eventKey)) {
1013
- return { created: false };
1014
- }
1015
-
1016
- const lead = findLeadAgent();
1017
- const { emoji, label } = getCheckConclusionInfo(conclusion);
1018
- const branch = check_suite.head_branch ?? "unknown";
1019
-
1020
- const result = resolveTemplate(
1021
- "github.check_suite.failed",
1022
- {
1023
- conclusion_emoji: emoji,
1024
- pr_number: prNumber,
1025
- conclusion_label: label,
1026
- repo_full_name: repository.full_name,
1027
- branch,
1028
- head_sha_short: check_suite.head_sha.substring(0, 7),
1029
- related_task_id: relatedTask.id,
1030
- },
1031
- { agentId: lead?.id, repoId: repository.full_name },
1032
- );
1033
-
1034
- if (result.skipped) {
1035
- return { created: false };
1036
- }
1037
-
1038
- const task = createTaskExtended(result.text, {
1039
- agentId: lead?.id ?? "",
1040
- source: "github",
1041
- vcsProvider: "github",
1042
- taskType: "github-ci",
1043
- vcsRepo: repository.full_name,
1044
- vcsEventType: "check_suite",
1045
- vcsNumber: prNumber,
1046
- vcsAuthor: "",
1047
- vcsUrl: repository.html_url,
1048
- });
1049
-
930
+ // Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
931
+ const conclusion = check_suite.conclusion ?? "unknown";
1050
932
  console.log(
1051
- `[GitHub] Created task ${task.id} for check_suite (${conclusion}) on PR #${prNumber} -> ${lead?.name ?? "unassigned"}`,
933
+ `[GitHub:suppressed] check_suite.${action} (${conclusion}) on ${repository.full_name} CI events disabled by default`,
1052
934
  );
1053
-
1054
- return { created: true, taskId: task.id };
935
+ return { created: false };
1055
936
  }
1056
937
 
1057
938
  /**
@@ -1065,87 +946,12 @@ export async function handleCheckSuite(
1065
946
  export async function handleWorkflowRun(
1066
947
  event: WorkflowRunEvent,
1067
948
  ): Promise<{ created: boolean; taskId?: string }> {
1068
- const { action, workflow_run, workflow, repository } = event;
1069
-
1070
- // Only handle completed workflow runs
1071
- if (action !== "completed") {
1072
- return { created: false };
1073
- }
1074
-
1075
- // Only notify on failure - success notifications would be too noisy
1076
- const conclusion = workflow_run.conclusion;
1077
- if (conclusion !== "failure") {
1078
- return { created: false };
1079
- }
1080
-
1081
- // Must be associated with at least one PR
1082
- if (!workflow_run.pull_requests || workflow_run.pull_requests.length === 0) {
1083
- return { created: false };
1084
- }
1085
-
1086
- // Check if we have a task for any of these PRs
1087
- let relatedTask = null;
1088
- let prNumber = 0;
1089
- for (const pr of workflow_run.pull_requests) {
1090
- const task = findTaskByVcs(repository.full_name, pr.number);
1091
- if (task) {
1092
- relatedTask = task;
1093
- prNumber = pr.number;
1094
- break;
1095
- }
1096
- }
1097
-
1098
- if (!relatedTask) {
1099
- // No task for any of the associated PRs
1100
- return { created: false };
1101
- }
1102
-
1103
- // Deduplicate
1104
- const eventKey = `workflow-run:${repository.full_name}:${workflow_run.id}`;
1105
- if (isDuplicate(eventKey)) {
1106
- return { created: false };
1107
- }
1108
-
1109
- const lead = findLeadAgent();
1110
- const { emoji, label } = getCheckConclusionInfo(conclusion);
1111
-
1112
- const result = resolveTemplate(
1113
- "github.workflow_run.failed",
1114
- {
1115
- conclusion_emoji: emoji,
1116
- pr_number: prNumber,
1117
- workflow_run_name: workflow_run.name,
1118
- conclusion_label: label,
1119
- repo_full_name: repository.full_name,
1120
- workflow_name: workflow.name,
1121
- run_number: workflow_run.run_number,
1122
- head_branch: workflow_run.head_branch,
1123
- trigger_event: workflow_run.event,
1124
- logs_url: workflow_run.html_url,
1125
- related_task_id: relatedTask.id,
1126
- },
1127
- { agentId: lead?.id, repoId: repository.full_name },
1128
- );
1129
-
1130
- if (result.skipped) {
1131
- return { created: false };
1132
- }
1133
-
1134
- const task = createTaskExtended(result.text, {
1135
- agentId: lead?.id ?? "",
1136
- source: "github",
1137
- vcsProvider: "github",
1138
- taskType: "github-ci",
1139
- vcsRepo: repository.full_name,
1140
- vcsEventType: "workflow_run",
1141
- vcsNumber: prNumber,
1142
- vcsAuthor: "",
1143
- vcsUrl: workflow_run.html_url,
1144
- });
949
+ const { action, workflow_run, repository } = event;
1145
950
 
951
+ // Suppressed: see thoughts/taras/plans/2026-03-30-github-event-safety-defaults.md
952
+ const conclusion = workflow_run.conclusion ?? "unknown";
1146
953
  console.log(
1147
- `[GitHub] Created task ${task.id} for workflow_run "${workflow_run.name}" (${conclusion}) on PR #${prNumber} -> ${lead?.name ?? "unassigned"}`,
954
+ `[GitHub:suppressed] workflow_run.${action} (${conclusion}) on ${repository.full_name} CI events disabled by default`,
1148
955
  );
1149
-
1150
- return { created: true, taskId: task.id };
956
+ return { created: false };
1151
957
  }
@@ -17,7 +17,14 @@ export {
17
17
  handlePullRequestReview,
18
18
  handleWorkflowRun,
19
19
  } from "./handlers";
20
- export { detectMention, extractMentionContext, GITHUB_BOT_NAME, isBotAssignee } from "./mentions";
20
+ export {
21
+ detectMention,
22
+ extractMentionContext,
23
+ GITHUB_BOT_NAME,
24
+ GITHUB_EVENT_LABELS,
25
+ isBotAssignee,
26
+ isSwarmLabel,
27
+ } from "./mentions";
21
28
  export type { ReactionType } from "./reactions";
22
29
  export { addIssueReaction, addReaction, postComment } from "./reactions";
23
30
  export type {
@@ -1,6 +1,16 @@
1
1
  // Bot name for @mentions (can be overridden via env)
2
2
  export const GITHUB_BOT_NAME = process.env.GITHUB_BOT_NAME || "agent-swarm-bot";
3
3
 
4
+ // Labels that trigger agent action on PR/issue label events (comma-separated env var)
5
+ const GITHUB_EVENT_LABELS_RAW = process.env.GITHUB_EVENT_LABELS || "swarm-review";
6
+ export const GITHUB_EVENT_LABELS: string[] = GITHUB_EVENT_LABELS_RAW.split(",")
7
+ .map((l) => l.trim().toLowerCase())
8
+ .filter(Boolean);
9
+
10
+ export function isSwarmLabel(label: string): boolean {
11
+ return GITHUB_EVENT_LABELS.includes(label.toLowerCase());
12
+ }
13
+
4
14
  // Additional aliases that also trigger the bot (comma-separated env var)
5
15
  // Example: GITHUB_BOT_ALIASES=heysidekick,sidekick,review-bot
6
16
  function computeBotNames(): string[] {
@@ -197,6 +197,35 @@ Context:
197
197
  category: "event",
198
198
  });
199
199
 
200
+ registerTemplate({
201
+ eventType: "github.pull_request.labeled",
202
+ header: "[GitHub PR #{{pr_number}}] {{pr_title}}",
203
+ defaultBody: `Label added: {{label_name}}
204
+ From: {{sender_login}}
205
+ Repo: {{repo_full_name}}
206
+ Branch: {{head_ref}} → {{base_ref}}
207
+ URL: {{pr_url}}
208
+
209
+ Context:
210
+ {{context}}
211
+
212
+ ---
213
+ {{@template[common.delegation_instruction]}}
214
+ {{@template[common.command_suggestions.github_pr]}}`,
215
+ variables: [
216
+ { name: "pr_number", description: "Pull request number" },
217
+ { name: "pr_title", description: "Pull request title" },
218
+ { name: "label_name", description: "Label that was added" },
219
+ { name: "sender_login", description: "Event sender login" },
220
+ { name: "repo_full_name", description: "Repository full name (owner/repo)" },
221
+ { name: "head_ref", description: "Head branch name" },
222
+ { name: "base_ref", description: "Base branch name" },
223
+ { name: "pr_url", description: "Pull request HTML URL" },
224
+ { name: "context", description: "PR body or title as context" },
225
+ ],
226
+ category: "event",
227
+ });
228
+
200
229
  // ============================================================================
201
230
  // Issue events
202
231
  // ============================================================================
@@ -251,6 +280,32 @@ Context:
251
280
  category: "event",
252
281
  });
253
282
 
283
+ registerTemplate({
284
+ eventType: "github.issue.labeled",
285
+ header: "[GitHub Issue #{{issue_number}}] {{issue_title}}",
286
+ defaultBody: `Label added: {{label_name}}
287
+ From: {{sender_login}}
288
+ Repo: {{repo_full_name}}
289
+ URL: {{issue_url}}
290
+
291
+ Context:
292
+ {{context}}
293
+
294
+ ---
295
+ {{@template[common.delegation_instruction]}}
296
+ {{@template[common.command_suggestions.github_issue]}}`,
297
+ variables: [
298
+ { name: "issue_number", description: "Issue number" },
299
+ { name: "issue_title", description: "Issue title" },
300
+ { name: "label_name", description: "Label that was added" },
301
+ { name: "sender_login", description: "Event sender login" },
302
+ { name: "repo_full_name", description: "Repository full name (owner/repo)" },
303
+ { name: "issue_url", description: "Issue HTML URL" },
304
+ { name: "context", description: "Issue body or title as context" },
305
+ ],
306
+ category: "event",
307
+ });
308
+
254
309
  // ============================================================================
255
310
  // Comment events
256
311
  // ============================================================================
@@ -20,6 +20,7 @@ export interface PullRequestEvent extends GitHubWebhookEvent {
20
20
  changed_files?: number;
21
21
  };
22
22
  requested_reviewer?: { login: string; id: number }; // Added for review_requested/review_request_removed events
23
+ label?: { id: number; name: string; color: string }; // Added for labeled/unlabeled events
23
24
  }
24
25
 
25
26
  export interface IssueEvent extends GitHubWebhookEvent {
@@ -30,6 +31,7 @@ export interface IssueEvent extends GitHubWebhookEvent {
30
31
  html_url: string;
31
32
  user: { login: string };
32
33
  };
34
+ label?: { id: number; name: string; color: string }; // Added for labeled/unlabeled events
33
35
  }
34
36
 
35
37
  export interface CommentEvent extends GitHubWebhookEvent {
package/src/hooks/hook.ts CHANGED
@@ -14,6 +14,7 @@ const CLAUDE_MD_BACKUP_PATH = `${process.env.HOME}/.claude/CLAUDE.md.bak`;
14
14
  const SOUL_MD_PATH = "/workspace/SOUL.md";
15
15
  const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
16
16
  const TOOLS_MD_PATH = "/workspace/TOOLS.md";
17
+ const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
17
18
  const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
18
19
 
19
20
  type McpServerConfig = {
@@ -389,6 +390,14 @@ export async function handleHook(): Promise<void> {
389
390
  }
390
391
  }
391
392
 
393
+ const heartbeatFile = Bun.file(HEARTBEAT_MD_PATH);
394
+ if (await heartbeatFile.exists()) {
395
+ const content = await heartbeatFile.text();
396
+ if (content.length <= 65536) {
397
+ updates.heartbeatMd = content;
398
+ }
399
+ }
400
+
392
401
  if (Object.keys(updates).length === 0) return;
393
402
 
394
403
  try {
@@ -910,7 +919,8 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
910
919
  if (
911
920
  editedPath === SOUL_MD_PATH ||
912
921
  editedPath === IDENTITY_MD_PATH ||
913
- editedPath === TOOLS_MD_PATH
922
+ editedPath === TOOLS_MD_PATH ||
923
+ editedPath === HEARTBEAT_MD_PATH
914
924
  ) {
915
925
  await syncIdentityFilesToServer(agentInfo.id, "self_edit");
916
926
  }
@@ -77,6 +77,10 @@ Available Slack tools:
77
77
  - \`slack-read\`: Read thread/channel history (use taskId or channelId)
78
78
  - \`slack-list-channels\`: Discover available Slack channels the bot can access
79
79
 
80
+ #### Identity & profile tools
81
+
82
+ - \`update-profile\`: Update your own profile fields (name, role, capabilities, soulMd, identityMd, heartbeatMd, claudeMd, toolsMd, setupScript). As lead, you can also update other agents' profiles to shape their behavior.
83
+
80
84
  #### General monitor and control tools
81
85
 
82
86
  - \`get-swarm\`: Get the list of all workers in the swarm along with their status
@@ -226,6 +230,37 @@ Task: {describe what needs to be done}
226
230
  - Complex feature/major refactor → Use PLANNING first, then IMPLEMENTATION
227
231
  - Bug fix/small code change → Use QUICK FIX template
228
232
  - Non-code task/question → Use GENERAL template
233
+
234
+ #### Heartbeat Checklist
235
+
236
+ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has content (not just
237
+ comments or empty lines), it creates a \`heartbeat-checklist\` task for you containing:
238
+ 1. **Auto-generated system status** — task counts, stalled tasks, agent health, idle workers, unassigned work
239
+ 2. **Your standing orders** — whatever you wrote in HEARTBEAT.md
240
+
241
+ **How to configure:**
242
+ - **Edit the file directly:** Open \`/workspace/HEARTBEAT.md\` and write your standing orders. Changes sync to the database automatically on save.
243
+ - **Use \`update-profile\`:** Call \`update-profile\` with the \`heartbeatMd\` field set to your checklist content. This updates both the database and the file.
244
+
245
+ **What to put in HEARTBEAT.md** — a plain markdown list of actionable standing orders:
246
+ \`\`\`markdown
247
+ - Check Slack for unaddressed requests older than 1 hour
248
+ - Review active tasks for any that seem stuck or need follow-up
249
+ - If idle workers exist and unassigned tasks are available, investigate why
250
+ - Post a daily summary to #agent-status at 5pm
251
+ \`\`\`
252
+
253
+ **Key mechanics:**
254
+ - **Empty = disabled** — leave HEARTBEAT.md empty (or all HTML comments) to skip checks at zero LLM cost
255
+ - **System status is automatic** — don't gather it yourself, it's injected into every checklist task
256
+ - **Don't create checklist tasks yourself** — the system handles scheduling. Complete your current one and the next arrives in ~30 minutes
257
+ - **Boot triage** — after a server restart, you get a one-time higher-priority checklist task within 30 seconds
258
+
259
+ **When you receive a checklist task:**
260
+ 1. Review the system status for anything that needs attention
261
+ 2. Review your standing orders for any periodic checks or actions due
262
+ 3. If something needs action — do it now (create tasks, post to Slack, etc.)
263
+ 4. If everything is healthy — complete the task with a brief "All clear" summary
229
264
  `,
230
265
  variables: [],
231
266
  category: "system",
@@ -203,11 +203,11 @@ describe("Concurrency Integration Tests", () => {
203
203
  isLead: false,
204
204
  status: "idle",
205
205
  capabilities: [],
206
- maxTasks: 20, // Max allowed by schema
206
+ maxTasks: 100, // Max allowed by schema
207
207
  });
208
208
 
209
- expect(agent.maxTasks).toBe(20);
210
- expect(getRemainingCapacity(agent.id)).toBe(20);
209
+ expect(agent.maxTasks).toBe(100);
210
+ expect(getRemainingCapacity(agent.id)).toBe(100);
211
211
  });
212
212
  });
213
213
  });
@@ -0,0 +1,383 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, createAgent, initDb } from "../be/db";
4
+ import {
5
+ handleCheckRun,
6
+ handleCheckSuite,
7
+ handleComment,
8
+ handleIssue,
9
+ handlePullRequest,
10
+ handlePullRequestReview,
11
+ handleWorkflowRun,
12
+ } from "../github/handlers";
13
+ import { GITHUB_BOT_NAME } from "../github/mentions";
14
+ import type {
15
+ CheckRunEvent,
16
+ CheckSuiteEvent,
17
+ CommentEvent,
18
+ IssueEvent,
19
+ PullRequestEvent,
20
+ PullRequestReviewEvent,
21
+ WorkflowRunEvent,
22
+ } from "../github/types";
23
+
24
+ const TEST_DB_PATH = "./test-github-event-filter.sqlite";
25
+
26
+ // ── Setup ──
27
+
28
+ beforeAll(async () => {
29
+ await unlink(TEST_DB_PATH).catch(() => {});
30
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
31
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
32
+ initDb(TEST_DB_PATH);
33
+ // Create a lead agent so handlers can assign tasks
34
+ createAgent({
35
+ id: "lead-gh-001",
36
+ name: "GitHubTestLead",
37
+ status: "idle",
38
+ isLead: true,
39
+ });
40
+ });
41
+
42
+ afterAll(async () => {
43
+ closeDb();
44
+ await unlink(TEST_DB_PATH).catch(() => {});
45
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
46
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
47
+ });
48
+
49
+ // ── Helpers ──
50
+
51
+ const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
52
+ const BASE_SENDER = { login: "testuser" };
53
+ const BASE_PR = {
54
+ number: 1,
55
+ title: "Test PR",
56
+ body: null as string | null,
57
+ html_url: "https://github.com/test/repo/pull/1",
58
+ user: { login: "testuser" },
59
+ head: { ref: "feature", sha: "abc1234567890" },
60
+ base: { ref: "main" },
61
+ merged: false,
62
+ merged_by: undefined,
63
+ };
64
+
65
+ function makePREvent(overrides: Partial<PullRequestEvent> = {}): PullRequestEvent {
66
+ return {
67
+ action: "opened",
68
+ pull_request: BASE_PR,
69
+ repository: BASE_REPO,
70
+ sender: BASE_SENDER,
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ function makeIssueEvent(overrides: Partial<IssueEvent> = {}): IssueEvent {
76
+ return {
77
+ action: "opened",
78
+ issue: {
79
+ number: 10,
80
+ title: "Test Issue",
81
+ body: null,
82
+ html_url: "https://github.com/test/repo/issues/10",
83
+ user: { login: "testuser" },
84
+ },
85
+ repository: BASE_REPO,
86
+ sender: BASE_SENDER,
87
+ ...overrides,
88
+ };
89
+ }
90
+
91
+ // ── Suppressed events ──
92
+
93
+ describe("suppressed cascade events", () => {
94
+ test("pull_request.closed returns created: false", async () => {
95
+ const result = await handlePullRequest(makePREvent({ action: "closed" }));
96
+ expect(result.created).toBe(false);
97
+ });
98
+
99
+ test("pull_request.synchronize returns created: false", async () => {
100
+ const result = await handlePullRequest(makePREvent({ action: "synchronize" }));
101
+ expect(result.created).toBe(false);
102
+ });
103
+
104
+ test("pull_request_review.submitted returns created: false when bot is not PR author and no existing task", async () => {
105
+ const event: PullRequestReviewEvent = {
106
+ action: "submitted",
107
+ review: {
108
+ id: 1,
109
+ body: "Looks good",
110
+ state: "approved",
111
+ html_url: "https://github.com/test/repo/pull/1#pullrequestreview-1",
112
+ user: { login: "reviewer" },
113
+ submitted_at: "2026-01-01T00:00:00Z",
114
+ },
115
+ pull_request: {
116
+ number: 1,
117
+ title: "Test PR",
118
+ body: null,
119
+ html_url: "https://github.com/test/repo/pull/1",
120
+ user: { login: "testuser" },
121
+ head: { ref: "feature" },
122
+ base: { ref: "main" },
123
+ },
124
+ repository: BASE_REPO,
125
+ sender: { login: "reviewer" },
126
+ };
127
+ const result = await handlePullRequestReview(event);
128
+ expect(result.created).toBe(false);
129
+ });
130
+
131
+ test("pull_request_review.submitted creates task when bot is PR author", async () => {
132
+ const event: PullRequestReviewEvent = {
133
+ action: "submitted",
134
+ review: {
135
+ id: 2,
136
+ body: "LGTM",
137
+ state: "approved",
138
+ html_url: "https://github.com/test/repo/pull/99#pullrequestreview-2",
139
+ user: { login: "reviewer" },
140
+ submitted_at: "2026-01-01T00:00:00Z",
141
+ },
142
+ pull_request: {
143
+ number: 99,
144
+ title: "Bot PR",
145
+ body: null,
146
+ html_url: "https://github.com/test/repo/pull/99",
147
+ user: { login: GITHUB_BOT_NAME },
148
+ head: { ref: "bot-feature" },
149
+ base: { ref: "main" },
150
+ },
151
+ repository: BASE_REPO,
152
+ sender: { login: "reviewer" },
153
+ };
154
+ const result = await handlePullRequestReview(event);
155
+ expect(result.created).toBe(true);
156
+ expect(result.taskId).toBeDefined();
157
+ });
158
+
159
+ test("pull_request_review.edited is ignored", async () => {
160
+ const event: PullRequestReviewEvent = {
161
+ action: "edited",
162
+ review: {
163
+ id: 3,
164
+ body: "Updated review",
165
+ state: "approved",
166
+ html_url: "https://github.com/test/repo/pull/99#pullrequestreview-3",
167
+ user: { login: "reviewer" },
168
+ submitted_at: "2026-01-01T00:00:00Z",
169
+ },
170
+ pull_request: {
171
+ number: 99,
172
+ title: "Bot PR",
173
+ body: null,
174
+ html_url: "https://github.com/test/repo/pull/99",
175
+ user: { login: GITHUB_BOT_NAME },
176
+ head: { ref: "bot-feature" },
177
+ base: { ref: "main" },
178
+ },
179
+ repository: BASE_REPO,
180
+ sender: { login: "reviewer" },
181
+ };
182
+ const result = await handlePullRequestReview(event);
183
+ expect(result.created).toBe(false);
184
+ });
185
+
186
+ test("pull_request_review.submitted with empty commented review is ignored", async () => {
187
+ const event: PullRequestReviewEvent = {
188
+ action: "submitted",
189
+ review: {
190
+ id: 4,
191
+ body: "",
192
+ state: "commented",
193
+ html_url: "https://github.com/test/repo/pull/99#pullrequestreview-4",
194
+ user: { login: "reviewer" },
195
+ submitted_at: "2026-01-01T00:00:00Z",
196
+ },
197
+ pull_request: {
198
+ number: 99,
199
+ title: "Bot PR",
200
+ body: null,
201
+ html_url: "https://github.com/test/repo/pull/99",
202
+ user: { login: GITHUB_BOT_NAME },
203
+ head: { ref: "bot-feature" },
204
+ base: { ref: "main" },
205
+ },
206
+ repository: BASE_REPO,
207
+ sender: { login: "reviewer" },
208
+ };
209
+ const result = await handlePullRequestReview(event);
210
+ expect(result.created).toBe(false);
211
+ });
212
+
213
+ test("check_run.completed with failure returns created: false", async () => {
214
+ const event: CheckRunEvent = {
215
+ action: "completed",
216
+ check_run: {
217
+ id: 1,
218
+ name: "ci/test",
219
+ status: "completed",
220
+ conclusion: "failure",
221
+ html_url: "https://github.com/test/repo/runs/1",
222
+ started_at: "2026-01-01T00:00:00Z",
223
+ completed_at: "2026-01-01T00:01:00Z",
224
+ output: { title: null, summary: null },
225
+ check_suite: { id: 1, head_sha: "abc1234" },
226
+ pull_requests: [{ number: 1, head: { sha: "abc1234" } }],
227
+ },
228
+ repository: BASE_REPO,
229
+ sender: BASE_SENDER,
230
+ };
231
+ const result = await handleCheckRun(event);
232
+ expect(result.created).toBe(false);
233
+ });
234
+
235
+ test("check_suite.completed with failure returns created: false", async () => {
236
+ const event: CheckSuiteEvent = {
237
+ action: "completed",
238
+ check_suite: {
239
+ id: 1,
240
+ status: "completed",
241
+ conclusion: "failure",
242
+ head_sha: "abc1234567890",
243
+ head_branch: "feature",
244
+ pull_requests: [{ number: 1, head: { sha: "abc1234" } }],
245
+ },
246
+ repository: BASE_REPO,
247
+ sender: BASE_SENDER,
248
+ };
249
+ const result = await handleCheckSuite(event);
250
+ expect(result.created).toBe(false);
251
+ });
252
+
253
+ test("workflow_run.completed with failure returns created: false", async () => {
254
+ const event: WorkflowRunEvent = {
255
+ action: "completed",
256
+ workflow_run: {
257
+ id: 1,
258
+ name: "CI",
259
+ head_branch: "feature",
260
+ head_sha: "abc1234567890",
261
+ status: "completed",
262
+ conclusion: "failure",
263
+ html_url: "https://github.com/test/repo/actions/runs/1",
264
+ run_number: 42,
265
+ event: "pull_request",
266
+ pull_requests: [{ number: 1, head: { sha: "abc1234" } }],
267
+ },
268
+ workflow: { id: 1, name: "CI", path: ".github/workflows/ci.yml" },
269
+ repository: BASE_REPO,
270
+ sender: BASE_SENDER,
271
+ };
272
+ const result = await handleWorkflowRun(event);
273
+ expect(result.created).toBe(false);
274
+ });
275
+ });
276
+
277
+ // ── Explicit actions still create tasks ──
278
+
279
+ describe("explicit actions create tasks", () => {
280
+ test("issue_comment with @mention creates task", async () => {
281
+ const event: CommentEvent = {
282
+ action: "created",
283
+ comment: {
284
+ id: 100,
285
+ body: `@${GITHUB_BOT_NAME} please review this`,
286
+ html_url: "https://github.com/test/repo/issues/10#issuecomment-100",
287
+ user: { login: "testuser" },
288
+ },
289
+ issue: {
290
+ number: 10,
291
+ title: "Test Issue",
292
+ html_url: "https://github.com/test/repo/issues/10",
293
+ },
294
+ repository: BASE_REPO,
295
+ sender: BASE_SENDER,
296
+ };
297
+ const result = await handleComment(event, "issue_comment");
298
+ expect(result.created).toBe(true);
299
+ expect(result.taskId).toBeDefined();
300
+ });
301
+
302
+ test("pull_request.review_requested with bot as reviewer creates task", async () => {
303
+ const event = makePREvent({
304
+ action: "review_requested",
305
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
306
+ });
307
+ const result = await handlePullRequest(event);
308
+ expect(result.created).toBe(true);
309
+ expect(result.taskId).toBeDefined();
310
+ });
311
+
312
+ test("pull_request.assigned with bot as assignee creates task", async () => {
313
+ const event = makePREvent({
314
+ action: "assigned",
315
+ pull_request: { ...BASE_PR, number: 2 },
316
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
317
+ });
318
+ const result = await handlePullRequest(event);
319
+ expect(result.created).toBe(true);
320
+ expect(result.taskId).toBeDefined();
321
+ });
322
+
323
+ test("pull_request.labeled with matching label creates task", async () => {
324
+ const event = makePREvent({
325
+ action: "labeled",
326
+ pull_request: { ...BASE_PR, number: 3 },
327
+ label: { id: 1, name: "swarm-review", color: "0075ca" },
328
+ });
329
+ const result = await handlePullRequest(event);
330
+ expect(result.created).toBe(true);
331
+ expect(result.taskId).toBeDefined();
332
+ });
333
+
334
+ test("pull_request.labeled with non-matching label does not create task", async () => {
335
+ const event = makePREvent({
336
+ action: "labeled",
337
+ pull_request: { ...BASE_PR, number: 4 },
338
+ label: { id: 2, name: "bug", color: "d73a4a" },
339
+ });
340
+ const result = await handlePullRequest(event);
341
+ expect(result.created).toBe(false);
342
+ });
343
+
344
+ test("issue.labeled with matching label creates task", async () => {
345
+ const event = makeIssueEvent({
346
+ action: "labeled",
347
+ label: { id: 1, name: "swarm-review", color: "0075ca" },
348
+ });
349
+ const result = await handleIssue(event);
350
+ expect(result.created).toBe(true);
351
+ expect(result.taskId).toBeDefined();
352
+ });
353
+
354
+ test("issue.labeled with non-matching label does not create task", async () => {
355
+ const event = makeIssueEvent({
356
+ action: "labeled",
357
+ issue: {
358
+ number: 11,
359
+ title: "Another Issue",
360
+ body: null,
361
+ html_url: "https://github.com/test/repo/issues/11",
362
+ user: { login: "testuser" },
363
+ },
364
+ label: { id: 2, name: "enhancement", color: "a2eeef" },
365
+ });
366
+ const result = await handleIssue(event);
367
+ expect(result.created).toBe(false);
368
+ });
369
+
370
+ test("pull_request.opened with @mention creates task", async () => {
371
+ const event = makePREvent({
372
+ action: "opened",
373
+ pull_request: {
374
+ ...BASE_PR,
375
+ number: 5,
376
+ title: `@${GITHUB_BOT_NAME} review this PR`,
377
+ },
378
+ });
379
+ const result = await handlePullRequest(event);
380
+ expect(result.created).toBe(true);
381
+ expect(result.taskId).toBeDefined();
382
+ });
383
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { GITHUB_EVENT_LABELS, isSwarmLabel } from "../github/mentions";
3
+
4
+ describe("isSwarmLabel", () => {
5
+ test("default label 'swarm-review' matches", () => {
6
+ expect(isSwarmLabel("swarm-review")).toBe(true);
7
+ });
8
+
9
+ test("case-insensitive matching", () => {
10
+ expect(isSwarmLabel("Swarm-Review")).toBe(true);
11
+ expect(isSwarmLabel("SWARM-REVIEW")).toBe(true);
12
+ });
13
+
14
+ test("non-matching labels return false", () => {
15
+ expect(isSwarmLabel("bug")).toBe(false);
16
+ expect(isSwarmLabel("enhancement")).toBe(false);
17
+ expect(isSwarmLabel("")).toBe(false);
18
+ });
19
+
20
+ test("GITHUB_EVENT_LABELS is populated", () => {
21
+ expect(GITHUB_EVENT_LABELS.length).toBeGreaterThan(0);
22
+ expect(GITHUB_EVENT_LABELS).toContain("swarm-review");
23
+ });
24
+ });
package/src/types.ts CHANGED
@@ -179,7 +179,7 @@ export const AgentSchema = z.object({
179
179
  heartbeatMd: z.string().max(65536).optional(),
180
180
 
181
181
  // Concurrency limit (defaults to 1 for backwards compatibility)
182
- maxTasks: z.number().int().min(1).max(20).optional(),
182
+ maxTasks: z.number().int().min(1).max(100).optional(),
183
183
 
184
184
  // Polling limit tracking (consecutive empty polls)
185
185
  emptyPollCount: z.number().int().min(0).optional(),