bosun 0.42.0 → 0.42.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.
Files changed (63) hide show
  1. package/.env.example +12 -0
  2. package/README.md +2 -0
  3. package/agent/agent-pool.mjs +34 -1
  4. package/agent/agent-work-report.mjs +89 -3
  5. package/agent/analyze-agent-work-helpers.mjs +14 -0
  6. package/agent/analyze-agent-work.mjs +23 -3
  7. package/agent/primary-agent.mjs +23 -1
  8. package/bosun-tui.mjs +4 -3
  9. package/bosun.schema.json +1 -1
  10. package/config/config.mjs +58 -0
  11. package/config/workspace-health.mjs +36 -6
  12. package/git/diff-stats.mjs +550 -124
  13. package/github/github-app-auth.mjs +9 -5
  14. package/infra/maintenance.mjs +13 -6
  15. package/infra/monitor.mjs +398 -10
  16. package/infra/runtime-accumulator.mjs +9 -1
  17. package/infra/session-tracker.mjs +163 -1
  18. package/infra/tui-bridge.mjs +415 -0
  19. package/infra/worktree-recovery-state.mjs +159 -0
  20. package/kanban/kanban-adapter.mjs +41 -8
  21. package/lib/repo-map.mjs +411 -0
  22. package/package.json +140 -137
  23. package/server/ui-server.mjs +953 -59
  24. package/shell/codex-config.mjs +34 -8
  25. package/task/task-cli.mjs +93 -19
  26. package/task/task-executor.mjs +397 -8
  27. package/task/task-store.mjs +194 -1
  28. package/telegram/telegram-bot.mjs +267 -18
  29. package/tools/vitest-runner.mjs +108 -0
  30. package/tui/app.mjs +252 -148
  31. package/tui/components/status-header.mjs +88 -131
  32. package/tui/lib/ws-bridge.mjs +125 -35
  33. package/tui/screens/agents-screen-helpers.mjs +219 -0
  34. package/tui/screens/agents.mjs +287 -270
  35. package/tui/screens/status.mjs +51 -189
  36. package/tui/screens/tasks.mjs +41 -253
  37. package/ui/app.js +52 -23
  38. package/ui/components/chat-view.js +263 -84
  39. package/ui/components/diff-viewer.js +324 -140
  40. package/ui/components/kanban-board.js +13 -9
  41. package/ui/components/session-list.js +111 -41
  42. package/ui/demo-defaults.js +481 -59
  43. package/ui/demo.html +32 -0
  44. package/ui/modules/session-api.js +320 -5
  45. package/ui/modules/stream-timeline.js +356 -0
  46. package/ui/modules/telegram.js +5 -2
  47. package/ui/modules/worktree-recovery.js +85 -0
  48. package/ui/styles.css +44 -0
  49. package/ui/tabs/chat.js +19 -4
  50. package/ui/tabs/dashboard.js +22 -0
  51. package/ui/tabs/infra.js +25 -0
  52. package/ui/tabs/tasks.js +119 -11
  53. package/voice/voice-auth-manager.mjs +10 -5
  54. package/workflow/workflow-engine.mjs +179 -1
  55. package/workflow/workflow-nodes.mjs +872 -16
  56. package/workflow/workflow-templates.mjs +4 -0
  57. package/workflow-templates/github.mjs +2 -1
  58. package/workflow-templates/planning.mjs +2 -1
  59. package/workflow-templates/sub-workflows.mjs +10 -0
  60. package/workflow-templates/task-batch.mjs +9 -8
  61. package/workflow-templates/task-execution.mjs +30 -12
  62. package/workflow-templates/task-lifecycle.mjs +59 -4
  63. package/workspace/shared-knowledge.mjs +409 -155
