santree 0.5.3 → 0.5.4

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 (66) hide show
  1. package/README.md +145 -52
  2. package/dist/commands/dashboard.d.ts +1 -1
  3. package/dist/commands/dashboard.js +22 -18
  4. package/dist/commands/doctor.js +33 -71
  5. package/dist/commands/github/auth.d.ts +2 -0
  6. package/dist/commands/github/auth.js +56 -0
  7. package/dist/commands/github/index.d.ts +1 -0
  8. package/dist/commands/github/index.js +1 -0
  9. package/dist/commands/helpers/template.d.ts +1 -0
  10. package/dist/commands/helpers/template.js +13 -10
  11. package/dist/commands/issue/index.d.ts +1 -0
  12. package/dist/commands/issue/index.js +1 -0
  13. package/dist/commands/issue/open.d.ts +2 -0
  14. package/dist/commands/{linear → issue}/open.js +13 -11
  15. package/dist/commands/issue/switch.d.ts +11 -0
  16. package/dist/commands/issue/switch.js +38 -0
  17. package/dist/commands/linear/auth.js +23 -10
  18. package/dist/commands/linear/switch.js +7 -3
  19. package/dist/commands/pr/create.js +7 -5
  20. package/dist/commands/worktree/create.js +4 -6
  21. package/dist/commands/worktree/work.js +1 -1
  22. package/dist/lib/ai.d.ts +8 -6
  23. package/dist/lib/ai.js +29 -15
  24. package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
  25. package/dist/lib/dashboard/DetailPanel.js +6 -3
  26. package/dist/lib/dashboard/data.js +17 -9
  27. package/dist/lib/dashboard/types.d.ts +3 -16
  28. package/dist/lib/git.d.ts +16 -33
  29. package/dist/lib/git.js +20 -74
  30. package/dist/lib/metadata.d.ts +3 -0
  31. package/dist/lib/metadata.js +27 -0
  32. package/dist/lib/multiplexer/cmux.js +1 -1
  33. package/dist/lib/multiplexer/types.d.ts +1 -1
  34. package/dist/lib/prompts.d.ts +4 -3
  35. package/dist/lib/prompts.js +4 -3
  36. package/dist/lib/session-signal.d.ts +2 -3
  37. package/dist/lib/session-signal.js +3 -29
  38. package/dist/lib/trackers/auth-store.d.ts +16 -0
  39. package/dist/lib/trackers/auth-store.js +57 -0
  40. package/dist/lib/trackers/config.d.ts +8 -0
  41. package/dist/lib/trackers/config.js +21 -0
  42. package/dist/lib/trackers/github/api.d.ts +3 -0
  43. package/dist/lib/trackers/github/api.js +90 -0
  44. package/dist/lib/trackers/github/auth.d.ts +5 -0
  45. package/dist/lib/trackers/github/auth.js +27 -0
  46. package/dist/lib/trackers/github/images.d.ts +2 -0
  47. package/dist/lib/trackers/github/images.js +42 -0
  48. package/dist/lib/trackers/github/index.d.ts +2 -0
  49. package/dist/lib/trackers/github/index.js +78 -0
  50. package/dist/lib/trackers/index.d.ts +12 -0
  51. package/dist/lib/trackers/index.js +34 -0
  52. package/dist/lib/trackers/linear/api.d.ts +4 -0
  53. package/dist/lib/trackers/linear/api.js +128 -0
  54. package/dist/lib/trackers/linear/auth.d.ts +11 -0
  55. package/dist/lib/trackers/linear/auth.js +206 -0
  56. package/dist/lib/trackers/linear/images.d.ts +2 -0
  57. package/dist/lib/trackers/linear/images.js +44 -0
  58. package/dist/lib/trackers/linear/index.d.ts +3 -0
  59. package/dist/lib/trackers/linear/index.js +100 -0
  60. package/dist/lib/trackers/types.d.ts +52 -0
  61. package/dist/lib/trackers/types.js +1 -0
  62. package/package.json +1 -1
  63. package/prompts/ticket.njk +3 -3
  64. package/dist/commands/linear/open.d.ts +0 -2
  65. package/dist/lib/linear.d.ts +0 -83
  66. package/dist/lib/linear.js +0 -482
