santree 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +2 -3
  2. package/dist/commands/config.d.ts +12 -0
  3. package/dist/commands/config.js +444 -0
  4. package/dist/commands/dashboard.js +187 -80
  5. package/dist/commands/helpers/statusline.js +0 -30
  6. package/dist/commands/issue/index.d.ts +1 -1
  7. package/dist/commands/issue/index.js +1 -1
  8. package/dist/commands/pr/context.d.ts +7 -0
  9. package/dist/commands/pr/context.js +36 -0
  10. package/dist/commands/pr/fix.d.ts +11 -2
  11. package/dist/commands/pr/fix.js +64 -12
  12. package/dist/commands/worktree/create.js +17 -11
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/commands/worktree/switch.js +3 -11
  15. package/dist/lib/ai.d.ts +11 -0
  16. package/dist/lib/ai.js +71 -2
  17. package/dist/lib/cd-hint.d.ts +18 -0
  18. package/dist/lib/cd-hint.js +27 -0
  19. package/dist/lib/claude-config.d.ts +26 -0
  20. package/dist/lib/claude-config.js +121 -0
  21. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  22. package/dist/lib/config/TrackerPicker.js +113 -0
  23. package/dist/lib/config/diagnostics.d.ts +37 -0
  24. package/dist/lib/config/diagnostics.js +190 -0
  25. package/dist/lib/config-store.d.ts +23 -0
  26. package/dist/lib/config-store.js +68 -0
  27. package/dist/lib/dashboard/DetailPanel.d.ts +7 -2
  28. package/dist/lib/dashboard/DetailPanel.js +54 -37
  29. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  30. package/dist/lib/dashboard/IssueList.js +24 -5
  31. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  32. package/dist/lib/dashboard/Overlays.js +3 -4
  33. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  34. package/dist/lib/dashboard/ReviewList.js +6 -3
  35. package/dist/lib/dashboard/data.js +7 -34
  36. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  37. package/dist/lib/dashboard/external-editor.js +3 -2
  38. package/dist/lib/dashboard/types.d.ts +19 -2
  39. package/dist/lib/dashboard/types.js +68 -2
  40. package/dist/lib/exec.d.ts +13 -0
  41. package/dist/lib/exec.js +11 -1
  42. package/dist/lib/fix-loop.d.ts +45 -0
  43. package/dist/lib/fix-loop.js +83 -0
  44. package/dist/lib/git.d.ts +3 -29
  45. package/dist/lib/git.js +6 -62
  46. package/dist/lib/github.d.ts +70 -0
  47. package/dist/lib/github.js +128 -0
  48. package/dist/lib/multiplexer/cmux.js +143 -17
  49. package/dist/lib/multiplexer/none.js +3 -3
  50. package/dist/lib/multiplexer/tmux.js +5 -29
  51. package/dist/lib/multiplexer/types.d.ts +34 -1
  52. package/dist/lib/open-url.d.ts +3 -2
  53. package/dist/lib/open-url.js +5 -4
  54. package/dist/lib/prompts.d.ts +35 -1
  55. package/dist/lib/prompts.js +7 -0
  56. package/dist/lib/setup/apply.d.ts +16 -0
  57. package/dist/lib/setup/apply.js +35 -0
  58. package/dist/lib/setup/gitignore.d.ts +24 -0
  59. package/dist/lib/setup/gitignore.js +95 -0
  60. package/dist/lib/setup/steps.d.ts +39 -0
  61. package/dist/lib/setup/steps.js +333 -0
  62. package/dist/lib/setup/tools.d.ts +23 -0
  63. package/dist/lib/setup/tools.js +47 -0
  64. package/dist/lib/squirrel-loader.d.ts +3 -1
  65. package/dist/lib/squirrel-loader.js +2 -2
  66. package/dist/lib/trackers/github/index.js +2 -2
  67. package/dist/lib/trackers/local/store.js +1 -1
  68. package/package.json +1 -2
  69. package/prompts/fix-context.njk +79 -0
  70. package/prompts/fix-loop.njk +18 -0
  71. package/prompts/pr.njk +1 -1
  72. package/dist/commands/doctor.d.ts +0 -2
  73. package/dist/commands/doctor.js +0 -618
  74. package/dist/commands/github/auth.d.ts +0 -2
  75. package/dist/commands/github/auth.js +0 -56
  76. package/dist/commands/github/index.d.ts +0 -1
  77. package/dist/commands/github/index.js +0 -1
  78. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  79. package/dist/commands/helpers/english-tutor/index.js +0 -1
  80. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  81. package/dist/commands/helpers/english-tutor/install.js +0 -24
  82. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  83. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  84. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  85. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  86. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  87. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  88. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  89. package/dist/commands/helpers/session-signal/end.js +0 -13
  90. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  91. package/dist/commands/helpers/session-signal/index.js +0 -1
  92. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  93. package/dist/commands/helpers/session-signal/install.js +0 -24
  94. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  95. package/dist/commands/helpers/session-signal/notification.js +0 -13
  96. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  97. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  98. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  99. package/dist/commands/helpers/session-signal/stop.js +0 -13
  100. package/dist/commands/helpers/shell-init.d.ts +0 -11
  101. package/dist/commands/helpers/shell-init.js +0 -122
  102. package/dist/commands/issue/setup.d.ts +0 -2
  103. package/dist/commands/issue/setup.js +0 -108
  104. package/dist/commands/issue/switch.d.ts +0 -12
  105. package/dist/commands/issue/switch.js +0 -38
  106. package/dist/commands/linear/switch.d.ts +0 -2
  107. package/dist/commands/linear/switch.js +0 -75
  108. package/dist/lib/english-tutor.d.ts +0 -13
  109. package/dist/lib/english-tutor.js +0 -125
  110. package/dist/lib/session-signal.d.ts +0 -16
  111. package/dist/lib/session-signal.js +0 -104
  112. package/prompts/english-tutor-prompt.njk +0 -15
  113. package/shell/init.bash.njk +0 -129
  114. package/shell/init.zsh.njk +0 -204
