santree 0.5.6 → 0.6.0

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 (35) hide show
  1. package/dist/commands/dashboard.js +210 -33
  2. package/dist/commands/doctor.js +2 -2
  3. package/dist/commands/helpers/squirrel.d.ts +2 -0
  4. package/dist/commands/helpers/squirrel.js +12 -0
  5. package/dist/commands/worktree/commit.d.ts +9 -1
  6. package/dist/commands/worktree/commit.js +58 -14
  7. package/dist/lib/ai.d.ts +26 -0
  8. package/dist/lib/ai.js +53 -0
  9. package/dist/lib/claude-todos.d.ts +37 -0
  10. package/dist/lib/claude-todos.js +98 -0
  11. package/dist/lib/dashboard/DetailPanel.js +99 -9
  12. package/dist/lib/dashboard/IssueList.js +2 -0
  13. package/dist/lib/dashboard/MultilineTextArea.js +14 -1
  14. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  15. package/dist/lib/dashboard/Overlays.js +75 -2
  16. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
  17. package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
  18. package/dist/lib/dashboard/ReviewList.js +12 -15
  19. package/dist/lib/dashboard/data.js +158 -7
  20. package/dist/lib/dashboard/types.d.ts +45 -5
  21. package/dist/lib/dashboard/types.js +40 -0
  22. package/dist/lib/diff-parse.d.ts +25 -0
  23. package/dist/lib/diff-parse.js +60 -0
  24. package/dist/lib/git.d.ts +22 -0
  25. package/dist/lib/git.js +41 -0
  26. package/dist/lib/github.d.ts +6 -0
  27. package/dist/lib/github.js +29 -0
  28. package/dist/lib/open-url.d.ts +10 -0
  29. package/dist/lib/open-url.js +20 -0
  30. package/dist/lib/squirrel-loader.d.ts +9 -0
  31. package/dist/lib/squirrel-loader.js +322 -0
  32. package/dist/lib/trackers/index.d.ts +13 -0
  33. package/dist/lib/trackers/index.js +19 -0
  34. package/package.json +1 -1
  35. package/prompts/fill-commit.njk +79 -0
