SLOPSHOPPER

branch8-jira-mod

Branch8 Jira workflow extension: shows the current ticket above the prompt, lists your open tickets in /jira-todo, reminds Claude at start / PR / merge points…

panebandguardcommandtoastprompt
v0.15.1no licenseupdated 2026-10-22branch8/claude-plugins/plugins/branch8-jira-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · branch8-jira-mod
│ ┃ Jira ✕ › fix the failing auth test and add an audit log call │ ┃ 🔗 Jira connecting… │ ┃ 📁 Folder app → no Jira project recorded ⏺ Read(src/auth.ts) │ ┃ yet ⎿ Read 6 lines │ ┃ set: /jira-board ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ⌨ Keys │ ⎿ 3 pass, 1 fail │ ┃ │ Ctrl+X Tab: into pane · Esc: back │ │ ┃ │ ↑↓ move · Enter menu/fold · o open Jira │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ⚙ Settings o: 🔗 Open in Jira ✻ Worked for 42s · done 4:20 PM │ ┃ ────────────────────────────────────────── │ ┃ ⟳ Refresh › /jira-todo │ ┃ Jira is not connected (no Atlassian MCP). ⎿ branch8-jira-mod: Jira is not connected (no Atlassian MCP). Run │ ┃ Run /jira-check │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Jira
🔗 Jira connecting… 📁 Folder app → no Jira project recorded yet set: /jira-board ╭──────────────────────────────────────────────────────────╮ │ ⌨ Keys │ │ Ctrl+X Tab: into pane · Esc: back │ │ ↑↓ move · Enter menu/fold · o open Jira │ ╰──────────────────────────────────────────────────────────╯ ⚙ Settings o: 🔗 Open in Jira ────────────────────────────────────────── ⟳ Refresh Jira is not connected (no Atlassian MCP). Run /jira-check
README

Branch8 claude-plugins

全公司 Claude 技能的單一來源。改這個 repo,全公司的 Claude 就跟著變。

同事不需要安裝任何東西,也不需要手動更新。

⚠️ 可見性要求取決於你走哪條發送管道,兩者相反:

管道可見性要求認證方式
managed-settings.json(Claude Code CLI / IDE)無限制,public 可以同事自己的 git credential helper
Organization settings › Plugins(chat / Cowork)必須 private / internalClaude GitHub App,不碰同事憑證

這個 repo 目前是 public,因為只走 Claude Code 那條。理由見 為什麼目前是 public。


目錄

  1. 目前發送什麼
  2. 一分鐘搞懂
  3. 目錄結構
  4. 四支腳本在做什麼
  5. 日常操作
  6. 全自動模式
  7. 首次設定
  8. 開發自訂 plugin
  9. 第三方 plugin 怎麼管
  10. 不依賴任何個人
  11. 排查
  12. 為什麼目前是 public
  13. 限制與已知問題

目前發送什麼

七個 plugin。六個直接用 Anthropic 官方來源,只有一個需要 vendoring。

Plugin來源為什麼
atlassian官方Jira / Confluence。Atlassian 官方 MCP,官方已 pin sha
superpowers官方TDD、除錯、規劃流程
mattpocock-skills官方TypeScript、code review、domain modelling
skill-creator官方寫 / 改 / 測 skill
frontend-design官方前端視覺設計方向
github官方GitHub MCP
ui-ux-pro-maxvendoredUI/UX 設計資料庫。官方沒有上架,所以走 vendoring

判斷方式不是「信不信任作者」,而是官方有沒有上架。官方上架的 plugin, Anthropic 的 CI 已經幫它釘在特定 commit 上(外部 repo 的 source 都帶 sha), 自己再 vendoring 一次是重做別人做過的事。查法:

python3 - <<'EOF'
import json
d=json.load(open('/home/glenn/.claude/plugins/marketplaces/claude-plugins-official/.claude-plugin/marketplace.json'))
print(sorted(p['name'] for p in d['plugins']))
EOF

代價要知道:走官方來源,等於把「何時更新」交給 Anthropic——它移動 pin, 同事就跟著更新,公司這邊沒有審查點。只有 ui-ux-pro-max 這種沒上架的, 才會經過本 repo 的 PR 流程。這是決策 3 刻意的取捨,見 CLAUDE.md。


一分鐘搞懂

你改 repo  →  開 PR  →  合併  →  自動同步  →  全公司更新

repo 裡有兩種東西:

  • plugins/ — 我們自己寫的。想改就直接改。
  • vendor/ — 從別人的 repo 複製過來的。不要手改,由腳本產生。

為什麼第三方要複製一份而不是直接連過去?因為 plugin 可以在同事電腦上執行任意程式碼, 而且第三方 plugin 不經 Anthropic 審核。複製一份並鎖定版本之後,作者半夜改東西不會 直接影響你們——會先變成一個 PR 讓你看過再決定。

(這在軟體業叫 vendoring,Go 的 vendor/、npm 的 lockfile 都是同一個概念。)

東西怎麼到同事手上

發送分兩個管道,都指向這個 repo:

管道涵蓋設定位置
組織 marketplacechat(網頁、手機、桌面 App 的 Chat 分頁)、CoworkOrganization settings › Plugins
managed settingsClaude Code(CLI、IDE)Admin Settings › Claude Code › Managed settings

兩處都是一次性設定。設完之後日常只動 repo,兩邊自動跟上。


目錄結構

.claude-plugin/
  marketplace.json          發送清單。只放「官方沒上架」的 plugin
managed-settings.json       貼到 Admin Settings › Claude Code › Managed settings 的內容
                            官方那 6 個是在這裡用 @claude-plugins-official 引用
plugins/
  branch8-jira-check/       SessionStart hook:顯示當下的 Claude 帳號(個人帳號碰公司專案
                            會警告)、檢查 Jira 連線與 Jira 帳號、每個專案問一次對應哪個
                            Jira 板並記在本機 ~/.claude/branch8-jira/projects/、新任務先問
                            有沒有 Jira 單,沒有就在該板建立。另有 /jira-check、/jira-board、
                            /jira-migrate。
                            Jira 專案是記在「資料夾」上,子資料夾繼承最近的上層記錄,
                            所以單一 repo、多 repo 的大資料夾、沒有 git 的 PM 資料夾都適用;
                            在家目錄 / 桌面開 session 會建議開 ~/Branch8/<KEY> 並 /cd 過去,
                            可順便把相關檔案複製過去(migrate)。
                            Jira 專案有哪些 repo 記在 Jira 裡的「設定單」(label claude-config)
                            ——不放本 repo,因為專案名常含客戶名稱。
                            公司設定值在 config.env;整個關掉:設 BRANCH8_JIRA_CHECK=off
                            skill jira-workflow:自己判斷要不要問單號(訊息、branch、本 session、
                            搜尋指派給我的未完成單)、要不要開新單,並在開始動手 / 開 PR / 合併時
                            從「這張單現在可走的轉換」推薦下一個狀態;每次寫入 Jira 都先用卡片問
  branch8-jira-mod/         可選擴充(mod,需 Claude Code v2.1.287+,桌面版 v2.1.286+):
                            輸入框上方顯示目前的單、/jira-todo 列出我的待辦可點選、
                            在三個時機點提醒 Claude、把規則放進 system prompt。
                            /jira-todo 面板最上方「⚙ 語言」可切換 繁中/简中/English/自動(自動=跟著
                            Claude Code 的 language 設定),選擇會記住;每張單後的「開啟」用瀏覽器開該單。
                            面板最上方顯示「📁 資料夾 ↔ Jira 專案」(讀 branch8-jira-check 的資料夾紀錄,
                            /jira-board 修改);「⚙ 範圍」切換只看本資料夾的專案或全部專案(預設:有對應就只看
                            本資料夾);全部專案時標示「本資料夾的專案/其他專案」。選其他專案的單會先確認,
                            並建議到該專案的資料夾開新 session(cd <資料夾> && claude);Claude 要做其他專案
                            的單時也會先用卡片問。專案與狀態都可以收折。
                            每張單前有類型圖示(🐞 錯誤、✨ 故事/功能、✅ 任務、⚡ 大型工作、🔹 子任務、🔥 HOTFIX、
                            ❓ 詢問),後面的 🔗 表示可開啟;在單上按 Enter 跳出選單(設為目前的單並處理/
                            開啟 Jira/取消;目前的單則是「取消選定」),游標在單上按 o 直接開 Jira。
                            面板最上方的「⏹ 取消選定」或 Claude 的 clear_ticket 讓 session 不綁任何單。
                            用 Branch8 組織帳號(claude.ai 登入)開 session 時面板會自動打開,不搶游標;
                            「⚙ 開場自動開啟」可關閉。終端機寬度不足 144 欄時面板會等到視窗拉寬才出現。
                            /jira-todo 操作:Ctrl+X Tab 進面板(再按一次可切換)、↑↓ 移動、
                            Enter 設為目前的單(在專案列上是收合/展開)、點 ↗ 在瀏覽器開該單、
                            Esc 回輸入框(不會關面板);清單依「專案 → 狀態」分層;
                            「✕ 關閉」按鈕或再打一次 /jira-todo 關閉,關閉後清單保留,
                            輸入框上方顯示待辦數量,重開立即顯示
                            已加入 managed-settings(全 team 自動安裝);需 Claude Code v2.1.287+
                            沒裝也不影響 branch8-jira-check 與 skill
template/
  plugin-template/          開發新 plugin 的起點,複製到 plugins/ 再改
                            沒列在 marketplace.json,所以永遠不會被發送
vendor/
  ui-ux-pro-max/            第三方,腳本產生,不要手改
vendor.lock.json            記錄每個第三方鎖在哪個 commit
scripts/                    四支維護腳本(見下節)
.github/workflows/          三個自動化流程
CLAUDE.md                   設計決策與待辦,Claude Code 會自動載入
CODEOWNERS                  審查者指派(用 team,不要用個人)

兩份清單,不要搞混:

  • marketplace.json — 只有 vendored 的東西。官方 plugin 不要寫進來。
  • managed-settings.json — 全部七個都在這,官方的標 @claude-plugins-official, vendored 的標 @branch8。

四支腳本在做什麼

全部只用 Python 標準函式庫,不需要安裝任何東西。

vendor.py — 把第三方抓下來

讀 vendor.lock.json,按裡面寫的網址和版本號去 GitHub 抓,複製到 vendor/, 清掉不需要的東西(.git、.github、node_modules),最後寫一張 VENDORED.md 記錄來源。

python3 scripts/vendor.py --check          # 只看上游有沒有新版,不動檔案
python3 scripts/vendor.py                  # 真的抓
python3 scripts/vendor.py --only superpowers

--check 會印出 compare 連結,點下去就能看上游改了什麼。

規定:ref 必須是完整 40 碼 commit SHA,不能寫 main。腳本會拒絕分支名稱—— 分支內容隨時會變,等於沒鎖。

auto_update.py — 機器人的大腦

把「檢查上游 → 重新抓 → 改版本號 → 寫好 PR 說明」一次做完。每週自動跑, 正常情況你不會手動碰它。

python3 scripts/auto_update.py --dry-run   # 預覽機器人會做什麼

跟 vendor.py 的差別:vendor.py 是照 lock 檔抓東西的工人,auto_update.py 是決定「該抓什麼、版本怎麼改、PR 要寫什麼」的管理者。

validate.py — 上線前的健康檢查

擋掉會讓同步失敗的錯誤:格式、保留名稱、資料夾存不存在、source 是不是相對路徑、 plugin.json 名字對不對得上、版本格式、vendored 目錄有沒有登記在 lock 檔。

python3 scripts/validate.py

為什麼需要:同步失敗時 plugin 可能從所有同事帳號上暫時消失,而且修好之後 安裝偏好有可能被重設。本機花兩秒檢查比事後收拾便宜太多。

check_version_bump.py — 防止忘記改版本號

比對這個 PR 動到哪些 plugin,檢查版本號有沒有跟著改。CI 會自動跑, 加 --fix 會直接幫你改好。

為什麼需要:後台判斷「要不要同步」是看版本號有沒有變,不是看你改了什麼檔案。 忘記改版本號的話,PR 會安靜地合併,然後什麼都不會發生——你以為部署了,其實沒有。


日常操作

A. 改我們自己的 plugin

# 1. 改 plugins/internal-review/ 底下的檔案
# 2. 改版本號(忘了也沒關係,CI 會幫你補)
python3 scripts/validate.py
# 3. 開 PR,合併

B. 更新第三方(通常不用手動,機器人會開 PR)

python3 scripts/vendor.py --check    # 看上游動靜
# 點開 compare 連結,確認可以接受
# 把 vendor.lock.json 的 ref 換成新 SHA
python3 scripts/vendor.py
git diff vendor/                     # 這份 diff 就是你的 review
python3 scripts/validate.py

C. 新增第三方

# 1. vendor.lock.json 的 sources 加一筆:
#    { "dest": "名字", "repo": "https://github.com/xxx/yyy.git",
#      "ref": "完整 40 碼 SHA", "subdir": ".", "license": "MIT" }
python3 scripts/vendor.py --only 名字
# 2. 確認 vendor/名字/ 底下有 plugin.json
#    沒有的話代表根目錄在子資料夾,回去改 subdir 重跑
# 3. marketplace.json 加一筆 { "name": "名字", "source": "./vendor/名字" }
python3 scripts/validate.py

⚠️ 新增 plugin 時,Claude Code 那份 managed settings 也要加一行 enabledPlugins。 這是唯一需要同時動兩邊的情況。

D. 移除

從 marketplace.json 拿掉那筆、刪資料夾(第三方的話連 lock 檔那筆也刪),開 PR。


全自動模式

每週一 09:00(台北時間)
  ↓  機器人檢查每個第三方的上游
  ↓  有更新:重新抓、改版本號、跑檢查
  ↓  自動開 PR,附上游 diff 連結和審查重點
  ↓  你看過、按 merge
  ↓  全公司更新

沒有更新就不會開 PR,信箱不會被洗。

PR 長什麼樣

## superpowers

- Upstream diff: https://github.com/obra/superpowers/compare/26d56a...29d3c7
- Commit: `26d56a78b0a9` -> `29d3c7555b2e`
- Version: `1.2.3` -> `1.2.4` (local bump)
- Changes: 4 files changed, 9 insertions(+), 3 deletions(-)

⚠️ 這次更新動到了可執行檔或 hook,請逐行看

**Executable or config files in this update:**
- `vendor/superpowers/scripts/install.sh`

最後那段是重點:上游偷偷多一支 shell script,或動到 hook、MCP 設定,PR 會直接標出來。

PR 描述是固定模板拼出來的,沒有 LLM 參與。它不會告訴你上游改了什麼, 只告訴你哪裡改了、去哪看。(為什麼不用 LLM 寫摘要,見 CLAUDE.md 決策 5。)

你在 PR 上可以做的三件事

想做怎麼做
接受直接 merge
這版不要直接 close,下週會重開
這版永遠不要手動把 lock 檔的 ref 鎖在最後一個好的 SHA,並留言說明

想立刻檢查

Actions 分頁 → vendor-update → Run workflow。可以只跑其中一個 plugin。


首次設定

第一步:GitHub

  1. Settings › Actions › General › Workflow permissions
  2. 選 Read and write permissions
  3. 勾 Allow GitHub Actions to create and approve pull requests
  4. 不開這兩個,機器人開 PR 會失敗
  5. 建兩個 label:vendor、automation-failure
  6. 把 CODEOWNERS 裡的 team handle 換成真實的
  7. Settings › Branches 加保護規則:要求 PR、要求 Code Owner 審查

第二步:chat / Cowork(Organization settings › Plugins)

需要 Owner 或 Primary Owner 權限。

  1. 確認組織已開 Code execution and file creation、Skills、Cowork
  2. 確認 Claude 的 GitHub App 已裝在這個 repo(要裝在 organization 層級)
  3. Add plugin → GitHub → 填 branch8/claude-plugins
  4. 右上角選單 → 開啟 Sync automatically
  5. 開這個開關的人需要對 repo 有 GitHub admin 權限(要建 webhook)
  6. 幫每個 plugin 選安裝偏好:
選項效果適合
Required ← 目前這七個都用這個自動裝並啟用,不能移除團隊必要工具、公司規範類
Installed by default自動裝,可自行移除選配、好用但不強制的
Available for install出現在目錄,自己裝實驗中的
Not available完全隱藏準備中或已淘汰