@@ -45,11 +45,30 @@ function workIndicator(wt) {
45
45
  function ciIndicator(checks) {
46
46
  if (!checks || checks.length === 0)
47
47
  return { glyph: "·", color: "gray" };
48
+ // `gh` buckets: pass | fail | pending | skipping | cancel. Only `fail` blocks
49
+ // (red) and only `pending` is genuinely still running (yellow). `skipping` /
50
+ // `cancel` don't run under the current conditions and don't block — GitHub
51
+ // reports them as "skipped" and still shows "all checks passed", so they must
52
+ // not turn the glyph yellow.
48
53
  if (checks.some((c) => c.bucket === "fail"))
49
54
  return { glyph: "✗", color: "red" };
50
- if (checks.every((c) => c.bucket === "pass"))
51
- return { glyph: "", color: "green" };
52
- return { glyph: "", color: "yellow" };
55
+ if (checks.some((c) => c.bucket === "pending"))
56
+ return { glyph: "", color: "yellow" };
57
+ return { glyph: "", color: "green" };
58
+ }
59
+ /**
60
+ * While a fix loop is actively running (or stalled), it owns the CI column — a ⟳
61
+ * conveys "being auto-fixed" better than the underlying check state. Stopped loops
62
+ * fall back to the real CI glyph (CI green ≈ stopped clean, CI red ≈ stopped stuck).
63
+ */
64
+ function fixLoopOverride(loop) {
65
+ if (!loop)
66
+ return null;
67
+ if (loop.phase === "running")
68
+ return { glyph: "⟳", color: "cyan" };
69
+ if (loop.phase === "stalled")
70
+ return { glyph: "⟳", color: "yellow" };
71
+ return null;
53
72
  }
54
73
  export function buildIssueListRows(groups, flatIssues) {
55
74
  const rows = [];
@@ -109,7 +128,7 @@ function readinessGlyph(di) {
109
128
  return { glyph: "·", color: "gray" };
110
129
  }
111
130
  }
112
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, }) {
131
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, fixLoops, }) {
113
132
  const isTriage = variant === "triage";
114
133
  const isIssues = variant === "issues";
115
134
  const rightFixed = isTriage ? SLA_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
@@ -146,7 +165,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
146
165
  const prio = snoozed ? { glyph: " ", color: "gray" } : priorityMarker(di.issue.priority);
147
166
  const isDeleting = deletingIds?.has(di.issue.identifier) ?? false;
148
167
  const work = isDeleting ? { glyph: "⌫", color: "yellow" } : workIndicator(di.worktree);
149
- const ci = ciIndicator(di.checks);
168
+ const ci = fixLoopOverride(fixLoops?.[di.issue.identifier]) ?? ciIndicator(di.checks);
150
169
  const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
151
170
  const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
152
171
  const title = di.issue.title.length > adjustedTitleWidth
@@ -162,7 +162,7 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
162
162
  onSubmit();
163
163
  return;
164
164
  }
165
- // Ctrl+O: escalate to $SANTREE_EDITOR / $VISUAL / $EDITOR. On save+close
165
+ // Ctrl+O: escalate to the configured editor / $VISUAL / $EDITOR. On save+close
166
166
  // the buffer is replaced and control returns to the textbox so the
167
167
  // user can keep editing or submit with Ctrl+D.
