mintree 0.4.9 → 0.5.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
@@ -126,6 +126,41 @@ chmod 600 ~/.mintree/credentials.json
126
126
 
127
127
  The key goes straight into the `Authorization` header (no `Bearer` prefix). `mintree doctor` validates the key, resolves the viewer, and pings each configured team when `provider === "linear"`.
128
128
 
129
+ ### Launch behaviour (optional)
130
+
131
+ Three top-level keys in `.mintree/metadata.json` tune how mintree launches Claude — all apply to GitHub and Linear repos alike:
132
+
133
+ ```json
134
+ {
135
+ "version": 1,
136
+ "provider": "linear",
137
+ "issues": {},
138
+ "defaultPermissionMode": "auto",
139
+ "promptTemplate": "Trabajá en el ticket {{id}} ({{title}}). Abrí {{url}} para el contexto completo y seguí las convenciones del repo.",
140
+ "orchestratorPromptTemplate": "Hacé de orquestador con los tickets {{ids}} ({{count}} en total). Resolvelos con la menor intervención posible, paralelizando con subagentes salvo dependencias.",
141
+ "linear": { "workspaceSlug": "my-team", "teams": [{ "key": "FE" }] }
142
+ }
143
+ ```
144
+
145
+ - **`defaultPermissionMode`** (`"default"` | `"auto"`): the Claude `--permission-mode` mintree uses when it launches a session — from the dashboard (`w` / `↵`), `worktree work`, or `worktree create --work`. Omitted (or `"default"`) keeps the stricter default mode; `"auto"` starts every session with auto-accept on. The `--permission-mode` / `-m` CLI flag still overrides it per launch.
146
+ - **`promptTemplate`**: the initial message seeded into the dashboard's `w` overlay (the text Claude receives as its first prompt). It replaces mintree's built-in default and supports these placeholders, substituted per issue:
147
+
148
+ | Placeholder | Replaced with |
149
+ |-------------|-------------------------------------------------------|
150
+ | `{{id}}` | Issue id — `100` (GitHub) or `FE-123` (Linear) |
151
+ | `{{title}}` | Issue title |
152
+ | `{{url}}` | Issue URL (GitHub issue page / Linear issue link) |
153
+
154
+ It's a single line on purpose — the overlay's prompt field is one line, and you can still edit it before launching. When omitted, mintree falls back to its provider-aware default (`gh issue view` for GitHub, the bare id + URL for Linear).
155
+ - **`orchestratorPromptTemplate`**: the message handed to the Claude **orchestrator** launched from the dashboard's `Orchestrate` tab (or `mintree orchestrate`). It replaces the built-in default and supports:
156
+
157
+ | Placeholder | Replaced with |
158
+ |-------------|--------------------------------------------------------|
159
+ | `{{ids}}` | Comma-separated list of the selected ticket ids |
160
+ | `{{count}}` | How many tickets were selected |
161
+
162
+ When omitted, mintree uses a built-in default that asks Claude to orchestrate the selected tickets with minimal intervention — parallelising via subagents unless dependencies force sequential work, creating a worktree per ticket with mintree, using the repo's skills, and moving each ticket to *in progress* on start and closing it when done.
163
+
129
164
  ---
130
165
 
131
166
  ## Daily flow
@@ -138,10 +173,19 @@ mintree dashboard
138
173
 
139
174
  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.
140
175
 
176
+ It has three tabs, switched with `←` / `→`:
177
+
178
+ - **Issues** — your assigned open issues, grouped by project/Status.
179
+ - **Worktrees** — orphaned worktrees (on disk under `.mintree/worktrees/` but no longer matching an open issue).
180
+ - **Orchestrate** — the same issues as the Issues tab, but each row is a checkbox (`[ ]` / `[✔]`). Tick the tickets you want resolved and press `↵` to launch a single Claude **orchestrator** in the repo root that drives them to completion (parallel subagents when possible, sequential otherwise), creating a worktree per ticket with mintree. The message is built from `orchestratorPromptTemplate` (see above) or the built-in default.
181
+
141
182
  | Shortcut | Action |
142
183
  |----------|-----------------------------------------------------------------------|
143
- | `↑/↓` or `j/k` | Move between issues |
144
- | `↵` | Resume Claude in the existing worktree, or open the create overlay if there's none |
184
+ | `←/→` | Switch tab (Issues Worktrees → Orchestrate) |
185
+ | `↑/↓` or `j/k` | Move between rows |
186
+ | `↵` | Issues/Worktrees: resume Claude in the existing worktree, or open the create overlay. Orchestrate: launch the orchestrator over the checked tickets |
187
+ | `Space` | Orchestrate tab: toggle the ticket under the cursor |
188
+ | `a` | Orchestrate tab: select / deselect all visible tickets |
145
189
  | `w` | Always open the create overlay (type + kebab description) |
146
190
  | `d` | Delete the selected worktree (confirmation overlay) |
147
191
  | `r` | Manual refresh (auto-refreshes silently every 5 min) |
@@ -173,6 +217,11 @@ mintree worktree list --pr # also fetch PR status per branch
173
217
  mintree worktree remove fix/55-bug # drop worktree but keep branch + session_id
174
218
  mintree worktree remove fix/55-bug --force # discard uncommitted changes too
175
219
  mintree worktree clean # sweep worktrees whose PR is merged/closed