目前發送的七個全部是團隊必要,所以一律 Required。之後加選配工具時 再用 Installed by default——把兩者混在同一個等級,會讓「必要」失去意義。

第三步:Claude Code(Admin Settings › Claude Code › Managed settings)

直接貼 managed-settings.json 的內容(那個檔就是這一步的單一來源, 改發送清單時改它,不要改這裡的複製品):

{
  "extraKnownMarketplaces": {
    "branch8": {
      "source": { "source": "github", "repo": "branch8/claude-plugins" },
      "autoUpdate": true
    }
  },
  "enabledPlugins": {
    "atlassian@claude-plugins-official": true,
    "superpowers@claude-plugins-official": true,
    "mattpocock-skills@claude-plugins-official": true,
    "skill-creator@claude-plugins-official": true,
    "frontend-design@claude-plugins-official": true,
    "github@claude-plugins-official": true,
    "ui-ux-pro-max@branch8": true,
    "branch8-jira-check@branch8": true,
    "cc-plugin-you-should-know@builtin": true
  }
}

三個欄位回答三個問題:

  • extraKnownMarketplaces — 去哪拿。只放自己的 repo。 官方的 claude-plugins-official 是內建的,不需要註冊(已實測: known_marketplaces.json 裡它是自動出現的,沒有人手動加過)。
  • enabledPlugins — 拿哪些。格式是 plugin名字@來源名字。 官方上架的一律標 @claude-plugins-official,不要 vendoring 進本 repo。 @builtin 是 Claude Code 本體內建的 plugin(不用下載、沒有 marketplace), 例如 cc-plugin-you-should-know:側邊 agent 在長任務中提醒你可能漏掉的事。 它預設是關的,這裡設 true 就等於幫全公司跑 /plugin enable。 注意 managed 層優先於個人設定:它自己提示的 /plugin disable cc-plugin-you-should-know@builtin 對同事不會生效, 真的要關只能從這裡拿掉(或改成 false 強制全公司關)。 它是否出現還取決於 Anthropic 端的灰度開關,HIPAA / ZDR 組織也拿不到, 設了 true 不保證每個人都看得到。
  • (沒設 strictKnownMarketplaces)— 同事仍可自由安裝別的 plugin。 公司設定只管「至少要有什麼」。

⚠️ branch8 這個名字出現在三個地方且必須完全一致: marketplace.json 的 name、managed-settings.json 的兩處。

兩邊的差別

chat 那邊是完全無感自動裝好;Claude Code 那邊會跳一次資料夾信任確認才裝。 這是刻意的,因為 plugin 可以在本機執行程式碼。

先設一邊、跑順了再補另一邊也完全沒問題,兩邊沒有依賴關係。


開發自訂 plugin

從 template/plugin-template/ 複製,不要從零開始:

cp -r template/plugin-template plugins/你的plugin名

然後改三個地方——這三處的名字必須完全一致:

檔案改什麼
plugins/你的plugin名/.claude-plugin/plugin.jsonname、version、description
.claude-plugin/marketplace.json加一筆 { "name": "...", "source": "./plugins/..." }
managed-settings.jsonenabledPlugins 加一行 "你的plugin名@branch8": true

python3 scripts/validate.py → 開 PR → 合併 → 全公司拿到。

細節(description 怎麼寫才會觸發、複製後的完整 checklist、validate 會擋什麼) 見 template/README.md。

一個 plugin 可以同時裝很多東西

不用為了 skill 和 command 開兩個 plugin:

plugins/你的plugin名/
  .claude-plugin/plugin.json
  skills/xxx/SKILL.md        技能
  commands/yyy.md            slash command
  agents/zzz.md              subagent
  .mcp.json                  MCP server

用不到的目錄直接刪掉。validate.py 只要求至少有其中一種。

MCP 的審查標準要分兩級:type: "http" / "sse" 只是一個 URL,風險低; stdio 型的會在每位同事的機器上執行程式,這跟「第三方 plugin 能執行任意程式碼」 是同一類風險,要用跟 vendoring 同等的標準審。

為什麼放在這個 repo,而不是各自開 repo

自家 plugin 一律用相對路徑放在 plugins/ 底下(官方也是這個建議: *"place the plugin folders inside the marketplace repository and reference them with a relative path"*)。理由是維護成本與失敗成本都最低——一個 PR 看得到全部改動, 不需要在兩個 repo 之間同步版本。

不要用 git submodule。 官方文件對組織同步的 submodule 支援完全沒有敘述。 Claude Code 那側的 clone 確實帶 --recurse-submodules,但組織同步走的是 「Claude GitHub App 打包每個 plugin」那條路,submodule 會不會被打包進去無從得知。 用沒有文件保證的行為承載全公司分發,壞掉的時候無法歸因。

也不要對自家 code 用 vendor/。 vendoring 存在的理由是「上游不受我控制, 所以要 pin 住 SHA 並逐行看 diff」(見決策 1)。自家 repo 沒有這個問題,套用只會 讓每改一行都要走兩次 PR——摩擦大到會讓人乾脆繞過流程。vendor/ 只留給真正的第三方。

什麼時候才該把某個 plugin 拆出去

用 github source 加 sha(決策 3 的「第 2 級」,目前腳本還不支援,要先實作)。 觸發條件是下列之一,不是「plugin 變多了」:

  1. 那個 plugin 開始有 repo 外的 contributor
  2. 它需要自己的 CI / release cycle,跟 marketplace 的節奏脫鉤
  3. 它大到讓 marketplace repo 的 clone 明顯變慢

而且是拆那一個,不是全部拆。

repo 是 public,所以有內容邊界

放進 plugins/ 的東西就是公開的。含公司業務邏輯的 plugin(談判立場、客戶專屬流程、 內部定價規則)不能走這條路。

那種的路徑是「同 owner 的 private repo + github source」——組織同步官方明文支援 (*"A github.com source that shares the marketplace repository's owner"*)。 但 Claude Code 那側仍需同事的 git 憑證,因為要取得 plugin 內容就得 clone 那個 private repo。

plugin 性質放哪Claude Code 端
通用方法論、工具、設計規範plugins/(public)零設定
含公司業務邏輯同 owner 的 private repo + github source同事要 gh auth login
真正的第三方vendor/ + pin SHA零設定

第三方 plugin 怎麼管

判斷標準是這個 plugin 能做什麼,不是信不信任作者。

等級做法適用
1直接用官方 marketplace純 prompt,沒有 hooks / MCP / 腳本
2marketplace.json 用 github source 加 sha想控版本但不需要看 diff
3完整 vendoring(本 repo 這套)有 hooks / MCP / 可執行檔,或壞掉會擋住很多人

已經上架官方的,直接用官方版。 Anthropic 的 marketplace 會用 CI 把每個核准的 plugin 釘在特定 commit,pin 只在重新審查後才移動——這正是 vendoring 想達到的效果。 自己再 vendoring 一次是重做別人做過的事,而且會慢慢落後於上游修正。

絕對不要把第三方 marketplace 直接寫進 extraKnownMarketplaces:

// ❌ 不要這樣
"extraKnownMarketplaces": {
  "superpowers-mp": { "source": {...obra/superpowers-marketplace...}, "autoUpdate": true }
}

上游作者 push 什麼,同事立刻拿到,中間沒有 PR、沒有你、什麼都沒有。 等於把整套審查機制繞過去。

「信任這個作者」和「信任他未來每一次的 push」是兩件事。

同名不同來源會裝兩份

如果同事之前自己 /plugin marketplace add obra/superpowers-marketplace, 那是 superpowers@superpowers-marketplace,跟 superpowers@claude-plugins-official 對系統來說是兩個完全不同的東西,兩份都會載入。

目前沒有優先權或覆蓋機制,無法指定「優先用內部這份」。

三個緩解做法:

  1. 在團隊文件裡明講哪個 plugin 從哪個來源出
  2. 上線前請大家開 /plugin 的 Installed 分頁清一次重複的
  3. 自己寫的 plugin 用公司前綴命名(acme-review 而非 code-review)

同名同來源則沒問題

管理層級的設定優先權最高,覆蓋專案和個人設定。同事那份會被接管, 以 managed scope 顯示且無法修改。順序不是「誰先裝誰贏」,而是管理設定永遠贏。


不依賴任何個人

已經處理好的

項目做法
執行環境GitHub Actions,跑在 GitHub 的機器上,沒有任何人的筆電參與
認證內建 GITHUB_TOKEN。沒用任何人的 PAT——會過期,也會跟著離職的人消失
觸發cron 排程,不需要有人記得去跑
審查者CODEOWNERS 指到 team 而非個人
排程被停用每月 heartbeat commit 維持 repo 活躍度
Workflow 壞掉自動開 issue,不會靜悄悄
沒人審 PR超過 14 天自動開 issue 提醒

你要自己確認的

  1. Repo 屬於 GitHub organization,不能放在個人帳號底下
  2. Claude 那邊至少兩個 Owner
  3. Claude 的 GitHub App 裝在 organization 層級
  4. CODEOWNERS 裡每個 team 至少兩個人
  5. 開啟自動同步的人離職後會怎樣? 見下方「未確認的事」

誰擁有這個 repo

  • 主要負責:@Branch8/platform-team
  • 第三方程式碼審查:@Branch8/security
  • Claude 後台 Owner:(至少列兩個人)

排查

改完合併了,但同事沒拿到新版 八成是忘了 bump 版本號。直接 push 到預設分支也不會觸發,一定要走 PR。 同步最長可能跑 30 分鐘。

同事一直停在舊版,/plugin 顯示 1 marketplace could not be refreshed: branch8 先在終端機跑 claude plugin marketplace update branch8 看錯誤。2026-10-21 實際碰到的是 網路慢:同事連 GitHub 只有 30 KiB/s,clone 超過 Claude Code 預設的 120 秒逾時就放棄, 而且殘留的 git clone 會繼續空轉、占住暫存資料夾 marketplaces/branch8..clone,之後每次更新都撞在一起失敗 (錯誤變成 .git/: No such file or directory)。處理:

ps aux | grep -i "[g]it.*claude-plugins"      # 有殘留就 kill
rm -rf ~/.claude/plugins/marketplaces/branch8..clone
CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS=600000 claude plugin marketplace update branch8

managed-settings.json 已把這個逾時設成 10 分鐘(env),套用到後台後同事不用再手動設。 用 ccs 的人,~/.ccs/instances/<名稱>/plugins 會同步到 ~/.claude/plugins,錯誤裡兩種路徑都會出現,這是正常的。

同步失敗,plugin 從大家帳號上消失 修好問題重新同步。修好之後回去確認安裝偏好還在——失敗可能把偏好重設掉。

後台看不到我的 repo Claude GitHub App 沒裝在那個 repo 上。

開自動同步時說 Cannot access repository 你對 repo 沒有 GitHub admin 權限,或 Claude GitHub App 的 Webhooks 權限還沒批准。

vendor.py 抓下來找不到 plugin.json plugin 根目錄不在最上層。去 GitHub 看實際結構,把 subdir 設對。

機器人開的 PR 上沒有綠色勾勾 正常。GitHub 的安全設計讓 GITHUB_TOKEN 開的 PR 不觸發其他 workflow, 所以檢查是在開 PR 之前跑掉的,過不了就不會有 PR。


為什麼目前是 public

2026-08-26 實測後的決定。 官方對兩條管道的要求是相反的:

  • managed-settings.json 的 extraKnownMarketplaces(Claude Code)——官方文件沒有 可見性要求,並明言支援 private repo。
  • Organization settings › Plugins(chat / Cowork)——官方原文: "Your repository must be private or internal—public repos aren't allowed for organization marketplaces."

只要還沒設 chat 那一側,public 就沒有壞處,而且一次解掉三個閘門:

閘門private 時public 後
repo 讀取權每個同事都要被加進 repo不需要
git 憑證每人要 gh auth login,漏掉的人只看到「contact your admin」不需要
背景自動更新必定失敗(見下)正常運作

private + Claude Code 的隱藏地雷

Claude Code 的背景自動更新會強制清空 credential helper 再 git fetch:

let allow = gate("tengu_plugin_autoupdate_allow_credential_helper", false);  // 預設 false
refresh(mp, { disableCredentialHelper: !allow });   // → ["-c", "credential.helper="]

官方文件也承認:*"the background refresh disables git credential helpers for its git pull, so the pull can't authenticate to private repositories over HTTPS even when a helper is configured."*

也就是說 private repo 上的 autoUpdate: true 是無效設定——初次安裝和手動 /plugin marketplace update 會成功(那條路徑有用 helper),但之後永遠不會自動更新。

之後要上 chat / Cowork 怎麼辦

兩個選項,屆時再決定:

  1. 改回 private,並讓每位同事跑一次 gh auth login(接受背景更新失效,靠手動 /plugin marketplace update branch8)
  2. 另開一個 private repo 專供組織 marketplace,本 repo 繼續服務 Claude Code

改回 private 只要 gh repo edit branch8/claude-plugins --visibility private, 但公開過的內容收不回(可能已被 fork 或索引)——所以這裡不放任何機密。 目前 repo 內容:marketplace.json、MIT 授權的 vendored 程式碼、維護腳本、本文件。


限制與已知問題

項目數字
repo 可見性Claude Code 端無限制;組織 marketplace(chat / Cowork)只能 private / internal
每個 marketplace 的 plugin 上限500
plugin 名稱小寫加連字號,64 字以內
同步逾時30 分鐘

版本無法逐一釘選。 plugin install 目前沒有 --version 參數,而把整個 marketplace 釘在某個 git ref 會凍結該 marketplace 裡的所有 plugin。 對官方 marketplace 只有「跟或不跟」,沒有中間地帶。

依部門分流是 Enterprise 功能。 Team 方案只能設全組織統一的偏好。

未確認的事

開啟 Sync automatically 的人離職後,同步會不會斷掉? 建立 webhook 用的是 那個人對 repo 的 GitHub admin 權限。權限消失後會怎樣沒有明確答案。

建議:設定完成後合併一個小改動確認同步正常,並把「重新開關一次自動同步」 寫進離職 checklist。有 service account 的話用它來開更乾淨。

Claude Code 是否真的會自動安裝。 文件說成員信任 repo 資料夾後會自動安裝, 但曾有回報指出 CLI 使用者仍需手動跑一次 /plugin install。 上線前先在一台測試機驗證。