@@ -0,0 +1,20 @@
1
+ import { execSync } from "child_process";
2
+ /**
3
+ * Open a URL in the platform's default browser.
4
+ * macOS → `open <url>`
5
+ * else → `xdg-open <url>`
6
+ *
7
+ * Returns true on apparent success, false on failure. Callers decide how to
8
+ * surface failures (e.g. a dashboard action message). Uses execSync with
9
+ * `stdio: "ignore"` — fast, no output leak into the dashboard's alt screen.
10
+ */
11
+ export function openUrl(url) {
12
+ try {
13
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
14
+ execSync(`${cmd} "${url}"`, { stdio: "ignore" });
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ text?: string;
3
+ }
4
+ /** Animated 3D squirrel for loading states. SDF ray-marched at module
5
+ * load with surface-normal lighting, soft shadows, AO, and a rainbow
6
+ * Y-gradient. Body spin + tail wag + breathing/sniff/ear-flick layers
7
+ * combine over independent phases. */
8
+ export default function SquirrelLoader({ text }: Props): import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,322 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ // ─────────────────────────────────────────────────────────────────────────
5
+ // 3D rendered squirrel via SDF + sphere tracing.
6
+ //
7
+ // Anatomy is composed of ellipsoid/sphere/capsule primitives blended with
8
+ // smooth-min so the silhouette looks organic. Per-pixel ray-marching
9
+ // finds the surface; surface normals are finite-differenced. Lighting
10
+ // combines Lambertian diffuse, soft shadows (Inigo Quilez technique),
11
+ // and ambient occlusion. Brightness picks a glyph from a luminance ramp;
12
+ // world-Y position picks a rainbow color band (red top → blue bottom)
13
+ // with shadow/lit two-tone variants.
14
+ //
15
+ // Animation runs on three independent phases per loop (so nothing reads
16
+ // as a single synchronised pendulum):
17
+ // - body angle: 1 full Y-rotation (every face visible)
18
+ // - tail phase: 3 wag cycles
19
+ // - anim phase: 1 cycle, with BodyAnim curves using internal multiples
20
+ // (×5 for ear flicks, ×2 for acorn jiggle) for per-part timing
21
+ // ─────────────────────────────────────────────────────────────────────────
22
+ const W = 100;
23
+ const H = 40;
24
+ const FRAME_COUNT = 36;
25
+ const FRAME_MS = 80;
26
+ const RAMP = ".,-~:;=!*#$@";
27
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
28
+ const addV = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
29
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
30
+ const vlen = (a) => Math.hypot(a[0], a[1], a[2]);
31
+ const vscale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
32
+ function vnorm(a) {
33
+ const l = vlen(a) || 1;
34
+ return [a[0] / l, a[1] / l, a[2] / l];
35
+ }
36
+ // Rotate around Y axis. `c`/`s` are precomputed cos/sin so callers can
37
+ // share them across many evals at the same angle.
38
+ const rotY = (p, c, s) => [c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]];
39
+ // ── SDF primitives ──────────────────────────────────────────────────
40
+ function sdEllipsoid(p, r) {
41
+ const k0 = Math.hypot(p[0] / r[0], p[1] / r[1], p[2] / r[2]);
42
+ const k1 = Math.hypot(p[0] / (r[0] * r[0]), p[1] / (r[1] * r[1]), p[2] / (r[2] * r[2]));
43
+ return (k0 * (k0 - 1)) / Math.max(0.0001, k1);
44
+ }
45
+ const sdSphere = (p, r) => vlen(p) - r;
46
+ function sdCapsule(p, a, b, r) {
47
+ const pa = sub(p, a);
48
+ const ba = sub(b, a);
49
+ const h = Math.max(0, Math.min(1, dot(pa, ba) / dot(ba, ba)));
50
+ return vlen(sub(pa, vscale(ba, h))) - r;
51
+ }
52
+ function smin(a, b, k) {
53
+ const h = Math.max(0, k - Math.abs(a - b)) / k;
54
+ return Math.min(a, b) - h * h * k * 0.25;
55
+ }
56
+ // ── Animation curves ────────────────────────────────────────────────
57
+ // Tail joint base positions. Peak (j5) is at y=2.7, level with the ear
58
+ // tips — tail no longer towers over the silhouette. Tip is well to the
59
+ // left (x=-1.1) and behind (z=0.0) so it stays clear of the head/ears.
60
+ const TAIL_BASES = [
61
+ [-1.4, -0.4, -0.4], // anchor at rump
62
+ [-1.9, 0.3, -0.6],
63
+ [-2.2, 1.1, -0.6],
64
+ [-2.3, 1.9, -0.4], // peak of outward arc
65
+ [-1.9, 2.5, -0.2],
66
+ [-1.1, 2.7, 0.0], // tip — at ear height, behind & left of head
67
+ ];
68
+ // Per-frame tail joints: amplitude scales with joint index (rump stays
69
+ // anchored, tip flicks). Phase shift per joint creates a travelling
70
+ // wave instead of a synchronised wag.
71
+ function computeTailJoints(phase) {
72
+ return TAIL_BASES.map((b, i) => {
73
+ const amp = i / 5;
74
+ const wagX = Math.sin(phase + i * 0.6) * 0.7 * amp;
75
+ const liftY = Math.cos(phase * 0.9 + i * 0.4) * 0.5 * amp;
76
+ return [b[0] + wagX, b[1] + liftY, b[2]];
77
+ });
78
+ }
79
+ function computeBodyAnim(phase) {
80
+ const bobY = Math.sin(phase) * 0.35;
81
+ return {
82
+ bodyBobY: bobY,
83
+ stretchY: bobY * 0.4,
84
+ headExtraY: Math.sin(phase * 1.6 + 0.5) * 0.18,
85
+ headTurnAngle: Math.sin(phase * 0.7) * 0.45,
86
+ headLeanZ: Math.sin(phase * 1.3 + 1.0) * 0.18,
87
+ earLExtraY: Math.max(0, Math.sin(phase * 5)) * 0.35,
88
+ earRExtraY: Math.max(0, Math.sin(phase * 5 + 1.7)) * 0.35,
89
+ snoutWiggleZ: Math.sin(phase * 4) * 0.1,
90
+ pawForwardZ: Math.max(0, Math.sin(phase * 3 - 0.5)) * 0.15,
91
+ acornBobY: Math.sin(phase * 2 + 1) * 0.15,
92
+ };
93
+ }
94
+ // Tail capsule radii from base (rump) to tip. Defined alongside the
95
+ // joint positions so anyone tweaking shape sees both at once.
96
+ const TAIL_RADII = [0.5, 0.6, 0.65, 0.6, 0.45];
97
+ function tailSDF(p, tj) {
98
+ const t1 = sdCapsule(p, tj[0], tj[1], TAIL_RADII[0]);
99
+ const t2 = sdCapsule(p, tj[1], tj[2], TAIL_RADII[1]);
100
+ const t3 = sdCapsule(p, tj[2], tj[3], TAIL_RADII[2]);
101
+ const t4 = sdCapsule(p, tj[3], tj[4], TAIL_RADII[3]);
102
+ const t5 = sdCapsule(p, tj[4], tj[5], TAIL_RADII[4]);
103
+ // Internal smooth-min keeps the tail one continuous bushy curve.
104
+ return smin(smin(smin(t1, t2, 0.3), smin(t3, t4, 0.3), 0.3), t5, 0.3);
105
+ }
106
+ // SDF evaluated in body-local space (point already rotated by camera
107
+ // spin). Returns signed distance to the squirrel surface.
108
+ function squirrelLocal(p, state) {
109
+ const { tj, anim } = state;
110
+ // Whole-body bob: shift the eval point down by bodyBobY, equivalent
111
+ // to moving the body up by bodyBobY. The tail uses ps too so it
112
+ // bobs with the body.
113
+ const ps = [p[0], p[1] - anim.bodyBobY, p[2]];
114
+ // Volume-preserving squash & stretch: Y grows when stretchY > 0,
115
+ // X/Z shrink slightly to compensate.
116
+ const sy = 1 + anim.stretchY;
117
+ const sxz = 1 - anim.stretchY * 0.5;
118
+ // Pear-shape body: wider haunches, narrower chest.
119
+ const haunches = sdEllipsoid(sub(ps, [0, -0.3, 0]), [1.2 * sxz, 1.0 * sy, 1.0 * sxz]);
120
+ const chest = sdEllipsoid(sub(ps, [0, 0.7, 0.1]), [0.9 * sxz, 0.9 * sy, 0.85 * sxz]);
121
+ const body = smin(haunches, chest, 0.3);
122
+ // Head primitives evaluated in a head-local frame: rotate the eval
123
+ // point around a neck pivot by -headTurnAngle so the head appears
124
+ // to look L/R while the body stays straight.
125
+ const headPivot = [0, 1.4, 0.2];
126
+ const cosTurn = Math.cos(anim.headTurnAngle);
127
+ const sinTurn = Math.sin(anim.headTurnAngle);
128
+ const psHead = addV(rotY(sub(ps, headPivot), cosTurn, sinTurn), headPivot);
129
+ const lean = anim.headLeanZ;
130
+ const head = sdEllipsoid(sub(psHead, [0, 1.85 + anim.headExtraY, 0.4 + lean]), [0.7, 0.65, 0.7]);
131
+ const snout = sdEllipsoid(sub(psHead, [0, 1.65 + anim.headExtraY, 1.0 + lean + anim.snoutWiggleZ]), [0.32, 0.28, 0.4]);
132
+ const earL = sdEllipsoid(sub(psHead, [-0.45, 2.7 + anim.earLExtraY, 0.25 + lean]), [0.16, 0.45, 0.18]);
133
+ const earR = sdEllipsoid(sub(psHead, [0.45, 2.7 + anim.earRExtraY, 0.25 + lean]), [0.16, 0.45, 0.18]);
134
+ // Paws + acorn shift forward together (nibble lunge).
135
+ const pe = anim.pawForwardZ;
136
+ const pawL = sdSphere(sub(ps, [-0.45, 0.7, 0.95 + pe]), 0.3);
137
+ const pawR = sdSphere(sub(ps, [0.45, 0.7, 0.95 + pe]), 0.3);
138
+ const acorn = sdEllipsoid(sub(ps, [0, 0.55 + anim.acornBobY, 1.15 + pe]), [0.22, 0.28, 0.22]);
139
+ const tail = tailSDF(ps, tj);
140
+ let d = body;
141
+ d = smin(d, head, 0.25);
142
+ d = smin(d, snout, 0.2);
143
+ d = Math.min(d, earL); // hard union for ears (keeps them pointy)
144
+ d = Math.min(d, earR);
145
+ d = smin(d, pawL, 0.2);
146
+ d = smin(d, pawR, 0.2);
147
+ d = Math.min(d, acorn);
148
+ d = Math.min(d, tail); // hard union: tail is its own visible silhouette
149
+ return d;
150
+ }
151
+ // SDF at a world-space point: rotates into body-local frame, evaluates.
152
+ function evalAt(p, state) {
153
+ return squirrelLocal(rotY(p, state.cosA, state.sinA), state);
154
+ }
155
+ function calcNormal(p, state) {
156
+ const e = 0.01;
157
+ return vnorm([
158
+ evalAt(addV(p, [e, 0, 0]), state) - evalAt(addV(p, [-e, 0, 0]), state),
159
+ evalAt(addV(p, [0, e, 0]), state) - evalAt(addV(p, [0, -e, 0]), state),
160
+ evalAt(addV(p, [0, 0, e]), state) - evalAt(addV(p, [0, 0, -e]), state),
161
+ ]);
162
+ }
163
+ // Soft shadow via light-direction march (Inigo Quilez technique). The
164
+ // closest miss along the ray gives a partial shadow; the k=10 multiplier
165
+ // controls penumbra softness.
166
+ function softShadow(origin, dir, state) {
167
+ let res = 1.0;
168
+ let t = 0.05; // step off the surface to avoid self-occlusion
169
+ for (let step = 0; step < 20; step++) {
170
+ const sp = [origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t];
171
+ const d = evalAt(sp, state);
172
+ if (d < 0.001)
173
+ return 0;
174
+ res = Math.min(res, (10 * d) / t);
175
+ t += d;
176
+ if (t > 8)
177
+ break;
178
+ }
179
+ return Math.max(0, Math.min(1, res));
180
+ }
181
+ // Cheap ambient occlusion: sample SDF along the surface normal at
182
+ // increasing distances. Concave creases (ear bases, where tail meets
183
+ // body, under the chin) get darker because nearby geometry blocks the
184
+ // ambient light.
185
+ function ao(p, n, state) {
186
+ let occ = 0;
187
+ let sca = 1.0;
188
+ for (let i = 0; i < 5; i++) {
189
+ const h = 0.04 + 0.12 * i;
190
+ const sp = [p[0] + n[0] * h, p[1] + n[1] * h, p[2] + n[2] * h];
191
+ const d = evalAt(sp, state);
192
+ occ += (h - d) * sca;
193
+ sca *= 0.85;
194
+ }
195
+ return Math.max(0, Math.min(1, 1.0 - 0.8 * occ));
196
+ }
197
+ // ── Frame construction ──────────────────────────────────────────────
198
+ const LIGHT = vnorm([0.5, 0.6, -0.6]);
199
+ // Viewport in world space (orthographic). The squirrel spans roughly
200
+ // y=-1 (paws) to y=4.5 (ear tips), x=-2.5 (tail) to x=1.5 (snout).
201
+ const VIEW_X_MIN = -3.5;
202
+ const VIEW_X_MAX = 3.5;
203
+ const VIEW_Y_MID = 1.75;
204
+ const VIEW_Y_HALF = 2.75;
205
+ const RAY_MAX_T = 12;
206
+ const RAY_MAX_STEPS = 60;
207
+ const HIT_EPS = 0.01;
208
+ // Ambient floor keeps shadow regions clearly visible. A floor of 0.12
209
+ // produced internal "holes" in the silhouette that broke the shape.
210
+ const AMBIENT = 0.35;
211
+ const EMPTY_CELL = { ch: " ", y: 0, lit: 0 };
212
+ function buildFrame(angle, tailPhase, animPhase) {
213
+ const state = {
214
+ cosA: Math.cos(angle),
215
+ sinA: Math.sin(angle),
216
+ tj: computeTailJoints(tailPhase),
217
+ anim: computeBodyAnim(animPhase),
218
+ };
219
+ const grid = Array.from({ length: H }, () => Array.from({ length: W }, () => ({ ...EMPTY_CELL })));
220
+ for (let py = 0; py < H; py++) {
221
+ for (let px = 0; px < W; px++) {
222
+ const xw = VIEW_X_MIN + (px / (W - 1)) * (VIEW_X_MAX - VIEW_X_MIN);
223
+ const yw = VIEW_Y_MID + (1 - (py / (H - 1)) * 2) * VIEW_Y_HALF;
224
+ // Sphere-trace from in front of the model toward +Z (orthographic).
225
+ let t = 0;
226
+ let p = [xw, yw, -5];
227
+ let hit = false;
228
+ for (let step = 0; step < RAY_MAX_STEPS; step++) {
229
+ p = [xw, yw, -5 + t];
230
+ const d = evalAt(p, state);
231
+ if (d < HIT_EPS) {
232
+ hit = true;
233
+ break;
234
+ }
235
+ if (t > RAY_MAX_T)
236
+ break;
237
+ t += d;
238
+ }
239
+ if (!hit)
240
+ continue;
241
+ const n = calcNormal(p, state);
242
+ const diffuse = Math.max(0, dot(n, LIGHT));
243
+ // Offset shadow ray origin along the normal so we don't
244
+ // immediately re-hit the surface we just landed on.
245
+ const shadowOrigin = [p[0] + n[0] * 0.03, p[1] + n[1] * 0.03, p[2] + n[2] * 0.03];
246
+ const sh = softShadow(shadowOrigin, LIGHT, state);
247
+ const occ = ao(p, n, state);
248
+ const lit = Math.min(1, AMBIENT + (1 - AMBIENT) * diffuse * sh * occ);
249
+ const idx = Math.min(RAMP.length - 1, Math.floor(lit * 1.3 * RAMP.length));
250
+ // World Y = p[1] (rotY only rotates around Y, so Y is preserved).
251
+ grid[py][px] = { ch: RAMP[idx], y: p[1], lit };
252
+ }
253
+ }
254
+ return grid;
255
+ }
256
+ // Pre-compute every frame at module load; render path stays at zero
257
+ // math per tick.
258
+ const FRAMES = (() => {
259
+ const out = [];
260
+ for (let i = 0; i < FRAME_COUNT; i++) {
261
+ const t = i / FRAME_COUNT;
262
+ const angle = t * Math.PI * 2;
263
+ const tailPhase = t * Math.PI * 6; // 3 wag cycles per loop
264
+ const animPhase = t * Math.PI * 2;
265
+ out.push(buildFrame(angle, tailPhase, animPhase));
266
+ }
267
+ return out;
268
+ })();
269
+ // ── Color rendering ─────────────────────────────────────────────────
270
+ // Each band has lit/shadow variants of the same hue. Bundling them
271
+ // into one array means adding a band is a single-line change.
272
+ const COLOR_BANDS = [
273
+ { lit: "redBright", shadow: "red" }, // top of head/ears
274
+ { lit: "yellowBright", shadow: "yellow" },
275
+ { lit: "yellowBright", shadow: "yellow" },
276
+ { lit: "greenBright", shadow: "green" },
277
+ { lit: "cyanBright", shadow: "cyan" },
278
+ { lit: "cyanBright", shadow: "cyan" },
279
+ { lit: "blueBright", shadow: "blue" }, // bottom (paws/feet)
280
+ ];
281
+ // World Y range mapped onto bands. Squirrel spans roughly y=-1..3.5.
282
+ const COLOR_Y_MIN = -1;
283
+ const COLOR_Y_RANGE = 4.5;
284
+ const SHADOW_THRESHOLD = 0.5;
285
+ function colorForCell(y, lit) {
286
+ const t = Math.max(0, Math.min(0.999, (y - COLOR_Y_MIN) / COLOR_Y_RANGE));
287
+ const idx = Math.min(COLOR_BANDS.length - 1, Math.max(0, Math.floor((1 - t) * COLOR_BANDS.length)));
288
+ const band = COLOR_BANDS[idx];
289
+ return lit < SHADOW_THRESHOLD ? band.shadow : band.lit;
290
+ }
291
+ /** Animated 3D squirrel for loading states. SDF ray-marched at module
292
+ * load with surface-normal lighting, soft shadows, AO, and a rainbow
293
+ * Y-gradient. Body spin + tail wag + breathing/sniff/ear-flick layers
294
+ * combine over independent phases. */
295
+ export default function SquirrelLoader({ text }) {
296
+ const [frame, setFrame] = useState(0);
297
+ useEffect(() => {
298
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), FRAME_MS);
299
+ return () => clearInterval(id);
300
+ }, []);
301
+ // Coalesce contiguous same-color cells into single Text spans —
302
+ // without this we'd hand Ink ~4000 nodes per frame and stutter.
303
+ const rows = useMemo(() => {
304
+ const grid = FRAMES[frame];
305
+ return grid.map((row) => {
306
+ const spans = [];
307
+ let cur = null;
308
+ for (const cell of row) {
309
+ const color = cell.ch === " " ? undefined : colorForCell(cell.y, cell.lit);
310
+ if (!cur || cur.color !== color) {
311
+ cur = { text: cell.ch, color };
312
+ spans.push(cur);
313
+ }
314
+ else {
315
+ cur.text += cell.ch;
316
+ }
317
+ }
318
+ return spans;
319
+ });
320
+ }, [frame]);
321
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [rows.map((spans, i) => (_jsx(Box, { children: _jsx(Text, { children: spans.map((s, j) => (_jsx(Text, { color: s.color, children: s.text }, j))) }) }, i))), text && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }))] }));
322
+ }
@@ -10,3 +10,16 @@ export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.j
10
10
  */
