mintree 0.1.4 → 0.1.6

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.
@@ -12,7 +12,8 @@ import { ALLOWED_TYPES } from "../lib/branch.js";
12
12
  import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
13
13
  import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
14
14
  import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
15
- import { loadDashboard, } from "../lib/dashboard.js";
15
+ import { transitionIssueToInProgress } from "../lib/githubProject.js";
16
+ import { loadDashboard } from "../lib/dashboard.js";
16
17
  const require = createRequire(import.meta.url);
17
18
  const { version: mintreeVersion } = require("../../package.json");
18
19
  export const description = "Interactive dashboard listing open issues assigned to you with worktree + session state";
@@ -105,6 +106,17 @@ function kebabize(title) {
105
106
  .replace(/-+/g, "-")
106
107
  .replace(/^-+|-+$/g, "");
107
108
  }
109
+ /**
110
+ * Default prompt seeded into the overlay's Prompt field when the user opens
111
+ * `w` for an issue. Single-line on purpose — `ink-text-input` is one-line,
112
+ * so multi-line templates render weirdly when the user tabs in to edit.
113
+ * Points the agent at `gh issue view` for the full body rather than dumping
114
+ * the body inline (issue bodies can be long and contain markdown that
115
+ * doesn't survive argv). User can clear or rewrite freely before Enter.
116
+ */
117
+ function defaultPromptForIssue(number, title) {
118
+ return `Empezá a trabajar el issue #${number} (${title}). Usá \`gh issue view ${number}\` para leer el contexto completo y seguí las convenciones del repo.`;
119
+ }
108
120
  /**
109
121
  * Sanitises whatever the user typed into the desc field on every keystroke.
110
122
  * Same rules as kebabize but without the word cap — this is for live input.
@@ -120,11 +132,7 @@ function sanitizeDesc(value) {
120
132
  }
121
133
  function openInBrowser(url) {
122
134
  try {
123
- const cmd = process.platform === "darwin"
124
- ? "open"
125
- : process.platform === "win32"
126
- ? "start"
127
- : "xdg-open";
135
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
128
136
  execSync(`${cmd} ${shQuote(url)}`, { stdio: "ignore" });
129
137
  return true;
130
138
  }
@@ -160,15 +168,15 @@ function FooterRow({ phase, overlayKind, }) {
160
168
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
161
169
  }
162
170
  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: " 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" })] })] }));
171
+ 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
172
  }
165
173
  if (overlayKind === "remove") {
166
- 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" })] }));
174
+ 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" })] }));
167
175
  }
168
- 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" })] })] }));
176
+ 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
177
  }
170
178
  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 ?? `(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] }) }))] }));
179
+ 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
180
  }
173
181
  function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
174
182
  const labelWidth = 14;
@@ -180,9 +188,7 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
180
188
  const dirPreview = isNewBranch
181
189
  ? `${overlay.issue.issue.number}-${overlay.desc}`
182
190
  : `${overlay.issue.issue.number}-${detachedDesc}`;
183
- 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
184
- ? "new"
185
- : `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: "(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: " 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] }) }))] }));
191
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
186
192
  }
187
193
  function stateChar(state) {
188
194
  if (!state)
@@ -231,7 +237,10 @@ function wrapLine(s, width) {
231
237
  // (consecutive empty lines collapse to one) and trimming leading/trailing
232
238
  // blank lines. Used to feed the description into the flat-line renderer.
233
239
  function wrapBody(body, width) {
234
- const raw = body.replace(/\r\n/g, "\n").split("\n").map(l => l.trimEnd());
240
+ const raw = body
241
+ .replace(/\r\n/g, "\n")
242
+ .split("\n")
243
+ .map((l) => l.trimEnd());
235
244
  while (raw.length > 0 && raw[0] === "")
236
245
  raw.shift();
237
246
  while (raw.length > 0 && raw[raw.length - 1] === "")
@@ -275,14 +284,17 @@ function buildDetailLines(d, width) {
275
284
  const titleWrapped = wrapLine(d.issue.title, Math.max(8, w - titlePrefix.length));
276
285
  titleWrapped.forEach((chunk, i) => {
277
286
  if (i === 0) {
278
- lines.push([{ text: titlePrefix, bold: true }, { text: chunk, bold: true }]);
287
+ lines.push([
288
+ { text: titlePrefix, bold: true },
289
+ { text: chunk, bold: true },
290
+ ]);
279
291
  }
280
292
  else {
281
293
  lines.push([{ text: " ".repeat(titlePrefix.length) + chunk, bold: true }]);
282
294
  }
283
295
  });
284
- const labels = d.issue.labels.map(l => l.name);
285
- const labelText = labels.length > 0 ? labels.map(l => `[${l}]`).join(" ") : "(no labels)";
296
+ const labels = d.issue.labels.map((l) => l.name);
297
+ const labelText = labels.length > 0 ? labels.map((l) => `[${l}]`).join(" ") : "(no labels)";
286
298
  for (const w2 of wrapLine(labelText, w))
287
299
  lines.push([{ text: w2, dim: true }]);
288
300
  lines.push([
@@ -310,9 +322,7 @@ function buildDetailLines(d, width) {
310
322
  for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
311
323
  lines.push([{ text: w2, dim: true }]);
312
324
  const statusLine = [{ text: ` status: `, dim: true }];
313
- statusLine.push(d.worktree.dirty
314
- ? { text: "dirty", color: "yellow" }
315
- : { text: "clean", color: "green" });
325
+ statusLine.push(d.worktree.dirty ? { text: "dirty", color: "yellow" } : { text: "clean", color: "green" });
316
326
  if (d.worktree.ab) {
317
327
  statusLine.push({
318
328
  text: ` +${d.worktree.ab.ahead} / -${d.worktree.ab.behind}`,
@@ -435,7 +445,7 @@ export default function Dashboard() {
435
445
  });
436
446
  return;
437
447
  }
438
- setState(prev => {
448
+ setState((prev) => {
439
449
  const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
440
450
  const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
441
451
  const previousToast = prev.phase === "ready" ? prev.toast : null;
@@ -494,7 +504,7 @@ export default function Dashboard() {
494
504
  const inLeftPane = col <= lw;
495
505
  // Functional setState so a fast wheel doesn't read stale
496
506
  // scroll/selection through the ref between dispatches.
497
- setState(prev => {
507
+ setState((prev) => {
498
508
  if (prev.phase !== "ready")
499
509
  return prev;
500
510
  if (prev.overlay)
@@ -627,10 +637,7 @@ export default function Dashboard() {
627
637
  // as `worktree create --work`, minus the create. The wrapper
628
638
  // will cd + run `mintree worktree work`, which itself sees the
629
639
  // session_id in metadata and uses --resume.
630
- emitMarkers([
631
- `MINTREE_CD:${issue.worktree.path}`,
632
- "MINTREE_WORK:1",
633
- ]);
640
+ emitMarkers([`MINTREE_CD:${issue.worktree.path}`, "MINTREE_WORK:1"]);
634
641
  exit();
635
642
  return;
636
643
  }
@@ -669,10 +676,11 @@ export default function Dashboard() {
669
676
  currentBranch: root ? getCurrentBranch(root) : null,
670
677
  type: "feat",
671
678
  desc: kebabize(issue.issue.title) || `issue-${issue.issue.number}`,
672
- prompt: "",
679
+ prompt: defaultPromptForIssue(issue.issue.number, issue.issue.title),
673
680
  field: "branchMode",
674
681
  error: null,
675
682
  conventionDoc: root ? findBranchConventionDoc(root) : null,
683
+ pending: null,
676
684
  },
677
685
  toast: null,
678
686
  });
@@ -681,6 +689,12 @@ export default function Dashboard() {
681
689
  if (state.phase !== "ready" || !state.overlay)
682
690
  return;
683
691
  const overlay = state.overlay;
692
+ // When a create overlay is finishing its post-create transition the
693
+ // worktree is already on disk and we're about to exit() — freeze the
694
+ // overlay so escape / stray keys don't dismiss it mid-flight.
695
+ if (overlay.kind === "create" && overlay.pending) {
696
+ return;
697
+ }
684
698
  if (key.escape || (input === "c" && key.ctrl)) {
685
699
  setState({ ...state, overlay: null });
686
700
  return;
@@ -689,7 +703,6 @@ export default function Dashboard() {
689
703
  handleRemoveOverlayInput(input, key, overlay);
690
704
  return;
691
705
  }
692
- // Create overlay from here on.
693
706
  // In "current" branch mode we skip type+desc fields entirely — they have
694
707
  // no meaning when the worktree is going to be detached. Tab cycles
695
708
  // branchMode ⇄ prompt only.
@@ -793,6 +806,23 @@ export default function Dashboard() {
793
806
  });
794
807
  return;
795
808
  }
809
+ // Worktree's on disk — keep the overlay visible while we move the issue
810
+ // to In Progress on its project. Errors from the GraphQL call don't
811
+ // block the worktree hand-off; we swallow them and let `mintree doctor`
812
+ // surface persistent issues (missing `project` scope, etc.).
813
+ setState({
814
+ ...state,
815
+ overlay: { ...overlay, error: null, pending: "Updating issue status..." },
816
+ });
817
+ const repoRoot = findMainRepoRoot();
818
+ if (repoRoot) {
819
+ try {
820
+ await transitionIssueToInProgress(repoRoot, issueNumber);
821
+ }
822
+ catch {
823
+ // best effort — surface via doctor / next dashboard refresh
824
+ }
825
+ }
796
826
  emitMarkers(buildCreateMarkers({
797
827
  worktreePath: result.worktreePath,
798
828
  work: result.work,
@@ -859,10 +889,10 @@ export default function Dashboard() {
859
889
  // Left pane is the issue list — it only needs room for "#N ICON title".
860
890
  // We give it ~40% of the width so the detail pane (URLs, descriptions,
861
891
  // branch paths) has the room it actually needs.
862
- const listWidthPct = 0.40;
892
+ const listWidthPct = 0.4;
863
893
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
864
894
  const detailWidth = columns - listWidth - 2; // border slack
865
- const identifierWidth = Math.max(3, ...issues.map(d => `#${d.issue.number}`.length));
895
+ const identifierWidth = Math.max(3, ...issues.map((d) => `#${d.issue.number}`.length));
866
896
  // Lista ocupa todo menos: " #N ICON " (id + 4 cols of pad/icon).
867
897
  const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
868
898
  // Reserve rows: header (2), top borders (1), footer (3).
@@ -881,9 +911,5 @@ export default function Dashboard() {
881
911
  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: issues.length }) }), 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: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [slice.map((d, i) => {
882
912
  const absoluteIdx = startIdx + i;
883
913
  return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
884
- }), startIdx > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", startIdx, " more above"] })), endIdx < issues.length && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", issues.length - endIdx, " 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: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success"
885
- ? "green"
886
- : toast.kind === "error"
887
- ? "red"
888
- : "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 })] })] }));
914
+ }), startIdx > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", startIdx, " more above"] }), endIdx < issues.length && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", issues.length - endIdx, " 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: [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 })] })] }));
889
915
  }
@@ -7,6 +7,7 @@ import * as path from "path";
7
7
  import { createRequire } from "module";
8
8
  import { tryExec, getPath } from "../lib/exec.js";
9
9
  import { ghCliAvailable, getGhUserLogin, getRepoFullName } from "../lib/github.js";
10
+ import { getGhTokenScopes, hasProjectScope } from "../lib/githubProject.js";
10
11
  import { resolveClaudeBinary } from "../lib/claude.js";
11
12
  import { findMainRepoRoot, getMintreeDir, getInitScriptPath, isGitIgnored, isExecutable, pathExists, } from "../lib/git.js";
12
13
  const require = createRequire(import.meta.url);
@@ -110,6 +111,19 @@ async function checkGithubIssues() {
110
111
  : undefined,
111
112
  };
112
113
  }
114
+ async function checkProjectScope() {
115
+ const scopes = await getGhTokenScopes();
116
+ if (scopes === null) {
117
+ // Auth/install issue — surfaced by the gh row already.
118
+ return { scopes: null, hasScope: false };
119
+ }
120
+ const ok = hasProjectScope(scopes);
121
+ return {
122
+ scopes,
123
+ hasScope: ok,
124
+ hint: ok ? undefined : "Run: gh auth refresh -s project",
125
+ };
126
+ }
113
127
  function checkRemoteControl() {
114
128
  const home = process.env["HOME"] || "";
115
129
  const configPath = path.join(home, ".claude.json");
@@ -153,7 +167,7 @@ function checkSessionSignalHooks() {
153
167
  }
154
168
  const found = eventHooks.some((entry) => {
155
169
  const inner = entry.hooks || [];
156
- return inner.some(h => typeof h.command === "string" && h.command.includes("mintree helpers session-signal"));
170
+ return inner.some((h) => typeof h.command === "string" && h.command.includes("mintree helpers session-signal"));
157
171
  });
158
172
  if (!found)
159
173
  missing.push(event);
@@ -239,13 +253,23 @@ function StatusIcon({ ok, required }) {
239
253
  function ToolRow({ tool }) {
240
254
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), tool.path && _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
241
255
  }
256
+ function ProjectScopeRow({ status }) {
257
+ // Optional — auto-discovery still works for the "list issues" path even
258
+ // without the `project` scope; the scope only matters when we need to
259
+ // write status back to a Project v2 board (the `w` flow does this).
260
+ if (status.scopes === null) {
261
+ // gh not installed / not authenticated — handled by the gh row.
262
+ return null;
263
+ }
264
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.hasScope, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Project v2 Scope" }), _jsx(Text, { dimColor: true, children: " - lets `w` move the issue to In Progress" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Token scopes: ", status.scopes.join(", ") || "(none)"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
265
+ }
242
266
  function GithubIssuesRow({ gh }) {
243
267
  // Required only when we're inside a git repo. Outside one, the row is
244
268
  // purely informational (auth check) so doctor can stay green when run
245
269
  // from $HOME or any non-repo directory.
246
270
  const required = gh.inGitRepo;
247
271
  const ok = required ? gh.authenticated && !!gh.repoName : gh.authenticated;
248
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Issues" }), _jsx(Text, { dimColor: true, children: " - issue listing + PR ops" }), !required && _jsx(Text, { dimColor: true, children: " (no repo here)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [gh.authenticated ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["User: ", gh.accountName] }), required && (_jsxs(Text, { dimColor: true, children: ["Repo: ", gh.repoName ?? "(not a GitHub repo)"] }))] })) : (_jsx(Text, { dimColor: true, children: "Not authenticated" })), gh.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", gh.hint] })] })] }));
272
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Issues" }), _jsx(Text, { dimColor: true, children: " - issue listing + PR ops" }), !required && _jsx(Text, { dimColor: true, children: " (no repo here)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [gh.authenticated ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["User: ", gh.accountName] }), required && _jsxs(Text, { dimColor: true, children: ["Repo: ", gh.repoName ?? "(not a GitHub repo)"] })] })) : (_jsx(Text, { dimColor: true, children: "Not authenticated" })), gh.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", gh.hint] })] })] }));
249
273
  }
250
274
  function ShellRow({ status }) {
251
275
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured, required: true }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Shell Integration" }), _jsx(Text, { dimColor: true, children: " - enables `cd` into worktrees" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsxs(Text, { dimColor: true, children: ["Shell: ", status.shell ?? "unknown", " (MINTREE_SHELL_INTEGRATION=1)"] })) : status.shell ? (_jsx(Text, { color: "yellow", children: `↳ Add to ~/.${status.shell}rc: eval "$(mintree helpers shell-init ${status.shell})"` })) : (_jsx(Text, { color: "yellow", children: "\u21B3 Unsupported shell. mintree shell integration supports zsh and bash." })) })] }));
@@ -274,6 +298,7 @@ function MintreeSetupRow({ status }) {
274
298
  export default function Doctor() {
275
299
  const [tools, setTools] = useState(null);
276
300
  const [gh, setGh] = useState(null);
301
+ const [projectScope, setProjectScope] = useState(null);
277
302
  const [rc, setRc] = useState(null);
278
303
  const [hooks, setHooks] = useState(null);
279
304
  const [setup, setSetup] = useState(null);
@@ -303,25 +328,27 @@ export default function Doctor() {
303
328
  };
304
329
  toolResults.unshift(nodeRow);
305
330
  const ghRes = await checkGithubIssues();
331
+ const projectScopeRes = await checkProjectScope();
306
332
  setTools(toolResults);
307
333
  setGh(ghRes);
334
+ setProjectScope(projectScopeRes);
308
335
  setRc(checkRemoteControl());
309
336
  setHooks(checkSessionSignalHooks());
310
337
  setSetup(checkMintreeSetup());
311
338
  setShell(checkShellIntegration());
312
339
  })();
313
340
  }, []);
314
- const loading = !tools || !gh || !rc || !hooks || !setup || !shell;
341
+ const loading = !tools || !gh || !projectScope || !rc || !hooks || !setup || !shell;
315
342
  if (loading) {
316
343
  return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking system requirements..." })] }));
317
344
  }
318
- const requiredMissing = tools.filter(t => t.required && (!t.installed || t.hint));
319
- const optionalMissing = tools.filter(t => !t.required && !t.installed);
345
+ const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
346
+ const optionalMissing = tools.filter((t) => !t.required && !t.installed);
320
347
  // GitHub Issues only counts toward the required tally when we're inside a
321
348
  // git repo; otherwise the auth-only check is purely informational.
322
349
  const ghOk = gh.inGitRepo ? gh.authenticated && !!gh.repoName : true;
323
350
  const shellOk = shell.configured;
324
351
  const allRequired = requiredMissing.length === 0 && ghOk && shellOk;
325
352
  const requiredFailing = requiredMissing.length + (ghOk ? 0 : 1) + (shellOk ? 0 : 1);
326
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map(t => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
353
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((t) => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ProjectScopeRow, { status: projectScope }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
327
354
  }
@@ -121,7 +121,7 @@ export default function Init() {
121
121
  if (!result.ok) {
122
122
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
123
123
  }
124
- const anyChange = result.steps.some(s => s.kind === "created" || s.kind === "added");
124
+ const anyChange = result.steps.some((s) => s.kind === "created" || s.kind === "added");
125
125
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree init" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.repoRoot] })] }), result.steps.map((step, i) => {
126
126
  const detail = stepDetail(step.kind);
127
127
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), detail && _jsxs(Text, { dimColor: true, children: [" (", detail, ")"] })] }), step.hint && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", step.hint] }) }))] }, i));
@@ -67,7 +67,7 @@ async function loadCandidates(force) {
67
67
  if (ours.length === 0) {
68
68
  return { phase: "nothing", message: "No mintree worktrees in this repo. Nothing to clean." };
69
69
  }
70
- const prs = await Promise.all(ours.map(w => fetchPr(w.branch)));
70
+ const prs = await Promise.all(ours.map((w) => fetchPr(w.branch)));
71
71
  const candidates = [];
72
72
  for (let i = 0; i < ours.length; i++) {
73
73
  const w = ours[i];
@@ -96,7 +96,7 @@ async function loadCandidates(force) {
96
96
  return { phase: "prompt", repoRoot: root, candidates };
97
97
  }
98
98
  function executeRemovals(repoRoot, candidates) {
99
- const toRemove = candidates.filter(c => c.willClean);
99
+ const toRemove = candidates.filter((c) => c.willClean);
100
100
  const results = [];
101
101
  for (const c of toRemove) {
102
102
  try {
@@ -120,7 +120,10 @@ function PrTag({ pr }) {
120
120
  }
121
121
  export default function Clean({ options }) {
122
122
  const { exit } = useApp();
123
- const [state, setState] = useState({ phase: "loading", message: "Inspecting worktrees..." });
123
+ const [state, setState] = useState({
124
+ phase: "loading",
125
+ message: "Inspecting worktrees...",
126
+ });
124
127
  useEffect(() => {
125
128
  (async () => {
126
129
  try {
@@ -193,14 +196,14 @@ export default function Clean({ options }) {
193
196
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: state.message }) }));
194
197
  }
195
198
  if (state.phase === "prompt" || state.phase === "executing") {
196
- const willCleanCount = state.candidates.filter(c => c.willClean).length;
197
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.candidates.length, " candidate(s)"] })] }), state.candidates.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: c.willClean ? "green" : "yellow", children: c.willClean ? "✓" : "○" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: c.branch }), _jsx(Text, { children: " " }), _jsx(PrTag, { pr: c.pr }), c.dirty && _jsx(Text, { color: "yellow", children: " [dirty]" }), c.reasonSkipped && _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.reasonSkipped] })] }, i))), _jsx(Box, { marginTop: 1, children: state.phase === "prompt" ? (_jsxs(Text, { children: ["Remove ", willCleanCount, " worktree(s)?", " ", _jsx(Text, { bold: true, children: "[y/N]" })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Removing..." })] })) })] }));
199
+ const willCleanCount = state.candidates.filter((c) => c.willClean).length;
200
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.candidates.length, " candidate(s)"] })] }), state.candidates.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: c.willClean ? "green" : "yellow", children: c.willClean ? "✓" : "○" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: c.branch }), _jsx(Text, { children: " " }), _jsx(PrTag, { pr: c.pr }), c.dirty && _jsx(Text, { color: "yellow", children: " [dirty]" }), c.reasonSkipped && _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.reasonSkipped] })] }, i))), _jsx(Box, { marginTop: 1, children: state.phase === "prompt" ? (_jsxs(Text, { children: ["Remove ", willCleanCount, " worktree(s)? ", _jsx(Text, { bold: true, children: "[y/N]" })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Removing..." })] })) })] }));
198
201
  }
199
202
  // state.phase === "done"
200
203
  if (state.cancelled) {
201
204
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: "Cancelled. No worktrees were removed." }) }));
202
205
  }
203
- const okCount = state.results.filter(r => r.ok).length;
206
+ const okCount = state.results.filter((r) => r.ok).length;
204
207
  const failCount = state.results.length - okCount;
205
208
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean \u00B7 done" }) }), state.results.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { color: r.ok ? "green" : "red", children: r.ok ? "✓" : "✗" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: r.branch }), !r.ok && _jsxs(Text, { color: "red", children: [" \u2014 ", r.error] })] }, i))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Removed ", okCount, failCount > 0 && (_jsxs(_Fragment, { children: [", ", _jsxs(Text, { color: "red", children: [failCount, " failed"] })] })), ". Branches and metadata preserved."] }) })] }));
206
209
  }
@@ -7,11 +7,11 @@ import { z } from "zod";
7
7
  import { PERMISSION_MODES } from "../../lib/claude.js";
8
8
  import { runCreate } from "../../lib/worktreeCreate.js";
9
9
  import { buildCreateMarkers, emitMarkers } from "../../lib/markers.js";
10
+ import { findMainRepoRoot } from "../../lib/git.js";
11
+ import { transitionIssueToInProgress, describeTransition, } from "../../lib/githubProject.js";
10
12
  export const description = "Create a worktree for an issue branch";
11
13
  export const args = z.tuple([
12
- z
13
- .string()
14
- .describe(argument({
14
+ z.string().describe(argument({
15
15
  name: "branch",
16
16
  description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial)",
17
17
  })),
@@ -53,6 +53,7 @@ function StepIcon({ kind }) {
53
53
  export default function Create({ args, options }) {
54
54
  const [branch] = args;
55
55
  const [result, setResult] = useState(null);
56
+ const [transition, setTransition] = useState("idle");
56
57
  useEffect(() => {
57
58
  setTimeout(() => {
58
59
  try {
@@ -68,26 +69,70 @@ export default function Create({ args, options }) {
68
69
  }
69
70
  }, 0);
70
71
  }, [branch, options.base, options.work, options.prompt, options.permissionMode]);
71
- // Emit shell-wrapper markers when create succeeded. Goes through the
72
- // emitMarkers helper so it lands in MINTREE_MARKER_FILE if set, otherwise
73
- // stdout. Bypasses Ink so word-wrap can't split a long path mid-marker.
72
+ // Kick the Project v2 transition once the worktree is in place. Only when
73
+ // --work was on non-work creates leave status untouched. Errors from the
74
+ // GraphQL call surface as a step but never block the worktree hand-off.
74
75
  useEffect(() => {
75
76
  if (!result || !result.ok)
76
77
  return;
78
+ if (!result.work) {
79
+ setTransition("skipped");
80
+ return;
81
+ }
82
+ setTransition("running");
83
+ let cancelled = false;
84
+ (async () => {
85
+ const root = findMainRepoRoot();
86
+ if (!root) {
87
+ if (!cancelled)
88
+ setTransition("skipped");
89
+ return;
90
+ }
91
+ try {
92
+ const r = await transitionIssueToInProgress(root, result.issueId);
93
+ if (!cancelled)
94
+ setTransition(r);
95
+ }
96
+ catch (err) {
97
+ if (cancelled)
98
+ return;
99
+ setTransition({
100
+ kind: "error",
101
+ message: err instanceof Error ? err.message : String(err),
102
+ });
103
+ }
104
+ })();
105
+ return () => {
106
+ cancelled = true;
107
+ };
108
+ }, [result]);
109
+ // Emit shell-wrapper markers when create succeeded AND the transition has
110
+ // settled (run or skipped). Goes through the emitMarkers helper so it
111
+ // lands in MINTREE_MARKER_FILE if set, otherwise stdout. Bypasses Ink so
112
+ // word-wrap can't split a long path mid-marker.
113
+ useEffect(() => {
114
+ if (!result || !result.ok)
115
+ return;
116
+ if (transition === "idle" || transition === "running")
117
+ return;
77
118
  emitMarkers(buildCreateMarkers({
78
119
  worktreePath: result.worktreePath,
79
120
  work: result.work,
80
121
  promptFile: result.promptFile,
81
122
  permissionMode: result.permissionMode,
82
123
  }));
83
- }, [result]);
124
+ }, [result, transition]);
84
125
  if (!result) {
85
126
  return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" Creating worktree for ", branch, "..."] })] }));
86
127
  }
87
128
  if (!result.ok) {
88
129
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
89
130
  }
90
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree create" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "green", children: ["Worktree ready at ", _jsx(Text, { bold: true, children: result.worktreePath })] }), _jsx(Text, { dimColor: true, children: result.work
131
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree create" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))), transition === "running" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Updating issue status..." })] })), typeof transition === "object" &&
132
+ (() => {
133
+ const step = describeTransition(transition);
134
+ return (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }));
135
+ })(), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "green", children: ["Worktree ready at ", _jsx(Text, { bold: true, children: result.worktreePath })] }), _jsx(Text, { dimColor: true, children: result.work
91
136
  ? "Launching Claude in the new worktree..."
92
137
  : "Next: `mt worktree work` to start a Claude session, or `cd` and run `claude` directly." })] })] }));
93
138
  }
@@ -58,7 +58,7 @@ async function load(checkPr) {
58
58
  }
59
59
  const worktreesDir = getWorktreesDir(root);
60
60
  const all = listWorktrees(root);
61
- const ours = all.filter(w => {
61
+ const ours = all.filter((w) => {
62
62
  // Filter to worktrees that live under .mintree/worktrees/. macOS reports
63
63
  // /private/tmp paths so use a relative-prefix check after resolving both
64
64
  // to absolute.
@@ -70,7 +70,7 @@ async function load(checkPr) {
70
70
  return { phase: "empty", repoRoot: root };
71
71
  }
72
72
  const metadata = readMetadata(root);
73
- const rows = ours.map(w => {
73
+ const rows = ours.map((w) => {
74
74
  const issueId = extractIssueId(w.branch);
75
75
  const baseFromMeta = issueId ? metadata.issues[issueId]?.base_branch : undefined;
76
76
  return {
@@ -82,7 +82,7 @@ async function load(checkPr) {
82
82
  };
83
83
  });
84
84
  if (checkPr) {
85
- const prResults = await Promise.all(rows.map(r => (r.branch === "(detached)" ? Promise.resolve(undefined) : fetchPrStatus(r.branch))));
85
+ const prResults = await Promise.all(rows.map((r) => r.branch === "(detached)" ? Promise.resolve(undefined) : fetchPrStatus(r.branch)));
86
86
  rows.forEach((r, i) => {
87
87
  r.pr = prResults[i];
88
88
  });
@@ -137,7 +137,7 @@ export default function List({ options }) {
137
137
  if (state.phase === "empty") {
138
138
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { dimColor: true, children: ["No mintree worktrees in ", state.repoRoot, "."] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Create one with ", _jsx(Text, { bold: true, children: "mintree worktree create <branch>" }), "."] }) })] }));
139
139
  }
140
- const issueWidth = Math.max(5, ...state.rows.map(r => (r.issueId ?? "—").length));
141
- const branchWidth = Math.max(6, ...state.rows.map(r => r.branch.length));
142
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: pad("ISSUE", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: pad("BRANCH", branchWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "STATUS" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "\u0394" }), _jsx(Text, { children: " " }), state.checkedPr && _jsx(Text, { bold: true, children: "PR" })] }), state.rows.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { children: pad(r.issueId ?? "—", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: pad(r.branch, branchWidth) }), _jsx(Text, { children: " " }), _jsx(Box, { width: 9, children: _jsx(StatusCell, { dirty: r.dirty }) }), _jsx(Box, { width: 12, children: _jsx(AheadBehindCell, { ab: r.ab }) }), _jsx(PrCell, { pr: r.pr, checked: state.checkedPr })] }, i)))] }));
140
+ const issueWidth = Math.max(5, ...state.rows.map((r) => (r.issueId ?? "—").length));
141
+ const branchWidth = Math.max(6, ...state.rows.map((r) => r.branch.length));
142
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: pad("ISSUE", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: pad("BRANCH", branchWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "STATUS" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "\u0394" }), _jsx(Text, { children: " " }), state.checkedPr && _jsx(Text, { bold: true, children: "PR" })] }), state.rows.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { children: pad(r.issueId ?? "—", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: pad(r.branch, branchWidth) }), _jsx(Text, { children: " " }), _jsx(Box, { width: 9, children: _jsx(StatusCell, { dirty: r.dirty }) }), _jsx(Box, { width: 12, children: _jsx(AheadBehindCell, { ab: r.ab }) }), _jsx(PrCell, { pr: r.pr, checked: state.checkedPr })] }, i)))] }));
143
143
  }
@@ -42,5 +42,5 @@ export default function Remove({ args, options }) {
42
42
  if (!result.ok) {
43
43
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
44
44
  }
45
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed", " ", _jsxs(Text, { dimColor: true, children: ["(", result.worktreePath, ")"] })] }), result.wasDirty && (_jsx(Text, { color: "yellow", children: "\u21B3 forced past uncommitted changes" }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Branch ", _jsx(Text, { color: "cyan", children: result.branch }), " was preserved (use `git branch -D", " ", result.branch, "` to delete it)."] }), _jsx(Text, { dimColor: true, children: "Issue metadata (incl. session_id) was preserved for re-attach." })] })] }));
45
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed ", _jsxs(Text, { dimColor: true, children: ["(", result.worktreePath, ")"] })] }), result.wasDirty && _jsx(Text, { color: "yellow", children: "\u21B3 forced past uncommitted changes" })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Branch ", _jsx(Text, { color: "cyan", children: result.branch }), " was preserved (use `git branch -D", " ", result.branch, "` to delete it)."] }), _jsx(Text, { dimColor: true, children: "Issue metadata (incl. session_id) was preserved for re-attach." })] })] }));
46
46
  }
@@ -50,9 +50,7 @@ function resolve(cwd) {
50
50
  }
51
51
  const worktreesDir = path.resolve(getWorktreesDir(repoRoot));
52
52
  const cwdAbs = path.resolve(cwd);
53
- const insideMintreeWorktree = cwdAbs === worktreesDir
54
- ? false
55
- : cwdAbs.startsWith(worktreesDir + path.sep);
53
+ const insideMintreeWorktree = cwdAbs === worktreesDir ? false : cwdAbs.startsWith(worktreesDir + path.sep);
56
54
  if (!insideMintreeWorktree) {
57
55
  return {
58
56
  ok: false,
@@ -126,7 +126,7 @@ export async function loadDashboard(repoRoot) {
126
126
  prByBranch.set(w.branch, pr);
127
127
  });
128
128
  await Promise.all(prFetches);
129
- return issues.map(issue => {
129
+ return issues.map((issue) => {
130
130
  const issueId = String(issue.number);
131
131
  const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
132
132
  const sessionId = metadata.issues[issueId]?.session_id;
package/dist/lib/git.js CHANGED
@@ -16,9 +16,7 @@ export function findMainRepoRoot(cwd = process.cwd()) {
16
16
  })
17
17
  .toString()
18
18
  .trim();
19
- const absoluteCommonDir = path.isAbsolute(commonDir)
20
- ? commonDir
21
- : path.resolve(cwd, commonDir);
19
+ const absoluteCommonDir = path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir);
22
20
  // `--git-common-dir` points at `<root>/.git` in a normal repo. Its parent
23
21
  // is the working tree root we want.
24
22
  if (path.basename(absoluteCommonDir) === ".git") {
@@ -119,7 +117,7 @@ export function isExecutable(p) {
119
117
  */
