xtrm-tools 0.5.26 → 0.5.28
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +5 -0
- package/README.md +8 -1
- package/cli/dist/index.cjs +263 -306
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/hooks.json +11 -10
- package/config/pi/extensions/beads/index.ts +103 -77
- package/config/pi/extensions/custom-footer/index.ts +245 -49
- package/config/pi/extensions/quality-gates/index.ts +28 -29
- package/config/pi/extensions/session-flow/index.ts +50 -21
- package/config/pi/extensions/xtrm-loader/index.ts +38 -24
- package/hooks/hooks.json +14 -0
- package/package.json +1 -1
- package/plugins/xtrm-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/xtrm-tools/hooks/hooks.json +14 -0
- package/plugins/xtrm-tools/skills/planning/SKILL.md +350 -0
- package/plugins/xtrm-tools/skills/planning/evals/evals.json +19 -0
- package/skills/planning/SKILL.md +350 -0
- package/skills/planning/evals/evals.json +19 -0
- package/config/pi/extensions/plan-mode/README.md +0 -65
- package/config/pi/extensions/plan-mode/index.ts +0 -417
- package/config/pi/extensions/plan-mode/package.json +0 -12
- package/config/pi/extensions/plan-mode/utils.ts +0 -324
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* XTRM Custom Footer Extension
|
|
3
3
|
*
|
|
4
|
-
* Displays: XTRM brand,
|
|
4
|
+
* Displays: XTRM brand, model/context, host, cwd, git branch/status, beads state.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
8
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import { basename, relative } from "node:path";
|
|
10
|
+
import { hostname } from "node:os";
|
|
9
11
|
|
|
10
12
|
import { SubprocessRunner, EventAdapter } from "../core/lib";
|
|
11
13
|
|
|
@@ -13,25 +15,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
13
15
|
interface BeadState {
|
|
14
16
|
claimId: string | null;
|
|
15
17
|
shortId: string | null;
|
|
18
|
+
claimTitle: string | null;
|
|
16
19
|
status: string | null;
|
|
17
20
|
openCount: number;
|
|
18
21
|
lastFetch: number;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
interface RuntimeState {
|
|
25
|
+
host: string;
|
|
26
|
+
displayDir: string;
|
|
27
|
+
branch: string | null;
|
|
28
|
+
gitStatus: string;
|
|
29
|
+
venv: string | null;
|
|
30
|
+
lastFetch: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
const STATUS_ICONS: Record<string, string> = {
|
|
22
34
|
open: "○",
|
|
23
35
|
in_progress: "◐",
|
|
24
36
|
blocked: "●",
|
|
25
37
|
closed: "✓",
|
|
26
38
|
};
|
|
39
|
+
|
|
27
40
|
// Chip background colours (raw ANSI — theme has no bg() API)
|
|
28
|
-
const CHIP_BG_NEUTRAL
|
|
29
|
-
const CHIP_BG_ACTIVE
|
|
30
|
-
const CHIP_BG_BLOCKED
|
|
31
|
-
const CHIP_FG
|
|
32
|
-
const CHIP_RESET
|
|
33
|
-
const chip = (text: string, bg = CHIP_BG_NEUTRAL): string =>
|
|
34
|
-
`${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
|
|
41
|
+
const CHIP_BG_NEUTRAL = "\x1b[48;5;238m";
|
|
42
|
+
const CHIP_BG_ACTIVE = "\x1b[48;5;39m";
|
|
43
|
+
const CHIP_BG_BLOCKED = "\x1b[48;5;88m";
|
|
44
|
+
const CHIP_FG = "\x1b[38;5;15m";
|
|
45
|
+
const CHIP_RESET = "\x1b[0m";
|
|
35
46
|
|
|
36
47
|
const STATUS_BG: Record<string, string> = {
|
|
37
48
|
open: CHIP_BG_NEUTRAL,
|
|
@@ -39,35 +50,144 @@ export default function (pi: ExtensionAPI) {
|
|
|
39
50
|
blocked: CHIP_BG_BLOCKED,
|
|
40
51
|
};
|
|
41
52
|
|
|
53
|
+
const chip = (text: string, bg = CHIP_BG_NEUTRAL): string => `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
|
|
54
|
+
|
|
42
55
|
let capturedCtx: any = null;
|
|
43
|
-
let sessionId
|
|
44
|
-
let beadState: BeadState = { claimId: null, shortId: null, status: null, openCount: 0, lastFetch: 0 };
|
|
45
|
-
let refreshing = false;
|
|
56
|
+
let sessionId = "";
|
|
46
57
|
let requestRender: (() => void) | null = null;
|
|
58
|
+
|
|
47
59
|
const CACHE_TTL = 5000;
|
|
60
|
+
let refreshingBeads = false;
|
|
61
|
+
let refreshingRuntime = false;
|
|
62
|
+
|
|
63
|
+
let beadState: BeadState = {
|
|
64
|
+
claimId: null,
|
|
65
|
+
shortId: null,
|
|
66
|
+
claimTitle: null,
|
|
67
|
+
status: null,
|
|
68
|
+
openCount: 0,
|
|
69
|
+
lastFetch: 0,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let runtimeState: RuntimeState = {
|
|
73
|
+
host: hostname().split(".")[0] || "host",
|
|
74
|
+
displayDir: process.cwd(),
|
|
75
|
+
branch: null,
|
|
76
|
+
gitStatus: "",
|
|
77
|
+
venv: process.env.VIRTUAL_ENV ? basename(process.env.VIRTUAL_ENV) : null,
|
|
78
|
+
lastFetch: 0,
|
|
79
|
+
};
|
|
48
80
|
|
|
49
81
|
const getCwd = () => capturedCtx?.cwd || process.cwd();
|
|
50
82
|
const getShortId = (id: string) => id.split("-").pop() ?? id;
|
|
51
83
|
|
|
84
|
+
const parseGitFlags = (porcelain: string): string => {
|
|
85
|
+
let modified = false;
|
|
86
|
+
let staged = false;
|
|
87
|
+
let deleted = false;
|
|
88
|
+
for (const line of porcelain.split("\n").filter(Boolean)) {
|
|
89
|
+
if (/^ M|^AM|^MM/.test(line)) modified = true;
|
|
90
|
+
if (/^A |^M /.test(line)) staged = true;
|
|
91
|
+
if (/^ D|^D /.test(line)) deleted = true;
|
|
92
|
+
}
|
|
93
|
+
return `${modified ? "*" : ""}${staged ? "+" : ""}${deleted ? "-" : ""}`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const refreshRuntimeState = async () => {
|
|
97
|
+
if (refreshingRuntime || Date.now() - runtimeState.lastFetch < CACHE_TTL) return;
|
|
98
|
+
refreshingRuntime = true;
|
|
99
|
+
const cwd = getCwd();
|
|
100
|
+
try {
|
|
101
|
+
const host = hostname().split(".")[0] || "host";
|
|
102
|
+
const venv = process.env.VIRTUAL_ENV ? basename(process.env.VIRTUAL_ENV) : null;
|
|
103
|
+
const rootResult = await SubprocessRunner.run("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
104
|
+
const repoRoot = rootResult.code === 0 ? rootResult.stdout.trim() : null;
|
|
105
|
+
|
|
106
|
+
const displayDir = repoRoot
|
|
107
|
+
? (() => {
|
|
108
|
+
const relPath = relative(repoRoot, cwd) || ".";
|
|
109
|
+
return relPath === "." ? basename(repoRoot) : `${basename(repoRoot)}/${relPath}`;
|
|
110
|
+
})()
|
|
111
|
+
: (() => {
|
|
112
|
+
const parts = cwd.split("/");
|
|
113
|
+
return parts.length > 2 ? parts.slice(-2).join("/") : cwd;
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
let branch: string | null = null;
|
|
117
|
+
let gitStatus = "";
|
|
118
|
+
if (repoRoot) {
|
|
119
|
+
const branchResult = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
|
|
120
|
+
branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
|
|
121
|
+
|
|
122
|
+
const porcelainResult = await SubprocessRunner.run("git", ["--no-optional-locks", "status", "--porcelain"], { cwd });
|
|
123
|
+
const baseFlags = porcelainResult.code === 0 ? parseGitFlags(porcelainResult.stdout) : "";
|
|
124
|
+
|
|
125
|
+
let upstreamFlags = "";
|
|
126
|
+
const abResult = await SubprocessRunner.run(
|
|
127
|
+
"git",
|
|
128
|
+
["--no-optional-locks", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
|
|
129
|
+
{ cwd },
|
|
130
|
+
);
|
|
131
|
+
if (abResult.code === 0) {
|
|
132
|
+
const [behindRaw, aheadRaw] = abResult.stdout.trim().split(/\s+/);
|
|
133
|
+
const behind = Number(behindRaw || 0);
|
|
134
|
+
const ahead = Number(aheadRaw || 0);
|
|
135
|
+
if (ahead > 0 && behind > 0) upstreamFlags = "↕";
|
|
136
|
+
else if (ahead > 0) upstreamFlags = "↑";
|
|
137
|
+
else if (behind > 0) upstreamFlags = "↓";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
gitStatus = `${baseFlags}${upstreamFlags}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
runtimeState = {
|
|
144
|
+
host,
|
|
145
|
+
displayDir,
|
|
146
|
+
branch,
|
|
147
|
+
gitStatus,
|
|
148
|
+
venv,
|
|
149
|
+
lastFetch: Date.now(),
|
|
150
|
+
};
|
|
151
|
+
requestRender?.();
|
|
152
|
+
} catch {
|
|
153
|
+
// Fail soft — keep last known runtime state.
|
|
154
|
+
} finally {
|
|
155
|
+
refreshingRuntime = false;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
52
159
|
const refreshBeadState = async () => {
|
|
53
|
-
if (
|
|
160
|
+
if (refreshingBeads || Date.now() - beadState.lastFetch < CACHE_TTL) return;
|
|
54
161
|
const cwd = getCwd();
|
|
55
|
-
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
56
|
-
|
|
57
|
-
refreshing = true;
|
|
162
|
+
if (!EventAdapter.isBeadsProject(cwd) || !sessionId) return;
|
|
163
|
+
refreshingBeads = true;
|
|
58
164
|
try {
|
|
59
165
|
const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
60
166
|
const claimId = claimResult.code === 0 ? claimResult.stdout.trim() || null : null;
|
|
61
167
|
|
|
62
168
|
let status: string | null = null;
|
|
169
|
+
let claimTitle: string | null = null;
|
|
63
170
|
if (claimId) {
|
|
64
171
|
const showResult = await SubprocessRunner.run("bd", ["show", claimId, "--json"], { cwd });
|
|
65
172
|
if (showResult.code === 0) {
|
|
66
|
-
try {
|
|
173
|
+
try {
|
|
174
|
+
const issue = JSON.parse(showResult.stdout)?.[0];
|
|
175
|
+
status = issue?.status ?? null;
|
|
176
|
+
claimTitle = issue?.title ?? null;
|
|
177
|
+
} catch {
|
|
178
|
+
// keep nulls
|
|
179
|
+
}
|
|
67
180
|
}
|
|
68
181
|
if (status === "closed") {
|
|
69
182
|
await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
|
|
70
|
-
beadState = {
|
|
183
|
+
beadState = {
|
|
184
|
+
claimId: null,
|
|
185
|
+
shortId: null,
|
|
186
|
+
claimTitle: null,
|
|
187
|
+
status: null,
|
|
188
|
+
openCount: beadState.openCount,
|
|
189
|
+
lastFetch: Date.now(),
|
|
190
|
+
};
|
|
71
191
|
requestRender?.();
|
|
72
192
|
return;
|
|
73
193
|
}
|
|
@@ -80,10 +200,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
200
|
if (m) openCount = parseInt(m[1], 10);
|
|
81
201
|
}
|
|
82
202
|
|
|
83
|
-
beadState = {
|
|
203
|
+
beadState = {
|
|
204
|
+
claimId,
|
|
205
|
+
shortId: claimId ? getShortId(claimId) : null,
|
|
206
|
+
claimTitle,
|
|
207
|
+
status,
|
|
208
|
+
openCount,
|
|
209
|
+
lastFetch: Date.now(),
|
|
210
|
+
};
|
|
84
211
|
requestRender?.();
|
|
85
|
-
} catch {
|
|
86
|
-
|
|
212
|
+
} catch {
|
|
213
|
+
// Fail soft — keep last known beads state.
|
|
214
|
+
} finally {
|
|
215
|
+
refreshingBeads = false;
|
|
216
|
+
}
|
|
87
217
|
};
|
|
88
218
|
|
|
89
219
|
const buildBeadChip = (): string => {
|
|
@@ -93,68 +223,134 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
223
|
const bg = STATUS_BG[status] ?? CHIP_BG_NEUTRAL;
|
|
94
224
|
return chip(`bd:${shortId}${icon}`, bg);
|
|
95
225
|
}
|
|
226
|
+
if (openCount > 0) return chip(`bd:${openCount}${STATUS_ICONS.open}`);
|
|
227
|
+
return "";
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const buildIssueLine = (width: number, theme: any): string => {
|
|
231
|
+
const { shortId, claimTitle, openCount } = beadState;
|
|
232
|
+
if (shortId && claimTitle) {
|
|
233
|
+
const prefix = `◐ ${shortId} `;
|
|
234
|
+
const title = theme.fg("muted", claimTitle);
|
|
235
|
+
return truncateToWidth(`${prefix}${title}`, width);
|
|
236
|
+
}
|
|
96
237
|
if (openCount > 0) {
|
|
97
|
-
return
|
|
238
|
+
return truncateToWidth(`○ ${openCount} open`, width);
|
|
98
239
|
}
|
|
99
|
-
return "";
|
|
240
|
+
return truncateToWidth("○ no open issues", width);
|
|
100
241
|
};
|
|
101
242
|
|
|
102
|
-
|
|
103
|
-
capturedCtx = ctx;
|
|
104
|
-
// Get session ID from sessionManager/context (prefer UUID, consistent with hooks)
|
|
105
|
-
sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
|
|
243
|
+
let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
106
244
|
|
|
245
|
+
const applyCustomFooter = (ctx: any) => {
|
|
246
|
+
capturedCtx = ctx;
|
|
107
247
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
108
248
|
requestRender = () => tui.requestRender();
|
|
109
|
-
const unsub = footerData.onBranchChange(() =>
|
|
249
|
+
const unsub = footerData.onBranchChange(() => {
|
|
250
|
+
runtimeState.lastFetch = 0;
|
|
251
|
+
tui.requestRender();
|
|
252
|
+
});
|
|
110
253
|
|
|
111
254
|
return {
|
|
112
|
-
dispose() {
|
|
255
|
+
dispose() {
|
|
256
|
+
unsub();
|
|
257
|
+
requestRender = null;
|
|
258
|
+
},
|
|
113
259
|
invalidate() {},
|
|
114
260
|
render(width: number): string[] {
|
|
261
|
+
refreshRuntimeState().catch(() => {});
|
|
115
262
|
refreshBeadState().catch(() => {});
|
|
116
263
|
|
|
117
|
-
const BOLD = "\x1b[1m"
|
|
264
|
+
const BOLD = "\x1b[1m";
|
|
265
|
+
const BOLD_OFF = "\x1b[22m";
|
|
118
266
|
const brand = `${BOLD}${theme.fg("accent", "XTRM")}${BOLD_OFF}`;
|
|
119
267
|
|
|
120
268
|
const usage = ctx.getContextUsage();
|
|
121
269
|
const pct = usage?.percent ?? 0;
|
|
122
270
|
const pctColor = pct > 75 ? "error" : pct > 50 ? "warning" : "success";
|
|
123
|
-
const usageStr = theme.fg(pctColor,
|
|
124
|
-
|
|
125
|
-
const parts = process.cwd().split("/");
|
|
126
|
-
const short = parts.length > 2 ? parts.slice(-2).join("/") : process.cwd();
|
|
127
|
-
const cwdStr = theme.fg("muted", `⌂ ${short}`);
|
|
128
|
-
|
|
129
|
-
const branch = footerData.getGitBranch();
|
|
130
|
-
const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
|
|
271
|
+
const usageStr = theme.fg(pctColor, `[${pct.toFixed(0)}%]`);
|
|
131
272
|
|
|
132
273
|
const modelId = ctx.model?.id || "no-model";
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
const sep = " ";
|
|
274
|
+
const modelStr = `${modelId} ${usageStr}`;
|
|
136
275
|
|
|
137
|
-
const
|
|
276
|
+
const branchFromFooter = footerData.getGitBranch();
|
|
277
|
+
const branch = runtimeState.branch || branchFromFooter;
|
|
278
|
+
const branchWithStatus = branch
|
|
279
|
+
? runtimeState.gitStatus
|
|
280
|
+
? `${branch} (${runtimeState.gitStatus})`
|
|
281
|
+
: branch
|
|
282
|
+
: "";
|
|
283
|
+
const branchStr = branchWithStatus ? theme.fg("muted", branchWithStatus) : "";
|
|
284
|
+
const hostStr = theme.fg("muted", runtimeState.host);
|
|
285
|
+
const cwdStr = `${BOLD}${runtimeState.displayDir}${BOLD_OFF}`;
|
|
286
|
+
const venvStr = runtimeState.venv ? theme.fg("muted", `(${runtimeState.venv})`) : "";
|
|
138
287
|
const beadChip = buildBeadChip();
|
|
139
|
-
const leftParts = [brandModel, usageStr];
|
|
140
|
-
if (beadChip) leftParts.push(beadChip);
|
|
141
|
-
leftParts.push(cwdStr);
|
|
142
|
-
if (branchStr) leftParts.push(branchStr);
|
|
143
288
|
|
|
144
|
-
const
|
|
145
|
-
|
|
289
|
+
const line1Parts = [brand, modelStr, hostStr, cwdStr];
|
|
290
|
+
if (branchStr) line1Parts.push(branchStr);
|
|
291
|
+
if (venvStr) line1Parts.push(venvStr);
|
|
292
|
+
if (beadChip) line1Parts.push(beadChip);
|
|
293
|
+
|
|
294
|
+
const line1 = truncateToWidth(line1Parts.join(" "), width);
|
|
295
|
+
const line2 = buildIssueLine(width, theme);
|
|
296
|
+
return [line1, line2];
|
|
146
297
|
},
|
|
147
298
|
};
|
|
148
299
|
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const scheduleFooterReapply = (ctx: any, delayMs = 40) => {
|
|
303
|
+
if (footerReapplyTimer) clearTimeout(footerReapplyTimer);
|
|
304
|
+
footerReapplyTimer = setTimeout(() => {
|
|
305
|
+
applyCustomFooter(ctx);
|
|
306
|
+
footerReapplyTimer = null;
|
|
307
|
+
}, delayMs);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
311
|
+
capturedCtx = ctx;
|
|
312
|
+
sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
|
|
313
|
+
runtimeState.lastFetch = 0;
|
|
314
|
+
beadState.lastFetch = 0;
|
|
315
|
+
applyCustomFooter(ctx);
|
|
316
|
+
scheduleFooterReapply(ctx);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
320
|
+
runtimeState.lastFetch = 0;
|
|
321
|
+
scheduleFooterReapply(ctx);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
pi.on("session_fork", async (_event, ctx) => {
|
|
325
|
+
runtimeState.lastFetch = 0;
|
|
326
|
+
scheduleFooterReapply(ctx);
|
|
149
327
|
});
|
|
150
328
|
|
|
151
|
-
|
|
329
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
330
|
+
runtimeState.lastFetch = 0;
|
|
331
|
+
scheduleFooterReapply(ctx);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
pi.on("session_shutdown", async () => {
|
|
335
|
+
if (footerReapplyTimer) {
|
|
336
|
+
clearTimeout(footerReapplyTimer);
|
|
337
|
+
footerReapplyTimer = null;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Bust caches immediately after relevant writes
|
|
152
342
|
pi.on("tool_result", async (event: any) => {
|
|
153
343
|
const cmd = event?.input?.command;
|
|
154
|
-
if (cmd
|
|
344
|
+
if (!cmd) return undefined;
|
|
345
|
+
|
|
346
|
+
if (/\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
|
|
155
347
|
beadState.lastFetch = 0;
|
|
156
348
|
setTimeout(() => refreshBeadState().catch(() => {}), 200);
|
|
157
349
|
}
|
|
350
|
+
if (/\bgit\s+/.test(cmd)) {
|
|
351
|
+
runtimeState.lastFetch = 0;
|
|
352
|
+
setTimeout(() => refreshRuntimeState().catch(() => {}), 200);
|
|
353
|
+
}
|
|
158
354
|
return undefined;
|
|
159
355
|
});
|
|
160
356
|
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
2
|
-
import { SubprocessRunner, EventAdapter
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { SubprocessRunner, EventAdapter } from "../core/lib";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
function resolveQualityHook(cwd: string, ext: string): { runner: string; scriptPath: string } | null {
|
|
7
|
+
if ([".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"].includes(ext)) {
|
|
8
|
+
const scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.cjs");
|
|
9
|
+
return { runner: "node", scriptPath };
|
|
10
|
+
}
|
|
11
|
+
if (ext === ".py") {
|
|
12
|
+
const scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.py");
|
|
13
|
+
return { runner: "python3", scriptPath };
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
export default function (pi: ExtensionAPI) {
|
|
9
19
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -15,27 +25,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
15
25
|
|
|
16
26
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
17
27
|
const ext = path.extname(fullPath);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
23
|
-
scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.cjs");
|
|
24
|
-
runner = "node";
|
|
25
|
-
} else if (ext === ".py") {
|
|
26
|
-
scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.py");
|
|
27
|
-
runner = "python3";
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (!scriptPath || !fs.existsSync(scriptPath)) return undefined;
|
|
28
|
+
const resolved = resolveQualityHook(cwd, ext);
|
|
29
|
+
if (!resolved) return undefined;
|
|
30
|
+
if (!fs.existsSync(resolved.scriptPath)) return undefined;
|
|
31
31
|
|
|
32
32
|
const hookInput = JSON.stringify({
|
|
33
33
|
tool_name: event.toolName,
|
|
34
34
|
tool_input: event.input,
|
|
35
|
-
cwd
|
|
35
|
+
cwd,
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
const result = await SubprocessRunner.run(runner, [scriptPath], {
|
|
38
|
+
const result = await SubprocessRunner.run(resolved.runner, [resolved.scriptPath], {
|
|
39
39
|
cwd,
|
|
40
40
|
input: hookInput,
|
|
41
41
|
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
@@ -43,23 +43,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
if (result.code === 0) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
return undefined;
|
|
46
|
+
const details = (result.stdout || result.stderr || "").trim();
|
|
47
|
+
if (!details) return undefined;
|
|
48
|
+
return {
|
|
49
|
+
content: [...event.content, { type: "text", text: `\n\n**Quality Gate**: ${details}` }],
|
|
50
|
+
};
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
if (result.code === 2) {
|
|
55
|
-
const
|
|
56
|
-
newContent.push({ type: "text", text: `\n\n**Quality Gate FAILED**:\n${result.stderr || result.stdout || "Unknown error"}` });
|
|
57
|
-
|
|
54
|
+
const details = (result.stderr || result.stdout || "Unknown error").trim();
|
|
58
55
|
if (ctx.hasUI) {
|
|
59
56
|
ctx.ui.notify(`Quality Gate failed for ${path.basename(fullPath)}`, "error");
|
|
60
57
|
}
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
return {
|
|
59
|
+
isError: true,
|
|
60
|
+
content: [...event.content, { type: "text", text: `\n\n**Quality Gate FAILED**:\n${details}` }],
|
|
61
|
+
};
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
return undefined;
|
|
@@ -14,8 +14,39 @@ function isWorktree(cwd: string): boolean {
|
|
|
14
14
|
return cwd.includes("/.xtrm/worktrees/") || cwd.includes("/.claude/worktrees/");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function getSessionId(ctx: any): string {
|
|
18
|
+
return ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? process.pid.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getSessionClaim(cwd: string, sessionId: string): Promise<string | null> {
|
|
22
|
+
const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
23
|
+
if (claimResult.code !== 0) return null;
|
|
24
|
+
const claimId = claimResult.stdout.trim();
|
|
25
|
+
return claimId.length > 0 ? claimId : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function isClaimStillInProgress(cwd: string, issueId: string): Promise<boolean> {
|
|
29
|
+
const showResult = await SubprocessRunner.run("bd", ["show", issueId, "--json"], { cwd });
|
|
30
|
+
if (showResult.code === 0 && showResult.stdout.trim()) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(showResult.stdout);
|
|
33
|
+
const record = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
34
|
+
if (record?.status) return record.status === "in_progress";
|
|
35
|
+
} catch {
|
|
36
|
+
// fall back to text parsing below
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const listResult = await SubprocessRunner.run("bd", ["list", "--status=in_progress"], { cwd });
|
|
41
|
+
if (listResult.code !== 0) return false;
|
|
42
|
+
const issuePattern = new RegExp(`^\\s*[◐●]?\\s*${issueId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "m");
|
|
43
|
+
return issuePattern.test(listResult.stdout);
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export default function (pi: ExtensionAPI) {
|
|
18
47
|
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
48
|
+
let lastStopNoticeIssue: string | null = null;
|
|
49
|
+
let lastWorktreeReminderCwd: string | null = null;
|
|
19
50
|
|
|
20
51
|
// Claim sync: notify when a bd update --claim command is run.
|
|
21
52
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -31,35 +62,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
31
62
|
return { content: [...event.content, { type: "text", text }] };
|
|
32
63
|
});
|
|
33
64
|
|
|
34
|
-
// Stop gate:
|
|
35
|
-
//
|
|
65
|
+
// Stop gate: warn (non-blocking) if this session's claimed issue is still in progress.
|
|
66
|
+
// IMPORTANT: never call sendUserMessage() from agent_end, it always triggers a new turn.
|
|
36
67
|
pi.on("agent_end", async (_event, ctx) => {
|
|
37
68
|
const cwd = getCwd(ctx);
|
|
38
69
|
if (!EventAdapter.isBeadsProject(cwd)) return undefined;
|
|
39
70
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const idMatch = output.match(/^\s*([a-zA-Z0-9._-]+)\s+in_progress/m);
|
|
51
|
-
const issueId = idMatch ? idMatch[1] : "<id>";
|
|
52
|
-
pi.sendUserMessage(
|
|
53
|
-
`Stop blocked: close your issue first: bd close ${issueId}`,
|
|
54
|
-
);
|
|
71
|
+
const sessionId = getSessionId(ctx);
|
|
72
|
+
const claimId = await getSessionClaim(cwd, sessionId);
|
|
73
|
+
|
|
74
|
+
if (claimId) {
|
|
75
|
+
const inProgress = await isClaimStillInProgress(cwd, claimId);
|
|
76
|
+
if (inProgress) {
|
|
77
|
+
if (lastStopNoticeIssue !== claimId && ctx.hasUI) {
|
|
78
|
+
ctx.ui.notify(`Stop blocked: close your issue first: bd close ${claimId}`, "warning");
|
|
79
|
+
lastStopNoticeIssue = claimId;
|
|
80
|
+
}
|
|
55
81
|
return undefined;
|
|
56
82
|
}
|
|
83
|
+
|
|
84
|
+
if (lastStopNoticeIssue === claimId) {
|
|
85
|
+
lastStopNoticeIssue = null;
|
|
86
|
+
}
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
if (isWorktree(cwd)) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
89
|
+
if (isWorktree(cwd) && ctx.hasUI && lastWorktreeReminderCwd !== cwd) {
|
|
90
|
+
ctx.ui.notify("Run `xt end` to create a PR and clean up this worktree.", "info");
|
|
91
|
+
lastWorktreeReminderCwd = cwd;
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
return undefined;
|