santree 0.7.2 → 0.7.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.
@@ -10,7 +10,8 @@ const require = createRequire(import.meta.url);
10
10
  const { version } = require("../../package.json");
11
11
  import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
12
12
  import { run, spawnAsync } from "../lib/exec.js";
13
- import { resolveAgentBinary, fillCommitMessage, askTicketQuestion } from "../lib/ai.js";
13
+ import { resolveAgentBinary, resolveClaudeBinary, fillCommitMessage, askTicketQuestion, } from "../lib/ai.js";
14
+ import { readTriageInvestigateConfig, isTriageInvestigateConfigured, buildInvestigatePrompt, buildInvestigateCommand, } from "../lib/triage-config.js";
14
15
  import { getInstalledClaudeVersion } from "../lib/version.js";
15
16
  import { extractTicketId, getStagedDiffContent } from "../lib/git.js";
16
17
  import { getMultiplexer } from "../lib/multiplexer/index.js";
@@ -325,6 +326,11 @@ export default function Dashboard() {
325
326
  // Triage tab appears at all. Recomputed on every data refresh — feature
326
327
  // detection via `tracker.supportsTriage`, never a kind check.
327
328
  const [supportsTriage, setSupportsTriage] = useState(false);
329
+ // Whether `.santree/metadata.json` configures an "investigate triage ticket"
330
+ // skill/prompt (`_triage.skill_name` / `_triage.prompt`). Drives whether the
331
+ // Triage `[i]` action renders enabled (cyan) or greyed. Read fresh on every
332
+ // data refresh so manual edits to metadata.json show up within a cycle.
333
+ const [triageInvestigateConfigured, setTriageInvestigateConfigured] = useState(false);
328
334
  const refreshTimerRef = useRef(null);
329
335
  const repoRootRef = useRef(null);
330
336
  const stateRef = useRef(state);
@@ -440,6 +446,7 @@ export default function Dashboard() {
440
446
  setHasWorkspaceFile(hasWs);
441
447
  const tracker = getIssueTracker(repoRoot);
442
448
  setSupportsTriage(tracker.supportsTriage === true);
449
+ setTriageInvestigateConfigured(isTriageInvestigateConfigured(readTriageInvestigateConfig(repoRoot)));
443
450
  dispatch({ type: "SET_DATA", ...data });
444
451
  dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
445
452
  // Triage on-call rotations (Linear). Best-effort, non-blocking — never
@@ -2658,6 +2665,43 @@ export default function Dashboard() {
2658
2665
  dispatch({ type: "TRIAGE_ASK_OPEN", ticketId: di.issue.identifier });
2659
2666
  return;
2660
2667
  }
2668
+ // Investigate — hand the ticket to a user-configured skill/prompt in a
2669
+ // new multiplexer window. Config lives in `.santree/metadata.json`
2670
+ // (`_triage.skill_name` or `_triage.prompt`); read fresh so manual
2671
+ // edits take effect without waiting for a refresh.
2672
+ if (input === "i") {
2673
+ const root = repoRootRef.current;
2674
+ if (!root)
2675
+ return;
2676
+ const prompt = buildInvestigatePrompt(readTriageInvestigateConfig(root), di.issue.identifier);
2677
+ if (!prompt) {
2678
+ dispatch({
2679
+ type: "SET_ACTION_MESSAGE",
2680
+ message: "Set _triage.skill_name (or _triage.prompt) in .santree/metadata.json to enable investigate",
2681
+ });
2682
+ return;
2683
+ }
2684
+ const mux = getMultiplexer();
2685
+ if (!mux.isActive()) {
2686
+ dispatch({
2687
+ type: "SET_ACTION_MESSAGE",
2688
+ message: "Investigate needs a multiplexer (tmux or cmux)",
2689
+ });
2690
+ return;
2691
+ }
2692
+ const command = buildInvestigateCommand(resolveClaudeBinary() ?? "claude", prompt);
2693
+ const windowName = `investigate-${di.issue.identifier}`;
2694
+ void (async () => {
2695
+ const created = await mux.createWindow({ name: windowName, cwd: root, command });
2696
+ dispatch({
2697
+ type: "SET_ACTION_MESSAGE",
2698
+ message: created.ok
2699
+ ? "Launched investigation in new window"
2700
+ : `Failed to launch investigation${created.message ? `: ${created.message}` : ""}`,
2701
+ });
2702
+ })();
2703
+ return;
2704
+ }
2661
2705
  if (input === "o") {
2662
2706
  if (!di.issue.url) {
2663
2707
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
@@ -3108,14 +3152,14 @@ export default function Dashboard() {
3108
3152
  ? state.treeDetailScrollOffset
3109
3153
  : state.activeTab === "triage"
3110
3154
  ? state.triageDetailScrollOffset
3111
- : state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true }) })] })) })] })] }));
3155
+ : state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true, triageInvestigateConfigured: triageInvestigateConfigured }) })] })) })] })] }));
3112
3156
  }
