bosun 0.29.1 → 0.29.2

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.
@@ -20,6 +20,7 @@ import { createServer } from "node:http";
20
20
  import { resolve, dirname } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
23
+ import { randomUUID } from "node:crypto";
23
24
 
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
  const __dirname = dirname(__filename);
@@ -505,6 +506,20 @@ export class AgentEndpoint {
505
506
  return await this._handleListTasks(url, res);
506
507
  }
507
508
 
509
+ if (method === "POST" && pathname === "/api/tasks/create") {
510
+ const body = await parseBody(req);
511
+ return await this._handleCreateTask(body, res);
512
+ }
513
+
514
+ if (method === "GET" && pathname === "/api/tasks/stats") {
515
+ return await this._handleTaskStats(res);
516
+ }
517
+
518
+ if (method === "POST" && pathname === "/api/tasks/import") {
519
+ const body = await parseBody(req);
520
+ return await this._handleImportTasks(body, res);
521
+ }
522
+
508
523
  if (method === "GET" && pathname === "/api/executor") {
509
524
  return this._handleExecutorStatus(res);
510
525
  }
@@ -545,6 +560,15 @@ export class AgentEndpoint {
545
560
  const body = await parseBody(req);
546
561
  return await this._handleError(taskId, body, res);
547
562
  }
563
+
564
+ if (method === "POST" && pathname === `/api/tasks/${taskId}/update`) {
565
+ const body = await parseBody(req);
566
+ return await this._handleUpdateTask(taskId, body, res);
567
+ }
568
+
569
+ if (method === "DELETE" && pathname === `/api/tasks/${taskId}`) {
570
+ return await this._handleDeleteTask(taskId, res);
571
+ }
548
572
  }
549
573
 
550
574
  // ── 404 ─────────────────────────────────────────────────────────
@@ -864,6 +888,299 @@ export class AgentEndpoint {
864
888
  sendJson(res, 200, { ok: true, task, nextAction });
865
889
  }
866
890
 