220
+
221
+ # Launch a Claude orchestrator over a batch of tickets (renders
222
+ # orchestratorPromptTemplate, or the built-in default)
223
+ mintree orchestrate VAL-81 VAL-84 VAL-82
224
+ mintree orchestrate VAL-81 VAL-84 -m auto
176
225
  ```
177
226
 
178
227
  `mt`, `mtw`, `mtn` are shell aliases the wrapper installs for `mintree`, `mintree worktree`, and an interactive "name a branch" shortcut.
@@ -12,8 +12,10 @@ import { getLatestVersion, isNewerVersion } from "../lib/version.js";
12
12
  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
- import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
15
+ import { writePromptFile } from "../lib/worktreeCreate.js";
16
+ import { buildCreateMarkers, buildOrchestrateMarkers, emitMarkers } from "../lib/markers.js";
16
17
  import { readMetadata } from "../lib/metadata.js";
18
+ import { defaultOrchestratorPrompt, renderOrchestratorTemplate, renderPromptTemplate, } from "../lib/promptTemplate.js";
17
19
  import { createProvider } from "../lib/providers/index.js";
18
20
  import { loadDashboard } from "../lib/dashboard.js";
19
21
  import { priorityDisplay } from "../lib/priority.js";
@@ -32,15 +34,27 @@ function issueMatchesFilter(d, filter) {
32
34
  return d.issue.id.replace(/\D/g, "").includes(filter);
33
35
  }
34
36
  function tabIssues(issues, tab, filter = "") {
35
- return issues.filter((d) => (tab === "issues" ? !isOrphan(d) : isOrphan(d)) && issueMatchesFilter(d, filter));
37
+ // Orchestrate shows the same set as Issues (open issues assigned to you,
38
+ // non-orphan); only Worktrees flips to the orphan set.
39
+ return issues.filter((d) => (tab === "worktrees" ? isOrphan(d) : !isOrphan(d)) && issueMatchesFilter(d, filter));
40
+ }
41
+ function tabIndex(s, tab) {
42
+ if (tab === "issues")
43
+ return s.issuesIndex;
44
+ if (tab === "worktrees")
45
+ return s.worktreesIndex;
46
+ return s.orchestrateIndex;
36
47
  }
37
48
  function currentSelected(s) {
38
49
  const displayed = tabIssues(s.issues, s.activeTab, s.filter);
39
- const selectedIndex = s.activeTab === "issues" ? s.issuesIndex : s.worktreesIndex;
40
- return { displayed, selectedIndex };
50
+ return { displayed, selectedIndex: tabIndex(s, s.activeTab) };
41
51
  }
42
52
  function withSelectedIndex(s, next) {
43
- return s.activeTab === "issues" ? { ...s, issuesIndex: next } : { ...s, worktreesIndex: next };
53
+ if (s.activeTab === "issues")
54
+ return { ...s, issuesIndex: next };
55
+ if (s.activeTab === "worktrees")
56
+ return { ...s, worktreesIndex: next };
57
+ return { ...s, orchestrateIndex: next };
44
58
  }
45
59
  // xterm/iTerm/etc switch to the alternate screen buffer with these escape
46
60
  // codes. Using the buffer means the dashboard owns the whole window for its
@@ -121,11 +135,18 @@ function kebabize(title) {
121
135
  * Default prompt seeded into the overlay's Prompt field when the user opens
122
136
  * `w` for an issue. Single-line on purpose — `ink-text-input` is one-line,
123
137
  * so multi-line templates render weirdly when the user tabs in to edit.
124
- * Provider-aware: GitHub issues get the `#<n>` + `gh issue view` form;
125
- * Linear issues (id like `FE-123`) get the bare id + the issue URL, since
126
- * `gh` can't read Linear and `#` isn't Linear's notation.
138
+ *
139
+ * When the repo configures a `promptTemplate` in `.mintree/metadata.json`,
140
+ * it wins: the `{{id}}`, `{{title}}` and `{{url}}` placeholders are rendered
141
+ * and the result seeds the field. Otherwise we fall back to the built-in,
142
+ * provider-aware default: GitHub issues get the `#<n>` + `gh issue view`
143
+ * form; Linear issues (id like `FE-123`) get the bare id + the issue URL,
144
+ * since `gh` can't read Linear and `#` isn't Linear's notation.
127
145
  */
128
- function defaultPromptForIssue(id, title, url) {
146
+ function defaultPromptForIssue(id, title, url, template) {
147
+ if (template) {
148
+ return renderPromptTemplate(template, { id, title, url });
149
+ }
129
150
  const isTeamPrefixed = /^[A-Z][A-Z0-9_]*-\d+$/.test(id);
130
151
  if (isTeamPrefixed) {
131
152
  return `Empezá a trabajar el ticket ${id} (${title}). Abrí ${url} para leer el contexto completo y seguí las convenciones del repo.`;
@@ -175,12 +196,18 @@ function useTerminalSize() {
175
196
  }, [stdout]);
176
197
  return size;
177
198
  }
178
- function HeaderRow({ repoName, claudeVersion, issueCount, worktreeCount, activeTab, updateAvailable, }) {
199
+ // Tab order for / cycling.
200
+ const TAB_ORDER = ["issues", "worktrees", "orchestrate"];
201
+ function TabChip({ label, active }) {
202
+ return active ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: label })) : (_jsx(Text, { dimColor: true, children: label }));
203
+ }
204
+ function HeaderRow({ repoName, claudeVersion, issueCount, worktreeCount, orchestrateCount, activeTab, updateAvailable, }) {
179
205
  const issuesLabel = ` Issues (${issueCount}) `;
180
206
  const worktreesLabel = ` Worktrees (${worktreeCount}) `;
181
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsxs(Box, { children: [activeTab === "issues" ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: issuesLabel })) : (_jsx(Text, { dimColor: true, children: issuesLabel })), _jsx(Text, { children: " " }), activeTab === "worktrees" ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: worktreesLabel })) : (_jsx(Text, { dimColor: true, children: worktreesLabel })), _jsx(Text, { dimColor: true, children: " ← / → switch tab" })] })] }));
207
+ const orchestrateLabel = orchestrateCount > 0 ? ` Orchestrate (${orchestrateCount}) ` : ` Orchestrate `;
208
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsxs(Box, { children: [_jsx(TabChip, { label: issuesLabel, active: activeTab === "issues" }), _jsx(Text, { children: " " }), _jsx(TabChip, { label: worktreesLabel, active: activeTab === "worktrees" }), _jsx(Text, { children: " " }), _jsx(TabChip, { label: orchestrateLabel, active: activeTab === "orchestrate" }), _jsx(Text, { dimColor: true, children: " ← / → switch tab" })] })] }));
182
209
  }
183
- function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
210
+ function FooterRow({ phase, overlayKind, latestVersion, listWidth, activeTab, selectedCount, }) {
184
211
  if (phase === "error") {
185
212
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
186
213
  }
@@ -190,6 +217,11 @@ function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
190
217
  if (overlayKind === "remove") {
191
218
  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" })] }));
192
219
  }
220
+ // Orchestrate tab: selection-driven controls instead of the per-ticket
221
+ // work/open/remove actions.
222
+ if (activeTab === "orchestrate") {
223
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " Space" }), _jsx(Text, { dimColor: true, children: " toggle " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " a" }), _jsx(Text, { dimColor: true, children: " all " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " \u21B5" }), _jsxs(Text, { dimColor: true, children: [" orchestrate", selectedCount ? ` (${selectedCount})` : "", " "] }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _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` })] }))] }));
224
+ }
193
225
  // Two-column footer like santree: common navigation/dashboard commands
194
226
  // align under the left (list) pane; ticket-specific actions align under
195
227
  // the right (detail) pane. Falls back to a single inline row when no
@@ -228,7 +260,7 @@ function CreateStepIcon({ kind }) {
228
260
  return _jsx(Text, { color: "yellow", children: "!" });
229
261
  return _jsx(Text, { color: "cyan", children: "\u25CB" });
230
262
  }
231
- function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
263
+ function IssueListRow({ d, selected, identifierWidth, rowWidth, checkbox, }) {
232
264
  // Display the issue id raw (e.g. "FE-123", "100"). The `#` prefix is a
233
265
  // GitHub convention that reads as noise for Linear's already-prefixed
234
266
  // ids, and dropping it across the board keeps the dashboard provider-
@@ -242,12 +274,14 @@ function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
242
274
  // priority. See lib/priority.ts.
243
275
  const prio = priorityDisplay(d.issue.priority);
244
276
  const title = d.issue.title;
277
+ const checkPrefix = checkbox === undefined ? " " : checkbox === "on" ? "[✔] " : "[ ] ";
278
+ const checkColor = checkbox === "on" ? "green" : undefined;
245
279
  // The leading-dot Text and the rest are nested under a single Text so the
246
280
  // selection background paints the whole row in one contiguous block.
247
281
  // `wrap="truncate"` clamps the row to a single line and Ink renders an
248
282
  // ellipsis at the cut. The outer Box has a fixed width so the wrap
249
283
  // behaviour knows where to truncate.
250
- return (_jsx(Box, { width: rowWidth, children: _jsxs(Text, { wrap: "truncate", backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: [" ", _jsx(Text, { color: selected ? "white" : dotColor, children: "\u25CF" }), " ", _jsx(Text, { color: selected ? "white" : prio.color, children: prio.icon }), ` ${idText} ${title}`] }) }));
284
+ return (_jsx(Box, { width: rowWidth, children: _jsxs(Text, { wrap: "truncate", backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: [_jsx(Text, { color: selected ? "white" : checkColor, children: checkPrefix }), _jsx(Text, { color: selected ? "white" : dotColor, children: "\u25CF" }), " ", _jsx(Text, { color: selected ? "white" : prio.color, children: prio.icon }), ` ${idText} ${title}`] }) }));
251
285
  }
