mintree 0.1.3 → 0.1.5

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.
@@ -5,12 +5,12 @@ import TextInput from "ink-text-input";
5
5
  import Spinner from "ink-spinner";
6
6
  import { execSync } from "child_process";
7
7
  import { createRequire } from "module";
8
- import { findBranchConventionDoc, findMainRepoRoot, getMintreeDir, pathExists, } from "../lib/git.js";
8
+ import { findBranchConventionDoc, findMainRepoRoot, getCurrentBranch, getMintreeDir, pathExists, } from "../lib/git.js";
9
9
  import { resolveClaudeBinary } from "../lib/claude.js";
10
10
  import { tryExec } from "../lib/exec.js";
11
11
  import { ALLOWED_TYPES } from "../lib/branch.js";
12
- import { runCreate } from "../lib/worktreeCreate.js";
13
- import { runRemove } from "../lib/worktreeRemove.js";
12
+ import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
13
+ import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
14
14
  import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
15
15
  import { loadDashboard, } from "../lib/dashboard.js";
16
16
  const require = createRequire(import.meta.url);
@@ -105,6 +105,17 @@ function kebabize(title) {
105
105
  .replace(/-+/g, "-")
106
106
  .replace(/^-+|-+$/g, "");
107
107
  }
108
+ /**
109
+ * Default prompt seeded into the overlay's Prompt field when the user opens
110
+ * `w` for an issue. Single-line on purpose — `ink-text-input` is one-line,
111
+ * so multi-line templates render weirdly when the user tabs in to edit.
112
+ * Points the agent at `gh issue view` for the full body rather than dumping
113
+ * the body inline (issue bodies can be long and contain markdown that
114
+ * doesn't survive argv). User can clear or rewrite freely before Enter.
115
+ */
116
+ function defaultPromptForIssue(number, title) {
117
+ return `Empezá a trabajar el issue #${number} (${title}). Usá \`gh issue view ${number}\` para leer el contexto completo y seguí las convenciones del repo.`;
118
+ }
108
119
  /**
109
120
  * Sanitises whatever the user typed into the desc field on every keystroke.
110
121
  * Same rules as kebabize but without the word cap — this is for live input.
@@ -160,7 +171,7 @@ function FooterRow({ phase, overlayKind, }) {
160
171
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
161
172
  }
162
173
  if (overlayKind === "create") {
163
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " change type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
174
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " toggle branch / cycle type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
164
175
  }
165
176
  if (overlayKind === "remove") {
166
177
  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" })] }));
@@ -168,12 +179,21 @@ function FooterRow({ phase, overlayKind, }) {
168
179
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " remove" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] })] }));
169
180
  }
170
181
  function RemoveOverlayView({ overlay }) {
171
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " to force-remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, color: "green", children: "y" }), " to remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
182
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " to force-remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, color: "green", children: "y" }), " to remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
172
183
  }
173
184
  function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
174
185
  const labelWidth = 14;
175
- const branchPreview = `${overlay.type}/${overlay.issue.issue.number}-${overlay.desc}`;
176
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && (_jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" }))] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(optional) initial message for Claude" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(optional — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Branch:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] }), overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
186
+ const isNewBranch = overlay.branchMode === "new";
187
+ const detachedDesc = kebabize(overlay.issue.issue.title) || `issue-${overlay.issue.issue.number}`;
188
+ const branchPreview = isNewBranch
189
+ ? `${overlay.type}/${overlay.issue.issue.number}-${overlay.desc}`
190
+ : `detached @ ${overlay.currentBranch ?? "(unknown)"}`;
191
+ const dirPreview = isNewBranch
192
+ ? `${overlay.issue.issue.number}-${overlay.desc}`
193
+ : `${overlay.issue.issue.number}-${detachedDesc}`;
194
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch
195
+ ? "new"
196
+ : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && (_jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" }))] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && (_jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" }))] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
177
197
  }
178
198
  function stateChar(state) {
179
199
  if (!state)
@@ -296,7 +316,7 @@ function buildDetailLines(d, width) {
296
316
  lines.push(blank());
297
317
  lines.push([{ text: "⌥ Worktree", bold: true }]);
298
318
  if (d.worktree) {
299
- for (const w2 of wrapLine(` branch: ${d.worktree.branch}`, w))
319
+ for (const w2 of wrapLine(` branch: ${d.worktree.branch ?? "(detached HEAD)"}`, w))
300
320
  lines.push([{ text: w2, dim: true }]);
301
321
  for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
302
322
  lines.push([{ text: w2, dim: true }]);
@@ -638,6 +658,7 @@ export default function Dashboard() {
638
658
  kind: "remove",
639
659
  issue,
640
660
  branch: issue.worktree.branch,
661
+ worktreePath: issue.worktree.path,
641
662
  dirty: issue.worktree.dirty,
642
663
  error: null,
643
664
  },
@@ -655,10 +676,12 @@ export default function Dashboard() {
655
676
  overlay: {
656
677
  kind: "create",
657
678
  issue,
679
+ branchMode: "new",
680
+ currentBranch: root ? getCurrentBranch(root) : null,
658
681
  type: "feat",
659
682
  desc: kebabize(issue.issue.title) || `issue-${issue.issue.number}`,
660
- prompt: "",
661
- field: "type",
683
+ prompt: defaultPromptForIssue(issue.issue.number, issue.issue.title),
684
+ field: "branchMode",
662
685
  error: null,
663
686
  conventionDoc: root ? findBranchConventionDoc(root) : null,
664
687
  },
@@ -678,18 +701,28 @@ export default function Dashboard() {
678
701
  return;
679
702
  }
680
703
  // Create overlay from here on.
704
+ // In "current" branch mode we skip type+desc fields entirely — they have
705
+ // no meaning when the worktree is going to be detached. Tab cycles
706
+ // branchMode ⇄ prompt only.
681
707
  if (key.tab) {
682
- const nextField = overlay.field === "type"
683
- ? "desc"
684
- : overlay.field === "desc"
685
- ? "prompt"
686
- : "type";
708
+ const order = overlay.branchMode === "current"
709
+ ? ["branchMode", "prompt"]
710
+ : ["branchMode", "type", "desc", "prompt"];
711
+ const i = order.indexOf(overlay.field);
712
+ const nextField = order[(i + 1) % order.length];
687
713
  setState({
688
714
  ...state,
689
715
  overlay: { ...overlay, field: nextField },
690
716
  });
691
717
  return;
692
718
  }
719
+ if (overlay.field === "branchMode") {
720
+ if (key.leftArrow || key.rightArrow || input === "h" || input === "l") {
721
+ const next = overlay.branchMode === "new" ? "current" : "new";
722
+ setState({ ...state, overlay: { ...overlay, branchMode: next, error: null } });
723
+ return;
724
+ }
725
+ }
693
726
  if (overlay.field === "type") {
694
727
  if (key.leftArrow || input === "h") {
695
728
  const idx = ALLOWED_TYPES.indexOf(overlay.type);
@@ -734,20 +767,36 @@ export default function Dashboard() {
734
767
  async function confirmCreate(overlay) {
735
768
  if (state.phase !== "ready")
736
769
  return;
737
- const desc = overlay.desc.trim();
738
- if (!desc) {
739
- setState({
740
- ...state,
741
- overlay: { ...overlay, error: "Description is required." },
770
+ const prompt = overlay.prompt.trim();
771
+ const issueNumber = overlay.issue.issue.number;
772
+ let result;
773
+ if (overlay.branchMode === "current") {
774
+ // Detached worktree off the main repo's current branch. Desc comes
775
+ // from the issue title (kebabized), not user input — keeping the
776
+ // "current branch" flow as low-friction as possible.
777
+ const descKebab = kebabize(overlay.issue.issue.title) || `issue-${issueNumber}`;
778
+ result = runCreateDetached({
779
+ issueId: String(issueNumber),
780
+ descKebab,
781
+ work: true,
782
+ ...(prompt.length > 0 ? { prompt } : {}),
783
+ });
784
+ }
785
+ else {
786
+ const desc = overlay.desc.trim();
787
+ if (!desc) {
788
+ setState({
789
+ ...state,
790
+ overlay: { ...overlay, error: "Description is required." },
791
+ });
792
+ return;
793
+ }
794
+ const branch = `${overlay.type}/${issueNumber}-${desc}`;
795
+ result = runCreate(branch, {
796
+ work: true,
797
+ ...(prompt.length > 0 ? { prompt } : {}),
742
798
  });
743
- return;
744
799
  }
745
- const branch = `${overlay.type}/${overlay.issue.issue.number}-${desc}`;
746
- const prompt = overlay.prompt.trim();
747
- const result = runCreate(branch, {
748
- work: true,
749
- ...(prompt.length > 0 ? { prompt } : {}),
750
- });
751
800
  if (!result.ok) {
752
801
  setState({
753
802
  ...state,
@@ -766,7 +815,9 @@ export default function Dashboard() {
766
815
  async function confirmRemove(overlay, force) {
767
816
  if (state.phase !== "ready")
768
817
  return;
769
- const result = runRemove(overlay.branch, force);
818
+ const result = overlay.branch
819
+ ? runRemove(overlay.branch, force)
820
+ : runRemoveByPath(overlay.worktreePath, force);
770
821
  if (!result.ok) {
771
822
  setState({
772
823
  ...state,
@@ -7,7 +7,6 @@ import { z } from "zod";
7
7
  import { randomUUID } from "crypto";
8
8
  import { readFileSync, unlinkSync } from "fs";
9
9
  import * as path from "path";
10
- import { parseBranch, isParseError } from "../../lib/branch.js";
11
10
  import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getCurrentBranch, pathExists, } from "../../lib/git.js";
12
11
  import { getSessionId, setSessionId } from "../../lib/metadata.js";
13
12
  import { launchClaude, PERMISSION_MODES } from "../../lib/claude.js";
@@ -61,28 +60,28 @@ function resolve(cwd) {
61
60
  hint: "Run `mintree worktree work` from inside `.mintree/worktrees/<issue>-<desc>`.",
62
61
  };
63
62
  }
64
- const branch = getCurrentBranch(cwdAbs);
65
- if (!branch) {
66
- return {
67
- ok: false,
68
- message: "Could not determine the current branch (detached HEAD?)",
69
- };
70
- }
71
- const parsed = parseBranch(branch);
72
- if (isParseError(parsed)) {
73
- return {
74
- ok: false,
75
- message: `Branch '${branch}' does not match the mintree convention.`,
76
- hint: parsed.hint,
77
- };
78
- }
79
63
  // The worktree path that git knows about is the *root* of this checkout.
80
64
  // Walk up from cwd until we land directly under .mintree/worktrees/<name>.
81
65
  const segmentBeneathWorktreesDir = cwdAbs.slice(worktreesDir.length + 1).split(path.sep)[0];
82
66
  const worktreePath = segmentBeneathWorktreesDir
83
67
  ? path.join(worktreesDir, segmentBeneathWorktreesDir)
84
68
  : cwdAbs;
85
- const existing = getSessionId(repoRoot, parsed.issueId);
69
+ const worktreeDirName = path.basename(worktreePath);
70
+ // IssueId comes from the worktree dir name, not the branch — that way
71
+ // detached-HEAD worktrees (the "current branch" path from the dashboard)
72
+ // still resolve their session_id. Convention guarantees the dir is named
73
+ // `<issueId>-<desc>` for both attached and detached creates.
74
+ const issueIdMatch = worktreeDirName.match(/^(\d+)-/);
75
+ if (!issueIdMatch || !issueIdMatch[1]) {
76
+ return {
77
+ ok: false,
78
+ message: `Worktree directory '${worktreeDirName}' doesn't start with an issue number.`,
79
+ hint: "Expected `<issue>-<desc>` (e.g. 100-claude-md-inicial).",
80
+ };
81
+ }
82
+ const issueId = issueIdMatch[1];
83
+ const branch = getCurrentBranch(cwdAbs); // null = detached HEAD, that's fine
84
+ const existing = getSessionId(repoRoot, issueId);
86
85
  let sessionId;
87
86
  let resume;
88
87
  if (existing) {
@@ -91,7 +90,7 @@ function resolve(cwd) {
91
90
  }
92
91
  else {
93
92
  sessionId = randomUUID();
94
- setSessionId(repoRoot, parsed.issueId, sessionId);
93
+ setSessionId(repoRoot, issueId, sessionId);
95
94
  resume = false;
96
95
  }
97
96
  return {
@@ -99,8 +98,9 @@ function resolve(cwd) {
99
98
  data: {
100
99
  repoRoot,
101
100
  worktreePath,
101
+ worktreeDirName,
102
102
  branch,
103
- issueId: parsed.issueId,
103
+ issueId,
104
104
  sessionId,
105
105
  resume,
106
106
  },
@@ -156,6 +156,7 @@ export default function Work({ options }) {
156
156
  resume: resolved.resume,
157
157
  prompt: effectivePrompt,
158
158
  cwd: resolved.worktreePath,
159
+ remoteControlName: resolved.worktreeDirName,
159
160
  });
160
161
  child.on("error", (err) => {
161
162
  setState({
@@ -183,7 +184,7 @@ export default function Work({ options }) {
183
184
  const { resolved } = state;
184
185
  const sessionShort = resolved.sessionId.slice(0, 8);
185
186
  const action = resolved.resume ? "resuming" : "starting";
186
- 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] })] }), _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..." }) })] }));
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..." }) })] }));
187
188
  }
188
189
  function truncate(s, max) {
189
190
  if (s.length <= max)
@@ -14,6 +14,7 @@ export type LaunchClaudeOptions = {
14
14
  resume: boolean;
15
15
  prompt?: string;
16
16
  cwd: string;
17
+ remoteControlName?: string;
17
18
  };
18
19
  /**
19
20
  * Spawns the Claude CLI with stdio inherited so the child takes over the TTY.
@@ -60,6 +60,17 @@ export function launchClaude(options) {
60
60
  else {
61
61
  args.push("--session-id", options.sessionId);
62
62
  }
63
+ // Always launch with Remote Control. Mintree's whole premise is being able
64
+ // to resume sessions from other devices, so the flag is non-optional —
65
+ // global `remoteControlAtStartup` becomes irrelevant for mintree-launched
66
+ // sessions. The optional name = worktree dir, so the RC UI can identify
67
+ // the session at a glance instead of showing a UUID.
68
+ if (options.remoteControlName) {
69
+ args.push("--remote-control", options.remoteControlName);
70
+ }
71
+ else {
72
+ args.push("--remote-control");
73
+ }
63
74
  if (options.prompt && options.prompt.length > 0) {
64
75
  args.push("--", promptArg(options.prompt));
65
76
  }
@@ -13,7 +13,7 @@ export type GhIssue = {
13
13
  };
14
14
  export type WorktreeInfo = {
15
15
  path: string;
16
- branch: string;
16
+ branch: string | null;
17
17
  dirty: boolean;
18
18
  ab: AheadBehind | null;
19
19
  sessionId?: string;
@@ -26,21 +26,21 @@ export async function fetchAssignedIssues() {
26
26
  }
27
27
  /**
28
28
  * Builds a map from issue id (number, as string) to the matching mintree
29
- * worktree, parsing each branch with the same `<type>/<issue>-<desc>` regex
30
- * the create command enforces. Worktrees with branches that don't follow
31
- * the convention are simply skipped.
29
+ * worktree. IssueId comes from the worktree dir name (`<issue>-<desc>`)
30
+ * rather than the branch, so detached worktrees (created via the dashboard's
31
+ * "current branch" mode) are included alongside the regular branch-based
32
+ * ones. Worktrees outside `.mintree/worktrees/` are skipped.
32
33
  */