168
168
  if (key.ctrl && input === "o") {
@@ -68,6 +68,8 @@ const LEGEND = [
68
68
  { glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
69
69
  { glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
70
70
  { glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
71
+ { glyph: "⟳", color: "cyan", meaning: "CI column: auto-fix loop running (every 5 min)" },
72
+ { glyph: "⟳", color: "yellow", meaning: "CI column: fix loop stalled (no recent activity)" },
71
73
  { glyph: "◷", color: "red", meaning: "SLA column (Triage): breached / under a day" },
72
74
  { glyph: "◷", color: "yellow", meaning: "SLA column (Triage): under two days" },
73
75
  { glyph: "●", color: "gray", meaning: "Triage row greyed: snoozed (parked, sunk to bottom)" },
@@ -86,10 +88,7 @@ const LEGEND = [
86
88
  { glyph: "✓ clean", color: "green", meaning: "Working tree clean" },
87
89
  { glyph: "↑ N", color: "cyan", meaning: "N commits ahead of base" },
88
90
  { glyph: "↓ N behind", color: "yellow", meaning: "Main repo: N commits to pull from origin" },
89
- { glyph: "", color: "red", meaning: "Session needs input (permission prompt)" },
90
- { glyph: "◆", color: "green", meaning: "Session active (Claude is working)" },
91
- { glyph: "◆", color: "yellow", meaning: "Session idle (waiting for prompt)" },
92
- { glyph: "◇", color: "cyan", meaning: "Session id stored, no live signal" },
91
+ { glyph: "", color: "cyan", meaning: "Resumable Claude session id stored" },
93
92
  { glyph: "◇", color: "gray", meaning: "No session" },
94
93
  ],
95
94
  },
@@ -223,16 +223,20 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
223
223
  }
224
224
  // ── Checks ────────────────────────────────────────────────────────
225
225
  if (checks && checks.length > 0) {
226
+ // `skipping`/`cancel` checks don't run under the current conditions and
227
+ // don't block — GitHub counts them as "skipped" while still reporting all
228
+ // checks passed. Treat them as a non-blocking aside, never as pending.
226
229
  const passing = checks.filter((c) => c.bucket === "pass");
227
230
  const failing = checks.filter((c) => c.bucket === "fail");
228
- const pending = checks.filter((c) => c.bucket !== "pass" && c.bucket !== "fail");
231
+ const pending = checks.filter((c) => c.bucket === "pending");
232
+ const skipped = checks.filter((c) => c.bucket === "skipping" || c.bucket === "cancel");
229
233
  const headerColor = failing.length > 0 ? "red" : pending.length > 0 ? "yellow" : "green";
230
234
  lines.push(ruleLine);
231
235
  const headerSegs = [
232
236
  { text: "✓ ", color: "cyan", bold: true },
233
237
  { text: "Checks", bold: true },
234
238
  { text: " " },
235
- { text: `${passing.length}/${checks.length} passing`, color: headerColor },
239
+ { text: `${passing.length} passed`, color: headerColor },
236
240
  ];
237
241
  if (failing.length > 0) {
238
242
  headerSegs.push({ text: " · ", dim: true });
@@ -242,8 +246,13 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
242
246
  headerSegs.push({ text: " · ", dim: true });
243
247
  headerSegs.push({ text: `${pending.length} pending`, color: "yellow" });
244
248
  }
249
+ if (skipped.length > 0) {
250
+ headerSegs.push({ text: " · ", dim: true });
251
+ headerSegs.push({ text: `${skipped.length} skipped`, dim: true });
252
+ }
245
253
  lines.push({ text: "", segments: headerSegs });
246
254
  // Order: failing first (most important), then pending, then passing.
255
+ // Skipped checks aren't listed individually — just summarized above.
247
256
  for (const check of failing) {
248
257
  const desc = check.description ? ` — ${check.description}` : "";
249
258
  lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
@@ -3,11 +3,14 @@ import { Box, Text } from "ink";
3
3
  function checksIndicator(checks) {
4
4
  if (!checks || checks.length === 0)
5
5
  return { text: "-", color: "gray" };
6
+ // Only `fail` blocks (red) and only `pending` is genuinely running (yellow);
7
+ // `skipping`/`cancel` don't run under the current conditions and don't block,
8
+ // so they must not turn the glyph yellow (matches GitHub's "all checks passed").
6
9
  if (checks.some((c) => c.bucket === "fail"))
7
10
  return { text: "✗", color: "red" };
8
- if (checks.every((c) => c.bucket === "pass"))
9
- return { text: "", color: "green" };
10
- return { text: "", color: "yellow" };
11
+ if (checks.some((c) => c.bucket === "pending"))
12
+ return { text: "", color: "yellow" };
13
+ return { text: "", color: "green" };
11
14
  }
12
15
  const HEADER_ROWS = 1;
13
16
  export function getReviewListRowCount(flatReviews) {
@@ -1,5 +1,6 @@
1
- import { listWorktrees, extractTicketId, getBaseBranch, getDefaultBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, clearSessionId, getGitStatusAsync, getCommitsAheadAsync, getCommitsBehindAsync, getDiffShortstatAsync, } from "../git.js";
1
+ import { listWorktrees, extractTicketId, getBaseBranch, getDefaultBranch, readAllMetadata, clearSessionId, getGitStatusAsync, getCommitsAheadAsync, getCommitsBehindAsync, getDiffShortstatAsync, } from "../git.js";
2
2
  import { runAsync } from "../exec.js";
3
+ import { readFixLoopRuntime } from "../fix-loop.js";
3
4
  import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
4
5
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
5
6
  import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
@@ -46,13 +47,6 @@ export async function loadDashboardData(repoRoot) {
46
47
  getPRInfoAsync(wt.branch),
47
48
  getDiffShortstatAsync(wt.path, base),
48
49
  ]);
49
- let sessState = readSessionState(repoRoot, issue.identifier);
50
- // Validate against the active multiplexer — if the session has gone, clear stale state
51
- if (sessState && !isSessionAlive(issue.identifier)) {
52
- clearSessionState(repoRoot, issue.identifier);
53
- sessState = null;
54
- }
55
- const ss = sessState?.state ?? null;
56
50
  const storedId = metadata[issue.identifier]?.session_id ?? null;
57
51
  // Verify the session is still resumable. Claude Code clears old
58
52
  // transcript files (or `/clear` mints a new ID), leaving our stored
@@ -69,10 +63,7 @@ export async function loadDashboardData(repoRoot) {
69
63
  clearSessionId(repoRoot, issue.identifier);
70
64
  sessionId = null;
71
65
  }
72
- // Hide stale todos when the session has exited or its file is gone —
73
- // the on-disk todos file outlives the process and showing them
74
- // would lie about state.
75
- const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
66
+ const claudeTodos = sessionId ? readMainAgentTodos(sessionId) : null;
76
67
  worktreeInfo = {
77
68
  path: wt.path,
78
69
  branch: wt.branch,
@@ -82,8 +73,6 @@ export async function loadDashboardData(repoRoot) {
82
73
  sessionId,
83
74
  sessionCwd,
84
75
  gitStatus: gitStatusOutput,
85
- sessionState: ss === "exited" ? null : ss,
86
- sessionMessage: sessState?.message ?? null,
87
76
  diffStats: shortstat,
88
77
  claudeTodos,
89
78
  };
@@ -101,6 +90,7 @@ export async function loadDashboardData(repoRoot) {
101
90
  pr: prInfo,
102
91
  checks: checksInfo,
103
92
  reviews: reviewsInfo,
93
+ fixLoop: readFixLoopRuntime(repoRoot, issue.identifier),
104
94
  };
105
95
  }));
106
96
  // Build orphan DashboardIssue objects for worktrees not matched to any fetched issue
@@ -132,12 +122,6 @@ export async function loadDashboardData(repoRoot) {
132
122
  : wt.branch.replace(/^[^/]+\//, ""))
133
123
  .replace(/-/g, " ")
134
124
  .trim() || tid;
135
- let sessState = readSessionState(repoRoot, tid);
136
- if (sessState && !isSessionAlive(tid)) {
137
- clearSessionState(repoRoot, tid);
138
- sessState = null;
139
- }
140
- const ss = sessState?.state ?? null;
141
125
  const storedId = metadata[tid]?.session_id ?? null;
142
126
  const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
143
127
  let sessionId = storedId;
@@ -145,7 +129,7 @@ export async function loadDashboardData(repoRoot) {
145
129
  clearSessionId(repoRoot, tid);
146
130
  sessionId = null;
147
131
  }
148
- const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
132
+ const claudeTodos = sessionId ? readMainAgentTodos(sessionId) : null;
149
133
  return {
150
134
  issue: {
151
135
  identifier: tid,
@@ -168,14 +152,13 @@ export async function loadDashboardData(repoRoot) {
168
152
  sessionId,
169
153
  sessionCwd,
170
154
  gitStatus: gitStatusOutput,
171
- sessionState: ss === "exited" ? null : ss,
172
- sessionMessage: sessState?.message ?? null,
173
155
  diffStats: shortstat,
174
156
  claudeTodos,
175
157
  },
176
158
  pr,
177
159
  checks: checksInfo,
178
160
  reviews: reviewsInfo,
161
+ fixLoop: readFixLoopRuntime(repoRoot, tid),
179
162
  };
180
163
  }));
181
164
  // ── Compute parent-child relationships ──────────────────────────
@@ -367,8 +350,6 @@ async function buildMainEntry(repoRoot) {
367
350
  sessionId: null,
368
351
  sessionCwd: null,
369
352
  gitStatus: gitStatusOutput,
370
- sessionState: null,
371
- sessionMessage: null,
372
353
  diffStats: shortstat,
373
354
  claudeTodos: null,
374
355
  },
@@ -421,12 +402,6 @@ export async function loadReviewsData(repoRoot) {
421
402
  getCommitsAheadAsync(wt.path, base),
422
403
  getDiffShortstatAsync(wt.path, base),
423
404
  ]);
424
- let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
425
- if (sessState && ticketId && !isSessionAlive(ticketId)) {
426
- clearSessionState(repoRoot, ticketId);
427
- sessState = null;
428
- }
429
- const ss = sessState?.state ?? null;
430
405
  const storedId = ticketId ? (metadata[ticketId]?.session_id ?? null) : null;
431
406
  const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
432
407
  let sessionId = storedId;
@@ -434,7 +409,7 @@ export async function loadReviewsData(repoRoot) {
434
409
  clearSessionId(repoRoot, ticketId);
435
410
  sessionId = null;
436
411
  }
437
- const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
412
+ const claudeTodos = sessionId ? readMainAgentTodos(sessionId) : null;
438
413
  worktreeInfo = {
439
414
  path: wt.path,
440
415
  branch: wt.branch,
@@ -444,8 +419,6 @@ export async function loadReviewsData(repoRoot) {
444
419
  sessionId,
445
420
  sessionCwd,
446
421
  gitStatus: gitStatusOutput,
447
- sessionState: ss === "exited" ? null : ss,
448
- sessionMessage: sessState?.message ?? null,
449
422
  diffStats: shortstat,
450
423
  claudeTodos,
451
424
  };
@@ -7,6 +7,6 @@ export interface EditExternallyResult {
7
7
  * Open the user's editor on a temp file seeded with `initial`, then return the
8
8
  * saved content. Empty buffer is treated as cancel (matches `git commit`).
9
9
  *
10
- * Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
10
+ * Editor resolution: santree config editor (or SANTREE_EDITOR) > VISUAL > EDITOR > "vim".
11
11
  */
12
12
  export declare function editExternally(initial: string, ext?: string): EditExternallyResult;
@@ -2,6 +2,7 @@ import { spawnSync } from "child_process";
2
2
  import * as fs from "fs";
3
3
  import * as os from "os";
4
4
  import * as path from "path";
5
+ import { getConfiguredEditor } from "../config-store.js";
5
6
  // GUI editors that detach by default and need a `--wait` flag to make spawnSync
6
7
  // block until the file is closed. Terminal editors (vim, nvim, nano, emacs -nw)
7
8
  // already block, so they aren't listed here.
@@ -17,10 +18,10 @@ const GUI_EDITORS_NEEDING_WAIT = new Set([
17
18
  * Open the user's editor on a temp file seeded with `initial`, then return the
18
19
  * saved content. Empty buffer is treated as cancel (matches `git commit`).
19
20
  *
20
- * Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
21
+ * Editor resolution: santree config editor (or SANTREE_EDITOR) > VISUAL > EDITOR > "vim".
21
22
  */
22
23
  export function editExternally(initial, ext = "md") {
23
- const editorRaw = process.env["SANTREE_EDITOR"] || process.env["VISUAL"] || process.env["EDITOR"] || "vim";
24
+ const editorRaw = getConfiguredEditor() || process.env["VISUAL"] || process.env["EDITOR"] || "vim";
24
25
  const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext.replace(/^\./, "")}`);
25
26
  try {
26
27
  fs.writeFileSync(filePath, initial);
@@ -1,6 +1,8 @@
1
1
  import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
2
  import type { ClaudeTodo } from "../claude-todos.js";
3
+ import type { FixLoopRuntime } from "../fix-loop.js";
3
4
  import type { AssignedIssue, Comment, Issue, TriageSchedule } from "../trackers/types.js";
5
+ export type { FixLoopRuntime } from "../fix-loop.js";
4
6
  export type { AssignedIssue, Comment, Issue, TriageSchedule } from "../trackers/types.js";
5
7
  export interface WorktreeInfo {
6
8
  path: string;
@@ -22,8 +24,6 @@ export interface WorktreeInfo {
22
24
  * sessionId is null or the underlying file can't be located. */
23
25
  sessionCwd: string | null;
24
26
  gitStatus: string;
25
- sessionState: "waiting" | "idle" | "active" | null;
26
- sessionMessage: string | null;
27
27
  diffStats: {
28
28
  filesChanged: number;
29
29
  insertions: number;
@@ -37,6 +37,8 @@ export interface DashboardIssue {
37
37
  pr: PRInfo | null;
38
38
  checks: PRCheck[] | null;
39
39
  reviews: PRReview[] | null;
40
+ /** Active/recent auto-fix loop for this ticket (from .santree/fix-loops/). */
41
+ fixLoop?: FixLoopRuntime | null;
40
42
  parentTicketId?: string;
41
43
  children?: DashboardIssue[];
42
44
  }
@@ -163,6 +165,18 @@ export interface DashboardState {
163
165
  creationError: string | null;
164
166
  /** In-flight (and just-finished) worktree deletions, keyed by ticket id. */
165
167
  deletingTickets: Record<string, DeleteStatus>;
168
+ /**
169
+ * Optimistic PRs just created from the dashboard, keyed by ticket id. They
170
+ * make the "Open PR" action appear immediately (before the next refresh
171
+ * surfaces the real PR), and are reconciled away by SET_DATA.
172
+ */
173
+ pendingPrs: Record<string, PRInfo>;
174
+ /**
175
+ * Active/recent auto-fix loops keyed by ticket id, used to render the per-row
176
+ * ⟳ badge + detail line. Seeded optimistically by FIXLOOP_START and rebuilt
177
+ * from the `.santree/fix-loops/` markers on every SET_DATA.
178
+ */
179
+ fixLoopTickets: Record<string, FixLoopRuntime>;
166
180
  commitPhase: CommitPhase;
167
181
  commitMessage: string;
168
182
  commitError: string | null;
@@ -355,6 +369,9 @@ export type DashboardAction = {
355
369
  type: "DELETE_ERROR";
356
370
  ticketId: string;
357
371
  error: string;
372
+ } | {
373
+ type: "FIXLOOP_START";
374
+ ticketId: string;
358
375
  } | {
359
376
  type: "COMMIT_START";
360
377
  /** Null when committing on a non-ticket branch (e.g. the main
@@ -48,6 +48,8 @@ export const initialState = {
48
48
  creationLogs: "",
49
49
  creationError: null,
50
50
  deletingTickets: {},
51
+ pendingPrs: {},
52
+ fixLoopTickets: {},
51
53
  commitPhase: "idle",
52
54
  commitMessage: "",
53
55
  commitError: null,
@@ -117,12 +119,36 @@ export function reducer(state, action) {
117
119
  // keep their row, so their entries survive and stay visible.
118
120
  const presentTreeIds = new Set(action.flatTrees.map((d) => d.issue.identifier));
119
121
  const deletingTickets = Object.fromEntries(Object.entries(state.deletingTickets).filter(([id]) => presentTreeIds.has(id)));
122
+ // Reconcile optimistic PRs: keep the placeholder until the refresh
123
+ // actually surfaces the PR (GitHub may not have indexed it yet), then
124
+ // drop it once real PR data — or the worktree itself — is gone.
125
+ let reconciledTrees = action.flatTrees;
126
+ const pendingPrs = {};
127
+ for (const [id, optimistic] of Object.entries(state.pendingPrs)) {
128
+ const idx = reconciledTrees.findIndex((d) => d.issue.identifier === id);
129
+ if (idx < 0)
130
+ continue; // worktree gone → drop
131
+ if (reconciledTrees[idx].pr)
132
+ continue; // real PR arrived → reconciled
133
+ reconciledTrees = reconciledTrees.map((d, i) => (i === idx ? { ...d, pr: optimistic } : d));
134
+ pendingPrs[id] = optimistic; // still pending → keep showing it
135
+ }
136
+ // Rebuild the fix-loop badge map authoritatively from the freshly-read
137
+ // markers (drops loops whose marker was cleared); optimistic FIXLOOP_START
138
+ // entries are superseded here once the marker file is picked up.
139
+ const fixLoopTickets = {};
140
+ for (const d of reconciledTrees) {
141
+ if (d.fixLoop)
142
+ fixLoopTickets[d.issue.identifier] = d.fixLoop;
143
+ }
120
144
  return {
121
145
  ...state,
122
146
  groups: action.groups,
123
147
  flatIssues: action.flatIssues,
124
148
  treeGroups: action.treeGroups,
125
- flatTrees: action.flatTrees,
149
+ flatTrees: reconciledTrees,
150
+ pendingPrs,
151
+ fixLoopTickets,
126
152
  triageGroups: action.triageGroups,
127
153
  flatTriage: action.flatTriage,
128
154
  deletingTickets,
@@ -344,6 +370,23 @@ export function reducer(state, action) {
344
370
  },
345
371
  };
346
372
  }
373
+ case "FIXLOOP_START": {
374
+ // Optimistic running badge until SET_DATA reads the marker the launcher wrote.
375
+ const now = new Date().toISOString();
376
+ return {
377
+ ...state,
378
+ fixLoopTickets: {
379
+ ...state.fixLoopTickets,
380
+ [action.ticketId]: {
381
+ phase: "running",
382
+ status: "running",
383
+ startedAt: now,
384
+ at: now,
385
+ ageMs: 0,
386
+ },
387
+ },
388
+ };
389
+ }
347
390
  case "COMMIT_START":
348
391
  return {
349
392
  ...state,
@@ -408,14 +451,37 @@ export function reducer(state, action) {
408
451
  return { ...state, prCreateDraft: !state.prCreateDraft };
409
452
  case "PR_CREATE_EDIT":
410
453
  return { ...state, prCreatePhase: "review" };
411
- case "PR_CREATE_DONE":
454
+ case "PR_CREATE_DONE": {
455
+ // Optimistically attach the new PR to its tree row so the "Open PR"
456
+ // action appears immediately, instead of waiting for the next refresh
457
+ // to surface it via `gh pr view`.
458
+ const ticketId = state.prCreateTicketId;
459
+ let flatTrees = state.flatTrees;
460
+ let pendingPrs = state.pendingPrs;
461
+ // Only attach the optimistic PR if we can parse a real number from the
462
+ // returned URL — a `"?"` placeholder would break later `gh pr view <n>`
463
+ // calls. If it doesn't match, the next refresh surfaces the real PR.
464
+ const prNumber = action.url.match(/\/pull\/(\d+)/)?.[1];
465
+ if (ticketId && prNumber) {
466
+ const optimistic = {
467
+ number: prNumber,
468
+ state: "OPEN",
469
+ isDraft: state.prCreateDraft,
470
+ url: action.url,
471
+ };
472
+ flatTrees = state.flatTrees.map((d) => d.issue.identifier === ticketId && !d.pr ? { ...d, pr: optimistic } : d);
473
+ pendingPrs = { ...state.pendingPrs, [ticketId]: optimistic };
474
+ }
412
475
  return {
413
476
  ...state,
414
477
  prCreatePhase: "done",
415
478
  prCreateUrl: action.url,
416
479
  prCreateBody: null,
417
480
  prCreateTitle: null,
481
+ flatTrees,
482
+ pendingPrs,
418
483
  };
484
+ }
419
485
  case "PR_CREATE_CANCEL":
420
486
  return {
421
487
  ...state,
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Run a command with array args (NO shell) and return `{ stdout, stderr }`.
3
+ * Rejects on non-zero exit with an error carrying `.stdout`/`.stderr`/`.message`,
4
+ * matching promisified `exec` semantics. Use this — not a `exec(\`… ${x}\`)`
5
+ * template — whenever any argument is user-supplied, to avoid shell injection.
6
+ */
7
+ export declare function execFileAsync(file: string, args: string[], options?: {
8
+ cwd?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ }): Promise<{
11
+ stdout: string;
12
+ stderr: string;
13
+ }>;
1
14
  /**
2
15
  * Run a shell command and return trimmed stdout, or null on failure.
3
16
  */
package/dist/lib/exec.js CHANGED
@@ -1,6 +1,16 @@
1
- import { execSync, exec, spawn } from "child_process";
1
+ import { execSync, exec, execFile, spawn } from "child_process";
2
2
  import { promisify } from "util";
3
3
  const execPromise = promisify(exec);
4
+ const execFilePromise = promisify(execFile);
5
+ /**
6
+ * Run a command with array args (NO shell) and return `{ stdout, stderr }`.
7
+ * Rejects on non-zero exit with an error carrying `.stdout`/`.stderr`/`.message`,
8
+ * matching promisified `exec` semantics. Use this — not a `exec(\`… ${x}\`)`
9
+ * template — whenever any argument is user-supplied, to avoid shell injection.
10
+ */
11
+ export async function execFileAsync(file, args, options) {
12
+ return execFilePromise(file, args, { encoding: "utf-8", ...options });
13
+ }
4
14
  /**
5
15
  * Run a shell command and return trimmed stdout, or null on failure.
6
16
  */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Marker store for the auto-fix `/loop` (see `santree pr fix --loop`).
3
+ *
4
+ * Each running loop writes `.santree/fix-loops/<ticketId>.json` and heartbeats it
5
+ * every iteration. The dashboard reads these markers to render a per-ticket fix-loop
6
+ * badge — kept as files (not in dashboard memory) because the loop runs in a separate
7
+ * process/window and must survive dashboard restarts. Staleness is timestamp-based:
8
+ * the loop has a known cadence, so a "running" marker that hasn't heartbeat in a
9
+ * while is treated as stalled.
10
+ *
11
+ * Standalone module (only `fs`/`path`/`metadata`) so both commands and the dashboard
12
+ * data layer can import it without pulling in git/tracker cycles.
13
+ */
14
+ /** Lifecycle status written by the loop. `stopped:*` are terminal. */
15
+ export type FixLoopStatus = "running" | "merging" | "fixing" | "waiting-ci" | "stopped:clean" | "stopped:stuck";
16
+ export interface FixLoopMarker {
17
+ status: FixLoopStatus;
18
+ /** Loop interval in minutes (used for staleness math). */
19
+ intervalMin: number;
20
+ /** ISO timestamp the loop first launched. */
21
+ startedAt: string;
22
+ /** ISO timestamp of the last heartbeat / status change. */
23
+ at: string;
24
+ }
25
+ /** Display phase derived from a marker + the current time. */
26
+ export type FixLoopPhase = "running" | "stalled" | "stopped-clean" | "stopped-stuck";
27
+ export interface FixLoopRuntime {
28
+ phase: FixLoopPhase;
29
+ status: FixLoopStatus;
30
+ startedAt: string;
31
+ at: string;
32
+ ageMs: number;
33
+ }
34
+ /** Create/overwrite a marker as a freshly-started running loop. */
35
+ export declare function startFixLoop(repoRoot: string, ticketId: string, intervalMin: number): void;
36
+ /**
37
+ * Update an existing marker's status and heartbeat. If no marker exists yet (e.g. a
38
+ * signal arrives before the launcher wrote one), a minimal one is created so the
39
+ * dashboard still reflects the loop.
40
+ */
41
+ export declare function signalFixLoop(repoRoot: string, ticketId: string, status: FixLoopStatus): void;
42
+ export declare function readFixLoopMarker(repoRoot: string, ticketId: string): FixLoopMarker | null;
43
+ export declare function clearFixLoop(repoRoot: string, ticketId: string): void;
44
+ /** Derive the dashboard-facing phase from a marker. Returns null if no marker. */
45
+ export declare function readFixLoopRuntime(repoRoot: string, ticketId: string, now?: number): FixLoopRuntime | null;
@@ -0,0 +1,83 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { getSantreeDir } from "./metadata.js";
4
+ function getFixLoopsDir(repoRoot) {
5
+ return path.join(getSantreeDir(repoRoot), "fix-loops");
6
+ }
7
+ function markerPath(repoRoot, ticketId) {
8
+ return path.join(getFixLoopsDir(repoRoot), `${ticketId}.json`);
9
+ }
10
+ /** Create/overwrite a marker as a freshly-started running loop. */
11
+ export function startFixLoop(repoRoot, ticketId, intervalMin) {
12
+ const now = new Date().toISOString();
13
+ writeMarker(repoRoot, ticketId, {
14
+ status: "running",
15
+ intervalMin,
16
+ startedAt: now,
17
+ at: now,
18
+ });
19
+ }
20
+ /**
21
+ * Update an existing marker's status and heartbeat. If no marker exists yet (e.g. a
22
+ * signal arrives before the launcher wrote one), a minimal one is created so the
23
+ * dashboard still reflects the loop.
24
+ */
25
+ export function signalFixLoop(repoRoot, ticketId, status) {
26
+ const prev = readFixLoopMarker(repoRoot, ticketId);
27
+ const now = new Date().toISOString();
28
+ writeMarker(repoRoot, ticketId, {
29
+ status,
30
+ intervalMin: prev?.intervalMin ?? 5,
31
+ startedAt: prev?.startedAt ?? now,
32
+ at: now,
33
+ });
34
+ }
35
+ function writeMarker(repoRoot, ticketId, marker) {
36
+ const dir = getFixLoopsDir(repoRoot);
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ fs.writeFileSync(markerPath(repoRoot, ticketId), JSON.stringify(marker, null, 2) + "\n");
39
+ }
40
+ export function readFixLoopMarker(repoRoot, ticketId) {
41
+ const filePath = markerPath(repoRoot, ticketId);
42
+ if (!fs.existsSync(filePath))
43
+ return null;
44
+ try {
45
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function clearFixLoop(repoRoot, ticketId) {
52
+ try {
53
+ fs.unlinkSync(markerPath(repoRoot, ticketId));
54
+ }
55
+ catch {
56
+ // Ignore if missing.
57
+ }
58
+ }
59
+ /**
60
+ * A heartbeating loop that hasn't checked in for ~2.5 intervals is treated as
61
+ * stalled (its window was probably closed). Floor of 10 min so a slow first
62
+ * iteration isn't mistaken for a dead loop.
63
+ */
64
+ function staleThresholdMs(intervalMin) {
65
+ return Math.max(intervalMin * 2.5, 10) * 60_000;
66
+ }
67
+ /** Derive the dashboard-facing phase from a marker. Returns null if no marker. */
68
+ export function readFixLoopRuntime(repoRoot, ticketId, now = Date.now()) {
69
+ const marker = readFixLoopMarker(repoRoot, ticketId);
70
+ if (!marker)
71
+ return null;
72
+ const ageMs = now - new Date(marker.at).getTime();
73
+ let phase;
74
+ if (marker.status === "stopped:clean")
75
+ phase = "stopped-clean";
76
+ else if (marker.status === "stopped:stuck")
77
+ phase = "stopped-stuck";
78
+ else if (ageMs > staleThresholdMs(marker.intervalMin))
79
+ phase = "stalled";
80
+ else
81
+ phase = "running";
82
+ return { phase, status: marker.status, startedAt: marker.startedAt, at: marker.at, ageMs };
83
+ }