@@ -0,0 +1,415 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export const TUI_EVENT_TYPES = Object.freeze([
5
+ "monitor:stats",
6
+ "sessions:update",
7
+ "session:event",
8
+ "logs:stream",
9
+ "workflow:status",
10
+ "tasks:update",
11
+ ]);
12
+
13
+ const RATE_LIMIT_BUCKET_SCHEMA = {
14
+ type: "object",
15
+ required: ["primary", "secondary", "credits", "unit"],
16
+ additionalProperties: false,
17
+ properties: {
18
+ primary: { type: ["number", "null"] },
19
+ secondary: { type: ["number", "null"] },
20
+ credits: { type: ["number", "null"] },
21
+ unit: { type: "string", minLength: 1 },
22
+ },
23
+ };
24
+
25
+ const SESSION_SUMMARY_SCHEMA = {
26
+ type: "object",
27
+ required: [
28
+ "id",
29
+ "taskId",
30
+ "title",
31
+ "type",
32
+ "status",
33
+ "workspaceId",
34
+ "workspaceDir",
35
+ "branch",
36
+ "turnCount",
37
+ "createdAt",
38
+ "lastActiveAt",
39
+ "idleMs",
40
+ "elapsedMs",
41
+ "recommendation",
42
+ "preview",
43
+ "lastMessage",
44
+ "insights",
45
+ ],
46
+ additionalProperties: true,
47
+ properties: {
48
+ id: { type: "string", minLength: 1 },
49
+ taskId: { type: "string", minLength: 1 },
50
+ title: { type: ["string", "null"] },
51
+ type: { type: "string", minLength: 1 },
52
+ status: { type: "string", minLength: 1 },
53
+ workspaceId: { type: ["string", "null"] },
54
+ workspaceDir: { type: ["string", "null"] },
55
+ branch: { type: ["string", "null"] },
56
+ turnCount: { type: "number", minimum: 0 },
57
+ createdAt: { type: "string", minLength: 1 },
58
+ lastActiveAt: { type: "string", minLength: 1 },
59
+ idleMs: { type: "number", minimum: 0 },
60
+ elapsedMs: { type: "number", minimum: 0 },
61
+ recommendation: { type: "string" },
62
+ preview: { type: ["string", "null"] },
63
+ lastMessage: { type: ["string", "null"] },
64
+ insights: {},
65
+ },
66
+ };
67
+
68
+ export const TUI_EVENT_SCHEMAS = Object.freeze({
69
+ "monitor:stats": {
70
+ type: "object",
71
+ required: [
72
+ "activeAgents",
73
+ "maxAgents",
74
+ "tokensIn",
75
+ "tokensOut",
76
+ "tokensTotal",
77
+ "throughputTps",
78
+ "uptimeMs",
79
+ "rateLimits",
80
+ ],
81
+ additionalProperties: false,
82
+ properties: {
83
+ activeAgents: { type: "number", minimum: 0 },
84
+ maxAgents: { type: "number", minimum: 0 },
85
+ tokensIn: { type: "number", minimum: 0 },
86
+ tokensOut: { type: "number", minimum: 0 },
87
+ tokensTotal: { type: "number", minimum: 0 },
88
+ throughputTps: { type: "number", minimum: 0 },
89
+ uptimeMs: { type: "number", minimum: 0 },
90
+ rateLimits: {
91
+ type: "object",
92
+ additionalProperties: RATE_LIMIT_BUCKET_SCHEMA,
93
+ },
94
+ },
95
+ },
96
+ "sessions:update": {
97
+ type: "array",
98
+ items: SESSION_SUMMARY_SCHEMA,
99
+ },
100
+ "session:event": {
101
+ type: "object",
102
+ required: ["sessionId", "taskId", "session", "event"],
103
+ additionalProperties: false,
104
+ properties: {
105
+ sessionId: { type: "string", minLength: 1 },
106
+ taskId: { type: "string", minLength: 1 },
107
+ session: {
108
+ type: "object",
109
+ required: ["id", "taskId", "type", "status", "lastActiveAt", "turnCount"],
110
+ additionalProperties: true,
111
+ properties: {
112
+ id: { type: "string", minLength: 1 },
113
+ taskId: { type: "string", minLength: 1 },
114
+ type: { type: "string", minLength: 1 },
115
+ status: { type: "string", minLength: 1 },
116
+ lastActiveAt: { type: "string", minLength: 1 },
117
+ turnCount: { type: "number", minimum: 0 },
118
+ },
119
+ },
120
+ event: {
121
+ type: "object",
122
+ required: ["kind"],
123
+ additionalProperties: true,
124
+ properties: {
125
+ kind: { type: "string", enum: ["message", "state"] },
126
+ },
127
+ },
128
+ },
129
+ },
130
+ "logs:stream": {
131
+ type: "object",
132
+ required: ["logType", "raw", "line", "level", "timestamp", "filePath"],
133
+ additionalProperties: false,
134
+ properties: {
135
+ logType: { type: "string", minLength: 1 },
136
+ query: { type: ["string", "null"] },
137
+ filePath: { type: ["string", "null"] },
138
+ line: { type: "string" },
139
+ raw: { type: "string" },
140
+ level: { type: "string", minLength: 1 },
141
+ timestamp: { type: ["string", "null"] },
142
+ },
143
+ },
144
+ "workflow:status": {
145
+ type: "object",
146
+ required: ["runId", "workflowId", "eventType", "status", "timestamp"],
147
+ additionalProperties: false,
148
+ properties: {
149
+ runId: { type: "string", minLength: 1 },
150
+ workflowId: { type: "string", minLength: 1 },
151
+ workflowName: { type: ["string", "null"] },
152
+ eventType: { type: "string", minLength: 1 },
153
+ status: { type: "string", minLength: 1 },
154
+ nodeId: { type: ["string", "null"] },
155
+ nodeType: { type: ["string", "null"] },
156
+ nodeLabel: { type: ["string", "null"] },
157
+ error: { type: ["string", "null"] },
158
+ durationMs: { type: ["number", "null"], minimum: 0 },
159
+ timestamp: { type: "number", minimum: 0 },
160
+ meta: { type: ["object", "null"] },
161
+ },
162
+ },
163
+ "tasks:update": {
164
+ type: "object",
165
+ required: ["reason", "sourceEvent", "patch"],
166
+ additionalProperties: false,
167
+ properties: {
168
+ reason: { type: "string", minLength: 1 },
169
+ sourceEvent: { type: "string", minLength: 1 },
170
+ taskId: { type: ["string", "null"] },
171
+ taskIds: {
172
+ type: ["array", "null"],
173
+ items: { type: "string", minLength: 1 },
174
+ },
175
+ status: { type: ["string", "null"] },
176
+ workspaceId: { type: ["string", "null"] },
177
+ projectId: { type: ["string", "null"] },
178
+ patch: { type: "object", additionalProperties: true },
179
+ },
180
+ },
181
+ });
182
+
183
+ function numberOr(value, fallback = 0) {
184
+ const numeric = Number(value);
185
+ return Number.isFinite(numeric) ? numeric : fallback;
186
+ }
187
+
188
+ function nonNegativeNumber(value, fallback = 0) {
189
+ return Math.max(0, numberOr(value, fallback));
190
+ }
191
+
192
+ function roundMetric(value, precision = 4) {
193
+ const numeric = nonNegativeNumber(value, 0);
194
+ return Number(numeric.toFixed(precision));
195
+ }
196
+
197
+ function resolveCacheDir({ cacheDir, configDir } = {}) {
198
+ if (cacheDir) return resolve(String(cacheDir));
199
+ return resolve(String(configDir || process.cwd()), ".cache");
200
+ }
201
+
202
+ function normalizeRateLimits(rateLimits = {}) {
203
+ const normalized = {};
204
+ for (const [provider, bucket] of Object.entries(rateLimits || {})) {
205
+ const key = String(provider || "").trim();
206
+ if (!key || !bucket || typeof bucket !== "object") continue;
207
+ normalized[key] = {
208
+ primary: Number.isFinite(Number(bucket.primary)) ? Number(bucket.primary) : null,
209
+ secondary: Number.isFinite(Number(bucket.secondary)) ? Number(bucket.secondary) : null,
210
+ credits: bucket.credits == null ? null : (Number.isFinite(Number(bucket.credits)) ? Number(bucket.credits) : null),
211
+ unit: String(bucket.unit || "count").trim() || "count",
212
+ };
213
+ }
214
+ return normalized;
215
+ }
216
+
217
+ function sumRuntimeTokens(runtimeStats = {}) {
218
+ const sessions = Array.isArray(runtimeStats?.sessions) ? runtimeStats.sessions : [];
219
+ return sessions.reduce((acc, session) => {
220
+ acc.tokensIn += nonNegativeNumber(session?.inputTokens, 0);
221
+ acc.tokensOut += nonNegativeNumber(session?.outputTokens, 0);
222
+ return acc;
223
+ }, { tokensIn: 0, tokensOut: 0 });
224
+ }
225
+
226
+ export function buildMonitorStatsPayload({ agentPool, runtimeStats = {}, uptimeMs = 0 } = {}) {
227
+ const agentPoolStats = typeof agentPool?.getTuiStats === "function"
228
+ ? (agentPool.getTuiStats() || {})
229
+ : (agentPool && typeof agentPool === "object" ? agentPool : {});
230
+
231
+ const runtimeTokenTotals = sumRuntimeTokens(runtimeStats);
232
+ const tokensIn = nonNegativeNumber(
233
+ agentPoolStats.tokensIn,
234
+ runtimeStats.totalInputTokens ?? runtimeTokenTotals.tokensIn,
235
+ );
236
+ const tokensOut = nonNegativeNumber(
237
+ agentPoolStats.tokensOut,
238
+ runtimeStats.totalOutputTokens ?? runtimeTokenTotals.tokensOut,
239
+ );
240
+ const tokensTotal = nonNegativeNumber(agentPoolStats.tokensTotal, tokensIn + tokensOut);
241
+ const resolvedUptimeMs = nonNegativeNumber(
242
+ uptimeMs,
243
+ runtimeStats.startedAt ? Date.now() - Number(runtimeStats.startedAt) : 0,
244
+ );
245
+ const throughputTps = Number.isFinite(Number(agentPoolStats.throughputTps))
246
+ ? roundMetric(agentPoolStats.throughputTps)
247
+ : (resolvedUptimeMs > 0 ? roundMetric(tokensTotal / (resolvedUptimeMs / 1000)) : 0);
248
+
249
+ return {
250
+ activeAgents: nonNegativeNumber(agentPoolStats.activeAgents, 0),
251
+ maxAgents: nonNegativeNumber(agentPoolStats.maxAgents, 0),
252
+ tokensIn,
253
+ tokensOut,
254
+ tokensTotal,
255
+ throughputTps,
256
+ uptimeMs: resolvedUptimeMs,
257
+ rateLimits: normalizeRateLimits(agentPoolStats.rateLimits),
258
+ };
259
+ }
260
+
261
+ export function buildSessionsUpdatePayload(sessions = []) {
262
+ return Array.isArray(sessions) ? sessions.map((session) => ({ ...session })) : [];
263
+ }
264
+
265
+ export function buildSessionEventPayload(payload = {}) {
266
+ const event = payload?.event && typeof payload.event === "object"
267
+ ? { ...payload.event }
268
+ : { kind: "message", ...(payload?.message ? { message: payload.message } : {}) };
269
+ return {
270
+ sessionId: String(payload?.sessionId || payload?.session?.id || "").trim(),
271
+ taskId: String(payload?.taskId || payload?.session?.taskId || "").trim(),
272
+ session: payload?.session && typeof payload.session === "object" ? { ...payload.session } : {},
273
+ event,
274
+ };
275
+ }
276
+
277
+ export function buildWorkflowStatusPayload(payload = {}) {
278
+ return {
279
+ runId: String(payload?.runId || "").trim(),
280
+ workflowId: String(payload?.workflowId || "").trim(),
281
+ workflowName: String(payload?.workflowName || payload?.name || "").trim() || null,
282
+ eventType: String(payload?.eventType || payload?.event || "").trim(),
283
+ status: String(payload?.status || "unknown").trim() || "unknown",
284
+ nodeId: String(payload?.nodeId || "").trim() || null,
285
+ nodeType: String(payload?.nodeType || "").trim() || null,
286
+ nodeLabel: String(payload?.nodeLabel || "").trim() || null,
287
+ error: String(payload?.error || "").trim() || null,
288
+ durationMs: Number.isFinite(Number(payload?.durationMs)) ? Number(payload.durationMs) : null,
289
+ timestamp: nonNegativeNumber(payload?.timestamp, Date.now()),
290
+ meta: payload?.meta && typeof payload.meta === "object" ? { ...payload.meta } : null,
291
+ };
292
+ }
293
+
294
+ export function buildLogStreamPayload({ logType, query = null, filePath = null, line = "" } = {}) {
295
+ const raw = String(line || "");
296
+ const levelMatch = raw.match(/\b(trace|debug|info|warn|warning|error|fatal)\b/i);
297
+ const timestampMatch = raw.match(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/);
298
+ return {
299
+ logType: String(logType || "system").trim() || "system",
300
+ query: query == null ? null : String(query),
301
+ filePath: filePath == null ? null : String(filePath),
302
+ line: raw,
303
+ raw,
304
+ level: levelMatch ? levelMatch[1].toLowerCase().replace("warning", "warn") : "info",
305
+ timestamp: timestampMatch ? timestampMatch[0] : null,
306
+ };
307
+ }
308
+
309
+ export function buildTasksUpdatePayload(payload = {}, { sourceEvent = "invalidate" } = {}) {
310
+ const normalized = payload && typeof payload === "object" ? payload : {};
311
+ const patch = normalized.patch && typeof normalized.patch === "object"
312
+ ? { ...normalized.patch }
313
+ : { ...normalized };
314
+ const reason = String(normalized.reason || sourceEvent || "tasks-changed").trim() || "tasks-changed";
315
+ const taskIds = Array.isArray(normalized.taskIds)
316
+ ? normalized.taskIds.map((taskId) => String(taskId || "").trim()).filter(Boolean)
317
+ : null;
318
+ return {
319
+ reason,
320
+ sourceEvent: String(sourceEvent || "invalidate").trim() || "invalidate",
321
+ taskId: String(normalized.taskId || normalized.id || "").trim() || null,
322
+ taskIds: taskIds && taskIds.length ? taskIds : null,
323
+ status: String(normalized.status || "").trim() || null,
324
+ workspaceId: String(normalized.workspaceId || "").trim() || null,
325
+ projectId: String(normalized.projectId || "").trim() || null,
326
+ patch,
327
+ };
328
+ }
329
+
330
+ export function resolveTuiAuthToken({ env = process.env, cacheDir, configDir = process.cwd() } = {}) {
331
+ const envCandidates = [
332
+ env?.BOSUN_TUI_AUTH_TOKEN,
333
+ env?.BOSUN_TUI_WS_TOKEN,
334
+ env?.BOSUN_UI_TOKEN,
335
+ env?.BOSUN_WS_TOKEN,
336
+ ];
337
+ for (const candidate of envCandidates) {
338
+ const token = String(candidate || "").trim();
339
+ if (token) return token;
340
+ }
341
+
342
+ const resolvedCacheDir = resolveCacheDir({ cacheDir, configDir });
343
+ const candidateFiles = [
344
+ resolve(resolvedCacheDir, "ui-token"),
345
+ resolve(resolvedCacheDir, "ui-session-token.json"),
346
+ ];
347
+ for (const filePath of candidateFiles) {
348
+ try {
349
+ if (!existsSync(filePath)) continue;
350
+ const raw = readFileSync(filePath, "utf8").trim();
351
+ if (!raw) continue;
352
+ if (raw.startsWith("{")) {
353
+ const parsed = JSON.parse(raw);
354
+ const token = String(parsed?.token || "").trim();
355
+ if (token) return token;
356
+ continue;
357
+ }
358
+ return raw;
359
+ } catch {
360
+ // ignore invalid cache files
361
+ }
362
+ }
363
+ return "";
364
+ }
365
+
366
+ export function persistCompatibleTuiAuthToken(token, { cacheDir, configDir = process.cwd() } = {}) {
367
+ const normalized = String(token || "").trim();
368
+ if (!normalized) return "";
369
+ const resolvedCacheDir = resolveCacheDir({ cacheDir, configDir });
370
+ mkdirSync(resolvedCacheDir, { recursive: true });
371
+ writeFileSync(resolve(resolvedCacheDir, "ui-token"), `${normalized}\n`, "utf8");
372
+ return normalized;
373
+ }
374
+
375
+ export function createTuiStatsEmitter({ intervalMs = 2000, getPayload, emit } = {}) {
376
+ let timer = null;
377
+ let inFlight = null;
378
+
379
+ const tick = async () => {
380
+ if (inFlight) return inFlight;
381
+ inFlight = Promise.resolve()
382
+ .then(() => (typeof getPayload === "function" ? getPayload() : null))
383
+ .then(async (payload) => {
384
+ if (payload && typeof emit === "function") {
385
+ await emit(payload);
386
+ }
387
+ return payload;
388
+ })
389
+ .finally(() => {
390
+ inFlight = null;
391
+ });
392
+ return inFlight;
393
+ };
394
+
395
+ return {
396
+ async tick() {
397
+ return tick();
398
+ },
399
+ start() {
400
+ if (timer) return;
401
+ timer = setInterval(() => {
402
+ void tick();
403
+ }, Math.max(250, Number(intervalMs) || 2000));
404
+ timer.unref?.();
405
+ },
406
+ stop() {
407
+ if (!timer) return;
408
+ clearInterval(timer);
409
+ timer = null;
410
+ },
411
+ get isRunning() {
412
+ return Boolean(timer);
413
+ },
414
+ };
415
+ }
@@ -0,0 +1,159 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ const DEFAULT_RECOVERY_STATE = Object.freeze({
5
+ health: "healthy",
6
+ failureStreak: 0,
7
+ failureCount: 0,
8
+ successCount: 0,
9
+ lastUpdatedAt: null,
10
+ lastFailureAt: null,
11
+ lastRecoveredAt: null,
12
+ lastHealthyAt: null,
13
+ recentEvents: Object.freeze([]),
14
+ });
15
+
16
+ const MAX_RECOVERY_EVENTS = 12;
17
+ const VALID_HEALTH = new Set(["healthy", "recovered", "failing", "degraded"]);
18
+ const VALID_OUTCOMES = new Set(["healthy_noop", "recreated", "recreation_failed"]);
19
+
20
+ function getStatusPath(repoRoot) {
21
+ const override = String(process.env.STATUS_FILE || "").trim();
22
+ if (override) {
23
+ // If override is an absolute path, resolve it directly;
24
+ // if it's relative, resolve it against the repo root.
25
+ const isWindowsAbsolute = /^[a-zA-Z]:[\\/]/.test(override) || override.startsWith("\\\\");
26
+ const isPosixAbsolute = override.startsWith("/");
27
+ if (isWindowsAbsolute || isPosixAbsolute) {
28
+ return resolve(override);
29
+ }
30
+ return resolve(repoRoot, override);
31
+ }
32
+ return resolve(repoRoot, ".cache", "ve-orchestrator-status.json");
33
+ }
34
+
35
+ function toIsoTimestamp(value) {
36
+ const text = String(value || "").trim();
37
+ if (!text) return new Date().toISOString();
38
+ const parsed = new Date(text);
39
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : new Date().toISOString();
40
+ }
41
+
42
+ function normalizeDetectedIssues(input) {
43
+ const values = Array.isArray(input) ? input : [input];
44
+ const seen = new Set();
45
+ const issues = [];
46
+ for (const value of values) {
47
+ const normalized = String(value || "").trim();
48
+ if (!normalized || seen.has(normalized)) continue;
49
+ seen.add(normalized);
50
+ issues.push(normalized);
51
+ }
52
+ return issues;
53
+ }
54
+
55
+ function normalizeRecoveryEvent(event = {}) {
56
+ const outcome = String(event?.outcome || "").trim().toLowerCase();
57
+ return {
58
+ outcome: VALID_OUTCOMES.has(outcome) ? outcome : "healthy_noop",
59
+ reason: String(event?.reason || "").trim() || null,
60
+ branch: String(event?.branch || "").trim() || null,
61
+ taskId: String(event?.taskId || "").trim() || null,
62
+ worktreePath: String(event?.worktreePath || "").trim() || null,
63
+ phase: String(event?.phase || "").trim() || null,
64
+ error: String(event?.error || "").trim() || null,
65
+ detectedIssues: normalizeDetectedIssues(event?.detectedIssues),
66
+ timestamp: toIsoTimestamp(event?.timestamp),
67
+ };
68
+ }
69
+
70
+ function normalizeWorktreeRecoveryState(input = null) {
71
+ const source = input && typeof input === "object" ? input : {};
72
+ const health = String(source.health || "").trim().toLowerCase();
73
+ return {
74
+ health: VALID_HEALTH.has(health) ? health : "healthy",
75
+ failureStreak: Math.max(0, Number.parseInt(String(source.failureStreak || 0), 10) || 0),
76
+ failureCount: Math.max(0, Number.parseInt(String(source.failureCount || 0), 10) || 0),
77
+ successCount: Math.max(0, Number.parseInt(String(source.successCount || 0), 10) || 0),
78
+ lastUpdatedAt: source.lastUpdatedAt ? toIsoTimestamp(source.lastUpdatedAt) : null,
79
+ lastFailureAt: source.lastFailureAt ? toIsoTimestamp(source.lastFailureAt) : null,
80
+ lastRecoveredAt: source.lastRecoveredAt ? toIsoTimestamp(source.lastRecoveredAt) : null,
81
+ lastHealthyAt: source.lastHealthyAt ? toIsoTimestamp(source.lastHealthyAt) : null,
82
+ recentEvents: Array.isArray(source.recentEvents)
83
+ ? source.recentEvents.map((event) => normalizeRecoveryEvent(event)).slice(0, MAX_RECOVERY_EVENTS)
84
+ : [],
85
+ };
86
+ }
87
+
88
+ function buildNextWorktreeRecoveryState(currentState, event) {
89
+ const state = normalizeWorktreeRecoveryState(currentState);
90
+ const normalizedEvent = normalizeRecoveryEvent(event);
91
+ const nextState = {
92
+ ...state,
93
+ lastUpdatedAt: normalizedEvent.timestamp,
94
+ };
95
+
96
+ if (normalizedEvent.outcome === "healthy_noop") {
97
+ return {
98
+ ...nextState,
99
+ health: state.failureStreak > 0 ? state.health : "healthy",
100
+ lastHealthyAt: normalizedEvent.timestamp,
101
+ };
102
+ }
103
+
104
+ const recentEvents = [normalizedEvent, ...state.recentEvents].slice(0, MAX_RECOVERY_EVENTS);
105
+ if (normalizedEvent.outcome === "recreated") {
106
+ return {
107
+ ...nextState,
108
+ health: "recovered",
109
+ failureStreak: 0,
110
+ successCount: state.successCount + 1,
111
+ lastRecoveredAt: normalizedEvent.timestamp,
112
+ lastHealthyAt: normalizedEvent.timestamp,
113
+ recentEvents,
114
+ };
115
+ }
116
+
117
+ const failureStreak = state.failureStreak + 1;
118
+ return {
119
+ ...nextState,
120
+ health: failureStreak > 1 ? "degraded" : "failing",
121
+ failureStreak,
122
+ failureCount: state.failureCount + 1,
123
+ lastFailureAt: normalizedEvent.timestamp,
124
+ recentEvents,
125
+ };
126
+ }
127
+
128
+ async function readStatusDocument(repoRoot) {
129
+ const statusPath = getStatusPath(repoRoot);
130
+ try {
131
+ const raw = await readFile(statusPath, "utf8");
132
+ const parsed = JSON.parse(raw);
133
+ return parsed && typeof parsed === "object" ? parsed : {};
134
+ } catch {
135
+ return {};
136
+ }
137
+ }
138
+
139
+ async function readWorktreeRecoveryState(repoRoot) {
140
+ const document = await readStatusDocument(repoRoot);
141
+ return normalizeWorktreeRecoveryState(document.worktreeRecovery);
142
+ }
143
+
144
+ async function recordWorktreeRecoveryEvent(repoRoot, event) {
145
+ const statusPath = getStatusPath(repoRoot);
146
+ const document = await readStatusDocument(repoRoot);
147
+ document.worktreeRecovery = buildNextWorktreeRecoveryState(document.worktreeRecovery, event);
148
+ await mkdir(dirname(statusPath), { recursive: true });
149
+ await writeFile(statusPath, JSON.stringify(document, null, 2), "utf8");
150
+ return document.worktreeRecovery;
151
+ }
152
+
153
+ export {
154
+ buildNextWorktreeRecoveryState,
155
+ normalizeRecoveryEvent,
156
+ normalizeWorktreeRecoveryState,
157
+ readWorktreeRecoveryState,
158
+ recordWorktreeRecoveryEvent,
159
+ };
@@ -137,6 +137,23 @@ function normaliseStatus(raw) {
137
137
  return STATUS_MAP[key] || "todo";
138
138
  }
