xtrm-tools 0.5.26 → 0.5.27
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 +4 -0
- package/README.md +7 -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 +40 -7
- 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/cli/package.json
CHANGED
package/config/hooks.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
3
|
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"script": "using-xtrm-reminder.mjs"
|
|
6
|
+
},
|
|
4
7
|
{
|
|
5
8
|
"script": "beads-compact-restore.mjs",
|
|
6
9
|
"timeout": 5000
|
|
7
10
|
},
|
|
8
11
|
{
|
|
9
|
-
"script": "
|
|
10
|
-
"timeout":
|
|
11
|
-
}
|
|
12
|
-
],
|
|
13
|
-
"UserPromptSubmit": [
|
|
12
|
+
"script": "quality-check-env.mjs",
|
|
13
|
+
"timeout": 5000
|
|
14
|
+
},
|
|
14
15
|
{
|
|
15
|
-
"script": "
|
|
16
|
+
"script": "xtrm-session-logger.mjs",
|
|
16
17
|
"timeout": 3000
|
|
17
18
|
}
|
|
18
19
|
],
|
|
@@ -40,17 +41,17 @@
|
|
|
40
41
|
"timeout": 5000
|
|
41
42
|
},
|
|
42
43
|
{
|
|
43
|
-
"matcher": "Write|
|
|
44
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
44
45
|
"script": "quality-check.cjs",
|
|
45
46
|
"timeout": 30000
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
|
-
"matcher": "Write|
|
|
49
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
49
50
|
"script": "quality-check.py",
|
|
50
51
|
"timeout": 30000
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
|
-
"matcher": "Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview|mcp__serena__search_for_pattern|mcp__serena__find_referencing_symbols|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__rename_symbol",
|
|
54
|
+
"matcher": "Bash|Grep|Read|Glob|mcp__serena__find_symbol|mcp__serena__get_symbols_overview|mcp__serena__search_for_pattern|mcp__serena__find_referencing_symbols|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__rename_symbol",
|
|
54
55
|
"script": "gitnexus/gitnexus-hook.cjs",
|
|
55
56
|
"timeout": 10000
|
|
56
57
|
},
|
|
@@ -77,6 +78,6 @@
|
|
|
77
78
|
]
|
|
78
79
|
},
|
|
79
80
|
"statusLine": {
|
|
80
|
-
"script": "statusline
|
|
81
|
+
"script": "statusline.mjs"
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SubprocessRunner, EventAdapter } from "../core/lib";
|
|
6
6
|
|
|
7
7
|
export default function (pi: ExtensionAPI) {
|
|
8
8
|
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
@@ -21,8 +21,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
21
21
|
|
|
22
22
|
const getSessionClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
|
|
23
23
|
const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
24
|
-
if (result.code
|
|
25
|
-
|
|
24
|
+
if (result.code !== 0) return null;
|
|
25
|
+
const claim = result.stdout.trim();
|
|
26
|
+
return claim.length > 0 ? claim : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const clearClaimMarker = async (sessionId: string, cwd: string) => {
|
|
30
|
+
await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isIssueInProgress = async (issueId: string, cwd: string): Promise<boolean | null> => {
|
|
34
|
+
const result = await SubprocessRunner.run("bd", ["show", issueId, "--json"], { cwd });
|
|
35
|
+
if (result.code !== 0 || !result.stdout.trim()) return null;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(result.stdout);
|
|
38
|
+
const issue = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
39
|
+
if (!issue?.status) return null;
|
|
40
|
+
return issue.status === "in_progress";
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const getActiveClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
|
|
47
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
48
|
+
if (!claim) return null;
|
|
49
|
+
|
|
50
|
+
const inProgress = await isIssueInProgress(claim, cwd);
|
|
51
|
+
if (inProgress === false) {
|
|
52
|
+
await clearClaimMarker(sessionId, cwd);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return claim;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getClosedThisSession = async (sessionId: string, cwd: string): Promise<string | null> => {
|
|
60
|
+
const result = await SubprocessRunner.run("bd", ["kv", "get", `closed-this-session:${sessionId}`], { cwd });
|
|
61
|
+
if (result.code !== 0) return null;
|
|
62
|
+
const issue = result.stdout.trim();
|
|
63
|
+
return issue.length > 0 ? issue : null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const clearSessionMarkers = async (sessionId: string, cwd: string) => {
|
|
67
|
+
await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
|
|
68
|
+
await SubprocessRunner.run("bd", ["kv", "clear", `closed-this-session:${sessionId}`], { cwd });
|
|
26
69
|
};
|
|
27
70
|
|
|
28
71
|
const hasTrackableWork = async (cwd: string): Promise<boolean> => {
|
|
@@ -34,15 +77,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
77
|
return false;
|
|
35
78
|
};
|
|
36
79
|
|
|
37
|
-
const hasInProgressWork = async (cwd: string): Promise<boolean> => {
|
|
38
|
-
const result = await SubprocessRunner.run("bd", ["list"], { cwd });
|
|
39
|
-
if (result.code === 0 && result.stdout.includes("Total:")) {
|
|
40
|
-
const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
|
|
41
|
-
if (m) return parseInt(m[2], 10) > 0;
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
80
|
pi.on("session_start", async (_event, ctx) => {
|
|
47
81
|
cachedSessionId = ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? cachedSessionId;
|
|
48
82
|
return undefined;
|
|
@@ -54,33 +88,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
88
|
const sessionId = getSessionId(ctx);
|
|
55
89
|
|
|
56
90
|
if (EventAdapter.isMutatingFileTool(event)) {
|
|
57
|
-
const claim = await
|
|
91
|
+
const claim = await getActiveClaim(sessionId, cwd);
|
|
58
92
|
if (!claim) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
const hasWork = await hasTrackableWork(cwd);
|
|
94
|
+
if (hasWork) {
|
|
95
|
+
if (ctx.hasUI) {
|
|
96
|
+
ctx.ui.notify("Beads: Edit blocked. Claim an issue first.", "warning");
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
block: true,
|
|
100
|
+
reason: `No active claim for session ${sessionId}.\n bd update <id> --claim\n`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
70
104
|
}
|
|
71
105
|
|
|
72
106
|
if (isToolCallEventType("bash", event)) {
|
|
73
107
|
const command = event.input.command;
|
|
74
108
|
if (command && /\bgit\s+commit\b/.test(command)) {
|
|
75
|
-
const claim = await
|
|
109
|
+
const claim = await getActiveClaim(sessionId, cwd);
|
|
76
110
|
if (claim) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n (Pi workflow) publish/merge are external steps; do not rely on xtrm finish.\n`,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
111
|
+
return {
|
|
112
|
+
block: true,
|
|
113
|
+
reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n (Pi workflow) publish/merge are external steps; do not rely on xtrm finish.\n`,
|
|
114
|
+
};
|
|
84
115
|
}
|
|
85
116
|
}
|
|
86
117
|
}
|
|
@@ -89,65 +120,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
89
120
|
});
|
|
90
121
|
|
|
91
122
|
pi.on("tool_result", async (event, ctx) => {
|
|
92
|
-
if (isBashToolResult(event))
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
123
|
+
if (!isBashToolResult(event)) return undefined;
|
|
124
|
+
|
|
125
|
+
const command = event.input.command || "";
|
|
126
|
+
const sessionId = getSessionId(ctx);
|
|
127
|
+
const cwd = getCwd(ctx);
|
|
128
|
+
|
|
129
|
+
// Auto-claim on bd update --claim regardless of exit code.
|
|
130
|
+
if (/\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
|
|
131
|
+
const issueMatch = command.match(/\bbd\s+update\s+(\S+)/);
|
|
132
|
+
if (issueMatch) {
|
|
133
|
+
const issueId = issueMatch[1];
|
|
134
|
+
await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
|
|
135
|
+
memoryGateFired = false;
|
|
136
|
+
const claimNotice = `\n\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
|
|
137
|
+
return { content: [...event.content, { type: "text", text: claimNotice }] };
|
|
107
138
|
}
|
|
139
|
+
}
|
|
108
140
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
141
|
+
if (/\bbd\s+close\b/.test(command) && !event.isError) {
|
|
142
|
+
const closeMatch = command.match(/\bbd\s+close\s+(\S+)/);
|
|
143
|
+
const closedIssueId = closeMatch?.[1] ?? null;
|
|
144
|
+
if (closedIssueId) {
|
|
145
|
+
await SubprocessRunner.run("bd", ["kv", "set", `closed-this-session:${sessionId}`, closedIssueId], { cwd });
|
|
146
|
+
memoryGateFired = false;
|
|
113
147
|
}
|
|
148
|
+
const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
|
|
149
|
+
return { content: [...event.content, { type: "text", text: reminder }] };
|
|
114
150
|
}
|
|
151
|
+
|
|
115
152
|
return undefined;
|
|
116
153
|
});
|
|
117
154
|
|
|
118
|
-
//
|
|
119
|
-
|
|
155
|
+
// Memory gate: if this session closed an issue, prompt for insight persistence.
|
|
156
|
+
// Uses sendUserMessage to trigger a new turn in Pi (non-blocking alternative to Claude Stop hook).
|
|
157
|
+
const triggerMemoryGateIfNeeded = async (ctx: any) => {
|
|
120
158
|
const cwd = getCwd(ctx);
|
|
121
159
|
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
122
160
|
const sessionId = getSessionId(ctx);
|
|
123
|
-
const claim = await getSessionClaim(sessionId, cwd);
|
|
124
|
-
if (!claim) return;
|
|
125
161
|
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
const markerPath = join(cwd, ".beads", ".memory-gate-done");
|
|
163
|
+
if (existsSync(markerPath)) {
|
|
164
|
+
try { unlinkSync(markerPath); } catch { /* ignore */ }
|
|
165
|
+
await clearSessionMarkers(sessionId, cwd);
|
|
166
|
+
memoryGateFired = false;
|
|
167
|
+
return;
|
|
131
168
|
}
|
|
132
|
-
};
|
|
133
169
|
|
|
134
|
-
// Memory gate: if the session's claimed issue was closed, prompt for insights.
|
|
135
|
-
// Uses sendUserMessage to trigger a new agent turn (Pi has no blocking stop hook).
|
|
136
|
-
// memoryGateFired prevents re-triggering on the follow-up agent_end.
|
|
137
|
-
const triggerMemoryGateIfNeeded = async (ctx: any) => {
|
|
138
170
|
if (memoryGateFired) return;
|
|
139
|
-
const cwd = getCwd(ctx);
|
|
140
|
-
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
141
|
-
const sessionId = getSessionId(ctx);
|
|
142
|
-
const claimId = await getSessionClaim(sessionId, cwd);
|
|
143
|
-
if (!claimId) return;
|
|
144
171
|
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
172
|
+
const closedIssueId = await getClosedThisSession(sessionId, cwd);
|
|
173
|
+
if (!closedIssueId) return;
|
|
147
174
|
|
|
148
175
|
memoryGateFired = true;
|
|
149
176
|
pi.sendUserMessage(
|
|
150
|
-
`🧠 Memory gate: claim \`${
|
|
177
|
+
`🧠 Memory gate: claim \`${closedIssueId}\` was closed this session.\n` +
|
|
151
178
|
`For each closed issue, worth persisting?\n` +
|
|
152
179
|
` YES → \`bd remember "<insight>"\`\n` +
|
|
153
180
|
` NO → note "nothing to persist"\n` +
|
|
@@ -156,13 +183,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
156
183
|
};
|
|
157
184
|
|
|
158
185
|
pi.on("agent_end", async (_event, ctx) => {
|
|
159
|
-
await notifySessionEnd(ctx);
|
|
160
186
|
await triggerMemoryGateIfNeeded(ctx);
|
|
161
187
|
return undefined;
|
|
162
188
|
});
|
|
163
189
|
|
|
164
190
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
165
|
-
await
|
|
191
|
+
await triggerMemoryGateIfNeeded(ctx);
|
|
166
192
|
return undefined;
|
|
167
193
|
});
|
|
168
194
|
}
|
|
@@ -99,11 +99,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
99
99
|
return "";
|
|
100
100
|
};
|
|
101
101
|
|
|
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();
|
|
102
|
+
let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
106
103
|
|
|
104
|
+
const applyCustomFooter = (ctx: any) => {
|
|
105
|
+
capturedCtx = ctx;
|
|
107
106
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
108
107
|
requestRender = () => tui.requestRender();
|
|
109
108
|
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
@@ -133,7 +132,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
133
132
|
const modelChip = chip(modelId);
|
|
134
133
|
|
|
135
134
|
const sep = " ";
|
|
136
|
-
|
|
137
135
|
const brandModel = `${brand} ${modelChip}`;
|
|
138
136
|
const beadChip = buildBeadChip();
|
|
139
137
|
const leftParts = [brandModel, usageStr];
|
|
@@ -141,11 +139,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
141
139
|
leftParts.push(cwdStr);
|
|
142
140
|
if (branchStr) leftParts.push(branchStr);
|
|
143
141
|
|
|
144
|
-
|
|
145
|
-
return [truncateToWidth(left, width)];
|
|
142
|
+
return [truncateToWidth(leftParts.join(sep), width)];
|
|
146
143
|
},
|
|
147
144
|
};
|
|
148
145
|
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const scheduleFooterReapply = (ctx: any, delayMs = 40) => {
|
|
149
|
+
if (footerReapplyTimer) clearTimeout(footerReapplyTimer);
|
|
150
|
+
footerReapplyTimer = setTimeout(() => {
|
|
151
|
+
applyCustomFooter(ctx);
|
|
152
|
+
footerReapplyTimer = null;
|
|
153
|
+
}, delayMs);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
157
|
+
capturedCtx = ctx;
|
|
158
|
+
// Get session ID from sessionManager/context (prefer UUID, consistent with hooks)
|
|
159
|
+
sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
|
|
160
|
+
applyCustomFooter(ctx);
|
|
161
|
+
// pi-dex reapplies footer on session start with setTimeout(0); reclaim after that pass.
|
|
162
|
+
scheduleFooterReapply(ctx);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
166
|
+
scheduleFooterReapply(ctx);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
pi.on("session_fork", async (_event, ctx) => {
|
|
170
|
+
scheduleFooterReapply(ctx);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
174
|
+
scheduleFooterReapply(ctx);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
pi.on("session_shutdown", async () => {
|
|
178
|
+
if (footerReapplyTimer) {
|
|
179
|
+
clearTimeout(footerReapplyTimer);
|
|
180
|
+
footerReapplyTimer = null;
|
|
181
|
+
}
|
|
149
182
|
});
|
|
150
183
|
|
|
151
184
|
// Bust the bead cache immediately after any bd write
|
|
@@ -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;
|