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…

全公司 Claude 技能的單一來源。改這個 repo,全公司的 Claude 就跟著變。
同事不需要安裝任何東西,也不需要手動更新。
⚠️ 可見性要求取決於你走哪條發送管道,兩者相反:
管道 可見性要求 認證方式 managed-settings.json(Claude Code CLI / IDE)無限制,public 可以 同事自己的 git credential helper Organization settings › Plugins(chat / Cowork) 必須 private / internal Claude GitHub App,不碰同事憑證 這個 repo 目前是 public,因為只走 Claude Code 那條。理由見 為什麼目前是 public。
七個 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-max | vendored | UI/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:
| 管道 | 涵蓋 | 設定位置 |
|---|---|---|
| 組織 marketplace | chat(網頁、手機、桌面 App 的 Chat 分頁)、Cowork | Organization settings › Plugins |
| managed settings | Claude 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 會安靜地合併,然後什麼都不會發生——你以為部署了,其實沒有。
# 1. 改 plugins/internal-review/ 底下的檔案
# 2. 改版本號(忘了也沒關係,CI 會幫你補)
python3 scripts/validate.py
# 3. 開 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
# 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。 這是唯一需要同時動兩邊的情況。
從 marketplace.json 拿掉那筆、刪資料夾(第三方的話連 lock 檔那筆也刪),開 PR。
每週一 09:00(台北時間)
↓ 機器人檢查每個第三方的上游
↓ 有更新:重新抓、改版本號、跑檢查
↓ 自動開 PR,附上游 diff 連結和審查重點
↓ 你看過、按 merge
↓ 全公司更新
沒有更新就不會開 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。)
| 想做 | 怎麼做 |
|---|---|
| 接受 | 直接 merge |
| 這版不要 | 直接 close,下週會重開 |
| 這版永遠不要 | 手動把 lock 檔的 ref 鎖在最後一個好的 SHA,並留言說明 |
Actions 分頁 → vendor-update → Run workflow。可以只跑其中一個 plugin。
vendor、automation-failureCODEOWNERS 裡的 team handle 換成真實的需要 Owner 或 Primary Owner 權限。
branch8/claude-plugins| 選項 | 效果 | 適合 |
|---|---|---|
| Required ← 目前這七個都用這個 | 自動裝並啟用,不能移除 | 團隊必要工具、公司規範類 |
| Installed by default | 自動裝,可自行移除 | 選配、好用但不強制的 |
| Available for install | 出現在目錄,自己裝 | 實驗中的 |
| Not available | 完全隱藏 | 準備中或已淘汰 |
目前發送的七個全部是團隊必要,所以一律 Required。之後加選配工具時 再用 Installed by default——把兩者混在同一個等級,會讓「必要」失去意義。
直接貼 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 可以在本機執行程式碼。
先設一邊、跑順了再補另一邊也完全沒問題,兩邊沒有依賴關係。
從 template/plugin-template/ 複製,不要從零開始:
cp -r template/plugin-template plugins/你的plugin名
然後改三個地方——這三處的名字必須完全一致:
| 檔案 | 改什麼 |
|---|---|
plugins/你的plugin名/.claude-plugin/plugin.json | name、version、description |
.claude-plugin/marketplace.json | 加一筆 { "name": "...", "source": "./plugins/..." } |
managed-settings.json | enabledPlugins 加一行 "你的plugin名@branch8": true |
python3 scripts/validate.py → 開 PR → 合併 → 全公司拿到。
細節(description 怎麼寫才會觸發、複製後的完整 checklist、validate 會擋什麼) 見 template/README.md。
不用為了 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 同等的標準審。
自家 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/ 只留給真正的第三方。
用 github source 加 sha(決策 3 的「第 2 級」,目前腳本還不支援,要先實作)。 觸發條件是下列之一,不是「plugin 變多了」:
而且是拆那一個,不是全部拆。
放進 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 能做什麼,不是信不信任作者。
| 等級 | 做法 | 適用 |
|---|---|---|
| 1 | 直接用官方 marketplace | 純 prompt,沒有 hooks / MCP / 腳本 |
| 2 | marketplace.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 對系統來說是兩個完全不同的東西,兩份都會載入。
目前沒有優先權或覆蓋機制,無法指定「優先用內部這份」。
三個緩解做法:
/plugin 的 Installed 分頁清一次重複的acme-review 而非 code-review)管理層級的設定優先權最高,覆蓋專案和個人設定。同事那份會被接管, 以 managed scope 顯示且無法修改。順序不是「誰先裝誰贏」,而是管理設定永遠贏。
| 項目 | 做法 |
|---|---|
| 執行環境 | GitHub Actions,跑在 GitHub 的機器上,沒有任何人的筆電參與 |
| 認證 | 內建 GITHUB_TOKEN。沒用任何人的 PAT——會過期,也會跟著離職的人消失 |
| 觸發 | cron 排程,不需要有人記得去跑 |
| 審查者 | CODEOWNERS 指到 team 而非個人 |
| 排程被停用 | 每月 heartbeat commit 維持 repo 活躍度 |
| Workflow 壞掉 | 自動開 issue,不會靜悄悄 |
| 沒人審 PR | 超過 14 天自動開 issue 提醒 |
CODEOWNERS 裡每個 team 至少兩個人@Branch8/platform-team@Branch8/security改完合併了,但同事沒拿到新版 八成是忘了 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。
2026-08-26 實測後的決定。 官方對兩條管道的要求是相反的:
managed-settings.json 的 extraKnownMarketplaces(Claude Code)——官方文件沒有 可見性要求,並明言支援 private repo。只要還沒設 chat 那一側,public 就沒有壞處,而且一次解掉三個閘門:
| 閘門 | private 時 | public 後 |
|---|---|---|
| repo 讀取權 | 每個同事都要被加進 repo | 不需要 |
| git 憑證 | 每人要 gh auth login,漏掉的人只看到「contact your admin」 | 不需要 |
| 背景自動更新 | 必定失敗(見下) | 正常運作 |
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),但之後永遠不會自動更新。
兩個選項,屆時再決定:
gh auth login(接受背景更新失效,靠手動 /plugin marketplace update branch8)改回 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。 上線前先在一台測試機驗證。
hooks/register.tsx 1202 lines1// 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 lines1// 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}
959types/index.d.ts 57 lines1export 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