252
286
  // A project board header — the top level of the grouped issue list. Mirrors
253
287
  // the bold project name + dim count seen in the santree dashboard.
@@ -398,7 +432,7 @@ function windowListRows(listRows, selectedIndex, viewportRows) {
398
432
  }
399
433
  // Renders a single grouped-list row — used for both the sticky header region
400
434
  // and the scrollable body so the two stay visually identical.
401
- function ListRowView({ row, selectedIndex, identifierWidth, width, }) {
435
+ function ListRowView({ row, selectedIndex, identifierWidth, width, selectedIds, }) {
402
436
  if (row.kind === "spacer")
403
437
  return _jsx(Text, { children: " " });
404
438
  if (row.kind === "project") {
@@ -407,7 +441,7 @@ function ListRowView({ row, selectedIndex, identifierWidth, width, }) {
407
441
  if (row.kind === "status") {
408
442
  return _jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: width });
409
443
  }
410
- return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, rowWidth: width }));
444
+ return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, rowWidth: width, checkbox: selectedIds ? (selectedIds.has(row.d.issue.id) ? "on" : "off") : undefined }));
411
445
  }
412
446
  // Word-wraps a single line at `width` columns, breaking on the last space
413
447
  // before the limit when that yields a reasonable cut. Falls back to a hard
@@ -687,22 +721,42 @@ export default function Dashboard() {
687
721
  const activeTab = prevReady?.activeTab ?? "issues";
688
722
  const previousIssuesIndex = prevReady?.issuesIndex ?? 0;
689
723
  const previousWorktreesIndex = prevReady?.worktreesIndex ?? 0;
724
+ const previousOrchestrateIndex = prevReady?.orchestrateIndex ?? 0;
690
725
  const previousOverlay = prevReady?.overlay ?? null;
691
726
  const previousToast = prevReady?.toast ?? null;
692
727
  const previousScroll = prevReady?.detailScrollOffset ?? 0;
693
728
  const filter = prevReady?.filter ?? "";
694
729
  const issuesList = tabIssues(issues, "issues", filter);
695
730
  const worktreesList = tabIssues(issues, "worktrees", filter);
731
+ const orchestrateList = tabIssues(issues, "orchestrate", filter);
696
732
  const issuesIndex = Math.min(previousIssuesIndex, Math.max(0, issuesList.length - 1));
697
733
  const worktreesIndex = Math.min(previousWorktreesIndex, Math.max(0, worktreesList.length - 1));
734
+ const orchestrateIndex = Math.min(previousOrchestrateIndex, Math.max(0, orchestrateList.length - 1));
735
+ // Keep only checked ids that still exist among the open issues, so a
736
+ // resolved/closed ticket drops out of the batch instead of lingering.
737
+ const liveIds = new Set(issues.map((d) => d.issue.id));
738
+ const selectedIds = new Set([...(prevReady?.selectedIds ?? [])].filter((id) => liveIds.has(id)));
698
739
  // Preserve scroll only when the active tab's selected issue still
699
740
  // resolves to the same row — clamping or list churn means the user
700
741
  // is now reading something else.
701
742
  const prevDisplayed = prevReady ? tabIssues(prevReady.issues, activeTab, filter) : [];
702
- const nextDisplayed = activeTab === "issues" ? issuesList : worktreesList;
703
- const prevSelectedId = prevDisplayed[activeTab === "issues" ? previousIssuesIndex : previousWorktreesIndex]?.issue
704
- .id ?? null;
705
- const nextSelectedId = nextDisplayed[activeTab === "issues" ? issuesIndex : worktreesIndex]?.issue.id ?? null;
743
+ const nextDisplayed = activeTab === "worktrees"
744
+ ? worktreesList
745
+ : activeTab === "orchestrate"
746
+ ? orchestrateList
747
+ : issuesList;
748
+ const prevIdx = activeTab === "worktrees"
749
+ ? previousWorktreesIndex
750
+ : activeTab === "orchestrate"
751
+ ? previousOrchestrateIndex
752
+ : previousIssuesIndex;
753
+ const nextIdx = activeTab === "worktrees"
754
+ ? worktreesIndex
755
+ : activeTab === "orchestrate"
756
+ ? orchestrateIndex
757
+ : issuesIndex;
758
+ const prevSelectedId = prevDisplayed[prevIdx]?.issue.id ?? null;
759
+ const nextSelectedId = nextDisplayed[nextIdx]?.issue.id ?? null;
706
760
  const detailScrollOffset = prevSelectedId !== null && prevSelectedId === nextSelectedId ? previousScroll : 0;
707
761
  return {
708
762
  phase: "ready",
@@ -710,6 +764,8 @@ export default function Dashboard() {
710
764
  activeTab,
711
765
  issuesIndex,
712
766
  worktreesIndex,
767
+ orchestrateIndex,
768
+ selectedIds,
713
769
  detailScrollOffset,
714
770
  refreshing: false,
715
771
  overlay: previousOverlay,
@@ -839,7 +895,14 @@ export default function Dashboard() {
839
895
  // Esc clears an active numeric filter before it falls through to quit —
840
896
  // so the user can back out of a search without leaving the dashboard.
841
897
  if (key.escape && state.phase === "ready" && state.filter) {
842
- setState({ ...state, filter: "", issuesIndex: 0, worktreesIndex: 0, detailScrollOffset: 0 });
898
+ setState({
899
+ ...state,
900
+ filter: "",
901
+ issuesIndex: 0,
902
+ worktreesIndex: 0,
903
+ orchestrateIndex: 0,
904
+ detailScrollOffset: 0,
905
+ });
843
906
  return;
844
907
  }
845
908
  if (input === "q" || key.escape || (input === "c" && key.ctrl)) {
@@ -858,6 +921,7 @@ export default function Dashboard() {
858
921
  filter: state.filter + input,
859
922
  issuesIndex: 0,
860
923
  worktreesIndex: 0,
924
+ orchestrateIndex: 0,
861
925
  detailScrollOffset: 0,
862
926
  });
863
927
  return;
@@ -868,14 +932,17 @@ export default function Dashboard() {
868
932
  filter: state.filter.slice(0, -1),
869
933
  issuesIndex: 0,
870
934
  worktreesIndex: 0,
935
+ orchestrateIndex: 0,
871
936
  detailScrollOffset: 0,
872
937
  });
873
938
  return;
874
939
  }
875
940
  if (key.leftArrow || key.rightArrow) {
876
- // Two tabs only either arrow toggles. Per-tab indices are
877
- // preserved, so the user returns to the row they left.
878
- const next = state.activeTab === "issues" ? "worktrees" : "issues";
941
+ // Cycle through the three tabs; advances, goes back. Per-tab
942
+ // indices are preserved, so the user returns to the row they left.
943
+ const cur = TAB_ORDER.indexOf(state.activeTab);
944
+ const delta = key.leftArrow ? -1 : 1;
945
+ const next = TAB_ORDER[(cur + delta + TAB_ORDER.length) % TAB_ORDER.length];
879
946
  setState({ ...state, activeTab: next, detailScrollOffset: 0 });
880
947
  return;
881
948
  }
@@ -910,6 +977,34 @@ export default function Dashboard() {
910
977
  void refresh();
911
978
  return;
912
979
  }
980
+ // Orchestrate tab: Space toggles the ticket under the cursor; `a`
981
+ // toggles all visible tickets at once.
982
+ if (state.activeTab === "orchestrate" && input === " ") {
983
+ const { displayed, selectedIndex } = currentSelected(state);
984
+ const issue = displayed[selectedIndex];
985
+ if (!issue)
986
+ return;
987
+ const next = new Set(state.selectedIds);
988
+ if (next.has(issue.issue.id))
989
+ next.delete(issue.issue.id);
990
+ else
991
+ next.add(issue.issue.id);
992
+ setState({ ...state, selectedIds: next });
993
+ return;
994
+ }
995
+ if (state.activeTab === "orchestrate" && input === "a") {
996
+ const { displayed } = currentSelected(state);
997
+ const allSelected = displayed.length > 0 && displayed.every((d) => state.selectedIds.has(d.issue.id));
998
+ const next = new Set(state.selectedIds);
999
+ for (const d of displayed) {
1000
+ if (allSelected)
1001
+ next.delete(d.issue.id);
1002
+ else
1003
+ next.add(d.issue.id);
1004
+ }
1005
+ setState({ ...state, selectedIds: next });
1006
+ return;
1007
+ }
913
1008
  if (input === "o") {
914
1009
  const { displayed, selectedIndex } = currentSelected(state);
915
1010
  const issue = displayed[selectedIndex];
@@ -932,6 +1027,12 @@ export default function Dashboard() {
932
1027
  return;
933
1028
  }
934
1029
  if (key.return) {
1030
+ // Orchestrate tab: Enter launches the orchestrator over the checked
1031
+ // tickets instead of resuming/creating a single worktree.
1032
+ if (state.activeTab === "orchestrate") {
1033
+ launchOrchestrator();
1034
+ return;
1035
+ }
935
1036
  const { displayed, selectedIndex } = currentSelected(state);
936
1037
  const issue = displayed[selectedIndex];
937
1038
  if (!issue)
@@ -968,6 +1069,41 @@ export default function Dashboard() {
968
1069
  return;
969
1070
  }
970
1071
  });
1072
+ function launchOrchestrator() {
1073
+ if (state.phase !== "ready")
1074
+ return;
1075
+ const { displayed } = currentSelected(state);
1076
+ // Preserve the display order; only keep ids that are actually visible
1077
+ // and checked.
1078
+ const ids = displayed.filter((d) => state.selectedIds.has(d.issue.id)).map((d) => d.issue.id);
1079
+ if (ids.length === 0) {
1080
+ setState({
1081
+ ...state,
1082
+ toast: {
1083
+ kind: "error",
1084
+ text: "Seleccioná al menos un ticket (Space) antes de orquestar.",
1085
+ },
1086
+ });
1087
+ return;
1088
+ }
1089
+ const root = findMainRepoRoot();
1090
+ if (!root) {
1091
+ setState({ ...state, toast: { kind: "error", text: "No estás en un repositorio git." } });
1092
+ return;
1093
+ }
1094
+ const meta = readMetadata(root);
1095
+ const idList = ids.join(", ");
1096
+ const prompt = meta.orchestratorPromptTemplate
1097
+ ? renderOrchestratorTemplate(meta.orchestratorPromptTemplate, {
1098
+ ids: idList,
1099
+ count: ids.length,
1100
+ })
1101
+ : defaultOrchestratorPrompt(idList);
1102
+ const promptFile = writePromptFile(prompt);
1103
+ const permissionMode = meta.defaultPermissionMode ?? "default";
1104
+ emitMarkers(buildOrchestrateMarkers({ repoRoot: root, promptFile, permissionMode }));
1105
+ exit();
1106
+ }
971
1107
  function openCreateOverlay(issue) {
972
1108
  if (state.phase !== "ready")
973
1109
  return;
@@ -976,7 +1112,8 @@ export default function Dashboard() {
976
1112
  // (its `branchName`) over the synthesised `<type>/<issue>-<desc>` form —
977
1113
  // that's the convention those repos actually follow. Falls back to the
978
1114
  // convention form when the issue has no branchName.
979
- const provider = root ? readMetadata(root).provider : undefined;
1115
+ const meta = root ? readMetadata(root) : undefined;
1116
+ const provider = meta?.provider;
980
1117
  const linearBranch = provider === "linear" && issue.issue.branchName ? issue.issue.branchName : null;
981
1118
  setState({
982
1119
  ...state,
@@ -988,7 +1125,7 @@ export default function Dashboard() {
988
1125
  type: "feat",
989
1126
  desc: kebabize(issue.issue.title) || `issue-${issue.issue.id}`,
990
1127
  linearBranch,
991
- prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title, issue.issue.url),
1128
+ prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title, issue.issue.url, meta?.promptTemplate),
992
1129
  field: "branchMode",
993
1130
  error: null,
994
1131
  conventionDoc: root ? findBranchConventionDoc(root) : null,
@@ -1231,11 +1368,13 @@ export default function Dashboard() {
1231
1368
  if (state.phase === "error") {
1232
1369
  return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", state.message] }), state.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", state.hint] }) })), _jsx(Box, { marginTop: 1, children: _jsx(FooterRow, { phase: "error" }) })] }));
