santree 0.7.5 → 0.7.7
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 +10 -2
- package/dist/commands/pr/fix.js +61 -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
package/dist/lib/github.d.ts
CHANGED
|
@@ -10,6 +10,34 @@ export interface PRInfo {
|
|
|
10
10
|
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
11
11
|
*/
|
|
12
12
|
export declare function getPRInfoAsync(branchName: string): Promise<PRInfo | null>;
|
|
13
|
+
/** GitHub's mergeability signals for a PR (subset we act on). */
|
|
14
|
+
export interface PRMergeState {
|
|
15
|
+
/** "MERGEABLE" | "CONFLICTING" | "UNKNOWN" */
|
|
16
|
+
mergeable: string;
|
|
17
|
+
/** "BEHIND" | "BLOCKED" | "CLEAN" | "DIRTY" | "DRAFT" | "HAS_HOOKS" | "UNKNOWN" | "UNSTABLE" */
|
|
18
|
+
mergeStateStatus: string;
|
|
19
|
+
/** True when the branch has conflicts with its base that need resolving. */
|
|
20
|
+
hasConflicts: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a PR's mergeability for a branch. `CONFLICTING` (or a `DIRTY` merge-state)
|
|
24
|
+
* means the branch conflicts with its base and needs a merge/resolve before CI can
|
|
25
|
+
* pass. Returns null if no PR exists or gh fails. GitHub computes mergeability
|
|
26
|
+
* asynchronously, so `mergeable` is `UNKNOWN` briefly after a push — callers should
|
|
27
|
+
* treat `UNKNOWN` as "don't know yet", not "no conflicts".
|
|
28
|
+
*/
|
|
29
|
+
export declare function getPRMergeStateAsync(branchName: string): Promise<PRMergeState | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Classify a CI check as something an AI agent can plausibly fix on its own
|
|
32
|
+
* ("fixable" — tests, types, lint, format, coverage) vs. one that needs a human or
|
|
33
|
+
* infra ("manual" — deploys, releases, image builds, e2e, security scans). Matched
|
|
34
|
+
* case-insensitively against the check's name + workflow. **Unknown ⇒ "manual"**:
|
|
35
|
+
* the fix loop should only touch failures it's confident about and stop otherwise.
|
|
36
|
+
* Extend the keyword lists as new check conventions appear (cf. HIDDEN_STATE_NAMES).
|
|
37
|
+
*/
|
|
38
|
+
export declare const FIXABLE_CHECK_PATTERN: RegExp;
|
|
39
|
+
export declare const MANUAL_CHECK_PATTERN: RegExp;
|
|
40
|
+
export declare function classifyCheck(check: Pick<PRCheck, "name" | "workflow">): "fixable" | "manual";
|
|
13
41
|
/**
|
|
14
42
|
* Check if the GitHub CLI (gh) is available on PATH.
|
|
15
43
|
* Runs: `which gh`
|
|
@@ -50,6 +78,48 @@ export declare function getPRReviewsAsync(prNumber: string): Promise<PRReview[]
|
|
|
50
78
|
* Fetch inline review comments for a pull request via the GitHub API (async).
|
|
51
79
|
*/
|
|
52
80
|
export declare function getPRReviewCommentsAsync(prNumber: string): Promise<PRReviewComment[] | null>;
|
|
81
|
+
/**
|
|
82
|
+
* The authenticated GitHub user's login (GraphQL `viewer`). Cached per process.
|
|
83
|
+
* Used to gate auto-applying a review comment on the PR owner's OWN 👍 — a
|
|
84
|
+
* reviewer can't self-approve their comment for the fix loop.
|
|
85
|
+
*/
|
|
86
|
+
export declare function getViewerLoginAsync(): Promise<string | null>;
|
|
87
|
+
export interface PRReviewThreadComment {
|
|
88
|
+
author: string;
|
|
89
|
+
body: string;
|
|
90
|
+
path: string | null;
|
|
91
|
+
/** Current line, falling back to the original line for outdated comments. */
|
|
92
|
+
line: number | null;
|
|
93
|
+
diffHunk: string;
|
|
94
|
+
createdAt: string;
|
|
95
|
+
/** Logins of users who reacted 👍 (THUMBS_UP) to this comment. */
|
|
96
|
+
thumbsUpBy: string[];
|
|
97
|
+
}
|
|
98
|
+
/** A PR review thread (an inline comment + its replies) with resolution state. */
|
|
99
|
+
export interface PRReviewThread {
|
|
100
|
+
/** GraphQL node id — used to resolve the thread after the fix is applied. */
|
|
101
|
+
id: string;
|
|
102
|
+
isResolved: boolean;
|
|
103
|
+
isOutdated: boolean;
|
|
104
|
+
path: string | null;
|
|
105
|
+
/** Anchor line in the current diff, falling back to the original line. */
|
|
106
|
+
line: number | null;
|
|
107
|
+
comments: PRReviewThreadComment[];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fetch a PR's review threads with resolution state and per-comment 👍 reactions.
|
|
111
|
+
* The REST comments endpoint exposes neither, so this goes through GraphQL.
|
|
112
|
+
* Returns null if the repo can't be resolved or gh fails.
|
|
113
|
+
*/
|
|
114
|
+
export declare function getPRReviewThreadsAsync(prNumber: string): Promise<PRReviewThread[] | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Filter review threads to the ones the fix loop should act on: **unresolved**
|
|
117
|
+
* AND whose **last comment carries a 👍 from `approver`** (the viewer's own
|
|
118
|
+
* approval). Resolved threads are dropped entirely — resolved means the ask was
|
|
119
|
+
* already applied or deliberately declined. The 👍 gate stops the loop from
|
|
120
|
+
* applying a comment the moment it's left, before the PR owner has vetted it.
|
|
121
|
+
*/
|
|
122
|
+
export declare function getApprovedReviewThreads(threads: PRReviewThread[], approver: string): PRReviewThread[];
|
|
53
123
|
/**
|
|
54
124
|
* Fetch structured conversation comments on a pull request (async).
|
|
55
125
|
*/
|
package/dist/lib/github.js
CHANGED
|
@@ -22,6 +22,50 @@ export async function getPRInfoAsync(branchName) {
|
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Fetch a PR's mergeability for a branch. `CONFLICTING` (or a `DIRTY` merge-state)
|
|
27
|
+
* means the branch conflicts with its base and needs a merge/resolve before CI can
|
|
28
|
+
* pass. Returns null if no PR exists or gh fails. GitHub computes mergeability
|
|
29
|
+
* asynchronously, so `mergeable` is `UNKNOWN` briefly after a push — callers should
|
|
30
|
+
* treat `UNKNOWN` as "don't know yet", not "no conflicts".
|
|
31
|
+
*/
|
|
32
|
+
export async function getPRMergeStateAsync(branchName) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await execAsync(`gh pr view "${branchName}" --json mergeable,mergeStateStatus`);
|
|
35
|
+
const data = JSON.parse(stdout);
|
|
36
|
+
const mergeable = String(data.mergeable ?? "UNKNOWN");
|
|
37
|
+
const mergeStateStatus = String(data.mergeStateStatus ?? "UNKNOWN");
|
|
38
|
+
return {
|
|
39
|
+
mergeable,
|
|
40
|
+
mergeStateStatus,
|
|
41
|
+
hasConflicts: mergeable === "CONFLICTING" || mergeStateStatus === "DIRTY",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Classify a CI check as something an AI agent can plausibly fix on its own
|
|
50
|
+
* ("fixable" — tests, types, lint, format, coverage) vs. one that needs a human or
|
|
51
|
+
* infra ("manual" — deploys, releases, image builds, e2e, security scans). Matched
|
|
52
|
+
* case-insensitively against the check's name + workflow. **Unknown ⇒ "manual"**:
|
|
53
|
+
* the fix loop should only touch failures it's confident about and stop otherwise.
|
|
54
|
+
* Extend the keyword lists as new check conventions appear (cf. HIDDEN_STATE_NAMES).
|
|
55
|
+
*/
|
|
56
|
+
// Trailing \b only (no leading boundary) so test-runner prefixes and plurals
|
|
57
|
+
// match: "pytest", "tests", "rspec", "codecov" all count as fixable.
|
|
58
|
+
export const FIXABLE_CHECK_PATTERN = /(tests?|specs?|jest|vitest|mocha|junit|phpunit|lint|format|prettier|eslint|ruff|black|gofmt|rustfmt|type[\s-]?check|tsc|mypy|pyright|coverage|cov)\b/i;
|
|
59
|
+
export const MANUAL_CHECK_PATTERN = /\b(deploy|release|publish|build[\s-]?image|docker|e2e|end[\s-]?to[\s-]?end|integration|smoke|terraform|infra|migrat|codeql|security|scan|sign|notariz)\b/i;
|
|
60
|
+
export function classifyCheck(check) {
|
|
61
|
+
const haystack = `${check.name} ${check.workflow}`;
|
|
62
|
+
// Manual wins ties: a "deploy tests" job is safer treated as manual.
|
|
63
|
+
if (MANUAL_CHECK_PATTERN.test(haystack))
|
|
64
|
+
return "manual";
|
|
65
|
+
if (FIXABLE_CHECK_PATTERN.test(haystack))
|
|
66
|
+
return "fixable";
|
|
67
|
+
return "manual";
|
|
68
|
+
}
|
|
25
69
|
/**
|
|
26
70
|
* Check if the GitHub CLI (gh) is available on PATH.
|
|
27
71
|
* Runs: `which gh`
|
|
@@ -137,6 +181,90 @@ export async function getPRReviewCommentsAsync(prNumber) {
|
|
|
137
181
|
return null;
|
|
138
182
|
}
|
|
139
183
|
}
|
|
184
|
+
/** Per-process cache of the authenticated GitHub login (the "viewer"). */
|
|
185
|
+
let viewerLoginCache = null;
|
|
186
|
+
/**
|
|
187
|
+
* The authenticated GitHub user's login (GraphQL `viewer`). Cached per process.
|
|
188
|
+
* Used to gate auto-applying a review comment on the PR owner's OWN 👍 — a
|
|
189
|
+
* reviewer can't self-approve their comment for the fix loop.
|
|
190
|
+
*/
|
|
191
|
+
export function getViewerLoginAsync() {
|
|
192
|
+
if (viewerLoginCache)
|
|
193
|
+
return viewerLoginCache;
|
|
194
|
+
viewerLoginCache = (async () => {
|
|
195
|
+
try {
|
|
196
|
+
const { stdout } = await execAsync(`gh api graphql -f query='{ viewer { login } }' --jq .data.viewer.login`);
|
|
197
|
+
return stdout.trim() || null;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
return viewerLoginCache;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Fetch a PR's review threads with resolution state and per-comment 👍 reactions.
|
|
207
|
+
* The REST comments endpoint exposes neither, so this goes through GraphQL.
|
|
208
|
+
* Returns null if the repo can't be resolved or gh fails.
|
|
209
|
+
*/
|
|
210
|
+
export async function getPRReviewThreadsAsync(prNumber) {
|
|
211
|
+
const repo = await getRepoNameAsync();
|
|
212
|
+
if (!repo)
|
|
213
|
+
return null;
|
|
214
|
+
const [owner, name] = repo.split("/");
|
|
215
|
+
if (!owner || !name)
|
|
216
|
+
return null;
|
|
217
|
+
// Single-line query (GraphQL ignores whitespace); single-quoted for the shell.
|
|
218
|
+
// `first` bounds keep us well under GitHub's query-complexity cap.
|
|
219
|
+
const query = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){` +
|
|
220
|
+
`pullRequest(number:$number){reviewThreads(first:100){nodes{id isResolved isOutdated path line originalLine ` +
|
|
221
|
+
`comments(first:50){nodes{author{login} body path line originalLine diffHunk createdAt ` +
|
|
222
|
+
`reactions(first:50){nodes{content user{login}}}}}}}}}}`;
|
|
223
|
+
const output = await runAsync(`gh api graphql -f query='${query}' -f owner='${owner}' -f name='${name}' -F number=${Number(prNumber)}`);
|
|
224
|
+
if (!output)
|
|
225
|
+
return null;
|
|
226
|
+
try {
|
|
227
|
+
const data = JSON.parse(output);
|
|
228
|
+
const nodes = data?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
229
|
+
return nodes.map((t) => ({
|
|
230
|
+
id: String(t.id),
|
|
231
|
+
isResolved: !!t.isResolved,
|
|
232
|
+
isOutdated: !!t.isOutdated,
|
|
233
|
+
path: t.path ?? null,
|
|
234
|
+
line: t.line ?? t.originalLine ?? null,
|
|
235
|
+
comments: (t.comments?.nodes ?? []).map((c) => ({
|
|
236
|
+
author: c.author?.login ?? "unknown",
|
|
237
|
+
body: c.body ?? "",
|
|
238
|
+
path: c.path ?? null,
|
|
239
|
+
line: c.line ?? c.originalLine ?? null,
|
|
240
|
+
diffHunk: c.diffHunk ?? "",
|
|
241
|
+
createdAt: c.createdAt ?? "",
|
|
242
|
+
thumbsUpBy: (c.reactions?.nodes ?? [])
|
|
243
|
+
.filter((r) => r.content === "THUMBS_UP")
|
|
244
|
+
.map((r) => r.user?.login)
|
|
245
|
+
.filter(Boolean),
|
|
246
|
+
})),
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Filter review threads to the ones the fix loop should act on: **unresolved**
|
|
255
|
+
* AND whose **last comment carries a 👍 from `approver`** (the viewer's own
|
|
256
|
+
* approval). Resolved threads are dropped entirely — resolved means the ask was
|
|
257
|
+
* already applied or deliberately declined. The 👍 gate stops the loop from
|
|
258
|
+
* applying a comment the moment it's left, before the PR owner has vetted it.
|
|
259
|
+
*/
|
|
260
|
+
export function getApprovedReviewThreads(threads, approver) {
|
|
261
|
+
return threads.filter((t) => {
|
|
262
|
+
if (t.isResolved)
|
|
263
|
+
return false;
|
|
264
|
+
const last = t.comments[t.comments.length - 1];
|
|
265
|
+
return !!last && last.thumbsUpBy.includes(approver);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
140
268
|
/**
|
|
141
269
|
* Fetch structured conversation comments on a pull request (async).
|
|
142
270
|
*/
|
|
@@ -28,16 +28,112 @@ function findWorkspaceByTitle(title) {
|
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
// `list-pane-surfaces --workspace X` reports one pane (the active one) and its
|
|
32
|
+
// surfaces (tabs). Enough for our two flows: the work workspace has a single
|
|
33
|
+
// pane, and a freshly-created temp workspace has a single pane + single surface.
|
|
34
|
+
function paneSurfaces(workspaceRef) {
|
|
35
|
+
const r = cmuxRun(`cmux --json list-pane-surfaces --workspace ${shellEscape(workspaceRef)}`);
|
|
36
|
+
if (!r.ok)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(r.stdout);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function renameTab(workspaceRef, surfaceRef, title) {
|
|
46
|
+
cmuxRun(`cmux rename-tab --workspace ${shellEscape(workspaceRef)} --surface ${shellEscape(surfaceRef)} ${shellEscape(title)}`);
|
|
47
|
+
}
|
|
48
|
+
// cmux exposes workspace-group (sidebar folder) operations only over the RPC
|
|
49
|
+
// socket, not as first-class subcommands. `cmux rpc <method> '<json>'`.
|
|
50
|
+
function cmuxRpc(method, params) {
|
|
51
|
+
const arg = params ? ` ${shellEscape(JSON.stringify(params))}` : "";
|
|
52
|
+
const result = cmuxRun(`cmux rpc ${method}${arg}`);
|
|
53
|
+
if (!result.ok)
|
|
54
|
+
return { ok: false };
|
|
55
|
+
try {
|
|
56
|
+
return { ok: true, data: JSON.parse(result.stdout) };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { ok: false };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function findGroupIdByName(name) {
|
|
63
|
+
const r = cmuxRpc("workspace.group.list");
|
|
64
|
+
if (!r.ok)
|
|
65
|
+
return null;
|
|
66
|
+
const groups = r.data?.groups ?? [];
|
|
67
|
+
return groups.find((g) => g.name === name)?.id ?? null;
|
|
68
|
+
}
|
|
69
|
+
// Snapshot every workspace's current group membership (ref -> group id), so we
|
|
70
|
+
// can detect and undo the side effect described in `createGroup` below.
|
|
71
|
+
function snapshotMemberships() {
|
|
72
|
+
const map = new Map();
|
|
73
|
+
const r = cmuxRpc("workspace.group.list");
|
|
74
|
+
if (!r.ok)
|
|
75
|
+
return map;
|
|
76
|
+
for (const g of r.data?.groups ?? []) {
|
|
77
|
+
if (!g.id)
|
|
78
|
+
continue;
|
|
79
|
+
for (const ref of g.member_workspace_refs ?? [])
|
|
80
|
+
map.set(ref, g.id);
|
|
81
|
+
}
|
|
82
|
+
return map;
|
|
83
|
+
}
|
|
84
|
+
// Create a sidebar group (folder) named `name` and return its id.
|
|
85
|
+
//
|
|
86
|
+
// Two cmux quirks shape this:
|
|
87
|
+
// 1. A group renders as a folder whose header IS its "anchor" workspace
|
|
88
|
+
// (auto-titled with the group name); members nest under it. We must keep
|
|
89
|
+
// that anchor — closing it removes the folder label and cmux flattens the
|
|
90
|
+
// members back to top-level rows (visually ungrouped).
|
|
91
|
+
// 2. `workspace.group.create` also pulls the *currently-selected* workspace
|
|
92
|
+
// into the new group as a stray member. We undo that: restore each stray to
|
|
93
|
+
// whatever group it was in before (or detach it if none), so creating a
|
|
94
|
+
// project folder never disturbs the user's existing folders or their
|
|
95
|
+
// dashboard window.
|
|
96
|
+
function createGroup(name) {
|
|
97
|
+
const before = snapshotMemberships();
|
|
98
|
+
const created = cmuxRpc("workspace.group.create", { name });
|
|
99
|
+
if (!created.ok)
|
|
100
|
+
return null;
|
|
101
|
+
const group = created.data?.group;
|
|
102
|
+
if (!group?.id)
|
|
103
|
+
return null;
|
|
104
|
+
const anchor = group.anchor_workspace_ref;
|
|
105
|
+
for (const ref of group.member_workspace_refs ?? []) {
|
|
106
|
+
if (ref === anchor)
|
|
107
|
+
continue;
|
|
108
|
+
const prev = before.get(ref);
|
|
109
|
+
if (prev && prev !== group.id) {
|
|
110
|
+
cmuxRpc("workspace.group.add", { group_id: prev, workspace_id: ref });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
cmuxRpc("workspace.group.remove", { group_id: group.id, workspace_id: ref });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return group.id;
|
|
117
|
+
}
|
|
118
|
+
// Place an already-created workspace into a sidebar group named `groupName`,
|
|
119
|
+
// creating the group on first use. Best-effort: any failure leaves the
|
|
120
|
+
// workspace ungrouped but otherwise intact.
|
|
121
|
+
function placeWorkspaceInGroup(groupName, workspaceRef) {
|
|
122
|
+
const groupId = findGroupIdByName(groupName) ?? createGroup(groupName);
|
|
123
|
+
if (!groupId)
|
|
124
|
+
return;
|
|
125
|
+
cmuxRpc("workspace.group.add", { group_id: groupId, workspace_id: workspaceRef });
|
|
126
|
+
}
|
|
31
127
|
export const cmuxMultiplexer = {
|
|
32
128
|
kind: "cmux",
|
|
33
129
|
isActive() {
|
|
34
130
|
return !!process.env["CMUX_SURFACE_ID"];
|
|
35
131
|
},
|
|
36
|
-
async createWindow({ name, cwd, command }) {
|
|
132
|
+
async createWindow({ name, cwd, command, group, tabName, }) {
|
|
37
133
|
// `new-workspace` accepts --name, --cwd, --command in a single atomic call.
|
|
38
|
-
// `--command` sends "<text>\n" to the new surface after creation
|
|
39
|
-
//
|
|
40
|
-
//
|
|
134
|
+
// `--command` sends "<text>\n" to the new surface after creation, which IS
|
|
135
|
+
// the one reliable way to get a live PTY (cmux #1472 only breaks commands on
|
|
136
|
+
// surfaces created *after* the workspace exists).
|
|
41
137
|
const parts = [`cmux new-workspace --name ${shellEscape(name)} --cwd ${shellEscape(cwd)}`];
|
|
42
138
|
if (command)
|
|
43
139
|
parts.push(`--command ${shellEscape(command)}`);
|
|
@@ -45,6 +141,49 @@ export const cmuxMultiplexer = {
|
|
|
45
141
|
if (!created.ok) {
|
|
46
142
|
return { ok: false, reason: "failed", message: "cmux new-workspace failed" };
|
|
47
143
|
}
|
|
144
|
+
// Grouping / tab-naming are best-effort and must never fail the launch.
|
|
145
|
+
// `new-workspace` prints the new workspace ref (`OK workspace:N`) to stdout.
|
|
146
|
+
const ref = created.stdout.match(/workspace:\d+/)?.[0];
|
|
147
|
+
if (group && ref)
|
|
148
|
+
placeWorkspaceInGroup(group, ref);
|
|
149
|
+
if (tabName && ref) {
|
|
150
|
+
const surface = paneSurfaces(ref)?.surfaces?.[0]?.ref;
|
|
151
|
+
if (surface)
|
|
152
|
+
renameTab(ref, surface, tabName);
|
|
153
|
+
}
|
|
154
|
+
return { ok: true };
|
|
155
|
+
},
|
|
156
|
+
async addTab({ windowName, tabName, cwd, command, group }) {
|
|
157
|
+
const target = findWorkspaceByTitle(windowName);
|
|
158
|
+
// No existing workspace for this ticket — create one named after it, with
|
|
159
|
+
// the command as its (renamed) tab.
|
|
160
|
+
if (!target?.ref) {
|
|
161
|
+
return this.createWindow({ name: windowName, cwd, command, group, tabName });
|
|
162
|
+
}
|
|
163
|
+
const targetPane = paneSurfaces(target.ref)?.pane_ref;
|
|
164
|
+
if (!targetPane) {
|
|
165
|
+
return { ok: false, reason: "failed", message: "could not resolve target pane" };
|
|
166
|
+
}
|
|
167
|
+
// A surface only gets a live PTY when its command is baked in at workspace
|
|
168
|
+
// creation. So we spawn the command in a throwaway workspace, then relocate
|
|
169
|
+
// the (already-running) surface into the target pane and discard the husk.
|
|
170
|
+
const tmpName = `__santree_tab_${windowName}`;
|
|
171
|
+
const tmp = cmuxRun(`cmux new-workspace --name ${shellEscape(tmpName)} --cwd ${shellEscape(cwd)} --command ${shellEscape(command)}`);
|
|
172
|
+
if (!tmp.ok) {
|
|
173
|
+
return { ok: false, reason: "failed", message: "could not launch tab command" };
|
|
174
|
+
}
|
|
175
|
+
const tmpRef = tmp.stdout.match(/workspace:\d+/)?.[0];
|
|
176
|
+
const surface = tmpRef ? paneSurfaces(tmpRef)?.surfaces?.[0]?.ref : undefined;
|
|
177
|
+
if (!tmpRef || !surface) {
|
|
178
|
+
return { ok: false, reason: "failed", message: "could not resolve new tab surface" };
|
|
179
|
+
}
|
|
180
|
+
const moved = cmuxRun(`cmux move-surface --surface ${shellEscape(surface)} --pane ${shellEscape(targetPane)}`);
|
|
181
|
+
if (!moved.ok) {
|
|
182
|
+
// Leave the command running in its own workspace rather than losing it.
|
|
183
|
+
return { ok: false, reason: "failed", message: "could not move tab into workspace" };
|
|
184
|
+
}
|
|
185
|
+
cmuxRun(`cmux workspace close --workspace ${shellEscape(tmpRef)}`);
|
|
186
|
+
renameTab(target.ref, surface, tabName);
|
|
48
187
|
return { ok: true };
|
|
49
188
|
},
|
|
50
189
|
async selectWindow(name) {
|
|
@@ -66,17 +205,4 @@ export const cmuxMultiplexer = {
|
|
|
66
205
|
message: "blocked by manaflow-ai/cmux#1472",
|
|
67
206
|
};
|
|
68
207
|
},
|
|
69
|
-
isSessionAlive(ticketId) {
|
|
70
|
-
const result = cmuxRun("cmux --json list-workspaces");
|
|
71
|
-
if (!result.ok)
|
|
72
|
-
return false;
|
|
73
|
-
try {
|
|
74
|
-
const parsed = JSON.parse(result.stdout);
|
|
75
|
-
const items = parsed.workspaces ?? [];
|
|
76
|
-
return items.some((w) => typeof w.title === "string" && w.title.startsWith(ticketId));
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
208
|
};
|
|
@@ -7,13 +7,13 @@ export const noneMultiplexer = {
|
|
|
7
7
|
async createWindow() {
|
|
8
8
|
return NOT_ACTIVE;
|
|
9
9
|
},
|
|
10
|
+
async addTab() {
|
|
11
|
+
return NOT_ACTIVE;
|
|
12
|
+
},
|
|
10
13
|
async selectWindow() {
|
|
11
14
|
return NOT_ACTIVE;
|
|
12
15
|
},
|
|
13
16
|
sendCommand() {
|
|
14
17
|
return NOT_ACTIVE;
|
|
15
18
|
},
|
|
16
|
-
isSessionAlive() {
|
|
17
|
-
return false;
|
|
18
|
-
},
|
|
19
19
|
};
|
|
@@ -30,6 +30,11 @@ export const tmuxMultiplexer = {
|
|
|
30
30
|
}
|
|
31
31
|
return { ok: true };
|
|
32
32
|
},
|
|
33
|
+
async addTab({ windowName, tabName, cwd, command }) {
|
|
34
|
+
// tmux has no in-window tab concept, so a "tab" becomes its own window named
|
|
35
|
+
// `<tabName>-<windowName>` (e.g. fix-loop-TEAM-123).
|
|
36
|
+
return this.createWindow({ name: `${tabName}-${windowName}`, cwd, command });
|
|
37
|
+
},
|
|
33
38
|
async selectWindow(name) {
|
|
34
39
|
if (!this.isActive())
|
|
35
40
|
return { ok: false, reason: "not-active" };
|
|
@@ -42,33 +47,4 @@ export const tmuxMultiplexer = {
|
|
|
42
47
|
const ok = tmuxSync(`tmux send-keys -t ${shellEscape(name)} ${shellEscape(command)} Enter`);
|
|
43
48
|
return ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
44
49
|
},
|
|
45
|
-
isSessionAlive(ticketId) {
|
|
46
|
-
try {
|
|
47
|
-
const output = execSync('tmux list-windows -F "#{window_name}\t#{pane_pid}"', {
|
|
48
|
-
encoding: "utf-8",
|
|
49
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
50
|
-
}).trim();
|
|
51
|
-
for (const line of output.split("\n")) {
|
|
52
|
-
const [name, pidStr] = line.split("\t");
|
|
53
|
-
if (!name?.startsWith(ticketId))
|
|
54
|
-
continue;
|
|
55
|
-
if (!pidStr)
|
|
56
|
-
return false;
|
|
57
|
-
try {
|
|
58
|
-
const ps = execSync(`pgrep -P ${pidStr} -a`, {
|
|
59
|
-
encoding: "utf-8",
|
|
60
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
61
|
-
}).trim();
|
|
62
|
-
return ps.split("\n").some((proc) => proc.includes("claude"));
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
// tmux not available
|
|
71
|
-
}
|
|
72
|
-
return false;
|
|
73
|
-
},
|
|
74
50
|
};
|
|
@@ -10,13 +10,46 @@ export interface CreateWindowOpts {
|
|
|
10
10
|
name: string;
|
|
11
11
|
cwd: string;
|
|
12
12
|
command?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional sidebar group/folder to place the new window in. Backends that
|
|
15
|
+
* have no grouping concept (tmux, none) ignore it; cmux files the workspace
|
|
16
|
+
* under a group of this name (creating it on first use). Used to cluster all
|
|
17
|
+
* windows for one Linear project together.
|
|
18
|
+
*/
|
|
19
|
+
group?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Optional name for the window's initial tab/surface. cmux renames the tab
|
|
22
|
+
* after creation; backends without an in-window tab concept ignore it.
|
|
23
|
+
*/
|
|
24
|
+
tabName?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface AddTabOpts {
|
|
27
|
+
/** Name of the existing window/workspace to add the tab to. */
|
|
28
|
+
windowName: string;
|
|
29
|
+
/** Name for the new tab. */
|
|
30
|
+
tabName: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
command: string;
|
|
33
|
+
/**
|
|
34
|
+
* Group to use only if the target window doesn't exist yet and a fresh one
|
|
35
|
+
* has to be created as a fallback (cmux only).
|
|
36
|
+
*/
|
|
37
|
+
group?: string;
|
|
13
38
|
}
|
|
14
39
|
export interface Multiplexer {
|
|
15
40
|
readonly kind: MultiplexerKind;
|
|
16
41
|
isActive(): boolean;
|
|
17
42
|
createWindow(opts: CreateWindowOpts): Promise<SessionResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Add a tab running `command` to an existing window/workspace named
|
|
45
|
+
* `windowName`. cmux relocates a live surface into that workspace so the two
|
|
46
|
+
* tabs (e.g. `work` + `fix-loop`) share one ticket workspace. Backends with
|
|
47
|
+
* no in-window tab concept (tmux, none) create a separate window named
|
|
48
|
+
* `<tabName>-<windowName>` instead. Falls back to creating the window when it
|
|
49
|
+
* doesn't exist yet.
|
|
50
|
+
*/
|
|
51
|
+
addTab(opts: AddTabOpts): Promise<SessionResult>;
|
|
18
52
|
selectWindow(name: string): Promise<SessionResult>;
|
|
19
53
|
sendCommand(name: string, command: string): SessionResult;
|
|
20
|
-
isSessionAlive(ticketId: string): boolean;
|
|
21
54
|
}
|
|
22
55
|
export declare function shellEscape(s: string): string;
|
package/dist/lib/open-url.d.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* else → `xdg-open <url>`
|
|
5
5
|
*
|
|
6
6
|
* Returns true on apparent success, false on failure. Callers decide how to
|
|
7
|
-
* surface failures (e.g. a dashboard action message). Uses
|
|
8
|
-
*
|
|
7
|
+
* surface failures (e.g. a dashboard action message). Uses execFileSync (array
|
|
8
|
+
* args, no shell) so a `"` in a tracker/PR-supplied URL can't break or inject;
|
|
9
|
+
* `stdio: "ignore"` keeps output out of the dashboard's alt screen.
|
|
9
10
|
*/
|
|
10
11
|
export declare function openUrl(url: string): boolean;
|
package/dist/lib/open-url.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
2
|
/**
|
|
3
3
|
* Open a URL in the platform's default browser.
|
|
4
4
|
* macOS → `open <url>`
|
|
5
5
|
* else → `xdg-open <url>`
|
|
6
6
|
*
|
|
7
7
|
* Returns true on apparent success, false on failure. Callers decide how to
|
|
8
|
-
* surface failures (e.g. a dashboard action message). Uses
|
|
9
|
-
*
|
|
8
|
+
* surface failures (e.g. a dashboard action message). Uses execFileSync (array
|
|
9
|
+
* args, no shell) so a `"` in a tracker/PR-supplied URL can't break or inject;
|
|
10
|
+
* `stdio: "ignore"` keeps output out of the dashboard's alt screen.
|
|
10
11
|
*/
|
|
11
12
|
export function openUrl(url) {
|
|
12
13
|
try {
|
|
13
14
|
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
14
|
-
|
|
15
|
+
execFileSync(cmd, [url], { stdio: "ignore" });
|
|
15
16
|
return true;
|
|
16
17
|
}
|
|
17
18
|
catch {
|
package/dist/lib/prompts.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Issue } from "./trackers/types.js";
|
|
2
|
-
import type { PRCheck, PRReview, PRReviewComment, FailedCheckDetail, PRConversationComment } from "./github.js";
|
|
2
|
+
import type { PRCheck, PRReview, PRReviewComment, PRReviewThread, FailedCheckDetail, PRConversationComment } from "./github.js";
|
|
3
3
|
/**
|
|
4
4
|
* Render a nunjucks template from the prompts/ directory.
|
|
5
5
|
* @param template - Template name without extension (e.g. "fill-pr")
|
|
@@ -35,3 +35,37 @@ export interface PRData {
|
|
|
35
35
|
* Render PR feedback data into formatted markdown using the pr template.
|
|
36
36
|
*/
|
|
37
37
|
export declare function renderPR(data: PRData): string;
|
|
38
|
+
/** A failed check annotated with whether the fix loop should attempt it. */
|
|
39
|
+
export interface FixContextCheck extends FailedCheckDetail {
|
|
40
|
+
fixable: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface FixContextData {
|
|
43
|
+
pr_number: string;
|
|
44
|
+
pr_url: string;
|
|
45
|
+
branch: string;
|
|
46
|
+
base_branch: string;
|
|
47
|
+
conflicts: boolean;
|
|
48
|
+
mergeable: string;
|
|
49
|
+
merge_state: string;
|
|
50
|
+
failed_checks: FixContextCheck[];
|
|
51
|
+
fixable_count: number;
|
|
52
|
+
manual_count: number;
|
|
53
|
+
pending_count: number;
|
|
54
|
+
reviews: PRReview[] | null;
|
|
55
|
+
/** Unresolved review threads the viewer has 👍-approved for auto-apply. */
|
|
56
|
+
approved_comments: PRReviewThread[];
|
|
57
|
+
/**
|
|
58
|
+
* The single recommended action for this iteration, computed from state so
|
|
59
|
+
* the loop doesn't have to: `merge` (conflicts) | `work` (fixable CI and/or
|
|
60
|
+
* approved comments) | `wait` (nothing to do, CI still running) |
|
|
61
|
+
* `stop-stuck` (only manual failures left) | `stop-clean` (all done).
|
|
62
|
+
*/
|
|
63
|
+
directive: "merge" | "work" | "wait" | "stop-stuck" | "stop-clean";
|
|
64
|
+
/** Absolute santree invocation, for the `--signal` / resolve commands. */
|
|
65
|
+
santree_cmd: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Render the compact per-iteration brief the auto-fix loop consumes: conflict
|
|
69
|
+
* status, failing checks tagged fixable/manual, and open review asks.
|
|
70
|
+
*/
|
|
71
|
+
export declare function renderFixContext(data: FixContextData): string;
|
package/dist/lib/prompts.js
CHANGED
|
@@ -51,3 +51,10 @@ export function renderDiff(data) {
|
|
|
51
51
|
export function renderPR(data) {
|
|
52
52
|
return promptsEnv.render("pr.njk", data);
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Render the compact per-iteration brief the auto-fix loop consumes: conflict
|
|
56
|
+
* status, failing checks tagged fixable/manual, and open review asks.
|
|
57
|
+
*/
|
|
58
|
+
export function renderFixContext(data) {
|
|
59
|
+
return promptsEnv.render("fix-context.njk", data);
|
|
60
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TTY hand-off for
|
|
3
|
-
* gh auth login,
|
|
2
|
+
* TTY hand-off for config steps that run an interactive subprocess (brew install,
|
|
3
|
+
* gh auth login, Claude install). Mirrors lib/dashboard/external-editor.ts:
|
|
4
4
|
* drop raw mode so the child owns the terminal, run it synchronously (Ink can't
|
|
5
5
|
* repaint while the event loop is blocked), then restore raw mode.
|
|
6
6
|
*
|
package/dist/lib/setup/apply.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "child_process";
|
|
2
2
|
/**
|
|
3
|
-
* TTY hand-off for
|
|
4
|
-
* gh auth login,
|
|
3
|
+
* TTY hand-off for config steps that run an interactive subprocess (brew install,
|
|
4
|
+
* gh auth login, Claude install). Mirrors lib/dashboard/external-editor.ts:
|
|
5
5
|
* drop raw mode so the child owns the terminal, run it synchronously (Ink can't
|
|
6
6
|
* repaint while the event loop is blocked), then restore raw mode.
|
|
7
7
|
*
|
|
@@ -15,3 +15,10 @@ export declare function missingIgnoreEntries(repoRoot: string): string[];
|
|
|
15
15
|
* the entries actually written (skips any already present as literal lines).
|
|
16
16
|
*/
|
|
17
17
|
export declare function addIgnoreEntries(repoRoot: string, target: IgnoreTarget): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Remove santree's ignore entries (and an orphaned `# santree` header) from both
|
|
20
|
+
* candidate files. Returns the entries actually removed. Leaves every other line
|
|
21
|
+
* — including user-authored ignores — untouched, and collapses the blank lines
|
|
22
|
+
* left behind so the file doesn't accumulate gaps across enable/disable cycles.
|
|
23
|
+
*/
|
|
24
|
+
export declare function removeIgnoreEntries(repoRoot: string): string[];
|