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.
- package/README.md +2 -3
- package/dist/commands/config.d.ts +12 -0
- package/dist/commands/config.js +444 -0
- package/dist/commands/dashboard.js +187 -80
- package/dist/commands/helpers/statusline.js +0 -30
- package/dist/commands/issue/index.d.ts +1 -1
- package/dist/commands/issue/index.js +1 -1
- package/dist/commands/pr/context.d.ts +7 -0
- package/dist/commands/pr/context.js +36 -0
- package/dist/commands/pr/fix.d.ts +11 -2
- package/dist/commands/pr/fix.js +64 -12
- package/dist/commands/worktree/create.js +17 -11
- package/dist/commands/worktree/open.js +3 -2
- package/dist/commands/worktree/switch.js +3 -11
- package/dist/lib/ai.d.ts +11 -0
- package/dist/lib/ai.js +71 -2
- package/dist/lib/cd-hint.d.ts +18 -0
- package/dist/lib/cd-hint.js +27 -0
- package/dist/lib/claude-config.d.ts +26 -0
- package/dist/lib/claude-config.js +121 -0
- package/dist/lib/config/TrackerPicker.d.ts +10 -0
- package/dist/lib/config/TrackerPicker.js +113 -0
- package/dist/lib/config/diagnostics.d.ts +37 -0
- package/dist/lib/config/diagnostics.js +190 -0
- package/dist/lib/config-store.d.ts +23 -0
- package/dist/lib/config-store.js +68 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +7 -2
- package/dist/lib/dashboard/DetailPanel.js +54 -37
- package/dist/lib/dashboard/IssueList.d.ts +5 -1
- package/dist/lib/dashboard/IssueList.js +24 -5
- package/dist/lib/dashboard/MultilineTextArea.js +1 -1
- package/dist/lib/dashboard/Overlays.js +3 -4
- package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
- package/dist/lib/dashboard/ReviewList.js +6 -3
- package/dist/lib/dashboard/data.js +7 -34
- package/dist/lib/dashboard/external-editor.d.ts +1 -1
- package/dist/lib/dashboard/external-editor.js +3 -2
- package/dist/lib/dashboard/types.d.ts +19 -2
- package/dist/lib/dashboard/types.js +68 -2
- package/dist/lib/exec.d.ts +13 -0
- package/dist/lib/exec.js +11 -1
- package/dist/lib/fix-loop.d.ts +45 -0
- package/dist/lib/fix-loop.js +83 -0
- package/dist/lib/git.d.ts +3 -29
- package/dist/lib/git.js +6 -62
- package/dist/lib/github.d.ts +70 -0
- package/dist/lib/github.js +128 -0
- package/dist/lib/multiplexer/cmux.js +143 -17
- package/dist/lib/multiplexer/none.js +3 -3
- package/dist/lib/multiplexer/tmux.js +5 -29
- package/dist/lib/multiplexer/types.d.ts +34 -1
- package/dist/lib/open-url.d.ts +3 -2
- package/dist/lib/open-url.js +5 -4
- package/dist/lib/prompts.d.ts +35 -1
- package/dist/lib/prompts.js +7 -0
- package/dist/lib/setup/apply.d.ts +16 -0
- package/dist/lib/setup/apply.js +35 -0
- package/dist/lib/setup/gitignore.d.ts +24 -0
- package/dist/lib/setup/gitignore.js +95 -0
- package/dist/lib/setup/steps.d.ts +39 -0
- package/dist/lib/setup/steps.js +333 -0
- package/dist/lib/setup/tools.d.ts +23 -0
- package/dist/lib/setup/tools.js +47 -0
- package/dist/lib/squirrel-loader.d.ts +3 -1
- package/dist/lib/squirrel-loader.js +2 -2
- package/dist/lib/trackers/github/index.js +2 -2
- package/dist/lib/trackers/local/store.js +1 -1
- package/package.json +1 -2
- package/prompts/fix-context.njk +79 -0
- package/prompts/fix-loop.njk +18 -0
- package/prompts/pr.njk +1 -1
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js +0 -618
- package/dist/commands/github/auth.d.ts +0 -2
- package/dist/commands/github/auth.js +0 -56
- package/dist/commands/github/index.d.ts +0 -1
- package/dist/commands/github/index.js +0 -1
- package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
- package/dist/commands/helpers/english-tutor/index.js +0 -1
- package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
- package/dist/commands/helpers/english-tutor/install.js +0 -24
- package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
- package/dist/commands/helpers/english-tutor/prompt.js +0 -16
- package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
- package/dist/commands/helpers/english-tutor/session-start.js +0 -34
- package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
- package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
- package/dist/commands/helpers/session-signal/end.d.ts +0 -2
- package/dist/commands/helpers/session-signal/end.js +0 -13
- package/dist/commands/helpers/session-signal/index.d.ts +0 -1
- package/dist/commands/helpers/session-signal/index.js +0 -1
- package/dist/commands/helpers/session-signal/install.d.ts +0 -8
- package/dist/commands/helpers/session-signal/install.js +0 -24
- package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
- package/dist/commands/helpers/session-signal/notification.js +0 -13
- package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
- package/dist/commands/helpers/session-signal/prompt.js +0 -13
- package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
- package/dist/commands/helpers/session-signal/stop.js +0 -13
- package/dist/commands/helpers/shell-init.d.ts +0 -11
- package/dist/commands/helpers/shell-init.js +0 -122
- package/dist/commands/issue/setup.d.ts +0 -2
- package/dist/commands/issue/setup.js +0 -108
- package/dist/commands/issue/switch.d.ts +0 -12
- package/dist/commands/issue/switch.js +0 -38
- package/dist/commands/linear/switch.d.ts +0 -2
- package/dist/commands/linear/switch.js +0 -75
- package/dist/lib/english-tutor.d.ts +0 -13
- package/dist/lib/english-tutor.js +0 -125
- package/dist/lib/session-signal.d.ts +0 -16
- package/dist/lib/session-signal.js +0 -104
- package/prompts/english-tutor-prompt.njk +0 -15
- package/shell/init.bash.njk +0 -129
- 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.
|
|
51
|
-
return { glyph: "
|
|
52
|
-
return { glyph: "
|
|
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
|
|
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: "
|
|
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
|
|
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}
|
|
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.
|
|
9
|
-
return { text: "
|
|
10
|
-
return { text: "
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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,
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
+
}
|