mintree 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  mintree wraps the steps you do manually every time a feature begins:
6
6
 
7
- 1. Pick an issue assigned to you on GitHub.
8
- 2. Create a git worktree on a branch named after that issue, following the project's convention.
7
+ 1. Pick an open issue or work item assigned to you (GitHub Issues or Plane).
8
+ 2. Create a git worktree on a branch named after that item, following the project's convention.
9
9
  3. Launch Claude Code inside the worktree with a session ID you can resume later.
10
10
  4. Live-track which Claude sessions are active, idle, or waiting.
11
11
 
12
- It is a smaller, opinionated cousin of [santree](https://github.com/santiagotoscanini/santree) — built on the same TypeScript + Ink + Pastel stack but stripped to GitHub-only and aligned with the `<type>/<issue>-<desc>` branch convention.
12
+ It is a smaller, opinionated cousin of [santree](https://github.com/santiagotoscanini/santree) — built on the same TypeScript + Ink + Pastel stack but stripped to the `<type>/<issue>-<desc>` branch convention and the two issue trackers most likely to be used by a small team: GitHub Issues and Plane.
13
13
 
14
14
  ---
15
15
 
@@ -48,7 +48,7 @@ EOF
48
48
 
49
49
  `mintree doctor` should report **all required checks pass** before you continue. The most common gaps are:
50
50
 
51
- - `gh` not authenticated → `gh auth login`
51
+ - `gh` not authenticated → `gh auth login` (still needed when using Plane — mintree uses `gh` for PR status on worktree branches)
52
52
  - Claude Code not installed → `npm install -g @anthropic-ai/claude-code`
53
53
  - Shell integration not loaded → re-run the `echo … >> ~/.zshrc` step and start a new shell
54
54
 
@@ -60,12 +60,58 @@ In every repository where you want to use mintree:
60
60
 
61
61
  ```bash
62
62
  cd path/to/repo
63
- mintree init # creates .mintree/, updates .gitignore
64
- mintree helpers session-signal install # optional: live session state in the dashboard
63
+
64
+ # Default: GitHub Issues provider
65
+ mintree init
66
+
67
+ # Or: Plane provider
68
+ mintree init --provider plane --workspace <your-workspace-slug>
69
+
70
+ mintree helpers session-signal install # optional: live session state in the dashboard
65
71
  ```
66
72
 
67
73
  `init` is idempotent — re-running it is a no-op when everything is already in place.
68
74
 
75
+ ### Picking the issue provider
76
+
77
+ mintree supports two issue providers, selected per repo via `.mintree/metadata.json`:
78
+
79
+ - **`github`** (default): lists issues assigned to you on the current repo via `gh`. Transitions to "In Progress" on a Projects v2 board when present.
80
+ - **`plane`**: lists work items assigned to you across a configured set of [Plane](https://plane.so) projects, via the Plane REST API. Transitions to the project's "started" state on `w`.
81
+
82
+ After `mintree init --provider plane`, edit `.mintree/metadata.json` to add at least one project:
83
+
84
+ ```json
85
+ {
86
+ "version": 1,
87
+ "provider": "plane",
88
+ "issues": {},
89
+ "plane": {
90
+ "apiUrl": "https://api.plane.so",
91
+ "workspaceSlug": "my-team",
92
+ "projects": [
93
+ { "id": "<project-uuid>", "identifier": "BACK", "name": "Backend" },
94
+ { "id": "<project-uuid>", "identifier": "WEB", "name": "Web" }
95
+ ]
96
+ }
97
+ }
98
+ ```
99
+
100
+ You can find each project's UUID in the Plane URL (`app.plane.so/<workspace>/projects/<uuid>/...`). The `identifier` is the short prefix shown on work-item IDs (the `BACK` in `BACK-100`).
101
+
102
+ Authenticate by setting `PLANE_API_KEY` in your shell, or by writing the key to `~/.mintree/credentials.json`:
103
+
104
+ ```bash
105
+ export PLANE_API_KEY=plane_api_XXXXXXXXXXXXXX
106
+ # or
107
+ cat > ~/.mintree/credentials.json <<'EOF'
108
+ { "plane": { "apiKey": "plane_api_XXXXXXXXXXXXXX" } }
109
+ EOF
110
+ chmod 600 ~/.mintree/credentials.json
111
+ ```
112
+
113
+ `mintree doctor` validates the key + each configured project's reachability when `provider === "plane"`.
114
+
69
115
  ---
70
116
 
71
117
  ## Daily flow
@@ -76,15 +122,15 @@ mintree helpers session-signal install # optional: live session state in
76
122
  mintree dashboard
77
123
  ```
78
124
 
79
- Opens a full-screen TUI listing your assigned open issues, each row marked with the live state of its Claude session (`● active`, `! waiting`, `○ idle`, `— exited`, `· no session`). The right pane shows the issue body, labels, worktree info, PR status, and live session message.
125
+ Opens a full-screen TUI listing your assigned open issues (or work items), each row marked with the live state of its Claude session (`● active`, `! waiting`, `○ idle`, `— exited`, `· no session`). Rows are grouped by project board and Status. The right pane shows the issue body, labels, worktree info, PR status, and live session message.
80
126
 
81
127
  | Shortcut | Action |
82
128
  |----------|-----------------------------------------------------------------------|
83
129
  | `↑/↓` or `j/k` | Move between issues |
84
130
  | `↵` | Resume Claude in the existing worktree, or open the create overlay if there's none |
85
131
  | `w` | Always open the create overlay (type + kebab description) |
86
- | `d` | Remove the selected worktree (confirmation overlay) |
87
- | `r` | Manual refresh (auto-refreshes silently every 30s) |
132
+ | `d` | Delete the selected worktree (confirmation overlay) |
133
+ | `r` | Manual refresh (auto-refreshes silently every 5 min) |
88
134
  | `o` | Open the issue in your browser |
89
135
  | `q`/`Esc`| Quit (or cancel an open overlay) |
90
136
 
@@ -97,10 +143,10 @@ Same building blocks, scriptable from any shell:
97
143
  ```bash
98
144
  # Create a worktree, optionally launch Claude with an initial prompt
99
145
  mintree worktree create feat/100-validar-patente
100
- mintree worktree create feat/100-validar-patente --work --prompt "empezar issue #100"
146
+ mintree worktree create feat/BACK-100-validar-patente --work --prompt "empezar BACK-100"
101
147
 
102
148
  # Resume Claude in the worktree you're currently inside
103
- cd .mintree/worktrees/100-validar-patente
149
+ cd .mintree/worktrees/BACK-100-validar-patente
104
150
  mintree worktree work
105
151
 
106
152
  # Inspect / clean up
@@ -127,7 +173,14 @@ mintree enforces:
127
173
 
128
174
  > `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `build`, `ci`, `perf`, `style`, `revert`
129
175
 
130
- `<issue>` is the GitHub issue number (no `#`), `<desc>` is lowercase kebab-case. Examples: `feat/42-validacion-patente`, `fix/55-selfie-upload-timeout`.
176
+ `<issue>` is one of:
177
+
178
+ - Bare digits for GitHub Issues (the issue number, no `#`): `42`, `100`, `1234`
179
+ - `<PROJ>-<digits>` for Plane work items (the human identifier): `BACK-100`, `WEB-7`, `PROJ_X-12`. The prefix is uppercase letters / digits / underscores, matching Plane's project-identifier constraints.
180
+
181
+ `<desc>` is lowercase kebab-case.
182
+
183
+ Examples: `feat/42-validacion-patente`, `fix/55-selfie-upload-timeout`, `feat/BACK-100-readme-update`, `fix/WEB-7-modal`.
131
184
 
132
185
  When the dashboard's `w` overlay opens, it suggests a kebab description capped at 5 words. If your repo has a `docs/conventions/git-workflow.md`, `CONTRIBUTING.md`, or `.claude/skills/` directory, mintree mentions it on the overlay so you can verify the suggestion against your project's rules — then edit the description to match.
133
186
 
@@ -139,15 +192,18 @@ When the dashboard's `w` overlay opens, it suggests a kebab description capped a
139
192
  <repo>/
140
193
  ├── .gitignore # gets `.mintree/worktrees/` + session-states/ + metadata.json appended
141
194
  └── .mintree/
142
- ├── metadata.json # gitignored. <issue-id> → { base_branch?, session_id? }
195
+ ├── metadata.json # gitignored. provider config + <issue-id> → { base_branch?, session_id? }
143
196
  ├── worktrees/ # gitignored
144
- └── 100-validar-patente/ # one directory per active worktree (named <issue>-<desc>)
197
+ ├── 100-validar-patente/ # GH form: <digits>-<desc>
198
+ │ └── BACK-100-readme-update/ # Plane form: <PROJ-digits>-<desc>
145
199
  ├── session-states/ # gitignored
146
200
  │ └── 100.json # live state written by Claude hooks (active/waiting/idle/exited)
147
201
  └── init.sh # opt-in. Runs in the new worktree post-create (copy .env, install deps, …)
148
202
  ```
149
203
 
150
- `metadata.json` is gitignored because the `session_id` is local to your machine — sharing it would only generate noise.
204
+ `metadata.json` is gitignored because the `session_id` is local to your machine — sharing it would only generate noise. The `provider` and `plane.*` keys can be re-derived from a Plane workspace if needed; sharing them would just leak local config preference.
205
+
206
+ Plane authentication lives in `~/.mintree/credentials.json` (user-scoped, not per-repo) or the `PLANE_API_KEY` env var.
151
207
 
152
208
  ---
153
209
 
@@ -163,7 +219,8 @@ When the dashboard's `w` overlay opens, it suggests a kebab description capped a
163
219
 
164
220
  - `mintree doctor` is the first stop. It surfaces missing tools, unauthenticated CLIs, missing hooks, and gitignore drift.
165
221
  - The shell wrapper exports `MINTREE_SHELL_INTEGRATION=1` — if doctor says it's missing, the wrapper isn't being loaded by your shell init file.
166
- - If the dashboard ever opens with a stale session state, press `r` to force a refetch (the auto-refresh runs every 30s).
222
+ - If the dashboard ever opens with a stale session state, press `r` to force a refetch (the auto-refresh runs every 5 minutes).
223
+ - Plane-side issues (timeouts, rate limits, unexpected response shapes) can be logged to `~/.mintree/plane-debug.log` by running `MINTREE_DEBUG=1 mintree dashboard`. The log is file-only so it never corrupts the Ink-rendered TUI.
167
224
 
168
225
  ---
169
226
 
@@ -171,7 +228,7 @@ When the dashboard's `w` overlay opens, it suggests a kebab description capped a
171
228
 
172
229
  mintree was written for projects that have:
173
230
 
174
- - GitHub Issues as the only tracker (santree supports both Linear and GitHub).
231
+ - A small set of trackers (GitHub Issues or Plane) santree supports both Linear and GitHub but is heavier.
175
232
  - An established branch convention without the `gh-` prefix santree imposes.
176
233
  - Skills (`.claude/skills/`) that own the SDD + TDD flow — mintree intentionally leaves the rich PR-create / PR-review prompts out of scope.
177
234
 
@@ -13,7 +13,8 @@ import { ALLOWED_TYPES } from "../lib/branch.js";
13
13
  import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
14
14
  import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
15
15
  import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
16
- import { transitionIssueToInProgress } from "../lib/githubProject.js";
16
+ import { readMetadata } from "../lib/metadata.js";
17
+ import { createProvider } from "../lib/providers/index.js";
17
18
  import { loadDashboard } from "../lib/dashboard.js";
18
19
  const require = createRequire(import.meta.url);
19
20
  const { version: mintreeVersion } = require("../../package.json");
@@ -115,8 +116,8 @@ function kebabize(title) {
115
116
  * the body inline (issue bodies can be long and contain markdown that
116
117
  * doesn't survive argv). User can clear or rewrite freely before Enter.
117
118
  */
118
- function defaultPromptForIssue(number, title) {
119
- return `Empezá a trabajar el issue #${number} (${title}). Usá \`gh issue view ${number}\` para leer el contexto completo y seguí las convenciones del repo.`;
119
+ function defaultPromptForIssue(id, title) {
120
+ return `Empezá a trabajar el issue #${id} (${title}). Usá \`gh issue view ${id}\` para leer el contexto completo y seguí las convenciones del repo.`;
120
121
  }
121
122
  /**
122
123
  * Sanitises whatever the user typed into the desc field on every keystroke.
@@ -174,22 +175,22 @@ function FooterRow({ phase, overlayKind, latestVersion, }) {
174
175
  if (overlayKind === "remove") {
175
176
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm " }), _jsx(Text, { bold: true, children: "n/Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] }));
176
177
  }
177
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " remove" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
178
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " delete" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
178
179
  }
179
180
  function RemoveOverlayView({ overlay }) {
180
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press", " ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " ", "to force-remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
181
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press", " ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " ", "to force-remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
181
182
  }
182
183
  function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
183
184
  const labelWidth = 14;
184
185
  const isNewBranch = overlay.branchMode === "new";
185
- const detachedDesc = kebabize(overlay.issue.issue.title) || `issue-${overlay.issue.issue.number}`;
186
+ const detachedDesc = kebabize(overlay.issue.issue.title) || `issue-${overlay.issue.issue.id}`;
186
187
  const branchPreview = isNewBranch
187
- ? `${overlay.type}/${overlay.issue.issue.number}-${overlay.desc}`
188
+ ? `${overlay.type}/${overlay.issue.issue.id}-${overlay.desc}`
188
189
  : `detached @ ${overlay.currentBranch ?? "(unknown)"}`;
189
190
  const dirPreview = isNewBranch
190
- ? `${overlay.issue.issue.number}-${overlay.desc}`
191
- : `${overlay.issue.issue.number}-${detachedDesc}`;
192
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
191
+ ? `${overlay.issue.issue.id}-${overlay.desc}`
192
+ : `${overlay.issue.issue.id}-${detachedDesc}`;
193
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
193
194
  }
194
195
  function stateChar(state) {
195
196
  if (!state)
@@ -203,7 +204,11 @@ function stateChar(state) {
203
204
  return "—";
204
205
  }
205
206
  function IssueListRow({ d, selected, identifierWidth, maxTitleWidth, }) {
206
- const idText = `#${d.issue.number}`.padEnd(identifierWidth, " ");
207
+ // Display the issue id raw (e.g. "AUTH-6", "100"). The `#` prefix is a
208
+ // GitHub convention that reads as noise for Plane's already-prefixed
209
+ // ids, and dropping it across the board keeps the dashboard provider-
210
+ // agnostic.
211
+ const idText = d.issue.id.padEnd(identifierWidth, " ");
207
212
  const icon = stateChar(d.session?.state ?? null);
208
213
  const title = truncate(d.issue.title, maxTitleWidth);
209
214
  // One single Text with a single string so the background highlight is
@@ -274,35 +279,62 @@ function buildListRows(issues) {
274
279
  }
275
280
  /**
276
281
  * Splits the grouped list into a pinned-header region and a scrollable body
277
- * windowed around the selected issue. The selected issue's own project and
278
- * Status headers are lifted out of the scroll flow so they stay visible
279
- * ("sticky") while paging through a long group — without them the user loses
280
- * track of which project/status the rows below belong to.
282
+ * windowed around the selected issue.
281
283
  *
282
- * Navigation is unaffected: it still moves `selectedIndex` over the flat
283
- * issues array; this only decides what's drawn where.
284
+ * Sticky pinning only kicks in when the whole list overflows the viewport
285
+ * AND the selected issue's project header would otherwise be scrolled off
286
+ * the top. In every other case (list fits, or selection is above its
287
+ * project header) the layout is rendered natively — moving the project
288
+ * label out of its natural position when there's no scrolling to track
289
+ * is purely confusing.
290
+ *
291
+ * Status headers are never pinned: they're short groups in close vertical
292
+ * proximity to their items, so the user always sees them inline. Pinning
293
+ * them was previously breaking grouping when the body window spanned
294
+ * multiple status sub-groups.
284
295
  */
285
296
  function windowListRows(listRows, selectedIndex, viewportRows) {
286
297
  const selRow = listRows.findIndex((r) => r.kind === "issue" && r.index === selectedIndex);
287
298
  const anchor = selRow >= 0 ? selRow : 0;
288
- // Walk back from the selected row to find its enclosing project and Status
289
- // headers. A "Sin proyecto" issue has a project header but no status one.
299
+ // If the entire list fits in the viewport, no scrolling will happen and
300
+ // pinning would just visually displace headers without serving any
301
+ // purpose. Render the list flat.
302
+ if (listRows.length <= viewportRows) {
303
+ return {
304
+ sticky: [],
305
+ body: [...listRows],
306
+ issuesAbove: 0,
307
+ issuesBelow: 0,
308
+ };
309
+ }
310
+ // Otherwise, decide if the project header needs to be pinned. It does iff
311
+ // the body window we'd render — centred on the anchor — would not include
312
+ // the project header itself. When it would, the user sees the header in
313
+ // place and pinning is again redundant.
290
314
  let projIdx = -1;
291
- let statusIdx = -1;
292
315
  for (let i = anchor; i >= 0; i--) {
293
316
  const r = listRows[i];
294
317
  if (!r)
295
318
  continue;
296
- if (statusIdx === -1 && r.kind === "status")
297
- statusIdx = i;
298
319
  if (r.kind === "project") {
299
320
  projIdx = i;
300
321
  break;
301
322
  }
302
323
  }
303
- // Pinned rows are pulled out of the body so they never render twice. The
304
- // blank spacer that precedes a project header is dropped too, so the pane
305
- // doesn't open with an empty line.
324
+ const tentativeMaxStart = Math.max(0, listRows.length - viewportRows);
325
+ const tentativeStart = Math.max(0, Math.min(tentativeMaxStart, anchor - Math.floor(viewportRows / 2)));
326
+ const projectVisibleInWindow = projIdx >= 0 && projIdx >= tentativeStart;
327
+ if (projectVisibleInWindow) {
328
+ const end = Math.min(listRows.length, tentativeStart + viewportRows);
329
+ return {
330
+ sticky: [],
331
+ body: listRows.slice(tentativeStart, end),
332
+ issuesAbove: listRows.slice(0, tentativeStart).filter((r) => r.kind === "issue").length,
333
+ issuesBelow: listRows.slice(end).filter((r) => r.kind === "issue").length,
334
+ };
335
+ }
336
+ // Project header is above the visible window — pin it (and drop the
337
+ // preceding spacer so the pane doesn't open with a blank line).
306
338
  const pinned = new Set();
307
339
  const sticky = [];
308
340
  if (projIdx >= 0) {
@@ -312,10 +344,6 @@ function windowListRows(listRows, selectedIndex, viewportRows) {
312
344
  if (before && before.kind === "spacer")
313
345
  pinned.add(projIdx - 1);
314
346
  }
315
- if (statusIdx >= 0) {
316
- pinned.add(statusIdx);
317
- sticky.push(listRows[statusIdx]);
318
- }
319
347
  const body = [];
320
348
  let anchorInBody = 0;
321
349
  listRows.forEach((r, i) => {
@@ -418,7 +446,7 @@ function buildDetailLines(d, width) {
418
446
  const lines = [];
419
447
  const blank = () => [{ text: " " }];
420
448
  const w = Math.max(20, width);
421
- const titlePrefix = `#${d.issue.number} `;
449
+ const titlePrefix = `${d.issue.id} `;
422
450
  const titleWrapped = wrapLine(d.issue.title, Math.max(8, w - titlePrefix.length));
423
451
  titleWrapped.forEach((chunk, i) => {
424
452
  if (i === 0) {
@@ -557,8 +585,17 @@ export default function Dashboard() {
557
585
  process.stdout.write(ALT_SCREEN_ENTER);
558
586
  altScreenEntered.current = true;
559
587
  }
588
+ // Set as the dashboard unmounts. The overlay mouse-pause effect below
589
+ // re-enables mouse tracking in its cleanup, and that cleanup also fires on
590
+ // unmount (the create overlay is still open when `confirmCreate` calls
591
+ // exit()). React runs effect cleanups in mount order, so the overlay's
592
+ // MOUSE_ON would run *after* the mouse effect's MOUSE_OFF and leave the
593
+ // terminal capturing the scroll wheel — breaking scroll once Claude takes
594
+ // over. This flag lets the overlay cleanup skip MOUSE_ON during teardown.
595
+ const tearingDown = useRef(false);
560
596
  useEffect(() => {
561
597
  return () => {
598
+ tearingDown.current = true;
562
599
  process.stdout.write(ALT_SCREEN_LEAVE);
563
600
  };
564
601
  }, []);
@@ -585,10 +622,31 @@ export default function Dashboard() {
585
622
  }
586
623
  const issues = await loadDashboard(root);
587
624
  if (!issues) {
588
- setState({
589
- phase: "error",
590
- message: "Could not fetch issues from GitHub.",
591
- hint: "Check `mintree doctor` gh must be authenticated and the repo must live on GitHub.",
625
+ const provider = readMetadata(root).provider ?? "github";
626
+ const message = provider === "plane"
627
+ ? "Could not fetch work items from Plane."
628
+ : "Could not fetch issues from GitHub.";
629
+ const hint = provider === "plane"
630
+ ? "Check `mintree doctor` — PLANE_API_KEY must be set and the workspace + projects reachable."
631
+ : "Check `mintree doctor` — gh must be authenticated and the repo must live on GitHub.";
632
+ setState((prev) => {
633
+ // Initial load failure → escalate to the full error screen so the
634
+ // user gets the actionable hint front-and-centre. But once the
635
+ // dashboard is up, a transient fetch failure (network blip on the
636
+ // 30s auto-refresh, momentary API slowness) shouldn't blow away
637
+ // what's already on screen — surface a toast and let the next
638
+ // refresh recover.
639
+ if (prev.phase !== "ready") {
640
+ return { phase: "error", message, hint };
641
+ }
642
+ return {
643
+ ...prev,
644
+ refreshing: false,
645
+ toast: {
646
+ kind: "error",
647
+ text: `${message} Showing last known data — press \`r\` to retry.`,
648
+ },
649
+ };
592
650
  });
593
651
  return;
594
652
  }
@@ -690,12 +748,20 @@ export default function Dashboard() {
690
748
  return;
691
749
  process.stdout.write(MOUSE_OFF);
692
750
  return () => {
751
+ // Skip on unmount: re-enabling mouse tracking here would survive the
752
+ // dashboard exit and break scroll in the terminal Claude inherits.
753
+ if (tearingDown.current)
754
+ return;
693
755
  process.stdout.write(MOUSE_ON);
694
756
  };
695
757
  }, [state.phase === "ready" && state.overlay ? state.overlay.kind : null]);
696
- // Auto-refresh every 30s while the dashboard is idle. Skipped while an
697
- // overlay is open so we don't yank state from under a confirmation, and
698
- // while a manual refresh is in flight to avoid stomping on its spinner.
758
+ // Auto-refresh every 5 minutes while the dashboard is idle. 30s was too
759
+ // aggressive for an issue list most of the time nothing has changed,
760
+ // and with multiple Plane projects configured each refresh fires N×2
761
+ // API calls. Press `r` for an immediate refresh when something
762
+ // changed externally. Skipped while an overlay is open so we don't yank
763
+ // state from under a confirmation, and while a manual refresh is in
764
+ // flight to avoid stomping on its spinner.
699
765
  const stateRef = useRef(state);
700
766
  useEffect(() => {
701
767
  stateRef.current = state;
@@ -710,7 +776,7 @@ export default function Dashboard() {
710
776
  if (s.refreshing)
711
777
  return;
712
778
  void refresh();
713
- }, 30_000);
779
+ }, 5 * 60 * 1000);
714
780
  return () => clearInterval(id);
715
781
  }, []);
716
782
  useInput((input, key) => {
@@ -826,8 +892,8 @@ export default function Dashboard() {
826
892
  branchMode: "new",
827
893
  currentBranch: root ? getCurrentBranch(root) : null,
828
894
  type: "feat",
829
- desc: kebabize(issue.issue.title) || `issue-${issue.issue.number}`,
830
- prompt: defaultPromptForIssue(issue.issue.number, issue.issue.title),
895
+ desc: kebabize(issue.issue.title) || `issue-${issue.issue.id}`,
896
+ prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title),
831
897
  field: "branchMode",
832
898
  error: null,
833
899
  conventionDoc: root ? findBranchConventionDoc(root) : null,
@@ -921,15 +987,15 @@ export default function Dashboard() {
921
987
  if (state.phase !== "ready")
922
988
  return;
923
989
  const prompt = overlay.prompt.trim();
924
- const issueNumber = overlay.issue.issue.number;
990
+ const issueId = overlay.issue.issue.id;
925
991
  let result;
926
992
  if (overlay.branchMode === "current") {
927
993
  // Detached worktree off the main repo's current branch. Desc comes
928
994
  // from the issue title (kebabized), not user input — keeping the
929
995
  // "current branch" flow as low-friction as possible.
930
- const descKebab = kebabize(overlay.issue.issue.title) || `issue-${issueNumber}`;
996
+ const descKebab = kebabize(overlay.issue.issue.title) || `issue-${issueId}`;
931
997
  result = runCreateDetached({
932
- issueId: String(issueNumber),
998
+ issueId,
933
999
  descKebab,
934
1000
  work: true,
935
1001
  ...(prompt.length > 0 ? { prompt } : {}),
@@ -944,7 +1010,7 @@ export default function Dashboard() {
944
1010
  });
945
1011
  return;
946
1012
  }
947
- const branch = `${overlay.type}/${issueNumber}-${desc}`;
1013
+ const branch = `${overlay.type}/${issueId}-${desc}`;
948
1014
  result = runCreate(branch, {
949
1015
  work: true,
950
1016
  ...(prompt.length > 0 ? { prompt } : {}),
@@ -968,7 +1034,8 @@ export default function Dashboard() {
968
1034
  const repoRoot = findMainRepoRoot();
969
1035
  if (repoRoot) {
970
1036
  try {
971
- await transitionIssueToInProgress(repoRoot, issueNumber);
1037
+ const provider = createProvider(repoRoot);
1038
+ await provider.transitionIssueToInProgress(issueId);
972
1039
  }
973
1040
  catch {
974
1041
  // best effort — surface via doctor / next dashboard refresh
@@ -1043,7 +1110,7 @@ export default function Dashboard() {
1043
1110
  const listWidthPct = 0.4;
1044
1111
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
1045
1112
  const detailWidth = columns - listWidth - 2; // border slack
1046
- const identifierWidth = Math.max(3, ...issues.map((d) => `#${d.issue.number}`.length));
1113
+ const identifierWidth = Math.max(3, ...issues.map((d) => d.issue.id.length));
1047
1114
  // Lista ocupa todo menos: " #N ICON " (2-space nest indent + id + icon).
1048
1115
  const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 9);
1049
1116
  // Reserve rows: header (2), top borders (1), footer (3).