mintree 0.4.10 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,7 +128,7 @@ The key goes straight into the `Authorization` header (no `Bearer` prefix). `min
128
128
 
129
129
  ### Launch behaviour (optional)
130
130
 
131
- Two top-level keys in `.mintree/metadata.json` tune how mintree launches Claude — both apply to GitHub and Linear repos alike:
131
+ Three top-level keys in `.mintree/metadata.json` tune how mintree launches Claude — all apply to GitHub and Linear repos alike:
132
132
 
133
133
  ```json
134
134
  {
@@ -137,6 +137,7 @@ Two top-level keys in `.mintree/metadata.json` tune how mintree launches Claude
137
137
  "issues": {},
138
138
  "defaultPermissionMode": "auto",
139
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.",
140
141
  "linear": { "workspaceSlug": "my-team", "teams": [{ "key": "FE" }] }
141
142
  }
142
143
  ```
@@ -151,6 +152,14 @@ Two top-level keys in `.mintree/metadata.json` tune how mintree launches Claude
151
152
  | `{{url}}` | Issue URL (GitHub issue page / Linear issue link) |
152
153
 
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.
154
163
 
155
164
  ---
156
165
 
@@ -164,10 +173,19 @@ mintree dashboard
164
173
 
165
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.
166
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
+
167
182
  | Shortcut | Action |
168
183
  |----------|-----------------------------------------------------------------------|
169
- | `↑/↓` or `j/k` | Move between issues |
170
- | `↵` | 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 |
171
189
  | `w` | Always open the create overlay (type + kebab description) |
172
190
  | `d` | Delete the selected worktree (confirmation overlay) |
173
191
  | `r` | Manual refresh (auto-refreshes silently every 5 min) |
@@ -199,6 +217,11 @@ mintree worktree list --pr # also fetch PR status per branch
199
217
  mintree worktree remove fix/55-bug # drop worktree but keep branch + session_id
200
218
  mintree worktree remove fix/55-bug --force # discard uncommitted changes too
201
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
202
225
  ```
203
226
 
204
227
  `mt`, `mtw`, `mtn` are shell aliases the wrapper installs for `mintree`, `mintree worktree`, and an interactive "name a branch" shortcut.
@@ -12,9 +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";
17
- import { renderPromptTemplate } from "../lib/promptTemplate.js";
18
+ import { defaultOrchestratorPrompt, renderOrchestratorTemplate, renderPromptTemplate, } from "../lib/promptTemplate.js";
18
19
  import { createProvider } from "../lib/providers/index.js";
19
20
  import { loadDashboard } from "../lib/dashboard.js";
20
21
  import { priorityDisplay } from "../lib/priority.js";
@@ -33,15 +34,27 @@ function issueMatchesFilter(d, filter) {
33
34
  return d.issue.id.replace(/\D/g, "").includes(filter);
34
35
  }
35
36
  function tabIssues(issues, tab, filter = "") {
36
- 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;
37
47
  }
38
48
  function currentSelected(s) {
39
49
  const displayed = tabIssues(s.issues, s.activeTab, s.filter);
40
- const selectedIndex = s.activeTab === "issues" ? s.issuesIndex : s.worktreesIndex;
41
- return { displayed, selectedIndex };
50
+ return { displayed, selectedIndex: tabIndex(s, s.activeTab) };
42
51
  }
43
52
  function withSelectedIndex(s, next) {
44
- 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 };
45
58
  }
46
59
  // xterm/iTerm/etc switch to the alternate screen buffer with these escape
47
60
  // codes. Using the buffer means the dashboard owns the whole window for its
@@ -183,12 +196,18 @@ function useTerminalSize() {
183
196
  }, [stdout]);
184
197
  return size;
185
198
  }
186
- 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, }) {
187
205
  const issuesLabel = ` Issues (${issueCount}) `;
188
206
  const worktreesLabel = ` Worktrees (${worktreeCount}) `;
189
- 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" })] })] }));
190
209
  }