11
11
  export declare function getIssueTracker(repoRoot: string | null): IssueTracker;
12
12
  export declare function getActiveTrackerKind(repoRoot: string | null): IssueTrackerKind;
13
+ /**
14
+ * Trackers worth trying when resolving a ticket from a foreign PR branch.
15
+ *
16
+ * The active tracker is always first. When the active tracker is GitHub but
17
+ * the user has stored Linear credentials, Linear is appended as a fallback —
18
+ * the typical reviewer scenario is a santree-managed repo (active tracker:
19
+ * GitHub) reviewing PRs from a Linear-driven team where branches encode
20
+ * `TEAM-1234`-style IDs that GitHub's parser ignores.
21
+ *
22
+ * Only used by features that look at OTHER people's branches (like the
23
+ * reviews tab). Per-repo flows still use the active tracker exclusively.
24
+ */
25
+ export declare function getCandidateTrackers(repoRoot: string | null): IssueTracker[];
@@ -32,3 +32,22 @@ export function getIssueTracker(repoRoot) {
32
32
  export function getActiveTrackerKind(repoRoot) {
33
33
  return getIssueTracker(repoRoot).kind;
34
34
  }
35
+ /**
36
+ * Trackers worth trying when resolving a ticket from a foreign PR branch.
37
+ *
38
+ * The active tracker is always first. When the active tracker is GitHub but
39
+ * the user has stored Linear credentials, Linear is appended as a fallback —
40
+ * the typical reviewer scenario is a santree-managed repo (active tracker:
41
+ * GitHub) reviewing PRs from a Linear-driven team where branches encode
42
+ * `TEAM-1234`-style IDs that GitHub's parser ignores.
43
+ *
44
+ * Only used by features that look at OTHER people's branches (like the
45
+ * reviews tab). Per-repo flows still use the active tracker exclusively.
46
+ */
47
+ export function getCandidateTrackers(repoRoot) {
48
+ const active = getIssueTracker(repoRoot);
49
+ if (active.kind === "github" && Object.keys(readLinearAuthStore()).length > 0) {
50
+ return [active, linearTracker];
51
+ }
52
+ return [active];
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -0,0 +1,79 @@
1
+ You are writing a Git commit message. Output ONLY the message string itself — no preamble, no quotes, no markdown, no explanation.
2
+
3
+ # Format rules
4
+
5
+ The message is **a single line** — no newlines, no body paragraphs.
6
+
7
+ If the diff covers multiple changes, list each one as a brief phrase joined by a `+`. Each phrase is one short imperative clause — never a sentence.
8
+
9
+ ```
10
+ [ABC-1] add login throttling + fix token refresh race + rename UserService
11
+ ```
12
+
13
+ If the diff is one change, write one phrase, no separator.
14
+
15
+ ```
16
+ [ABC-1] add login throttling
17
+ ```
18
+
19
+ # Per-phrase rules (apply to every clause in the message)
20
+
21
+ - Imperative mood: "add", "fix", "update", "rename", "remove" — NEVER "added", "fixing", "updates".
22
+ - Lowercase first letter (after any [TICKET] prefix).
23
+ - Concise — describe the change in 3–8 words. No "and that does X also" hedging.
24
+ - No trailing period. No wrapping quotes.
25
+ {% if ticket_id %}- The whole message is prefixed once: `[{{ ticket_id }}] ` (square brackets, single space). The prefix is not repeated per clause.{% endif %}
26
+
27
+ # What to include vs drop
28
+
29
+ Include user-facing changes — new commands, new keybindings, new UI surfaces, user-visible bugfixes, behaviour changes a user would notice.
30
+
31
+ Drop or merge into a parent phrase:
32
+ - Pure type/interface refactors that exist only to support an included feature
33
+ - Internal helper extractions (e.g. "extract X into util") unless they ARE the entire commit
34
+ - Prompt tweaks that go with a feature you already mentioned
35
+ - Dependency bumps from `npm install` triggered by an included feature
36
+
37
+ If the diff is genuinely 100% internal (refactor commit, no user-facing change), write one phrase about the structural change.
38
+
39
+ # Good examples
40
+
41
+ {% if ticket_id %}[{{ ticket_id }}] add dashboard help overlay + show main repo row + draft commit messages with claude
42
+ [{{ ticket_id }}] fix tail rendering in squirrel loader + speed up frame generation
43
+ [{{ ticket_id }}] rename UserService to AccountService{% else %}add dashboard help overlay + show main repo row + draft commit messages with claude
44
+ fix tail rendering in squirrel loader + speed up frame generation
45
+ rename UserService to AccountService{% endif %}
46
+
47
+ # Bad examples (do NOT do this)
48
+
49
+ - `Added the help overlay.` ← past tense + period
50
+ - `add the help overlay and also fix the squirrel tail` ← prose-y "and" + "also"; use `+`
51
+ - `add help overlay\nfix tail rendering` ← multi-line; must be a single line
52
+ - `"add help overlay"` ← wrapped in quotes
53
+ - `add help overlay, fix tail, refactor types, bump deps` ← drop "refactor types" and "bump deps" if they support a listed feature
54
+
55
+ # Context
56
+
57
+ Branch: {{ branch_name }}
58
+ {% if ticket_id %}Ticket: {{ ticket_id }}{% endif %}
59
+ {% if ticket_content %}
60
+ {{ ticket_content }}
61
+ {% endif %}
62
+
63
+ # Staged diff
64
+
65
+ ```
66
+ {{ diff_content }}
67
+ ```
68
+
69
+ # Final self-check
70
+
71
+ 1. Is the message a single line (no newlines)?
72
+ 2. Does each clause start with an imperative verb (add/fix/update/…)?
73
+ 3. Are clauses separated by ` + ` (with spaces around the plus)?
74
+ 4. Have I dropped clauses that just support another included clause?
75
+ 5. Is each clause concise (under ~8 words)?
76
+
77
+ # Output
78
+
79
+ Output ONLY the final commit message string. Nothing before, nothing after.