139
139
 
140
+ function resolveCreateTaskInput(projectIdOrTaskData, taskDataArg = {}) {
141
+ const payloadOnlyCall =
142
+ projectIdOrTaskData &&
143
+ typeof projectIdOrTaskData === "object" &&
144
+ !Array.isArray(projectIdOrTaskData);
145
+ const rawTaskData = payloadOnlyCall ? projectIdOrTaskData : (taskDataArg || {});
146
+ const projectId = payloadOnlyCall
147
+ ? rawTaskData?.projectId ?? rawTaskData?.project_id
148
+ : projectIdOrTaskData;
149
+ const {
150
+ projectId: _ignoredProjectId,
151
+ project_id: _ignoredProjectIdSnake,
152
+ ...taskData
153
+ } = rawTaskData || {};
154
+ return { projectId, taskData };
155
+ }
156
+
140
157
  const STATUS_LABEL_KEYS = new Set([
141
158
  "draft",
142
159
  "todo",
@@ -1008,7 +1025,11 @@ class InternalAdapter {
1008
1025
  return this._normalizeTask(updated);
1009
1026
  }
1010
1027
 
1011
- async createTask(projectId, taskData = {}) {
1028
+ async createTask(projectIdOrTaskData, taskDataArg = {}) {
1029
+ const { projectId, taskData } = resolveCreateTaskInput(
1030
+ projectIdOrTaskData,
1031
+ taskDataArg,
1032
+ );
1012
1033
  const id = String(taskData.id || randomUUID());
1013
1034
  const tags = normalizeTags(taskData.tags || taskData.labels || []);
1014
1035
  const draft = Boolean(taskData.draft || taskData.status === "draft");
@@ -1551,7 +1572,11 @@ class VKAdapter {
1551
1572
  return this._normaliseTask(task);
1552
1573
  }
1553
1574
 
1554
- async createTask(projectId, taskData) {
1575
+ async createTask(projectIdOrTaskData, taskDataArg = {}) {
1576
+ const { projectId, taskData } = resolveCreateTaskInput(
1577
+ projectIdOrTaskData,
1578
+ taskDataArg,
1579
+ );
1555
1580
  const fetchVk = await this._getFetchVk();
1556
1581
  const tags = normalizeTags(taskData?.tags || taskData?.labels || []);
1557
1582
  const draft = Boolean(taskData?.draft || taskData?.status === "draft");
@@ -3617,7 +3642,8 @@ class GitHubIssuesAdapter {
3617
3642
  return result?.data?.convertProjectV2DraftIssueItemToIssue?.issue || null;
3618
3643
  }
3619
3644
 
3620
- async createTask(_projectId, taskData) {
3645
+ async createTask(projectIdOrTaskData, taskDataArg = {}) {
3646
+ const { taskData } = resolveCreateTaskInput(projectIdOrTaskData, taskDataArg);
3621
3647
  const normalizedTitle = String(taskData?.title || "").trim();
3622
3648
  if (!normalizedTitle) {
3623
3649
  throw new Error("[kanban] github createTask requires non-empty title");
@@ -5417,7 +5443,11 @@ class JiraAdapter {
5417
5443
  return this.getTask(issueKey);
5418
5444
  }
5419
5445
 
5420
- async createTask(projectId, taskData = {}) {
5446
+ async createTask(projectIdOrTaskData, taskDataArg = {}) {
5447
+ const { projectId, taskData } = resolveCreateTaskInput(
5448
+ projectIdOrTaskData,
5449
+ taskDataArg,
5450
+ );
5421
5451
  const projectKey = this._normalizeProjectKey(projectId);
5422
5452
  if (!projectKey) {
5423
5453
  throw new Error(
@@ -6082,12 +6112,16 @@ export async function updateTask(taskId, patch) {
6082
6112
  return adapter.getTask(taskId);
6083
6113
  }
6084
6114
 
6085
- export async function createTask(projectId, taskData) {
6115
+ export async function createTask(projectIdOrTaskData, taskDataArg = {}) {
6116
+ const { projectId, taskData } = resolveCreateTaskInput(
6117
+ projectIdOrTaskData,
6118
+ taskDataArg,
6119
+ );
6086
6120
  const result = await getKanbanAdapter().createTask(projectId, taskData);
6087
6121
  emitKanbanEvent("task.created", {
6088
- projectId,
6122
+ projectId: projectId ?? result?.projectId ?? result?.project_id ?? null,
6089
6123
  taskId: result?.id || null,
6090
- title: taskData?.title || null,
6124
+ title: taskData?.title ?? result?.title ?? null,
6091
6125
  });
6092
6126
  return result;
6093
6127
  }
@@ -6164,4 +6198,3 @@ export async function unmarkTaskIgnored(taskId) {
6164
6198
  );
6165
6199
  return false;
6166
6200
  }
6167
-