120
118
  export function ensureGitignoreEntries(repoRoot, entries) {
121
119
  const gitignorePath = path.join(repoRoot, ".gitignore");
122
- const toAdd = entries.filter(entry => !isGitIgnored(entry, repoRoot));
120
+ const toAdd = entries.filter((entry) => !isGitIgnored(entry, repoRoot));
123
121
  if (toAdd.length === 0)
124
122
  return [];
125
123
  const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : "";
@@ -0,0 +1,55 @@
1
+ export type TransitionResult = {
2
+ kind: "transitioned";
3
+ projectTitle: string;
4
+ from: string | null;
5
+ to: string;
6
+ } | {
7
+ kind: "noop-already";
8
+ projectTitle: string;
9
+ } | {
10
+ kind: "noop-protected";
11
+ projectTitle: string;
12
+ current: string;
13
+ } | {
14
+ kind: "skip-no-repo";
15
+ } | {
16
+ kind: "skip-no-issue";
17
+ } | {
18
+ kind: "skip-no-project";
19
+ } | {
20
+ kind: "skip-ambiguous";
21
+ projects: string[];
22
+ } | {
23
+ kind: "skip-no-status-field";
24
+ projects: string[];
25
+ } | {
26
+ kind: "skip-no-in-progress-option";
27
+ projects: string[];
28
+ } | {
29
+ kind: "error";
30
+ message: string;
31
+ hint?: string;
32
+ };
33
+ /**
34
+ * Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
35
+ * project from the issue's projectItems; if there are several candidates and
36
+ * `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
37
+ * rather than guess.
38
+ *
39
+ * Skips silently when the status is already In Progress or one of the
40
+ * protected statuses (In Review, Done by default) — to avoid clobbering
41
+ * a PR-driven transition done by something else.
42
+ */
43
+ export declare function transitionIssueToInProgress(repoRoot: string, issueNumber: number | string): Promise<TransitionResult>;
44
+ /**
45
+ * Returns the gh CLI token scopes for github.com, or null when `gh` can't be
46
+ * called / the user isn't authenticated. `gh auth status` writes the scopes
47
+ * line to stderr; we capture both streams and grep for it.
48
+ */
49
+ export declare function getGhTokenScopes(): Promise<string[] | null>;
50
+ export declare function hasProjectScope(scopes: string[]): boolean;
51
+ export declare function describeTransition(result: TransitionResult): {
52
+ kind: "ok" | "skip" | "warn";
53
+ label: string;
54
+ detail?: string;
55
+ };
@@ -0,0 +1,277 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { getRepoFullName } from "./github.js";
4
+ import { readMetadata } from "./metadata.js";
5
+ const execFileAsync = promisify(execFile);
6
+ const DEFAULT_STATUS_FIELD = "Status";
7
+ const DEFAULT_IN_PROGRESS_OPTION = "In Progress";
8
+ const DEFAULT_PROTECTED_STATUSES = ["In Review", "Done"];
9
+ async function runGhGraphql(query, fields) {
10
+ const args = ["api", "graphql", "-f", `query=${query}`];
11
+ for (const [key, value] of fields) {
12
+ if (typeof value === "number") {
13
+ args.push("-F", `${key}=${value}`);
14
+ }
15
+ else {
16
+ args.push("-f", `${key}=${value}`);
17
+ }
18
+ }
19
+ const { stdout } = await execFileAsync("gh", args);
20
+ return JSON.parse(stdout);
21
+ }
22
+ function readProjectConfig(repoRoot) {
23
+ return readMetadata(repoRoot).project ?? {};
24
+ }
25
+ function parseProjectNumberFromUrl(url) {
26
+ const m = url.match(/\/projects\/(\d+)/);
27
+ return m && m[1] ? Number(m[1]) : null;
28
+ }
29
+ function interpretGhError(err) {
30
+ const stderr = err && typeof err === "object" && "stderr" in err
31
+ ? String(err.stderr)
32
+ : err instanceof Error
33
+ ? err.message
34
+ : String(err);
35
+ if (/INSUFFICIENT_SCOPES/i.test(stderr) || (/scope/i.test(stderr) && /project/i.test(stderr))) {
36
+ return {
37
+ kind: "error",
38
+ message: "gh token is missing the `project` scope.",
39
+ hint: "Run: gh auth refresh -s project",
40
+ };
41
+ }
42
+ if (/Could not resolve to a Repository/i.test(stderr)) {
43
+ return { kind: "skip-no-repo" };
44
+ }
45
+ if (/Could not resolve to an Issue/i.test(stderr)) {
46
+ return { kind: "skip-no-issue" };
47
+ }
48
+ const firstLine = stderr.split("\n").find((line) => line.trim().length > 0) ?? "";
49
+ return {
50
+ kind: "error",
51
+ message: firstLine.slice(0, 200) || "gh api graphql failed",
52
+ };
53
+ }
54
+ /**
55
+ * Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
56
+ * project from the issue's projectItems; if there are several candidates and
57
+ * `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
58
+ * rather than guess.
59
+ *
60
+ * Skips silently when the status is already In Progress or one of the
61
+ * protected statuses (In Review, Done by default) — to avoid clobbering
62
+ * a PR-driven transition done by something else.
63
+ */
64
+ export async function transitionIssueToInProgress(repoRoot, issueNumber) {
65
+ const repo = await getRepoFullName();
66
+ if (!repo)
67
+ return { kind: "skip-no-repo" };
68
+ const [owner, name] = repo.split("/");
69
+ if (!owner || !name)
70
+ return { kind: "skip-no-repo" };
71
+ const cfg = readProjectConfig(repoRoot);
72
+ const statusFieldName = cfg.statusField ?? DEFAULT_STATUS_FIELD;
73
+ const inProgressOptionName = cfg.inProgressOption ?? DEFAULT_IN_PROGRESS_OPTION;
74
+ const protectedStatuses = cfg.protectedStatuses ?? DEFAULT_PROTECTED_STATUSES;
75
+ // The Status field name is interpolated into the query (not a variable)
76
+ // because GraphQL field-argument names are not parameterizable through
77
+ // the variables object. Escape any embedded quotes to keep the query valid.
78
+ const escapedFieldName = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
79
+ const query = `query($owner: String!, $repo: String!, $number: Int!) {
80
+ repository(owner: $owner, name: $repo) {
81
+ issue(number: $number) {
82
+ id
83
+ projectItems(first: 20, includeArchived: false) {
84
+ nodes {
85
+ id
86
+ project {
87
+ id
88
+ title
89
+ number
90
+ url
91
+ field(name: "${escapedFieldName}") {
92
+ ... on ProjectV2SingleSelectField {
93
+ id
94
+ name
95
+ options { id name }
96
+ }
97
+ }
98
+ }
99
+ fieldValues(first: 30) {
100
+ nodes {
101
+ ... on ProjectV2ItemFieldSingleSelectValue {
102
+ name
103
+ field {
104
+ ... on ProjectV2SingleSelectField { name }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }`;
114
+ let raw;
115
+ try {
116
+ raw = (await runGhGraphql(query, [
117
+ ["owner", owner],
118
+ ["repo", name],
119
+ ["number", Number(issueNumber)],
120
+ ]));
121
+ }
122
+ catch (err) {
123
+ return interpretGhError(err);
124
+ }
125
+ const issue = raw?.data?.repository?.issue;
126
+ if (!issue)
127
+ return { kind: "skip-no-issue" };
128
+ let nodes = issue.projectItems.nodes;
129
+ if (nodes.length === 0)
130
+ return { kind: "skip-no-project" };
131
+ // Honour an explicit project URL in the config before doing anything else.
132
+ if (cfg.url) {
133
+ const targetNumber = parseProjectNumberFromUrl(cfg.url);
134
+ nodes = nodes.filter((n) => n.project.url === cfg.url || (targetNumber !== null && n.project.number === targetNumber));
135
+ if (nodes.length === 0)
136
+ return { kind: "skip-no-project" };
137
+ }
138
+ const withField = nodes.filter((n) => n.project.field !== null);
139
+ if (withField.length === 0) {
140
+ return { kind: "skip-no-status-field", projects: nodes.map((n) => n.project.title) };
141
+ }
142
+ const withOption = withField.filter((n) => n.project.field.options.some((o) => o.name === inProgressOptionName));
143
+ if (withOption.length === 0) {
144
+ return {
145
+ kind: "skip-no-in-progress-option",
146
+ projects: withField.map((n) => n.project.title),
147
+ };
148
+ }
149
+ if (withOption.length > 1) {
150
+ return { kind: "skip-ambiguous", projects: withOption.map((n) => n.project.title) };
151
+ }
152
+ const item = withOption[0];
153
+ const project = item.project;
154
+ const field = project.field;
155
+ const option = field.options.find((o) => o.name === inProgressOptionName);
156
+ if (!option) {
157
+ // Defensive — already filtered above, but TypeScript can't see it.
158
+ return { kind: "skip-no-in-progress-option", projects: [project.title] };
159
+ }
160
+ const currentStatus = item.fieldValues.nodes.find((v) => v.field?.name === statusFieldName)?.name ?? null;
161
+ if (currentStatus === inProgressOptionName) {
162
+ return { kind: "noop-already", projectTitle: project.title };
163
+ }
164
+ if (currentStatus !== null && protectedStatuses.includes(currentStatus)) {
165
+ return { kind: "noop-protected", projectTitle: project.title, current: currentStatus };
166
+ }
167
+ const mutation = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
168
+ updateProjectV2ItemFieldValue(input: {
169
+ projectId: $projectId
170
+ itemId: $itemId
171
+ fieldId: $fieldId
172
+ value: { singleSelectOptionId: $optionId }
173
+ }) {
174
+ projectV2Item { id }
175
+ }
176
+ }`;
177
+ try {
178
+ await runGhGraphql(mutation, [
179
+ ["projectId", project.id],
180
+ ["itemId", item.id],
181
+ ["fieldId", field.id],
182
+ ["optionId", option.id],
183
+ ]);
184
+ }
185
+ catch (err) {
186
+ return interpretGhError(err);
187
+ }
188
+ return {
189
+ kind: "transitioned",
190
+ projectTitle: project.title,
191
+ from: currentStatus,
192
+ to: inProgressOptionName,
193
+ };
194
+ }
195
+ /**
196
+ * Returns the gh CLI token scopes for github.com, or null when `gh` can't be
197
+ * called / the user isn't authenticated. `gh auth status` writes the scopes
198
+ * line to stderr; we capture both streams and grep for it.
199
+ */
200
+ export async function getGhTokenScopes() {
201
+ try {
202
+ const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"]);
203
+ const combined = `${stdout}\n${stderr}`;
204
+ return parseScopesFromAuthStatus(combined);
205
+ }
206
+ catch (err) {
207
+ const out = err && typeof err === "object" && "stdout" in err && "stderr" in err
208
+ ? `${String(err.stdout)}\n${String(err.stderr)}`
209
+ : "";
210
+ const parsed = parseScopesFromAuthStatus(out);
211
+ return parsed ?? null;
212
+ }
213
+ }
214
+ function parseScopesFromAuthStatus(text) {
215
+ const m = text.match(/Token scopes:\s*([^\n]+)/i);
216
+ if (!m || !m[1])
217
+ return null;
218
+ return m[1]
219
+ .split(",")
220
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
221
+ .filter(Boolean);
222
+ }
223
+ export function hasProjectScope(scopes) {
224
+ return scopes.some((s) => s === "project" || s === "write:project");
225
+ }
226
+ export function describeTransition(result) {
227
+ switch (result.kind) {
228
+ case "transitioned":
229
+ return {
230
+ kind: "ok",
231
+ label: `issue → ${result.to}`,
232
+ detail: result.from ? `${result.projectTitle} (was: ${result.from})` : result.projectTitle,
233
+ };
234
+ case "noop-already":
235
+ return {
236
+ kind: "skip",
237
+ label: "issue already In Progress",
238
+ detail: result.projectTitle,
239
+ };
240
+ case "noop-protected":
241
+ return {
242
+ kind: "skip",
243
+ label: `issue kept at ${result.current}`,
244
+ detail: `${result.projectTitle} (status is protected)`,
245
+ };
246
+ case "skip-no-repo":
247
+ return { kind: "skip", label: "no GitHub repo — skipping project update" };
248
+ case "skip-no-issue":
249
+ return { kind: "skip", label: "issue not found on GitHub — skipping project update" };
250
+ case "skip-no-project":
251
+ return { kind: "skip", label: "issue not on any project — skipping project update" };
252
+ case "skip-ambiguous":
253
+ return {
254
+ kind: "warn",
255
+ label: "multiple matching projects — skipping",
256
+ detail: `set .mintree/metadata.json project.url to one of: ${result.projects.join(", ")}`,
257
+ };
258
+ case "skip-no-status-field":
259
+ return {
260
+ kind: "skip",
261
+ label: "no Status field on project — skipping",
262
+ detail: result.projects.join(", "),
263
+ };
264
+ case "skip-no-in-progress-option":
265
+ return {
266
+ kind: "skip",
267
+ label: "no In Progress option on Status field — skipping",
268
+ detail: result.projects.join(", "),
269
+ };
270
+ case "error":
271
+ return {
272
+ kind: "warn",
273
+ label: "project update failed",
274
+ detail: result.hint ? `${result.message} — ${result.hint}` : result.message,
275
+ };
276
+ }
277
+ }
@@ -2,9 +2,16 @@ export type IssueMeta = {
2
2
  base_branch?: string;
3
3
  session_id?: string;
4
4
  };
5
+ export type ProjectMeta = {
6
+ url?: string;
7
+ statusField?: string;
8
+ inProgressOption?: string;
9
+ protectedStatuses?: string[];
10
+ };
5
11
  export type Metadata = {
6
12
  version: 1;
7
13
  issues: Record<string, IssueMeta>;
14
+ project?: ProjectMeta;
8
15
  };
9
16
  export declare function readMetadata(repoRoot: string): Metadata;
10
17
  export declare function writeMetadata(repoRoot: string, data: Metadata): void;
@@ -1,6 +1,26 @@
1
1
  import * as fs from "fs";
2
2
  import { getMetadataPath } from "./git.js";
3
3
  const EMPTY = { version: 1, issues: {} };
4
+ function sanitizeProject(raw) {
5
+ if (typeof raw !== "object" || raw === null)
6
+ return undefined;
7
+ const r = raw;
8
+ const out = {};
9
+ if (typeof r["url"] === "string" && r["url"].length > 0)
10
+ out.url = r["url"];
11
+ if (typeof r["statusField"] === "string" && r["statusField"].length > 0) {
12
+ out.statusField = r["statusField"];
13
+ }
14
+ if (typeof r["inProgressOption"] === "string" && r["inProgressOption"].length > 0) {
15
+ out.inProgressOption = r["inProgressOption"];
16
+ }
17
+ if (Array.isArray(r["protectedStatuses"])) {
18
+ const arr = r["protectedStatuses"].filter((v) => typeof v === "string" && v.length > 0);
19
+ if (arr.length > 0)
20
+ out.protectedStatuses = arr;
21
+ }
22
+ return Object.keys(out).length > 0 ? out : undefined;
23
+ }
4
24
  export function readMetadata(repoRoot) {
5
25
  const filePath = getMetadataPath(repoRoot);
6
26
  if (!fs.existsSync(filePath))
@@ -9,11 +29,13 @@ export function readMetadata(repoRoot) {
9
29
  const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
10
30
  if (typeof parsed !== "object" || parsed === null)
11
31
  return { ...EMPTY, issues: {} };
32
+ const project = sanitizeProject(parsed.project);
12
33
  return {
13
34
  version: 1,
14
35
  issues: typeof parsed.issues === "object" && parsed.issues !== null
15
36
  ? parsed.issues
16
37
  : {},
38
+ ...(project ? { project } : {}),
17
39
  };
18
40
  }
19
41
  catch {
@@ -140,15 +140,15 @@ export function installHooks() {
140
140
  existingHooks[event] = hookEntries;
141
141
  continue;
142
142
  }
143
- const filtered = existing.filter(entry => {
143
+ const filtered = existing.filter((entry) => {
144
144
  if (!entry || typeof entry !== "object")
145
145
  return true;
146
146
  const inner = entry.hooks ?? [];
147
- return !inner.some(h => {
147
+ return !inner.some((h) => {
148
148
  return (h !== null &&
149
149
  typeof h === "object" &&
150
150
  typeof h.command === "string" &&
151
- (h.command).includes("mintree helpers session-signal"));
151
+ h.command.includes("mintree helpers session-signal"));
152
152
  });
153
153
  });
154
154
  existingHooks[event] = [...filtered, ...hookEntries];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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>",