1233
1370
  }
1234
- const { issues, refreshing, overlay, toast, activeTab, filter } = state;
1371
+ const { issues, refreshing, overlay, toast, activeTab, filter, selectedIds } = state;
1235
1372
  const { displayed, selectedIndex } = currentSelected(state);
1236
1373
  const selected = displayed[selectedIndex] ?? null;
1237
1374
  const issuesTabCount = issues.reduce((n, d) => (isOrphan(d) ? n : n + 1), 0);
1238
1375
  const worktreesTabCount = issues.length - issuesTabCount;
1376
+ // The Orchestrate chip shows how many tickets are currently checked.
1377
+ const orchestrateTabCount = selectedIds.size;
1239
1378
  const onOverlayDescChange = (next) => {
1240
1379
  if (state.phase !== "ready" || !state.overlay)
1241
1380
  return;
@@ -1283,9 +1422,9 @@ export default function Dashboard() {
1283
1422
  : displayed.map((d, index) => ({ kind: "issue", d, index }));
1284
1423
  const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1285
1424
  const listContentWidth = Math.max(8, listWidth - 4);
1286
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issuesTabCount, worktreeCount: worktreesTabCount, activeTab: activeTab, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: displayed.length === 0 ? (_jsx(Text, { dimColor: true, children: filter
1425
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issuesTabCount, worktreeCount: worktreesTabCount, orchestrateCount: orchestrateTabCount, activeTab: activeTab, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: displayed.length === 0 ? (_jsx(Text, { dimColor: true, children: filter
1287
1426
  ? `No tickets match #${filter} — Esc to clear the filter.`
1288
- : activeTab === "issues"
1289
- ? "No open issues assigned to you in this repo."
1290
- : "No orphaned worktrees anything in `.mintree/worktrees/` matches an open issue." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [filter && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: `⌕ #${filter}` }), _jsx(Text, { dimColor: true, children: ` · ${displayed.length} match${displayed.length === 1 ? "" : "es"} · Esc clear` })] })), toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion, listWidth: listWidth })] })] }));
1427
+ : activeTab === "worktrees"
1428
+ ? "No orphaned worktrees anything in `.mintree/worktrees/` matches an open issue."
1429
+ : "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth, selectedIds: activeTab === "orchestrate" ? selectedIds : undefined }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth, selectedIds: activeTab === "orchestrate" ? selectedIds : undefined }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [filter && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: `⌕ #${filter}` }), _jsx(Text, { dimColor: true, children: ` · ${displayed.length} match${displayed.length === 1 ? "" : "es"} · Esc clear` })] })), toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion, listWidth: listWidth, activeTab: activeTab, selectedCount: orchestrateTabCount })] })] }));
1291
1430
  }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Launch a Claude orchestrator in the repo root to resolve a batch of tickets";
