herdr-plugin-amq 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-plugin-amq",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Herdr plugin for AMQ (Agent Message Queue) autonomous bridge, status monitoring, and AGmail dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -11,6 +11,7 @@
11
11
  "bin/",
12
12
  "src/",
13
13
  "skills/",
14
+ "docs/",
14
15
  "herdr-plugin.toml",
15
16
  "README.md",
16
17
  "LICENSE"
@@ -19,9 +20,11 @@
19
20
  "node": ">=18.0.0"
20
21
  },
21
22
  "scripts": {
22
- "test": "node --test test/*.test.mjs",
23
- "test:coverage": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=coverage/lcov.info test/*.test.mjs",
24
- "check": "node --check bin/herdr-amq.mjs src/*.mjs test/*.test.mjs",
23
+ "test": "HERDR_DISABLE_PROMPT=1 node --test test/*.test.mjs",
24
+ "test:coverage": "mkdir -p coverage && HERDR_DISABLE_PROMPT=1 node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=coverage/lcov.info test/*.test.mjs",
25
+ "test:e2e": "HERDR_DISABLE_PROMPT=1 node --test test/e2e/*.test.mjs",
26
+ "test:visual": "npm run test:e2e",
27
+ "check": "node --check bin/herdr-amq.mjs src/*.mjs src/web/app.js test/*.test.mjs test/e2e/*.mjs",
25
28
  "test:security": "node --test test/security.test.mjs",
26
29
  "coverage:summary": "node tools/coverage-summary.mjs",
27
30
  "status": "node bin/herdr-amq.mjs status",
@@ -46,5 +49,8 @@
46
49
  "agmail"
47
50
  ],
48
51
  "author": "cabra.lat",
49
- "license": "MIT"
52
+ "license": "MIT",
53
+ "devDependencies": {
54
+ "playwright-core": "1.48.2"
55
+ }
50
56
  }
@@ -12,18 +12,22 @@ metadata:
12
12
 
13
13
  ## Golden Rules for Agents
14
14
 
15
- 1. **Drain First**: Whenever awakened by a doorbell notification or starting a turn, drain your inbox before taking action:
15
+ 1. **Drain First**: Whenever awakened by a doorbell notification or starting a turn, drain your inbox and check assigned backlog tasks:
16
16
  ```bash
17
17
  herdr-amq mail drain --me <handle> --include-body
18
+ herdr-amq task drain --me <handle>
18
19
  ```
19
- 2. **Reply to the Sender**: Always reply to the sender on the same thread/ref chain. Never drop thread context:
20
+ Registration welcomes are status messages. Drain them, but do not reply unless the welcome explicitly requests action.
21
+ 2. **Reply Only When Action Is Requested**: Reply on the same thread/ref chain when a message asks a question, requests action, or reports a blocker. Do not reply to terminal ACKs or informational status messages, which prevents ACK loops:
20
22
  ```bash
21
23
  herdr-amq mail reply --me <handle> --id <msg_id> --body "..."
22
24
  ```
23
25
  3. **Atomic Task Claiming**: Claim cards from the directory bus before modifying shared code:
24
26
  ```bash
25
- herdr-amq task list
27
+ herdr-amq task drain --me <handle>
26
28
  herdr-amq task claim <task-id> --me <handle>
29
+ # Or auto-claim next in a single command:
30
+ herdr-amq task next --me <handle>
27
31
  ```
28
32
  When finished, complete with proof:
29
33
  ```bash
@@ -50,11 +54,13 @@ metadata:
50
54
 
51
55
  ### Task Board & Bus (`herdr-amq task`)
52
56
 
53
- Operates against decentralized card files in `.opencode/bus/{backlog,doing,blocked,done}/`:
57
+ Operates against decentralized card files in `.agent-mail/bus/{backlog,doing,blocked,done}/`:
54
58
 
55
59
  | Command | Usage | Description |
56
60
  |---|---|---|
57
61
  | `list` | `herdr-amq task list [--json]` | List cards grouped by column |
62
+ | `drain` | `herdr-amq task drain --me <handle> [--claim]` | Drain assigned backlog cards with full description bodies, optionally claiming |
63
+ | `next` | `herdr-amq task next --me <handle>` | Shortcut to auto-claim and start next assigned backlog card |
58
64
  | `claim` | `herdr-amq task claim <task-id> --me <handle>` | Atomically moves card to `doing/<task-id>.md` and updates assignee |
59
65
  | `done` | `herdr-amq task done <task-id> --proof "<proof>"` | Moves card to `done/` with timestamp, proof, and duration |
60
66
  | `block` | `herdr-amq task block <task-id> --reason "<reason>"` | Moves card to `blocked/` with blocker reason |
@@ -66,7 +72,7 @@ Unifies external tool agent briefs (`.opencode/agents`, `.agents`, `.pi/agents`,
66
72
  | Command | Usage | Description |
67
73
  |---|---|---|
68
74
  | `fleet status` | `herdr-amq fleet status` | Discover personas across external tools and show live Herdr pane states |
69
- | `fleet prepopulate` | `herdr-amq fleet prepopulate` | Ensure Maildirs, Git worktrees, and workspace trust exist for all personas |
75
+ | `fleet prepopulate` | `herdr-amq fleet prepopulate` | Ensure Maildirs, Git worktrees, and workspace trust exist; welcome each newly registered agent once |
70
76
  | `fleet up` | `herdr-amq fleet up [--kind agy\|opencode\|pi]` | Launch missing fleet agents into isolated Herdr tabs with auto-trust & clean PATH |
71
77
  | `bootstrap` | `herdr-amq bootstrap [--kind agy]` | Instant cold-start: prepopulate + launch fleet + start bridge daemon + doorbell pass |
72
78
  | `migrate` | `herdr-amq migrate [--dry-run]` | Migrate historical attachments into CAS blobs or pinned Git commits |
@@ -76,6 +82,6 @@ Unifies external tool agent briefs (`.opencode/agents`, `.agents`, `.pi/agents`,
76
82
  | Command | Usage | Description |
77
83
  |---|---|---|
78
84
  | `status` | `herdr-amq status` | Show bridge daemon status, unread counts per handle, and paths |
79
- | `doorbell` | `herdr-amq doorbell` | Run a single doorbell pass alerting idle/blocked agents |
85
+ | `doorbell` | `herdr-amq doorbell` | Run a single doorbell pass; operators may customize `.agent-mail/templates/doorbell.md` with scalar substitutions |
80
86
  | `dashboard` | `herdr-amq dashboard [--port 8505]` | Launch web AGmail UI & REST API |
81
87
  | `--skill` | `herdr-amq --skill [--install [dir]]` | Print or install this agentic skill definition |
package/src/actions.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  getStateDir,
8
8
  getConfigDir,
9
9
  getEventContext,
10
+ getPluginVersion,
10
11
  } from "./config.mjs";
11
12
  import {
12
13
  isDaemonRunning,
@@ -20,6 +21,7 @@ import {
20
21
  addBoardTask,
21
22
  updateBoardTask,
22
23
  deleteBoardTask,
24
+ drainTasks,
23
25
  } from "./board.mjs";
24
26
  import {
25
27
  sendMaildirMessage,
@@ -31,6 +33,7 @@ import {
31
33
  discoverFleetPersonas,
32
34
  prepopulateFleet,
33
35
  launchFleet,
36
+ stopFleet,
34
37
  } from "./fleet.mjs";
35
38
  import { getHerdrAgents } from "./herdr.mjs";
36
39
 
@@ -38,9 +41,11 @@ export function handleStatus() {
38
41
  const amqRoot = findAmqRoot();
39
42
  const pid = isDaemonRunning();
40
43
  const handles = amqRoot ? getAgentHandles(amqRoot) : [];
44
+ const version = getPluginVersion();
41
45
 
42
- console.log("\n📦 \x1b[1mHerdr AMQ Bridge Status\x1b[0m");
46
+ console.log(`\n📦 \x1b[1mHerdr AMQ Bridge Status\x1b[0m \x1b[2m(v${version})\x1b[0m`);
43
47
  console.log("──────────────────────────────────────────────");
48
+ console.log(`Version: v${version}`);
44
49
  console.log(`Daemon: ${pid ? `\x1b[32m● Running\x1b[0m (PID ${pid})` : "\x1b[33m○ Stopped\x1b[0m"}`);
45
50
  console.log(`AMQ Root: ${amqRoot ? `\x1b[36m${amqRoot}\x1b[0m` : "\x1b[31mNot found\x1b[0m"}`);
46
51
  console.log(`State Dir: ${getStateDir()}`);
@@ -109,17 +114,19 @@ export function handleDoorbell() {
109
114
  process.exit(1);
110
115
  }
111
116
 
112
- console.log(`🔔 Checking AMQ inboxes at ${amqRoot}...`);
113
- const res = runDoorbellPass({ amqRoot });
117
+ const force = process.argv.includes("--force") || process.argv.includes("-f");
118
+ console.log(`🔔 Checking AMQ inboxes at ${amqRoot}${force ? " (force=true)" : ""}...`);
119
+ const res = runDoorbellPass({ amqRoot, force, allowPrompt: true, persistState: true });
114
120
 
115
121
  if (!res.ok) {
116
122
  console.error(`❌ Doorbell check failed: ${res.error}`);
117
123
  return;
118
124
  }
119
125
 
120
- console.log(`Checked ${res.agentsChecked} agents. Doorbelled: ${res.doorbelled} message(s).`);
126
+ console.log(`Checked ${res.agentsChecked} agents. Doorbelled: ${res.doorbelled} message(s), ${res.doorbelledTasks || 0} task(s).`);
121
127
  for (const r of res.results || []) {
122
- console.log(` - ${r.handle} (${r.status}): ${r.count} msg(s) -> ${r.action}`);
128
+ const taskInfo = r.tasksCount ? `, ${r.tasksCount} task(s)` : "";
129
+ console.log(` - ${r.handle} (${r.status}): ${r.count} msg(s)${taskInfo} -> ${r.action}`);
123
130
  }
124
131
  }
125
132
 
@@ -140,7 +147,7 @@ export function handleAgentStatusChanged() {
140
147
  if (status === "idle" || status === "done") {
141
148
  const amqRoot = findAmqRoot();
142
149
  if (amqRoot && handle) {
143
- runDoorbellPass({ amqRoot, targetHandle: handle });
150
+ runDoorbellPass({ amqRoot, targetHandle: handle, allowPrompt: true, persistState: true });
144
151
  }
145
152
  }
146
153
  }
@@ -380,12 +387,75 @@ export function handleTaskCommand(subcommand = "list", rawArgs = []) {
380
387
  break;
381
388
  }
382
389
 
390
+ case "drain": {
391
+ const claim = Boolean(flags.claim || flags.autoClaim);
392
+ const res = drainTasks(repoRoot, amqRoot, {
393
+ me,
394
+ claim,
395
+ notify: flags.notify !== "false",
396
+ });
397
+
398
+ if (flags.json) {
399
+ console.log(JSON.stringify(res, null, 2));
400
+ return;
401
+ }
402
+
403
+ console.log(`\n📋 \x1b[1mTask Drain for ${me}\x1b[0m (${res.count} pending backlog task(s))`);
404
+ console.log("────────────────────────────────────────────────────────────────────────────");
405
+
406
+ if (res.activeTasks && res.activeTasks.length > 0) {
407
+ for (const at of res.activeTasks) {
408
+ console.log(`⚡ \x1b[33mActive task in progress (doing):\x1b[0m \x1b[1m${at.title}\x1b[0m (ID: ${at.id})`);
409
+ }
410
+ console.log("────────────────────────────────────────────────────────────────────────────");
411
+ }
412
+
413
+ if (res.tasks.length === 0) {
414
+ console.log(` (no pending backlog tasks assigned to ${me})`);
415
+ } else {
416
+ for (const t of res.tasks) {
417
+ const isClaimed = res.claimedTask && res.claimedTask.id === t.id;
418
+ const statusStr = isClaimed
419
+ ? `\x1b[32m[CLAIMED -> in_progress]\x1b[0m`
420
+ : `\x1b[34m[backlog]\x1b[0m`;
421
+
422
+ console.log(`\n${statusStr} \x1b[1m${t.title}\x1b[0m (ID: \x1b[36m${t.id}\x1b[0m)`);
423
+ if (t.created) console.log(` Created: ${t.created}`);
424
+ if (t.description) {
425
+ console.log(` Details:`);
426
+ for (const line of t.description.split("\n")) {
427
+ console.log(` ${line}`);
428
+ }
429
+ }
430
+ }
431
+
432
+ console.log("\n────────────────────────────────────────────────────────────────────────────");
433
+ if (res.claimedTask) {
434
+ console.log(`🚀 \x1b[32mAuto-claimed task ${res.claimedTask.id} into doing/\x1b[0m (status: in_progress)`);
435
+ console.log(`✉️ Notification dispatched to coordinator via AMQ.`);
436
+ } else {
437
+ console.log(`👉 \x1b[1mTo claim a task:\x1b[0m`);
438
+ console.log(` herdr-amq task claim ${res.tasks[0].id} --me ${me}`);
439
+ console.log(` or auto-claim next: herdr-amq task next --me ${me}`);
440
+ }
441
+ }
442
+ console.log("────────────────────────────────────────────────────────────────────────────\n");
443
+ break;
444
+ }
445
+
446
+ case "next": {
447
+ handleTaskCommand("drain", [...rawArgs, "--claim"]);
448
+ break;
449
+ }
450
+
383
451
  default:
384
452
  console.log(`\n📋 \x1b[1mAGboard Task Coordination CLI\x1b[0m`);
385
453
  console.log("────────────────────────────────────────────────────────────────────────────");
386
454
  console.log("Usage: herdr-amq task <subcommand> [options]");
387
455
  console.log("\nCommands:");
388
456
  console.log(" list [--owner <h>] [--status <s>] [--json] List all tasks");
457
+ console.log(" drain [--me <h>] [--claim] [--json] Drain backlog tasks with full descriptions");
458
+ console.log(" next [--me <h>] Auto-claim and start next backlog task");
389
459
  console.log(" assign --to <h> --title <t> [--desc <d>] Assign a new task to an agent");
390
460
  console.log(" claim <id> [--me <h>] Claim an existing task");
391
461
  console.log(" done <id> [--me <h>] [--proof <evidence>] Complete a task with proof");
@@ -567,8 +637,15 @@ export function handleSkillCommand(args = []) {
567
637
  destDir = path.resolve(process.cwd(), destDir);
568
638
  }
569
639
 
640
+ let targetFile;
641
+ if (destDir.endsWith(".md")) {
642
+ targetFile = destDir;
643
+ destDir = path.dirname(destDir);
644
+ } else {
645
+ targetFile = path.join(destDir, "SKILL.md");
646
+ }
647
+
570
648
  fs.mkdirSync(destDir, { recursive: true });
571
- const targetFile = path.join(destDir, "SKILL.md");
572
649
  fs.writeFileSync(targetFile, content, "utf-8");
573
650
  console.log(`✅ Successfully installed herdr-amq skill to ${targetFile}`);
574
651
  return targetFile;
@@ -654,12 +731,14 @@ Usage: herdr-amq fleet <command> [options]
654
731
  Commands:
655
732
  status, list Show discovered fleet personas, worktrees, and Herdr status
656
733
  prepopulate Create AMQ maildirs and worktrees for all fleet personas
657
- up Launch missing fleet agents into Herdr terminal tabs
734
+ up Launch missing agents and replace mismatched kinds
735
+ down Close fleet agent panes without removing worktrees
658
736
 
659
737
  Options:
660
- --kind <kind> Agent kind to launch (default: agy, options: agy, opencode, pi)
738
+ --kind <kind> Agent kind (default: agy for up, required for down)
661
739
  --agents <list> Comma-separated handles to target (default: all)
662
- --dry-run Preview actions without creating tabs or starting agents
740
+ --no-replace Refuse to replace agents running as another kind
741
+ --dry-run Preview actions without changing panes
663
742
  --help, -h Show this help message
664
743
  `);
665
744
  return;
@@ -675,7 +754,7 @@ Options:
675
754
  for (const [handle, p] of personas.entries()) {
676
755
  const live = liveMap.get(handle);
677
756
  const liveBadge = live
678
- ? `\x1b[32m● ${live.agent_status} (${live.pane_id})\x1b[0m`
757
+ ? `\x1b[32m● ${live.agent || "unknown"} ${live.agent_status} (${live.pane_id})\x1b[0m`
679
758
  : `\x1b[90m○ offline\x1b[0m`;
680
759
  const wtExists = fs.existsSync(path.join(repoRoot, ".worktrees", handle));
681
760
  const wtBadge = wtExists ? "worktree: ok" : "\x1b[33mno worktree\x1b[0m";
@@ -696,24 +775,82 @@ Options:
696
775
  return;
697
776
  }
698
777
 
778
+ if (subcommand === "down") {
779
+ const kindIdx = rawArgs.indexOf("--kind");
780
+ const kind = kindIdx !== -1 && rawArgs[kindIdx + 1] ? rawArgs[kindIdx + 1] : null;
781
+ const agentsIdx = rawArgs.indexOf("--agents");
782
+ const agents = agentsIdx !== -1 && rawArgs[agentsIdx + 1] ? rawArgs[agentsIdx + 1] : null;
783
+ const dryRun = rawArgs.includes("--dry-run");
784
+ if (!kind) {
785
+ console.error("❌ fleet down requires --kind <agy|opencode|pi>");
786
+ process.exitCode = 1;
787
+ return;
788
+ }
789
+
790
+ console.log(`\n🛑 \x1b[1mStopping Fleet via Herdr (kind: ${kind})\x1b[0m`);
791
+ console.log("──────────────────────────────────────────────");
792
+ if (dryRun) console.log("Mode: \x1b[33mDry Run (preview only)\x1b[0m\n");
793
+
794
+ const res = await stopFleet(amqRoot, repoRoot, { kind, agents, dryRun });
795
+ if (dryRun && res.wouldStop.length > 0) {
796
+ console.log(`\x1b[33m⚡ Would stop (${res.wouldStop.length}):\x1b[0m ${[...new Set(res.wouldStop.map((entry) => entry.handle))].join(", ")}`);
797
+ }
798
+ if (res.stopped.length > 0) {
799
+ console.log(`\x1b[32m✔ Stopped agents (${res.stopped.length}):\x1b[0m`);
800
+ for (const entry of res.stopped) {
801
+ console.log(` • ${entry.handle} -> pane ${entry.paneId} (${entry.kind})`);
802
+ }
803
+ }
804
+ if (res.skipped.length > 0) {
805
+ console.log(`\x1b[33m↷ Skipped mismatched agents (${res.skipped.length}):\x1b[0m`);
806
+ for (const entry of res.skipped) {
807
+ console.log(` • ${entry.handle}: ${entry.reason}`);
808
+ }
809
+ }
810
+ if (res.failed.length > 0) {
811
+ console.log(`\x1b[31m✖ Failed to stop:\x1b[0m`);
812
+ for (const entry of res.failed) {
813
+ console.log(` • ${entry.handle} (${entry.paneId}): ${entry.error}`);
814
+ }
815
+ }
816
+ console.log("\n✅ Fleet stop pass complete. Worktrees and maildirs were preserved.\n");
817
+ return res;
818
+ }
819
+
699
820
  if (subcommand === "up") {
700
821
  const kindIdx = rawArgs.indexOf("--kind");
701
822
  const kind = kindIdx !== -1 && rawArgs[kindIdx + 1] ? rawArgs[kindIdx + 1] : "agy";
702
823
  const agentsIdx = rawArgs.indexOf("--agents");
703
824
  const agents = agentsIdx !== -1 && rawArgs[agentsIdx + 1] ? rawArgs[agentsIdx + 1] : null;
704
825
  const dryRun = rawArgs.includes("--dry-run");
826
+ const replace = !rawArgs.includes("--no-replace");
705
827
 
706
828
  console.log(`\n🚀 \x1b[1mLaunching Fleet via Herdr (kind: ${kind})\x1b[0m`);
707
829
  console.log("──────────────────────────────────────────────");
708
830
  if (dryRun) console.log("Mode: \x1b[33mDry Run (preview only)\x1b[0m\n");
709
831
 
710
- const res = await launchFleet(amqRoot, repoRoot, { kind, agents, dryRun });
832
+ const res = await launchFleet(amqRoot, repoRoot, { kind, agents, dryRun, replace });
711
833
  if (res.alreadyRunning.length > 0) {
712
834
  console.log(`\x1b[36m● Already running (${res.alreadyRunning.length}):\x1b[0m ${res.alreadyRunning.join(", ")}`);
713
835
  }
836
+ if (res.replaced.length > 0) {
837
+ console.log(`\x1b[33m↻ Replaced mismatched agents (${res.replaced.length}):\x1b[0m`);
838
+ for (const entry of res.replaced) {
839
+ console.log(` • ${entry.handle}: ${entry.fromKinds.join(", ")} -> ${kind}`);
840
+ }
841
+ }
842
+ if (dryRun && res.wouldReplace.length > 0) {
843
+ console.log(`\x1b[33m⚡ Would replace (${res.wouldReplace.length}):\x1b[0m ${res.wouldReplace.join(", ")}`);
844
+ }
714
845
  if (dryRun && res.wouldLaunch.length > 0) {
715
846
  console.log(`\x1b[33m⚡ Would launch into Herdr (${res.wouldLaunch.length}):\x1b[0m ${res.wouldLaunch.join(", ")}`);
716
847
  }
848
+ if (res.blocked.length > 0) {
849
+ console.log(`\x1b[33m↷ Blocked mismatched agents (${res.blocked.length}):\x1b[0m`);
850
+ for (const entry of res.blocked) {
851
+ console.log(` • ${entry.handle}: ${entry.reason}`);
852
+ }
853
+ }
717
854
  if (res.launched.length > 0) {
718
855
  console.log(`\x1b[32m✔ Launched agents (${res.launched.length}):\x1b[0m`);
719
856
  for (const l of res.launched) {
package/src/board.mjs CHANGED
@@ -29,22 +29,34 @@ export function findStatusFile(repoRoot) {
29
29
 
30
30
  /**
31
31
  * Locate the global bus directory for tasks.
32
- * Defaults to .opencode/bus (or .agent-mail/bus).
32
+ * Defaults to .agent-mail/bus (lives alongside agent mailboxes and blobs).
33
+ * Falls back to legacy .opencode/bus if present.
33
34
  */
34
35
  export function getBusDirectory(repoRoot, amqRoot) {
36
+ if (process.env.AMQ_BUS_DIR) {
37
+ return path.resolve(process.env.AMQ_BUS_DIR);
38
+ }
39
+ // 1. Primary: .agent-mail/bus if amqRoot is provided
40
+ if (amqRoot) {
41
+ const amqBus = path.join(amqRoot, "bus");
42
+ if (fs.existsSync(amqBus)) return amqBus;
43
+ }
44
+ // 2. Check repoRoot/.agent-mail/bus
35
45
  if (repoRoot) {
46
+ const repoAmqBus = path.join(repoRoot, ".agent-mail", "bus");
47
+ if (fs.existsSync(repoAmqBus)) return repoAmqBus;
48
+ // 3. Fallback to legacy .opencode/bus if it exists
36
49
  const opencodeBus = path.join(repoRoot, ".opencode", "bus");
37
- if (fs.existsSync(opencodeBus)) {
38
- return opencodeBus;
39
- }
50
+ if (fs.existsSync(opencodeBus)) return opencodeBus;
40
51
  }
52
+ // Default new creation target: .agent-mail/bus
41
53
  if (amqRoot) {
42
54
  return path.join(amqRoot, "bus");
43
55
  }
44
56
  if (repoRoot) {
45
- return path.join(repoRoot, ".opencode", "bus");
57
+ return path.join(repoRoot, ".agent-mail", "bus");
46
58
  }
47
- return path.join(process.cwd(), ".opencode", "bus");
59
+ return path.join(process.cwd(), ".agent-mail", "bus");
48
60
  }
49
61
 
50
62
  export const STAGE_DIRS = {
@@ -758,3 +770,129 @@ export function deleteBoardTask(repoRoot, amqRoot, taskId) {
758
770
 
759
771
  return { ok: true, taskId, deleted };
760
772
  }
773
+
774
+ /**
775
+ * List pending backlog tasks assigned to a specific handle (or all backlog tasks if null/all).
776
+ */
777
+ export function listBacklogTasks(repoRoot, amqRoot, handle = null) {
778
+ const busDir = getBusDirectory(repoRoot, amqRoot);
779
+ const backlogDir = path.join(busDir, "backlog");
780
+ if (!fs.existsSync(backlogDir)) return [];
781
+
782
+ try {
783
+ const files = fs.readdirSync(backlogDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
784
+ const tasks = [];
785
+ const target = handle && handle !== "all" ? canonicalizeOwner(handle) : null;
786
+
787
+ for (const f of files) {
788
+ const fullPath = path.join(backlogDir, f);
789
+ const parsed = parseTaskFile(fullPath, "backlog");
790
+ if (parsed) {
791
+ if (!target || parsed.owner === target) {
792
+ tasks.push(parsed);
793
+ }
794
+ }
795
+ }
796
+ tasks.sort((a, b) => (a.created || "").localeCompare(b.created || ""));
797
+ return tasks;
798
+ } catch {
799
+ return [];
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Drain tasks for an agent: returns all pending backlog tasks with full descriptions,
805
+ * optionally auto-claiming the first available task if claim: true.
806
+ */
807
+ export function drainTasks(repoRoot, amqRoot, { me, claim = false, notify = true } = {}) {
808
+ const target = me ? canonicalizeOwner(me) : "coordinator";
809
+ const tasks = listBacklogTasks(repoRoot, amqRoot, target);
810
+
811
+ let claimedTask = null;
812
+ if (claim && tasks.length > 0) {
813
+ const toClaim = tasks[0];
814
+ const res = updateBoardTask(
815
+ repoRoot,
816
+ amqRoot,
817
+ toClaim.id,
818
+ { status: "in_progress", owner: target },
819
+ { from: target, notify }
820
+ );
821
+ if (res.ok) {
822
+ claimedTask = res.task;
823
+ }
824
+ }
825
+
826
+ // Also discover any active tasks currently in doing/in_progress
827
+ const busDir = getBusDirectory(repoRoot, amqRoot);
828
+ const doingDir = path.join(busDir, resolveStageDir(busDir, "doing"));
829
+ const activeTasks = [];
830
+ if (fs.existsSync(doingDir)) {
831
+ try {
832
+ const files = fs.readdirSync(doingDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
833
+ for (const f of files) {
834
+ const fullPath = path.join(doingDir, f);
835
+ const parsed = parseTaskFile(fullPath, "in_progress");
836
+ if (parsed && parsed.owner === target) {
837
+ activeTasks.push(parsed);
838
+ }
839
+ }
840
+ } catch {}
841
+ }
842
+
843
+ return {
844
+ ok: true,
845
+ owner: target,
846
+ count: tasks.length,
847
+ tasks,
848
+ activeTasks,
849
+ claimedTask,
850
+ };
851
+ }
852
+
853
+ /**
854
+ * Fast query of task numbers/statistics for an agent across stages:
855
+ * { backlog, doing, blocked, done, total }
856
+ */
857
+ export function getAgentTaskStats(repoRoot, amqRoot, handle) {
858
+ const target = handle ? canonicalizeOwner(handle) : null;
859
+ const busDir = getBusDirectory(repoRoot, amqRoot);
860
+ const stages = [
861
+ { dir: "backlog", key: "backlog" },
862
+ { dir: "doing", key: "doing" },
863
+ { dir: "in_progress", key: "doing" },
864
+ { dir: "blocked", key: "blocked" },
865
+ { dir: "done", key: "done" },
866
+ ];
867
+
868
+ const stats = {
869
+ backlog: 0,
870
+ doing: 0,
871
+ blocked: 0,
872
+ done: 0,
873
+ total: 0,
874
+ };
875
+
876
+ const seenIds = new Set();
877
+
878
+ for (const { dir, key } of stages) {
879
+ const fullDir = path.join(busDir, dir);
880
+ if (!fs.existsSync(fullDir)) continue;
881
+ try {
882
+ const files = fs.readdirSync(fullDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
883
+ for (const f of files) {
884
+ const fullPath = path.join(fullDir, f);
885
+ const parsed = parseTaskFile(fullPath, key);
886
+ if (parsed && (!target || parsed.owner === target)) {
887
+ if (!seenIds.has(parsed.id)) {
888
+ seenIds.add(parsed.id);
889
+ stats[key]++;
890
+ stats.total++;
891
+ }
892
+ }
893
+ }
894
+ } catch {}
895
+ }
896
+
897
+ return stats;
898
+ }