@virtengine/openfleet 0.25.0

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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,4491 @@
1
+ /**
2
+ * kanban-adapter.mjs — Unified Kanban Board Abstraction
3
+ *
4
+ * Provides a common interface over multiple task-tracking backends:
5
+ * - Internal Store — default, source-of-truth local kanban
6
+ * - Vibe-Kanban (VK) — optional external adapter
7
+ * - GitHub Issues — native GitHub integration with shared state persistence
8
+ * - Jira — enterprise project management via Jira REST v3
9
+ *
10
+ * This module handles TASK LIFECYCLE (tracking, status, metadata) only.
11
+ * Code execution is handled separately by agent-pool.mjs.
12
+ *
13
+ * Configuration:
14
+ * - `KANBAN_BACKEND` env var: "internal" | "vk" | "github" | "jira" (default: "internal")
15
+ * - `openfleet.config.json` → `kanban.backend` field
16
+ *
17
+ * EXPORTS:
18
+ * getKanbanAdapter() → Returns the configured adapter instance
19
+ * setKanbanBackend(name) → Switch backend at runtime
20
+ * getAvailableBackends() → List available backends
21
+ * getKanbanBackendName() → Get active backend name
22
+ * listProjects() → Convenience: adapter.listProjects()
23
+ * listTasks(projectId, f?) → Convenience: adapter.listTasks()
24
+ * getTask(taskId) → Convenience: adapter.getTask()
25
+ * updateTaskStatus(id, s, opts?) → Convenience: adapter.updateTaskStatus()
26
+ * createTask(projId, data) → Convenience: adapter.createTask()
27
+ * deleteTask(taskId) → Convenience: adapter.deleteTask()
28
+ * addComment(taskId, body) → Convenience: adapter.addComment()
29
+ * persistSharedStateToIssue(id, state) → GitHub/Jira: persist agent state to issue
30
+ * readSharedStateFromIssue(id) → GitHub/Jira: read agent state from issue
31
+ * markTaskIgnored(id, reason) → GitHub/Jira: mark task as ignored
32
+ *
33
+ * Each adapter implements the KanbanAdapter interface:
34
+ * - listTasks(projectId, filters?) → Task[]
35
+ * - getTask(taskId) → Task
36
+ * - updateTaskStatus(taskId, status, opts?)→ Task
37
+ * - createTask(projectId, task) → Task
38
+ * - deleteTask(taskId) → boolean
39
+ * - listProjects() → Project[]
40
+ * - addComment(taskId, body) → boolean
41
+ *
42
+ * GitHub adapter implements shared state methods:
43
+ * - persistSharedStateToIssue(num, state) → boolean
44
+ * - readSharedStateFromIssue(num) → SharedState|null
45
+ * - markTaskIgnored(num, reason) → boolean
46
+ *
47
+ * Jira adapter shared state methods:
48
+ * - persistSharedStateToIssue(key, state) → boolean
49
+ * - readSharedStateFromIssue(key) → SharedState|null
50
+ * - markTaskIgnored(key, reason) → boolean
51
+ */
52
+
53
+ import { loadConfig } from "./config.mjs";
54
+ import { fetchWithFallback } from "./fetch-runtime.mjs";
55
+ import {
56
+ getAllTasks as getInternalTasks,
57
+ getTask as getInternalTask,
58
+ addTask as addInternalTask,
59
+ setTaskStatus as setInternalTaskStatus,
60
+ removeTask as removeInternalTask,
61
+ updateTask as patchInternalTask,
62
+ } from "./task-store.mjs";
63
+ import { randomUUID } from "node:crypto";
64
+
65
+ const TAG = "[kanban]";
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Normalised Task & Project Types
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * @typedef {Object} KanbanTask
73
+ * @property {string} id Unique task identifier.
74
+ * @property {string} title Task title/summary.
75
+ * @property {string} description Full task description/body.
76
+ * @property {string} status Normalised status: "todo"|"inprogress"|"inreview"|"done"|"cancelled".
77
+ * @property {string|null} assignee Assigned user/agent.
78
+ * @property {string|null} priority "low"|"medium"|"high"|"critical".
79
+ * @property {string|null} projectId Parent project identifier.
80
+ * @property {string|null} baseBranch Base/epic branch for PRs.
81
+ * @property {string|null} branchName Associated git branch.
82
+ * @property {string|null} prNumber Associated PR number.
83
+ * @property {object} meta Backend-specific metadata.
84
+ * @property {string} backend Which backend this came from.
85
+ */
86
+
87
+ /**
88
+ * @typedef {Object} KanbanProject
89
+ * @property {string} id Unique project identifier.
90
+ * @property {string} name Project name.
91
+ * @property {object} meta Backend-specific metadata.
92
+ * @property {string} backend Which backend.
93
+ */
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Status Normalisation
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Map from various backend status strings to our canonical set */
100
+ const STATUS_MAP = {
101
+ // VK statuses
102
+ todo: "todo",
103
+ draft: "draft",
104
+ inprogress: "inprogress",
105
+ started: "inprogress",
106
+ "in-progress": "inprogress",
107
+ in_progress: "inprogress",
108
+ inreview: "inreview",
109
+ "in-review": "inreview",
110
+ in_review: "inreview",
111
+ "in review": "inreview",
112
+ blocked: "blocked",
113
+ done: "done",
114
+ cancelled: "cancelled",
115
+ canceled: "cancelled",
116
+ backlog: "todo",
117
+ // GitHub Issues
118
+ open: "todo",
119
+ closed: "done",
120
+ // Jira-style
121
+ "to do": "todo",
122
+ "in progress": "inprogress",
123
+ review: "inreview",
124
+ resolved: "done",
125
+ };
126
+
127
+ function normaliseStatus(raw) {
128
+ if (!raw) return "todo";
129
+ const key = String(raw).toLowerCase().trim();
130
+ return STATUS_MAP[key] || "todo";
131
+ }
132
+
133
+ const STATUS_LABEL_KEYS = new Set([
134
+ "draft",
135
+ "todo",
136
+ "backlog",
137
+ "inprogress",
138
+ "started",
139
+ "in-progress",
140
+ "in_progress",
141
+ "inreview",
142
+ "in-review",
143
+ "in_review",
144
+ "blocked",
145
+ ]);
146
+
147
+ const PRIORITY_LABEL_KEYS = new Set([
148
+ "critical",
149
+ "high",
150
+ "medium",
151
+ "low",
152
+ ]);
153
+
154
+ const CODEX_LABEL_KEYS = new Set([
155
+ "codex:ignore",
156
+ "codex:claimed",
157
+ "codex:working",
158
+ "codex:stale",
159
+ "openfleet",
160
+ "codex-mointor",
161
+ ]);
162
+
163
+ const SYSTEM_LABEL_KEYS = new Set([
164
+ ...STATUS_LABEL_KEYS,
165
+ ...PRIORITY_LABEL_KEYS,
166
+ ...CODEX_LABEL_KEYS,
167
+ "done",
168
+ "closed",
169
+ "cancelled",
170
+ "canceled",
171
+ ]);
172
+
173
+ function statusFromLabels(labels) {
174
+ if (!Array.isArray(labels)) return null;
175
+ for (const label of labels) {
176
+ const key = String(label || "")
177
+ .trim()
178
+ .toLowerCase();
179
+ if (STATUS_LABEL_KEYS.has(key)) {
180
+ return normaliseStatus(key);
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function normalizeSharedStatePayload(sharedState) {
187
+ if (!sharedState || typeof sharedState !== "object") return null;
188
+ const normalized = { ...sharedState };
189
+ if (!normalized.ownerId && normalized.owner_id) {
190
+ normalized.ownerId = normalized.owner_id;
191
+ }
192
+ if (!normalized.attemptToken && normalized.attempt_token) {
193
+ normalized.attemptToken = normalized.attempt_token;
194
+ }
195
+ if (!normalized.attemptStarted && normalized.attempt_started) {
196
+ normalized.attemptStarted = normalized.attempt_started;
197
+ }
198
+ if (!normalized.heartbeat && normalized.ownerHeartbeat) {
199
+ normalized.heartbeat = normalized.ownerHeartbeat;
200
+ }
201
+ if (
202
+ normalized.retryCount == null &&
203
+ normalized.retry_count != null &&
204
+ Number.isFinite(Number(normalized.retry_count))
205
+ ) {
206
+ normalized.retryCount = Number(normalized.retry_count);
207
+ }
208
+ if (
209
+ !normalized.status &&
210
+ normalized.attemptStatus &&
211
+ ["claimed", "working", "stale"].includes(normalized.attemptStatus)
212
+ ) {
213
+ normalized.status = normalized.attemptStatus;
214
+ }
215
+ return normalized;
216
+ }
217
+
218
+ /**
219
+ * Configurable mapping from internal statuses to GitHub Project v2 status names.
220
+ * Override via GITHUB_PROJECT_STATUS_* env vars.
221
+ */
222
+ const PROJECT_STATUS_MAP = {
223
+ todo: process.env.GITHUB_PROJECT_STATUS_TODO || "Todo",
224
+ inprogress: process.env.GITHUB_PROJECT_STATUS_INPROGRESS || "In Progress",
225
+ inreview: process.env.GITHUB_PROJECT_STATUS_INREVIEW || "In Review",
226
+ done: process.env.GITHUB_PROJECT_STATUS_DONE || "Done",
227
+ cancelled: process.env.GITHUB_PROJECT_STATUS_CANCELLED || "Cancelled",
228
+ };
229
+
230
+ function parseBooleanEnv(value, fallback = false) {
231
+ if (value == null || value === "") return fallback;
232
+ const key = String(value).trim().toLowerCase();
233
+ if (["1", "true", "yes", "on"].includes(key)) return true;
234
+ if (["0", "false", "no", "off"].includes(key)) return false;
235
+ return fallback;
236
+ }
237
+
238
+ function parseRepoSlug(raw) {
239
+ const text = String(raw || "").trim().replace(/^https?:\/\/github\.com\//i, "");
240
+ if (!text) return null;
241
+ const cleaned = text.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
242
+ const [owner, repo] = cleaned.split("/", 2);
243
+ if (!owner || !repo) return null;
244
+ return { owner, repo };
245
+ }
246
+
247
+ function extractBranchFromText(text) {
248
+ const raw = String(text || "");
249
+ if (!raw) return null;
250
+ const tableMatch = raw.match(/\|\s*\*\*Branch\*\*\s*\|\s*`?([^`|\s]+)`?\s*\|/i);
251
+ if (tableMatch?.[1]) return tableMatch[1];
252
+ const inlineMatch = raw.match(/branch:\s*`?([^\s`]+)`?/i);
253
+ if (inlineMatch?.[1]) return inlineMatch[1];
254
+ return null;
255
+ }
256
+
257
+ function extractPrFromText(text) {
258
+ const raw = String(text || "");
259
+ if (!raw) return null;
260
+ const tableMatch = raw.match(/\|\s*\*\*PR\*\*\s*\|\s*#?(\d+)/i);
261
+ if (tableMatch?.[1]) return tableMatch[1];
262
+ const inlineMatch = raw.match(/pr:\s*#?(\d+)/i);
263
+ if (inlineMatch?.[1]) return inlineMatch[1];
264
+ const urlMatch = raw.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i);
265
+ if (urlMatch?.[1]) return urlMatch[1];
266
+ return null;
267
+ }
268
+
269
+ class InternalAdapter {
270
+ constructor() {
271
+ this.name = "internal";
272
+ }
273
+
274
+ _normalizeTask(task) {
275
+ if (!task) return null;
276
+ const tags = normalizeTags(task.tags || task.meta?.tags || []);
277
+ const draft = Boolean(task.draft || task.meta?.draft || task.status === "draft");
278
+ const labelBag = []
279
+ .concat(Array.isArray(task.labels) ? task.labels : [])
280
+ .concat(Array.isArray(task.tags) ? task.tags : [])
281
+ .concat(Array.isArray(task.meta?.labels) ? task.meta.labels : [])
282
+ .concat(Array.isArray(task.meta?.tags) ? task.meta.tags : []);
283
+ const baseBranch = normalizeBranchName(
284
+ task.baseBranch ||
285
+ task.base_branch ||
286
+ task.meta?.baseBranch ||
287
+ task.meta?.base_branch ||
288
+ extractBaseBranchFromLabels(labelBag) ||
289
+ extractBaseBranchFromText(task.description || task.body || ""),
290
+ );
291
+ return {
292
+ id: String(task.id || ""),
293
+ title: task.title || "",
294
+ description: task.description || "",
295
+ status: normaliseStatus(task.status),
296
+ assignee: task.assignee || null,
297
+ priority: task.priority || null,
298
+ tags,
299
+ draft,
300
+ projectId: task.projectId || "internal",
301
+ baseBranch,
302
+ branchName: task.branchName || null,
303
+ prNumber: task.prNumber || null,
304
+ prUrl: task.prUrl || null,
305
+ taskUrl: task.taskUrl || null,
306
+ createdAt: task.createdAt || null,
307
+ updatedAt: task.updatedAt || null,
308
+ backend: "internal",
309
+ meta: task.meta || {},
310
+ };
311
+ }
312
+
313
+ async listProjects() {
314
+ return [
315
+ {
316
+ id: "internal",
317
+ name: "Internal Task Store",
318
+ backend: "internal",
319
+ meta: {},
320
+ },
321
+ ];
322
+ }
323
+
324
+ async listTasks(projectId, filters = {}) {
325
+ const statusFilter = filters?.status ? normaliseStatus(filters.status) : null;
326
+ const limit = Number(filters?.limit || 0);
327
+ const normalizedProjectId = String(projectId || "internal").trim().toLowerCase();
328
+
329
+ let tasks = getInternalTasks().map((task) => this._normalizeTask(task));
330
+ if (normalizedProjectId && normalizedProjectId !== "internal") {
331
+ tasks = tasks.filter(
332
+ (task) =>
333
+ String(task.projectId || "internal").trim().toLowerCase() ===
334
+ normalizedProjectId,
335
+ );
336
+ }
337
+ if (statusFilter) {
338
+ tasks = tasks.filter((task) => normaliseStatus(task.status) === statusFilter);
339
+ }
340
+ if (Number.isFinite(limit) && limit > 0) {
341
+ tasks = tasks.slice(0, limit);
342
+ }
343
+ return tasks;
344
+ }
345
+
346
+ async getTask(taskId) {
347
+ return this._normalizeTask(getInternalTask(String(taskId || "")));
348
+ }
349
+
350
+ async updateTaskStatus(taskId, status) {
351
+ const normalizedId = String(taskId || "").trim();
352
+ if (!normalizedId) {
353
+ throw new Error("[kanban] internal updateTaskStatus requires taskId");
354
+ }
355
+ const normalizedStatus = normaliseStatus(status);
356
+ const updated = setInternalTaskStatus(
357
+ normalizedId,
358
+ normalizedStatus,
359
+ "orchestrator",
360
+ );
361
+ if (!updated) {
362
+ throw new Error(`[kanban] internal task not found: ${normalizedId}`);
363
+ }
364
+ return this._normalizeTask(updated);
365
+ }
366
+
367
+ async updateTask(taskId, patch = {}) {
368
+ const normalizedId = String(taskId || "").trim();
369
+ if (!normalizedId) {
370
+ throw new Error("[kanban] internal updateTask requires taskId");
371
+ }
372
+ const updates = {};
373
+ const baseBranch = resolveBaseBranchInput(patch);
374
+ if (typeof patch.title === "string") updates.title = patch.title;
375
+ if (typeof patch.description === "string") updates.description = patch.description;
376
+ if (typeof patch.status === "string" && patch.status.trim()) {
377
+ updates.status = normaliseStatus(patch.status);
378
+ }
379
+ if (typeof patch.priority === "string") updates.priority = patch.priority;
380
+ if (Array.isArray(patch.tags) || Array.isArray(patch.labels) || typeof patch.tags === "string") {
381
+ updates.tags = normalizeTags(patch.tags ?? patch.labels);
382
+ }
383
+ if (typeof patch.draft === "boolean") {
384
+ updates.draft = patch.draft;
385
+ if (!patch.status) {
386
+ updates.status = patch.draft ? "draft" : "todo";
387
+ }
388
+ }
389
+ const current = getInternalTask(normalizedId);
390
+ if (baseBranch) {
391
+ updates.baseBranch = baseBranch;
392
+ }
393
+ if (patch.meta && typeof patch.meta === "object") {
394
+ updates.meta = {
395
+ ...(current?.meta || {}),
396
+ ...patch.meta,
397
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
398
+ };
399
+ } else if (baseBranch) {
400
+ updates.meta = {
401
+ ...(current?.meta || {}),
402
+ base_branch: baseBranch,
403
+ baseBranch,
404
+ };
405
+ }
406
+ const updated = patchInternalTask(normalizedId, updates);
407
+ if (!updated) {
408
+ throw new Error(`[kanban] internal task not found: ${normalizedId}`);
409
+ }
410
+ return this._normalizeTask(updated);
411
+ }
412
+
413
+ async createTask(projectId, taskData = {}) {
414
+ const id = String(taskData.id || randomUUID());
415
+ const tags = normalizeTags(taskData.tags || taskData.labels || []);
416
+ const draft = Boolean(taskData.draft || taskData.status === "draft");
417
+ const baseBranch = resolveBaseBranchInput(taskData);
418
+ const created = addInternalTask({
419
+ id,
420
+ title: taskData.title || "Untitled task",
421
+ description: taskData.description || "",
422
+ status: draft ? "draft" : normaliseStatus(taskData.status || "todo"),
423
+ assignee: taskData.assignee || null,
424
+ priority: taskData.priority || null,
425
+ tags,
426
+ draft,
427
+ projectId: taskData.projectId || projectId || "internal",
428
+ baseBranch,
429
+ meta: {
430
+ ...(taskData.meta || {}),
431
+ ...(tags.length ? { tags } : {}),
432
+ ...(draft ? { draft: true } : {}),
433
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
434
+ },
435
+ });
436
+ if (!created) {
437
+ throw new Error("[kanban] internal task creation failed");
438
+ }
439
+ return this._normalizeTask(created);
440
+ }
441
+
442
+ async deleteTask(taskId) {
443
+ return removeInternalTask(String(taskId || ""));
444
+ }
445
+
446
+ async addComment(taskId, body) {
447
+ const id = String(taskId || "").trim();
448
+ const comment = String(body || "").trim();
449
+ if (!id || !comment) return false;
450
+
451
+ const current = getInternalTask(id);
452
+ if (!current) return false;
453
+
454
+ const comments = Array.isArray(current?.meta?.comments)
455
+ ? [...current.meta.comments]
456
+ : [];
457
+ comments.push({
458
+ body: comment,
459
+ createdAt: new Date().toISOString(),
460
+ source: "kanban-adapter/internal",
461
+ });
462
+
463
+ const patched = patchInternalTask(id, {
464
+ meta: {
465
+ ...(current.meta || {}),
466
+ comments,
467
+ },
468
+ });
469
+
470
+ return Boolean(patched);
471
+ }
472
+ }
473
+
474
+ function normalizeLabels(raw) {
475
+ const values = Array.isArray(raw)
476
+ ? raw
477
+ : String(raw || "")
478
+ .split(",")
479
+ .map((entry) => entry.trim())
480
+ .filter(Boolean);
481
+ const seen = new Set();
482
+ const labels = [];
483
+ for (const value of values) {
484
+ const normalized = String(value || "")
485
+ .trim()
486
+ .toLowerCase();
487
+ if (!normalized || seen.has(normalized)) continue;
488
+ seen.add(normalized);
489
+ labels.push(normalized);
490
+ }
491
+ return labels;
492
+ }
493
+
494
+ function normalizeTags(raw) {
495
+ return normalizeLabels(raw);
496
+ }
497
+
498
+ const UPSTREAM_LABEL_REGEX =
499
+ /^(?:upstream|base|target)(?:_branch)?[:=]\s*([A-Za-z0-9._/-]+)$/i;
500
+
501
+ function normalizeBranchName(value) {
502
+ if (!value) return null;
503
+ const trimmed = String(value || "").trim();
504
+ return trimmed ? trimmed : null;
505
+ }
506
+
507
+ function isUpstreamLabel(label) {
508
+ if (!label) return false;
509
+ return UPSTREAM_LABEL_REGEX.test(String(label || "").trim());
510
+ }
511
+
512
+ function extractBaseBranchFromLabels(labels) {
513
+ if (!Array.isArray(labels)) return null;
514
+ for (const label of labels) {
515
+ const match = String(label || "").trim().match(UPSTREAM_LABEL_REGEX);
516
+ if (match?.[1]) return normalizeBranchName(match[1]);
517
+ }
518
+ return null;
519
+ }
520
+
521
+ function extractBaseBranchFromText(text) {
522
+ if (!text) return null;
523
+ const match = String(text || "").match(
524
+ /\b(?:upstream|base|target)(?:_branch| branch)?\s*[:=]\s*([A-Za-z0-9._/-]+)/i,
525
+ );
526
+ if (!match?.[1]) return null;
527
+ return normalizeBranchName(match[1]);
528
+ }
529
+
530
+ function resolveBaseBranchInput(payload) {
531
+ if (!payload) return null;
532
+ const candidate =
533
+ payload.base_branch ||
534
+ payload.baseBranch ||
535
+ payload.upstream_branch ||
536
+ payload.upstreamBranch ||
537
+ payload.upstream ||
538
+ payload.target_branch ||
539
+ payload.targetBranch ||
540
+ payload.base ||
541
+ payload.target;
542
+ return normalizeBranchName(candidate);
543
+ }
544
+
545
+ function upsertBaseBranchMarker(text, baseBranch) {
546
+ const branch = normalizeBranchName(baseBranch);
547
+ if (!branch) return String(text || "");
548
+ const source = String(text || "");
549
+ const pattern =
550
+ /\b(?:upstream|base|target)(?:_branch| branch)?\s*[:=]\s*([A-Za-z0-9._/-]+)/i;
551
+ if (pattern.test(source)) {
552
+ return source.replace(pattern, `base_branch: ${branch}`);
553
+ }
554
+ const separator = source.trim().length ? "\n\n" : "";
555
+ return `${source}${separator}base_branch: ${branch}`.trim();
556
+ }
557
+
558
+ function extractTagsFromLabels(labels, extraSystem = []) {
559
+ const normalized = normalizeLabels(labels);
560
+ const extra = new Set(normalizeLabels(extraSystem));
561
+ return normalized.filter(
562
+ (label) =>
563
+ !SYSTEM_LABEL_KEYS.has(label) &&
564
+ !extra.has(label) &&
565
+ !isUpstreamLabel(label),
566
+ );
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // VK Adapter (Vibe-Kanban)
571
+ // ---------------------------------------------------------------------------
572
+
573
+ class VKAdapter {
574
+ constructor() {
575
+ this.name = "vk";
576
+ this._fetchVk = null;
577
+ }
578
+
579
+ /**
580
+ * Lazy-load the fetchVk helper from monitor.mjs or fall back to a minimal
581
+ * implementation using the VK endpoint URL from config.
582
+ */
583
+ async _getFetchVk() {
584
+ if (this._fetchVk) return this._fetchVk;
585
+
586
+ // Try importing a standalone vk-api module first
587
+ try {
588
+ const mod = await import("./vk-api.mjs");
589
+ const fn = mod.fetchVk || mod.default?.fetchVk || mod.default;
590
+ if (typeof fn === "function") {
591
+ this._fetchVk = fn;
592
+ return this._fetchVk;
593
+ }
594
+ } catch {
595
+ // Not available — build a minimal fetch wrapper
596
+ }
597
+
598
+ // Minimal fetch wrapper using config
599
+ const cfg = loadConfig();
600
+ const baseUrl = cfg.vkEndpointUrl || "http://127.0.0.1:54089";
601
+ this._fetchVk = async (path, opts = {}) => {
602
+ const url = `${baseUrl}${path.startsWith("/") ? path : "/" + path}`;
603
+ const method = (opts.method || "GET").toUpperCase();
604
+ const controller = new AbortController();
605
+ const timeout = setTimeout(
606
+ () => controller.abort(),
607
+ opts.timeoutMs || 15_000,
608
+ );
609
+
610
+ let res;
611
+ try {
612
+ const fetchOpts = {
613
+ method,
614
+ signal: controller.signal,
615
+ headers: { "Content-Type": "application/json" },
616
+ };
617
+ if (opts.body && method !== "GET") {
618
+ fetchOpts.body =
619
+ typeof opts.body === "string"
620
+ ? opts.body
621
+ : JSON.stringify(opts.body);
622
+ }
623
+ res = await fetchWithFallback(url, fetchOpts);
624
+ } catch (err) {
625
+ // Network error, timeout, abort - res is undefined
626
+ throw new Error(
627
+ `VK API ${method} ${path} network error: ${err.message || err}`,
628
+ );
629
+ } finally {
630
+ clearTimeout(timeout);
631
+ }
632
+
633
+ if (!res || typeof res.ok === "undefined") {
634
+ throw new Error(
635
+ `VK API ${method} ${path} invalid response object (res=${!!res}, res.ok=${res?.ok})`,
636
+ );
637
+ }
638
+
639
+ if (!res.ok) {
640
+ const text =
641
+ typeof res.text === "function"
642
+ ? await res.text().catch(() => "")
643
+ : "";
644
+ throw new Error(
645
+ `VK API ${method} ${path} failed: ${res.status} ${text.slice(0, 200)}`,
646
+ );
647
+ }
648
+
649
+ const contentTypeRaw =
650
+ typeof res.headers?.get === "function"
651
+ ? res.headers.get("content-type") || res.headers.get("Content-Type")
652
+ : res.headers?.["content-type"] ||
653
+ res.headers?.["Content-Type"] ||
654
+ "";
655
+ const contentType = String(contentTypeRaw || "").toLowerCase();
656
+
657
+ if (contentType && !contentType.includes("application/json")) {
658
+ const text =
659
+ typeof res.text === "function"
660
+ ? await res.text().catch(() => "")
661
+ : "";
662
+ // VK sometimes mislabels JSON as text/plain in proxy setups.
663
+ if (text) {
664
+ try {
665
+ return JSON.parse(text);
666
+ } catch {
667
+ // Fall through to explicit non-JSON error below.
668
+ }
669
+ }
670
+ throw new Error(
671
+ `VK API ${method} ${path} non-JSON response (${contentType})`,
672
+ );
673
+ }
674
+
675
+ try {
676
+ return await res.json();
677
+ } catch (err) {
678
+ throw new Error(
679
+ `VK API ${method} ${path} invalid JSON: ${err.message}`,
680
+ );
681
+ }
682
+ };
683
+ return this._fetchVk;
684
+ }
685
+
686
+ async listProjects() {
687
+ const fetchVk = await this._getFetchVk();
688
+ const result = await fetchVk("/api/projects");
689
+ const projects = Array.isArray(result) ? result : result?.data || [];
690
+ return projects.map((p) => ({
691
+ id: p.id,
692
+ name: p.name || p.title || p.id,
693
+ meta: p,
694
+ backend: "vk",
695
+ }));
696
+ }
697
+
698
+ async listTasks(projectId, filters = {}) {
699
+ const fetchVk = await this._getFetchVk();
700
+ // Use /api/tasks?project_id=... (query param style) instead of
701
+ // /api/projects/:id/tasks which gets caught by the SPA catch-all.
702
+ const params = [`project_id=${encodeURIComponent(projectId)}`];
703
+ if (filters.status)
704
+ params.push(`status=${encodeURIComponent(filters.status)}`);
705
+ if (filters.limit) params.push(`limit=${filters.limit}`);
706
+ const url = `/api/tasks?${params.join("&")}`;
707
+ const result = await fetchVk(url);
708
+ const tasks = Array.isArray(result)
709
+ ? result
710
+ : result?.data || result?.tasks || [];
711
+ return tasks.map((t) => this._normaliseTask(t, projectId));
712
+ }
713
+
714
+ async getTask(taskId) {
715
+ const fetchVk = await this._getFetchVk();
716
+ const result = await fetchVk(`/api/tasks/${taskId}`);
717
+ const task = result?.data || result;
718
+ return this._normaliseTask(task);
719
+ }
720
+
721
+ async updateTaskStatus(taskId, status) {
722
+ return this.updateTask(taskId, { status });
723
+ }
724
+
725
+ async updateTask(taskId, patch = {}) {
726
+ const fetchVk = await this._getFetchVk();
727
+ const body = {};
728
+ const baseBranch = resolveBaseBranchInput(patch);
729
+ if (typeof patch.status === "string" && patch.status.trim()) {
730
+ body.status = patch.status.trim();
731
+ }
732
+ if (typeof patch.title === "string") {
733
+ body.title = patch.title;
734
+ }
735
+ if (typeof patch.description === "string") {
736
+ body.description = patch.description;
737
+ }
738
+ if (typeof patch.priority === "string" && patch.priority.trim()) {
739
+ body.priority = patch.priority.trim();
740
+ }
741
+ if (Array.isArray(patch.tags) || typeof patch.tags === "string") {
742
+ body.tags = normalizeTags(patch.tags ?? patch.labels);
743
+ }
744
+ if (typeof patch.draft === "boolean") {
745
+ body.draft = patch.draft;
746
+ if (!patch.status) body.status = patch.draft ? "draft" : "todo";
747
+ }
748
+ if (baseBranch) {
749
+ body.base_branch = baseBranch;
750
+ }
751
+ if (Object.keys(body).length === 0) {
752
+ return this.getTask(taskId);
753
+ }
754
+ const result = await fetchVk(`/api/tasks/${taskId}`, {
755
+ method: "PUT",
756
+ body,
757
+ });
758
+ const task = result?.data || result;
759
+ return this._normaliseTask(task);
760
+ }
761
+
762
+ async createTask(projectId, taskData) {
763
+ const fetchVk = await this._getFetchVk();
764
+ const tags = normalizeTags(taskData?.tags || taskData?.labels || []);
765
+ const draft = Boolean(taskData?.draft || taskData?.status === "draft");
766
+ const baseBranch = resolveBaseBranchInput(taskData);
767
+ const payload = {
768
+ ...taskData,
769
+ status: draft ? "draft" : taskData?.status,
770
+ ...(tags.length ? { tags } : {}),
771
+ ...(draft ? { draft: true } : {}),
772
+ ...(baseBranch ? { base_branch: baseBranch } : {}),
773
+ };
774
+ // Use /api/tasks with project_id in body instead of
775
+ // /api/projects/:id/tasks which gets caught by the SPA catch-all.
776
+ const result = await fetchVk(`/api/tasks`, {
777
+ method: "POST",
778
+ body: { ...payload, project_id: projectId },
779
+ });
780
+ const task = result?.data || result;
781
+ return this._normaliseTask(task, projectId);
782
+ }
783
+
784
+ async deleteTask(taskId) {
785
+ const fetchVk = await this._getFetchVk();
786
+ await fetchVk(`/api/tasks/${taskId}`, { method: "DELETE" });
787
+ return true;
788
+ }
789
+
790
+ async addComment(_taskId, _body) {
791
+ return false; // VK backend doesn't support issue comments
792
+ }
793
+
794
+ _normaliseTask(raw, projectId = null) {
795
+ if (!raw) return null;
796
+ const tags = normalizeTags(raw.tags || raw.labels || raw.meta?.tags || []);
797
+ const draft = Boolean(raw.draft || raw.isDraft || raw.status === "draft");
798
+ const baseBranch = normalizeBranchName(
799
+ raw.base_branch ||
800
+ raw.baseBranch ||
801
+ raw.upstream_branch ||
802
+ raw.upstream ||
803
+ raw.target_branch ||
804
+ raw.targetBranch ||
805
+ raw.meta?.base_branch ||
806
+ raw.meta?.baseBranch,
807
+ );
808
+ return {
809
+ id: raw.id || raw.task_id || "",
810
+ title: raw.title || raw.name || "",
811
+ description: raw.description || raw.body || "",
812
+ status: normaliseStatus(raw.status),
813
+ assignee: raw.assignee || raw.assigned_to || null,
814
+ priority: raw.priority || null,
815
+ tags,
816
+ draft,
817
+ projectId: raw.project_id || projectId,
818
+ baseBranch,
819
+ branchName: raw.branch_name || raw.branchName || null,
820
+ prNumber: raw.pr_number || raw.prNumber || null,
821
+ meta: raw,
822
+ backend: "vk",
823
+ };
824
+ }
825
+ }
826
+
827
+ // ---------------------------------------------------------------------------
828
+ // GitHub Issues Adapter
829
+ // ---------------------------------------------------------------------------
830
+
831
+ /**
832
+ * @typedef {Object} SharedState
833
+ * @property {string} ownerId - Workstation/agent identifier (e.g., "workstation-123/agent-456")
834
+ * @property {string} attemptToken - Unique UUID for this claim attempt
835
+ * @property {string} attemptStarted - ISO 8601 timestamp of claim start
836
+ * @property {string} heartbeat - ISO 8601 timestamp of last heartbeat
837
+ * @property {string} status - Current status: "claimed"|"working"|"stale"
838
+ * @property {number} retryCount - Number of retry attempts
839
+ */
840
+
841
+ class GitHubIssuesAdapter {
842
+ constructor() {
843
+ this.name = "github";
844
+ const config = loadConfig();
845
+ const slugInfo =
846
+ parseRepoSlug(process.env.GITHUB_REPOSITORY) ||
847
+ parseRepoSlug(config?.repoSlug) ||
848
+ parseRepoSlug(
849
+ process.env.GITHUB_REPO_OWNER && process.env.GITHUB_REPO_NAME
850
+ ? `${process.env.GITHUB_REPO_OWNER}/${process.env.GITHUB_REPO_NAME}`
851
+ : "",
852
+ );
853
+ this._owner = process.env.GITHUB_REPO_OWNER || slugInfo?.owner || "unknown";
854
+ this._repo = process.env.GITHUB_REPO_NAME || slugInfo?.repo || "unknown";
855
+
856
+ // openfleet label scheme
857
+ this._codexLabels = {
858
+ claimed: "codex:claimed",
859
+ working: "codex:working",
860
+ stale: "codex:stale",
861
+ ignore: "codex:ignore",
862
+ };
863
+
864
+ this._canonicalTaskLabel =
865
+ process.env.CODEX_MONITOR_TASK_LABEL || "openfleet";
866
+ this._taskScopeLabels = normalizeLabels(
867
+ process.env.CODEX_MONITOR_TASK_LABELS ||
868
+ `${this._canonicalTaskLabel},codex-mointor`,
869
+ );
870
+ this._enforceTaskLabel = parseBooleanEnv(
871
+ process.env.CODEX_MONITOR_ENFORCE_TASK_LABEL,
872
+ true,
873
+ );
874
+
875
+ this._autoAssignCreator = parseBooleanEnv(
876
+ process.env.GITHUB_AUTO_ASSIGN_CREATOR,
877
+ true,
878
+ );
879
+ this._defaultAssignee =
880
+ process.env.GITHUB_DEFAULT_ASSIGNEE || this._owner || null;
881
+
882
+ this._projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues")
883
+ .trim()
884
+ .toLowerCase();
885
+ this._projectOwner = process.env.GITHUB_PROJECT_OWNER || this._owner;
886
+ this._projectTitle =
887
+ process.env.GITHUB_PROJECT_TITLE ||
888
+ process.env.PROJECT_NAME ||
889
+ "OpenFleet";
890
+ this._projectNumber =
891
+ process.env.GITHUB_PROJECT_NUMBER ||
892
+ process.env.GITHUB_PROJECT_ID ||
893
+ null;
894
+ this._cachedProjectNumber = this._projectNumber;
895
+
896
+ // --- Caching infrastructure for GitHub Projects v2 ---
897
+ /** @type {Map<string, string>} projectNumber → project node ID */
898
+ this._projectNodeIdCache = new Map();
899
+ /** @type {Map<string, string>} "projectNum:issueNum" → project item ID */
900
+ this._projectItemCache = new Map();
901
+ /** @type {Map<string, {fields: any, time: number}>} projectNumber → {fields, time} */
902
+ this._projectFieldsCache = new Map();
903
+ this._projectFieldsCacheTTL = 300_000; // 5 minutes
904
+ this._repositoryNodeId = null;
905
+
906
+ // Auto-sync toggle: set GITHUB_PROJECT_AUTO_SYNC=false to disable project sync
907
+ this._projectAutoSync = parseBooleanEnv(
908
+ process.env.GITHUB_PROJECT_AUTO_SYNC,
909
+ true,
910
+ );
911
+
912
+ // Rate limit retry delay (ms) — configurable for tests
913
+ this._rateLimitRetryDelayMs =
914
+ Number(process.env.GH_RATE_LIMIT_RETRY_MS) || 60_000;
915
+ }
916
+
917
+ /**
918
+ * Get project fields with caching (private — returns legacy format for _syncStatusToProject).
919
+ * Returns status field ID and options for project board.
920
+ * @private
921
+ * @param {string} projectNumber - GitHub project number
922
+ * @returns {Promise<{statusFieldId: string, statusOptions: Array<{id: string, name: string}>}|null>}
923
+ */
924
+ async _getProjectFields(projectNumber) {
925
+ if (!projectNumber) return null;
926
+
927
+ // Return cached value if still valid
928
+ const now = Date.now();
929
+ const cacheKey = String(projectNumber);
930
+ const cached = this._projectFieldsCache.get(cacheKey);
931
+ if (cached && now - cached.time < this._projectFieldsCacheTTL) {
932
+ return cached.fields;
933
+ }
934
+
935
+ try {
936
+ const owner = String(this._projectOwner || this._owner).trim();
937
+ const fields = await this._gh([
938
+ "project",
939
+ "field-list",
940
+ String(projectNumber),
941
+ "--owner",
942
+ owner,
943
+ "--format",
944
+ "json",
945
+ ]);
946
+
947
+ if (!Array.isArray(fields)) {
948
+ console.warn(
949
+ `${TAG} project field-list returned non-array for project ${projectNumber}`,
950
+ );
951
+ return null;
952
+ }
953
+
954
+ // Find the Status field
955
+ const statusField = fields.find(
956
+ (f) =>
957
+ f.name === "Status" &&
958
+ (f.type === "SINGLE_SELECT" || f.data_type === "SINGLE_SELECT"),
959
+ );
960
+
961
+ const result = {
962
+ statusFieldId: statusField?.id || null,
963
+ statusOptions: (statusField?.options || []).map((opt) => ({
964
+ id: opt.id,
965
+ name: opt.name,
966
+ })),
967
+ };
968
+
969
+ // Cache the result (also cache the raw fields array for getProjectFields)
970
+ this._projectFieldsCache.set(cacheKey, {
971
+ fields: result,
972
+ rawFields: fields,
973
+ time: now,
974
+ });
975
+
976
+ return result;
977
+ } catch (err) {
978
+ console.warn(
979
+ `${TAG} failed to fetch project fields for ${projectNumber}: ${err.message}`,
980
+ );
981
+ return null;
982
+ }
983
+ }
984
+
985
+ /**
986
+ * Get full project fields map for a GitHub Project board.
987
+ * Returns a Map keyed by lowercase field name with {id, name, type, options}.
988
+ * @public
989
+ * @param {string} projectNumber - GitHub project number
990
+ * @returns {Promise<Map<string, {id: string, name: string, type: string, options: Array<{id: string, name: string}>}>>}
991
+ */
992
+ async getProjectFields(projectNumber) {
993
+ if (!projectNumber) return new Map();
994
+ const cacheKey = String(projectNumber);
995
+ const now = Date.now();
996
+ const cached = this._projectFieldsCache.get(cacheKey);
997
+
998
+ let rawFields;
999
+ if (
1000
+ cached &&
1001
+ cached.rawFields &&
1002
+ now - cached.time < this._projectFieldsCacheTTL
1003
+ ) {
1004
+ rawFields = cached.rawFields;
1005
+ } else {
1006
+ // Trigger a fresh fetch via _getProjectFields which populates both caches
1007
+ await this._getProjectFields(projectNumber);
1008
+ const freshCached = this._projectFieldsCache.get(cacheKey);
1009
+ rawFields = freshCached?.rawFields;
1010
+ }
1011
+
1012
+ if (!Array.isArray(rawFields)) return new Map();
1013
+
1014
+ /** @type {Map<string, {id: string, name: string, type: string, options: Array}>} */
1015
+ const fieldMap = new Map();
1016
+ for (const f of rawFields) {
1017
+ if (!f.name) continue;
1018
+ fieldMap.set(f.name.toLowerCase(), {
1019
+ id: f.id,
1020
+ name: f.name,
1021
+ type: f.type || f.data_type || "UNKNOWN",
1022
+ options: (f.options || []).map((opt) => ({
1023
+ id: opt.id,
1024
+ name: opt.name,
1025
+ })),
1026
+ raw: f,
1027
+ });
1028
+ }
1029
+ return fieldMap;
1030
+ }
1031
+
1032
+ /**
1033
+ * Get the GraphQL node ID for a GitHub Project v2 board.
1034
+ * Resolves org or user project. Cached for session lifetime.
1035
+ * @public
1036
+ * @param {string} projectNumber - GitHub project number
1037
+ * @returns {Promise<string|null>} Project node ID or null
1038
+ */
1039
+ async getProjectNodeId(projectNumber) {
1040
+ if (!projectNumber) return null;
1041
+ const cacheKey = String(projectNumber);
1042
+ if (this._projectNodeIdCache.has(cacheKey)) {
1043
+ return this._projectNodeIdCache.get(cacheKey);
1044
+ }
1045
+
1046
+ const owner = String(this._projectOwner || this._owner).trim();
1047
+ const query = `
1048
+ query {
1049
+ user(login: "${owner}") {
1050
+ projectV2(number: ${projectNumber}) {
1051
+ id
1052
+ }
1053
+ }
1054
+ organization(login: "${owner}") {
1055
+ projectV2(number: ${projectNumber}) {
1056
+ id
1057
+ }
1058
+ }
1059
+ }
1060
+ `;
1061
+
1062
+ try {
1063
+ const data = await this._gh(["api", "graphql", "-f", `query=${query}`]);
1064
+ const nodeId =
1065
+ data?.data?.user?.projectV2?.id ||
1066
+ data?.data?.organization?.projectV2?.id ||
1067
+ null;
1068
+ if (nodeId) {
1069
+ this._projectNodeIdCache.set(cacheKey, nodeId);
1070
+ }
1071
+ return nodeId;
1072
+ } catch (err) {
1073
+ console.warn(
1074
+ `${TAG} failed to resolve project node ID for ${owner}/${projectNumber}: ${err.message}`,
1075
+ );
1076
+ return null;
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Normalize a GitHub Project v2 status name to internal codex status.
1082
+ * Also supports reverse mapping (internal → project).
1083
+ *
1084
+ * Bidirectional:
1085
+ * - project → internal: _normalizeProjectStatus("In Progress") → "inprogress"
1086
+ * - internal → project: _normalizeProjectStatus("inprogress", true) → "In Progress"
1087
+ *
1088
+ * @param {string} statusName - Status name to normalize
1089
+ * @param {boolean} [toProject=false] - If true, map internal→project; otherwise project→internal
1090
+ * @returns {string} Normalized status
1091
+ */
1092
+ _normalizeProjectStatus(statusName, toProject = false) {
1093
+ if (!statusName) return toProject ? PROJECT_STATUS_MAP.todo : "todo";
1094
+
1095
+ if (toProject) {
1096
+ // internal → project
1097
+ const key = String(statusName).toLowerCase().trim();
1098
+ return PROJECT_STATUS_MAP[key] || PROJECT_STATUS_MAP.todo;
1099
+ }
1100
+
1101
+ // project → internal: build reverse map from PROJECT_STATUS_MAP
1102
+ const lcInput = String(statusName).toLowerCase().trim();
1103
+ for (const [internal, projectName] of Object.entries(PROJECT_STATUS_MAP)) {
1104
+ if (String(projectName).toLowerCase() === lcInput) {
1105
+ return internal;
1106
+ }
1107
+ }
1108
+ // Fallback to standard normalisation
1109
+ return normaliseStatus(statusName);
1110
+ }
1111
+
1112
+ /**
1113
+ * Normalize a project item (from `gh project item-list`) into KanbanTask format
1114
+ * without issuing individual issue fetches (fixes N+1 problem).
1115
+ * @private
1116
+ * @param {Object} projectItem - Raw project item from item-list
1117
+ * @returns {KanbanTask|null}
1118
+ */
1119
+ _normaliseProjectItem(projectItem) {
1120
+ if (!projectItem) return null;
1121
+
1122
+ const content = projectItem.content || {};
1123
+ // content may have: number, title, body, url, type, repository, labels, assignees
1124
+ const issueNumber = content.number;
1125
+ if (!issueNumber && !content.url) return null; // skip draft items without info
1126
+
1127
+ // Extract issue number from URL if not directly available
1128
+ const num =
1129
+ issueNumber || String(content.url || "").match(/\/issues\/(\d+)/)?.[1];
1130
+ if (!num) return null;
1131
+
1132
+ // Extract labels
1133
+ const rawLabels = content.labels || projectItem.labels || [];
1134
+ const labels = rawLabels.map((l) =>
1135
+ typeof l === "string" ? l : l?.name || "",
1136
+ );
1137
+ const labelSet = new Set(
1138
+ labels.map((l) =>
1139
+ String(l || "")
1140
+ .trim()
1141
+ .toLowerCase(),
1142
+ ),
1143
+ );
1144
+ const labelStatus = statusFromLabels(labels);
1145
+ const tags = extractTagsFromLabels(labels, this._taskScopeLabels || []);
1146
+ const body = content.body || "";
1147
+ const baseBranch = normalizeBranchName(
1148
+ extractBaseBranchFromLabels(labels) || extractBaseBranchFromText(body),
1149
+ );
1150
+
1151
+ // Determine status from project Status field value
1152
+ const projectStatus =
1153
+ projectItem.status || projectItem.fieldValues?.Status || null;
1154
+ let status;
1155
+ if (projectStatus) {
1156
+ status = this._normalizeProjectStatus(projectStatus);
1157
+ } else {
1158
+ // Fallback to content state + labels
1159
+ if (content.state === "closed" || content.state === "CLOSED") {
1160
+ status = "done";
1161
+ } else if (labelStatus) {
1162
+ status = labelStatus;
1163
+ } else {
1164
+ status = "todo";
1165
+ }
1166
+ }
1167
+ if (labelSet.has("draft")) status = "draft";
1168
+
1169
+ // Codex meta flags
1170
+ const codexMeta = {
1171
+ isIgnored: labelSet.has("codex:ignore"),
1172
+ isClaimed: labelSet.has("codex:claimed"),
1173
+ isWorking: labelSet.has("codex:working"),
1174
+ isStale: labelSet.has("codex:stale"),
1175
+ };
1176
+
1177
+ // Extract branch/PR from body if available
1178
+ const branchMatch = body.match(/branch:\s*`?([^\s`]+)`?/i);
1179
+ const prMatch = body.match(/pr:\s*#?(\d+)/i);
1180
+
1181
+ // Assignees
1182
+ const assignees = content.assignees || [];
1183
+ const assignee =
1184
+ assignees.length > 0
1185
+ ? typeof assignees[0] === "string"
1186
+ ? assignees[0]
1187
+ : assignees[0]?.login
1188
+ : null;
1189
+
1190
+ const issueUrl =
1191
+ content.url ||
1192
+ `https://github.com/${this._owner}/${this._repo}/issues/${num}`;
1193
+
1194
+ return {
1195
+ id: String(num),
1196
+ title: content.title || projectItem.title || "",
1197
+ description: body,
1198
+ status,
1199
+ assignee: assignee || null,
1200
+ priority: labelSet.has("critical")
1201
+ ? "critical"
1202
+ : labelSet.has("high")
1203
+ ? "high"
1204
+ : null,
1205
+ tags,
1206
+ draft: labelSet.has("draft") || status === "draft",
1207
+ projectId: `${this._owner}/${this._repo}`,
1208
+ baseBranch,
1209
+ branchName: branchMatch?.[1] || null,
1210
+ prNumber: prMatch?.[1] || null,
1211
+ meta: {
1212
+ number: Number(num),
1213
+ title: content.title || projectItem.title || "",
1214
+ body,
1215
+ state: content.state || null,
1216
+ url: issueUrl,
1217
+ labels: rawLabels,
1218
+ assignees,
1219
+ task_url: issueUrl,
1220
+ tags,
1221
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
1222
+ codex: codexMeta,
1223
+ projectNumber: null, // set by caller
1224
+ projectItemId: projectItem.id || null,
1225
+ projectStatus: projectStatus || null,
1226
+ projectFieldValues:
1227
+ projectItem.fieldValues && typeof projectItem.fieldValues === "object"
1228
+ ? { ...projectItem.fieldValues }
1229
+ : {},
1230
+ },
1231
+ taskUrl: issueUrl,
1232
+ backend: "github",
1233
+ };
1234
+ }
1235
+
1236
+ _escapeGraphQLString(value) {
1237
+ return JSON.stringify(String(value == null ? "" : value));
1238
+ }
1239
+
1240
+ _stringifyProjectFieldValue(field, value) {
1241
+ const fieldType = String(field?.type || "TEXT").toUpperCase();
1242
+ if (fieldType === "SINGLE_SELECT") {
1243
+ const option = (field.options || []).find((opt) => {
1244
+ const optionId = String(opt?.id || "").trim();
1245
+ const optionName = String(opt?.name || "")
1246
+ .trim()
1247
+ .toLowerCase();
1248
+ const input = String(value || "").trim().toLowerCase();
1249
+ return optionId === String(value || "").trim() || optionName === input;
1250
+ });
1251
+ if (!option) return null;
1252
+ return `{singleSelectOptionId: ${this._escapeGraphQLString(option.id)}}`;
1253
+ }
1254
+ if (fieldType === "ITERATION") {
1255
+ const rawIterations = field?.raw?.configuration?.iterations;
1256
+ const iterations = Array.isArray(rawIterations)
1257
+ ? rawIterations
1258
+ : Array.isArray(field?.options)
1259
+ ? field.options
1260
+ : [];
1261
+ const iteration = iterations.find((entry) => {
1262
+ const entryId = String(entry?.id || "").trim();
1263
+ const name = String(entry?.title || entry?.name || "")
1264
+ .trim()
1265
+ .toLowerCase();
1266
+ const input = String(value || "").trim().toLowerCase();
1267
+ return entryId === String(value || "").trim() || name === input;
1268
+ });
1269
+ if (!iteration?.id) return null;
1270
+ return `{iterationId: ${this._escapeGraphQLString(iteration.id)}}`;
1271
+ }
1272
+ if (fieldType === "NUMBER") {
1273
+ const numeric = Number(value);
1274
+ if (!Number.isFinite(numeric)) return null;
1275
+ return `{number: ${numeric}}`;
1276
+ }
1277
+ if (fieldType === "DATE") {
1278
+ return `{date: ${this._escapeGraphQLString(value)}}`;
1279
+ }
1280
+ return `{text: ${this._escapeGraphQLString(value)}}`;
1281
+ }
1282
+
1283
+ async _updateProjectItemFieldsBatch(projectId, itemId, updates = []) {
1284
+ if (!projectId || !itemId || updates.length === 0) return false;
1285
+ const operations = updates
1286
+ .map((update, index) => {
1287
+ const alias = `update_${index}`;
1288
+ return `
1289
+ ${alias}: updateProjectV2ItemFieldValue(
1290
+ input: {
1291
+ projectId: ${this._escapeGraphQLString(projectId)},
1292
+ itemId: ${this._escapeGraphQLString(itemId)},
1293
+ fieldId: ${this._escapeGraphQLString(update.fieldId)},
1294
+ value: ${update.value}
1295
+ }
1296
+ ) {
1297
+ projectV2Item {
1298
+ id
1299
+ }
1300
+ }
1301
+ `;
1302
+ })
1303
+ .join("\n");
1304
+
1305
+ const mutation = `mutation { ${operations} }`;
1306
+ await this._gh(["api", "graphql", "-f", `query=${mutation}`]);
1307
+ return true;
1308
+ }
1309
+
1310
+ _matchesProjectFieldFilters(task, projectFieldFilter) {
1311
+ if (!projectFieldFilter || typeof projectFieldFilter !== "object") {
1312
+ return true;
1313
+ }
1314
+ const values = task?.meta?.projectFieldValues;
1315
+ if (!values || typeof values !== "object") return false;
1316
+ const entries = Object.entries(projectFieldFilter);
1317
+ if (entries.length === 0) return true;
1318
+
1319
+ return entries.every(([fieldName, expected]) => {
1320
+ const actualKey =
1321
+ Object.keys(values).find(
1322
+ (key) => key.toLowerCase() === String(fieldName).toLowerCase(),
1323
+ ) || fieldName;
1324
+ const actual = values[actualKey];
1325
+ if (Array.isArray(expected)) {
1326
+ const expectedSet = new Set(
1327
+ expected.map((entry) =>
1328
+ String(entry == null ? "" : entry)
1329
+ .trim()
1330
+ .toLowerCase(),
1331
+ ),
1332
+ );
1333
+ return expectedSet.has(
1334
+ String(actual == null ? "" : actual)
1335
+ .trim()
1336
+ .toLowerCase(),
1337
+ );
1338
+ }
1339
+ return (
1340
+ String(actual == null ? "" : actual)
1341
+ .trim()
1342
+ .toLowerCase() ===
1343
+ String(expected == null ? "" : expected)
1344
+ .trim()
1345
+ .toLowerCase()
1346
+ );
1347
+ });
1348
+ }
1349
+
1350
+ async getRepositoryNodeId() {
1351
+ if (this._repositoryNodeId) return this._repositoryNodeId;
1352
+ const query = `
1353
+ query {
1354
+ repository(
1355
+ owner: ${this._escapeGraphQLString(this._owner)},
1356
+ name: ${this._escapeGraphQLString(this._repo)}
1357
+ ) {
1358
+ id
1359
+ }
1360
+ }
1361
+ `;
1362
+ const data = await this._gh(["api", "graphql", "-f", `query=${query}`]);
1363
+ const repoId = data?.data?.repository?.id || null;
1364
+ if (repoId) this._repositoryNodeId = repoId;
1365
+ return repoId;
1366
+ }
1367
+
1368
+ /**
1369
+ * Get project item ID for an issue within a project (cached).
1370
+ * @private
1371
+ * @param {string} projectNumber - GitHub project number
1372
+ * @param {string|number} issueNumber - Issue number
1373
+ * @returns {Promise<string|null>} Project item ID or null
1374
+ */
1375
+ async _getProjectItemIdForIssue(projectNumber, issueNumber) {
1376
+ if (!projectNumber || !issueNumber) return null;
1377
+ const cacheKey = `${projectNumber}:${issueNumber}`;
1378
+ if (this._projectItemCache.has(cacheKey)) {
1379
+ return this._projectItemCache.get(cacheKey);
1380
+ }
1381
+
1382
+ // Try GraphQL resource query
1383
+ const issueUrl = `https://github.com/${this._owner}/${this._repo}/issues/${issueNumber}`;
1384
+ const projectId = await this.getProjectNodeId(projectNumber);
1385
+ if (!projectId) return null;
1386
+
1387
+ const query = `
1388
+ query {
1389
+ resource(url: "${issueUrl}") {
1390
+ ... on Issue {
1391
+ projectItems(first: 10) {
1392
+ nodes {
1393
+ id
1394
+ project {
1395
+ id
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+ }
1401
+ }
1402
+ `;
1403
+
1404
+ try {
1405
+ const data = await this._gh(["api", "graphql", "-f", `query=${query}`]);
1406
+ const items = data?.data?.resource?.projectItems?.nodes || [];
1407
+ const match = items.find((item) => item.project?.id === projectId);
1408
+ const itemId = match?.id || null;
1409
+ if (itemId) {
1410
+ this._projectItemCache.set(cacheKey, itemId);
1411
+ }
1412
+ return itemId;
1413
+ } catch (err) {
1414
+ console.warn(
1415
+ `${TAG} failed to get project item ID for issue #${issueNumber}: ${err.message}`,
1416
+ );
1417
+ return null;
1418
+ }
1419
+ }
1420
+
1421
+ /**
1422
+ * Update a generic field value on a project item via GraphQL mutation.
1423
+ * Supports text, number, date, and single_select field types.
1424
+ * @public
1425
+ * @param {string|number} issueNumber - Issue number
1426
+ * @param {string} projectNumber - GitHub project number
1427
+ * @param {string} fieldName - Field name (case-insensitive)
1428
+ * @param {string|number} value - Value to set
1429
+ * @returns {Promise<boolean>} Success status
1430
+ */
1431
+ async syncFieldToProject(issueNumber, projectNumber, fieldName, value) {
1432
+ if (!issueNumber || !projectNumber || !fieldName) return false;
1433
+
1434
+ try {
1435
+ const projectId = await this.getProjectNodeId(projectNumber);
1436
+ if (!projectId) {
1437
+ console.warn(`${TAG} syncFieldToProject: cannot resolve project ID`);
1438
+ return false;
1439
+ }
1440
+
1441
+ const fieldMap = await this.getProjectFields(projectNumber);
1442
+ const fieldKey = String(fieldName).toLowerCase().trim();
1443
+ const field = fieldMap.get(fieldKey);
1444
+ if (!field) {
1445
+ console.warn(
1446
+ `${TAG} syncFieldToProject: field "${fieldName}" not found in project`,
1447
+ );
1448
+ return false;
1449
+ }
1450
+
1451
+ const itemId = await this._getProjectItemIdForIssue(projectNumber, issueNumber);
1452
+ if (!itemId) {
1453
+ console.warn(
1454
+ `${TAG} syncFieldToProject: issue #${issueNumber} not found in project`,
1455
+ );
1456
+ return false;
1457
+ }
1458
+
1459
+ const valueJson = this._stringifyProjectFieldValue(field, value);
1460
+ if (!valueJson) {
1461
+ console.warn(
1462
+ `${TAG} syncFieldToProject: value "${value}" invalid for field "${fieldName}"`,
1463
+ );
1464
+ return false;
1465
+ }
1466
+
1467
+ await this._updateProjectItemFieldsBatch(projectId, itemId, [
1468
+ {
1469
+ fieldId: field.id,
1470
+ value: valueJson,
1471
+ },
1472
+ ]);
1473
+ console.log(
1474
+ `${TAG} synced field "${fieldName}" = "${value}" for issue #${issueNumber}`,
1475
+ );
1476
+ return true;
1477
+ } catch (err) {
1478
+ console.warn(
1479
+ `${TAG} syncFieldToProject failed for issue #${issueNumber}: ${err.message}`,
1480
+ );
1481
+ return false;
1482
+ }
1483
+ }
1484
+
1485
+ async syncIterationToProject(issueNumber, projectNumber, iterationName) {
1486
+ if (!issueNumber || !projectNumber || !iterationName) return false;
1487
+ return this.syncFieldToProject(
1488
+ issueNumber,
1489
+ projectNumber,
1490
+ "Iteration",
1491
+ iterationName,
1492
+ );
1493
+ }
1494
+
1495
+ /**
1496
+ * List tasks from a GitHub Project board.
1497
+ * Fetches project items and normalizes them directly (no N+1 issue fetches).
1498
+ * @public
1499
+ * @param {string} projectNumber - GitHub project number
1500
+ * @returns {Promise<KanbanTask[]>}
1501
+ */
1502
+ async listTasksFromProject(projectNumber, filters = {}) {
1503
+ if (!projectNumber) return [];
1504
+
1505
+ try {
1506
+ const owner = String(this._projectOwner || this._owner).trim();
1507
+ const items = await this._gh([
1508
+ "project",
1509
+ "item-list",
1510
+ String(projectNumber),
1511
+ "--owner",
1512
+ owner,
1513
+ "--format",
1514
+ "json",
1515
+ ]);
1516
+
1517
+ if (!Array.isArray(items)) {
1518
+ console.warn(
1519
+ `${TAG} project item-list returned non-array for project ${projectNumber}`,
1520
+ );
1521
+ return [];
1522
+ }
1523
+
1524
+ const tasks = [];
1525
+ for (const item of items) {
1526
+ // Skip non-issue items (draft issues without content, PRs)
1527
+ if (item.content?.type === "PullRequest") continue;
1528
+
1529
+ const task = this._normaliseProjectItem(item);
1530
+ if (task) {
1531
+ task.meta.projectNumber = projectNumber;
1532
+ if (!task.meta.projectFieldValues.Status && item.status) {
1533
+ task.meta.projectFieldValues.Status = item.status;
1534
+ }
1535
+ // Cache the project item ID for later lookups
1536
+ if (task.id && item.id) {
1537
+ this._projectItemCache.set(`${projectNumber}:${task.id}`, item.id);
1538
+ }
1539
+ if (this._matchesProjectFieldFilters(task, filters.projectField)) {
1540
+ tasks.push(task);
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ return tasks;
1546
+ } catch (err) {
1547
+ console.warn(
1548
+ `${TAG} failed to list tasks from project ${projectNumber}: ${err.message}`,
1549
+ );
1550
+ return [];
1551
+ }
1552
+ }
1553
+
1554
+ /**
1555
+ * Sync task status to GitHub Project board.
1556
+ * Maps codex status to project Status field and updates via GraphQL.
1557
+ * Uses configurable PROJECT_STATUS_MAP for status name resolution.
1558
+ * @private
1559
+ * @param {string} issueUrl - Full GitHub issue URL
1560
+ * @param {string} projectNumber - GitHub project number
1561
+ * @param {string} status - Normalized status (todo/inprogress/inreview/done)
1562
+ * @returns {Promise<boolean>}
1563
+ */
1564
+ async _syncStatusToProject(
1565
+ issueUrl,
1566
+ projectNumber,
1567
+ status,
1568
+ projectFields = {},
1569
+ ) {
1570
+ if (!issueUrl || !projectNumber || !status) return false;
1571
+
1572
+ try {
1573
+ const owner = String(this._projectOwner || this._owner).trim();
1574
+
1575
+ // Get project fields
1576
+ const fields = await this._getProjectFields(projectNumber);
1577
+ if (!fields || !fields.statusFieldId) {
1578
+ console.warn(`${TAG} cannot sync to project: no status field found`);
1579
+ return false;
1580
+ }
1581
+
1582
+ // Map codex status to project status option using configurable mapping
1583
+ const targetStatusName = this._normalizeProjectStatus(status, true);
1584
+ const normalizedTarget = normaliseStatus(status);
1585
+ let statusOption = fields.statusOptions.find(
1586
+ (opt) => opt.name.toLowerCase() === targetStatusName.toLowerCase(),
1587
+ );
1588
+
1589
+ if (!statusOption) {
1590
+ statusOption = fields.statusOptions.find(
1591
+ (opt) => normaliseStatus(opt.name) === normalizedTarget,
1592
+ );
1593
+ }
1594
+
1595
+ if (!statusOption) {
1596
+ console.warn(
1597
+ `${TAG} no matching project status for "${targetStatusName}"`,
1598
+ );
1599
+ return false;
1600
+ }
1601
+
1602
+ // First, ensure issue is in the project
1603
+ try {
1604
+ await this._gh(
1605
+ [
1606
+ "project",
1607
+ "item-add",
1608
+ String(projectNumber),
1609
+ "--owner",
1610
+ owner,
1611
+ "--url",
1612
+ issueUrl,
1613
+ ],
1614
+ { parseJson: false },
1615
+ );
1616
+ } catch (err) {
1617
+ const text = String(err?.message || err).toLowerCase();
1618
+ if (!text.includes("already") && !text.includes("item")) {
1619
+ throw err;
1620
+ }
1621
+ // Item already in project, continue
1622
+ }
1623
+
1624
+ const issueNum = issueUrl.match(/\/issues\/(\d+)/)?.[1];
1625
+ if (!issueNum) {
1626
+ console.warn(`${TAG} could not parse issue number from URL: ${issueUrl}`);
1627
+ return false;
1628
+ }
1629
+
1630
+ const projectId = await this.getProjectNodeId(projectNumber);
1631
+ if (!projectId) {
1632
+ console.warn(
1633
+ `${TAG} could not resolve project ID for ${owner}/${projectNumber}`,
1634
+ );
1635
+ return false;
1636
+ }
1637
+ const itemId = await this._getProjectItemIdForIssue(projectNumber, issueNum);
1638
+ if (!itemId) {
1639
+ console.warn(
1640
+ `${TAG} issue not found in project ${owner}/${projectNumber}`,
1641
+ );
1642
+ return false;
1643
+ }
1644
+ const fieldMap = await this.getProjectFields(projectNumber);
1645
+ const updates = [];
1646
+ updates.push({
1647
+ fieldId: fields.statusFieldId,
1648
+ value: `{singleSelectOptionId: ${this._escapeGraphQLString(statusOption.id)}}`,
1649
+ });
1650
+ for (const [fieldName, fieldValue] of Object.entries(projectFields || {})) {
1651
+ if (!fieldName || /^status$/i.test(fieldName)) continue;
1652
+ const field = fieldMap.get(String(fieldName).toLowerCase().trim());
1653
+ if (!field) {
1654
+ console.warn(
1655
+ `${TAG} skipping unknown project field during status sync: ${fieldName}`,
1656
+ );
1657
+ continue;
1658
+ }
1659
+ const value = this._stringifyProjectFieldValue(field, fieldValue);
1660
+ if (!value) {
1661
+ console.warn(
1662
+ `${TAG} skipping invalid project field value during status sync: ${fieldName}`,
1663
+ );
1664
+ continue;
1665
+ }
1666
+ updates.push({
1667
+ fieldId: field.id,
1668
+ value,
1669
+ });
1670
+ }
1671
+ await this._updateProjectItemFieldsBatch(projectId, itemId, updates);
1672
+
1673
+ console.log(
1674
+ `${TAG} synced issue ${issueUrl} to project status: ${targetStatusName}`,
1675
+ );
1676
+ return true;
1677
+ } catch (err) {
1678
+ console.warn(`${TAG} failed to sync status to project: ${err.message}`);
1679
+ return false;
1680
+ }
1681
+ }
1682
+
1683
+ /** Execute a gh CLI command and return parsed JSON (with rate limit retry) */
1684
+ async _gh(args, options = {}) {
1685
+ const { parseJson = true } = options;
1686
+ const { execFile } = await import("node:child_process");
1687
+ const { promisify } = await import("node:util");
1688
+ const execFileAsync = promisify(execFile);
1689
+
1690
+ const attempt = async () => {
1691
+ const { stdout, stderr } = await execFileAsync("gh", args, {
1692
+ maxBuffer: 10 * 1024 * 1024,
1693
+ timeout: 30_000,
1694
+ });
1695
+ return { stdout, stderr };
1696
+ };
1697
+
1698
+ let result;
1699
+ try {
1700
+ result = await attempt();
1701
+ } catch (err) {
1702
+ const errText = String(err?.message || err?.stderr || err).toLowerCase();
1703
+ // Rate limit detection: "API rate limit exceeded" or HTTP 403
1704
+ if (
1705
+ errText.includes("rate limit") ||
1706
+ errText.includes("api rate limit exceeded") ||
1707
+ (errText.includes("403") && errText.includes("limit"))
1708
+ ) {
1709
+ console.warn(`${TAG} rate limit detected, waiting 60s before retry...`);
1710
+ await new Promise((resolve) =>
1711
+ setTimeout(resolve, this._rateLimitRetryDelayMs),
1712
+ );
1713
+ try {
1714
+ result = await attempt();
1715
+ } catch (retryErr) {
1716
+ throw new Error(
1717
+ `gh CLI failed (after rate limit retry): ${retryErr.message}`,
1718
+ );
1719
+ }
1720
+ } else {
1721
+ throw new Error(`gh CLI failed: ${err.message}`);
1722
+ }
1723
+ }
1724
+
1725
+ const text = String(result.stdout || "").trim();
1726
+ if (!parseJson) return text;
1727
+ if (!text) return null;
1728
+ return JSON.parse(text);
1729
+ }
1730
+
1731
+ async _ensureLabelExists(label) {
1732
+ const name = String(label || "").trim();
1733
+ if (!name) return;
1734
+ const colorByLabel = {
1735
+ inprogress: "2563eb",
1736
+ "in-progress": "2563eb",
1737
+ inreview: "f59e0b",
1738
+ "in-review": "f59e0b",
1739
+ blocked: "dc2626",
1740
+ };
1741
+ const color = colorByLabel[name.toLowerCase()] || "94a3b8";
1742
+ try {
1743
+ await this._gh(
1744
+ [
1745
+ "label",
1746
+ "create",
1747
+ name,
1748
+ "--repo",
1749
+ `${this._owner}/${this._repo}`,
1750
+ "--color",
1751
+ color,
1752
+ "--description",
1753
+ `openfleet status: ${name}`,
1754
+ ],
1755
+ { parseJson: false },
1756
+ );
1757
+ } catch (err) {
1758
+ const msg = String(err?.message || err).toLowerCase();
1759
+ if (
1760
+ msg.includes("already exists") ||
1761
+ msg.includes("label") && msg.includes("exists")
1762
+ ) {
1763
+ return;
1764
+ }
1765
+ console.warn(`${TAG} failed to ensure label "${name}": ${err?.message || err}`);
1766
+ }
1767
+ }
1768
+
1769
+ async listProjects() {
1770
+ // GitHub doesn't have "projects" in the same sense — return repo as project
1771
+ return [
1772
+ {
1773
+ id: `${this._owner}/${this._repo}`,
1774
+ name: this._repo,
1775
+ meta: { owner: this._owner, repo: this._repo },
1776
+ backend: "github",
1777
+ },
1778
+ ];
1779
+ }
1780
+
1781
+ async listTasks(_projectId, filters = {}) {
1782
+ // If project mode is enabled, read from project board
1783
+ if (this._projectMode === "kanban" && this._projectNumber) {
1784
+ const projectNumber = await this._resolveProjectNumber();
1785
+ if (projectNumber) {
1786
+ try {
1787
+ const tasks = await this.listTasksFromProject(projectNumber, filters);
1788
+
1789
+ // Apply filters
1790
+ let filtered = tasks;
1791
+
1792
+ if (this._enforceTaskLabel) {
1793
+ filtered = filtered.filter((task) =>
1794
+ this._isTaskScopedForCodex(task),
1795
+ );
1796
+ }
1797
+
1798
+ if (filters.status) {
1799
+ const normalizedFilter = normaliseStatus(filters.status);
1800
+ filtered = filtered.filter(
1801
+ (task) => task.status === normalizedFilter,
1802
+ );
1803
+ }
1804
+
1805
+ // Enrich with shared state from comments
1806
+ for (const task of filtered) {
1807
+ try {
1808
+ const sharedState = normalizeSharedStatePayload(
1809
+ await this.readSharedStateFromIssue(task.id),
1810
+ );
1811
+ if (sharedState) {
1812
+ task.meta.sharedState = sharedState;
1813
+ task.sharedState = sharedState;
1814
+ }
1815
+ } catch (err) {
1816
+ console.warn(
1817
+ `[kanban] failed to read shared state for #${task.id}: ${err.message}`,
1818
+ );
1819
+ }
1820
+ }
1821
+
1822
+ return filtered;
1823
+ } catch (err) {
1824
+ console.warn(
1825
+ `${TAG} failed to list tasks from project, falling back to issues: ${err.message}`,
1826
+ );
1827
+ // Fall through to regular issue listing
1828
+ }
1829
+ }
1830
+ }
1831
+
1832
+ // Default: list from issues
1833
+ const limit =
1834
+ Number(filters.limit || process.env.GITHUB_ISSUES_LIST_LIMIT || 1000) ||
1835
+ 1000;
1836
+ const args = [
1837
+ "issue",
1838
+ "list",
1839
+ "--repo",
1840
+ `${this._owner}/${this._repo}`,
1841
+ "--json",
1842
+ "number,title,body,state,url,assignees,labels,milestone,comments",
1843
+ "--limit",
1844
+ String(limit),
1845
+ ];
1846
+ if (filters.status === "done") {
1847
+ args.push("--state", "closed");
1848
+ } else if (filters.status && filters.status !== "todo") {
1849
+ args.push("--state", "open");
1850
+ args.push("--label", filters.status);
1851
+ } else {
1852
+ args.push("--state", "open");
1853
+ }
1854
+ const issues = await this._gh(args);
1855
+ let normalized = (Array.isArray(issues) ? issues : []).map((i) =>
1856
+ this._normaliseIssue(i),
1857
+ );
1858
+
1859
+ if (this._enforceTaskLabel) {
1860
+ normalized = normalized.filter((task) =>
1861
+ this._isTaskScopedForCodex(task),
1862
+ );
1863
+ }
1864
+
1865
+ // Enrich with shared state from comments
1866
+ for (const task of normalized) {
1867
+ try {
1868
+ const sharedState = normalizeSharedStatePayload(
1869
+ await this.readSharedStateFromIssue(task.id),
1870
+ );
1871
+ if (sharedState) {
1872
+ task.meta.sharedState = sharedState;
1873
+ task.sharedState = sharedState;
1874
+ }
1875
+ } catch (err) {
1876
+ // Non-critical - continue without shared state
1877
+ console.warn(
1878
+ `[kanban] failed to read shared state for #${task.id}: ${err.message}`,
1879
+ );
1880
+ }
1881
+ }
1882
+
1883
+ return normalized;
1884
+ }
1885
+
1886
+ async getTask(issueNumber) {
1887
+ const num = String(issueNumber).replace(/^#/, "");
1888
+ if (!/^\d+$/.test(num)) {
1889
+ throw new Error(
1890
+ `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
1891
+ );
1892
+ }
1893
+ let issue = null;
1894
+ try {
1895
+ issue = await this._gh([
1896
+ "issue",
1897
+ "view",
1898
+ num,
1899
+ "--repo",
1900
+ `${this._owner}/${this._repo}`,
1901
+ "--json",
1902
+ "number,title,body,state,url,assignees,labels,milestone,comments",
1903
+ ]);
1904
+ } catch (err) {
1905
+ console.warn(
1906
+ `${TAG} failed to fetch issue #${num}: ${err.message || err}`,
1907
+ );
1908
+ }
1909
+ const task = issue
1910
+ ? this._normaliseIssue(issue)
1911
+ : {
1912
+ id: String(num),
1913
+ title: "",
1914
+ description: "",
1915
+ status: "todo",
1916
+ assignee: null,
1917
+ priority: null,
1918
+ projectId: `${this._owner}/${this._repo}`,
1919
+ branchName: null,
1920
+ prNumber: null,
1921
+ meta: {},
1922
+ taskUrl: null,
1923
+ backend: "github",
1924
+ };
1925
+
1926
+ if (issue && (!task.branchName || !task.prNumber)) {
1927
+ const comments = Array.isArray(issue?.comments) ? issue.comments : [];
1928
+ for (const comment of comments) {
1929
+ const body = comment?.body || comment?.bodyText || comment?.body_html || "";
1930
+ if (!task.branchName) {
1931
+ const branch = extractBranchFromText(body);
1932
+ if (branch) task.branchName = branch;
1933
+ }
1934
+ if (!task.prNumber) {
1935
+ const pr = extractPrFromText(body);
1936
+ if (pr) task.prNumber = pr;
1937
+ }
1938
+ if (task.branchName && task.prNumber) break;
1939
+ }
1940
+ }
1941
+
1942
+ // Enrich with shared state from comments
1943
+ try {
1944
+ const sharedState = normalizeSharedStatePayload(
1945
+ await this.readSharedStateFromIssue(num),
1946
+ );
1947
+ if (sharedState) {
1948
+ task.meta.sharedState = sharedState;
1949
+ task.sharedState = sharedState;
1950
+ }
1951
+ } catch (err) {
1952
+ // Non-critical - continue without shared state
1953
+ console.warn(
1954
+ `[kanban] failed to read shared state for #${num}: ${err.message}`,
1955
+ );
1956
+ }
1957
+
1958
+ return task;
1959
+ }
1960
+
1961
+ async updateTaskStatus(issueNumber, status, options = {}) {
1962
+ const num = String(issueNumber).replace(/^#/, "");
1963
+ if (!/^\d+$/.test(num)) {
1964
+ throw new Error(
1965
+ `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
1966
+ );
1967
+ }
1968
+ const normalised = normaliseStatus(status);
1969
+ if (normalised === "done" || normalised === "cancelled") {
1970
+ const closeArgs = [
1971
+ "issue",
1972
+ "close",
1973
+ num,
1974
+ "--repo",
1975
+ `${this._owner}/${this._repo}`,
1976
+ ];
1977
+ if (normalised === "cancelled") {
1978
+ closeArgs.push("--reason", "not planned");
1979
+ }
1980
+ await this._gh(closeArgs, { parseJson: false });
1981
+ } else {
1982
+ await this._gh(
1983
+ ["issue", "reopen", num, "--repo", `${this._owner}/${this._repo}`],
1984
+ { parseJson: false },
1985
+ );
1986
+
1987
+ // Keep status labels in sync for open issues.
1988
+ const labelByStatus = {
1989
+ draft: "draft",
1990
+ inprogress: "inprogress",
1991
+ inreview: "inreview",
1992
+ blocked: "blocked",
1993
+ };
1994
+ const nextLabel = labelByStatus[normalised] || null;
1995
+ const statusLabels = [
1996
+ "draft",
1997
+ "inprogress",
1998
+ "in-progress",
1999
+ "inreview",
2000
+ "in-review",
2001
+ "blocked",
2002
+ ];
2003
+ const removeLabels = statusLabels.filter((label) => label !== nextLabel);
2004
+ const editArgs = [
2005
+ "issue",
2006
+ "edit",
2007
+ num,
2008
+ "--repo",
2009
+ `${this._owner}/${this._repo}`,
2010
+ ];
2011
+ if (nextLabel) {
2012
+ editArgs.push("--add-label", nextLabel);
2013
+ }
2014
+ for (const label of removeLabels) {
2015
+ editArgs.push("--remove-label", label);
2016
+ }
2017
+ const applyStatusLabels = async () =>
2018
+ this._gh(editArgs, { parseJson: false });
2019
+ try {
2020
+ await applyStatusLabels();
2021
+ } catch (err) {
2022
+ if (nextLabel) {
2023
+ try {
2024
+ await this._ensureLabelExists(nextLabel);
2025
+ await applyStatusLabels();
2026
+ } catch {
2027
+ // Label might not exist — non-critical
2028
+ }
2029
+ }
2030
+ }
2031
+ }
2032
+
2033
+ // Optionally sync shared state if provided
2034
+ if (options.sharedState) {
2035
+ try {
2036
+ await this.persistSharedStateToIssue(num, options.sharedState);
2037
+ } catch (err) {
2038
+ console.warn(
2039
+ `[kanban] failed to persist shared state for #${num}: ${err.message}`,
2040
+ );
2041
+ }
2042
+ }
2043
+
2044
+ // Sync to project if configured and auto-sync is enabled
2045
+ if (
2046
+ this._projectMode === "kanban" &&
2047
+ this._projectNumber &&
2048
+ this._projectAutoSync
2049
+ ) {
2050
+ const projectNumber = await this._resolveProjectNumber();
2051
+ if (projectNumber) {
2052
+ const task = await this.getTask(num);
2053
+ if (task?.taskUrl) {
2054
+ try {
2055
+ await this._syncStatusToProject(
2056
+ task.taskUrl,
2057
+ projectNumber,
2058
+ normalised,
2059
+ options.projectFields,
2060
+ );
2061
+ } catch (err) {
2062
+ // Log but don't fail - issue update should still succeed
2063
+ console.warn(
2064
+ `${TAG} failed to sync status to project: ${err.message}`,
2065
+ );
2066
+ }
2067
+ }
2068
+ }
2069
+ }
2070
+
2071
+ try {
2072
+ return await this.getTask(issueNumber);
2073
+ } catch (err) {
2074
+ console.warn(
2075
+ `${TAG} failed to fetch updated issue #${num} after status change: ${err.message}`,
2076
+ );
2077
+ return {
2078
+ id: num,
2079
+ title: "",
2080
+ description: "",
2081
+ status: normalised,
2082
+ assignee: null,
2083
+ priority: null,
2084
+ projectId: `${this._owner}/${this._repo}`,
2085
+ branchName: null,
2086
+ prNumber: null,
2087
+ meta: {},
2088
+ taskUrl: null,
2089
+ backend: "github",
2090
+ };
2091
+ }
2092
+ }
2093
+
2094
+ async updateTask(issueNumber, patch = {}) {
2095
+ const num = String(issueNumber).replace(/^#/, "");
2096
+ if (!/^\d+$/.test(num)) {
2097
+ throw new Error(
2098
+ `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
2099
+ );
2100
+ }
2101
+ const editArgs = [
2102
+ "issue",
2103
+ "edit",
2104
+ num,
2105
+ "--repo",
2106
+ `${this._owner}/${this._repo}`,
2107
+ ];
2108
+ let hasEditArgs = false;
2109
+ if (typeof patch.title === "string") {
2110
+ editArgs.push("--title", patch.title);
2111
+ hasEditArgs = true;
2112
+ }
2113
+ if (typeof patch.description === "string") {
2114
+ editArgs.push("--body", patch.description);
2115
+ hasEditArgs = true;
2116
+ }
2117
+ if (hasEditArgs) {
2118
+ await this._gh(editArgs, { parseJson: false });
2119
+ }
2120
+ const baseBranch = resolveBaseBranchInput(patch);
2121
+ const wantsTags =
2122
+ Array.isArray(patch.tags) ||
2123
+ Array.isArray(patch.labels) ||
2124
+ typeof patch.tags === "string";
2125
+ if (wantsTags || baseBranch) {
2126
+ const issue = await this._gh([
2127
+ "issue",
2128
+ "view",
2129
+ num,
2130
+ "--repo",
2131
+ `${this._owner}/${this._repo}`,
2132
+ "--json",
2133
+ "labels",
2134
+ ]);
2135
+ const currentLabels = normalizeLabels(
2136
+ (issue?.labels || []).map((l) => (typeof l === "string" ? l : l.name)),
2137
+ );
2138
+ const systemLabels = new Set([
2139
+ ...SYSTEM_LABEL_KEYS,
2140
+ ...normalizeLabels(this._taskScopeLabels || []),
2141
+ ]);
2142
+ const currentTags = currentLabels.filter(
2143
+ (label) => !systemLabels.has(label) && !isUpstreamLabel(label),
2144
+ );
2145
+ const desiredTags = wantsTags
2146
+ ? normalizeTags(patch.tags ?? patch.labels)
2147
+ : currentTags;
2148
+ const nextLabels = new Set(
2149
+ currentLabels.filter(
2150
+ (label) => systemLabels.has(label) || isUpstreamLabel(label),
2151
+ ),
2152
+ );
2153
+ for (const label of desiredTags) nextLabels.add(label);
2154
+ if (baseBranch) {
2155
+ const upstreamLabel = `base:${baseBranch}`.toLowerCase();
2156
+ for (const label of [...nextLabels]) {
2157
+ if (isUpstreamLabel(label)) nextLabels.delete(label);
2158
+ }
2159
+ nextLabels.add(upstreamLabel);
2160
+ }
2161
+ const desired = [...nextLabels];
2162
+ const desiredSet = new Set(desired);
2163
+ const toAdd = desired.filter((label) => !currentLabels.includes(label));
2164
+ const toRemove = currentLabels.filter(
2165
+ (label) => !desiredSet.has(label),
2166
+ );
2167
+ if (toAdd.length || toRemove.length) {
2168
+ const labelArgs = [
2169
+ "issue",
2170
+ "edit",
2171
+ num,
2172
+ "--repo",
2173
+ `${this._owner}/${this._repo}`,
2174
+ ];
2175
+ for (const label of toAdd) {
2176
+ labelArgs.push("--add-label", label);
2177
+ }
2178
+ for (const label of toRemove) {
2179
+ labelArgs.push("--remove-label", label);
2180
+ }
2181
+ try {
2182
+ await this._gh(labelArgs, { parseJson: false });
2183
+ } catch (err) {
2184
+ for (const label of toAdd) {
2185
+ try {
2186
+ await this._ensureLabelExists(label);
2187
+ } catch {
2188
+ // ignore
2189
+ }
2190
+ }
2191
+ await this._gh(labelArgs, { parseJson: false });
2192
+ }
2193
+ }
2194
+ }
2195
+ if (typeof patch.draft === "boolean" && !patch.status) {
2196
+ await this.updateTaskStatus(num, patch.draft ? "draft" : "todo");
2197
+ }
2198
+ if (typeof patch.status === "string" && patch.status.trim()) {
2199
+ return this.updateTaskStatus(num, patch.status.trim());
2200
+ }
2201
+ return this.getTask(num);
2202
+ }
2203
+
2204
+ async addProjectV2DraftIssue(projectNumber, title, body = "") {
2205
+ const projectId = await this.getProjectNodeId(projectNumber);
2206
+ if (!projectId) return null;
2207
+ const mutation = `
2208
+ mutation {
2209
+ addProjectV2DraftIssue(
2210
+ input: {
2211
+ projectId: ${this._escapeGraphQLString(projectId)},
2212
+ title: ${this._escapeGraphQLString(title || "New task")},
2213
+ body: ${this._escapeGraphQLString(body)}
2214
+ }
2215
+ ) {
2216
+ projectItem {
2217
+ id
2218
+ }
2219
+ }
2220
+ }
2221
+ `;
2222
+ const result = await this._gh(["api", "graphql", "-f", `query=${mutation}`]);
2223
+ return result?.data?.addProjectV2DraftIssue?.projectItem?.id || null;
2224
+ }
2225
+
2226
+ async convertProjectV2DraftIssueItemToIssue(_projectNumber, projectItemId) {
2227
+ if (!projectItemId) return null;
2228
+ const repositoryId = await this.getRepositoryNodeId();
2229
+ if (!repositoryId) return null;
2230
+ const mutation = `
2231
+ mutation {
2232
+ convertProjectV2DraftIssueItemToIssue(
2233
+ input: {
2234
+ itemId: ${this._escapeGraphQLString(projectItemId)},
2235
+ repositoryId: ${this._escapeGraphQLString(repositoryId)}
2236
+ }
2237
+ ) {
2238
+ item {
2239
+ id
2240
+ }
2241
+ issue {
2242
+ number
2243
+ url
2244
+ title
2245
+ }
2246
+ }
2247
+ }
2248
+ `;
2249
+ const result = await this._gh(["api", "graphql", "-f", `query=${mutation}`]);
2250
+ return result?.data?.convertProjectV2DraftIssueItemToIssue?.issue || null;
2251
+ }
2252
+
2253
+ async createTask(_projectId, taskData) {
2254
+ const wantsDraftCreate = Boolean(taskData?.draft || taskData?.createDraft);
2255
+ const shouldConvertDraft = Boolean(
2256
+ taskData?.convertDraft || taskData?.convertToIssue,
2257
+ );
2258
+ const requestedStatus = normaliseStatus(taskData.status || "todo");
2259
+
2260
+ let projectNumber = null;
2261
+ if (this._projectMode === "kanban") {
2262
+ projectNumber = await this._resolveProjectNumber();
2263
+ }
2264
+ if (wantsDraftCreate && projectNumber) {
2265
+ const draftItemId = await this.addProjectV2DraftIssue(
2266
+ projectNumber,
2267
+ taskData.title || "New task",
2268
+ taskData.description || "",
2269
+ );
2270
+ if (!draftItemId) {
2271
+ throw new Error("[kanban] failed to create draft issue in project");
2272
+ }
2273
+ if (!shouldConvertDraft) {
2274
+ return {
2275
+ id: `draft:${draftItemId}`,
2276
+ title: taskData.title || "New task",
2277
+ description: taskData.description || "",
2278
+ status: requestedStatus,
2279
+ assignee: null,
2280
+ priority: null,
2281
+ projectId: `${this._owner}/${this._repo}`,
2282
+ branchName: null,
2283
+ prNumber: null,
2284
+ meta: {
2285
+ projectNumber,
2286
+ projectItemId: draftItemId,
2287
+ isDraft: true,
2288
+ },
2289
+ backend: "github",
2290
+ };
2291
+ }
2292
+ const converted = await this.convertProjectV2DraftIssueItemToIssue(
2293
+ projectNumber,
2294
+ draftItemId,
2295
+ );
2296
+ const convertedIssueNumber = String(converted?.number || "").trim();
2297
+ if (!/^\d+$/.test(convertedIssueNumber)) {
2298
+ throw new Error(
2299
+ "[kanban] failed to convert draft issue to repository issue",
2300
+ );
2301
+ }
2302
+ const requestedLabels = normalizeLabels([
2303
+ ...(taskData.labels || []),
2304
+ ...(taskData.tags || []),
2305
+ ]);
2306
+ const baseBranch = resolveBaseBranchInput(taskData);
2307
+ const labelsToApply = new Set(requestedLabels);
2308
+ labelsToApply.add(
2309
+ String(this._canonicalTaskLabel || "openfleet").toLowerCase(),
2310
+ );
2311
+ if (requestedStatus === "draft") labelsToApply.add("draft");
2312
+ if (requestedStatus === "inprogress") labelsToApply.add("inprogress");
2313
+ if (requestedStatus === "inreview") labelsToApply.add("inreview");
2314
+ if (requestedStatus === "blocked") labelsToApply.add("blocked");
2315
+ if (baseBranch) labelsToApply.add(`base:${baseBranch}`.toLowerCase());
2316
+ for (const label of labelsToApply) {
2317
+ await this._ensureLabelExists(label);
2318
+ }
2319
+ const assignee =
2320
+ taskData.assignee ||
2321
+ (this._autoAssignCreator ? await this._resolveDefaultAssignee() : null);
2322
+ const editArgs = [
2323
+ "issue",
2324
+ "edit",
2325
+ convertedIssueNumber,
2326
+ "--repo",
2327
+ `${this._owner}/${this._repo}`,
2328
+ ];
2329
+ if (assignee) editArgs.push("--add-assignee", assignee);
2330
+ for (const label of labelsToApply) {
2331
+ editArgs.push("--add-label", label);
2332
+ }
2333
+ await this._gh(editArgs, { parseJson: false });
2334
+ return this.updateTaskStatus(convertedIssueNumber, requestedStatus, {
2335
+ projectFields: taskData.projectFields,
2336
+ });
2337
+ }
2338
+
2339
+ const requestedLabels = normalizeLabels([
2340
+ ...(taskData.labels || []),
2341
+ ...(taskData.tags || []),
2342
+ ]);
2343
+ const baseBranch = resolveBaseBranchInput(taskData);
2344
+ const labelsToApply = new Set(requestedLabels);
2345
+ labelsToApply.add(
2346
+ String(this._canonicalTaskLabel || "openfleet").toLowerCase(),
2347
+ );
2348
+
2349
+ if (requestedStatus === "draft") labelsToApply.add("draft");
2350
+ if (requestedStatus === "inprogress") labelsToApply.add("inprogress");
2351
+ if (requestedStatus === "inreview") labelsToApply.add("inreview");
2352
+ if (requestedStatus === "blocked") labelsToApply.add("blocked");
2353
+ if (baseBranch) labelsToApply.add(`base:${baseBranch}`.toLowerCase());
2354
+
2355
+ for (const label of labelsToApply) {
2356
+ await this._ensureLabelExists(label);
2357
+ }
2358
+
2359
+ const assignee =
2360
+ taskData.assignee ||
2361
+ (this._autoAssignCreator ? await this._resolveDefaultAssignee() : null);
2362
+
2363
+ const args = [
2364
+ "issue",
2365
+ "create",
2366
+ "--repo",
2367
+ `${this._owner}/${this._repo}`,
2368
+ "--title",
2369
+ taskData.title || "New task",
2370
+ "--body",
2371
+ taskData.description || "",
2372
+ ];
2373
+ if (assignee) args.push("--assignee", assignee);
2374
+ if (labelsToApply.size > 0) {
2375
+ for (const label of labelsToApply) {
2376
+ args.push("--label", label);
2377
+ }
2378
+ }
2379
+ const result = await this._gh(args, { parseJson: false });
2380
+ const issueUrl = String(result || "").match(/https?:\/\/\S+/)?.[0] || "";
2381
+ const issueNum = issueUrl.match(/\/issues\/(\d+)/)?.[1] || null;
2382
+ if (issueUrl) {
2383
+ await this._ensureIssueLinkedToProject(issueUrl);
2384
+ }
2385
+ if (
2386
+ issueUrl &&
2387
+ projectNumber &&
2388
+ this._projectAutoSync &&
2389
+ (requestedStatus !== "todo" ||
2390
+ (taskData.projectFields &&
2391
+ typeof taskData.projectFields === "object" &&
2392
+ Object.keys(taskData.projectFields).length > 0))
2393
+ ) {
2394
+ try {
2395
+ await this._syncStatusToProject(
2396
+ issueUrl,
2397
+ projectNumber,
2398
+ requestedStatus,
2399
+ taskData.projectFields,
2400
+ );
2401
+ } catch (err) {
2402
+ console.warn(
2403
+ `${TAG} failed to sync project fields for created issue: ${err.message}`,
2404
+ );
2405
+ }
2406
+ }
2407
+ if (issueNum) {
2408
+ return this.getTask(issueNum);
2409
+ }
2410
+ const numericFallback = String(result || "")
2411
+ .trim()
2412
+ .match(/^#?(\d+)$/)?.[1];
2413
+ if (numericFallback) {
2414
+ return this.getTask(numericFallback);
2415
+ }
2416
+ return { id: issueUrl || String(result || "").trim(), backend: "github" };
2417
+ }
2418
+
2419
+ async deleteTask(issueNumber) {
2420
+ // GitHub issues can't be deleted — close with "not planned"
2421
+ const num = String(issueNumber).replace(/^#/, "");
2422
+ if (!/^\d+$/.test(num)) {
2423
+ throw new Error(
2424
+ `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID`,
2425
+ );
2426
+ }
2427
+ await this._gh([
2428
+ "issue",
2429
+ "close",
2430
+ num,
2431
+ "--repo",
2432
+ `${this._owner}/${this._repo}`,
2433
+ "--reason",
2434
+ "not planned",
2435
+ ]);
2436
+ return true;
2437
+ }
2438
+
2439
+ async addComment(issueNumber, body) {
2440
+ const num = String(issueNumber).replace(/^#/, "");
2441
+ if (!/^\d+$/.test(num) || !body) return false;
2442
+ try {
2443
+ await this._gh(
2444
+ [
2445
+ "issue",
2446
+ "comment",
2447
+ num,
2448
+ "--repo",
2449
+ `${this._owner}/${this._repo}`,
2450
+ "--body",
2451
+ String(body).slice(0, 65536),
2452
+ ],
2453
+ { parseJson: false },
2454
+ );
2455
+ return true;
2456
+ } catch (err) {
2457
+ console.warn(
2458
+ `[kanban] failed to comment on issue #${num}: ${err.message}`,
2459
+ );
2460
+ return false;
2461
+ }
2462
+ }
2463
+
2464
+ /**
2465
+ * Persist shared state to a GitHub issue using structured comments and labels.
2466
+ *
2467
+ * Creates or updates a openfleet-state comment with JSON state and applies
2468
+ * appropriate labels (codex:claimed, codex:working, codex:stale).
2469
+ *
2470
+ * Error handling: Retries once on failure, logs and continues on second failure.
2471
+ *
2472
+ * @param {string|number} issueNumber - GitHub issue number
2473
+ * @param {SharedState} sharedState - State object to persist
2474
+ * @returns {Promise<boolean>} Success status
2475
+ *
2476
+ * @example
2477
+ * await adapter.persistSharedStateToIssue(123, {
2478
+ * ownerId: "workstation-123/agent-456",
2479
+ * attemptToken: "uuid-here",
2480
+ * attemptStarted: "2026-02-14T17:00:00Z",
2481
+ * heartbeat: "2026-02-14T17:30:00Z",
2482
+ * status: "working",
2483
+ * retryCount: 1
2484
+ * });
2485
+ */
2486
+ async persistSharedStateToIssue(issueNumber, sharedState) {
2487
+ const num = String(issueNumber).replace(/^#/, "");
2488
+ if (!/^\d+$/.test(num)) {
2489
+ throw new Error(`Invalid issue number: ${issueNumber}`);
2490
+ }
2491
+ const normalizedState = normalizeSharedStatePayload(sharedState);
2492
+ if (!normalizedState) {
2493
+ throw new Error(`Invalid shared state payload for issue #${num}`);
2494
+ }
2495
+
2496
+ const attemptWithRetry = async (fn, maxRetries = 1) => {
2497
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2498
+ try {
2499
+ return await fn();
2500
+ } catch (err) {
2501
+ if (attempt === maxRetries) {
2502
+ console.error(
2503
+ `[kanban] persistSharedStateToIssue #${num} failed after ${maxRetries + 1} attempts: ${err.message}`,
2504
+ );
2505
+ return false;
2506
+ }
2507
+ console.warn(
2508
+ `[kanban] persistSharedStateToIssue #${num} attempt ${attempt + 1} failed, retrying: ${err.message}`,
2509
+ );
2510
+ await new Promise((resolve) => setTimeout(resolve, 1000));
2511
+ }
2512
+ }
2513
+ };
2514
+
2515
+ // 1. Update labels based on status
2516
+ const labelsSuccess = await attemptWithRetry(async () => {
2517
+ const currentLabels = await this._getIssueLabels(num);
2518
+ const codexLabels = Object.values(this._codexLabels);
2519
+ const otherLabels = currentLabels.filter(
2520
+ (label) => !codexLabels.includes(label),
2521
+ );
2522
+
2523
+ // Determine new codex label based on status
2524
+ let newCodexLabel = null;
2525
+ if (normalizedState.status === "claimed") {
2526
+ newCodexLabel = this._codexLabels.claimed;
2527
+ } else if (normalizedState.status === "working") {
2528
+ newCodexLabel = this._codexLabels.working;
2529
+ } else if (normalizedState.status === "stale") {
2530
+ newCodexLabel = this._codexLabels.stale;
2531
+ }
2532
+
2533
+ // Build new label set
2534
+ const newLabels = [...otherLabels];
2535
+ if (newCodexLabel) {
2536
+ newLabels.push(newCodexLabel);
2537
+ }
2538
+
2539
+ // Apply labels
2540
+ const editArgs = [
2541
+ "issue",
2542
+ "edit",
2543
+ num,
2544
+ "--repo",
2545
+ `${this._owner}/${this._repo}`,
2546
+ ];
2547
+
2548
+ // Remove old codex labels
2549
+ for (const label of codexLabels) {
2550
+ if (label !== newCodexLabel && currentLabels.includes(label)) {
2551
+ editArgs.push("--remove-label", label);
2552
+ }
2553
+ }
2554
+
2555
+ // Add new codex label
2556
+ if (newCodexLabel && !currentLabels.includes(newCodexLabel)) {
2557
+ editArgs.push("--add-label", newCodexLabel);
2558
+ }
2559
+
2560
+ if (editArgs.length > 6) {
2561
+ // Only run if we have label changes
2562
+ await this._gh(editArgs, { parseJson: false });
2563
+ }
2564
+ return true;
2565
+ });
2566
+
2567
+ // Short-circuit: if labels failed, skip comment update to avoid hanging
2568
+ if (!labelsSuccess) return false;
2569
+
2570
+ // 2. Create/update structured comment
2571
+ const commentSuccess = await attemptWithRetry(async () => {
2572
+ const comments = await this._getIssueComments(num);
2573
+ const stateCommentIndex = comments.findIndex((c) =>
2574
+ c.body?.includes("<!-- openfleet-state"),
2575
+ );
2576
+
2577
+ const [agentId, workstationId] = normalizedState.ownerId
2578
+ .split("/")
2579
+ .reverse();
2580
+ const stateJson = JSON.stringify(normalizedState, null, 2);
2581
+ const commentBody = `<!-- openfleet-state
2582
+ ${stateJson}
2583
+ -->
2584
+ **Codex Monitor Status**: Agent \`${agentId}\` on \`${workstationId}\` is ${normalizedState.status === "working" ? "working on" : normalizedState.status === "claimed" ? "claiming" : "stale for"} this task.
2585
+ *Last heartbeat: ${normalizedState.heartbeat || normalizedState.ownerHeartbeat}*`;
2586
+
2587
+ if (stateCommentIndex >= 0) {
2588
+ // Update existing comment
2589
+ const commentId = comments[stateCommentIndex].id;
2590
+ await this._gh(
2591
+ [
2592
+ "api",
2593
+ `/repos/${this._owner}/${this._repo}/issues/comments/${commentId}`,
2594
+ "-X",
2595
+ "PATCH",
2596
+ "-f",
2597
+ `body=${commentBody}`,
2598
+ ],
2599
+ { parseJson: false },
2600
+ );
2601
+ } else {
2602
+ // Create new comment
2603
+ await this.addComment(num, commentBody);
2604
+ }
2605
+ return true;
2606
+ });
2607
+
2608
+ return commentSuccess;
2609
+ }
2610
+
2611
+ /**
2612
+ * Read shared state from a GitHub issue by parsing openfleet-state comments.
2613
+ *
2614
+ * Searches for the latest comment containing the structured state JSON and
2615
+ * returns the parsed SharedState object, or null if not found.
2616
+ *
2617
+ * @param {string|number} issueNumber - GitHub issue number
2618
+ * @returns {Promise<SharedState|null>} Parsed shared state or null
2619
+ *
2620
+ * @example
2621
+ * const state = await adapter.readSharedStateFromIssue(123);
2622
+ * if (state) {
2623
+ * console.log(`Task claimed by ${state.ownerId}`);
2624
+ * }
2625
+ */
2626
+ async readSharedStateFromIssue(issueNumber) {
2627
+ const num = String(issueNumber).replace(/^#/, "");
2628
+ if (!/^\d+$/.test(num)) {
2629
+ throw new Error(`Invalid issue number: ${issueNumber}`);
2630
+ }
2631
+
2632
+ try {
2633
+ const comments = await this._getIssueComments(num);
2634
+ const stateComment = comments
2635
+ .reverse()
2636
+ .find((c) => c.body?.includes("<!-- openfleet-state"));
2637
+
2638
+ if (!stateComment) {
2639
+ return null;
2640
+ }
2641
+
2642
+ // Extract JSON from comment
2643
+ const match = stateComment.body.match(
2644
+ /<!-- openfleet-state\s*\n([\s\S]*?)\n-->/,
2645
+ );
2646
+ if (!match) {
2647
+ return null;
2648
+ }
2649
+
2650
+ const stateJson = match[1].trim();
2651
+ const state = normalizeSharedStatePayload(JSON.parse(stateJson));
2652
+
2653
+ // Validate required fields
2654
+ if (
2655
+ !state?.ownerId ||
2656
+ !state?.attemptToken ||
2657
+ !state?.attemptStarted ||
2658
+ !(state?.heartbeat || state?.ownerHeartbeat) ||
2659
+ !state?.status
2660
+ ) {
2661
+ console.warn(
2662
+ `[kanban] invalid shared state in #${num}: missing required fields`,
2663
+ );
2664
+ return null;
2665
+ }
2666
+
2667
+ return state;
2668
+ } catch (err) {
2669
+ console.warn(
2670
+ `[kanban] failed to read shared state for #${num}: ${err.message}`,
2671
+ );
2672
+ return null;
2673
+ }
2674
+ }
2675
+
2676
+ /**
2677
+ * Mark a task as ignored by openfleet.
2678
+ *
2679
+ * Adds the `codex:ignore` label and posts a comment explaining why the task
2680
+ * is being ignored. This prevents openfleet from repeatedly attempting
2681
+ * to claim or work on tasks that are not suitable for automation.
2682
+ *
2683
+ * @param {string|number} issueNumber - GitHub issue number
2684
+ * @param {string} reason - Human-readable reason for ignoring
2685
+ * @returns {Promise<boolean>} Success status
2686
+ *
2687
+ * @example
2688
+ * await adapter.markTaskIgnored(123, "Task requires manual security review");
2689
+ */
2690
+ async markTaskIgnored(issueNumber, reason) {
2691
+ const num = String(issueNumber).replace(/^#/, "");
2692
+ if (!/^\d+$/.test(num)) {
2693
+ throw new Error(`Invalid issue number: ${issueNumber}`);
2694
+ }
2695
+
2696
+ try {
2697
+ // Add codex:ignore label
2698
+ await this._gh(
2699
+ [
2700
+ "issue",
2701
+ "edit",
2702
+ num,
2703
+ "--repo",
2704
+ `${this._owner}/${this._repo}`,
2705
+ "--add-label",
2706
+ this._codexLabels.ignore,
2707
+ ],
2708
+ { parseJson: false },
2709
+ );
2710
+
2711
+ // Add comment explaining why
2712
+ const commentBody = `**Codex Monitor**: This task has been marked as ignored.
2713
+
2714
+ **Reason**: ${reason}
2715
+
2716
+ To re-enable openfleet for this task, remove the \`${this._codexLabels.ignore}\` label.`;
2717
+
2718
+ await this.addComment(num, commentBody);
2719
+
2720
+ return true;
2721
+ } catch (err) {
2722
+ console.error(
2723
+ `[kanban] failed to mark task #${num} as ignored: ${err.message}`,
2724
+ );
2725
+ return false;
2726
+ }
2727
+ }
2728
+
2729
+ /**
2730
+ * Get all labels for an issue.
2731
+ * @private
2732
+ */
2733
+ async _getIssueLabels(issueNumber) {
2734
+ const issue = await this._gh([
2735
+ "issue",
2736
+ "view",
2737
+ issueNumber,
2738
+ "--repo",
2739
+ `${this._owner}/${this._repo}`,
2740
+ "--json",
2741
+ "labels",
2742
+ ]);
2743
+ return (issue.labels || []).map((l) =>
2744
+ typeof l === "string" ? l : l.name,
2745
+ );
2746
+ }
2747
+
2748
+ /**
2749
+ * Get all comments for an issue.
2750
+ * @private
2751
+ */
2752
+ async _getIssueComments(issueNumber) {
2753
+ try {
2754
+ const result = await this._gh([
2755
+ "api",
2756
+ `/repos/${this._owner}/${this._repo}/issues/${issueNumber}/comments`,
2757
+ "--jq",
2758
+ ".",
2759
+ ]);
2760
+ return Array.isArray(result) ? result : [];
2761
+ } catch (err) {
2762
+ console.warn(
2763
+ `[kanban] failed to fetch comments for #${issueNumber}: ${err.message}`,
2764
+ );
2765
+ return [];
2766
+ }
2767
+ }
2768
+
2769
+ _isTaskScopedForCodex(task) {
2770
+ const labels = normalizeLabels(
2771
+ (task?.meta?.labels || []).map((entry) =>
2772
+ typeof entry === "string" ? entry : entry?.name,
2773
+ ),
2774
+ );
2775
+ if (labels.length === 0) return false;
2776
+ return this._taskScopeLabels.some((label) => labels.includes(label));
2777
+ }
2778
+
2779
+ async _resolveDefaultAssignee() {
2780
+ if (this._defaultAssignee) return this._defaultAssignee;
2781
+ try {
2782
+ const login = await this._gh(["api", "user", "--jq", ".login"], {
2783
+ parseJson: false,
2784
+ });
2785
+ const normalized = String(login || "").trim();
2786
+ if (normalized) {
2787
+ this._defaultAssignee = normalized;
2788
+ }
2789
+ } catch {
2790
+ this._defaultAssignee = null;
2791
+ }
2792
+ return this._defaultAssignee;
2793
+ }
2794
+
2795
+ async _ensureLabelExists(label) {
2796
+ const normalized = String(label || "").trim();
2797
+ if (!normalized) return;
2798
+ try {
2799
+ await this._gh(
2800
+ [
2801
+ "api",
2802
+ `/repos/${this._owner}/${this._repo}/labels`,
2803
+ "-X",
2804
+ "POST",
2805
+ "-f",
2806
+ `name=${normalized}`,
2807
+ "-f",
2808
+ "color=1D76DB",
2809
+ "-f",
2810
+ "description=Managed by openfleet",
2811
+ ],
2812
+ { parseJson: false },
2813
+ );
2814
+ } catch (err) {
2815
+ const text = String(err?.message || err).toLowerCase();
2816
+ if (
2817
+ text.includes("already_exists") ||
2818
+ text.includes("already exists") ||
2819
+ text.includes("unprocessable") ||
2820
+ text.includes("422")
2821
+ ) {
2822
+ return;
2823
+ }
2824
+ console.warn(
2825
+ `[kanban] failed to ensure label "${normalized}": ${err.message || err}`,
2826
+ );
2827
+ }
2828
+ }
2829
+
2830
+ _extractProjectNumber(value) {
2831
+ if (!value) return null;
2832
+ const text = String(value).trim();
2833
+ if (/^\d+$/.test(text)) return text;
2834
+ const match = text.match(/\/projects\/(\d+)(?:\b|$)/i);
2835
+ return match?.[1] || null;
2836
+ }
2837
+
2838
+ async _resolveProjectNumber() {
2839
+ if (this._cachedProjectNumber) return this._cachedProjectNumber;
2840
+ const owner = String(this._projectOwner || "").trim();
2841
+ const title = String(this._projectTitle || "OpenFleet").trim();
2842
+ if (!owner || !title) return null;
2843
+
2844
+ try {
2845
+ const projects = await this._gh(
2846
+ ["project", "list", "--owner", owner, "--format", "json"],
2847
+ { parseJson: true },
2848
+ );
2849
+ const list = Array.isArray(projects)
2850
+ ? projects
2851
+ : Array.isArray(projects?.projects)
2852
+ ? projects.projects
2853
+ : [];
2854
+ const existing = list.find(
2855
+ (project) =>
2856
+ String(project?.title || "")
2857
+ .trim()
2858
+ .toLowerCase() === title.toLowerCase(),
2859
+ );
2860
+ const existingNumber = this._extractProjectNumber(
2861
+ existing?.number || existing?.url,
2862
+ );
2863
+ if (existingNumber) {
2864
+ this._cachedProjectNumber = existingNumber;
2865
+ return existingNumber;
2866
+ }
2867
+ } catch {
2868
+ return null;
2869
+ }
2870
+
2871
+ try {
2872
+ const output = await this._gh(
2873
+ ["project", "create", "--owner", owner, "--title", title],
2874
+ { parseJson: false },
2875
+ );
2876
+ const createdNumber = this._extractProjectNumber(output);
2877
+ if (createdNumber) {
2878
+ this._cachedProjectNumber = createdNumber;
2879
+ return createdNumber;
2880
+ }
2881
+ } catch {
2882
+ return null;
2883
+ }
2884
+
2885
+ return null;
2886
+ }
2887
+
2888
+ async _ensureIssueLinkedToProject(issueUrl) {
2889
+ if (this._projectMode !== "kanban") return;
2890
+ const owner = String(this._projectOwner || "").trim();
2891
+ if (!owner || !issueUrl) return;
2892
+ const projectNumber = await this._resolveProjectNumber();
2893
+ if (!projectNumber) return;
2894
+
2895
+ try {
2896
+ await this._gh(
2897
+ [
2898
+ "project",
2899
+ "item-add",
2900
+ String(projectNumber),
2901
+ "--owner",
2902
+ owner,
2903
+ "--url",
2904
+ issueUrl,
2905
+ ],
2906
+ { parseJson: false },
2907
+ );
2908
+ } catch (err) {
2909
+ const text = String(err?.message || err).toLowerCase();
2910
+ if (text.includes("already") && text.includes("item")) {
2911
+ return;
2912
+ }
2913
+ console.warn(
2914
+ `[kanban] failed to add issue to project ${owner}/${projectNumber}: ${err.message || err}`,
2915
+ );
2916
+ }
2917
+ }
2918
+
2919
+ _normaliseIssue(issue) {
2920
+ if (!issue) return null;
2921
+ const labels = (issue.labels || []).map((l) =>
2922
+ typeof l === "string" ? l : l.name,
2923
+ );
2924
+ const labelSet = new Set(
2925
+ labels.map((l) =>
2926
+ String(l || "")
2927
+ .trim()
2928
+ .toLowerCase(),
2929
+ ),
2930
+ );
2931
+ const labelStatus = statusFromLabels(labels);
2932
+ const tags = extractTagsFromLabels(labels, this._taskScopeLabels || []);
2933
+ let status = "todo";
2934
+ if (issue.state === "closed" || issue.state === "CLOSED") {
2935
+ status = "done";
2936
+ } else if (labelStatus) {
2937
+ status = labelStatus;
2938
+ }
2939
+ if (labelSet.has("draft")) status = "draft";
2940
+
2941
+ // Check for openfleet labels
2942
+ const codexMeta = {
2943
+ isIgnored: labelSet.has("codex:ignore"),
2944
+ isClaimed: labelSet.has("codex:claimed"),
2945
+ isWorking: labelSet.has("codex:working"),
2946
+ isStale: labelSet.has("codex:stale"),
2947
+ };
2948
+
2949
+ // Extract branch name from issue body if present
2950
+ const branchMatch = (issue.body || "").match(/branch:\s*`?([^\s`]+)`?/i);
2951
+ const prMatch = (issue.body || "").match(/pr:\s*#?(\d+)/i);
2952
+ const baseBranch = normalizeBranchName(
2953
+ extractBaseBranchFromLabels(labels) ||
2954
+ extractBaseBranchFromText(issue.body || ""),
2955
+ );
2956
+
2957
+ return {
2958
+ id: String(issue.number || ""),
2959
+ title: issue.title || "",
2960
+ description: issue.body || "",
2961
+ status,
2962
+ assignee: issue.assignees?.[0]?.login || null,
2963
+ priority: labelSet.has("critical")
2964
+ ? "critical"
2965
+ : labelSet.has("high")
2966
+ ? "high"
2967
+ : null,
2968
+ tags,
2969
+ draft: labelSet.has("draft") || status === "draft",
2970
+ projectId: `${this._owner}/${this._repo}`,
2971
+ baseBranch,
2972
+ branchName: branchMatch?.[1] || null,
2973
+ prNumber: prMatch?.[1] || null,
2974
+ meta: {
2975
+ ...issue,
2976
+ task_url: issue.url || null,
2977
+ tags,
2978
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
2979
+ codex: codexMeta,
2980
+ },
2981
+ taskUrl: issue.url || null,
2982
+ backend: "github",
2983
+ };
2984
+ }
2985
+ }
2986
+
2987
+ // ---------------------------------------------------------------------------
2988
+ // Jira Adapter
2989
+ // ---------------------------------------------------------------------------
2990
+
2991
+ class JiraAdapter {
2992
+ constructor() {
2993
+ this.name = "jira";
2994
+ this._baseUrl = String(process.env.JIRA_BASE_URL || "")
2995
+ .trim()
2996
+ .replace(/\/+$/, "");
2997
+ this._token = process.env.JIRA_API_TOKEN || null;
2998
+ this._email = process.env.JIRA_EMAIL || null;
2999
+ this._defaultProjectKey = String(process.env.JIRA_PROJECT_KEY || "")
3000
+ .trim()
3001
+ .toUpperCase();
3002
+ this._defaultIssueType = String(
3003
+ process.env.JIRA_ISSUE_TYPE || process.env.JIRA_DEFAULT_ISSUE_TYPE || "Task",
3004
+ ).trim();
3005
+ this._taskListLimit =
3006
+ Number(process.env.JIRA_ISSUES_LIST_LIMIT || 250) || 250;
3007
+ this._useAdfComments = parseBooleanEnv(process.env.JIRA_USE_ADF_COMMENTS, true);
3008
+ this._defaultAssignee = String(process.env.JIRA_DEFAULT_ASSIGNEE || "").trim();
3009
+ this._subtaskParentKey = String(
3010
+ process.env.JIRA_SUBTASK_PARENT_KEY || "",
3011
+ ).trim();
3012
+ this._canonicalTaskLabel = String(
3013
+ process.env.CODEX_MONITOR_TASK_LABEL || "openfleet",
3014
+ )
3015
+ .trim()
3016
+ .toLowerCase();
3017
+ this._taskScopeLabels = normalizeLabels(
3018
+ process.env.JIRA_TASK_LABELS ||
3019
+ process.env.CODEX_MONITOR_TASK_LABELS ||
3020
+ `${this._canonicalTaskLabel},openfleet`,
3021
+ ).map((label) => this._sanitizeJiraLabel(label));
3022
+ this._enforceTaskLabel = parseBooleanEnv(
3023
+ process.env.JIRA_ENFORCE_TASK_LABEL ?? process.env.CODEX_MONITOR_ENFORCE_TASK_LABEL,
3024
+ true,
3025
+ );
3026
+ this._codexLabels = {
3027
+ claimed: this._sanitizeJiraLabel(
3028
+ process.env.JIRA_LABEL_CLAIMED ||
3029
+ process.env.JIRA_CODEX_LABEL_CLAIMED ||
3030
+ "codex-claimed",
3031
+ ),
3032
+ working: this._sanitizeJiraLabel(
3033
+ process.env.JIRA_LABEL_WORKING ||
3034
+ process.env.JIRA_CODEX_LABEL_WORKING ||
3035
+ "codex-working",
3036
+ ),
3037
+ stale: this._sanitizeJiraLabel(
3038
+ process.env.JIRA_LABEL_STALE ||
3039
+ process.env.JIRA_CODEX_LABEL_STALE ||
3040
+ "codex-stale",
3041
+ ),
3042
+ ignore: this._sanitizeJiraLabel(
3043
+ process.env.JIRA_LABEL_IGNORE ||
3044
+ process.env.JIRA_CODEX_LABEL_IGNORE ||
3045
+ "codex-ignore",
3046
+ ),
3047
+ };
3048
+ this._statusMap = {
3049
+ todo: process.env.JIRA_STATUS_TODO || "To Do",
3050
+ inprogress: process.env.JIRA_STATUS_INPROGRESS || "In Progress",
3051
+ inreview: process.env.JIRA_STATUS_INREVIEW || "In Review",
3052
+ done: process.env.JIRA_STATUS_DONE || "Done",
3053
+ cancelled: process.env.JIRA_STATUS_CANCELLED || "Cancelled",
3054
+ };
3055
+ this._sharedStateFields = {
3056
+ ownerId: process.env.JIRA_CUSTOM_FIELD_OWNER_ID || "",
3057
+ attemptToken: process.env.JIRA_CUSTOM_FIELD_ATTEMPT_TOKEN || "",
3058
+ attemptStarted: process.env.JIRA_CUSTOM_FIELD_ATTEMPT_STARTED || "",
3059
+ heartbeat: process.env.JIRA_CUSTOM_FIELD_HEARTBEAT || "",
3060
+ retryCount: process.env.JIRA_CUSTOM_FIELD_RETRY_COUNT || "",
3061
+ ignoreReason: process.env.JIRA_CUSTOM_FIELD_IGNORE_REASON || "",
3062
+ stateJson: process.env.JIRA_CUSTOM_FIELD_SHARED_STATE || "",
3063
+ };
3064
+ this._customFieldBaseBranch = String(
3065
+ process.env.JIRA_CUSTOM_FIELD_BASE_BRANCH || "",
3066
+ ).trim();
3067
+ this._jiraFieldByNameCache = null;
3068
+ }
3069
+
3070
+ _requireConfigured() {
3071
+ if (!this._baseUrl || !this._email || !this._token) {
3072
+ throw new Error(
3073
+ `${TAG} Jira adapter requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN`,
3074
+ );
3075
+ }
3076
+ }
3077
+
3078
+ _validateIssueKey(issueKey) {
3079
+ const key = String(issueKey || "")
3080
+ .trim()
3081
+ .toUpperCase();
3082
+ if (!/^[A-Z][A-Z0-9]+-\d+$/.test(key)) {
3083
+ throw new Error(
3084
+ `Jira: invalid issue key "${issueKey}" — expected format PROJ-123`,
3085
+ );
3086
+ }
3087
+ return key;
3088
+ }
3089
+
3090
+ _normalizeProjectKey(projectKey) {
3091
+ const key = String(projectKey || this._defaultProjectKey || "")
3092
+ .trim()
3093
+ .toUpperCase();
3094
+ return /^[A-Z][A-Z0-9]+$/.test(key) ? key : "";
3095
+ }
3096
+
3097
+ _normalizeIssueKey(issueKey) {
3098
+ const key = String(issueKey || "").trim().toUpperCase();
3099
+ return /^[A-Z][A-Z0-9]+-\d+$/.test(key) ? key : "";
3100
+ }
3101
+
3102
+ _sanitizeJiraLabel(value) {
3103
+ return String(value || "")
3104
+ .trim()
3105
+ .toLowerCase()
3106
+ .replace(/[^a-z0-9_-]+/g, "-")
3107
+ .replace(/^-+|-+$/g, "");
3108
+ }
3109
+
3110
+ _authHeaders() {
3111
+ const credentials = Buffer.from(`${this._email}:${this._token}`).toString(
3112
+ "base64",
3113
+ );
3114
+ return {
3115
+ Authorization: `Basic ${credentials}`,
3116
+ Accept: "application/json",
3117
+ "Content-Type": "application/json",
3118
+ };
3119
+ }
3120
+
3121
+ async _jira(path, options = {}) {
3122
+ this._requireConfigured();
3123
+ const method = options.method || "GET";
3124
+ const headers = {
3125
+ ...this._authHeaders(),
3126
+ ...(options.headers || {}),
3127
+ };
3128
+ const response = await fetchWithFallback(
3129
+ `${this._baseUrl}${path.startsWith("/") ? path : `/${path}`}`,
3130
+ {
3131
+ method,
3132
+ headers,
3133
+ body: options.body == null ? undefined : JSON.stringify(options.body),
3134
+ },
3135
+ );
3136
+ if (!response || typeof response.status !== "number") {
3137
+ throw new Error(`Jira API ${method} ${path} failed: no HTTP response`);
3138
+ }
3139
+
3140
+ if (response.status === 204) return null;
3141
+
3142
+ const contentType = String(response.headers.get("content-type") || "");
3143
+ let payload = null;
3144
+ if (contentType.includes("application/json")) {
3145
+ payload = await response.json().catch(() => null);
3146
+ } else {
3147
+ payload = await response.text().catch(() => "");
3148
+ }
3149
+
3150
+ if (!response.ok) {
3151
+ const errorText =
3152
+ payload?.errorMessages?.join("; ") ||
3153
+ (payload?.errors ? Object.values(payload.errors || {}).join("; ") : "");
3154
+ const error = new Error(
3155
+ `Jira API ${method} ${path} failed (${response.status}): ${errorText || String(payload || response.statusText || "Unknown error")}`,
3156
+ );
3157
+ error.status = response.status;
3158
+ error.payload = payload;
3159
+ throw error;
3160
+ }
3161
+ return payload;
3162
+ }
3163
+
3164
+ _adfParagraph(text, marks = []) {
3165
+ return {
3166
+ type: "paragraph",
3167
+ content: [
3168
+ {
3169
+ type: "text",
3170
+ text: String(text || ""),
3171
+ ...(Array.isArray(marks) && marks.length > 0 ? { marks } : {}),
3172
+ },
3173
+ ],
3174
+ };
3175
+ }
3176
+
3177
+ _textToAdf(text) {
3178
+ const value = String(text || "");
3179
+ if (!value.trim()) {
3180
+ return { type: "doc", version: 1, content: [this._adfParagraph("")] };
3181
+ }
3182
+ const lines = value.split(/\r?\n/);
3183
+ return {
3184
+ type: "doc",
3185
+ version: 1,
3186
+ content: lines.map((line) => this._adfParagraph(line)),
3187
+ };
3188
+ }
3189
+
3190
+ _adfToText(node) {
3191
+ if (!node) return "";
3192
+ if (typeof node === "string") return node;
3193
+ if (Array.isArray(node)) {
3194
+ return node.map((entry) => this._adfToText(entry)).join("");
3195
+ }
3196
+ if (node.type === "text") return String(node.text || "");
3197
+ const content = Array.isArray(node.content) ? node.content : [];
3198
+ const inner = content.map((entry) => this._adfToText(entry)).join("");
3199
+ if (node.type === "paragraph" || node.type === "heading") {
3200
+ return `${inner}\n`;
3201
+ }
3202
+ if (node.type === "hardBreak") return "\n";
3203
+ return inner;
3204
+ }
3205
+
3206
+ _commentToText(commentBody) {
3207
+ if (typeof commentBody === "string") return commentBody;
3208
+ if (commentBody && typeof commentBody === "object") {
3209
+ return this._adfToText(commentBody).trim();
3210
+ }
3211
+ return "";
3212
+ }
3213
+
3214
+ _normalizePriority(priorityName) {
3215
+ const value = String(priorityName || "")
3216
+ .trim()
3217
+ .toLowerCase();
3218
+ if (!value) return null;
3219
+ if (value.includes("highest") || value.includes("critical")) return "critical";
3220
+ if (value.includes("high")) return "high";
3221
+ if (value.includes("medium") || value.includes("normal")) return "medium";
3222
+ if (value.includes("low") || value.includes("lowest")) return "low";
3223
+ return null;
3224
+ }
3225
+
3226
+ _normalizeJiraStatus(statusObj) {
3227
+ if (!statusObj) return "todo";
3228
+ const statusCategory = String(statusObj?.statusCategory?.key || "")
3229
+ .trim()
3230
+ .toLowerCase();
3231
+ if (statusCategory === "done") return "done";
3232
+ return normaliseStatus(statusObj.name || statusObj.statusCategory?.name || "");
3233
+ }
3234
+
3235
+ _normaliseIssue(issue) {
3236
+ const fields = issue?.fields || {};
3237
+ const labels = normalizeLabels(fields.labels || []);
3238
+ const labelSet = new Set(labels);
3239
+ const tags = extractTagsFromLabels(labels, this._taskScopeLabels || []);
3240
+ const codexMeta = {
3241
+ isIgnored:
3242
+ labelSet.has(this._codexLabels.ignore) || labelSet.has("codex:ignore"),
3243
+ isClaimed:
3244
+ labelSet.has(this._codexLabels.claimed) || labelSet.has("codex:claimed"),
3245
+ isWorking:
3246
+ labelSet.has(this._codexLabels.working) || labelSet.has("codex:working"),
3247
+ isStale: labelSet.has(this._codexLabels.stale) || labelSet.has("codex:stale"),
3248
+ };
3249
+ const description = this._commentToText(fields.description);
3250
+ const branchMatch = description.match(/branch:\s*`?([^\s`]+)`?/i);
3251
+ const prMatch = description.match(/pr:\s*#?(\d+)/i);
3252
+ const baseBranchFromField = this._customFieldBaseBranch
3253
+ ? fields[this._customFieldBaseBranch]
3254
+ : null;
3255
+ const baseBranch = normalizeBranchName(
3256
+ extractBaseBranchFromLabels(labels) ||
3257
+ extractBaseBranchFromText(description) ||
3258
+ (typeof baseBranchFromField === "string"
3259
+ ? baseBranchFromField
3260
+ : baseBranchFromField?.value ||
3261
+ baseBranchFromField?.name ||
3262
+ baseBranchFromField),
3263
+ );
3264
+ const issueKey = String(issue?.key || "");
3265
+ let status = this._normalizeJiraStatus(fields.status);
3266
+ if (labelSet.has("draft")) status = "draft";
3267
+ const normalizedFieldValues = {};
3268
+ for (const [fieldKey, fieldValue] of Object.entries(fields || {})) {
3269
+ if (fieldValue == null) continue;
3270
+ const lcKey = String(fieldKey || "").toLowerCase();
3271
+ if (typeof fieldValue === "object") {
3272
+ if (typeof fieldValue.name === "string") {
3273
+ normalizedFieldValues[fieldKey] = fieldValue.name;
3274
+ normalizedFieldValues[lcKey] = fieldValue.name;
3275
+ } else if (typeof fieldValue.value === "string") {
3276
+ normalizedFieldValues[fieldKey] = fieldValue.value;
3277
+ normalizedFieldValues[lcKey] = fieldValue.value;
3278
+ } else {
3279
+ normalizedFieldValues[fieldKey] = this._commentToText(fieldValue);
3280
+ normalizedFieldValues[lcKey] = this._commentToText(fieldValue);
3281
+ }
3282
+ } else {
3283
+ normalizedFieldValues[fieldKey] = fieldValue;
3284
+ normalizedFieldValues[lcKey] = fieldValue;
3285
+ }
3286
+ }
3287
+ return {
3288
+ id: issueKey,
3289
+ title: fields.summary || "",
3290
+ description,
3291
+ status,
3292
+ assignee:
3293
+ fields.assignee?.displayName ||
3294
+ fields.assignee?.emailAddress ||
3295
+ fields.assignee?.accountId ||
3296
+ null,
3297
+ priority: this._normalizePriority(fields.priority?.name),
3298
+ tags,
3299
+ draft: labelSet.has("draft") || status === "draft",
3300
+ projectId: fields.project?.key || null,
3301
+ baseBranch,
3302
+ branchName: branchMatch?.[1] || null,
3303
+ prNumber: prMatch?.[1] || null,
3304
+ taskUrl: issueKey ? `${this._baseUrl}/browse/${issueKey}` : null,
3305
+ createdAt: fields.created || null,
3306
+ updatedAt: fields.updated || null,
3307
+ meta: {
3308
+ ...issue,
3309
+ labels,
3310
+ fields: normalizedFieldValues,
3311
+ tags,
3312
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
3313
+ codex: codexMeta,
3314
+ },
3315
+ backend: "jira",
3316
+ };
3317
+ }
3318
+
3319
+ _isTaskScopedForCodex(task) {
3320
+ const labels = normalizeLabels(task?.meta?.labels || []);
3321
+ if (labels.length === 0) return false;
3322
+ return this._taskScopeLabels.some((label) => labels.includes(label));
3323
+ }
3324
+
3325
+ _statusCandidates(normalizedStatus) {
3326
+ switch (normalizedStatus) {
3327
+ case "todo":
3328
+ return ["to do", "todo", "selected for development", "open", "backlog"];
3329
+ case "inprogress":
3330
+ return ["in progress", "in development", "doing", "active"];
3331
+ case "inreview":
3332
+ return ["in review", "review", "code review", "qa", "testing"];
3333
+ case "done":
3334
+ return ["done", "resolved", "closed", "complete", "completed"];
3335
+ case "cancelled":
3336
+ return ["cancelled", "canceled", "won't do", "wont do", "declined"];
3337
+ default:
3338
+ return [String(normalizedStatus || "").trim().toLowerCase()];
3339
+ }
3340
+ }
3341
+
3342
+ _normalizeIsoTimestamp(value) {
3343
+ const raw = String(value || "").trim();
3344
+ if (!raw) return "";
3345
+ const date = new Date(raw);
3346
+ if (Number.isNaN(date.getTime())) return "";
3347
+ return date.toISOString();
3348
+ }
3349
+
3350
+ async _getJiraFieldMap() {
3351
+ if (this._jiraFieldByNameCache) return this._jiraFieldByNameCache;
3352
+ const fields = await this._jira("/rest/api/3/field");
3353
+ const map = new Map();
3354
+ for (const field of Array.isArray(fields) ? fields : []) {
3355
+ const id = String(field?.id || "").trim();
3356
+ const name = String(field?.name || "")
3357
+ .trim()
3358
+ .toLowerCase();
3359
+ if (!id || !name) continue;
3360
+ map.set(name, id);
3361
+ }
3362
+ this._jiraFieldByNameCache = map;
3363
+ return map;
3364
+ }
3365
+
3366
+ async _resolveJiraFieldId(fieldKeyOrName) {
3367
+ const raw = String(fieldKeyOrName || "").trim();
3368
+ if (!raw) return null;
3369
+ if (/^customfield_\d+$/i.test(raw)) return raw;
3370
+ const lc = raw.toLowerCase();
3371
+ if (
3372
+ [
3373
+ "summary",
3374
+ "description",
3375
+ "status",
3376
+ "assignee",
3377
+ "priority",
3378
+ "project",
3379
+ "labels",
3380
+ ].includes(lc)
3381
+ ) {
3382
+ return lc;
3383
+ }
3384
+ try {
3385
+ const map = await this._getJiraFieldMap();
3386
+ return map.get(lc) || null;
3387
+ } catch {
3388
+ return null;
3389
+ }
3390
+ }
3391
+
3392
+ async _mapProjectFieldsInput(projectFields = {}) {
3393
+ const mapped = {};
3394
+ for (const [fieldName, value] of Object.entries(projectFields || {})) {
3395
+ const fieldId = await this._resolveJiraFieldId(fieldName);
3396
+ if (!fieldId || fieldId === "status") continue;
3397
+ mapped[fieldId] = value;
3398
+ }
3399
+ return mapped;
3400
+ }
3401
+
3402
+ _matchesProjectFieldFilters(task, projectFieldFilter) {
3403
+ if (!projectFieldFilter || typeof projectFieldFilter !== "object") {
3404
+ return true;
3405
+ }
3406
+ const values = task?.meta?.fields;
3407
+ if (!values || typeof values !== "object") return false;
3408
+ const entries = Object.entries(projectFieldFilter);
3409
+ if (entries.length === 0) return true;
3410
+ return entries.every(([fieldName, expected]) => {
3411
+ const direct = values[fieldName];
3412
+ const custom = values[String(fieldName).toLowerCase()];
3413
+ const actual = direct ?? custom ?? null;
3414
+ if (Array.isArray(expected)) {
3415
+ const expectedSet = new Set(
3416
+ expected.map((entry) =>
3417
+ String(entry == null ? "" : entry)
3418
+ .trim()
3419
+ .toLowerCase(),
3420
+ ),
3421
+ );
3422
+ return expectedSet.has(
3423
+ String(actual == null ? "" : actual)
3424
+ .trim()
3425
+ .toLowerCase(),
3426
+ );
3427
+ }
3428
+ return (
3429
+ String(actual == null ? "" : actual)
3430
+ .trim()
3431
+ .toLowerCase() ===
3432
+ String(expected == null ? "" : expected)
3433
+ .trim()
3434
+ .toLowerCase()
3435
+ );
3436
+ });
3437
+ }
3438
+
3439
+ async _getIssueTransitions(issueKey) {
3440
+ const data = await this._jira(
3441
+ `/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`,
3442
+ );
3443
+ return Array.isArray(data?.transitions) ? data.transitions : [];
3444
+ }
3445
+
3446
+ async _transitionIssue(issueKey, normalizedStatus) {
3447
+ const transitions = await this._getIssueTransitions(issueKey);
3448
+ const targetStatusName = String(this._statusMap[normalizedStatus] || "")
3449
+ .trim()
3450
+ .toLowerCase();
3451
+ const candidates = new Set(this._statusCandidates(normalizedStatus));
3452
+ const match = transitions.find((transition) => {
3453
+ const toName = String(transition?.to?.name || "")
3454
+ .trim()
3455
+ .toLowerCase();
3456
+ const toCategory = String(transition?.to?.statusCategory?.key || "")
3457
+ .trim()
3458
+ .toLowerCase();
3459
+ if (targetStatusName && toName === targetStatusName) return true;
3460
+ if (normalizedStatus === "done" && toCategory === "done") return true;
3461
+ return candidates.has(toName);
3462
+ });
3463
+ if (!match?.id) return false;
3464
+ await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`, {
3465
+ method: "POST",
3466
+ body: { transition: { id: String(match.id) } },
3467
+ });
3468
+ return true;
3469
+ }
3470
+
3471
+ async _fetchIssue(issueKey, fields = []) {
3472
+ const fieldList =
3473
+ fields.length > 0
3474
+ ? fields.join(",")
3475
+ : "summary,description,status,assignee,priority,project,labels,comment,created,updated";
3476
+ return this._jira(
3477
+ `/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`,
3478
+ );
3479
+ }
3480
+
3481
+ _jiraSearchParams(jql, maxResults, fields) {
3482
+ const params = new URLSearchParams();
3483
+ params.set("jql", jql);
3484
+ params.set("maxResults", String(Math.min(Number(maxResults) || 0, 1000)));
3485
+ if (fields) params.set("fields", fields);
3486
+ return params.toString();
3487
+ }
3488
+
3489
+ async _searchIssues(jql, maxResults, fields) {
3490
+ const query = this._jiraSearchParams(jql, maxResults, fields);
3491
+ try {
3492
+ return await this._jira(`/rest/api/3/search/jql?${query}`);
3493
+ } catch (err) {
3494
+ const status = err?.status;
3495
+ const message = String(err?.message || "");
3496
+ const shouldFallback =
3497
+ status === 404 ||
3498
+ status === 410 ||
3499
+ message.includes("/search/jql") ||
3500
+ message.toLowerCase().includes("api has been removed");
3501
+ if (!shouldFallback) {
3502
+ throw err;
3503
+ }
3504
+ return this._jira(`/rest/api/3/search?${query}`);
3505
+ }
3506
+ }
3507
+
3508
+ async _listIssueComments(issueKey) {
3509
+ const comments = [];
3510
+ let startAt = 0;
3511
+ const maxResults = 100;
3512
+ while (true) {
3513
+ const page = await this._jira(
3514
+ `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`,
3515
+ );
3516
+ const values = Array.isArray(page?.comments) ? page.comments : [];
3517
+ comments.push(...values);
3518
+ if (comments.length >= Number(page?.total || values.length)) break;
3519
+ if (values.length < maxResults) break;
3520
+ startAt += values.length;
3521
+ }
3522
+ return comments;
3523
+ }
3524
+
3525
+ _extractSharedStateFromText(text) {
3526
+ const match = String(text || "").match(
3527
+ /<!-- openfleet-state\s*\n([\s\S]*?)\n-->/,
3528
+ );
3529
+ if (!match) return null;
3530
+ try {
3531
+ const parsed = normalizeSharedStatePayload(
3532
+ JSON.parse(String(match[1] || "").trim()),
3533
+ );
3534
+ if (
3535
+ !parsed?.ownerId ||
3536
+ !parsed?.attemptToken ||
3537
+ !parsed?.attemptStarted ||
3538
+ !(parsed?.heartbeat || parsed?.ownerHeartbeat) ||
3539
+ !parsed?.status
3540
+ ) {
3541
+ return null;
3542
+ }
3543
+ if (!["claimed", "working", "stale"].includes(parsed.status)) {
3544
+ return null;
3545
+ }
3546
+ return parsed;
3547
+ } catch {
3548
+ return null;
3549
+ }
3550
+ }
3551
+
3552
+ async _setIssueLabels(issueKey, labelsToAdd = [], labelsToRemove = []) {
3553
+ const operations = [];
3554
+ for (const label of normalizeLabels(labelsToRemove).map((v) =>
3555
+ this._sanitizeJiraLabel(v),
3556
+ )) {
3557
+ operations.push({ remove: label });
3558
+ }
3559
+ for (const label of normalizeLabels(labelsToAdd).map((v) =>
3560
+ this._sanitizeJiraLabel(v),
3561
+ )) {
3562
+ operations.push({ add: label });
3563
+ }
3564
+ if (operations.length === 0) return true;
3565
+ await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
3566
+ method: "PUT",
3567
+ body: {
3568
+ update: {
3569
+ labels: operations,
3570
+ },
3571
+ },
3572
+ });
3573
+ return true;
3574
+ }
3575
+
3576
+ _buildSharedStateComment(sharedState) {
3577
+ const normalized = normalizeSharedStatePayload(sharedState) || sharedState;
3578
+ const ownerParts = String(normalized?.ownerId || "").split("/");
3579
+ const workstationId = ownerParts[0] || "unknown-workstation";
3580
+ const agentId = ownerParts[1] || "unknown-agent";
3581
+ const json = JSON.stringify(normalized, null, 2);
3582
+ return (
3583
+ `<!-- openfleet-state\n${json}\n-->\n` +
3584
+ `Codex Monitor Status: Agent ${agentId} on ${workstationId} is ${normalized?.status} this task.\n` +
3585
+ `Last heartbeat: ${normalized?.heartbeat || normalized?.ownerHeartbeat || ""}`
3586
+ );
3587
+ }
3588
+
3589
+ async _createOrUpdateSharedStateComment(issueKey, sharedState) {
3590
+ const commentBody = this._buildSharedStateComment(sharedState);
3591
+ const comments = await this._listIssueComments(issueKey);
3592
+ const existing = [...comments].reverse().find((comment) => {
3593
+ const text = this._commentToText(comment?.body);
3594
+ return text.includes("<!-- openfleet-state");
3595
+ });
3596
+ if (existing?.id) {
3597
+ await this._jira(
3598
+ `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment/${encodeURIComponent(String(existing.id))}`,
3599
+ {
3600
+ method: "PUT",
3601
+ body: this._useAdfComments
3602
+ ? { body: this._textToAdf(commentBody) }
3603
+ : { body: commentBody },
3604
+ },
3605
+ );
3606
+ return true;
3607
+ }
3608
+ return this.addComment(issueKey, commentBody);
3609
+ }
3610
+
3611
+ async listProjects() {
3612
+ const data = await this._jira(
3613
+ "/rest/api/3/project/search?maxResults=1000&orderBy=name",
3614
+ );
3615
+ return (Array.isArray(data?.values) ? data.values : []).map((project) => ({
3616
+ id: String(project.key || project.id || ""),
3617
+ name: project.name || project.key || "Unnamed Jira Project",
3618
+ backend: "jira",
3619
+ meta: project,
3620
+ }));
3621
+ }
3622
+
3623
+ async listTasks(projectId, filters = {}) {
3624
+ const projectKey = this._normalizeProjectKey(projectId);
3625
+ const clauses = [];
3626
+ if (projectKey) clauses.push(`project = "${projectKey}"`);
3627
+ else if (this._defaultProjectKey) clauses.push(`project = "${this._defaultProjectKey}"`);
3628
+
3629
+ if (filters.status) {
3630
+ const normalized = normaliseStatus(filters.status);
3631
+ if (normalized === "draft") {
3632
+ clauses.push(`labels in ("draft")`);
3633
+ } else {
3634
+ const statusNames = this._statusCandidates(normalized)
3635
+ .map((name) => `"${name.replace(/"/g, '\\"')}"`)
3636
+ .join(", ");
3637
+ if (statusNames) clauses.push(`status in (${statusNames})`);
3638
+ }
3639
+ }
3640
+
3641
+ if (this._enforceTaskLabel && this._taskScopeLabels.length > 0) {
3642
+ const labelsExpr = this._taskScopeLabels
3643
+ .map((label) => `"${label.replace(/"/g, '\\"')}"`)
3644
+ .join(", ");
3645
+ clauses.push(`labels in (${labelsExpr})`);
3646
+ }
3647
+
3648
+ if (filters.assignee) {
3649
+ clauses.push(`assignee = "${String(filters.assignee).replace(/"/g, '\\"')}"`);
3650
+ }
3651
+
3652
+ const customJql = String(filters.jql || "").trim();
3653
+ if (customJql) clauses.push(`(${customJql})`);
3654
+ const jqlBase = clauses.length > 0 ? clauses.join(" AND ") : "updated IS NOT EMPTY";
3655
+ const jql = `${jqlBase} ORDER BY updated DESC`;
3656
+
3657
+ const maxResults =
3658
+ Number(filters.limit || 0) > 0
3659
+ ? Number(filters.limit)
3660
+ : this._taskListLimit;
3661
+ const data = await this._searchIssues(
3662
+ jql,
3663
+ maxResults,
3664
+ "summary,description,status,assignee,priority,project,labels,comment,created,updated",
3665
+ );
3666
+ let tasks = (Array.isArray(data?.issues) ? data.issues : []).map((issue) =>
3667
+ this._normaliseIssue(issue),
3668
+ );
3669
+
3670
+ if (this._enforceTaskLabel) {
3671
+ tasks = tasks.filter((task) => this._isTaskScopedForCodex(task));
3672
+ }
3673
+ if (filters?.projectField && typeof filters.projectField === "object") {
3674
+ tasks = tasks.filter((task) =>
3675
+ this._matchesProjectFieldFilters(task, filters.projectField),
3676
+ );
3677
+ }
3678
+
3679
+ for (const task of tasks) {
3680
+ try {
3681
+ const sharedState = normalizeSharedStatePayload(
3682
+ await this.readSharedStateFromIssue(task.id),
3683
+ );
3684
+ if (sharedState) {
3685
+ task.meta.sharedState = sharedState;
3686
+ task.sharedState = sharedState;
3687
+ }
3688
+ } catch (err) {
3689
+ console.warn(
3690
+ `${TAG} failed to read shared state for ${task.id}: ${err.message}`,
3691
+ );
3692
+ }
3693
+ }
3694
+ return tasks;
3695
+ }
3696
+
3697
+ async getTask(taskId) {
3698
+ const issueKey = this._validateIssueKey(taskId);
3699
+ const issue = await this._fetchIssue(issueKey);
3700
+ const task = this._normaliseIssue(issue);
3701
+ try {
3702
+ const sharedState = normalizeSharedStatePayload(
3703
+ await this.readSharedStateFromIssue(issueKey),
3704
+ );
3705
+ if (sharedState) {
3706
+ task.meta.sharedState = sharedState;
3707
+ task.sharedState = sharedState;
3708
+ }
3709
+ } catch (err) {
3710
+ console.warn(
3711
+ `${TAG} failed to read shared state for ${issueKey}: ${err.message}`,
3712
+ );
3713
+ }
3714
+ return task;
3715
+ }
3716
+
3717
+ async updateTaskStatus(taskId, status, options = {}) {
3718
+ const issueKey = this._validateIssueKey(taskId);
3719
+ const normalized = normaliseStatus(status);
3720
+ if (normalized === "draft") {
3721
+ await this.updateTask(issueKey, { draft: true });
3722
+ if (options.sharedState) {
3723
+ await this.persistSharedStateToIssue(issueKey, options.sharedState);
3724
+ }
3725
+ return this.getTask(issueKey);
3726
+ }
3727
+ const current = await this.getTask(issueKey);
3728
+ if (current.status !== normalized) {
3729
+ const transitioned = await this._transitionIssue(issueKey, normalized);
3730
+ if (!transitioned) {
3731
+ throw new Error(
3732
+ `Jira: no transition available from "${current.status}" to "${normalized}" for ${issueKey}`,
3733
+ );
3734
+ }
3735
+ }
3736
+ if (options.sharedState) {
3737
+ await this.persistSharedStateToIssue(issueKey, options.sharedState);
3738
+ }
3739
+ if (current.status === "draft") {
3740
+ await this.updateTask(issueKey, { draft: false });
3741
+ }
3742
+ if (
3743
+ options.projectFields &&
3744
+ typeof options.projectFields === "object" &&
3745
+ Object.keys(options.projectFields).length > 0
3746
+ ) {
3747
+ await this.updateTask(issueKey, { projectFields: options.projectFields });
3748
+ }
3749
+ return this.getTask(issueKey);
3750
+ }
3751
+
3752
+ async updateTask(taskId, patch = {}) {
3753
+ const issueKey = this._validateIssueKey(taskId);
3754
+ const fields = {};
3755
+ const baseBranch = resolveBaseBranchInput(patch);
3756
+ if (typeof patch.title === "string") {
3757
+ fields.summary = patch.title;
3758
+ }
3759
+ if (typeof patch.description === "string") {
3760
+ fields.description = this._textToAdf(patch.description);
3761
+ }
3762
+ if (typeof patch.priority === "string" && patch.priority.trim()) {
3763
+ fields.priority = { name: patch.priority.trim() };
3764
+ }
3765
+ const wantsTags =
3766
+ Array.isArray(patch.tags) ||
3767
+ Array.isArray(patch.labels) ||
3768
+ typeof patch.tags === "string";
3769
+ let fetchedIssue = null;
3770
+ if (wantsTags || typeof patch.draft === "boolean" || baseBranch) {
3771
+ fetchedIssue = await this._fetchIssue(issueKey);
3772
+ }
3773
+ if (wantsTags || typeof patch.draft === "boolean") {
3774
+ const currentLabels = normalizeLabels(fetchedIssue?.fields?.labels || []);
3775
+ const systemLabels = new Set([
3776
+ ...SYSTEM_LABEL_KEYS,
3777
+ ...normalizeLabels(this._taskScopeLabels || []),
3778
+ ]);
3779
+ const desiredTags = wantsTags
3780
+ ? normalizeTags(patch.tags ?? patch.labels)
3781
+ : currentLabels.filter(
3782
+ (label) => !systemLabels.has(label) && !isUpstreamLabel(label),
3783
+ );
3784
+ const nextLabels = new Set(
3785
+ currentLabels.filter(
3786
+ (label) => systemLabels.has(label) || isUpstreamLabel(label),
3787
+ ),
3788
+ );
3789
+ for (const label of desiredTags) nextLabels.add(label);
3790
+ if (typeof patch.draft === "boolean") {
3791
+ if (patch.draft) nextLabels.add("draft");
3792
+ else nextLabels.delete("draft");
3793
+ }
3794
+ fields.labels = [...nextLabels].map((label) => this._sanitizeJiraLabel(label));
3795
+ }
3796
+ if (baseBranch && !patch.description) {
3797
+ const currentDesc = this._commentToText(fetchedIssue?.fields?.description);
3798
+ const nextDesc = upsertBaseBranchMarker(currentDesc, baseBranch);
3799
+ fields.description = this._textToAdf(nextDesc);
3800
+ }
3801
+ if (baseBranch && this._customFieldBaseBranch) {
3802
+ fields[this._customFieldBaseBranch] = baseBranch;
3803
+ }
3804
+ if (patch.assignee) {
3805
+ fields.assignee = { accountId: String(patch.assignee) };
3806
+ }
3807
+ if (patch.projectFields && typeof patch.projectFields === "object") {
3808
+ const mappedProjectFields = await this._mapProjectFieldsInput(
3809
+ patch.projectFields,
3810
+ );
3811
+ Object.assign(fields, mappedProjectFields);
3812
+ }
3813
+ if (Object.keys(fields).length > 0) {
3814
+ await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
3815
+ method: "PUT",
3816
+ body: { fields },
3817
+ });
3818
+ }
3819
+ if (typeof patch.status === "string" && patch.status.trim()) {
3820
+ return this.updateTaskStatus(issueKey, patch.status.trim());
3821
+ }
3822
+ return this.getTask(issueKey);
3823
+ }
3824
+
3825
+ async createTask(projectId, taskData = {}) {
3826
+ const projectKey = this._normalizeProjectKey(projectId);
3827
+ if (!projectKey) {
3828
+ throw new Error(
3829
+ "Jira: createTask requires a project key (argument or JIRA_PROJECT_KEY)",
3830
+ );
3831
+ }
3832
+ const requestedStatus = normaliseStatus(taskData.status || "todo");
3833
+ const baseBranch = resolveBaseBranchInput(taskData);
3834
+ const issueTypeName =
3835
+ taskData.issueType ||
3836
+ taskData.issue_type ||
3837
+ this._defaultIssueType ||
3838
+ "Task";
3839
+ const isSubtask = /sub[-\\s]?task/.test(
3840
+ String(issueTypeName || "").toLowerCase(),
3841
+ );
3842
+ const parentKey = this._normalizeIssueKey(
3843
+ taskData.parentId ||
3844
+ taskData.parentKey ||
3845
+ this._subtaskParentKey ||
3846
+ "",
3847
+ );
3848
+ if (isSubtask && !parentKey) {
3849
+ throw new Error(
3850
+ "Jira: sub-task issue type requires a parent issue key (set JIRA_SUBTASK_PARENT_KEY or pass parentId)",
3851
+ );
3852
+ }
3853
+ const labels = normalizeLabels([
3854
+ ...(Array.isArray(this._taskScopeLabels) ? this._taskScopeLabels : []),
3855
+ ...normalizeLabels(taskData.labels || []),
3856
+ ...normalizeLabels(taskData.tags || []),
3857
+ ]).map((label) => this._sanitizeJiraLabel(label));
3858
+ if (!labels.includes(this._canonicalTaskLabel)) {
3859
+ labels.push(this._sanitizeJiraLabel(this._canonicalTaskLabel));
3860
+ }
3861
+ if (requestedStatus === "draft" && !labels.includes("draft")) {
3862
+ labels.push("draft");
3863
+ }
3864
+ const descriptionText = upsertBaseBranchMarker(
3865
+ taskData.description || "",
3866
+ baseBranch,
3867
+ );
3868
+ const fields = {
3869
+ project: { key: projectKey },
3870
+ summary: taskData.title || "New task",
3871
+ description: this._textToAdf(descriptionText),
3872
+ issuetype: {
3873
+ name: issueTypeName,
3874
+ },
3875
+ labels,
3876
+ };
3877
+ if (baseBranch && this._customFieldBaseBranch) {
3878
+ fields[this._customFieldBaseBranch] = baseBranch;
3879
+ }
3880
+ if (isSubtask && parentKey) {
3881
+ fields.parent = { key: parentKey };
3882
+ }
3883
+ if (taskData.priority) {
3884
+ fields.priority = { name: String(taskData.priority) };
3885
+ }
3886
+ const assigneeId = taskData.assignee || this._defaultAssignee;
3887
+ if (assigneeId) {
3888
+ fields.assignee = { accountId: String(assigneeId) };
3889
+ }
3890
+ const created = await this._jira("/rest/api/3/issue", {
3891
+ method: "POST",
3892
+ body: { fields },
3893
+ });
3894
+ const issueKey = this._validateIssueKey(created?.key || "");
3895
+ if (requestedStatus !== "todo" && requestedStatus !== "draft") {
3896
+ return this.updateTaskStatus(issueKey, requestedStatus, {
3897
+ sharedState: taskData.sharedState,
3898
+ });
3899
+ }
3900
+ if (taskData.sharedState) {
3901
+ await this.persistSharedStateToIssue(issueKey, taskData.sharedState);
3902
+ }
3903
+ return this.getTask(issueKey);
3904
+ }
3905
+
3906
+ async deleteTask(taskId) {
3907
+ const issueKey = this._validateIssueKey(taskId);
3908
+ const issue = await this.getTask(issueKey);
3909
+ if (issue.status === "done" || issue.status === "cancelled") {
3910
+ return true;
3911
+ }
3912
+ const target = String(process.env.JIRA_DELETE_TRANSITION_STATUS || "done").trim();
3913
+ await this.updateTaskStatus(issueKey, target);
3914
+ return true;
3915
+ }
3916
+
3917
+ async addComment(taskId, body) {
3918
+ const issueKey = this._validateIssueKey(taskId);
3919
+ const text = String(body || "").trim();
3920
+ if (!text) return false;
3921
+ try {
3922
+ await this._jira(`/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
3923
+ method: "POST",
3924
+ body: this._useAdfComments
3925
+ ? { body: this._textToAdf(text) }
3926
+ : { body: text },
3927
+ });
3928
+ return true;
3929
+ } catch (err) {
3930
+ if (this._useAdfComments) {
3931
+ // Fallback for Jira instances that accept only plain text payloads
3932
+ try {
3933
+ await this._jira(
3934
+ `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`,
3935
+ {
3936
+ method: "POST",
3937
+ body: { body: text },
3938
+ },
3939
+ );
3940
+ return true;
3941
+ } catch (fallbackErr) {
3942
+ console.warn(
3943
+ `${TAG} failed to add Jira comment on ${issueKey}: ${fallbackErr.message}`,
3944
+ );
3945
+ return false;
3946
+ }
3947
+ }
3948
+ console.warn(`${TAG} failed to add Jira comment on ${issueKey}: ${err.message}`);
3949
+ return false;
3950
+ }
3951
+ }
3952
+
3953
+ /**
3954
+ * Persist shared state to a Jira issue.
3955
+ *
3956
+ * Implements the same shared state protocol as GitHubAdapter but using Jira-specific
3957
+ * mechanisms. The implementation should use a combination of:
3958
+ *
3959
+ * 1. **Jira Custom Fields** (preferred if available):
3960
+ * - Create custom fields for openfleet state (e.g., "Codex Owner ID", "Codex Attempt Token")
3961
+ * - Store structured data as JSON in a text custom field
3962
+ * - Use Jira API v3: `PUT /rest/api/3/issue/{issueKey}`
3963
+ * - Custom field IDs are like `customfield_10042`
3964
+ *
3965
+ * 2. **Jira Labels** (for status flags):
3966
+ * - Use labels: `codex:claimed`, `codex:working`, `codex:stale`, `codex:ignore`
3967
+ * - Labels API: `PUT /rest/api/3/issue/{issueKey}` with `update.labels` field
3968
+ * - Remove conflicting codex labels before adding new ones
3969
+ *
3970
+ * 3. **Structured Comments** (fallback if custom fields unavailable):
3971
+ * - Similar to GitHub: embed JSON in HTML comment markers
3972
+ * - Format: `<!-- openfleet-state\n{json}\n-->`
3973
+ * - Comments API: `POST /rest/api/3/issue/{issueKey}/comment`
3974
+ * - Update via `PUT /rest/api/3/issue/{issueKey}/comment/{commentId}`
3975
+ *
3976
+ * **Jira API v3 Authentication**:
3977
+ * - Use Basic Auth with email + API token: `Authorization: Basic base64(email:token)`
3978
+ * - Token from: https://id.atlassian.com/manage-profile/security/api-tokens
3979
+ * - Base URL: `https://{domain}.atlassian.net`
3980
+ *
3981
+ * **Required Permissions**:
3982
+ * - Browse Projects
3983
+ * - Edit Issues
3984
+ * - Add Comments
3985
+ * - Manage Custom Fields (if using custom fields approach)
3986
+ *
3987
+ * @param {string} issueKey - Jira issue key (e.g., "PROJ-123")
3988
+ * @param {SharedState} sharedState - Agent state to persist
3989
+ * @param {string} sharedState.ownerId - Format: "workstation-id/agent-id"
3990
+ * @param {string} sharedState.attemptToken - Unique UUID for this attempt
3991
+ * @param {string} sharedState.attemptStarted - ISO 8601 timestamp
3992
+ * @param {string} sharedState.heartbeat - ISO 8601 timestamp
3993
+ * @param {string} sharedState.status - One of: "claimed", "working", "stale"
3994
+ * @param {number} sharedState.retryCount - Number of retry attempts
3995
+ * @returns {Promise<boolean>} Success status
3996
+ *
3997
+ * @example
3998
+ * await adapter.persistSharedStateToIssue("PROJ-123", {
3999
+ * ownerId: "workstation-123/agent-456",
4000
+ * attemptToken: "uuid-here",
4001
+ * attemptStarted: "2026-02-14T17:00:00Z",
4002
+ * heartbeat: "2026-02-14T17:30:00Z",
4003
+ * status: "working",
4004
+ * retryCount: 1
4005
+ * });
4006
+ *
4007
+ * @see {@link https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/}
4008
+ * @see GitHubIssuesAdapter.persistSharedStateToIssue for reference implementation
4009
+ */
4010
+ async persistSharedStateToIssue(issueKey, sharedState) {
4011
+ const key = this._validateIssueKey(issueKey);
4012
+ const normalizedState = normalizeSharedStatePayload(sharedState);
4013
+ if (
4014
+ !normalizedState?.ownerId ||
4015
+ !normalizedState?.attemptToken ||
4016
+ !normalizedState?.attemptStarted ||
4017
+ !(normalizedState?.heartbeat || normalizedState?.ownerHeartbeat) ||
4018
+ !["claimed", "working", "stale"].includes(normalizedState?.status)
4019
+ ) {
4020
+ throw new Error(
4021
+ `Jira: invalid shared state payload for ${key} (missing required fields)`,
4022
+ );
4023
+ }
4024
+
4025
+ const allCodexLabels = [
4026
+ this._codexLabels.claimed,
4027
+ this._codexLabels.working,
4028
+ this._codexLabels.stale,
4029
+ "codex:claimed",
4030
+ "codex:working",
4031
+ "codex:stale",
4032
+ ];
4033
+ const targetLabel =
4034
+ normalizedState.status === "claimed"
4035
+ ? this._codexLabels.claimed
4036
+ : normalizedState.status === "working"
4037
+ ? this._codexLabels.working
4038
+ : this._codexLabels.stale;
4039
+ const labelsToRemove = allCodexLabels.filter((label) => label !== targetLabel);
4040
+ try {
4041
+ await this._setIssueLabels(key, [targetLabel], labelsToRemove);
4042
+ const stateFieldPayload = {};
4043
+ if (this._sharedStateFields.ownerId) {
4044
+ stateFieldPayload[this._sharedStateFields.ownerId] =
4045
+ normalizedState.ownerId;
4046
+ }
4047
+ if (this._sharedStateFields.attemptToken) {
4048
+ stateFieldPayload[this._sharedStateFields.attemptToken] =
4049
+ normalizedState.attemptToken;
4050
+ }
4051
+ if (this._sharedStateFields.attemptStarted) {
4052
+ const iso = this._normalizeIsoTimestamp(normalizedState.attemptStarted);
4053
+ if (iso) stateFieldPayload[this._sharedStateFields.attemptStarted] = iso;
4054
+ }
4055
+ if (this._sharedStateFields.heartbeat) {
4056
+ const iso = this._normalizeIsoTimestamp(
4057
+ normalizedState.heartbeat || normalizedState.ownerHeartbeat,
4058
+ );
4059
+ if (iso) stateFieldPayload[this._sharedStateFields.heartbeat] = iso;
4060
+ }
4061
+ if (this._sharedStateFields.retryCount) {
4062
+ const retryCount = Number(normalizedState.retryCount || 0);
4063
+ if (Number.isFinite(retryCount)) {
4064
+ stateFieldPayload[this._sharedStateFields.retryCount] = retryCount;
4065
+ }
4066
+ }
4067
+ if (this._sharedStateFields.stateJson) {
4068
+ stateFieldPayload[this._sharedStateFields.stateJson] = JSON.stringify(
4069
+ normalizedState,
4070
+ );
4071
+ }
4072
+ if (Object.keys(stateFieldPayload).length > 0) {
4073
+ await this._jira(`/rest/api/3/issue/${encodeURIComponent(key)}`, {
4074
+ method: "PUT",
4075
+ body: { fields: stateFieldPayload },
4076
+ });
4077
+ }
4078
+ return this._createOrUpdateSharedStateComment(key, normalizedState);
4079
+ } catch (err) {
4080
+ console.warn(
4081
+ `${TAG} failed to persist shared state for ${key}: ${err.message}`,
4082
+ );
4083
+ return false;
4084
+ }
4085
+ }
4086
+
4087
+ /**
4088
+ * Read shared state from a Jira issue.
4089
+ *
4090
+ * Retrieves agent state previously written by persistSharedStateToIssue().
4091
+ * Implementation should check multiple sources in order of preference:
4092
+ *
4093
+ * 1. **Jira Custom Fields** (if configured):
4094
+ * - Read custom field values via `GET /rest/api/3/issue/{issueKey}`
4095
+ * - Parse JSON from custom field (e.g., `fields.customfield_10042`)
4096
+ * - Validate required fields before returning
4097
+ *
4098
+ * 2. **Structured Comments** (fallback):
4099
+ * - Fetch comments via `GET /rest/api/3/issue/{issueKey}/comment`
4100
+ * - Search for latest comment containing `<!-- openfleet-state`
4101
+ * - Extract and parse JSON from HTML comment markers
4102
+ * - Return most recent valid state
4103
+ *
4104
+ * **Validation Requirements**:
4105
+ * - Must have: ownerId, attemptToken, attemptStarted, heartbeat, status
4106
+ * - Status must be one of: "claimed", "working", "stale"
4107
+ * - Timestamps must be valid ISO 8601 format
4108
+ * - Return null if state is missing, invalid, or corrupted
4109
+ *
4110
+ * **Jira API v3 Endpoints**:
4111
+ * - Issue details: `GET /rest/api/3/issue/{issueKey}?fields=customfield_*,comment`
4112
+ * - Comments only: `GET /rest/api/3/issue/{issueKey}/comment`
4113
+ *
4114
+ * @param {string} issueKey - Jira issue key (e.g., "PROJ-123")
4115
+ * @returns {Promise<SharedState|null>} Parsed shared state or null if not found
4116
+ *
4117
+ * @typedef {Object} SharedState
4118
+ * @property {string} ownerId - Workstation/agent identifier
4119
+ * @property {string} attemptToken - Unique UUID for this attempt
4120
+ * @property {string} attemptStarted - ISO 8601 timestamp
4121
+ * @property {string} heartbeat - ISO 8601 timestamp
4122
+ * @property {string} status - One of: "claimed", "working", "stale"
4123
+ * @property {number} retryCount - Number of retry attempts
4124
+ *
4125
+ * @example
4126
+ * const state = await adapter.readSharedStateFromIssue("PROJ-123");
4127
+ * if (state) {
4128
+ * console.log(`Task claimed by ${state.ownerId}`);
4129
+ * console.log(`Status: ${state.status}, Heartbeat: ${state.heartbeat}`);
4130
+ * } else {
4131
+ * console.log("No shared state found - task is unclaimed");
4132
+ * }
4133
+ *
4134
+ * @see {@link https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/}
4135
+ * @see GitHubIssuesAdapter.readSharedStateFromIssue for reference implementation
4136
+ */
4137
+ async readSharedStateFromIssue(issueKey) {
4138
+ const key = this._validateIssueKey(issueKey);
4139
+ try {
4140
+ const fieldIds = [
4141
+ this._sharedStateFields.stateJson,
4142
+ this._sharedStateFields.ownerId,
4143
+ this._sharedStateFields.attemptToken,
4144
+ this._sharedStateFields.attemptStarted,
4145
+ this._sharedStateFields.heartbeat,
4146
+ this._sharedStateFields.retryCount,
4147
+ ].filter(Boolean);
4148
+ if (fieldIds.length > 0) {
4149
+ const issue = await this._fetchIssue(key, fieldIds);
4150
+ const rawFields = issue?.fields || {};
4151
+ if (this._sharedStateFields.stateJson) {
4152
+ const raw = rawFields[this._sharedStateFields.stateJson];
4153
+ if (typeof raw === "string" && raw.trim()) {
4154
+ try {
4155
+ const parsed = normalizeSharedStatePayload(JSON.parse(raw));
4156
+ if (
4157
+ parsed?.ownerId &&
4158
+ parsed?.attemptToken &&
4159
+ parsed?.attemptStarted &&
4160
+ (parsed?.heartbeat || parsed?.ownerHeartbeat) &&
4161
+ ["claimed", "working", "stale"].includes(parsed?.status)
4162
+ ) {
4163
+ return parsed;
4164
+ }
4165
+ } catch {
4166
+ // fall through to field-by-field and comment parsing
4167
+ }
4168
+ }
4169
+ }
4170
+ const fromFields = {
4171
+ ownerId: rawFields[this._sharedStateFields.ownerId],
4172
+ attemptToken: rawFields[this._sharedStateFields.attemptToken],
4173
+ attemptStarted: rawFields[this._sharedStateFields.attemptStarted],
4174
+ heartbeat: rawFields[this._sharedStateFields.heartbeat],
4175
+ status: null,
4176
+ retryCount: Number(rawFields[this._sharedStateFields.retryCount] || 0),
4177
+ };
4178
+ if (fromFields.ownerId) {
4179
+ const labels = normalizeLabels(rawFields.labels || []);
4180
+ if (labels.includes(this._codexLabels.working)) {
4181
+ fromFields.status = "working";
4182
+ } else if (labels.includes(this._codexLabels.claimed)) {
4183
+ fromFields.status = "claimed";
4184
+ } else if (labels.includes(this._codexLabels.stale)) {
4185
+ fromFields.status = "stale";
4186
+ }
4187
+ }
4188
+ if (
4189
+ fromFields.ownerId &&
4190
+ fromFields.attemptToken &&
4191
+ fromFields.attemptStarted &&
4192
+ fromFields.heartbeat &&
4193
+ fromFields.status
4194
+ ) {
4195
+ return normalizeSharedStatePayload(fromFields);
4196
+ }
4197
+ }
4198
+ const comments = await this._listIssueComments(key);
4199
+ const stateComment = [...comments].reverse().find((comment) => {
4200
+ const text = this._commentToText(comment?.body);
4201
+ return text.includes("<!-- openfleet-state");
4202
+ });
4203
+ if (!stateComment) return null;
4204
+ const parsed = normalizeSharedStatePayload(
4205
+ this._extractSharedStateFromText(
4206
+ this._commentToText(stateComment.body),
4207
+ ),
4208
+ );
4209
+ return parsed || null;
4210
+ } catch (err) {
4211
+ console.warn(`${TAG} failed to read shared state for ${key}: ${err.message}`);
4212
+ return null;
4213
+ }
4214
+ }
4215
+
4216
+ /**
4217
+ * Mark a Jira issue as ignored by openfleet.
4218
+ *
4219
+ * Prevents openfleet from repeatedly attempting to claim or work on tasks
4220
+ * that are not suitable for automation. Uses Jira-specific mechanisms:
4221
+ *
4222
+ * 1. **Add Label**: `codex:ignore`
4223
+ * - Labels API: `PUT /rest/api/3/issue/{issueKey}`
4224
+ * - Request body: `{"update": {"labels": [{"add": "codex:ignore"}]}}`
4225
+ * - Labels are case-sensitive in Jira
4226
+ *
4227
+ * 2. **Add Comment**: Human-readable explanation
4228
+ * - Comments API: `POST /rest/api/3/issue/{issueKey}/comment`
4229
+ * - Request body: `{"body": {"type": "doc", "version": 1, "content": [...]}}`
4230
+ * - Jira uses Atlassian Document Format (ADF) for rich text
4231
+ * - For simple text: `{"body": "text content"}` (legacy format)
4232
+ *
4233
+ * 3. **Optional: Transition Issue** (if workflow supports it):
4234
+ * - Get transitions: `GET /rest/api/3/issue/{issueKey}/transitions`
4235
+ * - Transition to "Won't Do" or similar: `POST /rest/api/3/issue/{issueKey}/transitions`
4236
+ * - Not required if labels are sufficient
4237
+ *
4238
+ * **Jira ADF Comment Example**:
4239
+ * ```json
4240
+ * {
4241
+ * "body": {
4242
+ * "type": "doc",
4243
+ * "version": 1,
4244
+ * "content": [
4245
+ * {
4246
+ * "type": "paragraph",
4247
+ * "content": [
4248
+ * {"type": "text", "text": "Codex Monitor: Task marked as ignored."}
4249
+ * ]
4250
+ * }
4251
+ * ]
4252
+ * }
4253
+ * }
4254
+ * ```
4255
+ *
4256
+ * **Required Permissions**:
4257
+ * - Edit Issues (for labels)
4258
+ * - Add Comments
4259
+ * - Transition Issues (optional, if changing status)
4260
+ *
4261
+ * @param {string} issueKey - Jira issue key (e.g., "PROJ-123")
4262
+ * @param {string} reason - Human-readable reason for ignoring
4263
+ * @returns {Promise<boolean>} Success status
4264
+ *
4265
+ * @example
4266
+ * await adapter.markTaskIgnored("PROJ-123", "Task requires manual security review");
4267
+ * // Adds "codex:ignore" label and comment explaining why
4268
+ *
4269
+ * @example
4270
+ * await adapter.markTaskIgnored("PROJ-456", "Task dependencies not in automation scope");
4271
+ * // Prevents openfleet from claiming this task in future iterations
4272
+ *
4273
+ * @see {@link https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/}
4274
+ * @see {@link https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/}
4275
+ * @see GitHubIssuesAdapter.markTaskIgnored for reference implementation
4276
+ */
4277
+ async markTaskIgnored(issueKey, reason) {
4278
+ const key = this._validateIssueKey(issueKey);
4279
+ const ignoreReason = String(reason || "").trim() || "No reason provided";
4280
+ try {
4281
+ await this._setIssueLabels(
4282
+ key,
4283
+ [this._codexLabels.ignore],
4284
+ ["codex:ignore"],
4285
+ );
4286
+ if (this._sharedStateFields.ignoreReason) {
4287
+ await this._jira(`/rest/api/3/issue/${encodeURIComponent(key)}`, {
4288
+ method: "PUT",
4289
+ body: {
4290
+ fields: {
4291
+ [this._sharedStateFields.ignoreReason]: ignoreReason,
4292
+ },
4293
+ },
4294
+ });
4295
+ }
4296
+ const commentBody =
4297
+ `Codex Monitor: This task has been marked as ignored.\n\n` +
4298
+ `Reason: ${ignoreReason}\n\n` +
4299
+ `To re-enable openfleet for this task, remove the ${this._codexLabels.ignore} label.`;
4300
+ await this.addComment(key, commentBody);
4301
+ return true;
4302
+ } catch (err) {
4303
+ console.error(`${TAG} failed to mark Jira issue ${key} as ignored: ${err.message}`);
4304
+ return false;
4305
+ }
4306
+ }
4307
+ }
4308
+
4309
+ // ---------------------------------------------------------------------------
4310
+ // Adapter Registry & Resolution
4311
+ // ---------------------------------------------------------------------------
4312
+
4313
+ const ADAPTERS = {
4314
+ internal: () => new InternalAdapter(),
4315
+ vk: () => new VKAdapter(),
4316
+ github: () => new GitHubIssuesAdapter(),
4317
+ jira: () => new JiraAdapter(),
4318
+ };
4319
+
4320
+ /** @type {Object|null} Cached adapter instance */
4321
+ let activeAdapter = null;
4322
+ /** @type {string|null} Cached backend name */
4323
+ let activeBackendName = null;
4324
+
4325
+ /**
4326
+ * Resolve which kanban backend to use (synchronous).
4327
+ *
4328
+ * Resolution order:
4329
+ * 1. Runtime override via setKanbanBackend()
4330
+ * 2. KANBAN_BACKEND env var
4331
+ * 3. openfleet.config.json → kanban.backend field
4332
+ * 4. Default: "internal"
4333
+ *
4334
+ * @returns {string}
4335
+ */
4336
+ function resolveBackendName() {
4337
+ if (activeBackendName) return activeBackendName;
4338
+
4339
+ // 1. Env var
4340
+ const envBackend = (process.env.KANBAN_BACKEND || "").trim().toLowerCase();
4341
+ if (envBackend && ADAPTERS[envBackend]) return envBackend;
4342
+
4343
+ // 2. Config file (loadConfig is imported statically — always sync-safe)
4344
+ try {
4345
+ const config = loadConfig();
4346
+ const configBackend = (config?.kanban?.backend || "").toLowerCase();
4347
+ if (configBackend && ADAPTERS[configBackend]) return configBackend;
4348
+ } catch {
4349
+ // Config not available — fall through to default
4350
+ }
4351
+
4352
+ // 3. Default
4353
+ return "internal";
4354
+ }
4355
+
4356
+ /**
4357
+ * Get the active kanban adapter.
4358
+ * @returns {InternalAdapter|VKAdapter|GitHubIssuesAdapter|JiraAdapter} Adapter instance.
4359
+ */
4360
+ export function getKanbanAdapter() {
4361
+ const name = resolveBackendName();
4362
+ if (activeAdapter && activeBackendName === name) return activeAdapter;
4363
+ const factory = ADAPTERS[name];
4364
+ if (!factory) throw new Error(`${TAG} unknown kanban backend: ${name}`);
4365
+ activeAdapter = factory();
4366
+ activeBackendName = name;
4367
+ console.log(`${TAG} using ${name} backend`);
4368
+ return activeAdapter;
4369
+ }
4370
+
4371
+ /**
4372
+ * Switch the kanban backend at runtime.
4373
+ * @param {string} name Backend name ("internal", "vk", "github", "jira").
4374
+ */
4375
+ export function setKanbanBackend(name) {
4376
+ const normalised = (name || "").trim().toLowerCase();
4377
+ if (!ADAPTERS[normalised]) {
4378
+ throw new Error(
4379
+ `${TAG} unknown kanban backend: "${name}". Valid: ${Object.keys(ADAPTERS).join(", ")}`,
4380
+ );
4381
+ }
4382
+ activeBackendName = normalised;
4383
+ activeAdapter = null; // Force re-create on next getKanbanAdapter()
4384
+ console.log(`${TAG} switched to ${normalised} backend`);
4385
+ }
4386
+
4387
+ /**
4388
+ * Get list of available kanban backends.
4389
+ * @returns {string[]}
4390
+ */
4391
+ export function getAvailableBackends() {
4392
+ return Object.keys(ADAPTERS);
4393
+ }
4394
+
4395
+ /**
4396
+ * Get the name of the active backend.
4397
+ * @returns {string}
4398
+ */
4399
+ export function getKanbanBackendName() {
4400
+ return resolveBackendName();
4401
+ }
4402
+
4403
+ // ---------------------------------------------------------------------------
4404
+ // Convenience exports: direct task operations via active adapter
4405
+ // ---------------------------------------------------------------------------
4406
+
4407
+ export async function listProjects() {
4408
+ return getKanbanAdapter().listProjects();
4409
+ }
4410
+
4411
+ export async function listTasks(projectId, filters) {
4412
+ return getKanbanAdapter().listTasks(projectId, filters);
4413
+ }
4414
+
4415
+ export async function getTask(taskId) {
4416
+ return getKanbanAdapter().getTask(taskId);
4417
+ }
4418
+
4419
+ export async function updateTaskStatus(taskId, status, options) {
4420
+ return getKanbanAdapter().updateTaskStatus(taskId, status, options);
4421
+ }
4422
+
4423
+ export async function updateTask(taskId, patch) {
4424
+ const adapter = getKanbanAdapter();
4425
+ if (typeof adapter.updateTask === "function") {
4426
+ return adapter.updateTask(taskId, patch);
4427
+ }
4428
+ if (patch?.status) {
4429
+ return adapter.updateTaskStatus(taskId, patch.status);
4430
+ }
4431
+ return adapter.getTask(taskId);
4432
+ }
4433
+
4434
+ export async function createTask(projectId, taskData) {
4435
+ return getKanbanAdapter().createTask(projectId, taskData);
4436
+ }
4437
+
4438
+ export async function deleteTask(taskId) {
4439
+ return getKanbanAdapter().deleteTask(taskId);
4440
+ }
4441
+
4442
+ export async function addComment(taskId, body) {
4443
+ return getKanbanAdapter().addComment(taskId, body);
4444
+ }
4445
+
4446
+ /**
4447
+ * Persist shared state to an issue (GitHub adapter only).
4448
+ * @param {string} taskId - Task identifier (issue number for GitHub)
4449
+ * @param {SharedState} sharedState - State to persist
4450
+ * @returns {Promise<boolean>} Success status
4451
+ */
4452
+ export async function persistSharedStateToIssue(taskId, sharedState) {
4453
+ const adapter = getKanbanAdapter();
4454
+ if (typeof adapter.persistSharedStateToIssue === "function") {
4455
+ return adapter.persistSharedStateToIssue(taskId, sharedState);
4456
+ }
4457
+ console.warn(
4458
+ `[kanban] persistSharedStateToIssue not supported by ${adapter.name} backend`,
4459
+ );
4460
+ return false;
4461
+ }
4462
+
4463
+ /**
4464
+ * Read shared state from an issue (GitHub adapter only).
4465
+ * @param {string} taskId - Task identifier (issue number for GitHub)
4466
+ * @returns {Promise<SharedState|null>} Shared state or null
4467
+ */
4468
+ export async function readSharedStateFromIssue(taskId) {
4469
+ const adapter = getKanbanAdapter();
4470
+ if (typeof adapter.readSharedStateFromIssue === "function") {
4471
+ return adapter.readSharedStateFromIssue(taskId);
4472
+ }
4473
+ return null;
4474
+ }
4475
+
4476
+ /**
4477
+ * Mark a task as ignored by openfleet (GitHub adapter only).
4478
+ * @param {string} taskId - Task identifier (issue number for GitHub)
4479
+ * @param {string} reason - Human-readable reason for ignoring
4480
+ * @returns {Promise<boolean>} Success status
4481
+ */
4482
+ export async function markTaskIgnored(taskId, reason) {
4483
+ const adapter = getKanbanAdapter();
4484
+ if (typeof adapter.markTaskIgnored === "function") {
4485
+ return adapter.markTaskIgnored(taskId, reason);
4486
+ }
4487
+ console.warn(
4488
+ `[kanban] markTaskIgnored not supported by ${adapter.name} backend`,
4489
+ );
4490
+ return false;
4491
+ }