@@ -36,7 +36,6 @@ export default function Create({ options, args }) {
36
36
  const [worktreePath, setWorktreePath] = useState("");
37
37
  const [baseBranch, setBaseBranch] = useState(null);
38
38
  const [muxWindowName, setMuxWindowName] = useState(null);
39
- const [muxKind, setMuxKind] = useState(null);
40
39
  async function finalize(path, branch) {
41
40
  const wantsWindow = options.window || options.tmux;
42
41
  if (wantsWindow) {
@@ -48,24 +47,23 @@ export default function Create({ options, args }) {
48
47
  return;
49
48
  }
50
49
  setStatus("spawning-window");
51
- setMessage(`Creating ${mux.kind} window...`);
50
+ setMessage("Creating window...");
52
51
  const windowName = getWindowName(branch, options.name);
53
52
  setMuxWindowName(windowName);
54
- setMuxKind(mux.kind);
55
53
  let runCommand;
56
54
  if (options.work) {
57
55
  runCommand = options.plan ? "st worktree work --plan" : "st worktree work";
58
56
  }
59
57
  const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
60
58
  if (!result.ok) {
61
- setMessage(`Worktree created, but failed to create ${mux.kind} window${result.message ? `: ${result.message}` : ""}`);
59
+ setMessage(`Worktree created, but failed to create window${result.message ? `: ${result.message}` : ""}`);
62
60
  setStatus("done");
63
61
  console.log(`SANTREE_CD:${path}`);
64
62
  return;
65
63
  }
66
64
  setStatus("done");
67
65
  const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
68
- setMessage(`Worktree and ${mux.kind} window created!${workInfo}`);
66
+ setMessage(`Worktree and window created!${workInfo}`);
69
67
  // Don't output SANTREE_CD when a window is created — user is already in the new window
70
68
  return;
71
69
  }
@@ -161,5 +159,5 @@ export default function Create({ options, args }) {
161
159
  status === "creating" ||
162
160
  status === "init-script" ||
163
161
  status === "spawning-window";
164
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && (_jsxs(Text, { dimColor: true, children: [" ", muxKind ?? "tmux", " window: ", muxWindowName] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
162
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && _jsxs(Text, { dimColor: true, children: [" window: ", muxWindowName] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
165
163
  }
@@ -99,5 +99,5 @@ export default function Work({ options }) {
99
99
  setError(err instanceof Error ? err.message : "Failed to launch agent");
100
100
  }
101
101
  }, [status, aiContext, mode]);
102
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching ticket from Linear..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
102
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching issue from tracker..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
103
103
  }
package/dist/lib/ai.d.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { type ChildProcess } from "child_process";
2
- import { cleanupImages, type LinearIssue } from "./linear.js";
2
+ import type { Issue } from "./trackers/types.js";
3
3
  export interface AIContext {
4
4
  repoRoot: string;
5
5
  mainRoot: string;
6
6
  branch: string;
7
7
  ticketId: string | null;
8
- ticket: LinearIssue | null;
8
+ ticket: Issue | null;
9
+ trackerName: string;
10
+ issueNoun: string;
9
11
  }
10
12
  /**
11
- * Resolves repo, branch, ticket ID, and fetches the Linear ticket.
12
- * Returns an error string if any required context is missing.
13
+ * Resolves repo, branch, issue identifier, and fetches the issue from the
14
+ * active tracker (Linear or GitHub Issues selected by repo config).
13
15
  */
14
16
  export declare function resolveAIContext(): Promise<{
15
17
  ok: true;
@@ -74,6 +76,6 @@ export declare function runAgent(prompt: string, opts?: {
74
76
  allowedTools?: string[];
75
77
  }): RunAgentResult;
76
78
  /**
77
- * Cleanup images downloaded for a ticket.
79
+ * Clean up cached image downloads for an issue identifier on the active tracker.
78
80
  */
79
- export { cleanupImages };
81
+ export declare function cleanupImages(ticketId: string): void;
package/dist/lib/ai.js CHANGED
@@ -2,15 +2,14 @@ import { execSync, spawn, spawnSync } from "child_process";
2
2
  import { existsSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir, tmpdir } from "os";
5
- import { getMultiplexer } from "./multiplexer/index.js";
6
- import { getCurrentBranch, extractTicketId, findRepoRoot, findMainRepoRoot, getBaseBranch, } from "./git.js";
5
+ import { getCurrentBranch, findRepoRoot, findMainRepoRoot, getBaseBranch } from "./git.js";
7
6
  import { renderPrompt, renderTicket, renderDiff, renderPR } from "./prompts.js";
8
- import { getTicketContent, cleanupImages } from "./linear.js";
7
+ import { getIssueTracker } from "./trackers/index.js";
9
8
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRReviewCommentsAsync, getPRConversationCommentsAsync, getFailedCheckDetailsAsync, } from "./github.js";
10
9
  import { runAsync } from "./exec.js";
11
10
  /**
12
- * Resolves repo, branch, ticket ID, and fetches the Linear ticket.
13
- * Returns an error string if any required context is missing.
11
+ * Resolves repo, branch, issue identifier, and fetches the issue from the
12
+ * active tracker (Linear or GitHub Issues selected by repo config).
14
13
  */
15
14
  export async function resolveAIContext() {
16
15
  const repoRoot = findRepoRoot();
@@ -21,18 +20,28 @@ export async function resolveAIContext() {
21
20
  if (!branch) {
22
21
  return { ok: false, error: "Could not determine current branch" };
23
22
  }
24
- const ticketId = extractTicketId(branch);
23
+ const mainRoot = findMainRepoRoot() ?? repoRoot;
24
+ const tracker = getIssueTracker(mainRoot);
25
+ const ticketId = tracker.extractIdFromBranch(branch);
25
26
  if (!ticketId) {
26
27
  return {
27
28
  ok: false,
28
- error: "Could not extract ticket ID from branch name. Expected format: user/TEAM-123-description",
29
+ error: `Could not extract ${tracker.issueNoun} ID from branch name '${branch}'.`,
29
30
  };
30
31
  }
31
- const mainRoot = findMainRepoRoot() ?? repoRoot;
32
- const ticket = await getTicketContent(ticketId, mainRoot);
32
+ const result = await tracker.getIssue(ticketId, mainRoot);
33
+ const ticket = result.ok ? result.value : null;
33
34
  return {
34
35
  ok: true,
35
- context: { repoRoot, mainRoot, branch, ticketId, ticket },
36
+ context: {
37
+ repoRoot,
38
+ mainRoot,
39
+ branch,
40
+ ticketId,
41
+ ticket,
42
+ trackerName: tracker.displayName,
43
+ issueNoun: tracker.issueNoun,
44
+ },
36
45
  };
37
46
  }
38
47
  /**
@@ -41,7 +50,7 @@ export async function resolveAIContext() {
41
50
  export function buildPromptContext(ctx, extra) {
42
51
  return {
43
52
  ticket_id: ctx.ticketId ?? undefined,
44
- ticket_content: ctx.ticket ? renderTicket(ctx.ticket) : undefined,
53
+ ticket_content: ctx.ticket ? renderTicket(ctx.ticket, ctx.trackerName) : undefined,
45
54
  ...extra,
46
55
  };
47
56
  }
@@ -122,8 +131,10 @@ const CMUX_CLAUDE_PATH = "/Applications/cmux.app/Contents/Resources/bin/claude";
122
131
  */
123
132
  export function resolveClaudeBinary() {
124
133
  // Inside cmux, the bundled binary is the only one wired to the active
125
- // workspace. Always prefer it when present.
126
- if (getMultiplexer().kind === "cmux" && existsSync(CMUX_CLAUDE_PATH)) {
134
+ // workspace. Gate on `CMUX_SURFACE_ID` (actual cmux runtime), not
135
+ // `SANTREE_MULTIPLEXER=cmux` outside a live workspace the bundled
136
+ // binary has no auth context and exits with "Invalid API key".
137
+ if (process.env["CMUX_SURFACE_ID"] && existsSync(CMUX_CLAUDE_PATH)) {
127
138
  return CMUX_CLAUDE_PATH;
128
139
  }
129
140
  // PATH lookup
@@ -216,6 +227,9 @@ export function runAgent(prompt, opts) {
216
227
  };
217
228
  }
218
229
  /**
219
- * Cleanup images downloaded for a ticket.
230
+ * Clean up cached image downloads for an issue identifier on the active tracker.
220
231
  */
221
- export { cleanupImages };
232
+ export function cleanupImages(ticketId) {
233
+ const repoRoot = findMainRepoRoot();
234
+ getIssueTracker(repoRoot).cleanupCache(ticketId);
235
+ }
@@ -14,7 +14,10 @@ export type IssueActionItem = {
14
14
  };
15
15
  /** Returns the context-sensitive action key list for the selected issue.
16
16
  * Lifted out of the panel so the dashboard can render it on the same row as
17
- * the global command bar (so left- and right-pane key hints align). */
18
- export declare function buildIssueActions(di: DashboardIssue): IssueActionItem[];
17
+ * the global command bar (so left- and right-pane key hints align). The
18
+ * `trackerName` is the active tracker's `displayName` ("Linear" / "GitHub"),
19
+ * surfaced as the open-in-browser action label so the panel doesn't hardcode
20
+ * a vendor name. */
21
+ export declare function buildIssueActions(di: DashboardIssue, trackerName: string): IssueActionItem[];
19
22
  export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }: Props): import("react/jsx-runtime").JSX.Element;
20
23
  export {};
@@ -51,8 +51,11 @@ function fileColor(xy) {
51
51
  }
52
52
  /** Returns the context-sensitive action key list for the selected issue.
53
53
  * Lifted out of the panel so the dashboard can render it on the same row as
54
- * the global command bar (so left- and right-pane key hints align). */
55
- export function buildIssueActions(di) {
54
+ * the global command bar (so left- and right-pane key hints align). The
55
+ * `trackerName` is the active tracker's `displayName` ("Linear" / "GitHub"),
56
+ * surfaced as the open-in-browser action label so the panel doesn't hardcode
57
+ * a vendor name. */
58
+ export function buildIssueActions(di, trackerName) {
56
59
  const { worktree, pr, issue } = di;
57
60
  const items = [];
58
61
  if (worktree?.sessionId) {
@@ -82,7 +85,7 @@ export function buildIssueActions(di) {
82
85
  items.push({ key: "r", label: "Review", color: "cyan" });
83
86
  }
84
87
  if (issue.url) {
85
- items.push({ key: "o", label: "Linear", color: "gray" });
88
+ items.push({ key: "o", label: trackerName, color: "gray" });
86
89
  }
87
90
  if (pr)
88
91
  items.push({ key: "p", label: "Open PR", color: "gray" });
@@ -1,14 +1,18 @@
1
1
  import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, getDiffShortstatAsync, } from "../git.js";
2
2
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
3
- import { fetchAssignedIssues } from "../linear.js";
3
+ import { getIssueTracker } from "../trackers/index.js";
4
4
  export async function loadDashboardData(repoRoot) {
5
5
  // Fetch issues and worktrees in parallel
6
- const [issues, worktrees] = await Promise.all([
7
- fetchAssignedIssues(repoRoot),
6
+ const tracker = getIssueTracker(repoRoot);
7
+ const [listResult, worktrees] = await Promise.all([
8
+ tracker.listAssigned(repoRoot),
8
9
  Promise.resolve(listWorktrees()),
9
10
  ]);
10
- if (!issues)
11
- throw new Error("Failed to authenticate with Linear. Run: santree linear auth");
11
+ if (!listResult.ok) {
12
+ const status = await tracker.getAuthStatus(repoRoot);
13
+ throw new Error(listResult.message ?? status.hint ?? `Failed to authenticate with ${tracker.displayName}`);
14
+ }
15
+ const issues = listResult.value;
12
16
  // Build worktree map: ticketId -> worktree info
13
17
  const wtMap = new Map();
14
18
  for (const wt of worktrees) {
@@ -92,10 +96,14 @@ export async function loadDashboardData(repoRoot) {
92
96
  getPRReviewsAsync(pr.number),
93
97
  ]);
94
98
  }
95
- // Derive a readable title from branch name: strip prefix and ticket ID
96
- const titleFromBranch = wt.branch
97
- .replace(/^[^/]+\//, "") // strip prefix (e.g. "feature/")
98
- .replace(/^[A-Z]+-\d+-?/, "") // strip ticket ID
99
+ // Derive a readable title from branch name: strip prefix and the
100
+ // tracker-format ID literal (e.g. "TEAM-123-" or "123-"). The ID
101
+ // shape comes from the tracker's parser so this works for both
102
+ // Linear and GitHub branches.
103
+ const idLiteral = tid ? new RegExp(`^${tid}-?`) : null;
104
+ const titleFromBranch = (idLiteral
105
+ ? wt.branch.replace(/^[^/]+\//, "").replace(idLiteral, "")
106
+ : wt.branch.replace(/^[^/]+\//, ""))
99
107
  .replace(/-/g, " ")
100
108
  .trim() || tid;
101
109
  let sessState = readSessionState(repoRoot, tid);
@@ -1,19 +1,6 @@
1
1
  import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
- export interface LinearAssignedIssue {
3
- identifier: string;
4
- title: string;
5
- description: string | null;
6
- url: string;
7
- priority: number;
8
- priorityLabel: string;
9
- state: {
10
- name: string;
11
- type: string;
12
- };
13
- labels: string[];
14
- projectId: string | null;
15
- projectName: string | null;
16
- }
2
+ import type { AssignedIssue } from "../trackers/types.js";
3
+ export type { AssignedIssue } from "../trackers/types.js";
17
4
  export interface WorktreeInfo {
18
5
  path: string;
19
6
  branch: string;
@@ -30,7 +17,7 @@ export interface WorktreeInfo {
30
17
  } | null;
31
18
  }
32
19
  export interface DashboardIssue {
33
- issue: LinearAssignedIssue;
20
+ issue: AssignedIssue;
34
21
  worktree: WorktreeInfo | null;
35
22
  pr: PRInfo | null;
36
23
  checks: PRCheck[] | null;
package/dist/lib/git.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
1
2
  export interface SessionState {
2
3
  state: "waiting" | "idle" | "active" | "exited";
3
4
  message: string | null;
@@ -53,10 +54,6 @@ export declare function getDefaultBranch(): string;
53
54
  * Returns an empty array on failure.
54
55
  */
55
56
  export declare function listWorktrees(): Worktree[];
56
- /**
57
- * Get the path to the .santree directory inside a repo root.
58
- */
59
- export declare function getSantreeDir(repoRoot: string): string;
60
57
  /**
61
58
  * Get the path to the .santree/worktrees directory inside a repo root.
62
59
  */
@@ -82,9 +79,14 @@ export declare function removeWorktree(branchName: string, repoRoot: string, for
82
79
  error?: string;
83
80
  }>;
84
81
  /**
85
- * Extract a ticket ID (e.g. "TEAM-123") from a branch name.
86
- * Matches the first occurrence of LETTERS-DIGITS in the string.
87
- * Returns null if no ticket ID pattern is found.
82
+ * Extract an issue identifier from a branch name. Delegates to the active
83
+ * issue tracker (Linear: `[A-Z]+-\d+`; GitHub: explicit-prefix numeric),
84
+ * resolving the tracker from the main repo's config.
85
+ *
86
+ * This is the single shim that frees every caller from having to know which
87
+ * tracker is active or how it formats IDs. Returns null when the active
88
+ * tracker doesn't recognize the branch's ID shape, or when no main repo can
89
+ * be resolved (e.g. before a worktree is created).
88
90
  */
89
91
  export declare function extractTicketId(branch: string): string | null;
90
92
  /**
@@ -93,30 +95,6 @@ export declare function extractTicketId(branch: string): string | null;
93
95
  * Returns null if no worktree is checked out on that branch.
94
96
  */
95
97
  export declare function getWorktreePath(branchName: string): string | null;
96
- /**
97
- * Read all entries from .santree/metadata.json.
98
- * Returns an empty object if the file doesn't exist or can't be parsed.
99
- */
100
- export declare function readAllMetadata(repoRoot: string): Record<string, any>;
101
- /**
102
- * Write all entries to .santree/metadata.json.
103
- */
104
- export declare function writeAllMetadata(repoRoot: string, data: Record<string, any>): void;
105
- /**
106
- * Get the Linear org slug associated with this repo.
107
- * Stored as `_linear.org` in .santree/metadata.json.
108
- */
109
- export declare function getRepoLinearOrg(repoRoot: string): string | null;
110
- /**
111
- * Associate a Linear org slug with this repo.
112
- * Stored as `_linear.org` in .santree/metadata.json.
113
- */
114
- export declare function setRepoLinearOrg(repoRoot: string, orgSlug: string): void;
115
- /**
116
- * Remove the Linear org association from this repo.
117
- * Deletes the `_linear` key from .santree/metadata.json.
118
- */
119
- export declare function removeRepoLinearOrg(repoRoot: string): void;
120
98
  /**
121
99
  * Get the stored session ID for a given ticket from .santree/metadata.json.
122
100
  * Returns null if no session ID is stored.
@@ -191,8 +169,13 @@ export declare function getCommitsAhead(baseBranch: string): number;
191
169
  */
192
170
  export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): Promise<number>;
193
171
  /**
194
- * Read the SANTREE_DIFF_TOOL env var, returning the configured pager command
195
- * (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
172
+ * Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
173
+ * command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
174
+ *
175
+ * The CLI `worktree diff` flow lets the pager do its full job (render +
176
+ * scroll) via git's `core.pager`. The dashboard's `[v]` overlay only uses
177
+ * the rendering half — it captures the pager's stdout as a string and
178
+ * handles scrolling itself in Ink.
196
179
  *
197
180
  * The value is restricted to a safe shell-token character set since it ends
198
181
  * up in arguments passed to spawn() — even though we never use shell:true,
package/dist/lib/git.js CHANGED
@@ -4,6 +4,9 @@ import * as path from "path";
4
4
  import * as fs from "fs";
5
5
  import { run, runAsync, spawnAsync } from "./exec.js";
6
6
  import { getMultiplexer } from "./multiplexer/index.js";
7
+ import { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
8
+ import { getIssueTracker } from "./trackers/index.js";
9
+ export { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
7
10
  const execAsync = promisify(exec);
8
11
  /**
9
12
  * Find the toplevel directory of the current git repository.
@@ -114,12 +117,6 @@ export function listWorktrees() {
114
117
  }
115
118
  return worktrees;
116
119
  }
117
- /**
118
- * Get the path to the .santree directory inside a repo root.
119
- */
120
- export function getSantreeDir(repoRoot) {
121
- return path.join(repoRoot, ".santree");
122
- }
123
120
  /**
124
121
  * Get the path to the .santree/worktrees directory inside a repo root.
125
122
  */
@@ -247,16 +244,18 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
247
244
  }
248
245
  }
249
246
  /**
250
- * Extract a ticket ID (e.g. "TEAM-123") from a branch name.
251
- * Matches the first occurrence of LETTERS-DIGITS in the string.
252
- * Returns null if no ticket ID pattern is found.
247
+ * Extract an issue identifier from a branch name. Delegates to the active
248
+ * issue tracker (Linear: `[A-Z]+-\d+`; GitHub: explicit-prefix numeric),
249
+ * resolving the tracker from the main repo's config.
250
+ *
251
+ * This is the single shim that frees every caller from having to know which
252
+ * tracker is active or how it formats IDs. Returns null when the active
253
+ * tracker doesn't recognize the branch's ID shape, or when no main repo can
254
+ * be resolved (e.g. before a worktree is created).
253
255
  */
254
256
  export function extractTicketId(branch) {
255
- const match = branch.match(/([a-zA-Z]+)-(\d+)/);
256
- if (match) {
257
- return `${match[1].toUpperCase()}-${match[2]}`;
258
- }
259
- return null;
257
+ const repoRoot = findMainRepoRoot();
258
+ return getIssueTracker(repoRoot).extractIdFromBranch(branch);
260
259
  }
261
260
  /**
262
261
  * Get the filesystem path for a worktree by its branch name.
@@ -278,64 +277,6 @@ export function getWorktreePath(branchName) {
278
277
  }
279
278
  return null;
280
279
  }
281
- /**
282
- * Get path to centralized metadata file: .santree/metadata.json in the repo root.
283
- */
284
- function getMetadataFilePath(repoRoot) {
285
- return path.join(getSantreeDir(repoRoot), "metadata.json");
286
- }
287
- /**
288
- * Read all entries from .santree/metadata.json.
289
- * Returns an empty object if the file doesn't exist or can't be parsed.
290
- */
291
- export function readAllMetadata(repoRoot) {
292
- const filePath = getMetadataFilePath(repoRoot);
293
- if (!fs.existsSync(filePath))
294
- return {};
295
- try {
296
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
297
- }
298
- catch {
299
- return {};
300
- }
301
- }
302
- /**
303
- * Write all entries to .santree/metadata.json.
304
- */
305
- export function writeAllMetadata(repoRoot, data) {
306
- const filePath = getMetadataFilePath(repoRoot);
307
- const dir = path.dirname(filePath);
308
- if (!fs.existsSync(dir)) {
309
- fs.mkdirSync(dir, { recursive: true });
310
- }
311
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
312
- }
313
- /**
314
- * Get the Linear org slug associated with this repo.
315
- * Stored as `_linear.org` in .santree/metadata.json.
316
- */
317
- export function getRepoLinearOrg(repoRoot) {
318
- const all = readAllMetadata(repoRoot);
319
- return all._linear?.org ?? null;
320
- }
321
- /**
322
- * Associate a Linear org slug with this repo.
323
- * Stored as `_linear.org` in .santree/metadata.json.
324
- */
325
- export function setRepoLinearOrg(repoRoot, orgSlug) {
326
- const all = readAllMetadata(repoRoot);
327
- all._linear = { org: orgSlug };
328
- writeAllMetadata(repoRoot, all);
329
- }
330
- /**
331
- * Remove the Linear org association from this repo.
332
- * Deletes the `_linear` key from .santree/metadata.json.
333
- */
334
- export function removeRepoLinearOrg(repoRoot) {
335
- const all = readAllMetadata(repoRoot);
336
- delete all._linear;
337
- writeAllMetadata(repoRoot, all);
338
- }
339
280
  /**
340
281
  * Get the stored session ID for a given ticket from .santree/metadata.json.
341
282
  * Returns null if no session ID is stored.
@@ -469,8 +410,13 @@ export async function getCommitsAheadAsync(cwd, baseBranch) {
469
410
  return output ? parseInt(output, 10) || 0 : 0;
470
411
  }
471
412
  /**
472
- * Read the SANTREE_DIFF_TOOL env var, returning the configured pager command
473
- * (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
413
+ * Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
414
+ * command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
415
+ *
416
+ * The CLI `worktree diff` flow lets the pager do its full job (render +
417
+ * scroll) via git's `core.pager`. The dashboard's `[v]` overlay only uses
418
+ * the rendering half — it captures the pager's stdout as a string and
419
+ * handles scrolling itself in Ink.
474
420
  *
475
421
  * The value is restricted to a safe shell-token character set since it ends
476
422
  * up in arguments passed to spawn() — even though we never use shell:true,
@@ -0,0 +1,3 @@
1
+ export declare function getSantreeDir(repoRoot: string): string;
2
+ export declare function readAllMetadata(repoRoot: string): Record<string, any>;
3
+ export declare function writeAllMetadata(repoRoot: string, data: Record<string, any>): void;
@@ -0,0 +1,27 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ export function getSantreeDir(repoRoot) {
4
+ return path.join(repoRoot, ".santree");
5
+ }
6
+ function getMetadataFilePath(repoRoot) {
7
+ return path.join(getSantreeDir(repoRoot), "metadata.json");
8
+ }
9
+ export function readAllMetadata(repoRoot) {
10
+ const filePath = getMetadataFilePath(repoRoot);
11
+ if (!fs.existsSync(filePath))
12
+ return {};
13
+ try {
14
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export function writeAllMetadata(repoRoot, data) {
21
+ const filePath = getMetadataFilePath(repoRoot);
22
+ const dir = path.dirname(filePath);
23
+ if (!fs.existsSync(dir)) {
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ }
26
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
27
+ }
@@ -77,7 +77,7 @@ export const cmuxMultiplexer = {
77
77
  // follow-up sends to an existing workspace, which doesn't.
78
78
  return {
79
79
  ok: false,
80
- reason: "unsupported",
80
+ reason: "failed",
81
81
  message: "blocked by manaflow-ai/cmux#1472",
82
82
  };
83
83
  },
@@ -3,7 +3,7 @@ export type SessionResult = {
3
3
  ok: true;
4
4
  } | {
5
5
  ok: false;
6
- reason: "not-active" | "unsupported" | "failed";
6
+ reason: "not-active" | "failed";
7
7
  message?: string;
8
8
  };
9
9
  export interface CreateWindowOpts {
@@ -1,4 +1,4 @@
1
- import type { LinearIssue } from "./linear.js";
1
+ import type { Issue } from "./trackers/types.js";
2
2
  import type { PRCheck, PRReview, PRReviewComment, FailedCheckDetail, PRConversationComment } from "./github.js";
3
3
  /**
4
4
  * Render a nunjucks template from the prompts/ directory.
@@ -7,9 +7,10 @@ import type { PRCheck, PRReview, PRReviewComment, FailedCheckDetail, PRConversat
7
7
  */
8
8
  export declare function renderPrompt(template: string, context: Record<string, string | undefined>): string;
9
9
  /**
10
- * Render a LinearIssue into formatted markdown using the ticket template.
10
+ * Render an issue into formatted markdown using the ticket template.
11
+ * `trackerName` is injected for header/link text ("Linear" / "GitHub").
11
12
  */
12
- export declare function renderTicket(issue: LinearIssue): string;
13
+ export declare function renderTicket(issue: Issue, trackerName: string): string;
13
14
  export interface DiffData {
14
15
  base_branch: string;
15
16
  commit_log: string | null;
@@ -33,10 +33,11 @@ export function renderPrompt(template, context) {
33
33
  return promptsEnv.render(`${template}.njk`, context);
34
34
  }
35
35
  /**
36
- * Render a LinearIssue into formatted markdown using the ticket template.
36
+ * Render an issue into formatted markdown using the ticket template.
37
+ * `trackerName` is injected for header/link text ("Linear" / "GitHub").
37
38
  */
38
- export function renderTicket(issue) {
39
- return promptsEnv.render("ticket.njk", issue);
39
+ export function renderTicket(issue, trackerName) {
40
+ return promptsEnv.render("ticket.njk", { ...issue, trackerName });
40
41
  }
41
42
  /**
42
43
  * Render diff data into formatted markdown using the diff template.
@@ -4,11 +4,10 @@ export declare function extractRepoAndTicket(cwd: string): {
4
4
  repoRoot: string;
5
5
  ticketId: string;
6
6
  } | null;
7
- export declare function renameTmuxWindow(ticketId: string, state: SessionStateValue): void;
8
- export declare function runHookScript(repoRoot: string, state: SessionStateValue, env: Record<string, string>): void;
7
+ export declare function renameSessionWindow(ticketId: string, state: SessionStateValue): void;
9
8
  /**
10
9
  * Unified helper: reads stdin, extracts repo/ticket, writes state file,
11
- * renames tmux window, runs hook script, then exits.
10
+ * renames the multiplexer window, then exits.
12
11
  */
13
12
  export declare function signalState(state: SessionStateValue): void;
14
13
  export declare function getHooksJson(): Record<string, unknown>;