Source 3 files
hooks/register.tsx 1202 lines
1// Engine wiring for the Branch8 Jira mod. The judgment (which ticket, which
2// transition) belongs to the jira-workflow skill; this module only keeps the
3// current ticket, reminds Claude at the three points, puts the rules in the
4// system prompt and draws the ticket above the prompt. It never writes Jira.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { Ticket, Transition } from '../types'
9import type { FolderRecord, Lang, LangPref, Scope } from './logic'
10import {
11  STRINGS,
12  ancestorDirs,
13  applyScope,
14  effectiveScope,
15  isOtherProject,
16  parseRecord,
17  projectOf,
18  recordFileName,
19  suggestedFolder,
20  typeIcon,
21  focusedTicket,
22  isOrgAccount,
23  touchesFolderRecords,
24  focusOrder,
25  stepFocus,
26  isArrowScroll,
27  syncTicket,
28  pickNote,
29  manifestVersion,
30  isNewer,
31  updateOutcome,
32  parseTransitions,
33  moveNote,
34  claudeAccount,
35  parseJiraUser,
36  jiraWarning,
37  leftFrom,
38  settingsValues,
39  menuValues,
40  todoJql,
41  nextRange,
42  parseIssuePage,
43  DEFAULT_RANGE,
44  TODO_FIELDS,
45  ISSUE_FIELDS,
46  detectNode,
47  bandText,
48  formatBand,
49  groupTodos,
50  issueUrl,
51  nextLangPref,
52  resolveLang,
53  statusTone,
54  parseIssueList,
55  parseIssueStatus,
56  reminderFor,
57  rulesSection,
58  todoRow,
59} from './logic'
60
61const P = 'branch8-jira-mod'
62const ticketAtom = atom({ plugin: 'branch8-jira-mod', key: 'ticket' } as const, null)
63const askedAtom = atom({ plugin: 'branch8-jira-mod', key: 'asked' } as const, [])
64// The ticket whose unknown status was asked of Jira once; the band never polls.
65const statusAskedAtom = atom({ plugin: 'branch8-jira-mod', key: 'statusAskedFor' } as const, null)
66const todosAtom = atom({ plugin: 'branch8-jira-mod', key: 'todos' } as const, {
67  items: [],
68  error: null,
69  isLoading: false,
70  isLoaded: false,
71  next: null,
72})
73// Only tickets updated within this many days (0: any age); kept in $.store.
74const rangeAtom = atom({ plugin: 'branch8-jira-mod', key: 'range' } as const, DEFAULT_RANGE)
75// Whether the /jira-todo pane is open. Closed works as collapsed: the list
76// stays here, the band shows its count, /jira-todo reopens it at once.
77const todoOpenAtom = atom({ plugin: 'branch8-jira-mod', key: 'todoOpen' } as const, false)
78// Language of everything the mod draws: the pane's button picks it, kept in
79// $.store across sessions; "auto" follows Claude Code's language setting.
80const langPrefAtom = atom({ plugin: 'branch8-jira-mod', key: 'langPref' } as const, 'auto')
81const langAtom = atom({ plugin: 'branch8-jira-mod', key: 'lang' } as const, 'en')
82// Projects folded in the /jira-todo tree.
83const todoFoldedAtom = atom({ plugin: 'branch8-jira-mod', key: 'todoFolded' } as const, [])
84// Statuses folded in the tree, as "<PROJECT>/<status>".
85const statusFoldedAtom = atom({ plugin: 'branch8-jira-mod', key: 'statusFolded' } as const, [])
86// Scope of the list: "auto" (this folder's project when it has one), "folder" or "all".
87const scopePrefAtom = atom({ plugin: 'branch8-jira-mod', key: 'scopePref' } as const, 'auto')
88// This session's folder and the Jira project branch8-jira-check recorded for it.
89const folderAtom = atom({ plugin: 'branch8-jira-mod', key: 'folder' } as const, { dir: '', project: null, name: '' })
90// Open the pane by itself at session start on the organisation account.
91const autoOpenAtom = atom({ plugin: 'branch8-jira-mod', key: 'autoOpen' } as const, true)
92// Signed in to the Branch8 organisation: hints about /jira-todo while the pane is closed.
93const orgAtom = atom({ plugin: 'branch8-jira-mod', key: 'isOrg' } as const, false)
94// The row menu open under one ticket (Enter on a row), if any.
95const menuAtom = atom({ plugin: 'branch8-jira-mod', key: 'menu' } as const, null)
96// The settings popup under the toolbar's ⚙ button: null when closed.
97// The account block at the top of the pane.
98const accountAtom = atom({ plugin: 'branch8-jira-mod', key: 'account' } as const, {
99  claude: null,
100  jira: null,
101  isJiraChecked: false,
102})
103const settingsAtom = atom({ plugin: 'branch8-jira-mod', key: 'settings' } as const, null)
104// The ticket row the pane's focus ring is on, for the "o" hotkey.
105const focusedAtom = atom({ plugin: 'branch8-jira-mod', key: 'focusedKey' } as const, null)
106// The pane element the ring is on, of any kind: where the arrows step from.
107const ringAtom = atom({ plugin: 'branch8-jira-mod', key: 'ring' } as const, null)
108// This mod's version, from its plugin.json, so a person can tell which one runs.
109const versionAtom = atom({ plugin: 'branch8-jira-mod', key: 'version' } as const, null)
110// The version published on main, and whether an update the pane started runs.
111const latestAtom = atom({ plugin: 'branch8-jira-mod', key: 'latest' } as const, { version: null, isUpdating: false })
112// The published manifest: the repo is public, so no sign-in is needed.
113const LATEST_URL = 'https://raw.githubusercontent.com/branch8/claude-plugins/main/plugins/branch8-jira-mod/.claude-plugin/plugin.json'
114const PLUGIN_ID = 'branch8-jira-mod@branch8'
115
116// The company Jira site, used when no ticket has given a cloudId yet.
117const SITE = 'https://branch8.atlassian.net'
118// Branch8's Claude organisation (as branch8-jira-check's config.env); BRANCH8_ORG_ID overrides.
119const DEFAULT_ORG_ID = 'a4a2aa1a-21cd-467f-af54-8253e2ed15d8'
120// Retries for the start-up load while the Atlassian MCP is still connecting.
121const AUTO_RETRY_MS = [5_000, 15_000, 30_000]
122const TODO_PANE = 'branch8-jira-todo'
123const TODO_LIMIT = 20
124// Columns a pane row may take; the pane is narrow and CJK titles wrap past it.
125const PANE_WIDTH = 46
126// A tree row: indent (4) and the Open button (6) come off the pane width.
127const ROW_WIDTH = PANE_WIDTH - 10
128
129// The Atlassian MCP under either name it is installed as.
130const ATLASSIAN = ['plugin:atlassian:atlassian', 'claude.ai Atlassian Rovo']
131
132const str = (v: unknown) => (typeof v === 'string' ? v : '')
133
134async function applyLang($: EngineInterface, pref: LangPref) {
135  let setting: string | undefined
136  try {
137    const { language } = (await $.settings.read()) as { language?: unknown }
138    setting = typeof language === 'string' ? language : undefined
139  } catch {
140    // Settings unreadable: fall back to the locale.
141  }
142  const lang: Lang = resolveLang(pref, setting, await $.env.get('LANG'))
143  await update($, langPrefAtom, () => pref)
144  await update($, langAtom, () => lang)
145}
146
147// Opens a URL in the person's browser: WSL hands it to Windows, macOS to
148// `open`, Linux to `xdg-open`, native Windows to `start`. False if none ran.
149async function openUrl($: EngineInterface, url: string): Promise<boolean> {
150  const wsl = await $.env.get('WSL_DISTRO_NAME')
151  const tries: string[][] = wsl
152    ? [['wslview', url], ['cmd.exe', '/c', 'start', '', url]]
153    : [['open', url], ['xdg-open', url], ['cmd', '/c', 'start', '', url]]
154  for (const argv of tries) {
155    try {
156      const r = await $.process.run(argv, { timeoutMs: 10_000 })
157      if (r.exitCode === 0) return true
158    } catch {
159      // Not installed here; try the next opener.
160    }
161  }
162  return false
163}
164
165async function homeDir($: EngineInterface): Promise<string> {
166  return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
167}
168
169// The records branch8-jira-check writes, one file per folder.
170async function stateDir($: EngineInterface): Promise<string> {
171  return `${await homeDir($)}/.claude/branch8-jira/projects`
172}
173
174// Which Jira project this session's folder belongs to: the repo root inside a
175// git repo, else the folder; the nearest record above it wins (as lib.sh).
176async function resolveFolder($: EngineInterface): Promise<void> {
177  const cwd = await $.session.cwd()
178  let base = cwd
179  try {
180    const r = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], { timeoutMs: 5_000 })
181    if (r.exitCode === 0 && r.stdout.trim()) base = r.stdout.trim()
182  } catch {
183    // No git: the folder itself is the unit.
184  }
185  const dirs = await stateDir($)
186  let rec: FolderRecord | null = null
187  for (const dir of ancestorDirs(base)) {
188    const file = `${dirs}/${recordFileName(dir)}`
189    if (!(await $.fs.exists(file))) continue
190    rec = parseRecord(await $.fs.read(file))
191    if (rec) break
192  }
193  const name = base.split(/[/\\]/).filter(Boolean).pop() || base
194  await update($, folderAtom, () =>
195    rec?.kind === 'project'
196      ? { dir: name, project: rec.key, name: rec.name }
197      : { dir: name, project: null, name: '', noJira: rec?.kind === 'none' },
198  )
199}
200
201// Where to suggest working on another project: a folder recorded for it,
202// else the workspace folder branch8-jira-check would create.
203async function folderForProject($: EngineInterface, key: string): Promise<string> {
204  const dirs = await stateDir($)
205  try {
206    for (const entry of await $.fs.list(dirs)) {
207      if (!entry.name.endsWith('.json')) continue
208      const text = await $.fs.read(`${dirs}/${entry.name}`)
209      const rec = parseRecord(text)
210      if (rec?.kind !== 'project' || rec.key !== key) continue
211      const dir = (JSON.parse(text) as { projectDir?: unknown }).projectDir
212      if (typeof dir === 'string' && dir) return dir
213    }
214  } catch {
215    // No records yet.
216  }
217  const root = (await $.env.get('BRANCH8_WORKSPACE_ROOT')) || `${await homeDir($)}/Branch8`
218  return suggestedFolder(root, key)
219}
220
221// Reads the open-ticket list into the pane's state; the reply for the command.
222// Moves the pane's ring and records it at once: the ui.focus hook records it
223// too, but not before the next arrow when the element is drawn in this pass.
224async function focusOn($: EngineInterface, key: string): Promise<void> {
225  await update($, ringAtom, () => key)
226  await $.ui.focus({ requestId: TODO_PANE, key })
227}
228
229// Fills the pane's list: the first page afresh, or with `more` the next page
230// after what is loaded. The search names its range and, scoped to this
231// folder, its project, so Jira sends only what the pane shows.
232// Tells Claude, in a row it reads but the person does not see as typed, what
233// the person just picked in the pane; see pickNote for why the system prompt
234// alone is not enough. A refusal or failure only loses the note.
235async function tellClaude($: EngineInterface, ticket: Ticket | null): Promise<void> {
236  try {
237    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: pickNote(ticket) }] } })
238  } catch {
239    // The band and the rules section still show the ticket.
240  }
241}
242
243// Reads the version published on main; a refused or failed fetch leaves it
244// unknown and the pane offers no update.
245async function checkLatest($: EngineInterface): Promise<void> {
246  try {
247    const res = await $.http.fetch(LATEST_URL)
248    if (res.ok) await update($, latestAtom, l => ({ ...l, version: manifestVersion(res.text) }))
249  } catch {
250    // Offline, or the organisation's web-fetch policy refused it.
251  }
252}
253
254// Updates the marketplace, then this plugin, as the person would with
255// /plugin; then reloads plugins so the new version runs. The CLI only: on
256// other surfaces the process call fails and the toast gives the command.
257async function runUpdate($: EngineInterface, str: (typeof STRINGS)[Lang]): Promise<void> {
258  await update($, latestAtom, l => ({ ...l, isUpdating: true }))
259  $.ui.toast(str.updating)
260  try {
261    const steps = [
262      ['claude', 'plugin', 'marketplace', 'update', 'branch8'],
263      ['claude', 'plugin', 'update', PLUGIN_ID],
264    ]
265    let said = ''
266    for (const argv of steps) {
267      const r = await $.process.run(argv, { timeoutMs: 120_000 })
268      if (r.exitCode !== 0) throw new Error(r.stderr || r.stdout)
269      said = r.stdout
270    }
271    const outcome = updateOutcome(said)
272    if (!outcome) throw new Error(said)
273    if (outcome.kind === 'latest') {
274      $.ui.toast(str.alreadyLatest(outcome.version))
275      return
276    }
277    $.ui.toast(str.updated(outcome.version))
278    try {
279      await $.command.run({ command: 'reload-plugins' })
280    } catch {
281      // The toast already says to open a new session if it does not take.
282    }
283  } catch {
284    $.ui.toast(str.updateFailed)
285  } finally {
286    await update($, latestAtom, l => ({ ...l, isUpdating: false }))
287  }
288}
289
290// The moves a ticket can make now, from whichever Atlassian server answers;
291// null when neither does.
292async function loadTransitions($: EngineInterface, key: string, current: string): Promise<Transition[] | null> {
293  const cloudId = (await read($, ticketAtom))?.cloudId || SITE
294  const calls: [string, string, Record<string, unknown>][] = [
295    [ATLASSIAN[0]!, 'executeRead', { name: 'listJiraIssueTransitions', cloudId, inputs: { issueIdOrKey: key } }],
296    [ATLASSIAN[1]!, 'getTransitionsForJiraIssue', { cloudId, issueIdOrKey: key }],
297  ]
298  for (const [server, tool, args] of calls) {
299    try {
300      const res = await $.mcp.call(server, tool, args)
301      if (res.isError) continue
302      const list = parseTransitions(res.content as { type: string; text?: string }[], current)
303      if (list) return list
304    } catch {
305      // Not connected under this name; try the next one.
306    }
307  }
308  return null
309}
310
311// Moves a ticket in Jira; false when no server takes it (fields to fill, no
312// permission, or not connected). The person confirmed it in the pane.
313async function moveTicket($: EngineInterface, key: string, transitionId: string): Promise<boolean> {
314  const cloudId = (await read($, ticketAtom))?.cloudId || SITE
315  const calls: [string, Record<string, unknown>][] = [
316    [ATLASSIAN[0]!, { cloudId, issueIdOrKey: key, transitionId }],
317    [ATLASSIAN[1]!, { cloudId, issueIdOrKey: key, transition: { id: transitionId } }],
318  ]
319  for (const [server, args] of calls) {
320    try {
321      const res = await $.mcp.call(server, 'transitionJiraIssue', args)
322      if (!res.isError) return true
323    } catch {
324      // Not connected under this name; try the next one.
325    }
326  }
327  return false
328}
329
330async function loadTodos(
331  $: EngineInterface,
332  str: (typeof STRINGS)[Lang],
333  more = false,
334): Promise<{ ok: boolean; text: string }> {
335  const before = await read($, todosAtom)
336  if (more && !before.next) return { ok: true, text: str.todoCount(before.items.length) }
337  await update($, todosAtom, t => ({ ...t, error: null, isLoading: true }))
338  const cloudId = (await read($, ticketAtom))?.cloudId || SITE
339  const folder = await read($, folderAtom)
340  const scope = effectiveScope(await read($, scopePrefAtom), folder.project)
341  const jql = todoJql({ days: await read($, rangeAtom), project: scope === 'folder' ? folder.project : null })
342  let error = str.noJira
343  for (const server of ATLASSIAN) {
344    try {
345      const res = await $.mcp.call(server, 'searchJiraIssuesUsingJql', {
346        cloudId,
347        jql,
348        maxResults: TODO_LIMIT,
349        fields: TODO_FIELDS,
350        ...(more && before.next ? { nextPageToken: before.next } : {}),
351      })
352      if (res.isError) {
353        // Installed but signed out answers isError: try the other server.
354        error = str.signedOut
355        continue
356      }
357      const page = parseIssuePage(res.content as { type: string; text?: string }[])
358      if (page) {
359        const seen = new Set(more ? before.items.map(i => i.key) : [])
360        const items = more ? [...before.items, ...page.items.filter(i => !seen.has(i.key))] : page.items
361        await update($, todosAtom, () => ({ items, error: null, isLoading: false, isLoaded: true, next: page.next }))
362        // The band follows the list: a status moved in Jira shows on the next
363        // load or refresh, with no request of its own.
364        const synced = syncTicket(await read($, ticketAtom), items)
365        if (synced) await update($, ticketAtom, () => synced)
366        // Jira answers now: the account block can name its user.
367        void loadJiraUser($)
368        return { ok: true, text: str.todoCount(items.length) }
369      }
370      error = str.queryFailed
371    } catch {
372      // Not connected under this name; try the next one.
373    }
374  }
375  await update($, todosAtom, t => ({ ...t, error, isLoading: false }))
376  return { ok: false, text: error }
377}
378
379// Signed in to the Branch8 organisation through claude.ai (not an API key,
380// token or cloud provider), read from the config dir's .claude.json.
381async function onOrgAccount($: EngineInterface): Promise<boolean> {
382  const overrides = [
383    await $.env.get('ANTHROPIC_API_KEY'),
384    await $.env.get('ANTHROPIC_AUTH_TOKEN'),
385    await $.env.get('CLAUDE_CODE_OAUTH_TOKEN'),
386    await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
387    await $.env.get('CLAUDE_CODE_USE_VERTEX'),
388    await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
389  ]
390  const text = await readClaudeJson($)
391  if (text === null) return false
392  const orgId = (await $.env.get('BRANCH8_ORG_ID')) || DEFAULT_ORG_ID
393  return isOrgAccount(text, orgId, overrides.some(Boolean))
394}
395
396// The config dir's .claude.json (it follows CLAUDE_CONFIG_DIR), else the one
397// in home; null when neither exists.
398async function readClaudeJson($: EngineInterface): Promise<string | null> {
399  const home = await homeDir($)
400  for (const dir of [(await $.env.get('CLAUDE_CONFIG_DIR')) || home, home]) {
401    const file = `${dir}/.claude.json`
402    if (await $.fs.exists(file)) return $.fs.read(file)
403  }
404  return null
405}
406
407// Asks once which Jira user the Atlassian MCP is signed in as, for the
408// account block; the plugin server's atlassianUserInfo gives no email.
409async function loadJiraUser($: EngineInterface): Promise<void> {
410  if ((await read($, accountAtom)).isJiraChecked) return
411  const cloudId = (await read($, ticketAtom))?.cloudId || SITE
412  const calls: [string, string, Record<string, unknown>][] = [
413    [ATLASSIAN[0]!, 'executeRead', { name: 'getJiraCurrentUser', cloudId, inputs: {} }],
414    [ATLASSIAN[1]!, 'atlassianUserInfo', {}],
415  ]
416  for (const [server, tool, args] of calls) {
417    try {
418      const res = await $.mcp.call(server, tool, args)
419      if (res.isError) continue
420      const user = parseJiraUser(res.content as { type: string; text?: string }[])
421      if (user) {
422        await update($, accountAtom, a => ({ ...a, jira: user, isJiraChecked: true }))
423        return
424      }
425    } catch {
426      // Not connected under this name; try the next one.
427    }
428  }
429}
430
431export const register: Register = on => {
432  on('session.start', async ($, e, next) => {
433    // Side by side: one after another took up to 15 s at start (2026-10-20),
434    // and the first session.start holds the first prompt until it passes.
435    await Promise.all([
436      $.tool.register({
437        name: 'set_ticket',
438        description:
439          'Record the Jira ticket the current task belongs to: it shows above the prompt and the mod reminds you at the start / PR / merge points. Call after the jira-workflow skill resolves or creates a ticket.',
440        inputSchema: {
441          type: 'object',
442          required: ['key', 'cloudId'],
443          properties: {
444            key: { type: 'string', description: 'Issue key, e.g. HS-123' },
445            summary: { type: 'string' },
446            status: { type: 'string', description: 'Current status name' },
447            cloudId: { type: 'string', description: 'Atlassian cloudId or site URL' },
448            confirmedOtherProject: {
449              type: 'boolean',
450              description: 'True only after the user agreed to work here on a ticket from a project other than this folder\'s',
451            },
452          },
453        },
454        isDeferred: false,
455      }),
456      $.tool.register({
457        name: 'get_ticket',
458        description: 'The current Jira ticket of this session and the workflow points already asked about.',
459        isDeferred: false,
460      }),
461      $.tool.register({
462        name: 'clear_ticket',
463        description:
464          'Unselect the current Jira ticket, so this session is tied to none (the user asked to stop tracking a ticket, or the work is not ticket work).',
465        isDeferred: false,
466      }),
467      $.tool.register({
468        name: 'mark_asked',
469        description:
470          'Record that the user was asked about a status transition at a point, as "<KEY>:<start|pr|merge>", so it is not asked again.',
471        inputSchema: { type: 'object', required: ['mark'], properties: { mark: { type: 'string' } } },
472        isDeferred: false,
473      }),
474      $.command.register({
475        name: 'jira-todo',
476        description: 'Jira 待辦 / open tickets: pick one as the current ticket',
477      }),
478    ])
479    // Last, and guarded: a language that fails to load must not cost the
480    // tools and the command registered above.
481    try {
482      await resolveFolder($)
483    } catch {
484      // Unknown folder: the list shows every project.
485    }
486    try {
487      const scope = await $.store.get('scope')
488      if (scope === 'folder' || scope === 'all') await update($, scopePrefAtom, () => scope)
489    } catch {
490      // Stays on auto.
491    }
492    try {
493      const manifest = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)
494      await update($, versionAtom, () => manifestVersion(manifest))
495    } catch {
496      // The pane shows no version.
497    }
498    // In the background: the pane offers the update once it answers.
499    void checkLatest($)
500    try {
501      const text = await readClaudeJson($)
502      const claude = text === null ? null : claudeAccount(text)
503      await update($, accountAtom, a => ({ ...a, claude }))
504    } catch {
505      // The block leaves the Claude line out.
506    }
507    try {
508      const range = await $.store.get('range')
509      if (typeof range === 'number' && range >= 0) await update($, rangeAtom, () => range)
510    } catch {
511      // Stays on the default range.
512    }
513    try {
514      const pref = await $.store.get('lang')
515      await applyLang($, pref === 'zh-TW' || pref === 'zh-CN' || pref === 'en' ? pref : 'auto')
516    } catch {
517      // Stays on the default (English).
518    }
519    // Last: open the pane by itself on the organisation account, without
520    // taking the keyboard, and fill it once the Atlassian MCP answers.
521    try {
522      const pref = await $.store.get('autoOpen')
523      await update($, autoOpenAtom, () => pref !== false)
524      const isOrg = await onOrgAccount($)
525      await update($, orgAtom, () => isOrg)
526      if (pref !== false && isOrg) {
527        const opened = await $.ui.open({ id: TODO_PANE, title: 'Jira' })
528        await update($, todoOpenAtom, () => true)
529        const str = STRINGS[(await read($, langAtom)) as Lang]
530        // Opened unasked, a narrow terminal keeps it undrawn: say how to get it.
531        if (!opened.isPlaced) $.ui.toast(str.narrowHint)
532        // In the background: the Atlassian MCP is still connecting at start, and
533        // waiting on it here held session.start for 17 s (2026-10-20).
534        void (async () => {
535          const first = await loadTodos($, str)
536          if (first.ok) return
537          for (const ms of AUTO_RETRY_MS) {
538            $.clock.after(ms, () => {
539              void (async () => {
540                const todos = await read($, todosAtom)
541                if (todos.isLoaded || !(await read($, todoOpenAtom))) return
542                await loadTodos($, STRINGS[(await read($, langAtom)) as Lang])
543              })()
544            })
545          }
546        })()
547      }
548    } catch {
549      // No auto-open; /jira-todo still works.
550    }
551    return next(e)
552  })
553
554  // /jira-todo toggles the pane: closes it when open, opens it otherwise.
555  on('command.run', { command: 'jira-todo' }, async $ => {
556    const str = STRINGS[(await read($, langAtom)) as Lang]
557    if (await read($, todoOpenAtom)) {
558      await $.ui.close({ id: TODO_PANE })
559      await update($, todoOpenAtom, () => false)
560      return { text: str.closed }
561    }
562    try {
563      await resolveFolder($)
564    } catch {
565      // Keep what session.start found.
566    }
567    await $.ui.open({ id: TODO_PANE, title: 'Jira', focus: true })
568    await update($, todoOpenAtom, () => true)
569    return { text: (await loadTodos($, str)).text }
570  })
571
572  on('ui.close', async ($, e, next) => {
573    if (e.id === TODO_PANE) {
574      await update($, todoOpenAtom, () => false)
575      if (e.origin.kind === 'person' && (await read($, orgAtom))) {
576        $.ui.toast(STRINGS[(await read($, langAtom)) as Lang].closedHint)
577      }
578    }
579    return next(e)
580  })
581
582  on('ui.focus', { requestId: TODO_PANE }, async ($, e, next) => {
583    // Every move updates it, so leaving a ticket for a button clears it.
584    await update($, focusedAtom, () => focusedTicket(e.element))
585    // No element: the ring went to one of the engine's stops or left the pane.
586    await update($, ringAtom, ring => (e.element ? e.element : leftFrom(ring)))
587    return next(e)
588  })
589
590  // A list taller than the pane's window makes the arrows scroll it a row
591  // instead of moving the ring, and the highlight is soon out of view. Walk the
592  // ring instead; it keeps the element it lands on in view. Wheel and page keys
593  // still scroll.
594  on('ui.scroll', { requestId: TODO_PANE }, async ($, e, next) => {
595    if (!isArrowScroll(e)) return next(e)
596    const folder = await read($, folderAtom)
597    const scope = effectiveScope(await read($, scopePrefAtom), folder.project)
598    const settings = await read($, settingsAtom)
599    const current = (await read($, ticketAtom))?.key ?? null
600    const menu = await read($, menuAtom)
601    const todos = await read($, todosAtom)
602    const order = focusOrder({
603      groups: groupTodos(applyScope(todos.items, scope, folder.project)),
604      folded: await read($, todoFoldedAtom),
605      statusFolded: await read($, statusFoldedAtom),
606      settings: settings ? settingsValues(Boolean(folder.project)) : null,
607      current,
608      menu: menu
609        ? {
610            key: menu.item.key,
611            values: menuValues(menu.other, menu.item.key === current),
612            transitions: menu.status ? menu.status.list.map(t => t.id) : null,
613            confirm: Boolean(menu.status?.pick),
614          }
615        : null,
616      more: Boolean(todos.next),
617      update: isNewer((await read($, latestAtom)).version, await read($, versionAtom)),
618    })
619    const to = stepFocus(order, await read($, ringAtom), e.by)
620    if (!to) return next(e)
621    try {
622      await focusOn($, to)
623    } catch {
624      return next(e)
625    }
626    return {}
627  })
628
629  on('ui.render', { component: 'Pane', requestId: TODO_PANE }, async ($, e) => {
630    const { Box, Button, Text } = $.ui.resolve(e)
631    const todos = await read($, todosAtom)
632    const folded = await read($, todoFoldedAtom)
633    const statusFolded = await read($, statusFoldedAtom)
634    const lang = (await read($, langAtom)) as Lang
635    const pref = (await read($, langPrefAtom)) as LangPref
636    const str = STRINGS[lang]
637    const folder = await read($, folderAtom)
638    const scopePref = await read($, scopePrefAtom)
639    const scope: Scope = effectiveScope(scopePref, folder.project)
640    const menu = await read($, menuAtom)
641    const focusedKey = await read($, focusedAtom)
642    const autoOpen = await read($, autoOpenAtom)
643    const settings = await read($, settingsAtom)
644    const range = await read($, rangeAtom)
645    const version = await read($, versionAtom)
646    const account = await read($, accountAtom)
647    const jiraWarn = account.jira ? jiraWarning(account.jira.email, account.claude?.email ?? null) : null
648    const latest = await read($, latestAtom)
649    const canUpdate = isNewer(latest.version, version)
650    const ticket = await read($, ticketAtom)
651    const current = ticket?.key
652    const cloudId = ticket?.cloudId || SITE
653
654    // A menu item disappears with the menu; hand the ring back to its row,
655    // or the arrows have nothing to move from.
656    const backToRow = async (key: string) => {
657      try {
658        await focusOn($, key)
659      } catch {
660        // The row is gone (list refreshed); the ring stays where the engine left it.
661      }
662    }
663    const pick = async (item: (typeof todos.items)[number]) => {
664      await update($, menuAtom, () => null)
665      await update($, ticketAtom, () => ({ ...item, cloudId }))
666      $.ui.toast(str.current(item.key))
667      await tellClaude($, { ...item, cloudId })
668    }
669    const clearTicket = async () => {
670      await update($, menuAtom, () => null)
671      await update($, ticketAtom, () => null)
672      $.ui.toast(str.cleared)
673      await tellClaude($, null)
674    }
675    const openIssue = async (key: string) => {
676      const url = issueUrl(SITE, key)
677      if (!(await openUrl($, url))) $.ui.toast(str.openFailed(url))
678    }
679
680    return (
681      <Box flexDirection="column">
682        {/* The session's accounts and folder, as branch8-jira-check's banner,
683            plus the Jira user the banner cannot know (MCP is not up yet then). */}
684        {account.claude && (
685          <Text>
686            <Text>{'✅ '}</Text>
687            <Text color="claude" bold>{`${str.labelClaude}  `}</Text>
688            <Text bold>{account.claude.email}</Text>
689            <Text dimColor>
690              {account.claude.org ? `(${account.claude.org}${account.claude.plan ? ` · ${account.claude.plan}` : ''})` : ''}
691            </Text>
692          </Text>
693        )}
694        {account.jira ? (
695          <Text>
696            <Text>{`${jiraWarn ? '⚠️' : '🔗'} `}</Text>
697            <Text color="claude" bold>{`${str.labelJira}  `}</Text>
698            <Text bold>{account.jira.name}</Text>
699            <Text dimColor>{`(${account.jira.email})`}</Text>
700          </Text>
701        ) : (
702          <Text>
703            <Text>{'🔗 '}</Text>
704            <Text color="claude" bold>{`${str.labelJira}  `}</Text>
705            <Text dimColor>{str.jiraPending}</Text>
706          </Text>
707        )}
708        {jiraWarn && (
709          <Text color="warning">{`   ${jiraWarn === 'domain' ? str.jiraDomain : str.jiraDiffers}`}</Text>
710        )}
711        <Text>
712          <Text>{'📁 '}</Text>
713          <Text color="claude" bold>{`${str.labelFolder}  `}</Text>
714          <Text bold>{folder.dir || '?'}</Text>
715          <Text color={folder.project ? 'success' : undefined} dimColor={!folder.project}>
716            {folder.project
717              ? ` → Jira ${folder.project}${folder.name ? `(${folder.name})` : ''}`
718              : ` → ${folder.noJira ? str.noJiraFolder : str.unmappedFolder}`}
719          </Text>
720        </Text>
721        <Text dimColor>{folder.project ? str.changeMapping : str.setMapping}</Text>
722        <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
723          <Text dimColor bold>{str.helpTitle + (version ? ` · v${version}` : '')}</Text>
724          {str.help.map(line => (
725            <Text key={line} dimColor>
726              {line}
727            </Text>
728          ))}
729        </Box>
730        <Box flexDirection="row" columnGap={2}>
731          <Button key="settings" plain dimColor={!settings} label={str.settings} onPress={async () => {
732            await update($, settingsAtom, open => (open ? null : 'lang'))
733            if (!settings) await focusOn($, 'settings-lang')
734          }} />
735          {/* Outside the popup: a hotkey works only while its button is drawn. */}
736          <Button key="hotkey-o" plain dimColor hotkey="o" label={str.menuOpen} onPress={async () => {
737            if (focusedKey) await openIssue(focusedKey)
738            else $.ui.toast(str.noFocus)
739          }} />
740          {canUpdate && latest.version && (
741            <Button
742              key="update"
743              plain
744              label={latest.isUpdating ? str.updating : str.updateTo(latest.version)}
745              onPress={async () => {
746                if (latest.isUpdating || !latest.version) return
747                await runUpdate($, str)
748              }}
749            />
750          )}
751        </Box>
752        {settings && (() => {
753          const labels: Record<string, string> = {
754            lang: `${str.language}${str.langNames[pref]}`,
755            auto: str.autoOpen(autoOpen),
756            scope: `${str.scope}${str.scopeNames[scope]}`,
757            range: str.rangeLabel(range),
758            close: str.close,
759            back: str.settingsBack,
760          }
761          const choose = async (value: string) => {
762            if (value === 'lang') {
763              const next = nextLangPref(pref)
764              await $.store.set('lang', next)
765              await applyLang($, next)
766            } else if (value === 'auto') {
767              await $.store.set('autoOpen', !autoOpen)
768              await update($, autoOpenAtom, () => !autoOpen)
769            } else if (value === 'scope') {
770              const next: Scope = scope === 'folder' ? 'all' : 'folder'
771              await $.store.set('scope', next)
772              await update($, scopePrefAtom, () => next)
773              await loadTodos($, str)
774            } else if (value === 'range') {
775              const next = nextRange(range)
776              await $.store.set('range', next)
777              await update($, rangeAtom, () => next)
778              await loadTodos($, str)
779            } else if (value === 'close') {
780              await update($, settingsAtom, () => null)
781              await $.ui.close({ id: TODO_PANE })
782              // A close from the plugin's own handler may not reach its ui.close hook.
783              await update($, todoOpenAtom, () => false)
784              if (await read($, orgAtom)) $.ui.toast(str.closedHint)
785            } else {
786              await update($, settingsAtom, () => null)
787              await backToRow('settings')
788            }
789          }
790          // A toggle keeps the popup open and the ring on it, to change several in a row.
791          return (
792            <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} marginLeft={2}>
793              {settingsValues(Boolean(folder.project)).map(v => (
794                <Button key={`settings-${v}`} plain label={labels[v] ?? v} onPress={() => choose(v)} />
795              ))}
796            </Box>
797          )
798        })()}
799        {current && (
800          <Button key="clear-top" plain label={str.clearTicket(current)} onPress={clearTicket} />
801        )}
802        <Text dimColor>{'─'.repeat(PANE_WIDTH - 4)}</Text>
803        <Button
804          key="refresh"
805          plain
806          dimColor
807          // The one loading line: first load or refresh.
808          label={todos.isLoading ? (todos.items.length ? str.refreshing : str.loading + '…') : str.refresh}
809          onPress={async () => {
810            if (todos.isLoading) return
811            await loadTodos($, str)
812          }}
813        />
814        {todos.error && <Text color="error">{todos.error}</Text>}
815        {todos.isLoaded && !todos.isLoading && !todos.error && todos.items.length === 0 && (
816          <Text dimColor>{str.empty}</Text>
817        )}
818        {groupTodos(applyScope(todos.items, scope, folder.project)).map(group => {
819          const isFolded = folded.includes(group.project)
820          const isMine = group.project === folder.project
821          return (
822            <Box key={group.project} flexDirection="column">
823              <Box flexDirection="row">
824                <Button
825                  key={`p-${group.project}`}
826                  plain
827                  label={`${isFolded ? '▸' : '▾'} ${group.project}`}
828                  onPress={() =>
829                    update($, todoFoldedAtom, f =>
830                      f.includes(group.project) ? f.filter(x => x !== group.project) : [...f, group.project],
831                    )
832                  }
833                />
834                <Text color="claude" bold>{`  ${group.count}`}</Text>
835                {folder.project && (
836                  <Text color={isMine ? 'success' : 'subtle'}>{`   ${isMine ? str.thisFolderTag : str.otherTag}`}</Text>
837                )}
838              </Box>
839              {!isFolded &&
840                group.statuses.map(st => {
841                  const fk = `${group.project}/${st.status}`
842                  const isStFolded = statusFolded.includes(fk)
843                  return (
844                    <Box key={fk} flexDirection="column" marginLeft={2}>
845                      <Box flexDirection="row">
846                        <Button
847                          key={`s-${fk}`}
848                          plain
849                          label={isStFolded ? '▸' : '▾'}
850                          onPress={() =>
851                            update($, statusFoldedAtom, f => (f.includes(fk) ? f.filter(x => x !== fk) : [...f, fk]))
852                          }
853                        />
854                        <Text color={statusTone(st.status)}>{` ● ${st.status || str.noStatus} (${st.items.length})`}</Text>
855                      </Box>
856                      {!isStFolded &&
857                        st.items.map(item => (
858                          <Box key={item.key} flexDirection="column" marginLeft={2}>
859                            <Box flexDirection="row">
860                              <Button
861                                key={item.key}
862                                plain
863                                {...(item.key === current ? {} : { label: `${typeIcon(item.type)} ${todoRow(item, ROW_WIDTH - 3)}` })}
864                                onPress={async () => {
865                                  if (menu?.item.key === item.key) return update($, menuAtom, () => null)
866                                  const other = isOtherProject(item.key, folder.project)
867                                  const dir = other ? await folderForProject($, projectOf(item.key)) : ''
868                                  await update($, menuAtom, () => ({ item, other, dir }))
869                                  const first = menuValues(other, item.key === current)[0]
870                                  await focusOn($, `menu-${item.key}-${first}`)
871                                }}
872                              >
873                                {/* The current ticket stands out: the rest are plain labels. */}
874                                {item.key === current && (
875                                  <Text bold color="success">{`▶ ${typeIcon(item.type)} ${todoRow(item, ROW_WIDTH - 3)}`}</Text>
876                                )}
877                              </Button>
878                              <Text color="suggestion">{' 🔗'}</Text>
879                            </Box>
880                            {menu?.item.key === item.key && (
881                              <Box
882                                flexDirection="column"
883                                borderStyle="round"
884                                borderColor="claude"
885                                paddingX={1}
886                                marginLeft={3}
887                              >
888                                <Text bold color="claude">{item.key}</Text>
889                                {menu.other && folder.project && (
890                                  <Text color="warning">{str.confirmOther(item.key, folder.project)}</Text>
891                                )}
892                                {menu.other && <Text dimColor>{str.moveHint(menu.dir)}</Text>}
893                                {(() => {
894                                  const labels: Record<string, string> = {
895                                    move: `→ ${str.moveThere}`,
896                                    work: menu.other ? `→ ${str.stayHere}` : str.menuWork,
897                                    clear: str.menuClear,
898                                    status: str.menuStatus,
899                                    open: str.menuOpen,
900                                    cancel: str.menuCancel,
901                                  }
902                                  const choose = async (value: string) => {
903                                    if (value === 'status') {
904                                      // Opens the status popup under the menu; the menu stays.
905                                      await update($, menuAtom, m =>
906                                        m ? { ...m, status: { isLoading: true, list: [], pick: null } } : m,
907                                      )
908                                      await focusOn($, `menu-${item.key}-tback`)
909                                      const list = await loadTransitions($, item.key, item.status)
910                                      await update($, menuAtom, m =>
911                                        m && m.item.key === item.key
912                                          ? { ...m, status: { isLoading: false, list: list ?? [], pick: null } }
913                                          : m,
914                                      )
915                                      if (list === null) $.ui.toast(str.queryFailed)
916                                      const first = list?.[0]
917                                      if (first) await focusOn($, `menu-${item.key}-t-${first.id}`)
918                                      return
919                                    }
920                                    if (value === 'work') await pick(item)
921                                    else if (value === 'clear') await clearTicket()
922                                    else if (value === 'open') await openIssue(item.key)
923                                    else if (value === 'move') $.ui.toast(str.moveHint(menu.dir))
924                                    await update($, menuAtom, () => null)
925                                    await backToRow(item.key)
926                                    // Setting or clearing the ticket adds or drops the unselect
927                                    // button above the list after this redraw, and the ring keeps
928                                    // its place in the order, a row off: point it again once drawn.
929                                    if (value === 'work' || value === 'clear') {
930                                      $.clock.after(300, () => void backToRow(item.key))
931                                    }
932                                  }
933                                  return menuValues(menu.other, item.key === current).map(v => (
934                                    <Button
935                                      key={`menu-${item.key}-${v}`}
936                                      plain
937                                      dimColor={v === 'cancel'}
938                                      label={labels[v] ?? v}
939                                      onPress={() => choose(v)}
940                                    />
941                                  ))
942                                })()}
943                              </Box>
944                            )}
945                            {menu?.item.key === item.key && menu.status && (
946                              <Box
947                                flexDirection="column"
948                                borderStyle="round"
949                                borderColor="claude"
950                                paddingX={1}
951                                marginLeft={5}
952                              >
953                                <Text bold color="claude">{str.statusTitle(item.key)}</Text>
954                                <Text dimColor>{str.statusNow(item.status)}</Text>
955                                {menu.status.isLoading && <Text dimColor>{str.loadingTransitions}</Text>}
956                                {!menu.status.isLoading && menu.status.list.length === 0 && (
957                                  <Text dimColor>{str.noTransitions}</Text>
958                                )}
959                                {menu.status.list.map(t => (
960                                  <Button
961                                    key={`menu-${item.key}-t-${t.id}`}
962                                    plain
963                                    label={`→ ${t.to}`}
964                                    onPress={async () => {
965                                      await update($, menuAtom, m =>
966                                        m?.status ? { ...m, status: { ...m.status, pick: t } } : m,
967                                      )
968                                      await focusOn($, `menu-${item.key}-yes`)
969                                    }}
970                                  />
971                                ))}
972                                <Button
973                                  key={`menu-${item.key}-tback`}
974                                  plain
975                                  dimColor
976                                  label={str.back}
977                                  onPress={async () => {
978                                    await update($, menuAtom, m => (m ? { ...m, status: null } : m))
979                                    await focusOn($, `menu-${item.key}-status`)
980                                  }}
981                                />
982                              </Box>
983                            )}
984                            {menu?.item.key === item.key && menu.status?.pick && (() => {
985                              const t = menu.status.pick
986                              return (
987                                <Box
988                                  flexDirection="column"
989                                  borderStyle="round"
990                                  borderColor="warning"
991                                  paddingX={1}
992                                  marginLeft={7}
993                                >
994                                  <Text bold>{str.confirmMove(item.key)}</Text>
995                                  <Text dimColor>{item.status || '?'}</Text>
996                                  <Text color="claude">{`→ ${t.to}`}</Text>
997                                  {t.hasScreen && <Text color="warning">{str.needsFields}</Text>}
998                                  <Button
999                                    key={`menu-${item.key}-yes`}
1000                                    plain
1001                                    label={str.confirmYes}
1002                                    onPress={async () => {
1003                                      const from = item.status
1004                                      if (!(await moveTicket($, item.key, t.id))) {
1005                                        $.ui.toast(str.moveFailed(issueUrl(SITE, item.key)))
1006                                        await update($, menuAtom, m =>
1007                                          m?.status ? { ...m, status: { ...m.status, pick: null } } : m,
1008                                        )
1009                                        await focusOn($, `menu-${item.key}-t-${t.id}`)
1010                                        return
1011                                      }
1012                                      // A done status leaves the open-tickets list; another moves the row.
1013                                      await update($, todosAtom, ts => ({
1014                                        ...ts,
1015                                        items: t.isDone
1016                                          ? ts.items.filter(i => i.key !== item.key)
1017                                          : ts.items.map(i => (i.key === item.key ? { ...i, status: t.to } : i)),
1018                                      }))
1019                                      await update($, ticketAtom, cur =>
1020                                        cur && cur.key === item.key ? { ...cur, status: t.to } : cur,
1021                                      )
1022                                      await update($, menuAtom, () => null)
1023                                      $.ui.toast(str.moved(item.key, t.to))
1024                                      try {
1025                                        await $.session.append({
1026                                          message: { type: 'user', content: [{ type: 'text', text: moveNote(item.key, from, t.to) }] },
1027                                        })
1028                                      } catch {
1029                                        // The list and the band still show the new status.
1030                                      }
1031                                      // The row moved to another status group: point the ring at it now,
1032                                      // and again once drawn (the popups' removal shifts the order).
1033                                      if (!t.isDone) {
1034                                        await backToRow(item.key)
1035                                        $.clock.after(300, () => void backToRow(item.key))
1036                                      }
1037                                    }}
1038                                  />
1039                                  <Button
1040                                    key={`menu-${item.key}-no`}
1041                                    plain
1042                                    dimColor
1043                                    label={str.confirmNo}
1044                                    onPress={async () => {
1045                                      await update($, menuAtom, m =>
1046                                        m?.status ? { ...m, status: { ...m.status, pick: null } } : m,
1047                                      )
1048                                      await focusOn($, `menu-${item.key}-t-${t.id}`)
1049                                    }}
1050                                  />
1051                                </Box>
1052                              )
1053                            })()}
1054                          </Box>
1055                        ))}
1056                    </Box>
1057                  )
1058                })}
1059            </Box>
1060          )
1061        })}
1062        {todos.next && (
1063          <Button
1064            key="more"
1065            plain
1066            dimColor
1067            // Stays as it is while loading: the refresh button is the one loading line.
1068            label={str.loadMore(TODO_LIMIT)}
1069            onPress={async () => {
1070              if (todos.isLoading) return
1071              const had = new Set(todos.items.map(i => i.key))
1072              await loadTodos($, str, true)
1073              // The ring would keep the button's place in the order, now on an
1074              // old row: put it on the first ticket just loaded, once drawn.
1075              const first = (await read($, todosAtom)).items.find(i => !had.has(i.key))
1076              if (first) $.clock.after(300, () => void backToRow(first.key))
1077            }}
1078          />
1079        )}
1080      </Box>
1081    )
1082  })
1083
1084  on('tool.call', { tool: 'mcp__branch8-jira-mod__set_ticket' }, async ($, e) => {
1085    const input = e as unknown as Record<string, unknown>
1086    const t: Ticket = {
1087      key: str(input.key).trim().toUpperCase(),
1088      summary: str(input.summary),
1089      status: str(input.status),
1090      cloudId: str(input.cloudId) || SITE,
1091    }
1092    if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(t.key)) {
1093      return { result: `Not a Jira issue key: "${str(input.key)}".` as never }
1094    }
1095    // Another project's ticket in this folder: the person decides first.
1096    const folder = await read($, folderAtom)
1097    if (isOtherProject(t.key, folder.project) && input.confirmedOtherProject !== true) {
1098      const dir = await folderForProject($, projectOf(t.key))
1099      return {
1100        result: `Not set: ${t.key} belongs to ${projectOf(t.key)}, but this folder is mapped to Jira ${folder.project}. Ask the user with one question card, in their language: (1) open a new session in that project's folder — recommended — telling them to run \`cd ${dir} && claude\`; (2) work on it here anyway; (3) cancel. Only on (2), call set_ticket again with confirmedOtherProject: true, then continue. On (1) or (3) do not start the work.` as never,
1101      }
1102    }
1103    await update($, ticketAtom, () => t)
1104    return { result: `Current ticket set to ${t.key}.` as never }
1105  }).catch(($, e, next) => next(e))
1106
1107  on('tool.call', { tool: 'mcp__branch8-jira-mod__clear_ticket' }, async $ => {
1108    const was = (await read($, ticketAtom))?.key
1109    await update($, ticketAtom, () => null)
1110    return { result: (was ? `Unselected ${was}; no current ticket.` : 'No current ticket was set.') as never }
1111  }).catch(($, e, next) => next(e))
1112
1113  on('tool.call', { tool: 'mcp__branch8-jira-mod__get_ticket' }, async $ => {
1114    const ticket = await read($, ticketAtom)
1115    const asked = await read($, askedAtom)
1116    return { result: JSON.stringify({ ticket, asked }) as never }
1117  }).catch(($, e, next) => next(e))
1118
1119  on('tool.call', { tool: 'mcp__branch8-jira-mod__mark_asked' }, async ($, e) => {
1120    const mark = str((e as unknown as Record<string, unknown>).mark)
1121    await update($, askedAtom, a => (a.includes(mark) ? a : [...a, mark]))
1122    return { result: `Recorded ${mark}.` as never }
1123  }).catch(($, e, next) => next(e))
1124
1125  // Safety net: remind Claude at the three points, once per ticket and point.
1126  on('tool.call', async ($, e, next) => {
1127    const ran = await next(e)
1128    if (ran.deny !== undefined || ran.isError) return ran
1129    // A folder record was just written (e.g. Claude recording the board):
1130    // refresh the pane's folder ↔ project line now, not next session.
1131    if (touchesFolderRecords(e.tool, e as unknown as Record<string, unknown>)) {
1132      try {
1133        await resolveFolder($)
1134      } catch {
1135        // Keep the previous answer.
1136      }
1137    }
1138    // Subagents and engine forks cannot ask the user; leave the point for the
1139    // main loop instead of spending it there.
1140    if ((e as { agentId?: string }).agentId) return ran
1141    const node = detectNode(e.tool, e as unknown as Record<string, unknown>)
1142    if (!node) return ran
1143    const reminder = reminderFor(node, await read($, ticketAtom), await read($, askedAtom))
1144    if (!reminder) return ran
1145    await update($, askedAtom, a => [...a, reminder.mark])
1146    return { ...ran, context: [...(ran.context ?? []), reminder.text] }
1147  }).catch(($, e, next) => next(e))
1148
1149  on('prompt.compose', async ($, e, next) => {
1150    const out = await next(e)
1151    const text = rulesSection(await read($, ticketAtom), await read($, askedAtom))
1152    return { sections: [...out.sections, { id: `${P}:rules`, text, scope: 'session' as const }] }
1153  })
1154
1155  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1156    const t = await read($, ticketAtom)
1157    const todos = await read($, todosAtom)
1158    // The count stands in for the pane while it is closed.
1159    const count = todos.isLoaded && !(await read($, todoOpenAtom)) ? todos.items.length : null
1160    const hint = (await read($, orgAtom)) && !(await read($, todoOpenAtom))
1161    const line = bandText(t, count, undefined, STRINGS[(await read($, langAtom)) as Lang], hint)
1162    if (!line || e.props.hasSurvey) return next(e)
1163    const { Text } = $.ui.resolve(e)
1164    return <Text>{line}</Text>
1165  })
1166
1167  // The band's status comes with the ticket (the pane, set_ticket) and follows
1168  // the pane's loads and moves; Jira is asked only for a status still unknown,
1169  // once per ticket. No polling.
1170  on('turn.complete', async ($, e, next) => {
1171    const out = await next(e)
1172    // Records can change outside this session, or /cd can move it.
1173    try {
1174      await resolveFolder($)
1175    } catch {
1176      // Keep the previous answer.
1177    }
1178    const t = await read($, ticketAtom)
1179    if (!t || t.status || (await read($, statusAskedAtom)) === t.key) return out
1180    await update($, statusAskedAtom, () => t.key)
1181
1182    let fresh: { summary?: string; status?: string } | null = null
1183    for (const server of ATLASSIAN) {
1184      try {
1185        const res = await $.mcp.call(server, 'getJiraIssue', {
1186          cloudId: t.cloudId || SITE,
1187          issueIdOrKey: t.key,
1188          fields: ISSUE_FIELDS,
1189        })
1190        // Installed but signed out answers isError: try the other server.
1191        if (res.isError) continue
1192        fresh = parseIssueStatus(res.content as { type: string; text?: string }[])
1193        if (fresh) break
1194      } catch {
1195        // Not connected under this name; try the next one.
1196      }
1197    }
1198    if (fresh) await update($, ticketAtom, cur => (cur && cur.key === t.key ? { ...cur, ...fresh } : cur))
1199    return out
1200  })
hooks/logic.ts 959 lines
1// Pure logic for the Branch8 Jira mod: no engine calls here, so it is
2// unit-testable. register.tsx wires these into the engine.
3import type { Node, Ticket, TodoItem } from '../types'
4
5const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
6const SKIP_FLAGS = /(^|\s)(--help|-h|--dry-run)(\s|$)/
7
8// Blank out quoted strings so `echo "gh pr create"` does not count.
9function unquoted(cmd: string): string {
10  return cmd.replace(/"[^"]*"|'[^']*'/g, ' ')
11}
12
13// Which of the three workflow points a tool call marks, if any.
14export function detectNode(tool: string, input: Record<string, unknown>): Node | null {
15  if (EDIT_TOOLS.has(tool)) return 'start'
16  if (/__create_pull_request$/.test(tool)) return 'pr'
17  if (/__merge_pull_request$/.test(tool)) return 'merge'
18  if (tool !== 'Bash' || typeof input.command !== 'string') return null
19  // A heredoc body (commit message, file content) is data, not commands.
20  const command = input.command.split('<<')[0] ?? ''
21  for (const part of unquoted(command).split(/&&|\|\||;|\n/)) {
22    const p = part.trim()
23    if (SKIP_FLAGS.test(p)) continue
24    if (/^gh\s+pr\s+create\b/.test(p)) return 'pr'
25    // Only a merge that lands the PR now. `git merge` is left out: it is as
26    // often syncing main into a feature branch as landing work, and `--auto`
27    // only queues the merge.
28    if (/^gh\s+pr\s+merge\b/.test(p) && !/(^|\s)--auto(\s|$)/.test(p)) return 'merge'
29  }
30  return null
31}
32
33const NODE_TEXT: Record<Node, string> = {
34  start: 'work on it has started (first file change)',
35  pr: 'a pull request was just opened',
36  merge: 'a merge just happened',
37}
38
39// The reminder Claude reads after the tool result, once per ticket and point.
40// `mark` is what gets recorded so the same reminder is not repeated.
41export function reminderFor(
42  node: Node,
43  ticket: Ticket | null,
44  asked: readonly string[],
45): { text: string; mark: string } | null {
46  if (!ticket) {
47    if (node !== 'start' || asked.includes('none:resolve')) return null
48    return {
49      mark: 'none:resolve',
50      text: 'Branch8 Jira: files are being changed but no Jira ticket is set for this task. Use the jira-workflow skill to resolve the ticket now (question card), unless the user already declined one this session.',
51    }
52  }
53  const mark = `${ticket.key}:${node}`
54  if (asked.includes(mark)) return null
55  return {
56    mark,
57    text: `Branch8 Jira: ${NODE_TEXT[node]} for ${ticket.key} (status: ${ticket.status}). Use the jira-workflow skill to decide whether to offer a status transition for the "${node}" point (question card, never move it without the user confirming).`,
58  }
59}
60
61// Terminal columns a string takes: wide (CJK, fullwidth, emoji) counts 2.
62export function displayWidth(text: string): number {
63  let w = 0
64  for (const ch of text) w += isWide(ch) ? 2 : 1
65  return w
66}
67
68function isWide(ch: string): boolean {
69  const c = ch.codePointAt(0) ?? 0
70  return (
71    (c >= 0x1100 && c <= 0x115f) ||
72    (c >= 0x2e80 && c <= 0xa4cf) ||
73    (c >= 0xac00 && c <= 0xd7a3) ||
74    (c >= 0xf900 && c <= 0xfaff) ||
75    (c >= 0xfe30 && c <= 0xfe4f) ||
76    (c >= 0xff00 && c <= 0xff60) ||
77    (c >= 0xffe0 && c <= 0xffe6) ||
78    (c >= 0x1f300 && c <= 0x1faff) ||
79    (c >= 0x20000 && c <= 0x3fffd)
80  )
81}
82
83// Cut to `max` columns on code-point boundaries, never splitting an emoji.
84function fitWidth(text: string, max: number): string {
85  if (displayWidth(text) <= max) return text
86  let out = ''
87  let w = 0
88  for (const ch of text) {
89    const cw = isWide(ch) ? 2 : 1
90    if (w + cw > max - 1) break
91    out += ch
92    w += cw
93  }
94  return out + '…'
95}
96
97// Columns the whole band may take; fits an 80-column terminal.
98const BAND_WIDTH = 78
99
100// One line above the prompt. `width` is the column budget for the whole line.
101export function formatBand(ticket: Ticket, width = BAND_WIDTH, str: Strings = STRINGS['zh-TW']): string {
102  // An unknown status is left out: the band asks Jira once, never polls, so a
103  // "loading" there could stay forever.
104  const head = `🎫 ${ticket.key}`
105  const tail = ticket.status ? ` · ${fitWidth(ticket.status, 24)}` : ''
106  const room = width - displayWidth(head) - displayWidth(tail) - 1
107  const summary = ticket.summary && room > 3 ? ` ${fitWidth(ticket.summary, room)}` : ''
108  return `${head}${summary}${tail}`
109}
110
111// The whole line above the prompt: the ticket, plus the open-ticket count
112// once /jira-todo has loaded it (so a closed pane still tells you how many).
113// Null when there is nothing to show.
114export function bandText(
115  ticket: Ticket | null,
116  todoCount: number | null,
117  width = BAND_WIDTH,
118  str: Strings = STRINGS['zh-TW'],
119  hint = false,
120): string | null {
121  // hint: org account with the pane closed, so the line says how to open it.
122  if (!ticket) {
123    if (todoCount !== null) return str.bandNoTicket(todoCount)
124    return hint ? str.bandHint : null
125  }
126  if (todoCount === null && !hint) return formatBand(ticket, width, str)
127  const tail = (todoCount === null ? '' : str.bandTail(todoCount)) + (hint ? ' · /jira-todo' : '')
128  return formatBand(ticket, width - displayWidth(tail), str) + tail
129}
130
131// The system prompt section: rules plus the current ticket, so they survive
132// compaction and apply to every turn, not only the first message.
133export function rulesSection(ticket: Ticket | null, asked: readonly string[]): string {
134  const lines = [
135    'Branch8 Jira workflow (branch8-jira-mod): use the jira-workflow skill whenever the user starts a new unit of work, when work on it starts, when a pull request is opened, and when it is merged. Ask with question cards; never move a Jira status without the user confirming.',
136    'After resolving or creating a ticket call mcp__branch8-jira-mod__set_ticket; after asking about a transition at a point call mcp__branch8-jira-mod__mark_asked with "<KEY>:<start|pr|merge>".',
137    'When the user asks about their Jira todos or tickets, also tell them that typing /jira-todo opens the todo pane.',
138  ]
139  if (ticket) {
140    const mine = asked
141      .filter(a => a.startsWith(`${ticket.key}:`))
142      .map(a => a.slice(ticket.key.length + 1))
143    lines.push(
144      `Current ticket: ${ticket.key}. Points already asked: ${mine.length ? mine.join(', ') : 'none'}. Its summary and live status: mcp__branch8-jira-mod__get_ticket.`,
145    )
146  } else {
147    lines.push('Current ticket: none set yet.')
148  }
149  return lines.join('\n')
150}
151
152type Content = readonly { type: string; text?: string }[]
153
154function firstJson(content: Content): unknown {
155  const text = content.find(c => c.type === 'text' && typeof c.text === 'string')?.text
156  if (!text) return null
157  try {
158    return JSON.parse(text)
159  } catch {
160    return null
161  }
162}
163
164// Summary and status from a getJiraIssue result; null when unreadable.
165export function parseIssueStatus(content: Content): { summary?: string; status?: string } | null {
166  const json = firstJson(content) as { data?: unknown } | null
167  if (!json) return null
168  type Issue = { fields?: { summary?: unknown; status?: { name?: unknown } } }
169  const body = (json.data ?? json) as Issue & { issues?: { nodes?: unknown } | unknown[] }
170  // Named fields answer as a one-issue search, { issues: { nodes: [...] } }.
171  const list = Array.isArray(body.issues) ? body.issues : (body.issues as { nodes?: unknown } | undefined)?.nodes
172  const issue = (Array.isArray(list) ? list[0] : body) as Issue | undefined
173  const status = issue?.fields?.status?.name
174  if (typeof status !== 'string') return null
175  const summary = issue?.fields?.summary
176  return typeof summary === 'string' ? { summary, status } : { status }
177}
178
179// Open tickets assigned to the person, for the /jira-todo pane: newest
180// first, only those updated within `days` (0: any age), in one project when
181// the scope is this folder's. Older ones are what bloated the list.
182export function todoJql(o: { days: number; project: string | null }): string {
183  return (
184    'assignee = currentUser() AND statusCategory != Done' +
185    (o.project ? ` AND project = "${o.project}"` : '') +
186    (o.days > 0 ? ` AND updated >= -${o.days}d` : '') +
187    ' ORDER BY updated DESC'
188  )
189}
190
191// The range setting's steps in days, 0 for no limit; 90 is the default.
192export const RANGES = [30, 90, 180, 0] as const
193export const DEFAULT_RANGE = 90
194export function nextRange(days: number): number {
195  const i = (RANGES as readonly number[]).indexOf(days)
196  return i < 0 ? DEFAULT_RANGE : (RANGES[(i + 1) % RANGES.length] ?? DEFAULT_RANGE)
197}
198// Only what the pane and the band draw. The default fields carry each issue's
199// description: 20 of them passed the MCP's size limit, which then answers a
200// pointer to a saved file instead of the JSON (2026-10-16).
201export const TODO_FIELDS = ['summary', 'status', 'issuetype']
202export const ISSUE_FIELDS = ['summary', 'status']
203
204// Items from a searchJiraIssuesUsingJql result; null when unreadable.
205export function parseIssueList(content: Content): TodoItem[] | null {
206  return parseIssuePage(content)?.items ?? null
207}
208
209// One page of a search: its items and the token for the next page, null on
210// the last one; null when unreadable.
211export function parseIssuePage(content: Content): { items: TodoItem[]; next: string | null } | null {
212  const json = firstJson(content) as { data?: unknown } | null
213  if (!json) return null
214  const page = (json.data ?? json) as { issues?: unknown; nextPageToken?: unknown; isLast?: unknown }
215  const found = page.issues
216  // An array, or { nodes: [...] } when the search names its fields.
217  const issues = Array.isArray(found) ? found : (found as { nodes?: unknown } | undefined)?.nodes
218  if (!Array.isArray(issues)) return null
219  const items: TodoItem[] = []
220  for (const raw of issues) {
221    const i = raw as {
222      key?: unknown
223      fields?: { summary?: unknown; status?: { name?: unknown }; issuetype?: { name?: unknown } }
224    }
225    if (typeof i?.key !== 'string') continue
226    const item: TodoItem = {
227      key: i.key,
228      summary: typeof i.fields?.summary === 'string' ? i.fields.summary : '',
229      status: typeof i.fields?.status?.name === 'string' ? i.fields.status.name : '',
230    }
231    if (typeof i.fields?.issuetype?.name === 'string') item.type = i.fields.issuetype.name
232    items.push(item)
233  }
234  // The token: nextPageToken beside an array, pageInfo.endCursor beside nodes.
235  const info = (found as { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown } } | undefined)?.pageInfo
236  const next = info
237    ? info.hasNextPage === true && typeof info.endCursor === 'string'
238      ? info.endCursor
239      : null
240    : page.isLast !== true && typeof page.nextPageToken === 'string'
241      ? page.nextPageToken
242      : null
243  return { items, next }
244}
245
246export type TodoGroup = {
247  project: string
248  count: number
249  statuses: { status: string; items: TodoItem[] }[]
250}
251
252// Tree for the pane: project (key prefix), then status, both in the order
253// they first appear (the list is newest-updated first).
254export function groupTodos(items: readonly TodoItem[]): TodoGroup[] {
255  const groups: TodoGroup[] = []
256  for (const item of items) {
257    const project = item.key.split('-')[0] ?? item.key
258    let g = groups.find(x => x.project === project)
259    if (!g) {
260      g = { project, count: 0, statuses: [] }
261      groups.push(g)
262    }
263    g.count += 1
264    let s = g.statuses.find(x => x.status === item.status)
265    if (!s) {
266      s = { status: item.status, items: [] }
267      g.statuses.push(s)
268    }
269    s.items.push(item)
270  }
271  return groups
272}
273
274// The issue's page on the Jira site.
275export function issueUrl(site: string, key: string): string {
276  return `${site.replace(/\/+$/, '')}/browse/${key}`
277}
278
279// One tree row: key and summary only (the status is the row's group).
280export function todoRow(item: TodoItem, width: number): string {
281  const room = width - displayWidth(item.key) - 1
282  return room > 3 ? `${item.key} ${fitWidth(item.summary, room)}` : item.key
283}
284
285// --- Language ---
286
287export type Lang = 'zh-TW' | 'zh-CN' | 'en'
288export type LangPref = Lang | 'auto'
289
290// Same mapping as the branch8-jira-check hook: a Claude Code "language"
291// setting, else the locale; anything else is English.
292export function langOf(setting: string | undefined, locale?: string): Lang {
293  const v = (setting || locale || '').toLowerCase().replace(/_/g, '-')
294  if (/简体|簡體|simplified|^zh-cn|^zh-sg|^zh-hans/.test(v)) return 'zh-CN'
295  if (/中文|繁體|繁体|正體|台灣|臺灣|香港|chinese|^zh/.test(v)) return 'zh-TW'
296  return 'en'
297}
298
299const PREF_CYCLE: LangPref[] = ['zh-TW', 'zh-CN', 'en', 'auto']
300
301// The pane's language button: 繁中 → 简中 → English → auto → 繁中.
302export function nextLangPref(pref: LangPref): LangPref {
303  const i = PREF_CYCLE.indexOf(pref)
304  return PREF_CYCLE[(i + 1) % PREF_CYCLE.length] ?? 'auto'
305}
306
307// A chosen language wins; "auto" (or anything unknown) follows Claude Code.
308export function resolveLang(pref: unknown, setting: string | undefined, locale: string | undefined): Lang {
309  return pref === 'zh-TW' || pref === 'zh-CN' || pref === 'en' ? pref : langOf(setting, locale)
310}
311
312export type Strings = {
313  help: readonly [string, string]
314  close: string
315  language: string
316  langNames: Record<LangPref, string>
317  open: string
318  openFailed: (url: string) => string
319  loading: string
320  refreshing: string
321  empty: string
322  noStatus: string
323  noJira: string
324  signedOut: string
325  queryFailed: string
326  todoCount: (n: number) => string
327  closed: string
328  current: (key: string) => string
329  bandNoTicket: (n: number) => string
330  bandTail: (n: number) => string
331  changeMapping: string
332  setMapping: string
333  thisFolderTag: string
334  otherTag: string
335  scope: string
336  scopeNames: Record<'folder' | 'all', string>
337  confirmOther: (key: string, folderProject: string) => string
338  moveThere: string
339  stayHere: string
340  cancel: string
341  moveHint: (dir: string) => string
342  menuWork: string
343  helpTitle: string
344  autoOpen: (on: boolean) => string
345  bandHint: string
346  closedHint: string
347  narrowHint: string
348  menuOpen: string
349  menuCancel: string
350  clearTicket: (key: string) => string
351  menuClear: string
352  cleared: string
353  noFocus: string
354  settings: string
355  settingsBack: string
356  rangeLabel: (days: number) => string
357  loadMore: (n: number) => string
358  updateTo: (v: string) => string
359  updating: string
360  updated: (v: string) => string
361  updateFailed: string
362  alreadyLatest: (v: string) => string
363  menuStatus: string
364  statusTitle: (key: string) => string
365  statusNow: (status: string) => string
366  loadingTransitions: string
367  noTransitions: string
368  confirmMove: (key: string) => string
369  confirmYes: string
370  confirmNo: string
371  back: string
372  moved: (key: string, to: string) => string
373  moveFailed: (url: string) => string
374  needsFields: string
375  refresh: string
376  labelClaude: string
377  labelJira: string
378  labelFolder: string
379  jiraPending: string
380  jiraDomain: string
381  jiraDiffers: string
382  noJiraFolder: string
383  unmappedFolder: string
384}
385
386const LANG_NAMES = { 'zh-TW': '繁中', 'zh-CN': '简中', en: 'English' }
387
388export const STRINGS: Record<Lang, Strings> = {
389  'zh-TW': {
390    help: ['Ctrl+X Tab 進面板 · Esc 回輸入框', '↑↓ 移動 · Enter 選單/收合 · o 開啟 Jira'],
391    close: '✕ 關閉 Jira 面板',
392    settings: '⚙ 設定',
393    settingsBack: '↩ 返回',
394    rangeLabel: days => `時間:${days ? `近 ${days / 30} 個月` : '不限'}`,
395    loadMore: n => `⋯ 再載入 ${n} 張`,
396    updateTo: v => `⬆ 更新到 v${v}`,
397    updating: '更新中…',
398    updated: v => `已更新到 v${v},重新載入中;若沒生效請開新 session`,
399    updateFailed: '更新失敗,請手動執行 /plugin update branch8-jira-mod@branch8',
400    alreadyLatest: v => `marketplace 目前最新仍是 v${v},新版可能還沒同步,稍後再試`,
401    menuStatus: '↻ 切換狀態',
402    statusTitle: key => `${key} 切換到…`,
403    statusNow: status => `目前:${status || '未知'}`,
404    loadingTransitions: '向 Jira 查詢可切換的狀態…',
405    noTransitions: '這張單目前沒有可切換的狀態',
406    confirmMove: key => `確認切換 ${key}?`,
407    confirmYes: '✔ 確認',
408    confirmNo: '✕ 取消',
409    back: '↩ 返回',
410    moved: (key, to) => `${key} 已移到「${to}」`,
411    moveFailed: url => `Jira 沒有接受這次切換(可能要填欄位或沒有權限),請到網頁處理:${url}`,
412    needsFields: '⚠ Jira 在這一步可能要求填欄位',
413    refresh: '⟳ 重新整理',
414    labelClaude: 'Claude',
415    labelJira: 'Jira  ',
416    labelFolder: '資料夾',
417    jiraPending: '連線中…',
418    jiraDomain: '⚠ 不是公司網域的帳號',
419    jiraDiffers: '⚠ 和 Claude 帳號不同',
420    noJiraFolder: '不使用 Jira',
421    unmappedFolder: '尚未對應 Jira 專案',
422    language: '語言:',
423    langNames: { ...LANG_NAMES, auto: '自動' },
424    open: '開啟',
425    openFailed: url => `無法開啟瀏覽器:${url}`,
426    loading: '查詢中',
427    refreshing: '更新中…',
428    empty: '沒有指派給你、還沒完成的單。',
429    noStatus: '(無狀態)',
430    noJira: 'Jira 沒有連線(找不到 Atlassian MCP),請先跑 /jira-check',
431    signedOut: 'Jira 查詢失敗:Atlassian 可能還沒登入,請跑 /jira-check',
432    queryFailed: 'Jira 查詢失敗,請稍後再試',
433    todoCount: n => `Jira 待辦:${n} 張。`,
434    closed: 'Jira 待辦面板已關閉(再打 /jira-todo 打開)。',
435    current: key => `目前的單:${key}`,
436    bandNoTicket: n => `📋 Jira 待辦 ${n} 張 · /jira-todo`,
437    bandTail: n => ` · 待辦 ${n}`,
438    changeMapping: '   改對應:/jira-board',
439    setMapping: '   設定:/jira-board',
440    thisFolderTag: '← 本資料夾的專案',
441    otherTag: '← 其他專案(非本資料夾)',
442    scope: '範圍:',
443    scopeNames: { folder: '本資料夾', all: '全部專案' },
444    confirmOther: (key, fp) => `${key} 不屬於本資料夾的專案(${fp})`,
445    moveThere: '到該專案資料夾開新 session(建議)',
446    stayHere: '仍在這裡做',
447    cancel: '取消',
448    moveHint: dir => `在新終端機執行:cd ${dir} && claude`,
449    menuWork: '▶ 設為目前的單並處理',
450    helpTitle: '⌨ 操作說明',
451    autoOpen: on => `開場自動開啟:${on ? '開' : '關'}`,
452    bandHint: '📋 輸入 /jira-todo 開啟 Jira 待辦',
453    closedHint: '已關閉,輸入 /jira-todo 可再打開',
454    narrowHint: 'Jira 待辦面板需要較寬的視窗:拉寬視窗,或隨時輸入 /jira-todo',
455    menuOpen: '🔗 開啟 Jira',
456    menuCancel: '✕ 取消',
457    clearTicket: key => `⏹ 取消選定 ${key}`,
458    menuClear: '⏹ 取消選定此任務',
459    cleared: '已取消選定任務:Claude 目前沒有綁定任何單',
460    noFocus: '請先用 ↑↓ 把游標移到一張單上,再按 o',
461  },
462  'zh-CN': {
463    help: ['Ctrl+X Tab 进面板 · Esc 回输入框', '↑↓ 移动 · Enter 菜单/收合 · o 打开 Jira'],
464    close: '✕ 关闭 Jira 面板',
465    settings: '⚙ 设置',
466    settingsBack: '↩ 返回',
467    rangeLabel: days => `时间:${days ? `近 ${days / 30} 个月` : '不限'}`,
468    loadMore: n => `⋯ 再加载 ${n} 张`,
469    updateTo: v => `⬆ 更新到 v${v}`,
470    updating: '更新中…',
471    updated: v => `已更新到 v${v},重新加载中;若没生效请开新 session`,
472    updateFailed: '更新失败,请手动执行 /plugin update branch8-jira-mod@branch8',
473    alreadyLatest: v => `marketplace 目前最新仍是 v${v},新版可能还没同步,稍后再试`,
474    menuStatus: '↻ 切换状态',
475    statusTitle: key => `${key} 切换到…`,
476    statusNow: status => `目前:${status || '未知'}`,
477    loadingTransitions: '向 Jira 查询可切换的状态…',
478    noTransitions: '这张单目前没有可切换的状态',
479    confirmMove: key => `确认切换 ${key}?`,
480    confirmYes: '✔ 确认',
481    confirmNo: '✕ 取消',
482    back: '↩ 返回',
483    moved: (key, to) => `${key} 已移到「${to}」`,
484    moveFailed: url => `Jira 没有接受这次切换(可能要填字段或没有权限),请到网页处理:${url}`,
485    needsFields: '⚠ Jira 在这一步可能要求填字段',
486    refresh: '⟳ 刷新',
487    labelClaude: 'Claude',
488    labelJira: 'Jira  ',
489    labelFolder: '文件夹',
490    jiraPending: '连接中…',
491    jiraDomain: '⚠ 不是公司域名的账号',
492    jiraDiffers: '⚠ 和 Claude 账号不同',
493    noJiraFolder: '不使用 Jira',
494    unmappedFolder: '尚未对应 Jira 项目',
495    language: '语言:',
496    langNames: { ...LANG_NAMES, auto: '自动' },
497    open: '打开',
498    openFailed: url => `无法打开浏览器:${url}`,
499    loading: '查询中',
500    refreshing: '更新中…',
501    empty: '没有指派给你、尚未完成的单。',
502    noStatus: '(无状态)',
503    noJira: 'Jira 未连接(找不到 Atlassian MCP),请先运行 /jira-check',
504    signedOut: 'Jira 查询失败:Atlassian 可能尚未登录,请运行 /jira-check',
505    queryFailed: 'Jira 查询失败,请稍后再试',
506    todoCount: n => `Jira 待办:${n} 张。`,
507    closed: 'Jira 待办面板已关闭(再输入 /jira-todo 打开)。',
508    current: key => `当前的单:${key}`,
509    bandNoTicket: n => `📋 Jira 待办 ${n} 张 · /jira-todo`,
510    bandTail: n => ` · 待办 ${n}`,
511    changeMapping: '   改对应:/jira-board',
512    setMapping: '   设置:/jira-board',
513    thisFolderTag: '← 本文件夹的项目',
514    otherTag: '← 其他项目(非本文件夹)',
515    scope: '范围:',
516    scopeNames: { folder: '本文件夹', all: '全部项目' },
517    confirmOther: (key, fp) => `${key} 不属于本文件夹的项目(${fp})`,
518    moveThere: '到该项目文件夹开新 session(建议)',
519    stayHere: '仍在这里做',
520    cancel: '取消',
521    moveHint: dir => `在新终端运行:cd ${dir} && claude`,
522    menuWork: '▶ 设为当前的单并处理',
523    helpTitle: '⌨ 操作说明',
524    autoOpen: on => `启动时自动打开:${on ? '开' : '关'}`,
525    bandHint: '📋 输入 /jira-todo 打开 Jira 待办',
526    closedHint: '已关闭,输入 /jira-todo 可再打开',
527    narrowHint: 'Jira 待办面板需要较宽的窗口:拉宽窗口,或随时输入 /jira-todo',
528    menuOpen: '🔗 打开 Jira',
529    menuCancel: '✕ 取消',
530    clearTicket: key => `⏹ 取消选定 ${key}`,
531    menuClear: '⏹ 取消选定此任务',
532    cleared: '已取消选定任务:Claude 当前没有绑定任何单',
533    noFocus: '请先用 ↑↓ 把光标移到一张单上,再按 o',
534  },
535  en: {
536    help: ['Ctrl+X Tab: into pane · Esc: back', '↑↓ move · Enter menu/fold · o open Jira'],
537    close: '✕ Close the Jira pane',
538    settings: '⚙ Settings',
539    settingsBack: '↩ Back',
540    rangeLabel: days => `Updated: ${days ? `last ${days / 30} month${days > 30 ? 's' : ''}` : 'any time'}`,
541    loadMore: n => `⋯ Load ${n} more`,
542    updateTo: v => `⬆ Update to v${v}`,
543    updating: 'Updating…',
544    updated: v => `Updated to v${v}, reloading; open a new session if it does not take`,
545    updateFailed: 'Update failed: run /plugin update branch8-jira-mod@branch8',
546    alreadyLatest: v => `The marketplace still has v${v}; the new one may not have synced yet, try again later`,
547    menuStatus: '↻ Change status',
548    statusTitle: key => `Move ${key} to…`,
549    statusNow: status => `Now: ${status || 'unknown'}`,
550    loadingTransitions: 'Asking Jira where it can move…',
551    noTransitions: 'This ticket has nowhere to move right now',
552    confirmMove: key => `Move ${key}?`,
553    confirmYes: '✔ Confirm',
554    confirmNo: '✕ Cancel',
555    back: '↩ Back',
556    moved: (key, to) => `${key} moved to "${to}"`,
557    moveFailed: url => `Jira did not take the move (fields to fill, or no permission); do it on the web: ${url}`,
558    needsFields: '⚠ Jira may ask for fields on this step',
559    refresh: '⟳ Refresh',
560    labelClaude: 'Claude',
561    labelJira: 'Jira  ',
562    labelFolder: 'Folder',
563    jiraPending: 'connecting…',
564    jiraDomain: '⚠ not a company account',
565    jiraDiffers: '⚠ not the Claude account',
566    noJiraFolder: 'no Jira',
567    unmappedFolder: 'no Jira project recorded yet',
568    language: 'Language: ',
569    langNames: { ...LANG_NAMES, auto: 'Auto' },
570    open: 'Open',
571    openFailed: url => `Could not open a browser: ${url}`,
572    loading: 'loading',
573    refreshing: 'Refreshing…',
574    empty: 'No open tickets assigned to you.',
575    noStatus: '(no status)',
576    noJira: 'Jira is not connected (no Atlassian MCP). Run /jira-check',
577    signedOut: 'Jira query failed: Atlassian may be signed out. Run /jira-check',
578    queryFailed: 'Jira query failed, try again later',
579    todoCount: n => `Jira: ${n} open tickets.`,
580    closed: 'Jira pane closed (/jira-todo opens it again).',
581    current: key => `Current ticket: ${key}`,
582    bandNoTicket: n => `📋 Jira: ${n} open · /jira-todo`,
583    bandTail: n => ` · ${n} open`,
584    changeMapping: '   change: /jira-board',
585    setMapping: '   set: /jira-board',
586    thisFolderTag: "← this folder's project",
587    otherTag: '← other project',
588    scope: 'Scope: ',
589    scopeNames: { folder: 'this folder', all: 'all projects' },
590    confirmOther: (key, fp) => `${key} is not this folder's project (${fp})`,
591    moveThere: "Open a session in its folder (recommended)",
592    stayHere: 'Work on it here',
593    cancel: 'Cancel',
594    moveHint: dir => `In a new terminal: cd ${dir} && claude`,
595    menuWork: '▶ Make it the current ticket',
596    helpTitle: '⌨ Keys',
597    autoOpen: on => `Open at start: ${on ? 'on' : 'off'}`,
598    bandHint: '📋 Type /jira-todo for your Jira todos',
599    closedHint: 'Closed. Type /jira-todo to open it again',
600    narrowHint: 'The Jira pane needs a wider window: widen it, or type /jira-todo any time',
601    menuOpen: '🔗 Open in Jira',
602    menuCancel: '✕ Cancel',
603    clearTicket: key => `⏹ Unselect ${key}`,
604    menuClear: '⏹ Unselect this ticket',
605    cleared: 'Ticket unselected: Claude has no current ticket',
606    noFocus: 'Move to a ticket with ↑↓ first, then press o',
607  },
608}
609
610// Kept for callers that want the default help lines.
611export const TODO_HELP = STRINGS['zh-TW'].help
612
613// --- Status colours ---
614
615export type Tone = 'success' | 'warning' | 'error' | 'suggestion' | 'inactive' | 'subtle'
616
617// Colour a status by what its name says; Jira's own categories are not
618// reliable here (HS files 已取消 as in progress, HTGO2 files WIP as to do).
619export function statusTone(status: string): Tone {
620  // The [HOTFIX] tag names a lane, not a problem; drop it before matching "fix".
621  const s = status.toLowerCase().replace(/hotfix/g, '')
622  if (/取消|cancel|discard/.test(s)) return 'inactive'
623  if (/fix|block|pending|hold|rca|root cause/.test(s)) return 'error'
624  if (/qa done|deployed|完成|^done|ready to deploy|merged|release/.test(s)) return 'success'
625  if (/進行中|进行中|wip|in progress|dev done|qa ?ing|qa to ?do|review|pr created/.test(s)) return 'warning'
626  if (/待辦|待办|to ?do|backlog|discuss|open/.test(s)) return 'suggestion'
627  return 'subtle'
628}
629
630// --- Folder ↔ Jira project: the records branch8-jira-check keeps ---
631
632// File name of a folder's record, exactly as hooks/lib.sh computes it.
633export function recordFileName(dir: string): string {
634  const key = dir.replace(/[/\\: ]/g, '_').replace(/^_+/, '')
635  return `${key || 'root'}.json`
636}
637
638// The folder itself, then each parent up to the root (POSIX or Windows).
639export function ancestorDirs(dir: string): string[] {
640  const sep = dir.includes('\\') ? '\\' : '/'
641  const out: string[] = []
642  let cur = dir.replace(/[/\\]+$/, '') || sep
643  for (;;) {
644    out.push(cur)
645    const i = cur.lastIndexOf(sep)
646    if (i < 0) break
647    const parent = i === 0 ? sep : /^[A-Za-z]:$/.test(cur.slice(0, i)) ? cur.slice(0, i) + sep : cur.slice(0, i)
648    if (parent === cur) break
649    cur = parent
650  }
651  return out
652}
653
654export type FolderRecord = { kind: 'project'; key: string; name: string } | { kind: 'none' }
655
656export function parseRecord(text: string): FolderRecord | null {
657  try {
658    const r = JSON.parse(text) as { projectKey?: unknown; projectName?: unknown; jira?: unknown; company?: unknown }
659    if (typeof r.projectKey === 'string' && r.projectKey) {
660      return { kind: 'project', key: r.projectKey, name: typeof r.projectName === 'string' ? r.projectName : '' }
661    }
662    if (r.jira === 'none' || r.company === false) return { kind: 'none' }
663    return null
664  } catch {
665    return null
666  }
667}
668
669export function projectOf(key: string): string {
670  return key.split('-')[0] ?? key
671}
672
673// A ticket outside this folder's Jira project (false when the folder has none).
674export function isOtherProject(key: string, folderProject: string | null): boolean {
675  return !!folderProject && projectOf(key) !== folderProject
676}
677
678export type Scope = 'folder' | 'all'
679
680// "auto" (or unknown) shows this folder's project when it has one.
681export function effectiveScope(pref: unknown, folderProject: string | null): Scope {
682  if (!folderProject) return 'all'
683  return pref === 'all' ? 'all' : 'folder'
684}
685
686export function applyScope(items: readonly TodoItem[], scope: Scope, folderProject: string | null): TodoItem[] {
687  return scope === 'folder' && folderProject ? items.filter(i => projectOf(i.key) === folderProject) : [...items]
688}
689
690// Folders recorded for a Jira project, for "open a session there".
691export function foldersFor(records: readonly { dir: string; text: string }[], projectKey: string): string[] {
692  return records.filter(r => {
693    const rec = parseRecord(r.text)
694    return rec?.kind === 'project' && rec.key === projectKey
695  }).map(r => r.dir)
696}
697
698// Where branch8-jira-check suggests a new project folder.
699export function suggestedFolder(workspaceRoot: string, projectKey: string): string {
700  return `${workspaceRoot.replace(/[/\\]+$/, '')}/${projectKey}`
701}
702
703// Icon for a Jira issue type, by name (each project names its own types).
704export function typeIcon(type: string | undefined): string {
705  const t = (type ?? '').toLowerCase()
706  if (/hotfix/.test(t)) return '🔥'
707  if (/sub-?task|子任務|子任务/.test(t)) return '🔹'
708  if (/bug|錯誤|错误|漏洞|defect/.test(t)) return '🐞'
709  if (/epic|大型工作/.test(t)) return '⚡'
710  if (/story|故事|功能|feature/.test(t)) return '✨'
711  if (/task|任務|任务/.test(t)) return '✅'
712  if (/inquire|詢問|询问|question/.test(t)) return '❓'
713  return '📄'
714}
715
716// The ticket under the pane's cursor, from the focused element's key: a
717// ticket row is keyed by its issue key; anything else (a button, a menu
718// item) means no ticket, so a stale one is never reused.
719export function focusedTicket(element: string | undefined): string | null {
720  return element && /^[A-Z][A-Z0-9_]*-\d+$/.test(element) ? element : null
721}
722
723// Whether Claude Code is signed in to the Branch8 organisation, from the
724// config dir's .claude.json (as branch8-jira-check's banner decides). An API
725// key, token or cloud provider overrides that login, so it never counts.
726export function isOrgAccount(claudeJson: string, orgId: string, hasLoginOverride: boolean): boolean {
727  if (hasLoginOverride || !orgId) return false
728  try {
729    const j = JSON.parse(claudeJson) as { oauthAccount?: { organizationUuid?: unknown } }
730    return j.oauthAccount?.organizationUuid === orgId
731  } catch {
732    return false
733  }
734}
735
736// A tool call that may have written a branch8-jira-check folder record, so
737// the pane's folder ↔ project line should be read again at once.
738export function touchesFolderRecords(tool: string, input: Record<string, unknown>): boolean {
739  const path = /branch8-jira[/\\]projects/
740  if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') {
741    return typeof input.file_path === 'string' && path.test(input.file_path)
742  }
743  if (tool === 'Bash') return typeof input.command === 'string' && path.test(input.command)
744  return false
745}
746
747// What the ⚙ popup offers, in order; scope only where the folder has a project.
748export function settingsValues(hasProject: boolean): string[] {
749  return ['lang', 'auto', ...(hasProject ? ['scope'] : []), 'range', 'close', 'back']
750}
751
752// What a row's menu offers, in order: another project's ticket first offers its
753// own folder; the current ticket offers unselecting instead of selecting.
754export function menuValues(other: boolean, isCurrent: boolean): string[] {
755  return [...(other ? ['move'] : []), isCurrent ? 'clear' : 'work', 'status', 'open', 'cancel']
756}
757
758// The pane's focusable elements in the order the tree draws them: the arrows
759// walk this list (see isArrowScroll). Keep it in step with the pane's render.
760export function focusOrder(o: {
761  groups: readonly TodoGroup[]
762  folded: readonly string[]
763  statusFolded: readonly string[]
764  settings?: readonly string[] | null
765  current?: string | null
766  // An open row menu; transitions (their ids) when its status popup is open,
767  // confirm when the confirmation popup under it is.
768  menu?: { key: string; values: readonly string[]; transitions?: readonly string[] | null; confirm?: boolean } | null
769  more?: boolean
770  update?: boolean
771}): string[] {
772  const keys = ['settings', 'hotkey-o']
773  if (o.update) keys.push('update')
774  for (const v of o.settings ?? []) keys.push(`settings-${v}`)
775  if (o.current) keys.push('clear-top')
776  keys.push('refresh')
777  for (const g of o.groups) {
778    keys.push(`p-${g.project}`)
779    if (o.folded.includes(g.project)) continue
780    for (const st of g.statuses) {
781      const fk = `${g.project}/${st.status}`
782      keys.push(`s-${fk}`)
783      if (o.statusFolded.includes(fk)) continue
784      for (const item of st.items) {
785        keys.push(item.key)
786        if (o.menu?.key === item.key) {
787          for (const v of o.menu.values) keys.push(`menu-${item.key}-${v}`)
788          if (o.menu.transitions) {
789            for (const id of o.menu.transitions) keys.push(`menu-${item.key}-t-${id}`)
790            keys.push(`menu-${item.key}-tback`)
791          }
792          if (o.menu.confirm) keys.push(`menu-${item.key}-yes`, `menu-${item.key}-no`)
793        }
794      }
795    }
796  }
797  if (o.more) keys.push('more')
798  return keys
799}
800
801// Where the ring was when it left the pane (Esc, a click on the prompt): the
802// first arrow back lands there again rather than a step past it.
803const LEFT = '\u0000left:'
804export function leftFrom(key: string | null): string | null {
805  return key === null || key.startsWith(LEFT) ? key : LEFT + key
806}
807
808// The element one step from `current` (by > 0 down), held at the ends; from
809// nothing, or from an element no longer drawn, the first or the last.
810export function stepFocus(order: readonly string[], current: string | null, by: number): string | null {
811  if (!order.length) return null
812  if (current?.startsWith(LEFT)) {
813    const was = current.slice(LEFT.length)
814    if (order.includes(was)) return was
815    current = null
816  }
817  const i = current ? order.indexOf(current) : -1
818  if (i < 0) return (by > 0 ? order[0] : order[order.length - 1]) ?? null
819  return order[Math.min(order.length - 1, Math.max(0, i + Math.sign(by)))] ?? null
820}
821
822// A pane taller than its window scrolls a row on each arrow instead of moving
823// the ring (2026-10-16, Tabby on WSL): a person's one-row move with no pointer
824// is an arrow key; the wheel carries a pointer, the page keys move further.
825export function isArrowScroll(e: { by: number; origin: { kind: string }; pointer?: unknown }): boolean {
826  return e.origin.kind === 'person' && !e.pointer && Math.abs(e.by) === 1
827}
828
829// What Claude reads when the person picks or unselects a ticket in the pane.
830// The system prompt's rules section says it too, but another plugin's
831// prompt.compose hook can answer without passing the event on (2026-10-20:
832// cc-plugin-sec-default), and Claude then never learns of the pick.
833export function pickNote(ticket: Ticket | null): string {
834  if (!ticket) {
835    return '[branch8-jira-mod] In the /jira-todo pane the user unselected the ticket: this session has no current Jira ticket now.'
836  }
837  const what = ticket.summary ? ` "${ticket.summary}"` : ''
838  const status = ticket.status ? ` (status: ${ticket.status})` : ''
839  return (
840    `[branch8-jira-mod] In the /jira-todo pane the user picked ${ticket.key}${what}${status} as the current Jira ticket. ` +
841    'Work on this ticket from now on; when the user says "this one" or "this ticket", they mean it. ' +
842    'It is already recorded: no need to call set_ticket again.'
843  )
844}
845
846// The version in the plugin's own plugin.json, as the pane shows it; null
847// when unreadable.
848export function manifestVersion(text: string): string | null {
849  try {
850    const v = (JSON.parse(text) as { version?: unknown }).version
851    return typeof v === 'string' && v ? v : null
852  } catch {
853    return null
854  }
855}
856
857// Whether `latest` is a higher version than `current`, number by number.
858export function isNewer(latest: string | null, current: string | null): boolean {
859  if (!latest || !current) return false
860  const a = latest.split('.').map(Number)
861  const b = current.split('.').map(Number)
862  for (let i = 0; i < Math.max(a.length, b.length); i++) {
863    const x = a[i] ?? 0
864    const y = b[i] ?? 0
865    if (Number.isNaN(x) || Number.isNaN(y)) return false
866    if (x !== y) return x > y
867  }
868  return false
869}
870
871// What `claude plugin update` said; it exits 0 whether or not it updated.
872export function updateOutcome(stdout: string): { kind: 'updated' | 'latest'; version: string } | null {
873  const up = /updated from \S+ to (\d[\w.-]*)/.exec(stdout)
874  if (up?.[1]) return { kind: 'updated', version: up[1] }
875  const same = /already at the latest version \((\d[\w.-]*)\)/.exec(stdout)
876  if (same?.[1]) return { kind: 'latest', version: same[1] }
877  return null
878}
879
880// A move the ticket can make now, as the status popup lists it.
881export type Transition = { id: string; to: string; isDone: boolean; hasScreen: boolean }
882
883// The transitions a ticket can take now, from either Atlassian server's
884// answer (the plugin wraps it in data), less the one to its own status and
885// any Jira marks unavailable; null when unreadable. Never a fixed list: each
886// project's workflow differs (HS has 10 statuses, HTGO2 33).
887export function parseTransitions(content: Content, current: string): Transition[] | null {
888  const json = firstJson(content) as { data?: unknown } | null
889  if (!json) return null
890  const list = ((json.data ?? json) as { transitions?: unknown }).transitions
891  if (!Array.isArray(list)) return null
892  const out: Transition[] = []
893  for (const raw of list) {
894    const t = raw as {
895      id?: unknown
896      to?: { name?: unknown; statusCategory?: { key?: unknown } }
897      hasScreen?: unknown
898      isAvailable?: unknown
899    }
900    if (typeof t?.id !== 'string' || typeof t.to?.name !== 'string') continue
901    if (t.isAvailable === false || t.to.name === current) continue
902    out.push({ id: t.id, to: t.to.name, isDone: t.to.statusCategory?.key === 'done', hasScreen: t.hasScreen === true })
903  }
904  return out
905}
906
907// What Claude reads when the person moves a ticket in the pane (see pickNote
908// for why it is a row rather than the system prompt).
909export function moveNote(key: string, from: string, to: string): string {
910  return `[branch8-jira-mod] In the /jira-todo pane the user moved ${key} in Jira from "${from}" to "${to}". It is done: do not move it again or ask about it.`
911}
912
913// The Claude login as the pane's account block shows it, from the config
914// dir's .claude.json (as branch8-jira-check's banner); null when signed out.
915export function claudeAccount(claudeJson: string): { email: string; org: string; plan: string } | null {
916  try {
917    const a = (JSON.parse(claudeJson) as { oauthAccount?: Record<string, unknown> }).oauthAccount
918    if (!a || typeof a.emailAddress !== 'string') return null
919    const str = (v: unknown) => (typeof v === 'string' ? v : '')
920    return { email: a.emailAddress, org: str(a.organizationName), plan: str(a.organizationType).replace(/^claude_/, '') }
921  } catch {
922    return null
923  }
924}
925
926// The Jira user signed in: getJiraCurrentUser (the plugin server, wrapped in
927// data) or atlassianUserInfo (claude.ai); null without a name and an email.
928export function parseJiraUser(content: Content): { name: string; email: string } | null {
929  const json = firstJson(content) as { data?: unknown } | null
930  if (!json) return null
931  const u = (json.data ?? json) as { displayName?: unknown; emailAddress?: unknown; name?: unknown; email?: unknown }
932  const name = typeof u.displayName === 'string' ? u.displayName : typeof u.name === 'string' ? u.name : ''
933  const email = typeof u.emailAddress === 'string' ? u.emailAddress : typeof u.email === 'string' ? u.email : ''
934  return name && email ? { name, email } : null
935}
936
937// Why the Jira account looks wrong, as branch8-jira-check's accounts check:
938// outside the company domain, or another person than the Claude login.
939export function jiraWarning(
940  jiraEmail: string,
941  claudeEmail: string | null,
942  domain = 'branch8.com',
943): 'domain' | 'differs' | null {
944  const j = jiraEmail.toLowerCase()
945  if (!j.endsWith(`@${domain}`)) return 'domain'
946  if (claudeEmail && j !== claudeEmail.toLowerCase()) return 'differs'
947  return null
948}
949
950// The current ticket as the pane's freshly read list has it, so the band
951// follows a status change without asking Jira on its own; null when the
952// ticket is not in the list or nothing changed.
953export function syncTicket(ticket: Ticket | null, items: readonly TodoItem[]): Ticket | null {
954  if (!ticket) return null
955  const item = items.find(i => i.key === ticket.key)
956  if (!item || (item.status === ticket.status && item.summary === ticket.summary)) return null
957  return { ...ticket, summary: item.summary || ticket.summary, status: item.status || ticket.status }
958}
959
types/index.d.ts 57 lines
1export type Node = 'start' | 'pr' | 'merge'
2export type Ticket = { key: string; summary: string; status: string; cloudId: string }
3export type TodoItem = { key: string; summary: string; status: string; type?: string }
4export type Transition = { id: string; to: string; isDone: boolean; hasScreen: boolean }
5export type Todos = {
6  items: TodoItem[]
7  error: string | null
8  isLoading: boolean
9  isLoaded: boolean
10  // The search's token for the next page; null when everything is loaded.
11  next: string | null
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'branch8-jira-mod': {
17      ticket: Ticket | null
18      asked: string[]
19      // The ticket whose unknown status was asked of Jira once (never polled).
20      statusAskedFor: string | null
21      todos: Todos
22      todoOpen: boolean
23      todoFolded: string[]
24      langPref: 'zh-TW' | 'zh-CN' | 'en' | 'auto'
25      lang: 'zh-TW' | 'zh-CN' | 'en'
26      statusFolded: string[]
27      scopePref: 'auto' | 'folder' | 'all'
28      // noJira: the folder's record says it uses no Jira (not merely unmapped).
29      folder: { dir: string; project: string | null; name: string; noJira?: boolean }
30      // The row menu under one ticket; status while its status popup is open:
31      // the moves Jira offers (loading until they come), and the one picked to confirm.
32      menu: {
33        item: TodoItem
34        other: boolean
35        dir: string
36        status?: { isLoading: boolean; list: Transition[]; pick: Transition | null } | null
37      } | null
38      focusedKey: string | null
39      // The pane element the focus ring is on, any kind (the arrows step from it).
40      ring: string | null
41      // The settings popup: null when closed.
42      settings: string | null
43      autoOpen: boolean
44      // The pane's account block: the Claude login, and the Jira user once the
45      // Atlassian MCP answers (checked: asked already, whatever came back).
46      account: { claude: { email: string; org: string; plan: string } | null; jira: { name: string; email: string } | null; isJiraChecked: boolean }
47      // Only tickets updated within this many days; 0 for any age.
48      range: number
49      isOrg: boolean
50      // This mod's version, from its plugin.json, shown in the pane.
51      version: string | null
52      // The version on the marketplace's main branch, and whether an update runs.
53      latest: { version: string | null; isUpdating: boolean }
54    }
55  }
56}
57