33
34
  function buildWorktreeIndex(repoRoot) {
34
35
  const worktreesRoot = path.resolve(getWorktreesDir(repoRoot));
35
- const branchRegex = /^[a-z]+\/(\d+)-/;
36
+ const dirNameRegex = /^(\d+)-/;
36
37
  const index = new Map();
37
38
  for (const w of listWorktrees(repoRoot)) {
38
- if (!w.branch)
39
- continue;
40
39
  const wAbs = path.resolve(w.path);
41
40
  if (wAbs !== worktreesRoot && !wAbs.startsWith(worktreesRoot + path.sep))
42
41
  continue;
43
- const m = w.branch.match(branchRegex);
42
+ const dirName = path.basename(wAbs);
43
+ const m = dirName.match(dirNameRegex);
44
44
  const issueId = m && m[1] ? m[1] : null;
45
45
  if (!issueId)
46
46
  continue;
@@ -115,9 +115,12 @@ export async function loadDashboard(repoRoot) {
115
115
  const metadata = readMetadata(repoRoot);
116
116
  // Fetch PRs in parallel for branches that actually have a worktree —
117
117
  // issues without one wouldn't have a branch on this user's repo, so we
118
- // skip the per-issue gh call for them.
118
+ // skip the per-issue gh call for them. Detached worktrees (branch=null)
119
+ // have no PR by definition.
119
120
  const prByBranch = new Map();
120
- const prFetches = Array.from(worktreesByIssue.values()).map(async (w) => {
121
+ const prFetches = Array.from(worktreesByIssue.values())
122
+ .filter((w) => w.branch !== null)
123
+ .map(async (w) => {
121
124
  const pr = await fetchPrForBranch(w.branch);
122
125
  if (pr)
123
126
  prByBranch.set(w.branch, pr);
@@ -128,7 +131,7 @@ export async function loadDashboard(repoRoot) {
128
131
  const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
129
132
  const sessionId = metadata.issues[issueId]?.session_id;
130
133
  const worktree = worktreeRaw ? { ...worktreeRaw, sessionId } : null;
131
- const pr = worktree ? (prByBranch.get(worktree.branch) ?? null) : null;
134
+ const pr = worktree && worktree.branch ? (prByBranch.get(worktree.branch) ?? null) : null;
132
135
  return {
133
136
  issue,
134
137
  worktree,
@@ -34,3 +34,23 @@ export type CreateResult = {
34
34
  * for the work hand-off when relevant.
35
35
  */
36
36
  export declare function runCreate(branchArg: string, opts: CreateOpts): CreateResult;
37
+ export type CreateDetachedOpts = {
38
+ issueId: string;
39
+ descKebab: string;
40
+ work: boolean;
41
+ prompt?: string;
42
+ permissionMode?: PermissionMode;
43
+ };
44
+ /**
45
+ * Variant of `runCreate` that doesn't create a new branch — the worktree is
46
+ * checked out in detached HEAD at the tip of the main repo's current branch.
47
+ * Used by the dashboard's "current branch" overlay mode: lets the user spin
48
+ * up a worktree off whatever they're on (typically `main`) without forcing
49
+ * the `<type>/<issue>-<desc>` convention upfront. They can `git switch -c`
50
+ * later if/when the work warrants a branch.
51
+ *
52
+ * Worktree dir naming follows the same `<issueId>-<desc>` shape as the
53
+ * branch-based flow so `worktree work` can still recover the issueId from
54
+ * the dir name (where it can't read it from the branch).
55
+ */
56
+ export declare function runCreateDetached(opts: CreateDetachedOpts): CreateResult;
@@ -3,7 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import { execSync } from "child_process";
5
5
  import { parseBranch, isParseError } from "./branch.js";
6
- import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, branchExists, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
6
+ import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, getCurrentBranch, branchExists, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
7
7
  import { upsertIssue } from "./metadata.js";
8
8
  function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
9
9
  if (!pathExists(scriptPath))
@@ -182,3 +182,119 @@ export function runCreate(branchArg, opts) {
182
182
  permissionMode: opts.permissionMode,
183
183
  };
184
184
  }
185
+ /**
186
+ * Variant of `runCreate` that doesn't create a new branch — the worktree is
187
+ * checked out in detached HEAD at the tip of the main repo's current branch.
188
+ * Used by the dashboard's "current branch" overlay mode: lets the user spin
189
+ * up a worktree off whatever they're on (typically `main`) without forcing
190
+ * the `<type>/<issue>-<desc>` convention upfront. They can `git switch -c`
191
+ * later if/when the work warrants a branch.
192
+ *
193
+ * Worktree dir naming follows the same `<issueId>-<desc>` shape as the
194
+ * branch-based flow so `worktree work` can still recover the issueId from
195
+ * the dir name (where it can't read it from the branch).
196
+ */
197
+ export function runCreateDetached(opts) {
198
+ const root = findMainRepoRoot();
199
+ if (!root) {
200
+ return {
201
+ ok: false,
202
+ message: "Not in a git repository.",
203
+ hint: "Run `git init` and then `mintree init`.",
204
+ };
205
+ }
206
+ if (!pathExists(getMintreeDir(root))) {
207
+ return {
208
+ ok: false,
209
+ message: ".mintree/ not found in this repo.",
210
+ hint: "Run `mintree init` first.",
211
+ };
212
+ }
213
+ if (!/^\d+$/.test(opts.issueId)) {
214
+ return { ok: false, message: `Invalid issueId: ${opts.issueId}` };
215
+ }
216
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(opts.descKebab)) {
217
+ return {
218
+ ok: false,
219
+ message: `Invalid desc: ${opts.descKebab}`,
220
+ hint: "Expected kebab-case starting with [a-z0-9].",
221
+ };
222
+ }
223
+ const currentBranch = getCurrentBranch(root);
224
+ if (!currentBranch) {
225
+ return {
226
+ ok: false,
227
+ message: "Main repo is in detached HEAD — can't determine current branch to fork from.",
228
+ hint: "Switch the main repo to a branch first (`git switch main`) and try again.",
229
+ };
230
+ }
231
+ const worktreeDirName = `${opts.issueId}-${opts.descKebab}`;
232
+ const worktreePath = path.join(getWorktreesDir(root), worktreeDirName);
233
+ if (pathExists(worktreePath)) {
234
+ return {
235
+ ok: false,
236
+ message: `Worktree directory already exists: ${worktreePath}`,
237
+ hint: "Remove it first or pick a different description.",
238
+ };
239
+ }
240
+ const steps = [];
241
+ steps.push({
242
+ kind: "ok",
243
+ label: "detached worktree",
244
+ detail: `issue=${opts.issueId}, base=${currentBranch}`,
245
+ });
246
+ try {
247
+ execSync(`git worktree add --detach '${worktreePath.replace(/'/g, `'\\''`)}' '${currentBranch.replace(/'/g, `'\\''`)}'`, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
248
+ }
249
+ catch (err) {
250
+ const stderr = err && typeof err === "object" && "stderr" in err
251
+ ? String(err.stderr).trim()
252
+ : err instanceof Error
253
+ ? err.message
254
+ : String(err);
255
+ return { ok: false, message: `git worktree add --detach failed: ${stderr}` };
256
+ }
257
+ steps.push({
258
+ kind: "ok",
259
+ label: "checked out detached HEAD",
260
+ detail: `at tip of ${currentBranch}`,
261
+ });
262
+ steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
263
+ upsertIssue(root, opts.issueId, { base_branch: currentBranch });
264
+ steps.push({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
265
+ const initShPath = getInitScriptPath(root);
266
+ const initResult = tryRunInitScript(initShPath, worktreePath, root);
267
+ if (initResult.ran) {
268
+ steps.push({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
269
+ }
270
+ else if (initResult.error) {
271
+ steps.push({ kind: "warn", label: "init.sh failed", detail: initResult.error });
272
+ }
273
+ else if (!pathExists(initShPath)) {
274
+ steps.push({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
275
+ }
276
+ let promptFile;
277
+ if (opts.work && opts.prompt && opts.prompt.length > 0) {
278
+ try {
279
+ promptFile = writePromptFile(opts.prompt);
280
+ }
281
+ catch (err) {
282
+ steps.push({
283
+ kind: "warn",
284
+ label: "failed to stage --prompt for hand-off",
285
+ detail: err instanceof Error ? err.message : String(err),
286
+ });
287
+ }
288
+ }
289
+ return {
290
+ ok: true,
291
+ steps,
292
+ worktreePath,
293
+ branch: `detached @ ${currentBranch}`,
294
+ issueId: opts.issueId,
295
+ base: currentBranch,
296
+ work: opts.work,
297
+ promptFile,
298
+ permissionMode: opts.permissionMode,
299
+ };
300
+ }
@@ -19,3 +19,10 @@ export type RemoveResult = {
19
19
  * resume the same Claude session.
20
20
  */
21
21
  export declare function runRemove(branchArg: string, force: boolean): RemoveResult;
22
+ /**
23
+ * Path-keyed counterpart to `runRemove`, used for worktrees that don't have
24
+ * a parseable branch (detached HEAD ones created via the dashboard's
25
+ * "current branch" mode). Same dirty/force/prune semantics as runRemove —
26
+ * just skips the `parseBranch` step and reports the worktree by its path.
27
+ */
28
+ export declare function runRemoveByPath(worktreePath: string, force: boolean): RemoveResult;
@@ -82,3 +82,71 @@ export function runRemove(branchArg, force) {
82
82
  wasDirty: dirty,
83
83
  };
84
84
  }
85
+ /**
86
+ * Path-keyed counterpart to `runRemove`, used for worktrees that don't have
87
+ * a parseable branch (detached HEAD ones created via the dashboard's
88
+ * "current branch" mode). Same dirty/force/prune semantics as runRemove —
89
+ * just skips the `parseBranch` step and reports the worktree by its path.
90
+ */
91
+ export function runRemoveByPath(worktreePath, force) {
92
+ const root = findMainRepoRoot();
93
+ if (!root) {
94
+ return {
95
+ ok: false,
96
+ message: "Not in a git repository.",
97
+ hint: "Run `git init` and then `mintree init`.",
98
+ };
99
+ }
100
+ if (!pathExists(getMintreeDir(root))) {
101
+ return {
102
+ ok: false,
103
+ message: ".mintree/ not found in this repo.",
104
+ hint: "Run `mintree init` first.",
105
+ };
106
+ }
107
+ const label = worktreePath.split("/").pop() ?? worktreePath;
108
+ if (!pathExists(worktreePath)) {
109
+ try {
110
+ pruneWorktrees(root);
111
+ }
112
+ catch (err) {
113
+ return {
114
+ ok: false,
115
+ message: `git worktree prune failed: ${err instanceof Error ? err.message : String(err)}`,
116
+ };
117
+ }
118
+ return {
119
+ ok: true,
120
+ branch: label,
121
+ worktreePath,
122
+ variant: "pruned-orphan",
123
+ wasDirty: false,
124
+ };
125
+ }
126
+ const dirty = isDirty(worktreePath);
127
+ if (dirty && !force) {
128
+ return {
129
+ ok: false,
130
+ message: `Worktree at ${worktreePath} has uncommitted changes.`,
131
+ hint: "Commit/stash first, or pass --force to discard them.",
132
+ };
133
+ }
134
+ try {
135
+ removeWorktree({ repoRoot: root, worktreePath, force });
136
+ }
137
+ catch (err) {
138
+ const stderr = err && typeof err === "object" && "stderr" in err
139
+ ? String(err.stderr).trim()
140
+ : err instanceof Error
141
+ ? err.message
142
+ : String(err);
143
+ return { ok: false, message: `git worktree remove failed: ${stderr}` };
144
+ }
145
+ return {
146
+ ok: true,
147
+ branch: label,
148
+ worktreePath,
149
+ variant: "removed",
150
+ wasDirty: dirty,
151
+ };
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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>",