191
- function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
210
+ function FooterRow({ phase, overlayKind, latestVersion, listWidth, activeTab, selectedCount, }) {
192
211
  if (phase === "error") {
193
212
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
194
213
  }
@@ -198,6 +217,11 @@ function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
198
217
  if (overlayKind === "remove") {
199
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" })] }));
200
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
+ }
201
225
  // Two-column footer like santree: common navigation/dashboard commands
202
226
  // align under the left (list) pane; ticket-specific actions align under
203
227
  // the right (detail) pane. Falls back to a single inline row when no
@@ -236,7 +260,7 @@ function CreateStepIcon({ kind }) {
236
260
  return _jsx(Text, { color: "yellow", children: "!" });
237
261
  return _jsx(Text, { color: "cyan", children: "\u25CB" });
238
262
  }
239
- function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
263
+ function IssueListRow({ d, selected, identifierWidth, rowWidth, checkbox, }) {
240
264
  // Display the issue id raw (e.g. "FE-123", "100"). The `#` prefix is a
241
265
  // GitHub convention that reads as noise for Linear's already-prefixed
242
266
  // ids, and dropping it across the board keeps the dashboard provider-
@@ -250,12 +274,14 @@ function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
250
274
  // priority. See lib/priority.ts.
251
275
  const prio = priorityDisplay(d.issue.priority);
252
276
  const title = d.issue.title;
277
+ const checkPrefix = checkbox === undefined ? " " : checkbox === "on" ? "[✔] " : "[ ] ";
278
+ const checkColor = checkbox === "on" ? "green" : undefined;
253
279
  // The leading-dot Text and the rest are nested under a single Text so the
254
280
  // selection background paints the whole row in one contiguous block.
255
281
  // `wrap="truncate"` clamps the row to a single line and Ink renders an
256
282
  // ellipsis at the cut. The outer Box has a fixed width so the wrap
257
283
  // behaviour knows where to truncate.
258
- 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}`] }) }));
259
285
  }
260
286
  // A project board header — the top level of the grouped issue list. Mirrors
261
287
  // the bold project name + dim count seen in the santree dashboard.
@@ -406,7 +432,7 @@ function windowListRows(listRows, selectedIndex, viewportRows) {
406
432
  }
407
433
  // Renders a single grouped-list row — used for both the sticky header region
408
434
  // and the scrollable body so the two stay visually identical.
409
- function ListRowView({ row, selectedIndex, identifierWidth, width, }) {
435
+ function ListRowView({ row, selectedIndex, identifierWidth, width, selectedIds, }) {
410
436
  if (row.kind === "spacer")
411
437
  return _jsx(Text, { children: " " });
412
438
  if (row.kind === "project") {
@@ -415,7 +441,7 @@ function ListRowView({ row, selectedIndex, identifierWidth, width, }) {
415
441
  if (row.kind === "status") {
416
442
  return _jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: width });
417
443
  }
418
- 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 }));
419
445
  }
420
446
  // Word-wraps a single line at `width` columns, breaking on the last space
421
447
  // before the limit when that yields a reasonable cut. Falls back to a hard
@@ -695,22 +721,42 @@ export default function Dashboard() {
695
721
  const activeTab = prevReady?.activeTab ?? "issues";
696
722
  const previousIssuesIndex = prevReady?.issuesIndex ?? 0;
697
723
  const previousWorktreesIndex = prevReady?.worktreesIndex ?? 0;
724
+ const previousOrchestrateIndex = prevReady?.orchestrateIndex ?? 0;
698
725
  const previousOverlay = prevReady?.overlay ?? null;
699
726
  const previousToast = prevReady?.toast ?? null;
700
727
  const previousScroll = prevReady?.detailScrollOffset ?? 0;
701
728
  const filter = prevReady?.filter ?? "";
702
729
  const issuesList = tabIssues(issues, "issues", filter);
703
730
  const worktreesList = tabIssues(issues, "worktrees", filter);
731
+ const orchestrateList = tabIssues(issues, "orchestrate", filter);
704
732
  const issuesIndex = Math.min(previousIssuesIndex, Math.max(0, issuesList.length - 1));
705
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)));
706
739
  // Preserve scroll only when the active tab's selected issue still
707
740
  // resolves to the same row — clamping or list churn means the user
708
741
  // is now reading something else.
709
742
  const prevDisplayed = prevReady ? tabIssues(prevReady.issues, activeTab, filter) : [];
710
- const nextDisplayed = activeTab === "issues" ? issuesList : worktreesList;
711
- const prevSelectedId = prevDisplayed[activeTab === "issues" ? previousIssuesIndex : previousWorktreesIndex]?.issue
712
- .id ?? null;
713
- 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;
714
760
  const detailScrollOffset = prevSelectedId !== null && prevSelectedId === nextSelectedId ? previousScroll : 0;
715
761
  return {
716
762
  phase: "ready",
@@ -718,6 +764,8 @@ export default function Dashboard() {
718
764
  activeTab,
719
765
  issuesIndex,
720
766
  worktreesIndex,
767
+ orchestrateIndex,
768
+ selectedIds,
721
769
  detailScrollOffset,
722
770
  refreshing: false,
723
771
  overlay: previousOverlay,
@@ -847,7 +895,14 @@ export default function Dashboard() {
847
895
  // Esc clears an active numeric filter before it falls through to quit —
848
896
  // so the user can back out of a search without leaving the dashboard.
849
897
  if (key.escape && state.phase === "ready" && state.filter) {
850
- 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
+ });
851
906
  return;
852
907
  }
853
908
  if (input === "q" || key.escape || (input === "c" && key.ctrl)) {
@@ -866,6 +921,7 @@ export default function Dashboard() {
866
921
  filter: state.filter + input,
867
922
  issuesIndex: 0,
868
923
  worktreesIndex: 0,
924
+ orchestrateIndex: 0,
869
925
  detailScrollOffset: 0,
870
926
  });
871
927
  return;
@@ -876,14 +932,17 @@ export default function Dashboard() {
876
932
  filter: state.filter.slice(0, -1),
877
933
  issuesIndex: 0,
878
934
  worktreesIndex: 0,
935
+ orchestrateIndex: 0,
879
936
  detailScrollOffset: 0,
880
937
  });
881
938
  return;
882
939
  }
883
940
  if (key.leftArrow || key.rightArrow) {
884
- // Two tabs only either arrow toggles. Per-tab indices are
885
- // preserved, so the user returns to the row they left.
886
- 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];
887
946
  setState({ ...state, activeTab: next, detailScrollOffset: 0 });
888
947
  return;
889
948
  }
@@ -918,6 +977,34 @@ export default function Dashboard() {
918
977
  void refresh();
919
978
  return;
920
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
+ }
921
1008
  if (input === "o") {
922
1009
  const { displayed, selectedIndex } = currentSelected(state);
923
1010
  const issue = displayed[selectedIndex];
@@ -940,6 +1027,12 @@ export default function Dashboard() {
940
1027
  return;
941
1028
  }
942
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
+ }
943
1036
  const { displayed, selectedIndex } = currentSelected(state);
944
1037
  const issue = displayed[selectedIndex];
945
1038
  if (!issue)
@@ -976,6 +1069,41 @@ export default function Dashboard() {
976
1069
  return;
977
1070
  }
978
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
+ }
979
1107
  function openCreateOverlay(issue) {
980
1108
  if (state.phase !== "ready")
981
1109
  return;
@@ -1240,11 +1368,13 @@ export default function Dashboard() {
1240
1368
  if (state.phase === "error") {
1241
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" }) })] }));
1242
1370
  }
1243
- const { issues, refreshing, overlay, toast, activeTab, filter } = state;
1371
+ const { issues, refreshing, overlay, toast, activeTab, filter, selectedIds } = state;
1244
1372
  const { displayed, selectedIndex } = currentSelected(state);
1245
1373
  const selected = displayed[selectedIndex] ?? null;
1246
1374
  const issuesTabCount = issues.reduce((n, d) => (isOrphan(d) ? n : n + 1), 0);
1247
1375
  const worktreesTabCount = issues.length - issuesTabCount;
1376
+ // The Orchestrate chip shows how many tickets are currently checked.
1377
+ const orchestrateTabCount = selectedIds.size;
1248
1378
  const onOverlayDescChange = (next) => {
1249
1379
  if (state.phase !== "ready" || !state.overlay)
1250
1380
  return;
@@ -1292,9 +1422,9 @@ export default function Dashboard() {
1292
1422
  : displayed.map((d, index) => ({ kind: "issue", d, index }));
1293
1423
  const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1294
1424
  const listContentWidth = Math.max(8, listWidth - 4);
1295
- 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
1296
1426
  ? `No tickets match #${filter} — Esc to clear the filter.`
1297
- : activeTab === "issues"
1298
- ? "No open issues assigned to you in this repo."
1299
- : "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 })] })] }));
1300
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.ZodDefault<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,157 @@
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
15
+ .array(z.string())
16
+ .default([])
17
+ .describe(argument({
18
+ name: "ids",
19
+ 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. Optional: the dashboard hands the prompt over via --prompt-file with no positional ids.",
20
+ }));
21
+ export const options = z.object({
22
+ prompt: z
23
+ .string()
24
+ .optional()
25
+ .describe(option({
26
+ description: "Literal orchestrator message (overrides the template/ids).",
27
+ })),
28
+ promptFile: z
29
+ .string()
30
+ .optional()
31
+ .describe(option({
32
+ description: "Read the orchestrator message from this file (deleted after read). Used by the dashboard's Orchestrate tab. Mutually exclusive with --prompt.",
33
+ })),
34
+ permissionMode: z
35
+ .enum(PERMISSION_MODES)
36
+ .optional()
37
+ .describe(option({
38
+ description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")}). Defaults to metadata.defaultPermissionMode, else "default".`,
39
+ alias: "m",
40
+ })),
41
+ });
42
+ function resolve(cwd, ids, opts) {
43
+ if (opts.prompt && opts.promptFile) {
44
+ return { ok: false, message: "--prompt and --prompt-file are mutually exclusive." };
45
+ }
46
+ const repoRoot = findMainRepoRoot(cwd);
47
+ if (!repoRoot) {
48
+ return {
49
+ ok: false,
50
+ message: "Not in a git repository.",
51
+ hint: "Run `mintree orchestrate` from inside a mintree-enabled repo.",
52
+ };
53
+ }
54
+ if (!pathExists(getMintreeDir(repoRoot))) {
55
+ return {
56
+ ok: false,
57
+ message: ".mintree/ not found in this repo.",
58
+ hint: "Run `mintree init` first.",
59
+ };
60
+ }
61
+ // Resolve the orchestrator message. Priority: --prompt-file (the dashboard
62
+ // path) > --prompt (literal) > render the template from the ticket ids.
63
+ let prompt;
64
+ if (opts.promptFile) {
65
+ try {
66
+ prompt = readFileSync(opts.promptFile, "utf-8");
67
+ }
68
+ catch {
69
+ return {
70
+ ok: false,
71
+ message: `Could not read --prompt-file ${opts.promptFile}.`,
72
+ };
73
+ }
74
+ try {
75
+ unlinkSync(opts.promptFile);
76
+ }
77
+ catch {
78
+ // Cleanup failure is non-fatal.
79
+ }
80
+ }
81
+ else if (opts.prompt) {
82
+ prompt = opts.prompt;
83
+ }
84
+ else if (ids.length > 0) {
85
+ const idList = ids.join(", ");
86
+ const template = readMetadata(repoRoot).orchestratorPromptTemplate;
87
+ prompt = template
88
+ ? renderOrchestratorTemplate(template, { ids: idList, count: ids.length })
89
+ : defaultOrchestratorPrompt(idList);
90
+ }
91
+ if (!prompt || prompt.trim().length === 0) {
92
+ return {
93
+ ok: false,
94
+ message: "Nothing to orchestrate.",
95
+ hint: "Pass ticket ids (e.g. `mintree orchestrate VAL-81 VAL-84`) or a --prompt.",
96
+ };
97
+ }
98
+ const permissionMode = opts.permissionMode ?? readMetadata(repoRoot).defaultPermissionMode ?? "default";
99
+ return {
100
+ ok: true,
101
+ data: { repoRoot, sessionId: randomUUID(), permissionMode, prompt },
102
+ };
103
+ }
104
+ export default function Orchestrate({ args: ids, options }) {
105
+ const [state, setState] = useState({ phase: "loading" });
106
+ useEffect(() => {
107
+ setTimeout(() => {
108
+ const result = resolve(process.cwd(), ids, options);
109
+ if (!result.ok) {
110
+ setState({ phase: "error", message: result.message, hint: result.hint });
111
+ return;
112
+ }
113
+ setState({ phase: "launching", resolved: result.data });
114
+ }, 0);
115
+ }, []);
116
+ useEffect(() => {
117
+ if (state.phase !== "launching")
118
+ return;
119
+ const { resolved } = state;
120
+ try {
121
+ const child = launchClaude({
122
+ permissionMode: resolved.permissionMode,
123
+ sessionId: resolved.sessionId,
124
+ resume: false,
125
+ prompt: resolved.prompt,
126
+ cwd: resolved.repoRoot,
127
+ remoteControlName: "orchestrator",
128
+ });
129
+ child.on("error", (err) => {
130
+ setState({ phase: "error", message: `Failed to launch claude: ${err.message}` });
131
+ });
132
+ child.on("close", (code) => {
133
+ process.exit(code ?? 0);
134
+ });
135
+ }
136
+ catch (err) {
137
+ setState({
138
+ phase: "error",
139
+ message: err instanceof Error ? err.message : String(err),
140
+ });
141
+ }
142
+ }, [state.phase]);
143
+ if (state.phase === "loading") {
144
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Resolving repo..." })] }));
145
+ }
146
+ if (state.phase === "error") {
147
+ 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] }) }))] }));
148
+ }
149
+ const { resolved } = state;
150
+ const sessionShort = resolved.sessionId.slice(0, 8);
151
+ 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..." }) })] }));
152
+ }
153
+ function truncate(s, max) {
154
+ if (s.length <= max)
155
+ return s;
156
+ return s.slice(0, max - 1) + "…";
157
+ }
@@ -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
+ }
@@ -29,6 +29,7 @@ export type Metadata = {
29
29
  linear?: LinearMeta;
30
30
  defaultPermissionMode?: PermissionMode;
31
31
  promptTemplate?: string;
32
+ orchestratorPromptTemplate?: string;
32
33
  };
