santree 0.7.5 → 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 +165 -61
- 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 +6 -1
- package/dist/commands/worktree/open.js +3 -2
- package/dist/lib/ai.d.ts +11 -0
- package/dist/lib/ai.js +71 -2
- package/dist/lib/claude-config.d.ts +18 -4
- package/dist/lib/claude-config.js +58 -23
- 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 +4 -2
- package/dist/lib/dashboard/DetailPanel.js +44 -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 +13 -2
- package/dist/lib/dashboard/types.js +33 -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 +2 -2
- package/dist/lib/setup/apply.js +2 -2
- package/dist/lib/setup/gitignore.d.ts +7 -0
- package/dist/lib/setup/gitignore.js +39 -1
- package/dist/lib/setup/steps.d.ts +6 -2
- package/dist/lib/setup/steps.js +65 -60
- package/dist/lib/setup/tools.d.ts +1 -1
- package/dist/lib/setup/tools.js +1 -1
- package/dist/lib/trackers/github/index.js +2 -2
- package/dist/lib/trackers/local/store.js +1 -1
- package/package.json +1 -1
- 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 -545
- 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/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/commands/setup.d.ts +0 -11
- package/dist/commands/setup.js +0 -194
- 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/dist/lib/setup/shell-config.d.ts +0 -38
- package/dist/lib/setup/shell-config.js +0 -131
- package/prompts/english-tutor-prompt.njk +0 -15
|
@@ -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
|
}
|
|
@@ -169,6 +171,12 @@ export interface DashboardState {
|
|
|
169
171
|
* surfaces the real PR), and are reconciled away by SET_DATA.
|
|
170
172
|
*/
|
|
171
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>;
|
|
172
180
|
commitPhase: CommitPhase;
|
|
173
181
|
commitMessage: string;
|
|
174
182
|
commitError: string | null;
|
|
@@ -361,6 +369,9 @@ export type DashboardAction = {
|
|
|
361
369
|
type: "DELETE_ERROR";
|
|
362
370
|
ticketId: string;
|
|
363
371
|
error: string;
|
|
372
|
+
} | {
|
|
373
|
+
type: "FIXLOOP_START";
|
|
374
|
+
ticketId: string;
|
|
364
375
|
} | {
|
|
365
376
|
type: "COMMIT_START";
|
|
366
377
|
/** Null when committing on a non-ticket branch (e.g. the main
|
|
@@ -49,6 +49,7 @@ export const initialState = {
|
|
|
49
49
|
creationError: null,
|
|
50
50
|
deletingTickets: {},
|
|
51
51
|
pendingPrs: {},
|
|
52
|
+
fixLoopTickets: {},
|
|
52
53
|
commitPhase: "idle",
|
|
53
54
|
commitMessage: "",
|
|
54
55
|
commitError: null,
|
|
@@ -132,6 +133,14 @@ export function reducer(state, action) {
|
|
|
132
133
|
reconciledTrees = reconciledTrees.map((d, i) => (i === idx ? { ...d, pr: optimistic } : d));
|
|
133
134
|
pendingPrs[id] = optimistic; // still pending → keep showing it
|
|
134
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
|
+
}
|
|
135
144
|
return {
|
|
136
145
|
...state,
|
|
137
146
|
groups: action.groups,
|
|
@@ -139,6 +148,7 @@ export function reducer(state, action) {
|
|
|
139
148
|
treeGroups: action.treeGroups,
|
|
140
149
|
flatTrees: reconciledTrees,
|
|
141
150
|
pendingPrs,
|
|
151
|
+
fixLoopTickets,
|
|
142
152
|
triageGroups: action.triageGroups,
|
|
143
153
|
flatTriage: action.flatTriage,
|
|
144
154
|
deletingTickets,
|
|
@@ -360,6 +370,23 @@ export function reducer(state, action) {
|
|
|
360
370
|
},
|
|
361
371
|
};
|
|
362
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
|
+
}
|
|
363
390
|
case "COMMIT_START":
|
|
364
391
|
return {
|
|
365
392
|
...state,
|
|
@@ -431,9 +458,13 @@ export function reducer(state, action) {
|
|
|
431
458
|
const ticketId = state.prCreateTicketId;
|
|
432
459
|
let flatTrees = state.flatTrees;
|
|
433
460
|
let pendingPrs = state.pendingPrs;
|
|
434
|
-
if
|
|
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) {
|
|
435
466
|
const optimistic = {
|
|
436
|
-
number:
|
|
467
|
+
number: prNumber,
|
|
437
468
|
state: "OPEN",
|
|
438
469
|
isDraft: state.prCreateDraft,
|
|
439
470
|
url: action.url,
|
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
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
export { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
|
|
2
|
-
export interface SessionState {
|
|
3
|
-
state: "waiting" | "idle" | "active" | "exited";
|
|
4
|
-
message: string | null;
|
|
5
|
-
session_id: string;
|
|
6
|
-
at: string;
|
|
7
|
-
}
|
|
8
2
|
export interface Worktree {
|
|
9
3
|
path: string;
|
|
10
4
|
branch: string;
|
|
@@ -191,17 +185,14 @@ export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): P
|
|
|
191
185
|
*/
|
|
192
186
|
export declare function getCommitsBehindAsync(cwd: string, baseBranch: string): Promise<number>;
|
|
193
187
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
188
|
+
* Resolve the configured diff pager command (e.g. "delta", "diff-so-fancy"),
|
|
189
|
+
* or null if unset/invalid. Source order: `SANTREE_DIFF_TOOL` env override →
|
|
190
|
+
* santree's config file (set from `santree config`). See `lib/config-store.ts`.
|
|
196
191
|
*
|
|
197
192
|
* The CLI `worktree diff` flow lets the pager do its full job (render +
|
|
198
193
|
* scroll) via git's `core.pager`. The dashboard's `[v]` overlay only uses
|
|
199
194
|
* the rendering half — it captures the pager's stdout as a string and
|
|
200
195
|
* handles scrolling itself in Ink.
|
|
201
|
-
*
|
|
202
|
-
* The value is restricted to a safe shell-token character set since it ends
|
|
203
|
-
* up in arguments passed to spawn() — even though we never use shell:true,
|
|
204
|
-
* keeping the surface tight defends against accidental misconfigurations.
|
|
205
196
|
*/
|
|
206
197
|
export declare function getDiffTool(): string | null;
|
|
207
198
|
export interface DiffShortstat {
|
|
@@ -315,20 +306,3 @@ export declare function unstageAll(cwd: string): Promise<void>;
|
|
|
315
306
|
* - Untracked: deletes the file from disk via fs.unlink.
|
|
316
307
|
*/
|
|
317
308
|
export declare function discardFile(cwd: string, filePath: string, isUntracked: boolean): Promise<void>;
|
|
318
|
-
/**
|
|
319
|
-
* Read the session state file for a given ticket.
|
|
320
|
-
* Returns null if missing or "exited".
|
|
321
|
-
*/
|
|
322
|
-
export declare function readSessionState(repoRoot: string, ticketId: string): SessionState | null;
|
|
323
|
-
/**
|
|
324
|
-
* Check if a session for the given ticket is still alive in the active multiplexer.
|
|
325
|
-
* Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
|
|
326
|
-
* cmux: workspace lookup; none: always false). Callers should also consult the
|
|
327
|
-
* .santree/session-states/<ticketId>.json file — that's the authoritative signal
|
|
328
|
-
* written by Claude Code hooks, while this is the "is the terminal still up" backstop.
|
|
329
|
-
*/
|
|
330
|
-
export declare function isSessionAlive(ticketId: string): boolean;
|
|
331
|
-
/**
|
|
332
|
-
* Delete the session state file for a given ticket.
|
|
333
|
-
*/
|
|
334
|
-
export declare function clearSessionState(repoRoot: string, ticketId: string): void;
|
package/dist/lib/git.js
CHANGED
|
@@ -3,7 +3,7 @@ import { promisify } from "util";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import { run, runAsync, spawnAsync } from "./exec.js";
|
|
6
|
-
import {
|
|
6
|
+
import { getConfiguredDiffTool } from "./config-store.js";
|
|
7
7
|
import { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
|
|
8
8
|
import { getIssueTracker } from "./trackers/index.js";
|
|
9
9
|
export { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
|
|
@@ -217,7 +217,7 @@ export async function removeWorktree(branchName, repoRoot, force = false, onProg
|
|
|
217
217
|
}
|
|
218
218
|
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
219
219
|
}
|
|
220
|
-
// Clean up centralized metadata entry
|
|
220
|
+
// Clean up centralized metadata entry
|
|
221
221
|
const ticketId = extractTicketId(branchName);
|
|
222
222
|
if (ticketId) {
|
|
223
223
|
const all = readAllMetadata(repoRoot);
|
|
@@ -225,7 +225,6 @@ export async function removeWorktree(branchName, repoRoot, force = false, onProg
|
|
|
225
225
|
delete all[ticketId];
|
|
226
226
|
writeAllMetadata(repoRoot, all);
|
|
227
227
|
}
|
|
228
|
-
clearSessionState(repoRoot, ticketId);
|
|
229
228
|
}
|
|
230
229
|
// Also delete the branch
|
|
231
230
|
report("Deleting branch…");
|
|
@@ -457,26 +456,17 @@ export async function getCommitsBehindAsync(cwd, baseBranch) {
|
|
|
457
456
|
return output ? parseInt(output, 10) || 0 : 0;
|
|
458
457
|
}
|
|
459
458
|
/**
|
|
460
|
-
*
|
|
461
|
-
*
|
|
459
|
+
* Resolve the configured diff pager command (e.g. "delta", "diff-so-fancy"),
|
|
460
|
+
* or null if unset/invalid. Source order: `SANTREE_DIFF_TOOL` env override →
|
|
461
|
+
* santree's config file (set from `santree config`). See `lib/config-store.ts`.
|
|
462
462
|
*
|
|
463
463
|
* The CLI `worktree diff` flow lets the pager do its full job (render +
|
|
464
464
|
* scroll) via git's `core.pager`. The dashboard's `[v]` overlay only uses
|
|
465
465
|
* the rendering half — it captures the pager's stdout as a string and
|
|
466
466
|
* handles scrolling itself in Ink.
|
|
467
|
-
*
|
|
468
|
-
* The value is restricted to a safe shell-token character set since it ends
|
|
469
|
-
* up in arguments passed to spawn() — even though we never use shell:true,
|
|
470
|
-
* keeping the surface tight defends against accidental misconfigurations.
|
|
471
467
|
*/
|
|
472
468
|
export function getDiffTool() {
|
|
473
|
-
|
|
474
|
-
if (!raw || !raw.trim())
|
|
475
|
-
return null;
|
|
476
|
-
const tool = raw.trim();
|
|
477
|
-
if (!/^[a-zA-Z0-9_\-/.+]+$/.test(tool))
|
|
478
|
-
return null;
|
|
479
|
-
return tool;
|
|
469
|
+
return getConfiguredDiffTool() ?? null;
|
|
480
470
|
}
|
|
481
471
|
/**
|
|
482
472
|
* Compute branch-only diff stats vs base (uses merge-base, like a GitHub PR
|
|
@@ -704,49 +694,3 @@ export async function discardFile(cwd, filePath, isUntracked) {
|
|
|
704
694
|
throw new Error(`git checkout HEAD failed: ${output.trim()}`);
|
|
705
695
|
}
|
|
706
696
|
}
|
|
707
|
-
/**
|
|
708
|
-
* Get the path to the .santree/session-states directory.
|
|
709
|
-
*/
|
|
710
|
-
function getSessionStatesDir(repoRoot) {
|
|
711
|
-
return path.join(getSantreeDir(repoRoot), "session-states");
|
|
712
|
-
}
|
|
713
|
-
/**
|
|
714
|
-
* Read the session state file for a given ticket.
|
|
715
|
-
* Returns null if missing or "exited".
|
|
716
|
-
*/
|
|
717
|
-
export function readSessionState(repoRoot, ticketId) {
|
|
718
|
-
const filePath = path.join(getSessionStatesDir(repoRoot), `${ticketId}.json`);
|
|
719
|
-
if (!fs.existsSync(filePath))
|
|
720
|
-
return null;
|
|
721
|
-
try {
|
|
722
|
-
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
723
|
-
if (data.state === "exited")
|
|
724
|
-
return null;
|
|
725
|
-
return data;
|
|
726
|
-
}
|
|
727
|
-
catch {
|
|
728
|
-
return null;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
/**
|
|
732
|
-
* Check if a session for the given ticket is still alive in the active multiplexer.
|
|
733
|
-
* Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
|
|
734
|
-
* cmux: workspace lookup; none: always false). Callers should also consult the
|
|
735
|
-
* .santree/session-states/<ticketId>.json file — that's the authoritative signal
|
|
736
|
-
* written by Claude Code hooks, while this is the "is the terminal still up" backstop.
|
|
737
|
-
*/
|
|
738
|
-
export function isSessionAlive(ticketId) {
|
|
739
|
-
return getMultiplexer().isSessionAlive(ticketId);
|
|
740
|
-
}
|
|
741
|
-
/**
|
|
742
|
-
* Delete the session state file for a given ticket.
|
|
743
|
-
*/
|
|
744
|
-
export function clearSessionState(repoRoot, ticketId) {
|
|
745
|
-
const filePath = path.join(getSessionStatesDir(repoRoot), `${ticketId}.json`);
|
|
746
|
-
try {
|
|
747
|
-
fs.unlinkSync(filePath);
|
|
748
|
-
}
|
|
749
|
-
catch {
|
|
750
|
-
// Ignore if file doesn't exist
|
|
751
|
-
}
|
|
752
|
-
}
|