mintree 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +188 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +12 -0
  4. package/dist/commands/dashboard.d.ts +2 -0
  5. package/dist/commands/dashboard.js +849 -0
  6. package/dist/commands/doctor.d.ts +2 -0
  7. package/dist/commands/doctor.js +327 -0
  8. package/dist/commands/helpers/index.d.ts +1 -0
  9. package/dist/commands/helpers/index.js +1 -0
  10. package/dist/commands/helpers/session-signal/end.d.ts +2 -0
  11. package/dist/commands/helpers/session-signal/end.js +9 -0
  12. package/dist/commands/helpers/session-signal/index.d.ts +1 -0
  13. package/dist/commands/helpers/session-signal/index.js +1 -0
  14. package/dist/commands/helpers/session-signal/install.d.ts +2 -0
  15. package/dist/commands/helpers/session-signal/install.js +25 -0
  16. package/dist/commands/helpers/session-signal/notification.d.ts +2 -0
  17. package/dist/commands/helpers/session-signal/notification.js +9 -0
  18. package/dist/commands/helpers/session-signal/prompt.d.ts +2 -0
  19. package/dist/commands/helpers/session-signal/prompt.js +9 -0
  20. package/dist/commands/helpers/session-signal/stop.d.ts +2 -0
  21. package/dist/commands/helpers/session-signal/stop.js +9 -0
  22. package/dist/commands/helpers/shell-init.d.ts +11 -0
  23. package/dist/commands/helpers/shell-init.js +111 -0
  24. package/dist/commands/index.d.ts +2 -0
  25. package/dist/commands/index.js +6 -0
  26. package/dist/commands/init.d.ts +2 -0
  27. package/dist/commands/init.js +129 -0
  28. package/dist/commands/worktree/clean.d.ts +11 -0
  29. package/dist/commands/worktree/clean.js +206 -0
  30. package/dist/commands/worktree/create.d.ts +18 -0
  31. package/dist/commands/worktree/create.js +93 -0
  32. package/dist/commands/worktree/index.d.ts +1 -0
  33. package/dist/commands/worktree/index.js +1 -0
  34. package/dist/commands/worktree/list.d.ts +10 -0
  35. package/dist/commands/worktree/list.js +143 -0
  36. package/dist/commands/worktree/remove.d.ts +12 -0
  37. package/dist/commands/worktree/remove.js +46 -0
  38. package/dist/commands/worktree/work.d.ts +15 -0
  39. package/dist/commands/worktree/work.js +192 -0
  40. package/dist/lib/branch.d.ts +26 -0
  41. package/dist/lib/branch.js +57 -0
  42. package/dist/lib/claude.d.ts +26 -0
  43. package/dist/lib/claude.js +67 -0
  44. package/dist/lib/dashboard.d.ts +50 -0
  45. package/dist/lib/dashboard.js +139 -0
  46. package/dist/lib/exec.d.ts +2 -0
  47. package/dist/lib/exec.js +15 -0
  48. package/dist/lib/git.d.ts +110 -0
  49. package/dist/lib/git.js +320 -0
  50. package/dist/lib/github.d.ts +7 -0
  51. package/dist/lib/github.js +15 -0
  52. package/dist/lib/markers.d.ts +21 -0
  53. package/dist/lib/markers.js +43 -0
  54. package/dist/lib/metadata.d.ts +18 -0
  55. package/dist/lib/metadata.js +44 -0
  56. package/dist/lib/session-signal.d.ts +63 -0
  57. package/dist/lib/session-signal.js +160 -0
  58. package/dist/lib/worktreeCreate.d.ts +36 -0
  59. package/dist/lib/worktreeCreate.js +184 -0
  60. package/dist/lib/worktreeRemove.d.ts +21 -0
  61. package/dist/lib/worktreeRemove.js +84 -0
  62. package/package.json +63 -0
  63. package/shell/init.bash +106 -0
  64. package/shell/init.zsh +125 -0
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { argument, option } from "pastel";
6
+ import { z } from "zod";
7
+ import { runRemove } from "../../lib/worktreeRemove.js";
8
+ export const description = "Remove a worktree (the branch and metadata are preserved so you can re-attach later)";
9
+ export const args = z.tuple([
10
+ z.string().describe(argument({
11
+ name: "branch",
12
+ description: "Branch whose worktree should be removed (in the same `<type>/<issue>-<desc>` format)",
13
+ })),
14
+ ]);
15
+ export const options = z.object({
16
+ force: z
17
+ .boolean()
18
+ .default(false)
19
+ .describe(option({
20
+ description: "Remove even if the worktree has uncommitted changes",
21
+ })),
22
+ });
23
+ export default function Remove({ args, options }) {
24
+ const [branch] = args;
25
+ const [result, setResult] = useState(null);
26
+ useEffect(() => {
27
+ setTimeout(() => {
28
+ try {
29
+ setResult(runRemove(branch, options.force));
30
+ }
31
+ catch (err) {
32
+ setResult({
33
+ ok: false,
34
+ message: err instanceof Error ? err.message : String(err),
35
+ });
36
+ }
37
+ }, 0);
38
+ }, [branch, options.force]);
39
+ if (!result) {
40
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" Removing worktree for ", branch, "..."] })] }));
41
+ }
42
+ if (!result.ok) {
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
+ }
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
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Launch Claude in the current worktree (creates or resumes a session)";
3
+ export declare const options: z.ZodObject<{
4
+ prompt: z.ZodOptional<z.ZodString>;
5
+ promptFile: z.ZodOptional<z.ZodString>;
6
+ permissionMode: z.ZodDefault<z.ZodEnum<{
7
+ default: "default";
8
+ auto: "auto";
9
+ }>>;
10
+ }, z.core.$strip>;
11
+ type Props = {
12
+ options: z.infer<typeof options>;
13
+ };
14
+ export default function Work({ options }: Props): import("react/jsx-runtime").JSX.Element;
15
+ export {};
@@ -0,0 +1,192 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { option } from "pastel";
6
+ import { z } from "zod";
7
+ import { randomUUID } from "crypto";
8
+ import { readFileSync, unlinkSync } from "fs";
9
+ import * as path from "path";
10
+ import { parseBranch, isParseError } from "../../lib/branch.js";
11
+ import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getCurrentBranch, pathExists, } from "../../lib/git.js";
12
+ import { getSessionId, setSessionId } from "../../lib/metadata.js";
13
+ import { launchClaude, PERMISSION_MODES } from "../../lib/claude.js";
14
+ export const description = "Launch Claude in the current worktree (creates or resumes a session)";
15
+ export const options = z.object({
16
+ prompt: z
17
+ .string()
18
+ .optional()
19
+ .describe(option({
20
+ description: "Initial prompt injected as the first user message (literal, no templating)",
21
+ })),
22
+ promptFile: z
23
+ .string()
24
+ .optional()
25
+ .describe(option({
26
+ description: "Read prompt from this file (deleted after read). Used by `worktree create --work` to bridge text from the create marker. Mutually exclusive with --prompt.",
27
+ })),
28
+ permissionMode: z
29
+ .enum(PERMISSION_MODES)
30
+ .default("default")
31
+ .describe(option({
32
+ description: `Claude --permission-mode (one of: ${PERMISSION_MODES.join(", ")})`,
33
+ alias: "m",
34
+ })),
35
+ });
36
+ function resolve(cwd) {
37
+ const repoRoot = findMainRepoRoot(cwd);
38
+ if (!repoRoot) {
39
+ return {
40
+ ok: false,
41
+ message: "Not in a git repository.",
42
+ hint: "Run `mintree worktree work` from inside a mintree worktree.",
43
+ };
44
+ }
45
+ if (!pathExists(getMintreeDir(repoRoot))) {
46
+ return {
47
+ ok: false,
48
+ message: ".mintree/ not found in this repo.",
49
+ hint: "Run `mintree init` first.",
50
+ };
51
+ }
52
+ const worktreesDir = path.resolve(getWorktreesDir(repoRoot));
53
+ const cwdAbs = path.resolve(cwd);
54
+ const insideMintreeWorktree = cwdAbs === worktreesDir
55
+ ? false
56
+ : cwdAbs.startsWith(worktreesDir + path.sep);
57
+ if (!insideMintreeWorktree) {
58
+ return {
59
+ ok: false,
60
+ message: "This directory isn't a mintree worktree.",
61
+ hint: "Run `mintree worktree work` from inside `.mintree/worktrees/<issue>-<desc>`.",
62
+ };
63
+ }
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
+ // The worktree path that git knows about is the *root* of this checkout.
80
+ // Walk up from cwd until we land directly under .mintree/worktrees/<name>.
81
+ const segmentBeneathWorktreesDir = cwdAbs.slice(worktreesDir.length + 1).split(path.sep)[0];
82
+ const worktreePath = segmentBeneathWorktreesDir
83
+ ? path.join(worktreesDir, segmentBeneathWorktreesDir)
84
+ : cwdAbs;
85
+ const existing = getSessionId(repoRoot, parsed.issueId);
86
+ let sessionId;
87
+ let resume;
88
+ if (existing) {
89
+ sessionId = existing;
90
+ resume = true;
91
+ }
92
+ else {
93
+ sessionId = randomUUID();
94
+ setSessionId(repoRoot, parsed.issueId, sessionId);
95
+ resume = false;
96
+ }
97
+ return {
98
+ ok: true,
99
+ data: {
100
+ repoRoot,
101
+ worktreePath,
102
+ branch,
103
+ issueId: parsed.issueId,
104
+ sessionId,
105
+ resume,
106
+ },
107
+ };
108
+ }
109
+ export default function Work({ options }) {
110
+ const [state, setState] = useState({ phase: "loading" });
111
+ useEffect(() => {
112
+ // Defer one tick so the spinner gets to render before sync work starts.
113
+ setTimeout(() => {
114
+ if (options.prompt && options.promptFile) {
115
+ setState({
116
+ phase: "error",
117
+ message: "--prompt and --prompt-file are mutually exclusive.",
118
+ });
119
+ return;
120
+ }
121
+ const result = resolve(process.cwd());
122
+ if (!result.ok) {
123
+ setState({ phase: "error", message: result.message, hint: result.hint });
124
+ return;
125
+ }
126
+ setState({ phase: "launching", resolved: result.data });
127
+ }, 0);
128
+ }, []);
129
+ useEffect(() => {
130
+ if (state.phase !== "launching")
131
+ return;
132
+ const { resolved } = state;
133
+ // --prompt-file handling: read once, delete the file. The file is the
134
+ // transport between `worktree create --work --prompt` and us — both
135
+ // sides own its cleanup so even a crash mid-handoff doesn't leave junk
136
+ // in /tmp forever (OS sweeps tmpdir eventually anyway).
137
+ let effectivePrompt = options.prompt;
138
+ if (options.promptFile) {
139
+ try {
140
+ effectivePrompt = readFileSync(options.promptFile, "utf-8");
141
+ }
142
+ catch {
143
+ // Missing/unreadable — fall through with no prompt.
144
+ }
145
+ try {
146
+ unlinkSync(options.promptFile);
147
+ }
148
+ catch {
149
+ // Cleanup failure is non-fatal.
150
+ }
151
+ }
152
+ try {
153
+ const child = launchClaude({
154
+ permissionMode: options.permissionMode,
155
+ sessionId: resolved.sessionId,
156
+ resume: resolved.resume,
157
+ prompt: effectivePrompt,
158
+ cwd: resolved.worktreePath,
159
+ });
160
+ child.on("error", (err) => {
161
+ setState({
162
+ phase: "error",
163
+ message: `Failed to launch claude: ${err.message}`,
164
+ });
165
+ });
166
+ child.on("close", (code) => {
167
+ process.exit(code ?? 0);
168
+ });
169
+ }
170
+ catch (err) {
171
+ setState({
172
+ phase: "error",
173
+ message: err instanceof Error ? err.message : String(err),
174
+ });
175
+ }
176
+ }, [state.phase, options.permissionMode, options.prompt]);
177
+ if (state.phase === "loading") {
178
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Resolving worktree..." })] }));
179
+ }
180
+ if (state.phase === "error") {
181
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", state.message] }), state.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", state.hint] }) }))] }));
182
+ }
183
+ const { resolved } = state;
184
+ const sessionShort = resolved.sessionId.slice(0, 8);
185
+ 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
+ }
188
+ function truncate(s, max) {
189
+ if (s.length <= max)
190
+ return s;
191
+ return s.slice(0, max - 1) + "…";
192
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Branch convention enforced by mintree:
3
+ *
4
+ * <type>/<issue>-<kebab-desc>
5
+ *
6
+ * `<type>` is one of the 11 conventional prefixes; `<issue>` is the GitHub
7
+ * issue number (digits only, no `#`); `<desc>` is lower-case kebab-case.
8
+ *
9
+ * Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout
10
+ * Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100, feat/100-FooBar
11
+ */
12
+ export declare const ALLOWED_TYPES: readonly ["feat", "fix", "docs", "chore", "refactor", "test", "build", "ci", "perf", "style", "revert"];
13
+ export type BranchType = (typeof ALLOWED_TYPES)[number];
14
+ export type ParsedBranch = {
15
+ branch: string;
16
+ type: BranchType;
17
+ issueId: string;
18
+ desc: string;
19
+ worktreeDirName: string;
20
+ };
21
+ export type ParseError = {
22
+ error: string;
23
+ hint: string;
24
+ };
25
+ export declare function parseBranch(branch: string): ParsedBranch | ParseError;
26
+ export declare function isParseError(result: ParsedBranch | ParseError): result is ParseError;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Branch convention enforced by mintree:
3
+ *
4
+ * <type>/<issue>-<kebab-desc>
5
+ *
6
+ * `<type>` is one of the 11 conventional prefixes; `<issue>` is the GitHub
7
+ * issue number (digits only, no `#`); `<desc>` is lower-case kebab-case.
8
+ *
9
+ * Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout
10
+ * Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100, feat/100-FooBar
11
+ */
12
+ export const ALLOWED_TYPES = [
13
+ "feat",
14
+ "fix",
15
+ "docs",
16
+ "chore",
17
+ "refactor",
18
+ "test",
19
+ "build",
20
+ "ci",
21
+ "perf",
22
+ "style",
23
+ "revert",
24
+ ];
25
+ const BRANCH_REGEX = /^([a-z]+)\/(\d+)-([a-z0-9][a-z0-9-]*)$/;
26
+ export function parseBranch(branch) {
27
+ const match = BRANCH_REGEX.exec(branch);
28
+ if (!match) {
29
+ return {
30
+ error: `Invalid branch name: ${branch}`,
31
+ hint: "Expected `<type>/<issue>-<kebab-desc>`. Example: feat/100-claude-md-inicial",
32
+ };
33
+ }
34
+ const [, type, issueId, desc] = match;
35
+ if (!type || !issueId || !desc) {
36
+ return {
37
+ error: `Invalid branch name: ${branch}`,
38
+ hint: "Expected `<type>/<issue>-<kebab-desc>`. Example: feat/100-claude-md-inicial",
39
+ };
40
+ }
41
+ if (!ALLOWED_TYPES.includes(type)) {
42
+ return {
43
+ error: `Unknown branch type \`${type}\``,
44
+ hint: `Allowed types: ${ALLOWED_TYPES.join(", ")}`,
45
+ };
46
+ }
47
+ return {
48
+ branch,
49
+ type: type,
50
+ issueId,
51
+ desc,
52
+ worktreeDirName: `${issueId}-${desc}`,
53
+ };
54
+ }
55
+ export function isParseError(result) {
56
+ return "error" in result;
57
+ }
@@ -0,0 +1,26 @@
1
+ import { type ChildProcess } from "child_process";
2
+ export declare const PERMISSION_MODES: readonly ["default", "auto"];
3
+ export type PermissionMode = (typeof PERMISSION_MODES)[number];
4
+ /**
5
+ * Resolves the absolute path of the Claude Code CLI binary, or null if not on
6
+ * PATH. Falls back to ~/.claude/local/claude (the Anthropic installer
7
+ * location) when PATH lookup fails — this is the single most common reason a
8
+ * Node child sees "claude not found" while the user sees it on the shell.
9
+ */
10
+ export declare function resolveClaudeBinary(): string | null;
11
+ export type LaunchClaudeOptions = {
12
+ permissionMode: PermissionMode;
13
+ sessionId: string;
14
+ resume: boolean;
15
+ prompt?: string;
16
+ cwd: string;
17
+ };
18
+ /**
19
+ * Spawns the Claude CLI with stdio inherited so the child takes over the TTY.
20
+ * The session is started fresh with `--session-id` or resumed with `--resume`
21
+ * depending on `resume`. Returns the ChildProcess so the caller can wire
22
+ * exit/error handlers.
23
+ *
24
+ * Throws if the claude binary is not resolvable.
25
+ */
26
+ export declare function launchClaude(options: LaunchClaudeOptions): ChildProcess;
@@ -0,0 +1,67 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { existsSync, writeFileSync } from "fs";
3
+ import { homedir, tmpdir } from "os";
4
+ import { join } from "path";
5
+ export const PERMISSION_MODES = ["default", "auto"];
6
+ /**
7
+ * Resolves the absolute path of the Claude Code CLI binary, or null if not on
8
+ * PATH. Falls back to ~/.claude/local/claude (the Anthropic installer
9
+ * location) when PATH lookup fails — this is the single most common reason a
10
+ * Node child sees "claude not found" while the user sees it on the shell.
11
+ */
12
+ export function resolveClaudeBinary() {
13
+ try {
14
+ const out = execSync("which claude", { stdio: ["ignore", "pipe", "ignore"] })
15
+ .toString()
16
+ .trim();
17
+ if (out)
18
+ return out;
19
+ }
20
+ catch {
21
+ // fall through
22
+ }
23
+ const local = join(homedir(), ".claude", "local", "claude");
24
+ if (existsSync(local))
25
+ return local;
26
+ return null;
27
+ }
28
+ // macOS ARG_MAX is 256KB; leave room for env vars.
29
+ const ARG_MAX_SAFE = 200 * 1024;
30
+ /**
31
+ * If `prompt` fits in argv, returns it as-is. Otherwise writes it to a temp
32
+ * file and returns a short instruction the agent can follow to read it. This
33
+ * keeps the launch flow safe against very long prompts without forcing
34
+ * callers to handle the spill case.
35
+ */
36
+ function promptArg(prompt) {
37
+ if (Buffer.byteLength(prompt) <= ARG_MAX_SAFE)
38
+ return prompt;
39
+ const filePath = join(tmpdir(), `mintree-prompt-${Date.now()}.md`);
40
+ writeFileSync(filePath, prompt);
41
+ return `Read ${filePath} and follow the instructions inside.`;
42
+ }
43
+ /**
44
+ * Spawns the Claude CLI with stdio inherited so the child takes over the TTY.
45
+ * The session is started fresh with `--session-id` or resumed with `--resume`
46
+ * depending on `resume`. Returns the ChildProcess so the caller can wire
47
+ * exit/error handlers.
48
+ *
49
+ * Throws if the claude binary is not resolvable.
50
+ */
51
+ export function launchClaude(options) {
52
+ const bin = resolveClaudeBinary();
53
+ if (!bin) {
54
+ throw new Error("Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code");
55
+ }
56
+ const args = ["--permission-mode", options.permissionMode];
57
+ if (options.resume) {
58
+ args.push("--resume", options.sessionId);
59
+ }
60
+ else {
61
+ args.push("--session-id", options.sessionId);
62
+ }
63
+ if (options.prompt && options.prompt.length > 0) {
64
+ args.push("--", promptArg(options.prompt));
65
+ }
66
+ return spawn(bin, args, { stdio: "inherit", cwd: options.cwd });
67
+ }
@@ -0,0 +1,50 @@
1
+ import { type AheadBehind } from "./git.js";
2
+ export type GhIssue = {
3
+ number: number;
4
+ title: string;
5
+ state: string;
6
+ url: string;
7
+ labels: {
8
+ name: string;
9
+ }[];
10
+ body: string;
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ };
14
+ export type WorktreeInfo = {
15
+ path: string;
16
+ branch: string;
17
+ dirty: boolean;
18
+ ab: AheadBehind | null;
19
+ sessionId?: string;
20
+ };
21
+ export type SessionStateValue = "active" | "idle" | "waiting" | "exited";
22
+ export type SessionStateInfo = {
23
+ state: SessionStateValue;
24
+ at: string;
25
+ message: string | null;
26
+ };
27
+ export type PrInfo = {
28
+ number: number;
29
+ state: "OPEN" | "CLOSED" | "MERGED";
30
+ url: string;
31
+ };
32
+ export type DashboardIssue = {
33
+ issue: GhIssue;
34
+ worktree: WorktreeInfo | null;
35
+ session: SessionStateInfo | null;
36
+ pr: PrInfo | null;
37
+ };
38
+ /**
39
+ * Fetches open issues assigned to the authenticated GitHub user for the
40
+ * current cwd's repo. Returns null when `gh` isn't authenticated, the cwd
41
+ * isn't a GitHub repo, or the API call fails — the caller surfaces the
42
+ * appropriate hint.
43
+ */
44
+ export declare function fetchAssignedIssues(): Promise<GhIssue[] | null>;
45
+ /**
46
+ * Top-level loader: enriches each assigned issue with its worktree and
47
+ * session snapshot. Designed to be called on dashboard mount and on every
48
+ * `r` refresh — cheap because all the per-worktree probes are local.
49
+ */
50
+ export declare function loadDashboard(repoRoot: string): Promise<DashboardIssue[] | null>;
@@ -0,0 +1,139 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { tryExec } from "./exec.js";
4
+ import { listWorktrees, getWorktreesDir, isDirty, getAheadBehind, } from "./git.js";
5
+ import { readMetadata } from "./metadata.js";
6
+ const ISSUE_LIST_LIMIT = 50;
7
+ /**
8
+ * Fetches open issues assigned to the authenticated GitHub user for the
9
+ * current cwd's repo. Returns null when `gh` isn't authenticated, the cwd
10
+ * isn't a GitHub repo, or the API call fails — the caller surfaces the
11
+ * appropriate hint.
12
+ */
13
+ export async function fetchAssignedIssues() {
14
+ const json = await tryExec(`gh issue list --assignee @me --state open --json number,title,state,url,labels,body,createdAt,updatedAt --limit ${ISSUE_LIST_LIMIT} 2>/dev/null`);
15
+ if (!json)
16
+ return null;
17
+ try {
18
+ const parsed = JSON.parse(json);
19
+ if (!Array.isArray(parsed))
20
+ return null;
21
+ return parsed;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /**
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.
32
+ */
33
+ function buildWorktreeIndex(repoRoot) {
34
+ const worktreesRoot = path.resolve(getWorktreesDir(repoRoot));
35
+ const branchRegex = /^[a-z]+\/(\d+)-/;
36
+ const index = new Map();
37
+ for (const w of listWorktrees(repoRoot)) {
38
+ if (!w.branch)
39
+ continue;
40
+ const wAbs = path.resolve(w.path);
41
+ if (wAbs !== worktreesRoot && !wAbs.startsWith(worktreesRoot + path.sep))
42
+ continue;
43
+ const m = w.branch.match(branchRegex);
44
+ const issueId = m && m[1] ? m[1] : null;
45
+ if (!issueId)
46
+ continue;
47
+ index.set(issueId, {
48
+ path: w.path,
49
+ branch: w.branch,
50
+ dirty: isDirty(w.path),
51
+ ab: getAheadBehind(w.path),
52
+ });
53
+ }
54
+ return index;
55
+ }
56
+ /**
57
+ * Reads the live state file written by the session-signal hooks. Returns null
58
+ * when the file doesn't exist, can't be parsed, or holds an unrecognised state
59
+ * value — the dashboard treats those as "no live session".
60
+ */
61
+ function readSessionState(repoRoot, issueId) {
62
+ const file = path.join(repoRoot, ".mintree", "session-states", `${issueId}.json`);
63
+ if (!fs.existsSync(file))
64
+ return null;
65
+ try {
66
+ const data = JSON.parse(fs.readFileSync(file, "utf-8"));
67
+ const state = data?.state;
68
+ if (!isSessionState(state))
69
+ return null;
70
+ return {
71
+ state,
72
+ at: typeof data.at === "string" ? data.at : "",
73
+ message: typeof data.message === "string" ? data.message : null,
74
+ };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function isSessionState(v) {
81
+ return v === "active" || v === "idle" || v === "waiting" || v === "exited";
82
+ }
83
+ function shQuote(value) {
84
+ return `'${value.replace(/'/g, `'\\''`)}'`;
85
+ }
86
+ /**
87
+ * Looks up the most recent PR for a branch (any state). Returns null when
88
+ * there's no PR or `gh` can't reach the API. Used to populate the detail
89
+ * pane's "Pull Request" section.
90
+ */
91
+ async function fetchPrForBranch(branch) {
92
+ const out = await tryExec(`gh pr list --head ${shQuote(branch)} --state all --json number,state,url --limit 1 2>/dev/null`);
93
+ if (!out)
94
+ return null;
95
+ try {
96
+ const arr = JSON.parse(out);
97
+ if (Array.isArray(arr) && arr.length > 0 && arr[0])
98
+ return arr[0];
99
+ }
100
+ catch {
101
+ // fall through
102
+ }
103
+ return null;
104
+ }
105
+ /**
106
+ * Top-level loader: enriches each assigned issue with its worktree and
107
+ * session snapshot. Designed to be called on dashboard mount and on every
108
+ * `r` refresh — cheap because all the per-worktree probes are local.
109
+ */
110
+ export async function loadDashboard(repoRoot) {
111
+ const issues = await fetchAssignedIssues();
112
+ if (!issues)
113
+ return null;
114
+ const worktreesByIssue = buildWorktreeIndex(repoRoot);
115
+ const metadata = readMetadata(repoRoot);
116
+ // Fetch PRs in parallel for branches that actually have a worktree —
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.
119
+ const prByBranch = new Map();
120
+ const prFetches = Array.from(worktreesByIssue.values()).map(async (w) => {
121
+ const pr = await fetchPrForBranch(w.branch);
122
+ if (pr)
123
+ prByBranch.set(w.branch, pr);
124
+ });
125
+ await Promise.all(prFetches);
126
+ return issues.map(issue => {
127
+ const issueId = String(issue.number);
128
+ const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
129
+ const sessionId = metadata.issues[issueId]?.session_id;
130
+ const worktree = worktreeRaw ? { ...worktreeRaw, sessionId } : null;
131
+ const pr = worktree ? (prByBranch.get(worktree.branch) ?? null) : null;
132
+ return {
133
+ issue,
134
+ worktree,
135
+ session: readSessionState(repoRoot, issueId),
136
+ pr,
137
+ };
138
+ });
139
+ }
@@ -0,0 +1,2 @@
1
+ export declare function tryExec(command: string): Promise<string | null>;
2
+ export declare function getPath(command: string): Promise<string | null>;
@@ -0,0 +1,15 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ const execAsync = promisify(exec);
4
+ export async function tryExec(command) {
5
+ try {
6
+ const { stdout } = await execAsync(command);
7
+ return stdout.trim();
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export async function getPath(command) {
14
+ return tryExec(`which ${command}`);
15
+ }