bosun 0.36.0 → 0.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +98 -16
  2. package/README.md +27 -0
  3. package/agent-event-bus.mjs +5 -5
  4. package/agent-pool.mjs +129 -12
  5. package/agent-prompts.mjs +7 -1
  6. package/agent-sdk.mjs +13 -2
  7. package/agent-supervisor.mjs +2 -2
  8. package/agent-work-report.mjs +1 -1
  9. package/anomaly-detector.mjs +6 -6
  10. package/autofix.mjs +15 -15
  11. package/bosun-skills.mjs +4 -4
  12. package/bosun.schema.json +160 -4
  13. package/claude-shell.mjs +11 -11
  14. package/cli.mjs +21 -21
  15. package/codex-config.mjs +19 -19
  16. package/codex-shell.mjs +180 -29
  17. package/config-doctor.mjs +27 -2
  18. package/config.mjs +60 -7
  19. package/copilot-shell.mjs +4 -4
  20. package/error-detector.mjs +1 -1
  21. package/fleet-coordinator.mjs +2 -2
  22. package/gemini-shell.mjs +692 -0
  23. package/github-oauth-portal.mjs +1 -1
  24. package/github-reconciler.mjs +2 -2
  25. package/kanban-adapter.mjs +741 -168
  26. package/merge-strategy.mjs +25 -25
  27. package/monitor.mjs +123 -105
  28. package/opencode-shell.mjs +22 -22
  29. package/package.json +7 -1
  30. package/postinstall.mjs +22 -22
  31. package/pr-cleanup-daemon.mjs +6 -6
  32. package/prepublish-check.mjs +4 -4
  33. package/presence.mjs +2 -2
  34. package/primary-agent.mjs +85 -7
  35. package/publish.mjs +1 -1
  36. package/review-agent.mjs +1 -1
  37. package/session-tracker.mjs +11 -0
  38. package/setup-web-server.mjs +429 -21
  39. package/setup.mjs +367 -12
  40. package/shared-knowledge.mjs +1 -1
  41. package/startup-service.mjs +9 -9
  42. package/stream-resilience.mjs +58 -4
  43. package/sync-engine.mjs +2 -2
  44. package/task-assessment.mjs +9 -9
  45. package/task-cli.mjs +1 -1
  46. package/task-complexity.mjs +71 -2
  47. package/task-context.mjs +1 -2
  48. package/task-executor.mjs +104 -41
  49. package/telegram-bot.mjs +825 -494
  50. package/telegram-sentinel.mjs +28 -28
  51. package/ui/app.js +256 -23
  52. package/ui/app.monolith.js +1 -1
  53. package/ui/components/agent-selector.js +4 -3
  54. package/ui/components/chat-view.js +101 -28
  55. package/ui/components/diff-viewer.js +3 -3
  56. package/ui/components/kanban-board.js +3 -3
  57. package/ui/components/session-list.js +255 -35
  58. package/ui/components/workspace-switcher.js +3 -3
  59. package/ui/demo.html +209 -194
  60. package/ui/index.html +3 -3
  61. package/ui/modules/icon-utils.js +206 -142
  62. package/ui/modules/icons.js +2 -27
  63. package/ui/modules/settings-schema.js +29 -5
  64. package/ui/modules/streaming.js +30 -2
  65. package/ui/modules/vision-stream.js +275 -0
  66. package/ui/modules/voice-client.js +102 -9
  67. package/ui/modules/voice-fallback.js +62 -6
  68. package/ui/modules/voice-overlay.js +594 -59
  69. package/ui/modules/voice.js +31 -38
  70. package/ui/setup.html +284 -34
  71. package/ui/styles/components.css +47 -0
  72. package/ui/styles/sessions.css +75 -0
  73. package/ui/tabs/agents.js +73 -43
  74. package/ui/tabs/chat.js +37 -40
  75. package/ui/tabs/control.js +2 -2
  76. package/ui/tabs/dashboard.js +1 -1
  77. package/ui/tabs/infra.js +10 -10
  78. package/ui/tabs/library.js +8 -8
  79. package/ui/tabs/logs.js +10 -10
  80. package/ui/tabs/settings.js +20 -20
  81. package/ui/tabs/tasks.js +76 -47
  82. package/ui-server.mjs +1761 -124
  83. package/update-check.mjs +13 -13
  84. package/ve-kanban.mjs +1 -1
  85. package/whatsapp-channel.mjs +5 -5
  86. package/workflow-engine.mjs +20 -1
  87. package/workflow-nodes.mjs +904 -4
  88. package/workflow-templates/agents.mjs +321 -7
  89. package/workflow-templates/ci-cd.mjs +6 -6
  90. package/workflow-templates/github.mjs +156 -84
  91. package/workflow-templates/planning.mjs +8 -8
  92. package/workflow-templates/reliability.mjs +8 -8
  93. package/workflow-templates/security.mjs +3 -3
  94. package/workflow-templates.mjs +15 -9
  95. package/workspace-manager.mjs +85 -1
  96. package/workspace-monitor.mjs +2 -2
  97. package/workspace-registry.mjs +2 -2
  98. package/worktree-manager.mjs +1 -1
@@ -287,9 +287,62 @@ const GH_ISSUE_LIST_CACHE_TTL_MS =
287
287
  const GH_SHARED_STATE_CACHE_TTL_MS =
288
288
  Number(process.env.GITHUB_SHARED_STATE_CACHE_TTL_MS) || 5 * 60 * 1000; // 5 min
289
289
 
290
+ const GH_ISSUE_VIEW_CACHE_TTL_MS =
291
+ Number(process.env.GH_ISSUE_VIEW_CACHE_TTL_MS) || 60 * 1000; // 1 min
292
+
293
+ const GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS =
294
+ Number(process.env.GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS) || 5 * 60 * 1000; // 5 min
295
+
296
+ const GH_ISSUE_LOCATOR_CACHE_TTL_MS =
297
+ Number(process.env.GH_ISSUE_LOCATOR_CACHE_TTL_MS) || 30 * 60 * 1000; // 30 min
298
+
299
+ const GH_READ_CACHE_TTL_MS =
300
+ Number(process.env.GH_READ_CACHE_TTL_MS) || 30 * 1000; // 30 sec
301
+
302
+ const GH_ERROR_COOLDOWN_MS =
303
+ Number(process.env.GH_ERROR_COOLDOWN_MS) || 60 * 1000; // 1 min
304
+
305
+ const GH_NOT_FOUND_COOLDOWN_MS =
306
+ Number(process.env.GH_NOT_FOUND_COOLDOWN_MS) || 5 * 60 * 1000; // 5 min
307
+
308
+ function buildGitHubAdapterCacheNamespace(owner, repo) {
309
+ const normalizedOwner = String(owner || "").trim().toLowerCase();
310
+ const normalizedRepo = String(repo || "").trim().toLowerCase();
311
+ if (normalizedOwner && normalizedRepo) return `${normalizedOwner}/${normalizedRepo}`;
312
+ if (normalizedRepo) return normalizedRepo;
313
+ return normalizedOwner || "unknown";
314
+ }
315
+
316
+ const GH_ADAPTER_CACHE_BUCKETS = new Map();
317
+
318
+ function createGitHubAdapterCacheBucket() {
319
+ return {
320
+ issueList: new Map(),
321
+ sharedState: new Map(),
322
+ issueLocator: new Map(),
323
+ missingIssue: new Map(),
324
+ ghRead: new Map(),
325
+ ghInflight: new Map(),
326
+ ghErrorCooldown: new Map(),
327
+ };
328
+ }
329
+
330
+ function getGitHubAdapterCacheBucket(owner, repo) {
331
+ const namespace = buildGitHubAdapterCacheNamespace(owner, repo);
332
+ if (!GH_ADAPTER_CACHE_BUCKETS.has(namespace)) {
333
+ GH_ADAPTER_CACHE_BUCKETS.set(namespace, createGitHubAdapterCacheBucket());
334
+ }
335
+ return GH_ADAPTER_CACHE_BUCKETS.get(namespace);
336
+ }
337
+
338
+ function clearGitHubAdapterCacheBuckets() {
339
+ GH_ADAPTER_CACHE_BUCKETS.clear();
340
+ }
341
+
290
342
  /** Build a cache key for the issue-list cache (per adapter instance). */