3113
3157
  /**
3114
3158
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
3115
3159
  * lifted out of the detail panels so they sit on the same row as the global
3116
3160
  * command bar. Empty when nothing is selected.
3117
3161
  */
3118
- function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, }) {
3162
+ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, triageInvestigateConfigured, }) {
3119
3163
  // During the diff overlay, none of the per-issue actions apply (View diff
3120
3164
  // is what got us here, Commit/PR/etc. need the detail panel context). Keep
3121
3165
  // the row blank so the diff-specific CommandBar reads cleanly.
@@ -3126,7 +3170,11 @@ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerN
3126
3170
  ? buildReviewActions(selectedReview)
3127
3171
  : []
3128
3172
  : selectedIssue
3129
- ? buildIssueActions(selectedIssue, trackerName, { tab: activeTab, canMutate })
3173
+ ? buildIssueActions(selectedIssue, trackerName, {
3174
+ tab: activeTab,
3175
+ canMutate,
3176
+ triageInvestigateConfigured,
3177
+ })
3130
3178
  : [];
3131
3179
  if (items.length === 0)
3132
3180
  return _jsx(Text, { children: " " });
@@ -37,6 +37,7 @@ export type IssueActionItem = {
37
37
  export declare function buildIssueActions(di: DashboardIssue, trackerName: string, opts?: {
38
38
  tab?: DashboardTab;
39
39
  canMutate?: boolean;
40
+ triageInvestigateConfigured?: boolean;
40
41
  }): IssueActionItem[];
41
42
  export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
42
43
  export {};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { formatDueDate } from "./due.js";
3
+ import { formatSla, isSnoozed } from "./sla.js";
4
4
  import { issueReadiness } from "../trackers/types.js";
5
5
  function stateColor(type) {
6
6
  switch (type) {
@@ -85,6 +85,13 @@ export function buildIssueActions(di, trackerName, opts) {
85
85
  if (opts?.tab === "triage") {
86
86
  items.push({ key: "w", label: "Send to tree", color: "cyan" });
87
87
  items.push({ key: "a", label: "Ask", color: "cyan" });
88
+ // Greyed until `_triage.skill_name`/`_triage.prompt` is set in
89
+ // `.santree/metadata.json` — pressing it then just prints a hint.
90
+ items.push({
91
+ key: "i",
92
+ label: "Investigate",
93
+ color: opts.triageInvestigateConfigured ? "cyan" : "gray",
94
+ });
88
95
  items.push({ key: "s", label: "Schedule", color: "cyan" });
89
96
  if (issue.url) {
90
97
  items.push({ key: "o", label: trackerName, color: "gray" });
@@ -210,30 +217,38 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
210
217
  // ── Hero: identifier + title, then a status pill row ───────────────
211
218
  lines.push({ text: `${li.identifier} ${li.title}`, bold: true });
212
219
  const sc = stateColor(li.state.type);
213
- const heroSegs = [
214
- { text: "● ", color: sc },
215
- { text: li.state.name, color: sc },
216
- { text: " · ", dim: true },
217
- { text: li.priorityLabel },
218
- ];
220
+ const heroSegs = [];
221
+ // On the Triage tab every issue is in the "Triage" state by definition, so the
222
+ // status pill is noise — lead with priority instead.
223
+ if (!triage) {
224
+ heroSegs.push({ text: "● ", color: sc }, { text: li.state.name, color: sc }, { text: " · ", dim: true });
225
+ }
226
+ heroSegs.push({ text: li.priorityLabel });
219
227
  if (li.labels.length > 0) {
220
228
  heroSegs.push({ text: " · ", dim: true });
221
229
  heroSegs.push({ text: li.labels.join(", "), dim: true });
222
230
  }
223
231
  lines.push({ text: "", segments: heroSegs });
224
- // ── Due date ──────────────────────────────────────────────────────
225
- // Urgency-coded; shown whenever the issue carries one (most relevant on the
226
- // Triage tab, harmless elsewhere).
227
- const due = formatDueDate(li.dueDate);
228
- if (due) {
232
+ // ── SLA countdown ─────────────────────────────────────────────────
233
+ // Urgency-coded time-to-breach; shown whenever the issue carries an SLA
234
+ // (Triage tab). Snoozed issues are parked, so render greyed with a marker.
235
+ const sla = formatSla(li.slaBreachesAt);
236
+ const snoozed = isSnoozed(li.snoozedUntilAt);
237
+ if (sla) {
238
+ const col = snoozed ? "gray" : sla.color;
239
+ const bold = !snoozed && sla.urgent;
229
240
  lines.push({
230
241
  text: "",
231
242
  segments: [
232
- { text: "◷ ", color: due.color, bold: due.urgent },
233
- { text: due.label, color: due.color, bold: due.urgent },
243
+ { text: "◷ ", color: col, bold },
244
+ { text: sla.label, color: col, bold },
245
+ ...(snoozed ? [{ text: " · snoozed", color: "gray" }] : []),
234
246
  ],
235
247
  });
236
248
  }
249
+ else if (snoozed) {
250
+ lines.push({ text: "", segments: [{ text: "◷ snoozed", color: "gray" }] });
251
+ }
237
252
  // ── Description ───────────────────────────────────────────────────
238
253
  if (li.description) {
239
254
  lines.push({ text: "" });
@@ -10,7 +10,7 @@ interface Props {
10
10
  selectionBg?: string;
11
11
  /** Right-column variant (row structure — and click→row mapping — is identical):
12
12
  * "default" — WT + CI status columns (Trees tab)
13
- * "triage" — a colored due-date badge
13
+ * "triage" — a colored SLA-countdown badge
14
14
  * "issues" — a readiness glyph (ready / blocked by dependencies) */
15
15
  variant?: "default" | "triage" | "issues";
16
16
  /** Ticket ids whose worktree is currently being removed — shown with a
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { formatDueDate } from "./due.js";
3
+ import { formatSla, isSnoozed } from "./sla.js";
4
4
  import { issueReadiness } from "../trackers/types.js";
5
5
  function stateColor(type, name) {
6
6
  const n = name?.toLowerCase();
@@ -73,7 +73,13 @@ export function buildIssueListRows(groups, flatIssues) {
73
73
  rows.push({ kind: "spacer" });
74
74
  rows.push({ kind: "header", name: group.name, count: totalIssues, isFirst: gi === 0 });
75
75
  for (const sg of group.statusGroups) {
76
- rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
76
+ // A blank status name suppresses the per-status sub-header (used by the
77
+ // Triage tab, where the single group needs no "Triage" header). Both the
78
+ // renderer and the click→row mapper call this builder, so skipping a row
79
+ // here keeps their indices aligned automatically.
80
+ if (sg.name) {
81
+ rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
82
+ }
77
83
  for (const di of sg.issues) {
78
84
  pushIssueWithChildren(di, 0);
79
85
  }
@@ -87,9 +93,9 @@ export function buildIssueListRows(groups, flatIssues) {
87
93
  // Glyphs are 1 char and rendered right-aligned within their column.
88
94
  const LEFT_FIXED = 1 + 1 + 1 + 2 + 11; // 16 — left-aligned columns
89
95
  const RIGHT_FIXED = 2 + 2 + 2; // 6 — WT + 2 spaces + CI
90
- // Triage variant: a single right-aligned due-date column. Widest badge is
91
- // "⚠ overdue 99d" (13 chars); pad/clamp everything to this so titles align.
92
- const DUE_COL_WIDTH = 13;
96
+ // Triage variant: a single right-aligned SLA-countdown column. Widest badge is
97
+ // "breached" (8 chars); pad/clamp everything to this so titles align.
98
+ const SLA_COL_WIDTH = 8;
93
99
  // Issues variant: a single readiness glyph under a "RDY" header.
94
100
  const READY_COL_WIDTH = 3;
95
101
  const TITLE_GAP = 2; // minimum spacing between title and the right columns
@@ -106,7 +112,7 @@ function readinessGlyph(di) {
106
112
  export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, }) {
107
113
  const isTriage = variant === "triage";
108
114
  const isIssues = variant === "issues";
109
- const rightFixed = isTriage ? DUE_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
115
+ const rightFixed = isTriage ? SLA_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
110
116
  const rows = buildIssueListRows(groups, flatIssues);
111
117
  const visible = rows.slice(scrollOffset, scrollOffset + height);
112
118
  const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - rightFixed - TITLE_GAP, 10);
@@ -121,7 +127,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
121
127
  // Label "WT CI" is 5 chars; the "W" lines up with the WT
122
128
  // glyph at column (width - RIGHT_FIXED + 1).
123
129
  const namePart = `${row.name} ${row.count}`;
124
- const labelText = isTriage ? "DUE" : isIssues ? "RDY" : "WT CI";
130
+ const labelText = isTriage ? "SLA" : isIssues ? "RDY" : "WT CI";
125
131
  const labelPad = row.isFirst
126
132
  ? Math.max(2, width - namePart.length - labelText.length)
127
133
  : 0;
@@ -133,8 +139,11 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
133
139
  const { issue, flatIndex, depth } = row;
134
140
  const selected = flatIndex === selectedIndex;
135
141
  const di = issue;
136
- const sc = stateColor(di.issue.state.type, di.issue.state.name);
137
- const prio = priorityMarker(di.issue.priority);
142
+ // Snoozed triage rows are parked work: grey them out (dot + id +
143
+ // title + SLA) so the active items the user should pick up stand out.
144
+ const snoozed = isTriage && isSnoozed(di.issue.snoozedUntilAt);
145
+ const sc = snoozed ? "gray" : stateColor(di.issue.state.type, di.issue.state.name);
146
+ const prio = snoozed ? { glyph: " ", color: "gray" } : priorityMarker(di.issue.priority);
138
147
  const isDeleting = deletingIds?.has(di.issue.identifier) ?? false;
139
148
  const work = isDeleting ? { glyph: "⌫", color: "yellow" } : workIndicator(di.worktree);
140
149
  const ci = ciIndicator(di.checks);
@@ -147,12 +156,13 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
147
156
  // Pad between title and the right columns so the markers stay
148
157
  // pinned to the right edge regardless of title length.
149
158
  const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - rightFixed);
150
- // Triage variant: a single right-aligned due-date badge in place
151
- // of the WT/CI columns.
152
- const due = isTriage ? formatDueDate(di.issue.dueDate) : null;
153
- const dueText = (due?.label ?? "").padStart(DUE_COL_WIDTH).slice(-DUE_COL_WIDTH);
159
+ // Triage variant: a single right-aligned SLA-countdown badge in
160
+ // place of the WT/CI columns. Snoozed rows show it greyed.
161
+ const sla = isTriage ? formatSla(di.issue.slaBreachesAt) : null;
162
+ const slaColor = snoozed ? "gray" : sla?.color;
163
+ const slaText = (sla?.label ?? "").padStart(SLA_COL_WIDTH).slice(-SLA_COL_WIDTH);
154
164
  // Issues variant: a single readiness glyph right-aligned under "RDY".
155
165
  const ready = isIssues ? readinessGlyph(di) : null;
156
- return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), isTriage ? (_jsx(Text, { backgroundColor: bg, color: due?.color, bold: due?.urgent, children: dueText })) : isIssues ? (_jsx(Text, { backgroundColor: bg, color: ready?.color, children: ` ${ready?.glyph ?? " "}` })) : (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }))] }, di.issue.identifier));
166
+ return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, color: snoozed ? "gray" : undefined, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), isTriage ? (_jsx(Text, { backgroundColor: bg, color: slaColor, bold: !snoozed && sla?.urgent, children: slaText })) : isIssues ? (_jsx(Text, { backgroundColor: bg, color: ready?.color, children: ` ${ready?.glyph ?? " "}` })) : (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }))] }, di.issue.identifier));
157
167
  }) }) }));
158
168
  }
@@ -34,7 +34,7 @@ const LEGEND = [
34
34
  {
35
35
  glyph: "Triage",
36
36
  color: "cyan",
37
- meaning: "Incoming inbox — due dates + comments (Linear only)",
37
+ meaning: "Incoming inbox — SLA countdown + comments (Linear only)",
38
38
  },
39
39
  { glyph: "Issues", color: "cyan", meaning: "Backlog / planning" },
40
40
  { glyph: "Trees", color: "cyan", meaning: "Worktrees in progress" },
@@ -45,6 +45,11 @@ const LEGEND = [
45
45
  { glyph: "d", color: "red", meaning: "Delete issue (built-in) / remove worktree (Trees)" },
46
46
  { glyph: "w", color: "cyan", meaning: "Start work / send to tree (creates a worktree)" },
47
47
  { glyph: "a", color: "cyan", meaning: "Ask Claude about the issue + comments (Triage tab)" },
48
+ {
49
+ glyph: "i",
50
+ color: "cyan",
51
+ meaning: "Investigate ticket via your configured skill/prompt (Triage tab)",
52
+ },
48
53
  { glyph: "s", color: "cyan", meaning: "Triage on-call schedule (Triage tab)" },
49
54
  ],
50
55
  },
@@ -63,8 +68,9 @@ const LEGEND = [
63
68
  { glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
64
69
  { glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
65
70
  { glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
66
- { glyph: "◷", color: "red", meaning: "DUE column (Triage): overdue / due today" },
67
- { glyph: "◷", color: "yellow", meaning: "DUE column (Triage): due within 2 days" },
71
+ { glyph: "◷", color: "red", meaning: "SLA column (Triage): breached / under a day" },
72
+ { glyph: "◷", color: "yellow", meaning: "SLA column (Triage): under two days" },
73
+ { glyph: "●", color: "gray", meaning: "Triage row greyed: snoozed (parked, sunk to bottom)" },
68
74
  { glyph: "✓", color: "green", meaning: "RDY column (Issues): ready — no open blockers" },
69
75
  {
70
76
  glyph: "⊘",
@@ -3,6 +3,7 @@ import { runAsync } from "../exec.js";
3
3
  import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
4
4
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
5
5
  import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
6
+ import { isSnoozed } from "./sla.js";
6
7
  export async function loadDashboardData(repoRoot) {
7
8
  // Fetch issues and worktrees in parallel
8
9
  const tracker = getIssueTracker(repoRoot);
@@ -266,24 +267,36 @@ export async function loadDashboardData(repoRoot) {
266
267
  const triageIssues = enriched.filter(isTriage);
267
268
  const backlogIssues = enriched.filter((di) => !di.worktree && !isTriage(di));
268
269
  const treeIssues = enriched.filter((di) => di.worktree);
269
- // Surface the most pressing triage items first: by due date ascending
270
- // (overdue/soonest first), undated last. `dueDate` is `YYYY-MM-DD` so a
271
- // plain string compare is chronological. buildProjectGroups preserves this
272
- // order within each status group.
270
+ // Order the triage inbox so the work that needs attention now is on top:
271
+ // active (non-snoozed) items first, snoozed parked at the bottom; within
272
+ // each, by SLA breach time ascending (breached/soonest first), SLA-less last.
273
+ const slaRank = (di) => {
274
+ const s = di.issue.slaBreachesAt;
275
+ const t = s ? Date.parse(s) : NaN;
276
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
277
+ };
273
278
  triageIssues.sort((a, b) => {
274
- const da = a.issue.dueDate ?? null;
275
- const db = b.issue.dueDate ?? null;
276
- if (da && db)
277
- return da < db ? -1 : da > db ? 1 : 0;
278
- if (da)
279
- return -1;
280
- if (db)
281
- return 1;
282
- return 0;
279
+ const sa = isSnoozed(a.issue.snoozedUntilAt) ? 1 : 0;
280
+ const sb = isSnoozed(b.issue.snoozedUntilAt) ? 1 : 0;
281
+ if (sa !== sb)
282
+ return sa - sb;
283
+ return slaRank(a) - slaRank(b);
283
284
  });
284
285
  const groups = buildProjectGroups(backlogIssues);
285
286
  const flatIssues = flatten(groups);
286
- const triageGroups = buildProjectGroups(triageIssues);
287
+ // Triage is scoped to issues assigned to the viewer, so project grouping and
288
+ // the redundant "Triage" status header add noise. Render one flat group under
289
+ // a single "Assigned to me" header (its column label is the SLA badge). The
290
+ // empty status-group name makes IssueList skip the per-status sub-header.
291
+ const triageGroups = triageIssues.length
292
+ ? [
293
+ {
294
+ name: "Assigned to me",
295
+ id: null,
296
+ statusGroups: [{ name: "", type: "triage", issues: triageIssues }],
297
+ },
298
+ ]
299
+ : [];
287
300
  const flatTriage = flatten(triageGroups);
288
301
  const treeGroups = buildProjectGroups(treeIssues);
289
302
  const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Triage SLA + snooze formatting for the Triage tab. Linear issues in triage
3
+ * carry an `slaBreachesAt` ISO timestamp (when the response SLA is breached);
4
+ * we render a short, urgency-coded countdown badge — the same "2d 23h" / "13h"
5
+ * shape Linear itself shows — used by both the issue list (right column) and
6
+ * the detail panel.
7
+ *
8
+ * Urgency buckets (by time until breach):
9
+ * - breached (≤ 0) → red, "breached"
10
+ * - imminent (< 24h) → red, "5h" / "45m"
11
+ * - soon (< 48h) → yellow, "1d 6h"
12
+ * - later (≥ 48h) → gray, "3d 2h"
13
+ *
14
+ * Returns null when there is no SLA so callers can render nothing.
15
+ */
16
+ export interface SlaInfo {
17
+ /** Short badge text, e.g. "2d 23h", "13h", or "breached". */
18
+ label: string;
19
+ /** Ink color name keyed to urgency. */
20
+ color: "red" | "yellow" | "gray";
21
+ /** True for breached / < 24h — the cases worth a bold warning. */
22
+ urgent: boolean;
23
+ }
24
+ export declare function formatSla(slaBreachesAt: string | null | undefined, now?: Date): SlaInfo | null;
25
+ /** True when the issue is snoozed past the current moment. A snoozed triage
26
+ * issue is parked — the UI greys it and sinks it below active work. */
27
+ export declare function isSnoozed(snoozedUntilAt: string | null | undefined, now?: Date): boolean;
@@ -0,0 +1,35 @@
1
+ export function formatSla(slaBreachesAt, now = new Date()) {
2
+ if (!slaBreachesAt)
3
+ return null;
4
+ const breach = Date.parse(slaBreachesAt);
5
+ if (Number.isNaN(breach))
6
+ return null;
7
+ const ms = breach - now.getTime();
8
+ if (ms <= 0)
9
+ return { label: "breached", color: "red", urgent: true };
10
+ const totalMin = Math.floor(ms / 60_000);
11
+ const days = Math.floor(totalMin / 1440);
12
+ const hours = Math.floor((totalMin % 1440) / 60);
13
+ const mins = totalMin % 60;
14
+ let label;
15
+ if (days >= 1)
16
+ label = `${days}d ${hours}h`;
17
+ else if (hours >= 1)
18
+ label = `${hours}h`;
19
+ else
20
+ label = `${mins}m`;
21
+ const hoursLeft = ms / 3_600_000;
22
+ if (hoursLeft < 24)
23
+ return { label, color: "red", urgent: true };
24
+ if (hoursLeft < 48)
25
+ return { label, color: "yellow", urgent: false };
26
+ return { label, color: "gray", urgent: false };
27
+ }
28
+ /** True when the issue is snoozed past the current moment. A snoozed triage
29
+ * issue is parked — the UI greys it and sinks it below active work. */
30
+ export function isSnoozed(snoozedUntilAt, now = new Date()) {
31
+ if (!snoozedUntilAt)
32
+ return false;
33
+ const until = Date.parse(snoozedUntilAt);
34
+ return !Number.isNaN(until) && until > now.getTime();
35
+ }
@@ -37,7 +37,8 @@ query GetIssue($id: String!) {
37
37
  title
38
38
  description
39
39
  url
40
- dueDate
40
+ slaBreachesAt
41
+ snoozedUntilAt
41
42
  state { name type }
42
43
  priority
43
44
  labels { nodes { name } }
@@ -73,7 +74,8 @@ query AssignedIssues {
73
74
  title
74
75
  description
75
76
  url
76
- dueDate
77
+ slaBreachesAt
78
+ snoozedUntilAt
77
79
  priority
78
80
  state { name type }
79
81
  labels { nodes { name } }
@@ -97,7 +99,8 @@ function mapAssigned(issue) {
97
99
  title: issue.title,
98
100
  description: issue.description ?? null,
99
101
  url: issue.url,
100
- dueDate: issue.dueDate ?? null,
102
+ slaBreachesAt: issue.slaBreachesAt ?? null,
103
+ snoozedUntilAt: issue.snoozedUntilAt ?? null,
101
104
  priority: issue.priority,
102
105
  priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
103
106
  state: {
@@ -38,11 +38,16 @@ export interface AssignedIssue {
38
38
  blockedBy?: IssueRef[];
39
39
  /** Issues this one blocks (downstream dependents). */
40
40
  blocking?: IssueRef[];
41
- /** Due date as an ISO `YYYY-MM-DD` string, or null when none is set.
42
- * Only trackers with a native due-date concept populate it (Linear today);
43
- * others leave it undefined. Surfaced on the Triage tab as a colored,
44
- * urgency-coded badge. */
45
- dueDate?: string | null;
41
+ /** When the issue's triage SLA breaches, as an ISO timestamp, or null when
42
+ * no SLA applies. Only trackers with a native SLA concept populate it
43
+ * (Linear today); others leave it undefined. Surfaced on the Triage tab as a
44
+ * colored, urgency-coded countdown badge. */
45
+ slaBreachesAt?: string | null;
46
+ /** When the issue is snoozed until, as an ISO timestamp, or null when not
47
+ * snoozed. A snooze in the future means the issue is parked — surfaced on the
48
+ * Triage tab as a greyed, sunk-to-the-bottom row so active work stands out.
49
+ * Linear-only today. */
50
+ snoozedUntilAt?: string | null;
46
51
  }
47
52
  export interface Issue extends AssignedIssue {
48
53
  comments: Comment[];
@@ -0,0 +1,21 @@
1
+ /** Per-repo "investigate triage ticket" config, read from
2
+ * `.santree/metadata.json` (`_triage.skill_name` / `_triage.prompt`).
3
+ * metadata.json is gitignored, so this is per-machine — each dev points it at
4
+ * their own skill or prompt without committing it. */
5
+ export interface TriageInvestigateConfig {
6
+ /** A Claude slash-command/skill name. Invoked as `/<skill_name> <ticketId>`. */
7
+ skillName: string | null;
8
+ /** A free-form prompt template; `{ticket_id}` is substituted with the ticket. */
9
+ prompt: string | null;
10
+ }
11
+ export declare function readTriageInvestigateConfig(repoRoot: string): TriageInvestigateConfig;
12
+ export declare function isTriageInvestigateConfigured(cfg: TriageInvestigateConfig): boolean;
13
+ /** Builds the prompt handed to Claude for investigating a ticket. Skill name
14
+ * wins when both are set: `/<skill> <ticketId>`. Otherwise the free-form
15
+ * template with every `{ticket_id}` replaced. Returns null when unconfigured. */
16
+ export declare function buildInvestigatePrompt(cfg: TriageInvestigateConfig, ticketId: string): string | null;
17
+ /** Builds the shell command line launched in the new multiplexer window:
18
+ * `<claudeBin> '<prompt>'`. The multiplexer escapes the whole line again for
19
+ * its send-keys step; this layer just needs the prompt to survive as a single
20
+ * argv entry so a slash command (`/skill TEAM-1`) reaches Claude intact. */
21
+ export declare function buildInvestigateCommand(claudeBin: string, prompt: string): string;
@@ -0,0 +1,32 @@
1
+ import { readAllMetadata } from "./metadata.js";
2
+ export function readTriageInvestigateConfig(repoRoot) {
3
+ const all = readAllMetadata(repoRoot);
4
+ const t = all["_triage"];
5
+ const skillName = typeof t?.skill_name === "string" && t.skill_name.trim() ? t.skill_name.trim() : null;
6
+ const prompt = typeof t?.prompt === "string" && t.prompt.trim() ? t.prompt.trim() : null;
7
+ return { skillName, prompt };
8
+ }
9
+ export function isTriageInvestigateConfigured(cfg) {
10
+ return cfg.skillName !== null || cfg.prompt !== null;
11
+ }
12
+ /** Builds the prompt handed to Claude for investigating a ticket. Skill name
13
+ * wins when both are set: `/<skill> <ticketId>`. Otherwise the free-form
14
+ * template with every `{ticket_id}` replaced. Returns null when unconfigured. */
15
+ export function buildInvestigatePrompt(cfg, ticketId) {
16
+ if (cfg.skillName)
17
+ return `/${cfg.skillName} ${ticketId}`;
18
+ if (cfg.prompt)
19
+ return cfg.prompt.replace(/\{ticket_id\}/g, ticketId);
20
+ return null;
21
+ }
22
+ /** POSIX single-quote a shell argument. */
23
+ function shellSingleQuote(s) {
24
+ return `'${s.replace(/'/g, `'\\''`)}'`;
25
+ }
26
+ /** Builds the shell command line launched in the new multiplexer window:
27
+ * `<claudeBin> '<prompt>'`. The multiplexer escapes the whole line again for
28
+ * its send-keys step; this layer just needs the prompt to survive as a single
29
+ * argv entry so a slash command (`/skill TEAM-1`) reaches Claude intact. */
30
+ export function buildInvestigateCommand(claudeBin, prompt) {
31
+ return `${claudeBin} ${shellSingleQuote(prompt)}`;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -1,24 +0,0 @@
1
- /**
2
- * Due-date formatting for the Triage tab. Turns Linear's `YYYY-MM-DD`
3
- * `dueDate` string into a short, urgency-coded badge used by both the issue
4
- * list (right column) and the detail panel.
5
- *
6
- * Urgency buckets (by whole calendar days from today, local time):
7
- * - overdue (< 0) → red, "⚠ overdue Nd"
8
- * - due today (0) → red, "⚠ due today"
9
- * - soon (1–2 days) → yellow, "due in Nd"
10
- * - later (≥ 3 days) → gray, "due Mon D"
11
- *
12
- * Returns null when there is no due date so callers can render nothing.
13
- */
14
- export interface DueInfo {
15
- /** Short badge text, e.g. "⚠ overdue 3d" or "due Jun 12". */
16
- label: string;
17
- /** Ink color name keyed to urgency. */
18
- color: "red" | "yellow" | "gray";
19
- /** True for overdue/today — the cases worth a warning glyph. */
20
- urgent: boolean;
21
- /** Whole days until due (negative when overdue). */
22
- days: number;
23
- }
24
- export declare function formatDueDate(dueDate: string | null | undefined, now?: Date): DueInfo | null;
@@ -1,32 +0,0 @@
1
- /** Whole-day difference between two dates, ignoring time-of-day. */
2
- function dayDiff(from, to) {
3
- const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
4
- const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
5
- return Math.round((b - a) / 86_400_000);
6
- }
7
- export function formatDueDate(dueDate, now = new Date()) {
8
- if (!dueDate)
9
- return null;
10
- // Parse `YYYY-MM-DD` as a local date (not UTC) so "today" matches the user's
11
- // wall clock. `new Date("2026-06-12")` would parse as UTC midnight; build the
12
- // date from parts instead.
13
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dueDate);
14
- if (!m)
15
- return null;
16
- const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
17
- if (Number.isNaN(due.getTime()))
18
- return null;
19
- const days = dayDiff(now, due);
20
- const monthDay = due.toLocaleDateString("en-US", { month: "short", day: "numeric" });
21
- if (days < 0) {
22
- const n = Math.abs(days);
23
- return { label: `⚠ overdue ${n}d`, color: "red", urgent: true, days };
24
- }
25
- if (days === 0) {
26
- return { label: "⚠ due today", color: "red", urgent: true, days };
27
- }
28
- if (days <= 2) {
29
- return { label: `due in ${days}d`, color: "yellow", urgent: false, days };
30
- }
31
- return { label: `due ${monthDay}`, color: "gray", urgent: false, days };
32
- }