33
34
  export declare function readMetadata(repoRoot: string): Metadata;
34
35
  export declare function writeMetadata(repoRoot: string, data: Metadata): void;
@@ -13,6 +13,9 @@ function sanitizePermissionMode(raw) {
13
13
  function sanitizePromptTemplate(raw) {
14
14
  return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
15
15
  }
16
+ function sanitizeOrchestratorPromptTemplate(raw) {
17
+ return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
18
+ }
16
19
  function sanitizeLinearTeam(raw) {
17
20
  if (typeof raw !== "object" || raw === null)
18
21
  return undefined;
@@ -86,6 +89,7 @@ export function readMetadata(repoRoot) {
86
89
  const linear = sanitizeLinear(parsed.linear);
87
90
  const defaultPermissionMode = sanitizePermissionMode(parsed.defaultPermissionMode);
88
91
  const promptTemplate = sanitizePromptTemplate(parsed.promptTemplate);
92
+ const orchestratorPromptTemplate = sanitizeOrchestratorPromptTemplate(parsed.orchestratorPromptTemplate);
89
93
  return {
90
94
  version: 1,
91
95
  issues: typeof parsed.issues === "object" && parsed.issues !== null
@@ -96,6 +100,7 @@ export function readMetadata(repoRoot) {
96
100
  ...(linear ? { linear } : {}),
97
101
  ...(defaultPermissionMode ? { defaultPermissionMode } : {}),
98
102
  ...(promptTemplate ? { promptTemplate } : {}),
103
+ ...(orchestratorPromptTemplate ? { orchestratorPromptTemplate } : {}),
99
104
  };
100
105
  }
101
106
  catch {
@@ -16,3 +16,30 @@ export declare const PROMPT_PLACEHOLDERS: readonly ["{{id}}", "{{title}}", "{{ur
16
16
  * is visible in the launched prompt instead of silently vanishing.
17
17
  */
18
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;
@@ -13,3 +13,38 @@ export function renderPromptTemplate(template, vars) {
13
13
  .replace(/\{\{\s*title\s*\}\}/g, vars.title)
14
14
  .replace(/\{\{\s*url\s*\}\}/g, vars.url);
15
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
+ }
@@ -21,7 +21,10 @@ import { readMetadata } from "../metadata.js";
21
21
  const DEFAULT_API_URL = "https://api.linear.app/graphql";
22
22
  // Linear state types we treat as "done" — work in these states is excluded
23
23
  // from the assigned list and protected from transitions back to In Progress.
24
- const DEFAULT_PROTECTED_STATE_TYPES = ["completed", "canceled"];
24
+ // "duplicate" is Linear's own state type for issues closed as duplicates
25
+ // (e.g. a "Duplicate" workflow state); it must be excluded alongside
26
+ // completed/canceled or those tickets keep showing up in the dashboard.
27
+ const DEFAULT_PROTECTED_STATE_TYPES = ["completed", "canceled", "duplicate"];
25
28
  const STATUS_ORDER_UNSET = 999;
26
29
  // One query covers viewer + teams + issues; a single 20s budget comfortably
27
30
  // fits even the slowest cold-start response without making real failures
@@ -261,7 +264,7 @@ const BOOTSTRAP_QUERY = /* GraphQL */ `
261
264
  first: 100
262
265
  filter: {
263
266
  assignee: { isMe: { eq: true } }
264
- state: { type: { nin: ["completed", "canceled"] } }
267
+ state: { type: { nin: ["completed", "canceled", "duplicate"] } }
265
268
  team: { key: { in: $teamKeys } }
266
269
  }
267
270
  ) {
@@ -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.10",
3
+ "version": "0.5.2",
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