291
- function _issueListCacheKey(state, limit) {
292
- return `${state}:${limit}`;
343
+ function _issueListCacheKey(repoKey, state, limit) {
344
+ const normalizedRepo = String(repoKey || "").trim().toLowerCase();
345
+ return `${normalizedRepo}:${state}:${limit}`;
293
346
  }
294
347
 
295
348
  /** Build a cache key for the shared-state cache (per adapter instance). */
@@ -299,6 +352,18 @@ function _sharedStateCacheKey(num, repoKey = "") {
299
352
  return normalizedRepo ? `${normalizedRepo}#${normalizedNum}` : normalizedNum;
300
353
  }
301
354
 
355
+ function _issueLocatorCacheKey(num, repoKey = "", owner = "", repo = "") {
356
+ const normalizedNum = String(num || "")
357
+ .trim()
358
+ .replace(/^#/, "");
359
+ const normalizedRepoKey =
360
+ String(repoKey || "")
361
+ .trim()
362
+ .toLowerCase() ||
363
+ buildGitHubAdapterCacheNamespace(owner, repo);
364
+ return _sharedStateCacheKey(normalizedNum, normalizedRepoKey);
365
+ }
366
+
302
367
  function parseIssueLocator(issueNumber, defaultOwner, defaultRepo, issueUrl = "") {
303
368
  const urlText = String(issueUrl || issueNumber || "").trim();
304
369
  const urlMatch = urlText.match(
@@ -330,6 +395,25 @@ function parseIssueLocator(issueNumber, defaultOwner, defaultRepo, issueUrl = ""
330
395
  };
331
396
  }
332
397
 
398
+ function parseIssueRefLocator(issueRef, defaultOwner, defaultRepo, issueUrl = "") {
399
+ const raw = String(issueRef || "").trim();
400
+ const explicit = raw.match(/^([^/\s]+)\/([^#\s]+)#(\d+)$/);
401
+ if (explicit) {
402
+ const owner = String(explicit[1] || "").trim();
403
+ const repo = String(explicit[2] || "")
404
+ .trim()
405
+ .replace(/\.git$/i, "");
406
+ const number = String(explicit[3] || "").trim();
407
+ return {
408
+ owner,
409
+ repo,
410
+ number,
411
+ repoKey: `${owner}/${repo}`.toLowerCase(),
412
+ };
413
+ }
414
+ return parseIssueLocator(issueRef, defaultOwner, defaultRepo, issueUrl);
415
+ }
416
+
333
417
  function isGhRateLimitError(text) {
334
418
  const errText = String(text || "").toLowerCase();
335
419
  if (!errText) return false;
@@ -347,6 +431,95 @@ function isGhTransientError(text) {
347
431
  return GH_TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errText));
348
432
  }
349
433
 
434
+ const GH_NOT_FOUND_ERROR_PATTERNS = [
435
+ /could not resolve to an issue or pull request with the number/i,
436
+ /repository\.issue/i,
437
+ /\bnot found\b/i,
438
+ /http\s*404/i,
439
+ ];
440
+
441
+ const GH_AUTH_ERROR_PATTERNS = [
442
+ /bad credentials/i,
443
+ /requires authentication/i,
444
+ /must authenticate/i,
445
+ /authentication failed/i,
446
+ /resource not accessible by integration/i,
447
+ /http\s*401/i,
448
+ ];
449
+
450
+ function buildGhRequestKey(args, parseJson = true) {
451
+ const parts = Array.isArray(args) ? args : [args];
452
+ return `${parseJson ? "json" : "text"}:${parts
453
+ .map((entry) => String(entry ?? ""))
454
+ .join("\u001f")}`;
455
+ }
456
+
457
+ function isGhGraphqlMutation(args) {
458
+ if (!Array.isArray(args)) return false;
459
+ if (String(args[0] || "").toLowerCase() !== "api") return false;
460
+ const queryArg = args.find(
461
+ (entry) =>
462
+ typeof entry === "string" &&
463
+ (entry.startsWith("query=") || entry.startsWith("-fquery=")),
464
+ );
465
+ if (!queryArg) return false;
466
+ return /\bmutation\b/i.test(queryArg);
467
+ }
468
+
469
+ function isGhReadOnlyCommand(args) {
470
+ if (!Array.isArray(args) || args.length === 0) return false;
471
+ const cmd = String(args[0] || "").toLowerCase();
472
+ const sub = String(args[1] || "").toLowerCase();
473
+
474
+ if (cmd === "issue") {
475
+ return sub === "view" || sub === "list";
476
+ }
477
+
478
+ if (cmd === "project") {
479
+ return ["list", "view", "field-list", "item-list"].includes(sub);
480
+ }
481
+
482
+ if (cmd === "api") {
483
+ const methodFlagIndex = args.findIndex(
484
+ (entry) => String(entry || "").toLowerCase() === "-x",
485
+ );
486
+ if (methodFlagIndex >= 0) {
487
+ const method = String(args[methodFlagIndex + 1] || "GET")
488
+ .trim()
489
+ .toUpperCase();
490
+ if (method && method !== "GET") return false;
491
+ }
492
+ if (isGhGraphqlMutation(args)) return false;
493
+ return true;
494
+ }
495
+
496
+ return false;
497
+ }
498
+
499
+ function classifyGhDeterministicError(text) {
500
+ const raw = String(text || "");
501
+ if (!raw) return null;
502
+ if (isGhRateLimitError(raw) || isGhTransientError(raw)) return null;
503
+ if (GH_NOT_FOUND_ERROR_PATTERNS.some((pattern) => pattern.test(raw))) {
504
+ return "not_found";
505
+ }
506
+ if (GH_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(raw))) {
507
+ return "auth";
508
+ }
509
+ return null;
510
+ }
511
+
512
+ function isGhNotFoundError(text) {
513
+ const errText = String(text || "").toLowerCase();
514
+ if (!errText) return false;
515
+ return (
516
+ errText.includes("http 404") ||
517
+ errText.includes("not found (http 404)") ||
518
+ errText.includes("could not resolve to an issue or pull request with the number") ||
519
+ errText.includes("(repository.issue)")
520
+ );
521
+ }
522
+
350
523
  function parseRepoSlug(raw) {
351
524
  const text = String(raw || "").trim().replace(/^https?:\/\/github\.com\//i, "");
352
525
  if (!text) return null;
@@ -1343,11 +1516,57 @@ class GitHubIssuesAdapter {
1343
1516
  Number(process.env.GH_TRANSIENT_RETRY_MAX) || 2,
1344
1517
  );
1345
1518
 
1346
- // Issue-list and shared-state caches (instance-level for test isolation)
1519
+ // Issue-list and shared-state caches (shared per repo namespace)
1520
+ const cacheBucket = getGitHubAdapterCacheBucket(this._owner, this._repo);
1347
1521
  /** @type {Map<string, {data: any, ts: number}>} state:limit → {data, ts} */
1348
- this._issueListCache = new Map();
1522
+ this._issueListCache = cacheBucket.issueList;
1349
1523
  /** @type {Map<string, {data: object|null, ts: number}>} issueNum → {data, ts} */
1350
- this._sharedStateCache = new Map();
1524
+ this._sharedStateCache = cacheBucket.sharedState;
1525
+ /** @type {Map<string, {owner:string, repo:string, repoKey:string, number:string, issueUrl:string, ts:number}>} */
1526
+ this._issueLocatorCache = cacheBucket.issueLocator;
1527
+ this._issueLocatorCacheTtlMs = parseDelayMs(
1528
+ process.env.GH_ISSUE_LOCATOR_CACHE_TTL_MS,
1529
+ GH_ISSUE_LOCATOR_CACHE_TTL_MS,
1530
+ 0,
1531
+ );
1532
+ /** @type {Map<string, number>} repo#issueNum → last-seen-missing timestamp */
1533
+ this._missingIssueCache = cacheBucket.missingIssue;
1534
+ this._missingIssueCacheTtlMs = parseDelayMs(
1535
+ process.env.GH_MISSING_ISSUE_CACHE_TTL_MS,
1536
+ 10 * 60 * 1000,
1537
+ 5_000,
1538
+ );
1539
+ /** @type {Map<string, {data:any, ts:number}>} gh request key → parsed payload */
1540
+ this._ghReadCache = cacheBucket.ghRead;
1541
+ /** @type {Map<string, Promise<any>>} gh request key → in-flight promise */
1542
+ this._ghInflight = cacheBucket.ghInflight;
1543
+ /** @type {Map<string, {until:number, message:string, type:string|null}>} gh request key → cooldown metadata */
1544
+ this._ghErrorCooldown = cacheBucket.ghErrorCooldown;
1545
+ this._ghReadCacheTtlMs = parseDelayMs(
1546
+ process.env.GH_READ_CACHE_TTL_MS,
1547
+ GH_READ_CACHE_TTL_MS,
1548
+ 0,
1549
+ );
1550
+ this._ghErrorCooldownMs = parseDelayMs(
1551
+ process.env.GH_ERROR_COOLDOWN_MS,
1552
+ GH_ERROR_COOLDOWN_MS,
1553
+ 0,
1554
+ );
1555
+ this._ghNotFoundCooldownMs = parseDelayMs(
1556
+ process.env.GH_NOT_FOUND_COOLDOWN_MS,
1557
+ GH_NOT_FOUND_COOLDOWN_MS,
1558
+ 0,
1559
+ );
1560
+ this._issueViewCacheTtlMs = parseDelayMs(
1561
+ process.env.GH_ISSUE_VIEW_CACHE_TTL_MS,
1562
+ GH_ISSUE_VIEW_CACHE_TTL_MS,
1563
+ 0,
1564
+ );
1565
+ this._issueViewNegativeCacheTtlMs = parseDelayMs(
1566
+ process.env.GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS,
1567
+ GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS,
1568
+ 5_000,
1569
+ );
1351
1570
  this._lastKnownTasks = [];
1352
1571
  this._taskListBackoffUntil = 0;
1353
1572
  this._taskListBackoffMs = parseDelayMs(
@@ -1364,6 +1583,11 @@ class GitHubIssuesAdapter {
1364
1583
  ...task,
1365
1584
  meta: task?.meta ? { ...task.meta } : {},
1366
1585
  }));
1586
+ for (const task of this._lastKnownTasks) {
1587
+ const issueUrl = String(task?.taskUrl || task?.meta?.url || "").trim();
1588
+ if (!issueUrl) continue;
1589
+ this._rememberIssueLocator(task.id, issueUrl);
1590
+ }
1367
1591
  this._taskListBackoffUntil = 0;
1368
1592
  this._taskListBackoffWarnAt = 0;
1369
1593
  }
@@ -1390,6 +1614,142 @@ class GitHubIssuesAdapter {
1390
1614
  return fallback;
1391
1615
  }
1392
1616
 
1617
+ _clearGhReadCaches() {
1618
+ this._ghReadCache.clear();
1619
+ this._ghErrorCooldown.clear();
1620
+ this._ghInflight.clear();
1621
+ }
1622
+
1623
+ _rememberIssueLocator(issueNumber, issueUrl = "") {
1624
+ const locator = parseIssueRefLocator(
1625
+ issueNumber,
1626
+ this._owner,
1627
+ this._repo,
1628
+ issueUrl,
1629
+ );
1630
+ if (!/^\d+$/.test(locator.number) || !locator.owner || !locator.repo) return;
1631
+ const key = _issueLocatorCacheKey(
1632
+ locator.number,
1633
+ locator.repoKey,
1634
+ this._owner,
1635
+ this._repo,
1636
+ );
1637
+ const canonicalUrl =
1638
+ issueUrl ||
1639
+ `https://github.com/${locator.owner}/${locator.repo}/issues/${locator.number}`;
1640
+ this._issueLocatorCache.set(key, {
1641
+ ...locator,
1642
+ issueUrl: canonicalUrl,
1643
+ ts: Date.now(),
1644
+ });
1645
+ }
1646
+
1647
+ _resolveIssueLocator(issueNumber, options = {}) {
1648
+ const issueUrl = String(options?.issueUrl || "").trim();
1649
+ const direct = parseIssueRefLocator(
1650
+ issueNumber,
1651
+ this._owner,
1652
+ this._repo,
1653
+ issueUrl,
1654
+ );
1655
+ if (!/^\d+$/.test(direct.number)) return direct;
1656
+
1657
+ // Explicit URL/ref always wins and refreshes cache.
1658
+ if (issueUrl || String(issueNumber || "").includes("/")) {
1659
+ this._rememberIssueLocator(direct.number, issueUrl || String(issueNumber || ""));
1660
+ return {
1661
+ ...direct,
1662
+ issueUrl:
1663
+ issueUrl ||
1664
+ `https://github.com/${direct.owner}/${direct.repo}/issues/${direct.number}`,
1665
+ };
1666
+ }
1667
+
1668
+ const key = _issueLocatorCacheKey(
1669
+ direct.number,
1670
+ direct.repoKey,
1671
+ this._owner,
1672
+ this._repo,
1673
+ );
1674
+ const cached = this._issueLocatorCache.get(key);
1675
+ if (cached) {
1676
+ if (Date.now() - cached.ts <= this._issueLocatorCacheTtlMs) {
1677
+ return { ...cached };
1678
+ }
1679
+ this._issueLocatorCache.delete(key);
1680
+ }
1681
+
1682
+ // Cross-repo fallback: if an issue number was previously observed with a
1683
+ // different repo slug (e.g. project board rows), reuse that locator.
1684
+ const suffix = `#${direct.number}`;
1685
+ for (const [cacheKey, value] of this._issueLocatorCache.entries()) {
1686
+ if (!cacheKey.endsWith(suffix)) continue;
1687
+ if (Date.now() - value.ts > this._issueLocatorCacheTtlMs) {
1688
+ this._issueLocatorCache.delete(cacheKey);
1689
+ continue;
1690
+ }
1691
+ return { ...value };
1692
+ }
1693
+
1694
+ // Fall back to last-known tasks (e.g., project item-list rows from other repos).
1695
+ const match = this._lastKnownTasks.find(
1696
+ (task) => String(task?.id || "") === String(direct.number),
1697
+ );
1698
+ const taskUrl = String(match?.taskUrl || match?.meta?.url || "").trim();
1699
+ if (taskUrl) {
1700
+ const resolved = parseIssueRefLocator(
1701
+ key,
1702
+ this._owner,
1703
+ this._repo,
1704
+ taskUrl,
1705
+ );
1706
+ if (/^\d+$/.test(resolved.number)) {
1707
+ const resolvedKey = _issueLocatorCacheKey(
1708
+ resolved.number,
1709
+ resolved.repoKey,
1710
+ this._owner,
1711
+ this._repo,
1712
+ );
1713
+ const value = {
1714
+ ...resolved,
1715
+ issueUrl: taskUrl,
1716
+ ts: Date.now(),
1717
+ };
1718
+ this._issueLocatorCache.set(resolvedKey, value);
1719
+ return value;
1720
+ }
1721
+ }
1722
+
1723
+ return {
1724
+ ...direct,
1725
+ issueUrl: `https://github.com/${direct.owner}/${direct.repo}/issues/${direct.number}`,
1726
+ };
1727
+ }
1728
+
1729
+ _isIssueKnownMissing(issueNumber, options = {}) {
1730
+ const locator = this._resolveIssueLocator(issueNumber, options);
1731
+ const key = _sharedStateCacheKey(locator.number, locator.repoKey);
1732
+ const seenAt = this._missingIssueCache.get(key);
1733
+ if (!seenAt) return false;
1734
+ if (Date.now() - seenAt > this._missingIssueCacheTtlMs) {
1735
+ this._missingIssueCache.delete(key);
1736
+ return false;
1737
+ }
1738
+ return true;
1739
+ }
1740
+
1741
+ _markIssueMissing(issueNumber, options = {}) {
1742
+ const locator = this._resolveIssueLocator(issueNumber, options);
1743
+ const key = _sharedStateCacheKey(locator.number, locator.repoKey);
1744
+ this._missingIssueCache.set(key, Date.now());
1745
+ }
1746
+
1747
+ _clearIssueMissing(issueNumber, options = {}) {
1748
+ const locator = this._resolveIssueLocator(issueNumber, options);
1749
+ const key = _sharedStateCacheKey(locator.number, locator.repoKey);
1750
+ this._missingIssueCache.delete(key);
1751
+ }
1752
+
1393
1753
  /**
1394
1754
  * Get project fields with caching (private — returns legacy format for _syncStatusToProject).
1395
1755
  * Returns status field ID and options for project board.
@@ -1674,9 +2034,20 @@ class GitHubIssuesAdapter {
1674
2034
  const issueUrl =
1675
2035
  content.url ||
1676
2036
  `https://github.com/${this._owner}/${this._repo}/issues/${num}`;
2037
+ const locator = parseIssueRefLocator(
2038
+ num,
2039
+ this._owner,
2040
+ this._repo,
2041
+ issueUrl,
2042
+ );
2043
+ const normalizedNum = /^\d+$/.test(locator.number)
2044
+ ? locator.number
2045
+ : String(num);
2046
+ const projectId = `${locator.owner}/${locator.repo}`;
2047
+ this._rememberIssueLocator(normalizedNum, issueUrl);
1677
2048
 
1678
2049
  return {
1679
- id: String(num),
2050
+ id: String(normalizedNum),
1680
2051
  title: content.title || projectItem.title || "",
1681
2052
  description: body,
1682
2053
  status,
@@ -1688,12 +2059,12 @@ class GitHubIssuesAdapter {
1688
2059
  : null,
1689
2060
  tags,
1690
2061
  draft: labelSet.has("draft") || status === "draft",
1691
- projectId: `${this._owner}/${this._repo}`,
2062
+ projectId,
1692
2063
  baseBranch,
1693
2064
  branchName: branchMatch?.[1] || null,
1694
2065
  prNumber: prMatch?.[1] || null,
1695
2066
  meta: {
1696
- number: Number(num),
2067
+ number: Number(normalizedNum),
1697
2068
  title: content.title || projectItem.title || "",
1698
2069
  body,
1699
2070
  state: content.state || null,
@@ -2182,120 +2553,221 @@ class GitHubIssuesAdapter {
2182
2553
  /** Execute a gh CLI command and return parsed JSON (with rate limit retry) */
2183
2554
  async _gh(args, options = {}) {
2184
2555
  const { parseJson = true } = options;
2185
- const { execFile } = await import("node:child_process");
2556
+ const normalizedArgs = Array.isArray(args)
2557
+ ? args.map((entry) => String(entry ?? ""))
2558
+ : [String(args ?? "")];
2559
+ const readOnly = isGhReadOnlyCommand(normalizedArgs);
2560
+ const isIssueViewRead =
2561
+ readOnly &&
2562
+ String(normalizedArgs[0] || "").toLowerCase() === "issue" &&
2563
+ String(normalizedArgs[1] || "").toLowerCase() === "view";
2564
+ const readCacheTtlMs = isIssueViewRead
2565
+ ? this._issueViewCacheTtlMs
2566
+ : this._ghReadCacheTtlMs;
2567
+ const requestKey = buildGhRequestKey(normalizedArgs, parseJson);
2568
+ const now = Date.now();
2186
2569
 
2187
- const attempt = async () => {
2188
- try {
2189
- const result = await new Promise((resolve, reject) => {
2190
- execFile(
2191
- "gh",
2192
- args,
2193
- {
2194
- maxBuffer: 10 * 1024 * 1024,
2195
- timeout: 30_000,
2196
- },
2197
- (err, stdout, stderr) => {
2198
- const normalizeOutput = (value, fallback = "") => {
2199
- if (value == null) return String(fallback || "");
2200
- if (typeof value === "string") return value;
2201
- return String(value);
2202
- };
2203
-
2204
- const outputFromStdoutObject =
2205
- stdout && typeof stdout === "object"
2206
- ? {
2207
- stdout: normalizeOutput(stdout.stdout),
2208
- stderr: normalizeOutput(stdout.stderr, stderr),
2209
- }
2210
- : null;
2211
-
2212
- const normalized = outputFromStdoutObject || {
2213
- stdout: normalizeOutput(stdout),
2214
- stderr: normalizeOutput(stderr),
2215
- };
2216
-
2217
- if (err) {
2218
- err.stdout = normalizeOutput(err.stdout, normalized.stdout);
2219
- err.stderr = normalizeOutput(err.stderr, normalized.stderr);
2220
- reject(err);
2221
- return;
2222
- }
2570
+ if (readOnly && readCacheTtlMs > 0) {
2571
+ const cached = this._ghReadCache.get(requestKey);
2572
+ if (cached && now - cached.ts < readCacheTtlMs) {
2573
+ return cached.data;
2574
+ }
2575
+ if (cached) this._ghReadCache.delete(requestKey);
2576
+ }
2223
2577
 
2224
- resolve(normalized);
2225
- },
2226
- );
2227
- });
2228
- return result;
2229
- } catch (err) {
2230
- const message = String(err?.message || err);
2231
- const stdout = String(err?.stdout || "");
2232
- const stderr = String(err?.stderr || "");
2233
- const ghError = new Error(message);
2234
- ghError.stdout = stdout;
2235
- ghError.stderr = stderr;
2236
- ghError.fullText = [message, stderr, stdout].filter(Boolean).join("\n");
2237
- ghError.isRateLimit = isGhRateLimitError([message, stderr].join("\n"));
2238
- ghError.isTransient = isGhTransientError([message, stderr, stdout].join("\n"));
2239
- throw ghError;
2578
+ if (readOnly && this._ghErrorCooldownMs > 0) {
2579
+ const cooldown = this._ghErrorCooldown.get(requestKey);
2580
+ if (cooldown && cooldown.until > now) {
2581
+ const cooldownError = new Error(
2582
+ `gh CLI request skipped during cooldown: ${cooldown.message}`,
2583
+ );
2584
+ cooldownError.isCooldown = true;
2585
+ cooldownError.isNotFound = cooldown.type === "not_found";
2586
+ throw cooldownError;
2240
2587
  }
2241
- };
2588
+ if (cooldown) this._ghErrorCooldown.delete(requestKey);
2589
+ }
2242
2590
 
2243
- let usedRateLimitRetry = false;
2244
- let transientRetries = 0;
2245
- const maxTransientRetries = Math.max(0, Number(this._transientRetryMax) || 0);
2591
+ if (readOnly && this._ghInflight.has(requestKey)) {
2592
+ return this._ghInflight.get(requestKey);
2593
+ }
2246
2594
 
2247
- while (true) {
2248
- let result;
2249
- try {
2250
- result = await attempt();
2251
- } catch (err) {
2252
- const message = String(err?.message || err);
2253
- if (err?.isRateLimit && !usedRateLimitRetry) {
2254
- usedRateLimitRetry = true;
2255
- console.warn(
2256
- `${TAG} rate limit detected, waiting ${this._rateLimitRetryDelayMs}ms before retry...`,
2257
- );
2258
- await sleepMs(this._rateLimitRetryDelayMs);
2259
- continue;
2595
+ const { execFile } = await import("node:child_process");
2596
+
2597
+ const run = async () => {
2598
+ const attempt = async () => {
2599
+ try {
2600
+ const result = await new Promise((resolve, reject) => {
2601
+ execFile(
2602
+ "gh",
2603
+ normalizedArgs,
2604
+ {
2605
+ maxBuffer: 10 * 1024 * 1024,
2606
+ timeout: 30_000,
2607
+ },
2608
+ (err, stdout, stderr) => {
2609
+ const normalizeOutput = (value, fallback = "") => {
2610
+ if (value == null) return String(fallback || "");
2611
+ if (typeof value === "string") return value;
2612
+ return String(value);
2613
+ };
2614
+
2615
+ const outputFromStdoutObject =
2616
+ stdout && typeof stdout === "object"
2617
+ ? {
2618
+ stdout: normalizeOutput(stdout.stdout),
2619
+ stderr: normalizeOutput(stdout.stderr, stderr),
2620
+ }
2621
+ : null;
2622
+
2623
+ const normalized = outputFromStdoutObject || {
2624
+ stdout: normalizeOutput(stdout),
2625
+ stderr: normalizeOutput(stderr),
2626
+ };
2627
+
2628
+ if (err) {
2629
+ err.stdout = normalizeOutput(err.stdout, normalized.stdout);
2630
+ err.stderr = normalizeOutput(err.stderr, normalized.stderr);
2631
+ reject(err);
2632
+ return;
2633
+ }
2634
+
2635
+ resolve(normalized);
2636
+ },
2637
+ );
2638
+ });
2639
+ return result;
2640
+ } catch (err) {
2641
+ const message = String(err?.message || err);
2642
+ const stdout = String(err?.stdout || "");
2643
+ const stderr = String(err?.stderr || "");
2644
+ const ghError = new Error(message);
2645
+ ghError.stdout = stdout;
2646
+ ghError.stderr = stderr;
2647
+ ghError.fullText = [message, stderr, stdout].filter(Boolean).join("\n");
2648
+ ghError.isRateLimit = isGhRateLimitError([message, stderr].join("\n"));
2649
+ ghError.isTransient = isGhTransientError([message, stderr, stdout].join("\n"));
2650
+ ghError.isNotFound = isGhNotFoundError([message, stderr, stdout].join("\n"));
2651
+ throw ghError;
2260
2652
  }
2261
- if (err?.isTransient && transientRetries < maxTransientRetries) {
2262
- transientRetries += 1;
2263
- console.warn(
2264
- `${TAG} transient gh failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
2653
+ };
2654
+
2655
+ let usedRateLimitRetry = false;
2656
+ let transientRetries = 0;
2657
+ const maxTransientRetries = Math.max(0, Number(this._transientRetryMax) || 0);
2658
+
2659
+ while (true) {
2660
+ let result;
2661
+ try {
2662
+ result = await attempt();
2663
+ } catch (err) {
2664
+ const message = String(err?.message || err);
2665
+ if (err?.isRateLimit && !usedRateLimitRetry) {
2666
+ usedRateLimitRetry = true;
2667
+ console.warn(
2668
+ `${TAG} rate limit detected, waiting ${this._rateLimitRetryDelayMs}ms before retry...`,
2669
+ );
2670
+ await sleepMs(this._rateLimitRetryDelayMs);
2671
+ continue;
2672
+ }
2673
+ if (err?.isTransient && transientRetries < maxTransientRetries) {
2674
+ transientRetries += 1;
2675
+ console.warn(
2676
+ `${TAG} transient gh failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
2677
+ );
2678
+ await sleepMs(this._transientRetryDelayMs);
2679
+ continue;
2680
+ }
2681
+
2682
+ const fullText = String(err?.fullText || message);
2683
+ const deterministicClass = classifyGhDeterministicError(fullText);
2684
+ if (readOnly && deterministicClass) {
2685
+ const cooldownMs =
2686
+ deterministicClass === "not_found"
2687
+ ? isIssueViewRead
2688
+ ? this._issueViewNegativeCacheTtlMs
2689
+ : this._ghNotFoundCooldownMs
2690
+ : this._ghErrorCooldownMs;
2691
+ if (cooldownMs > 0) {
2692
+ this._ghErrorCooldown.set(requestKey, {
2693
+ until: Date.now() + cooldownMs,
2694
+ message,
2695
+ type: deterministicClass,
2696
+ });
2697
+ }
2698
+ }
2699
+
2700
+ if (err?.isRateLimit && usedRateLimitRetry) {
2701
+ const wrapped = new Error(
2702
+ `gh CLI failed (after rate limit retry): ${message}`,
2703
+ );
2704
+ wrapped.cause = err;
2705
+ wrapped.isRateLimit = Boolean(err?.isRateLimit);
2706
+ wrapped.isTransient = Boolean(err?.isTransient);
2707
+ wrapped.isNotFound = Boolean(
2708
+ err?.isNotFound || deterministicClass === "not_found",
2709
+ );
2710
+ wrapped.isDeterministic = Boolean(deterministicClass);
2711
+ wrapped.deterministicClass = deterministicClass;
2712
+ throw wrapped;
2713
+ }
2714
+ const wrapped = new Error(`gh CLI failed: ${message}`);
2715
+ wrapped.cause = err;
2716
+ wrapped.isRateLimit = Boolean(err?.isRateLimit);
2717
+ wrapped.isTransient = Boolean(err?.isTransient);
2718
+ wrapped.isNotFound = Boolean(
2719
+ err?.isNotFound || deterministicClass === "not_found",
2265
2720
  );
2266
- await sleepMs(this._transientRetryDelayMs);
2267
- continue;
2721
+ wrapped.isDeterministic = Boolean(deterministicClass);
2722
+ wrapped.deterministicClass = deterministicClass;
2723
+ throw wrapped;
2268
2724
  }
2269
- if (err?.isRateLimit && usedRateLimitRetry) {
2270
- throw new Error(`gh CLI failed (after rate limit retry): ${message}`);
2271
- }
2272
- throw new Error(`gh CLI failed: ${message}`);
2273
- }
2274
2725
 
2275
- const text = String(result?.stdout || "").trim();
2276
- if (!parseJson) return text;
2277
- if (!text) return null;
2726
+ const text = String(result?.stdout || "").trim();
2727
+ let parsed;
2728
+ if (!parseJson) {
2729
+ parsed = text;
2730
+ } else if (!text) {
2731
+ parsed = null;
2732
+ } else {
2733
+ try {
2734
+ parsed = JSON.parse(text);
2735
+ } catch (err) {
2736
+ const parseMessage = String(err?.message || err);
2737
+ const parseContext = [parseMessage, result?.stderr || "", text.slice(0, 512)]
2738
+ .filter(Boolean)
2739
+ .join("\n");
2740
+ if (
2741
+ isGhTransientError(parseContext) &&
2742
+ transientRetries < maxTransientRetries
2743
+ ) {
2744
+ transientRetries += 1;
2745
+ console.warn(
2746
+ `${TAG} transient gh JSON parse failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
2747
+ );
2748
+ await sleepMs(this._transientRetryDelayMs);
2749
+ continue;
2750
+ }
2751
+ throw new Error(`gh CLI returned invalid JSON: ${parseMessage}`);
2752
+ }
2753
+ }
2278
2754
 
2279
- try {
2280
- return JSON.parse(text);
2281
- } catch (err) {
2282
- const parseMessage = String(err?.message || err);
2283
- const parseContext = [parseMessage, result?.stderr || "", text.slice(0, 512)]
2284
- .filter(Boolean)
2285
- .join("\n");
2286
- if (
2287
- isGhTransientError(parseContext) &&
2288
- transientRetries < maxTransientRetries
2289
- ) {
2290
- transientRetries += 1;
2291
- console.warn(
2292
- `${TAG} transient gh JSON parse failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
2293
- );
2294
- await sleepMs(this._transientRetryDelayMs);
2295
- continue;
2755
+ if (readOnly && readCacheTtlMs > 0) {
2756
+ this._ghReadCache.set(requestKey, { data: parsed, ts: Date.now() });
2757
+ } else if (!readOnly) {
2758
+ // A successful mutation invalidates all request-level read caches.
2759
+ this._clearGhReadCaches();
2296
2760
  }
2297
- throw new Error(`gh CLI returned invalid JSON: ${parseMessage}`);
2761
+ return parsed;
2298
2762
  }
2763
+ };
2764
+
2765
+ const promise = run();
2766
+ if (readOnly) this._ghInflight.set(requestKey, promise);
2767
+ try {
2768
+ return await promise;
2769
+ } finally {
2770
+ if (readOnly) this._ghInflight.delete(requestKey);
2299
2771
  }
2300
2772
  }
2301
2773
  async _ensureLabelExists(label) {
@@ -2434,7 +2906,11 @@ class GitHubIssuesAdapter {
2434
2906
  }
2435
2907
 
2436
2908
  // Check instance-level issue-list cache to avoid redundant gh API calls
2437
- const listCacheKey = _issueListCacheKey(stateFilter, limit);
2909
+ const listCacheKey = _issueListCacheKey(
2910
+ `${this._owner}/${this._repo}`,
2911
+ stateFilter,
2912
+ limit,
2913
+ );
2438
2914
  const nowMs = Date.now();
2439
2915
  const cachedList = this._issueListCache.get(listCacheKey);
2440
2916
  let rawIssues;
@@ -2520,13 +2996,33 @@ class GitHubIssuesAdapter {
2520
2996
  return normalized;
2521
2997
  }
2522
2998
 
2523
- async getTask(issueNumber) {
2524
- const num = String(issueNumber).replace(/^#/, "");
2999
+ async getTask(issueNumber, options = {}) {
3000
+ const locator = this._resolveIssueLocator(issueNumber, options);
3001
+ const num = String(locator.number || "").replace(/^#/, "");
2525
3002
  if (!/^\d+$/.test(num)) {
2526
3003
  throw new Error(
2527
3004
  `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
2528
3005
  );
2529
3006
  }
3007
+ const repoRef = `${locator.owner}/${locator.repo}`;
3008
+ const issueUrl =
3009
+ locator.issueUrl || `https://github.com/${repoRef}/issues/${num}`;
3010
+ if (this._isIssueKnownMissing(num, { issueUrl })) {
3011
+ return {
3012
+ id: String(num),
3013
+ title: "",
3014
+ description: "",
3015
+ status: "todo",
3016
+ assignee: null,
3017
+ priority: null,
3018
+ projectId: repoRef,
3019
+ branchName: null,
3020
+ prNumber: null,
3021
+ meta: { externalMissing: true, externalMissingReason: "issue_not_found" },
3022
+ taskUrl: issueUrl,
3023
+ backend: "github",
3024
+ };
3025
+ }
2530
3026
  let issue = null;
2531
3027
  try {
2532
3028
  issue = await this._gh([
@@ -2534,14 +3030,20 @@ class GitHubIssuesAdapter {
2534
3030
  "view",
2535
3031
  num,
2536
3032
  "--repo",
2537
- `${this._owner}/${this._repo}`,
3033
+ repoRef,
2538
3034
  "--json",
2539
3035
  "number,title,body,state,url,assignees,labels,milestone,comments",
2540
3036
  ]);
3037
+ this._rememberIssueLocator(num, issue?.url || issueUrl);
3038
+ this._clearIssueMissing(num, { issueUrl: issue?.url || issueUrl });
2541
3039
  } catch (err) {
2542
- console.warn(
2543
- `${TAG} failed to fetch issue #${num}: ${err.message || err}`,
2544
- );
3040
+ if (err?.isNotFound) {
3041
+ this._markIssueMissing(num, { issueUrl });
3042
+ } else if (!err?.isCooldown) {
3043
+ console.warn(
3044
+ `${TAG} failed to fetch issue #${num}: ${err.message || err}`,
3045
+ );
3046
+ }
2545
3047
  }
2546
3048
  const task = issue
2547
3049
  ? this._normaliseIssue(issue)
@@ -2552,13 +3054,19 @@ class GitHubIssuesAdapter {
2552
3054
  status: "todo",
2553
3055
  assignee: null,
2554
3056
  priority: null,
2555
- projectId: `${this._owner}/${this._repo}`,
3057
+ projectId: repoRef,
2556
3058
  branchName: null,
2557
3059
  prNumber: null,
2558
3060
  meta: {},
2559
- taskUrl: null,
3061
+ taskUrl: issueUrl,
2560
3062
  backend: "github",
2561
3063
  };
3064
+ if (!issue) {
3065
+ task.meta.externalMissing = this._isIssueKnownMissing(num, { issueUrl });
3066
+ if (task.meta.externalMissing) {
3067
+ task.meta.externalMissingReason = "issue_not_found";
3068
+ }
3069
+ }
2562
3070
 
2563
3071
  if (issue && (!task.branchName || !task.prNumber)) {
2564
3072
  const comments = Array.isArray(issue?.comments) ? issue.comments : [];
@@ -2577,31 +3085,40 @@ class GitHubIssuesAdapter {
2577
3085
  }
2578
3086
 
2579
3087
  // Enrich with shared state from comments
2580
- try {
2581
- const sharedState = normalizeSharedStatePayload(
2582
- await this.readSharedStateFromIssue(num),
2583
- );
2584
- if (sharedState) {
2585
- task.meta.sharedState = sharedState;
2586
- task.sharedState = sharedState;
3088
+ if (issue) {
3089
+ try {
3090
+ const embeddedComments = Array.isArray(issue?.comments)
3091
+ ? issue.comments
3092
+ : null;
3093
+ const sharedState = normalizeSharedStatePayload(
3094
+ await this.readSharedStateFromIssue(num, embeddedComments, {
3095
+ issueUrl: issue?.url || issueUrl,
3096
+ }),
3097
+ );
3098
+ if (sharedState) {
3099
+ task.meta.sharedState = sharedState;
3100
+ task.sharedState = sharedState;
3101
+ }
3102
+ } catch (err) {
3103
+ // Non-critical - continue without shared state
3104
+ console.warn(
3105
+ `[kanban] failed to read shared state for #${num}: ${err.message}`,
3106
+ );
2587
3107
  }
2588
- } catch (err) {
2589
- // Non-critical - continue without shared state
2590
- console.warn(
2591
- `[kanban] failed to read shared state for #${num}: ${err.message}`,
2592
- );
2593
3108
  }
2594
3109
 
2595
3110
  return task;
2596
3111
  }
2597
3112
 
2598
3113
  async updateTaskStatus(issueNumber, status, options = {}) {
2599
- const num = String(issueNumber).replace(/^#/, "");
3114
+ const locator = this._resolveIssueLocator(issueNumber, options);
3115
+ const num = String(locator.number || "").replace(/^#/, "");
2600
3116
  if (!/^\d+$/.test(num)) {
2601
3117
  throw new Error(
2602
3118
  `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
2603
3119
  );
2604
3120
  }
3121
+ const repoRef = `${locator.owner}/${locator.repo}`;
2605
3122
 
2606
3123
  // Invalidate the instance issue-list cache so the next listTasks() poll
2607
3124
  // sees fresh data rather than stale cache.
@@ -2613,7 +3130,7 @@ class GitHubIssuesAdapter {
2613
3130
  "close",
2614
3131
  num,
2615
3132
  "--repo",
2616
- `${this._owner}/${this._repo}`,
3133
+ repoRef,
2617
3134
  ];
2618
3135
  if (normalised === "cancelled") {
2619
3136
  closeArgs.push("--reason", "not planned");
@@ -2621,7 +3138,7 @@ class GitHubIssuesAdapter {
2621
3138
  await this._gh(closeArgs, { parseJson: false });
2622
3139
  } else {
2623
3140
  await this._gh(
2624
- ["issue", "reopen", num, "--repo", `${this._owner}/${this._repo}`],
3141
+ ["issue", "reopen", num, "--repo", repoRef],
2625
3142
  { parseJson: false },
2626
3143
  );
2627
3144
 
@@ -2647,7 +3164,7 @@ class GitHubIssuesAdapter {
2647
3164
  "edit",
2648
3165
  num,
2649
3166
  "--repo",
2650
- `${this._owner}/${this._repo}`,
3167
+ repoRef,
2651
3168
  ];
2652
3169
  if (nextLabel) {
2653
3170
  editArgs.push("--add-label", nextLabel);
@@ -2690,7 +3207,7 @@ class GitHubIssuesAdapter {
2690
3207
  ) {
2691
3208
  const projectNumber = await this._resolveProjectNumber();
2692
3209
  if (projectNumber) {
2693
- const task = await this.getTask(num);
3210
+ const task = await this.getTask(num, { issueUrl: locator.issueUrl });
2694
3211
  if (task?.taskUrl) {
2695
3212
  try {
2696
3213
  await this._syncStatusToProject(
@@ -2710,7 +3227,7 @@ class GitHubIssuesAdapter {
2710
3227
  }
2711
3228
 
2712
3229
  try {
2713
- return await this.getTask(issueNumber);
3230
+ return await this.getTask(issueNumber, { issueUrl: locator.issueUrl });
2714
3231
  } catch (err) {
2715
3232
  console.warn(
2716
3233
  `${TAG} failed to fetch updated issue #${num} after status change: ${err.message}`,
@@ -2722,29 +3239,31 @@ class GitHubIssuesAdapter {
2722
3239
  status: normalised,
2723
3240
  assignee: null,
2724
3241
  priority: null,
2725
- projectId: `${this._owner}/${this._repo}`,
3242
+ projectId: repoRef,
2726
3243
  branchName: null,
2727
3244
  prNumber: null,
2728
3245
  meta: {},
2729
- taskUrl: null,
3246
+ taskUrl: locator.issueUrl || `https://github.com/${repoRef}/issues/${num}`,
2730
3247
  backend: "github",
2731
3248
  };
2732
3249
  }
2733
3250
  }
2734
3251
 
2735
3252
  async updateTask(issueNumber, patch = {}) {
2736
- const num = String(issueNumber).replace(/^#/, "");
3253
+ const locator = this._resolveIssueLocator(issueNumber);
3254
+ const num = String(locator.number || "").replace(/^#/, "");
2737
3255
  if (!/^\d+$/.test(num)) {
2738
3256
  throw new Error(
2739
3257
  `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
2740
3258
  );
2741
3259
  }
3260
+ const repoRef = `${locator.owner}/${locator.repo}`;
2742
3261
  const editArgs = [
2743
3262
  "issue",
2744
3263
  "edit",
2745
3264
  num,
2746
3265
  "--repo",
2747
- `${this._owner}/${this._repo}`,
3266
+ repoRef,
2748
3267
  ];
2749
3268
  let hasEditArgs = false;
2750
3269
  if (typeof patch.title === "string") {
@@ -2769,7 +3288,7 @@ class GitHubIssuesAdapter {
2769
3288
  "view",
2770
3289
  num,
2771
3290
  "--repo",
2772
- `${this._owner}/${this._repo}`,
3291
+ repoRef,
2773
3292
  "--json",
2774
3293
  "labels",
2775
3294
  ]);
@@ -2811,7 +3330,7 @@ class GitHubIssuesAdapter {
2811
3330
  "edit",
2812
3331
  num,
2813
3332
  "--repo",
2814
- `${this._owner}/${this._repo}`,
3333
+ repoRef,
2815
3334
  ];
2816
3335
  for (const label of toAdd) {
2817
3336
  labelArgs.push("--add-label", label);
@@ -3059,18 +3578,20 @@ class GitHubIssuesAdapter {
3059
3578
 
3060
3579
  async deleteTask(issueNumber) {
3061
3580
  // GitHub issues can't be deleted — close with "not planned"
3062
- const num = String(issueNumber).replace(/^#/, "");
3581
+ const locator = this._resolveIssueLocator(issueNumber);
3582
+ const num = String(locator.number || "").replace(/^#/, "");
3063
3583
  if (!/^\d+$/.test(num)) {
3064
3584
  throw new Error(
3065
3585
  `GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID`,
3066
3586
  );
3067
3587
  }
3588
+ const repoRef = `${locator.owner}/${locator.repo}`;
3068
3589
  await this._gh([
3069
3590
  "issue",
3070
3591
  "close",
3071
3592
  num,
3072
3593
  "--repo",
3073
- `${this._owner}/${this._repo}`,
3594
+ repoRef,
3074
3595
  "--reason",
3075
3596
  "not planned",
3076
3597
  ]);
@@ -3078,8 +3599,10 @@ class GitHubIssuesAdapter {
3078
3599
  }
3079
3600
 
3080
3601
  async addComment(issueNumber, body) {
3081
- const num = String(issueNumber).replace(/^#/, "");
3602
+ const locator = this._resolveIssueLocator(issueNumber);
3603
+ const num = String(locator.number || "").replace(/^#/, "");
3082
3604
  if (!/^\d+$/.test(num) || !body) return false;
3605
+ const repoRef = `${locator.owner}/${locator.repo}`;
3083
3606
  try {
3084
3607
  await this._gh(
3085
3608
  [
@@ -3087,7 +3610,7 @@ class GitHubIssuesAdapter {
3087
3610
  "comment",
3088
3611
  num,
3089
3612
  "--repo",
3090
- `${this._owner}/${this._repo}`,
3613
+ repoRef,
3091
3614
  "--body",
3092
3615
  String(body).slice(0, 65536),
3093
3616
  ],
@@ -3125,10 +3648,14 @@ class GitHubIssuesAdapter {
3125
3648
  * });
3126
3649
  */
3127
3650
  async persistSharedStateToIssue(issueNumber, sharedState) {
3128
- const num = String(issueNumber).replace(/^#/, "");
3651
+ const locator = this._resolveIssueLocator(issueNumber);
3652
+ const num = String(locator.number || "").replace(/^#/, "");
3129
3653
  if (!/^\d+$/.test(num)) {
3130
3654
  throw new Error(`Invalid issue number: ${issueNumber}`);
3131
3655
  }
3656
+ const repoRef = `${locator.owner}/${locator.repo}`;
3657
+ const issueUrl =
3658
+ locator.issueUrl || `https://github.com/${repoRef}/issues/${num}`;
3132
3659
  const normalizedState = normalizeSharedStatePayload(sharedState);
3133
3660
  if (!normalizedState) {
3134
3661
  throw new Error(`Invalid shared state payload for issue #${num}`);
@@ -3183,7 +3710,7 @@ class GitHubIssuesAdapter {
3183
3710
  "edit",
3184
3711
  num,
3185
3712
  "--repo",
3186
- `${this._owner}/${this._repo}`,
3713
+ repoRef,
3187
3714
  ];
3188
3715
 
3189
3716
  // Remove old codex labels
@@ -3210,7 +3737,7 @@ class GitHubIssuesAdapter {
3210
3737
 
3211
3738
  // 2. Create/update structured comment
3212
3739
  const commentSuccess = await attemptWithRetry(async () => {
3213
- const comments = await this._getIssueComments(num);
3740
+ const comments = await this._getIssueComments(num, { issueUrl });
3214
3741
  const stateCommentIndex = comments.findIndex((c) =>
3215
3742
  c.body?.includes("<!-- bosun-state"),
3216
3743
  );
@@ -3231,7 +3758,7 @@ ${stateJson}
3231
3758
  await this._gh(
3232
3759
  [
3233
3760
  "api",
3234
- `/repos/${this._owner}/${this._repo}/issues/comments/${commentId}`,
3761
+ `/repos/${locator.owner}/${locator.repo}/issues/comments/${commentId}`,
3235
3762
  "-X",
3236
3763
  "PATCH",
3237
3764
  "-f",
@@ -3248,7 +3775,7 @@ ${stateJson}
3248
3775
 
3249
3776
  // Invalidate the shared-state cache so the next read fetches fresh data
3250
3777
  if (commentSuccess) {
3251
- this._sharedStateCache.delete(_sharedStateCacheKey(num));
3778
+ this._sharedStateCache.delete(_sharedStateCacheKey(num, locator.repoKey));
3252
3779
  }
3253
3780
 
3254
3781
  return commentSuccess;
@@ -3271,7 +3798,7 @@ ${stateJson}
3271
3798
  */
3272
3799
  async readSharedStateFromIssue(issueNumber, cachedComments = null, options = {}) {
3273
3800
  const issueUrl = String(options?.issueUrl || "").trim();
3274
- const locator = parseIssueLocator(
3801
+ const locator = parseIssueRefLocator(
3275
3802
  issueNumber,
3276
3803
  this._owner,
3277
3804
  this._repo,
@@ -3281,6 +3808,9 @@ ${stateJson}
3281
3808
  if (!/^\d+$/.test(num)) {
3282
3809
  throw new Error(`Invalid issue number: ${issueNumber}`);
3283
3810
  }
3811
+ if (issueUrl) {
3812
+ this._rememberIssueLocator(num, issueUrl);
3813
+ }
3284
3814
 
3285
3815
  // If no pre-fetched comments, check the instance-level shared-state cache
3286
3816
  // to avoid a separate API call per issue during bulk listTasks cycles.
@@ -3370,10 +3900,12 @@ ${stateJson}
3370
3900
  * await adapter.markTaskIgnored(123, "Task requires manual security review");
3371
3901
  */
3372
3902
  async markTaskIgnored(issueNumber, reason) {
3373
- const num = String(issueNumber).replace(/^#/, "");
3903
+ const locator = this._resolveIssueLocator(issueNumber);
3904
+ const num = String(locator.number || "").replace(/^#/, "");
3374
3905
  if (!/^\d+$/.test(num)) {
3375
3906
  throw new Error(`Invalid issue number: ${issueNumber}`);
3376
3907
  }
3908
+ const repoRef = `${locator.owner}/${locator.repo}`;
3377
3909
 
3378
3910
  try {
3379
3911
  // Add codex:ignore label
@@ -3383,7 +3915,7 @@ ${stateJson}
3383
3915
  "edit",
3384
3916
  num,
3385
3917
  "--repo",
3386
- `${this._owner}/${this._repo}`,
3918
+ repoRef,
3387
3919
  "--add-label",
3388
3920
  this._codexLabels.ignore,
3389
3921
  ],
@@ -3417,10 +3949,12 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3417
3949
  * @returns {Promise<boolean>} Success status
3418
3950
  */
3419
3951
  async unmarkTaskIgnored(issueNumber) {
3420
- const num = String(issueNumber).replace(/^#/, "");
3952
+ const locator = this._resolveIssueLocator(issueNumber);
3953
+ const num = String(locator.number || "").replace(/^#/, "");
3421
3954
  if (!/^\d+$/.test(num)) {
3422
3955
  throw new Error(`Invalid issue number: ${issueNumber}`);
3423
3956
  }
3957
+ const repoRef = `${locator.owner}/${locator.repo}`;
3424
3958
 
3425
3959
  try {
3426
3960
  await this._gh(
@@ -3429,7 +3963,7 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3429
3963
  "edit",
3430
3964
  num,
3431
3965
  "--repo",
3432
- `${this._owner}/${this._repo}`,
3966
+ repoRef,
3433
3967
  "--remove-label",
3434
3968
  this._codexLabels.ignore,
3435
3969
  ],
@@ -3449,12 +3983,15 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3449
3983
  * @private
3450
3984
  */
3451
3985
  async _getIssueLabels(issueNumber) {
3986
+ const locator = this._resolveIssueLocator(issueNumber);
3987
+ const num = String(locator.number || "").replace(/^#/, "");
3988
+ const repoRef = `${locator.owner}/${locator.repo}`;
3452
3989
  const issue = await this._gh([
3453
3990
  "issue",
3454
3991
  "view",
3455
- issueNumber,
3992
+ num,
3456
3993
  "--repo",
3457
- `${this._owner}/${this._repo}`,
3994
+ repoRef,
3458
3995
  "--json",
3459
3996
  "labels",
3460
3997
  ]);
@@ -3469,12 +4006,21 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3469
4006
  */
3470
4007
  async _getIssueComments(issueNumber, options = {}) {
3471
4008
  const issueUrl = String(options?.issueUrl || "").trim();
3472
- const locator = parseIssueLocator(
4009
+ const locator = parseIssueRefLocator(
3473
4010
  issueNumber,
3474
4011
  this._owner,
3475
4012
  this._repo,
3476
4013
  issueUrl,
3477
4014
  );
4015
+ const resolvedIssueUrl =
4016
+ issueUrl ||
4017
+ `https://github.com/${locator.owner}/${locator.repo}/issues/${locator.number}`;
4018
+ if (/^\d+$/.test(locator.number)) {
4019
+ this._rememberIssueLocator(locator.number, resolvedIssueUrl);
4020
+ }
4021
+ if (this._isIssueKnownMissing(locator.number, { issueUrl: resolvedIssueUrl })) {
4022
+ return [];
4023
+ }
3478
4024
  try {
3479
4025
  const result = await this._gh([
3480
4026
  "api",
@@ -3482,11 +4028,16 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3482
4028
  "--jq",
3483
4029
  ".",
3484
4030
  ]);
4031
+ this._clearIssueMissing(locator.number, { issueUrl: resolvedIssueUrl });
3485
4032
  return Array.isArray(result) ? result : [];
3486
4033
  } catch (err) {
3487
- console.warn(
3488
- `[kanban] failed to fetch comments for ${locator.owner}/${locator.repo}#${locator.number}: ${err.message}`,
3489
- );
4034
+ if (err?.isNotFound) {
4035
+ this._markIssueMissing(locator.number, { issueUrl: resolvedIssueUrl });
4036
+ } else {
4037
+ console.warn(
4038
+ `[kanban] failed to fetch comments for ${locator.owner}/${locator.repo}#${locator.number}: ${err.message}`,
4039
+ );
4040
+ }
3490
4041
  return [];
3491
4042
  }
3492
4043
  }
@@ -3643,6 +4194,24 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3643
4194
 
3644
4195
  _normaliseIssue(issue) {
3645
4196
  if (!issue) return null;
4197
+ const fallbackNum = String(issue.number || "").trim();
4198
+ const fallbackUrl = fallbackNum
4199
+ ? `https://github.com/${this._owner}/${this._repo}/issues/${fallbackNum}`
4200
+ : null;
4201
+ const locator = parseIssueRefLocator(
4202
+ fallbackNum,
4203
+ this._owner,
4204
+ this._repo,
4205
+ issue.url || fallbackUrl || "",
4206
+ );
4207
+ const issueNum = /^\d+$/.test(locator.number)
4208
+ ? locator.number
4209
+ : fallbackNum;
4210
+ const issueUrl = issue.url || fallbackUrl || null;
4211
+ const projectId = `${locator.owner}/${locator.repo}`;
4212
+ if (issueNum && issueUrl) {
4213
+ this._rememberIssueLocator(issueNum, issueUrl);
4214
+ }
3646
4215
  const labels = (issue.labels || []).map((l) =>
3647
4216
  typeof l === "string" ? l : l.name,
3648
4217
  );
@@ -3695,14 +4264,14 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3695
4264
  createdAt: comment.createdAt,
3696
4265
  }),
3697
4266
  );
3698
- const localAttachments = listTaskAttachments(issue.number, "github");
4267
+ const localAttachments = listTaskAttachments(issueNum, "github");
3699
4268
  const mergedAttachments = mergeTaskAttachments(
3700
4269
  mergeTaskAttachments(descriptionAttachments, commentAttachments),
3701
4270
  localAttachments,
3702
4271
  );
3703
4272
 
3704
4273
  return {
3705
- id: String(issue.number || ""),
4274
+ id: String(issueNum || ""),
3706
4275
  title: issue.title || "",
3707
4276
  description: issue.body || "",
3708
4277
  status,
@@ -3714,7 +4283,7 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3714
4283
  : null,
3715
4284
  tags,
3716
4285
  draft: labelSet.has("draft") || status === "draft",
3717
- projectId: `${this._owner}/${this._repo}`,
4286
+ projectId,
3718
4287
  baseBranch,
3719
4288
  branchName: branchMatch?.[1] || null,
3720
4289
  prNumber: prMatch?.[1] || null,
@@ -3722,14 +4291,14 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
3722
4291
  comments,
3723
4292
  meta: {
3724
4293
  ...issue,
3725
- task_url: issue.url || null,
4294
+ task_url: issueUrl,
3726
4295
  tags,
3727
4296
  comments,
3728
4297
  attachments: mergedAttachments,
3729
4298
  ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
3730
4299
  codex: codexMeta,
3731
4300
  },
3732
- taskUrl: issue.url || null,
4301
+ taskUrl: issueUrl,
3733
4302
  backend: "github",
3734
4303
  };
3735
4304
  }
@@ -5225,6 +5794,10 @@ export function setKanbanBackend(name) {
5225
5794
  `${TAG} unknown kanban backend: "${name}". Valid: ${Object.keys(ADAPTERS).join(", ")}`,
5226
5795
  );
5227
5796
  }
5797
+ if (normalised === "github") {
5798
+ // Avoid carrying stale GH cache state across explicit backend switches.
5799
+ clearGitHubAdapterCacheBuckets();
5800
+ }
5228
5801
  activeBackendName = normalised;
5229
5802
  activeAdapter = null; // Force re-create on next getKanbanAdapter()
5230
5803
  console.log(`${TAG} switched to ${normalised} backend`);