3
+ export declare const args: z.ZodArray<z.ZodString>;
4
+ export declare const options: z.ZodObject<{
5
+ prompt: z.ZodOptional<z.ZodString>;
6
+ promptFile: z.ZodOptional<z.ZodString>;
7
+ permissionMode: z.ZodOptional<z.ZodEnum<{
8
+ default: "default";
9
+ auto: "auto";
10
+ }>>;
11
+ }, z.core.$strip>;
12
+ type Props = {
13
+ args: z.infer<typeof args>;
14
+ options: z.infer<typeof options>;
15
+ };
16
+ export default function Orchestrate({ args: ids, options }: Props): import("react/jsx-runtime").JSX.Element;
17
+ export {};
@@ -0,0 +1,154 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { argument, option } from "pastel";
6
+ import { z } from "zod";
7
+ import { randomUUID } from "crypto";
8
+ import { readFileSync, unlinkSync } from "fs";
9
+ import { findMainRepoRoot, getMintreeDir, pathExists } from "../lib/git.js";
10
+ import { readMetadata } from "../lib/metadata.js";
11
+ import { launchClaude, PERMISSION_MODES } from "../lib/claude.js";
12
+ import { defaultOrchestratorPrompt, renderOrchestratorTemplate } from "../lib/promptTemplate.js";
13
+ export const description = "Launch a Claude orchestrator in the repo root to resolve a batch of tickets";
14
+ export const args = z.array(z.string()).describe(argument({
15
+ name: "ids",
16
+ description: "Ticket ids to orchestrate (e.g. VAL-81 VAL-84). Renders the orchestratorPromptTemplate (or the built-in default). Ignored when --prompt / --prompt-file is given.",
17
+ }));
18
+ export const options = z.object({
19
+ prompt: z
20
+ .string()
21
+ .optional()
22
+ .describe(option({
23
+ description: "Literal orchestrator message (overrides the template/ids).",
24
+ })),
25
+ promptFile: z
26
+ .string()
27
+ .optional()
28
+ .describe(option({
29
+ description: "Read the orchestrator message from this file (deleted after read). Used by the dashboard's Orchestrate tab. Mutually exclusive with --prompt.",
30
+ })),
31
+ permissionMode: z
32
+ .enum(PERMISSION_MODES)
33
+ .optional()
34
+ .describe(option({
35
+ description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")}). Defaults to metadata.defaultPermissionMode, else "default".`,
36
+ alias: "m",
37
+ })),
38
+ });
39
+ function resolve(cwd, ids, opts) {
40
+ if (opts.prompt && opts.promptFile) {
41
+ return { ok: false, message: "--prompt and --prompt-file are mutually exclusive." };
42
+ }
43
+ const repoRoot = findMainRepoRoot(cwd);
44
+ if (!repoRoot) {
45
+ return {
46
+ ok: false,
47
+ message: "Not in a git repository.",
48
+ hint: "Run `mintree orchestrate` from inside a mintree-enabled repo.",
49
+ };
50
+ }
51
+ if (!pathExists(getMintreeDir(repoRoot))) {
52
+ return {
53
+ ok: false,
54
+ message: ".mintree/ not found in this repo.",
55
+ hint: "Run `mintree init` first.",
56
+ };
57
+ }
58
+ // Resolve the orchestrator message. Priority: --prompt-file (the dashboard
59
+ // path) > --prompt (literal) > render the template from the ticket ids.
60
+ let prompt;
61
+ if (opts.promptFile) {
62
+ try {
63
+ prompt = readFileSync(opts.promptFile, "utf-8");
64
+ }
65
+ catch {
66
+ return {
67
+ ok: false,
68
+ message: `Could not read --prompt-file ${opts.promptFile}.`,
69
+ };
70
+ }
71
+ try {
72
+ unlinkSync(opts.promptFile);
73
+ }
74
+ catch {
75
+ // Cleanup failure is non-fatal.
76
+ }
77
+ }
78
+ else if (opts.prompt) {
79
+ prompt = opts.prompt;
80
+ }
81
+ else if (ids.length > 0) {
82
+ const idList = ids.join(", ");
83
+ const template = readMetadata(repoRoot).orchestratorPromptTemplate;
84
+ prompt = template
85
+ ? renderOrchestratorTemplate(template, { ids: idList, count: ids.length })
86
+ : defaultOrchestratorPrompt(idList);
87
+ }
88
+ if (!prompt || prompt.trim().length === 0) {
89
+ return {
90
+ ok: false,
91
+ message: "Nothing to orchestrate.",
92
+ hint: "Pass ticket ids (e.g. `mintree orchestrate VAL-81 VAL-84`) or a --prompt.",
93
+ };
94
+ }
95
+ const permissionMode = opts.permissionMode ?? readMetadata(repoRoot).defaultPermissionMode ?? "default";
96
+ return {
97
+ ok: true,
98
+ data: { repoRoot, sessionId: randomUUID(), permissionMode, prompt },
99
+ };
100
+ }
101
+ export default function Orchestrate({ args: ids, options }) {
102
+ const [state, setState] = useState({ phase: "loading" });
103
+ useEffect(() => {
104
+ setTimeout(() => {
105
+ const result = resolve(process.cwd(), ids, options);
106
+ if (!result.ok) {
107
+ setState({ phase: "error", message: result.message, hint: result.hint });
108
+ return;
109
+ }
110
+ setState({ phase: "launching", resolved: result.data });
111
+ }, 0);
112
+ }, []);
113
+ useEffect(() => {
114
+ if (state.phase !== "launching")
115
+ return;
116
+ const { resolved } = state;
117
+ try {
118
+ const child = launchClaude({
119
+ permissionMode: resolved.permissionMode,
120
+ sessionId: resolved.sessionId,
121
+ resume: false,
122
+ prompt: resolved.prompt,
123
+ cwd: resolved.repoRoot,
124
+ remoteControlName: "orchestrator",
125
+ });
126
+ child.on("error", (err) => {
127
+ setState({ phase: "error", message: `Failed to launch claude: ${err.message}` });
128
+ });
129
+ child.on("close", (code) => {
130
+ process.exit(code ?? 0);
131
+ });
132
+ }
133
+ catch (err) {
134
+ setState({
135
+ phase: "error",
136
+ message: err instanceof Error ? err.message : String(err),
137
+ });
138
+ }
139
+ }, [state.phase]);
140
+ if (state.phase === "loading") {
141
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Resolving repo..." })] }));
142
+ }
143
+ if (state.phase === "error") {
144
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", state.message] }), state.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", state.hint] }) }))] }));
145
+ }
146
+ const { resolved } = state;
147
+ const sessionShort = resolved.sessionId.slice(0, 8);
148
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree orchestrate" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.repoRoot] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsx(Text, { dimColor: true, children: " (starting)" })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: resolved.permissionMode })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "prompt: " }), _jsxs(Text, { children: ["\"", truncate(resolved.prompt.replace(/\n/g, " "), 60), "\""] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude orchestrator..." }) })] }));
149
+ }
150
+ function truncate(s, max) {
151
+ if (s.length <= max)
152
+ return s;
153
+ return s.slice(0, max - 1) + "…";
154
+ }
@@ -3,7 +3,7 @@ export declare const description = "Launch Claude in the current worktree (creat
3
3
  export declare const options: z.ZodObject<{
4
4
  prompt: z.ZodOptional<z.ZodString>;
5
5
  promptFile: z.ZodOptional<z.ZodString>;
6
- permissionMode: z.ZodDefault<z.ZodEnum<{
6
+ permissionMode: z.ZodOptional<z.ZodEnum<{
7
7
  default: "default";
8
8
  auto: "auto";
9
9
  }>>;
@@ -8,7 +8,7 @@ import { randomUUID } from "crypto";
8
8
  import { readFileSync, unlinkSync } from "fs";
9
9
  import * as path from "path";
10
10
  import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getCurrentBranch, pathExists, } from "../../lib/git.js";
11
- import { getSessionId, setSessionId } from "../../lib/metadata.js";
11
+ import { getSessionId, setSessionId, readMetadata } from "../../lib/metadata.js";
12
12
  import { launchClaude, PERMISSION_MODES } from "../../lib/claude.js";
13
13
  export const description = "Launch Claude in the current worktree (creates or resumes a session)";
14
14
  export const options = z.object({
@@ -26,13 +26,13 @@ export const options = z.object({
26
26
  })),
27
27
  permissionMode: z
28
28
  .enum(PERMISSION_MODES)
29
- .default("default")
29
+ .optional()
30
30
  .describe(option({
31
- description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")})`,
31
+ description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")}). Defaults to metadata.defaultPermissionMode, else "default".`,
32
32
  alias: "m",
33
33
  })),
34
34
  });