891
+ // ── Task CRUD Handlers ──────────────────────────────────────────────────
892
+
893
+ async _handleCreateTask(body, res) {
894
+ if (!this._taskStore) {
895
+ sendJson(res, 503, { error: "Task store not configured" });
896
+ return;
897
+ }
898
+
899
+ const { title, description, status, priority, tags, baseBranch, base_branch, workspace, repository, repositories, implementation_steps, acceptance_criteria, verification, draft } = body;
900
+
901
+ if (!title) {
902
+ sendJson(res, 400, { error: "Missing 'title' in body" });
903
+ return;
904
+ }
905
+
906
+ try {
907
+ // Build description from structured fields if provided
908
+ let fullDescription = description || "";
909
+ if (implementation_steps?.length || acceptance_criteria?.length || verification?.length) {
910
+ const parts = [fullDescription];
911
+ if (implementation_steps?.length) {
912
+ parts.push("", "## Implementation Steps");
913
+ for (const step of implementation_steps) parts.push(`- ${step}`);
914
+ }
915
+ if (acceptance_criteria?.length) {
916
+ parts.push("", "## Acceptance Criteria");
917
+ for (const c of acceptance_criteria) parts.push(`- ${c}`);
918
+ }
919
+ if (verification?.length) {
920
+ parts.push("", "## Verification");
921
+ for (const v of verification) parts.push(`- ${v}`);
922
+ }
923
+ fullDescription = parts.join("\n");
924
+ }
925
+
926
+ let task;
927
+ if (typeof this._taskStore.createTask === "function") {
928
+ // kanban adapter — handles ID generation
929
+ task = await this._taskStore.createTask(null, {
930
+ title,
931
+ description: fullDescription,
932
+ status: status || "draft",
933
+ priority: priority || "medium",
934
+ tags: tags || [],
935
+ baseBranch: baseBranch || base_branch || "main",
936
+ workspace: workspace || "",
937
+ repository: repository || "",
938
+ repositories: repositories || [],
939
+ draft: draft ?? (status === "draft" || !status),
940
+ });
941
+ } else if (typeof this._taskStore.addTask === "function") {
942
+ // raw task-store
943
+ task = this._taskStore.addTask({
944
+ id: randomUUID(),
945
+ title,
946
+ description: fullDescription,
947
+ status: status || "draft",
948
+ priority: priority || "medium",
949
+ tags: tags || [],
950
+ baseBranch: baseBranch || base_branch || "main",
951
+ workspace: workspace || "",
952
+ repository: repository || "",
953
+ repositories: repositories || [],
954
+ draft: draft ?? (status === "draft" || !status),
955
+ });
956
+ } else {
957
+ sendJson(res, 501, { error: "Task store does not support creation" });
958
+ return;
959
+ }
960
+
961
+ if (!task) {
962
+ sendJson(res, 500, { error: "Failed to create task" });
963
+ return;
964
+ }
965
+
966
+ console.log(`${TAG} Created task ${task.id}: ${task.title}`);
967
+ sendJson(res, 201, { ok: true, task });
968
+ } catch (err) {
969
+ console.error(`${TAG} createTask error:`, err.message);
970
+ sendJson(res, 500, { error: `Failed to create task: ${err.message}` });
971
+ }
972
+ }
973
+
974
+ async _handleUpdateTask(taskId, body, res) {
975
+ if (!this._taskStore) {
976
+ sendJson(res, 503, { error: "Task store not configured" });
977
+ return;
978
+ }
979
+
980
+ try {
981
+ // Verify task exists
982
+ let existing = null;
983
+ if (typeof this._taskStore.getTask === "function") {
984
+ existing = await this._taskStore.getTask(taskId);
985
+ } else if (typeof this._taskStore.get === "function") {
986
+ existing = await this._taskStore.get(taskId);
987
+ }
988
+ if (!existing) {
989
+ sendJson(res, 404, { error: "Task not found" });
990
+ return;
991
+ }
992
+
993
+ const updates = { ...body };
994
+ delete updates.id; // Never allow ID change
995
+
996
+ // Normalize base_branch → baseBranch
997
+ if (updates.base_branch) {
998
+ updates.baseBranch = updates.base_branch;
999
+ delete updates.base_branch;
1000
+ }
1001
+
1002
+ // Handle status change with history tracking
1003
+ if (updates.status && updates.status !== existing.status) {
1004
+ if (typeof this._taskStore.updateTaskStatus === "function") {
1005
+ await this._taskStore.updateTaskStatus(taskId, updates.status);
1006
+ }
1007
+ delete updates.status;
1008
+ }
1009
+
1010
+ // Apply remaining updates
1011
+ let updatedTask;
1012
+ if (Object.keys(updates).length > 0) {
1013
+ if (typeof this._taskStore.updateTask === "function") {
1014
+ updatedTask = await this._taskStore.updateTask(taskId, updates);
1015
+ } else if (typeof this._taskStore.update === "function") {
1016
+ updatedTask = await this._taskStore.update(taskId, updates);
1017
+ }
1018
+ }
1019
+
1020
+ // Re-fetch to get final state
1021
+ if (typeof this._taskStore.getTask === "function") {
1022
+ updatedTask = await this._taskStore.getTask(taskId);
1023
+ } else if (typeof this._taskStore.get === "function") {
1024
+ updatedTask = await this._taskStore.get(taskId);
1025
+ }
1026
+
1027
+ console.log(`${TAG} Updated task ${taskId}`);
1028
+ sendJson(res, 200, { ok: true, task: updatedTask || { id: taskId } });
1029
+ } catch (err) {
1030
+ console.error(`${TAG} updateTask(${taskId}) error:`, err.message);
1031
+ sendJson(res, 500, { error: `Failed to update task: ${err.message}` });
1032
+ }
1033
+ }
1034
+
1035
+ async _handleDeleteTask(taskId, res) {
1036
+ if (!this._taskStore) {
1037
+ sendJson(res, 503, { error: "Task store not configured" });
1038
+ return;
1039
+ }
1040
+
1041
+ try {
1042
+ let removed = false;
1043
+ if (typeof this._taskStore.deleteTask === "function") {
1044
+ await this._taskStore.deleteTask(taskId);
1045
+ removed = true;
1046
+ } else if (typeof this._taskStore.removeTask === "function") {
1047
+ removed = this._taskStore.removeTask(taskId);
1048
+ } else {
1049
+ sendJson(res, 501, { error: "Task store does not support deletion" });
1050
+ return;
1051
+ }
1052
+
1053
+ if (!removed) {
1054
+ sendJson(res, 404, { error: "Task not found" });
1055
+ return;
1056
+ }
1057
+
1058
+ console.log(`${TAG} Deleted task ${taskId}`);
1059
+ sendJson(res, 200, { ok: true, deleted: taskId });
1060
+ } catch (err) {
1061
+ console.error(`${TAG} deleteTask(${taskId}) error:`, err.message);
1062
+ sendJson(res, 500, { error: `Failed to delete task: ${err.message}` });
1063
+ }
1064
+ }
1065
+
1066
+ async _handleTaskStats(res) {
1067
+ if (!this._taskStore) {
1068
+ sendJson(res, 503, { error: "Task store not configured" });
1069
+ return;
1070
+ }
1071
+
1072
+ try {
1073
+ let stats;
1074
+ if (typeof this._taskStore.getStats === "function") {
1075
+ stats = this._taskStore.getStats();
1076
+ } else {
1077
+ // Compute from list
1078
+ let tasks = [];
1079
+ if (typeof this._taskStore.listTasks === "function") {
1080
+ tasks = await this._taskStore.listTasks(null, {});
1081
+ } else if (typeof this._taskStore.list === "function") {
1082
+ tasks = await this._taskStore.list({});
1083
+ }
1084
+ const list = Array.isArray(tasks) ? tasks : [];
1085
+ stats = {
1086
+ draft: list.filter((t) => t.status === "draft").length,
1087
+ todo: list.filter((t) => t.status === "todo").length,
1088
+ inprogress: list.filter((t) => t.status === "inprogress").length,
1089
+ inreview: list.filter((t) => t.status === "inreview").length,
1090
+ done: list.filter((t) => t.status === "done").length,
1091
+ blocked: list.filter((t) => t.status === "blocked").length,
1092
+ total: list.length,
1093
+ };
1094
+ }
1095
+
1096
+ sendJson(res, 200, { ok: true, stats });
1097
+ } catch (err) {
1098
+ console.error(`${TAG} taskStats error:`, err.message);
1099
+ sendJson(res, 500, { error: `Failed to get stats: ${err.message}` });
1100
+ }
1101
+ }
1102
+
1103
+ async _handleImportTasks(body, res) {
1104
+ if (!this._taskStore) {
1105
+ sendJson(res, 503, { error: "Task store not configured" });
1106
+ return;
1107
+ }
1108
+
1109
+ const tasks = body.tasks || body.backlog || (Array.isArray(body) ? body : null);
1110
+ if (!tasks || !Array.isArray(tasks)) {
1111
+ sendJson(res, 400, { error: "Body must contain 'tasks' array" });
1112
+ return;
1113
+ }
1114
+
1115
+ const results = { created: [], failed: [] };
1116
+ for (const t of tasks) {
1117
+ try {
1118
+ // Recursively use our create handler logic
1119
+ let fullDescription = t.description || "";
1120
+ if (t.implementation_steps?.length || t.acceptance_criteria?.length || t.verification?.length) {
1121
+ const parts = [fullDescription];
1122
+ if (t.implementation_steps?.length) {
1123
+ parts.push("", "## Implementation Steps");
1124
+ for (const step of t.implementation_steps) parts.push(`- ${step}`);
1125
+ }
1126
+ if (t.acceptance_criteria?.length) {
1127
+ parts.push("", "## Acceptance Criteria");
1128
+ for (const c of t.acceptance_criteria) parts.push(`- ${c}`);
1129
+ }
1130
+ if (t.verification?.length) {
1131
+ parts.push("", "## Verification");
1132
+ for (const v of t.verification) parts.push(`- ${v}`);
1133
+ }
1134
+ fullDescription = parts.join("\n");
1135
+ }
1136
+
1137
+ let task;
1138
+ if (typeof this._taskStore.createTask === "function") {
1139
+ task = await this._taskStore.createTask(null, {
1140
+ title: t.title,
1141
+ description: fullDescription,
1142
+ status: t.status || "draft",
1143
+ priority: t.priority || "medium",
1144
+ tags: t.tags || [],
1145
+ baseBranch: t.baseBranch || t.base_branch || "main",
1146
+ workspace: t.workspace || "",
1147
+ repository: t.repository || "",
1148
+ draft: t.draft ?? true,
1149
+ });
1150
+ } else if (typeof this._taskStore.addTask === "function") {
1151
+ task = this._taskStore.addTask({
1152
+ id: randomUUID(),
1153
+ title: t.title,
1154
+ description: fullDescription,
1155
+ status: t.status || "draft",
1156
+ priority: t.priority || "medium",
1157
+ tags: t.tags || [],
1158
+ baseBranch: t.baseBranch || t.base_branch || "main",
1159
+ workspace: t.workspace || "",
1160
+ repository: t.repository || "",
1161
+ draft: t.draft ?? true,
1162
+ });
1163
+ }
1164
+
1165
+ if (task) {
1166
+ results.created.push({ id: task.id, title: task.title });
1167
+ } else {
1168
+ results.failed.push({ title: t.title, error: "addTask returned null" });
1169
+ }
1170
+ } catch (err) {
1171
+ results.failed.push({ title: t.title || "untitled", error: err.message });
1172
+ }
1173
+ }
1174
+
1175
+ console.log(`${TAG} Import: ${results.created.length} created, ${results.failed.length} failed`);
1176
+ sendJson(res, 200, {
1177
+ ok: true,
1178
+ created: results.created.length,
1179
+ failed: results.failed.length,
1180
+ results,
1181
+ });
1182
+ }
1183
+
867
1184
  async _handleError(taskId, body, res) {
868
1185
  const { error: errorMsg, pattern, output } = body;
869
1186
 
package/agent-pool.mjs CHANGED
@@ -42,14 +42,26 @@
42
42
  import { resolve, dirname } from "node:path";
43
43
  import { fileURLToPath } from "node:url";
44
44
  import { loadConfig } from "./config.mjs";
45
- import { resolveRepoRoot } from "./repo-root.mjs";
45
+ import { resolveRepoRoot, resolveAgentRepoRoot } from "./repo-root.mjs";
46
46
  import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
47
47
 
48
48
  const __filename = fileURLToPath(import.meta.url);
49
49
  const __dirname = dirname(__filename);
50
50
 
51
- /** Repository root for the active workspace */
52
- const REPO_ROOT = resolveRepoRoot();
51
+ /**
52
+ * Repository root for the active workspace.
53
+ * Uses lazy resolution so workspace config changes are picked up per-call.
54
+ * Prefers workspace-aware agent root over raw REPO_ROOT.
55
+ */
56
+ let _cachedRepoRoot = null;
57
+ function getAgentRepoRoot() {
58
+ if (!_cachedRepoRoot) {
59
+ _cachedRepoRoot = resolveAgentRepoRoot();
60
+ }
61
+ return _cachedRepoRoot;
62
+ }
63
+ /** @deprecated Use getAgentRepoRoot() — kept for backward compat */
64
+ const REPO_ROOT = resolveAgentRepoRoot();
53
65
 
54
66
  /** Default timeout: 6 hours — agents should run until the stream-based watchdog detects real issues */
55
67
  const DEFAULT_TIMEOUT_MS = 6 * 60 * 60 * 1000;
@@ -102,12 +114,74 @@ function shouldFallbackForSdkError(error) {
102
114
  if (!error) return false;
103
115
  const message = String(error).toLowerCase();
104
116
  if (!message) return false;
117
+ // SDK not installed / not found
105
118
  if (message.includes("not available")) return true;
119
+ // Missing finish_reason (incomplete response)
106
120
  if (message.includes("missing finish_reason")) return true;
107
121
  if (message.includes("missing") && message.includes("finish_reason")) return true;
122
+ // Model-related errors — SDK doesn't support the requested model
123
+ if (message.includes("not supported")) return true;
124
+ if (message.includes("model not found")) return true;
125
+ if (message.includes("model_not_found")) return true;
126
+ if (message.includes("does not exist")) return true;
127
+ if (message.includes("invalid model")) return true;
128
+ // Auth / key errors — SDK isn't properly configured
129
+ if (message.includes("unauthorized") || message.includes("401")) return true;
130
+ if (message.includes("api key") && (message.includes("invalid") || message.includes("missing") || message.includes("required"))) return true;
131
+ if (message.includes("authentication") && (message.includes("failed") || message.includes("required") || message.includes("error"))) return true;
132
+ if (message.includes("forbidden") || message.includes("403")) return true;
133
+ // Connection errors — SDK endpoint is unreachable
134
+ if (message.includes("econnrefused")) return true;
135
+ if (message.includes("enotfound")) return true;
136
+ if (message.includes("connection refused")) return true;
108
137
  return false;
109
138
  }
110
139
 
140
+ /**
141
+ * Check whether an SDK has the minimum prerequisites to be attempted.
142
+ * This prevents wasting time and error logs trying unconfigured SDKs.
143
+ *
144
+ * @param {string} name SDK canonical name
145
+ * @returns {{ ok: boolean, reason: string|null }}
146
+ */
147
+ function hasSdkPrerequisites(name) {
148
+ // Test mocks bypass prerequisite checks
149
+ if (process.env[`__MOCK_${name.toUpperCase()}_AVAILABLE`] === "1") {
150
+ return { ok: true, reason: null };
151
+ }
152
+
153
+ if (name === "codex") {
154
+ // Codex needs an OpenAI API key (or Azure key, or profile-specific key)
155
+ const hasKey =
156
+ process.env.OPENAI_API_KEY ||
157
+ process.env.AZURE_OPENAI_API_KEY ||
158
+ process.env.CODEX_MODEL_PROFILE_XL_API_KEY ||
159
+ process.env.CODEX_MODEL_PROFILE_M_API_KEY;
160
+ if (!hasKey) {
161
+ return { ok: false, reason: "no API key (OPENAI_API_KEY / AZURE_OPENAI_API_KEY)" };
162
+ }
163
+ return { ok: true, reason: null };
164
+ }
165
+ if (name === "copilot") {
166
+ // Copilot needs either a token or VS Code context
167
+ const hasToken = process.env.COPILOT_CLI_TOKEN || process.env.GITHUB_TOKEN;
168
+ // Copilot also works from VS Code extension context — check for common indicators
169
+ const hasVsCode = process.env.VSCODE_PID || process.env.COPILOT_AGENT_HOST;
170
+ if (!hasToken && !hasVsCode) {
171
+ return { ok: false, reason: "no COPILOT_CLI_TOKEN or GITHUB_TOKEN" };
172
+ }
173
+ return { ok: true, reason: null };
174
+ }
175
+ if (name === "claude") {
176
+ const hasKey = process.env.ANTHROPIC_API_KEY;
177
+ if (!hasKey) {
178
+ return { ok: false, reason: "no ANTHROPIC_API_KEY" };
179
+ }
180
+ return { ok: true, reason: null };
181
+ }
182
+ return { ok: true, reason: null };
183
+ }
184
+
111
185
  /**
112
186
  * Detect if an error is a context/token overflow that should trigger a
113
187
  * new thread instead of retrying on the same (exhausted) session.
@@ -1452,18 +1526,25 @@ export async function launchEphemeralThread(
1452
1526
 
1453
1527
  // ── Try primary SDK ──────────────────────────────────────────────────────
1454
1528
  if (primaryAdapter && !isDisabled(primaryName)) {
1455
- const launcher = await primaryAdapter.load();
1456
- const result = await launcher(prompt, cwd, timeoutMs, extra);
1529
+ const prereq = hasSdkPrerequisites(primaryName);
1530
+ if (!prereq.ok) {
1531
+ console.warn(
1532
+ `${TAG} primary SDK "${primaryName}" missing prerequisites: ${prereq.reason}; trying fallback chain`,
1533
+ );
1534
+ } else {
1535
+ const launcher = await primaryAdapter.load();
1536
+ const result = await launcher(prompt, cwd, timeoutMs, extra);
1457
1537
 
1458
- // If it succeeded, or if the error isn't "not available", return as-is
1459
- if (result.success || !shouldFallbackForSdkError(result.error)) {
1460
- return result;
1461
- }
1538
+ // If it succeeded, or if the error isn't fallback-worthy, return as-is
1539
+ if (result.success || !shouldFallbackForSdkError(result.error)) {
1540
+ return result;
1541
+ }
1462
1542
 
1463
- // Primary SDK not installed — fall through to fallback chain
1464
- console.warn(
1465
- `${TAG} primary SDK "${primaryName}" failed (${result.error}); trying fallback chain`,
1466
- );
1543
+ // Primary SDK not installed — fall through to fallback chain
1544
+ console.warn(
1545
+ `${TAG} primary SDK "${primaryName}" failed (${result.error}); trying fallback chain`,
1546
+ );
1547
+ }
1467
1548
  }
1468
1549
 
1469
1550
  // ── Fallback chain ───────────────────────────────────────────────────────
@@ -1474,6 +1555,15 @@ export async function launchEphemeralThread(
1474
1555
  const adapter = SDK_ADAPTERS[name];
1475
1556
  if (!adapter) continue;
1476
1557
 
1558
+ // Check prerequisites before wasting time trying an unconfigured SDK
1559
+ const prereq = hasSdkPrerequisites(name);
1560
+ if (!prereq.ok) {
1561
+ console.log(
1562
+ `${TAG} skipping fallback SDK "${name}": ${prereq.reason}`,
1563
+ );
1564
+ continue;
1565
+ }
1566
+
1477
1567
  console.log(`${TAG} trying fallback SDK: ${name}`);
1478
1568
  const launcher = await adapter.load();
1479
1569
  const result = await launcher(prompt, cwd, timeoutMs, extra);
@@ -1485,11 +1575,23 @@ export async function launchEphemeralThread(
1485
1575
 
1486
1576
  // ── All SDKs exhausted ───────────────────────────────────────────────────
1487
1577
  const triedSdks = SDK_FALLBACK_ORDER.filter((n) => !isDisabled(n));
1578
+ const configuredSdks = triedSdks.filter((n) => hasSdkPrerequisites(n).ok);
1579
+ const skippedSdks = triedSdks.filter((n) => !hasSdkPrerequisites(n).ok);
1580
+ let errorMsg = `${TAG} no SDK available.`;
1581
+ if (configuredSdks.length > 0) {
1582
+ errorMsg += ` Tried: ${configuredSdks.join(", ")}.`;
1583
+ }
1584
+ if (skippedSdks.length > 0) {
1585
+ errorMsg += ` Skipped (missing credentials): ${skippedSdks.map((n) => `${n} (${hasSdkPrerequisites(n).reason})`).join(", ")}.`;
1586
+ }
1587
+ if (triedSdks.length === 0) {
1588
+ errorMsg += " All SDKs are disabled.";
1589
+ }
1488
1590
  return {
1489
1591
  success: false,
1490
1592
  output: "",
1491
1593
  items: [],
1492
- error: `${TAG} no SDK available. Tried: ${triedSdks.join(", ") || "(all disabled)"}`,
1594
+ error: errorMsg,
1493
1595
  sdk: primaryName,
1494
1596
  threadId: null,
1495
1597
  };
package/agent-prompts.mjs CHANGED
@@ -113,6 +113,12 @@ const PROMPT_DEFS = [
113
113
  filename: "monitor-restart-loop-fix.md",
114
114
  description: "Prompt used when monitor/orchestrator enters restart loops.",
115
115
  },
116
+ {
117
+ key: "taskManager",
118
+ filename: "task-manager.md",
119
+ description:
120
+ "Task management agent prompt with full CRUD access via CLI and REST API.",
121
+ },
116
122
  ];
117
123
 
118
124
  export const AGENT_PROMPT_DEFINITIONS = Object.freeze(
@@ -542,6 +548,38 @@ Constraints:
542
548
  2. Keep behavior stable and production-safe.
543
549
  3. Avoid unrelated refactors.
544
550
  4. Prefer small guardrails.
551
+ `,
552
+ taskManager: `# Bosun Task Manager Agent
553
+
554
+ You manage the backlog via CLI, REST API, or Node.js API.
555
+
556
+ ## Quick Reference
557
+
558
+ CLI:
559
+ bosun task list [--status s] [--json]
560
+ bosun task create '{"title":"..."}' | --title "..." --priority high
561
+ bosun task get <id> [--json]
562
+ bosun task update <id> --status todo --priority critical
563
+ bosun task delete <id>
564
+ bosun task stats [--json]
565
+ bosun task import <file.json>
566
+ bosun task plan --count N
567
+
568
+ REST API (port 18432):
569
+ GET /api/tasks[?status=todo]
570
+ GET /api/tasks/<id>
571
+ POST /api/tasks/create {"title":"...","description":"...","priority":"high"}
572
+ POST /api/tasks/<id>/update {"status":"todo","priority":"critical"}
573
+ DELETE /api/tasks/<id>
574
+ GET /api/tasks/stats
575
+ POST /api/tasks/import {"tasks":[...]}
576
+
577
+ Task title format: [size] type(scope): description
578
+ Sizes: [xs] [s] [m] [l] [xl]
579
+ Types: feat, fix, docs, refactor, test, chore
580
+ Statuses: draft → todo → inprogress → inreview → done
581
+
582
+ See .bosun/agents/task-manager.md for full documentation.
545
583
  `,
546
584
  };
547
585
 
package/cli.mjs CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  mkdirSync,
25
25
  } from "node:fs";
26
26
  import { fileURLToPath } from "node:url";
27
- import { execFileSync, fork, spawn } from "node:child_process";
27
+ import { execFileSync, execSync, fork, spawn } from "node:child_process";
28
28
  import os from "node:os";
29
29
  import { createDaemonCrashTracker } from "./daemon-restart-policy.mjs";
30
30
  import {
@@ -119,6 +119,16 @@ function showHelp() {
119
119
  --workspace-switch <id> Switch active workspace
120
120
  --workspace-add-repo Add repo to workspace (interactive)
121
121
 
122
+ TASK MANAGEMENT
123
+ task list [--status s] [--json] List tasks with optional filters
124
+ task create <json|flags> Create a new task from JSON or flags
125
+ task get <id> [--json] Show task details by ID (prefix match)
126
+ task update <id> <patch> Update task fields (JSON or flags)
127
+ task delete <id> Delete a task
128
+ task stats [--json] Show aggregate task statistics
129
+ task import <file.json> Bulk import tasks from JSON
130
+ task plan [--count N] Trigger AI task planner
131
+
122
132
  VIBE-KANBAN
123
133
  --no-vk-spawn Don't auto-spawn Vibe-Kanban
124
134
  --vk-ensure-interval <ms> VK health check interval (default: 60000)
@@ -440,7 +450,27 @@ function startDaemon() {
440
450
  detached: true,
441
451
  stdio: "ignore",
442
452
  windowsHide: process.platform === "win32",
443
- env: { ...process.env, BOSUN_DAEMON: "1" },
453
+ env: {
454
+ ...process.env,
455
+ BOSUN_DAEMON: "1",
456
+ // Propagate the bosun package directory so repo-root detection works
457
+ // even when the daemon child's cwd is not inside a git repo.
458
+ BOSUN_DIR: process.env.BOSUN_DIR || fileURLToPath(new URL(".", import.meta.url)),
459
+ // Propagate REPO_ROOT if available; otherwise resolve from cwd before detaching
460
+ ...(process.env.REPO_ROOT
461
+ ? {}
462
+ : (() => {
463
+ try {
464
+ const gitRoot = execSync("git rev-parse --show-toplevel", {
465
+ encoding: "utf8",
466
+ stdio: ["ignore", "pipe", "ignore"],
467
+ }).trim();
468
+ return gitRoot ? { REPO_ROOT: gitRoot } : {};
469
+ } catch {
470
+ return {};
471
+ }
472
+ })()),
473
+ },
444
474
  // Use home dir so spawn never inherits a deleted CWD (e.g. old git worktree)
445
475
  cwd: os.homedir(),
446
476
  },
@@ -595,6 +625,15 @@ async function main() {
595
625
  return;
596
626
  }
597
627
 
628
+ // Handle 'task' subcommand — must come before flag-based routing
629
+ if (args[0] === "task" || args.includes("--task")) {
630
+ const { runTaskCli } = await import("./task-cli.mjs");
631
+ // Pass everything after "task" to the task CLI
632
+ const taskArgs = args[0] === "task" ? args.slice(1) : args.slice(args.indexOf("--task") + 1);
633
+ await runTaskCli(taskArgs);
634
+ process.exit(0);
635
+ }
636
+
598
637
  // Handle --doctor
599
638
  if (args.includes("--doctor") || args.includes("doctor")) {
600
639
  const { runConfigDoctor, formatConfigDoctorReport } =