35
- function resolve(cwd) {
35
+ function resolve(cwd, flagPermissionMode) {
36
36
  const repoRoot = findMainRepoRoot(cwd);
37
37
  if (!repoRoot) {
38
38
  return {
@@ -93,6 +93,9 @@ function resolve(cwd) {
93
93
  setSessionId(repoRoot, issueId, sessionId);
94
94
  resume = false;
95
95
  }
96
+ // Effective permission mode: explicit `--permission-mode` flag wins, else
97
+ // the repo's `metadata.defaultPermissionMode`, else the stricter "default".
98
+ const permissionMode = flagPermissionMode ?? readMetadata(repoRoot).defaultPermissionMode ?? "default";
96
99
  return {
97
100
  ok: true,
98
101
  data: {
@@ -103,6 +106,7 @@ function resolve(cwd) {
103
106
  issueId,
104
107
  sessionId,
105
108
  resume,
109
+ permissionMode,
106
110
  },
107
111
  };
108
112
  }
@@ -118,7 +122,7 @@ export default function Work({ options }) {
118
122
  });
119
123
  return;
120
124
  }
121
- const result = resolve(process.cwd());
125
+ const result = resolve(process.cwd(), options.permissionMode);
122
126
  if (!result.ok) {
123
127
  setState({ phase: "error", message: result.message, hint: result.hint });
124
128
  return;
@@ -151,7 +155,7 @@ export default function Work({ options }) {
151
155
  }
152
156
  try {
153
157
  const child = launchClaude({
154
- permissionMode: options.permissionMode,
158
+ permissionMode: resolved.permissionMode,
155
159
  sessionId: resolved.sessionId,
156
160
  resume: resolved.resume,
157
161
  prompt: effectivePrompt,
@@ -184,7 +188,7 @@ export default function Work({ options }) {
184
188
  const { resolved } = state;
185
189
  const sessionShort = resolved.sessionId.slice(0, 8);
186
190
  const action = resolved.resume ? "resuming" : "starting";
187
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch ?? `detached @ ${resolved.worktreeDirName}`] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: options.permissionMode })] }), options.prompt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "initial prompt: " }), _jsxs(Text, { children: ["\"", truncate(options.prompt, 60), "\""] })] })), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd: " }), _jsx(Text, { dimColor: true, children: resolved.worktreePath })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }) })] }));
191
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch ?? `detached @ ${resolved.worktreeDirName}`] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: resolved.permissionMode })] }), options.prompt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "initial prompt: " }), _jsxs(Text, { children: ["\"", truncate(options.prompt, 60), "\""] })] })), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd: " }), _jsx(Text, { dimColor: true, children: resolved.worktreePath })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }) })] }));
188
192
  }
189
193
  function truncate(s, max) {
190
194
  if (s.length <= max)
@@ -19,3 +19,14 @@ export type CreateMarkers = {
19
19
  * three work-related markers only when --work was on.
20
20
  */
21
21
  export declare function buildCreateMarkers(input: CreateMarkers): string[];
22
+ export type OrchestrateMarkers = {
23
+ repoRoot: string;
24
+ promptFile: string;
25
+ permissionMode?: string;
26
+ };
27
+ /**
28
+ * Builds the marker block emitted when the dashboard launches the orchestrator
29
+ * from the Orchestrate tab. The shell wrapper cd's to `repoRoot` and then runs
30
+ * `mintree orchestrate --prompt-file <file> [--permission-mode <mode>]`.
31
+ */
32
+ export declare function buildOrchestrateMarkers(input: OrchestrateMarkers): string[];
@@ -41,3 +41,19 @@ export function buildCreateMarkers(input) {
41
41
  }
42
42
  return lines;
43
43
  }
44
+ /**
45
+ * Builds the marker block emitted when the dashboard launches the orchestrator
46
+ * from the Orchestrate tab. The shell wrapper cd's to `repoRoot` and then runs
47
+ * `mintree orchestrate --prompt-file <file> [--permission-mode <mode>]`.
48
+ */
49
+ export function buildOrchestrateMarkers(input) {
50
+ const lines = [
51
+ `MINTREE_CD:${input.repoRoot}`,
52
+ "MINTREE_ORCHESTRATE:1",
53
+ `MINTREE_ORCHESTRATE_PROMPT_FILE:${input.promptFile}`,
54
+ ];
55
+ if (input.permissionMode) {
56
+ lines.push(`MINTREE_PERMISSION_MODE:${input.permissionMode}`);
57
+ }
58
+ return lines;
59
+ }
@@ -1,3 +1,4 @@
1
+ import { type PermissionMode } from "./claude.js";
1
2
  export type IssueMeta = {
2
3
  base_branch?: string;
3
4
  session_id?: string;
@@ -26,6 +27,9 @@ export type Metadata = {
26
27
  issues: Record<string, IssueMeta>;
27
28
  project?: ProjectMeta;
28
29
  linear?: LinearMeta;
30
+ defaultPermissionMode?: PermissionMode;
31
+ promptTemplate?: string;
32
+ orchestratorPromptTemplate?: string;
29
33
  };
30
34
  export declare function readMetadata(repoRoot: string): Metadata;
31
35
  export declare function writeMetadata(repoRoot: string, data: Metadata): void;
@@ -1,11 +1,21 @@
1
1
  import * as fs from "fs";
2
2
  import { getMetadataPath } from "./git.js";
3
+ import { PERMISSION_MODES } from "./claude.js";
3
4
  const EMPTY = { version: 1, issues: {} };
4
5
  function sanitizeProvider(raw) {
5
6
  if (raw === "github" || raw === "linear")
6
7
  return raw;
7
8
  return undefined;
8
9
  }
10
+ function sanitizePermissionMode(raw) {
11
+ return PERMISSION_MODES.includes(raw) ? raw : undefined;
12
+ }
13
+ function sanitizePromptTemplate(raw) {
14
+ return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
15
+ }
16
+ function sanitizeOrchestratorPromptTemplate(raw) {
17
+ return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
18
+ }
9
19
  function sanitizeLinearTeam(raw) {
10
20
  if (typeof raw !== "object" || raw === null)
11
21
  return undefined;
@@ -77,6 +87,9 @@ export function readMetadata(repoRoot) {
77
87
  const project = sanitizeProject(parsed.project);
78
88
  const provider = sanitizeProvider(parsed.provider);
79
89
  const linear = sanitizeLinear(parsed.linear);
90
+ const defaultPermissionMode = sanitizePermissionMode(parsed.defaultPermissionMode);
91
+ const promptTemplate = sanitizePromptTemplate(parsed.promptTemplate);
92
+ const orchestratorPromptTemplate = sanitizeOrchestratorPromptTemplate(parsed.orchestratorPromptTemplate);
80
93
  return {
81
94
  version: 1,
82
95
  issues: typeof parsed.issues === "object" && parsed.issues !== null
@@ -85,6 +98,9 @@ export function readMetadata(repoRoot) {
85
98
  ...(provider ? { provider } : {}),
86
99
  ...(project ? { project } : {}),
87
100
  ...(linear ? { linear } : {}),
101
+ ...(defaultPermissionMode ? { defaultPermissionMode } : {}),
102
+ ...(promptTemplate ? { promptTemplate } : {}),
103
+ ...(orchestratorPromptTemplate ? { orchestratorPromptTemplate } : {}),
88
104
  };
89
105
  }
90
106
  catch {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Variables available to a `promptTemplate` in `.mintree/metadata.json`.
3
+ * Kept intentionally small — the template seeds Claude's first message, it
4
+ * doesn't need the whole issue object.
5
+ */
6
+ export type PromptVars = {
7
+ id: string;
8
+ title: string;
9
+ url: string;
10
+ };
11
+ export declare const PROMPT_PLACEHOLDERS: readonly ["{{id}}", "{{title}}", "{{url}}"];
12
+ /**
13
+ * Renders a `promptTemplate` by substituting the `{{id}}`, `{{title}}` and
14
+ * `{{url}}` placeholders with the issue's values. Whitespace inside the braces
15
+ * is tolerated (`{{ id }}`). Unknown placeholders are left untouched so a typo
16
+ * is visible in the launched prompt instead of silently vanishing.
17
+ */
18
+ export declare function renderPromptTemplate(template: string, vars: PromptVars): string;
19
+ /**
20
+ * Variables available to an `orchestratorPromptTemplate`. The orchestrator
21
+ * works a batch of tickets, so it only needs the list of ids and the count —
22
+ * not per-issue title/url (the orchestrator looks those up itself).
23
+ */
24
+ export type OrchestratorVars = {
25
+ ids: string;
26
+ count: number;
27
+ };
28
+ export declare const ORCHESTRATOR_PLACEHOLDERS: readonly ["{{ids}}", "{{count}}"];
29
+ /**
30
+ * Renders an `orchestratorPromptTemplate` by substituting the `{{ids}}` and
31
+ * `{{count}}` placeholders. Whitespace inside the braces is tolerated
32
+ * (`{{ ids }}`); unknown placeholders are left untouched so a typo is visible
33
+ * in the launched prompt instead of silently vanishing.
34
+ */
35
+ export declare function renderOrchestratorTemplate(template: string, vars: OrchestratorVars): string;
36
+ /**
37
+ * Built-in default for the orchestrator message, used when the repo doesn't
38
+ * configure an `orchestratorPromptTemplate`. Mirrors the manual flow the user
39
+ * was running by hand: act as an orchestrator over the selected tickets,
40
+ * resolve them with minimal intervention, parallelise via subagents unless
41
+ * dependencies force sequential work, and for each ticket follow the repo
42
+ * conventions, create the worktree with mintree, use the right skills, move it
43
+ * to "in progress" on start and close it when done.
44
+ */
45
+ export declare function defaultOrchestratorPrompt(ids: string): string;
@@ -0,0 +1,50 @@
1
+ // Placeholder tokens a user can drop into their `promptTemplate`. Documented
2
+ // here so the README and any future `init`/help output stay in sync.
3
+ export const PROMPT_PLACEHOLDERS = ["{{id}}", "{{title}}", "{{url}}"];
4
+ /**
5
+ * Renders a `promptTemplate` by substituting the `{{id}}`, `{{title}}` and
6
+ * `{{url}}` placeholders with the issue's values. Whitespace inside the braces
7
+ * is tolerated (`{{ id }}`). Unknown placeholders are left untouched so a typo
8
+ * is visible in the launched prompt instead of silently vanishing.
9
+ */
10
+ export function renderPromptTemplate(template, vars) {
11
+ return template
12
+ .replace(/\{\{\s*id\s*\}\}/g, vars.id)
13
+ .replace(/\{\{\s*title\s*\}\}/g, vars.title)
14
+ .replace(/\{\{\s*url\s*\}\}/g, vars.url);
15
+ }
16
+ // Placeholder tokens for an `orchestratorPromptTemplate`. Kept in sync with
17
+ // the README and any `init`/help output.
18
+ export const ORCHESTRATOR_PLACEHOLDERS = ["{{ids}}", "{{count}}"];
19
+ /**
20
+ * Renders an `orchestratorPromptTemplate` by substituting the `{{ids}}` and
21
+ * `{{count}}` placeholders. Whitespace inside the braces is tolerated
22
+ * (`{{ ids }}`); unknown placeholders are left untouched so a typo is visible
23
+ * in the launched prompt instead of silently vanishing.
24
+ */
25
+ export function renderOrchestratorTemplate(template, vars) {
26
+ return template
27
+ .replace(/\{\{\s*ids\s*\}\}/g, vars.ids)
28
+ .replace(/\{\{\s*count\s*\}\}/g, String(vars.count));
29
+ }
30
+ /**
31
+ * Built-in default for the orchestrator message, used when the repo doesn't
32
+ * configure an `orchestratorPromptTemplate`. Mirrors the manual flow the user
33
+ * was running by hand: act as an orchestrator over the selected tickets,
34
+ * resolve them with minimal intervention, parallelise via subagents unless
35
+ * dependencies force sequential work, and for each ticket follow the repo
36
+ * conventions, create the worktree with mintree, use the right skills, move it
37
+ * to "in progress" on start and close it when done.
38
+ */
39
+ export function defaultOrchestratorPrompt(ids) {
40
+ return [
41
+ `Quiero que hagas de orquestador con los tickets ${ids}.`,
42
+ "",
43
+ "La idea es que resuelvas esos tickets con la menor intervención mía posible.",
44
+ "Trabajá los tickets en paralelo creando subagentes (a no ser que tengan",
45
+ "dependencias entre sí y no se puedan paralelizar, en cuyo caso trabajalos",
46
+ "secuencialmente). Para cada ticket: seguí los lineamientos del repo, creá el",
47
+ "worktree usando mintree, usá las skills correctas para cada caso, poné el ticket",
48
+ 'en "in progress" al empezar y cerralo al terminar.',
49
+ ].join("\n");
50
+ }
@@ -39,6 +39,12 @@ export type CreateResult = {
39
39
  message: string;
40
40
  hint?: string;
41
41
  };
42
+ /**
43
+ * Stashes a `--prompt` value into a temp file so the shell wrapper can hand
44
+ * it back to `worktree work` via `--prompt-file`. Plain stdout markers can't
45
+ * carry multi-line / shell-special text safely, hence the file.
46
+ */
47
+ export declare function writePromptFile(prompt: string): string;
42
48
  /**
43
49
  * The whole `worktree create` flow as a pure function — same code path used
44
50
  * by the CLI command and by the dashboard's `w` overlay. Validates input,
@@ -54,7 +54,7 @@ function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
54
54
  * it back to `worktree work` via `--prompt-file`. Plain stdout markers can't
55
55
  * carry multi-line / shell-special text safely, hence the file.
56
56
  */
57
- function writePromptFile(prompt) {
57
+ export function writePromptFile(prompt) {
58
58
  const fileName = `mintree-prompt-${process.pid}-${Date.now()}.txt`;
59
59
  const filePath = path.join(os.tmpdir(), fileName);
60
60
  fs.writeFileSync(filePath, prompt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Mineo <mmineo@canarytechnologies.com>",
package/shell/init.bash CHANGED
@@ -43,14 +43,28 @@ function mintree() {
43
43
  fi
44
44
  cd "$target_dir" && echo "Switched to: $target_dir"
45
45
 
46
+ local perm_mode
47
+ perm_mode=$(echo "$clean_output" | grep "MINTREE_PERMISSION_MODE:" | sed 's/.*MINTREE_PERMISSION_MODE://')
48
+
49
+ # Orchestrate: launch a Claude orchestrator in the repo root (already
50
+ # cd'd above) with the batch prompt. Takes precedence over WORK — the
51
+ # two markers are never emitted together.
52
+ if [[ "$clean_output" == *MINTREE_ORCHESTRATE:* ]]; then
53
+ local extra=()
54
+ local orch_prompt_file
55
+ orch_prompt_file=$(echo "$clean_output" | grep "MINTREE_ORCHESTRATE_PROMPT_FILE:" | sed 's/.*MINTREE_ORCHESTRATE_PROMPT_FILE://')
56
+ [[ -n "$orch_prompt_file" ]] && extra+=(--prompt-file "$orch_prompt_file")
57
+ [[ -n "$perm_mode" ]] && extra+=(--permission-mode "$perm_mode")
58
+ command mintree orchestrate "${extra[@]}"
59
+ return $?
60
+ fi
61
+
46
62
  if [[ "$clean_output" != *MINTREE_WORK:* ]]; then
47
63
  return 0
48
64
  fi
49
65
  local extra=()
50
66
  local prompt_file
51
67
  prompt_file=$(echo "$clean_output" | grep "MINTREE_WORK_PROMPT_FILE:" | sed 's/.*MINTREE_WORK_PROMPT_FILE://')
52
- local perm_mode
53
- perm_mode=$(echo "$clean_output" | grep "MINTREE_PERMISSION_MODE:" | sed 's/.*MINTREE_PERMISSION_MODE://')
54
68
  [[ -n "$prompt_file" ]] && extra+=(--prompt-file "$prompt_file")
55
69
  [[ -n "$perm_mode" ]] && extra+=(--permission-mode "$perm_mode")
56
70
  command mintree worktree work "${extra[@]}"
@@ -63,7 +77,7 @@ function mintree() {
63
77
  local exit_code=$?
64
78
 
65
79
  if [[ "$output" == *MINTREE_CD:* ]]; then
66
- echo "$output" | grep -vE "MINTREE_(CD|WORK|WORK_PROMPT_FILE|PERMISSION_MODE):"
80
+ echo "$output" | grep -vE "MINTREE_(CD|WORK|WORK_PROMPT_FILE|PERMISSION_MODE|ORCHESTRATE|ORCHESTRATE_PROMPT_FILE):"
67
81
  local clean_output=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g')
68
82
  _mintree_handle_markers "$clean_output"
69
83
  else
package/shell/init.zsh CHANGED
@@ -53,14 +53,28 @@ function mintree() {
53
53
  fi
54
54
  cd "$target_dir" && echo "Switched to: $target_dir"
55
55
 
56
+ local perm_mode
57
+ perm_mode=$(echo "$clean_output" | grep "MINTREE_PERMISSION_MODE:" | sed 's/.*MINTREE_PERMISSION_MODE://')
58
+
59
+ # Orchestrate: launch a Claude orchestrator in the repo root (already
60
+ # cd'd above) with the batch prompt. Takes precedence over WORK — the
61
+ # two markers are never emitted together.
62
+ if [[ "$clean_output" == *MINTREE_ORCHESTRATE:* ]]; then
63
+ local extra=()
64
+ local orch_prompt_file
65
+ orch_prompt_file=$(echo "$clean_output" | grep "MINTREE_ORCHESTRATE_PROMPT_FILE:" | sed 's/.*MINTREE_ORCHESTRATE_PROMPT_FILE://')
66
+ [[ -n "$orch_prompt_file" ]] && extra+=(--prompt-file "$orch_prompt_file")
67
+ [[ -n "$perm_mode" ]] && extra+=(--permission-mode "$perm_mode")
68
+ command mintree orchestrate "${extra[@]}"
69
+ return $?
70
+ fi
71
+
56
72
  if [[ "$clean_output" != *MINTREE_WORK:* ]]; then
57
73
  return 0
58
74
  fi
59
75
  local extra=()
60
76
  local prompt_file
61
77
  prompt_file=$(echo "$clean_output" | grep "MINTREE_WORK_PROMPT_FILE:" | sed 's/.*MINTREE_WORK_PROMPT_FILE://')
62
- local perm_mode
63
- perm_mode=$(echo "$clean_output" | grep "MINTREE_PERMISSION_MODE:" | sed 's/.*MINTREE_PERMISSION_MODE://')
64
78
  [[ -n "$prompt_file" ]] && extra+=(--prompt-file "$prompt_file")
65
79
  [[ -n "$perm_mode" ]] && extra+=(--permission-mode "$perm_mode")
66
80
  command mintree worktree work "${extra[@]}"
@@ -75,7 +89,7 @@ function mintree() {
75
89
  local exit_code=$?
76
90
 
77
91
  if [[ "$output" == *MINTREE_CD:* ]]; then
78
- echo "$output" | grep -vE "MINTREE_(CD|WORK|WORK_PROMPT_FILE|PERMISSION_MODE):"
92
+ echo "$output" | grep -vE "MINTREE_(CD|WORK|WORK_PROMPT_FILE|PERMISSION_MODE|ORCHESTRATE|ORCHESTRATE_PROMPT_FILE):"
79
93
  local clean_output=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g')
80
94
  _mintree_handle_markers "$clean_output"
81
95
  else