xtrm-tools 2.1.29 → 2.2.0
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/cli/dist/index.cjs +32 -0
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/hooks.json +8 -6
- package/config/pi/extensions/beads.ts +61 -19
- package/config/pi/extensions/core/adapter.ts +19 -0
- package/config/pi/extensions/custom-footer.ts +22 -15
- package/config/pi/extensions/main-guard.ts +3 -3
- package/config/pi/extensions/service-skills.ts +0 -32
- package/config/pi/extensions/xtrm-loader.ts +4 -4
- package/hooks/beads-claim-sync.mjs +55 -21
- package/hooks/beads-commit-gate.mjs +5 -3
- package/hooks/beads-edit-gate.mjs +6 -6
- package/hooks/beads-gate-messages.mjs +21 -17
- package/hooks/branch-state.mjs +51 -0
- package/hooks/main-guard-post-push.mjs +9 -7
- package/hooks/main-guard.mjs +6 -4
- package/package.json +1 -1
- package/project-skills/tdd-guard/.claude/skills/using-tdd-guard/SKILL.md +5 -0
package/cli/package.json
CHANGED
package/config/hooks.json
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"script": "serena-workflow-reminder.py"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
|
+
"UserPromptSubmit": [
|
|
13
|
+
{
|
|
14
|
+
"script": "branch-state.mjs",
|
|
15
|
+
"timeout": 3000
|
|
16
|
+
}
|
|
17
|
+
],
|
|
12
18
|
"PreToolUse": [
|
|
13
19
|
{
|
|
14
20
|
"matcher": "Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
@@ -20,10 +26,6 @@
|
|
|
20
26
|
"script": "main-guard.mjs",
|
|
21
27
|
"timeout": 5000
|
|
22
28
|
},
|
|
23
|
-
{
|
|
24
|
-
"matcher": "Read|Edit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
25
|
-
"script": "serena-workflow-reminder.py"
|
|
26
|
-
},
|
|
27
29
|
{
|
|
28
30
|
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
29
31
|
"script": "beads-edit-gate.mjs",
|
|
@@ -37,7 +39,7 @@
|
|
|
37
39
|
],
|
|
38
40
|
"PostToolUse": [
|
|
39
41
|
{
|
|
40
|
-
"matcher": "Bash",
|
|
42
|
+
"matcher": "Bash|execute_shell_command|bash",
|
|
41
43
|
"script": "beads-claim-sync.mjs",
|
|
42
44
|
"timeout": 5000
|
|
43
45
|
},
|
|
@@ -47,7 +49,7 @@
|
|
|
47
49
|
"timeout": 5000
|
|
48
50
|
},
|
|
49
51
|
{
|
|
50
|
-
"matcher": "
|
|
52
|
+
"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",
|
|
51
53
|
"script": "gitnexus/gitnexus-hook.cjs",
|
|
52
54
|
"timeout": 10000
|
|
53
55
|
}
|
|
@@ -1,43 +1,60 @@
|
|
|
1
1
|
import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
4
|
import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
|
|
6
5
|
|
|
7
6
|
const logger = new Logger({ namespace: "beads" });
|
|
8
7
|
|
|
9
8
|
export default function (pi: ExtensionAPI) {
|
|
10
9
|
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
11
|
-
const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
const sessionId = process.pid.toString();
|
|
11
|
+
let cachedSessionId: string | null = null;
|
|
15
12
|
|
|
16
|
-
|
|
13
|
+
// Resolve a stable session ID across event types.
|
|
14
|
+
const getSessionId = (ctx: any): string => {
|
|
15
|
+
const fromManager = ctx?.sessionManager?.getSessionId?.();
|
|
16
|
+
const fromContext = ctx?.sessionId ?? ctx?.session_id;
|
|
17
|
+
const resolved = fromManager || fromContext || cachedSessionId || process.pid.toString();
|
|
18
|
+
if (resolved && !cachedSessionId) cachedSessionId = resolved;
|
|
19
|
+
return resolved;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getSessionClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
|
|
17
23
|
const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
18
24
|
if (result.code === 0) return result.stdout.trim();
|
|
19
25
|
return null;
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
const hasTrackableWork = async (cwd: string): Promise<boolean> => {
|
|
29
|
+
const result = await SubprocessRunner.run("bd", ["list"], { cwd });
|
|
30
|
+
if (result.code === 0) {
|
|
31
|
+
const counts = EventAdapter.parseBdCounts(result.stdout);
|
|
32
|
+
if (counts) return (counts.open + counts.inProgress) > 0;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const hasInProgressWork = async (cwd: string): Promise<boolean> => {
|
|
23
38
|
const result = await SubprocessRunner.run("bd", ["list"], { cwd });
|
|
24
39
|
if (result.code === 0 && result.stdout.includes("Total:")) {
|
|
25
40
|
const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
|
|
26
|
-
if (m)
|
|
27
|
-
const open = parseInt(m[1], 10);
|
|
28
|
-
const inProgress = parseInt(m[2], 10);
|
|
29
|
-
return (open + inProgress) > 0;
|
|
30
|
-
}
|
|
41
|
+
if (m) return parseInt(m[2], 10) > 0;
|
|
31
42
|
}
|
|
32
43
|
return false;
|
|
33
44
|
};
|
|
34
45
|
|
|
46
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
47
|
+
cachedSessionId = ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? cachedSessionId;
|
|
48
|
+
return undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
35
51
|
pi.on("tool_call", async (event, ctx) => {
|
|
36
52
|
const cwd = getCwd(ctx);
|
|
37
|
-
if (!isBeadsProject(cwd)) return undefined;
|
|
53
|
+
if (!EventAdapter.isBeadsProject(cwd)) return undefined;
|
|
54
|
+
const sessionId = getSessionId(ctx);
|
|
38
55
|
|
|
39
56
|
if (EventAdapter.isMutatingFileTool(event)) {
|
|
40
|
-
const claim = await getSessionClaim(cwd);
|
|
57
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
41
58
|
if (!claim) {
|
|
42
59
|
const hasWork = await hasTrackableWork(cwd);
|
|
43
60
|
if (hasWork) {
|
|
@@ -46,7 +63,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
46
63
|
}
|
|
47
64
|
return {
|
|
48
65
|
block: true,
|
|
49
|
-
reason: `No active
|
|
66
|
+
reason: `No active claim for session ${sessionId}.\n bd update <id> --claim\n`,
|
|
50
67
|
};
|
|
51
68
|
}
|
|
52
69
|
}
|
|
@@ -55,12 +72,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
55
72
|
if (isToolCallEventType("bash", event)) {
|
|
56
73
|
const command = event.input.command;
|
|
57
74
|
if (command && /\bgit\s+commit\b/.test(command)) {
|
|
58
|
-
|
|
75
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
59
76
|
if (claim) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
const inProgress = await hasInProgressWork(cwd);
|
|
78
|
+
if (inProgress) {
|
|
79
|
+
return {
|
|
80
|
+
block: true,
|
|
81
|
+
reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n git push -u origin <feature-branch>\n gh pr create --fill && gh pr merge --squash\n`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
64
84
|
}
|
|
65
85
|
}
|
|
66
86
|
}
|
|
@@ -71,6 +91,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
71
91
|
pi.on("tool_result", async (event, ctx) => {
|
|
72
92
|
if (isBashToolResult(event)) {
|
|
73
93
|
const command = event.input.command;
|
|
94
|
+
const sessionId = getSessionId(ctx);
|
|
74
95
|
|
|
75
96
|
// Auto-claim on bd update --claim regardless of exit code.
|
|
76
97
|
// bd returns exit 1 with "already in_progress" when status unchanged — still a valid claim intent.
|
|
@@ -80,7 +101,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
101
|
const issueId = issueMatch[1];
|
|
81
102
|
const cwd = getCwd(ctx);
|
|
82
103
|
await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
|
|
83
|
-
const claimNotice = `\n\n✅ **Beads**: Session
|
|
104
|
+
const claimNotice = `\n\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
|
|
84
105
|
return { content: [...event.content, { type: "text", text: claimNotice }] };
|
|
85
106
|
}
|
|
86
107
|
}
|
|
@@ -93,4 +114,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
114
|
}
|
|
94
115
|
return undefined;
|
|
95
116
|
});
|
|
117
|
+
|
|
118
|
+
// Dual safety net: notify about unclosed claims when session ends
|
|
119
|
+
const notifySessionEnd = async (ctx: any) => {
|
|
120
|
+
const cwd = getCwd(ctx);
|
|
121
|
+
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
122
|
+
const sessionId = getSessionId(ctx);
|
|
123
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
124
|
+
if (claim && ctx.hasUI) {
|
|
125
|
+
ctx.ui.notify(`Beads: Session ending with active claim [${claim}]`, "warning");
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
130
|
+
await notifySessionEnd(ctx);
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
135
|
+
await notifySessionEnd(ctx);
|
|
136
|
+
return undefined;
|
|
137
|
+
});
|
|
96
138
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as nodePath from "node:path";
|
|
3
|
+
|
|
1
4
|
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
5
|
|
|
3
6
|
export class EventAdapter {
|
|
@@ -42,4 +45,20 @@ export class EventAdapter {
|
|
|
42
45
|
static formatBlockReason(prefix: string, details: string): string {
|
|
43
46
|
return `${prefix}: ${details}`;
|
|
44
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Returns true if the given directory is a beads project (has a .beads directory).
|
|
50
|
+
*/
|
|
51
|
+
static isBeadsProject(cwd: string): boolean {
|
|
52
|
+
return fs.existsSync(nodePath.join(cwd, ".beads"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses the summary line from `bd list` output.
|
|
57
|
+
* Returns { open, inProgress } or null if the line is absent.
|
|
58
|
+
*/
|
|
59
|
+
static parseBdCounts(output: string): { open: number; inProgress: number } | null {
|
|
60
|
+
const m = output.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return { open: parseInt(m[1], 10), inProgress: parseInt(m[2], 10) };
|
|
63
|
+
}
|
|
45
64
|
}
|
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
8
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import * as fs from "node:fs";
|
|
12
|
-
import { SubprocessRunner } from "./core/lib";
|
|
10
|
+
import { SubprocessRunner, EventAdapter } from "./core/lib";
|
|
13
11
|
|
|
14
12
|
export default function (pi: ExtensionAPI) {
|
|
15
13
|
interface BeadState {
|
|
@@ -26,10 +24,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
26
24
|
blocked: "●",
|
|
27
25
|
closed: "✓",
|
|
28
26
|
};
|
|
27
|
+
// Chip background colours (raw ANSI — theme has no bg() API)
|
|
28
|
+
const CHIP_BG_NEUTRAL = "\x1b[48;5;238m"; // dark gray
|
|
29
|
+
const CHIP_BG_ACTIVE = "\x1b[48;5;39m"; // blue
|
|
30
|
+
const CHIP_BG_BLOCKED = "\x1b[48;5;88m"; // red
|
|
31
|
+
const CHIP_FG = "\x1b[38;5;15m"; // white
|
|
32
|
+
const CHIP_RESET = "\x1b[0m";
|
|
33
|
+
const chip = (text: string, bg = CHIP_BG_NEUTRAL): string =>
|
|
34
|
+
`${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
|
|
35
|
+
|
|
29
36
|
const STATUS_BG: Record<string, string> = {
|
|
30
|
-
open:
|
|
31
|
-
in_progress:
|
|
32
|
-
blocked:
|
|
37
|
+
open: CHIP_BG_NEUTRAL,
|
|
38
|
+
in_progress: CHIP_BG_ACTIVE,
|
|
39
|
+
blocked: CHIP_BG_BLOCKED,
|
|
33
40
|
};
|
|
34
41
|
|
|
35
42
|
let capturedCtx: any = null;
|
|
@@ -40,13 +47,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
40
47
|
const CACHE_TTL = 5000;
|
|
41
48
|
|
|
42
49
|
const getCwd = () => capturedCtx?.cwd || process.cwd();
|
|
43
|
-
const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
|
|
44
50
|
const getShortId = (id: string) => id.split("-").pop() ?? id;
|
|
45
51
|
|
|
46
52
|
const refreshBeadState = async () => {
|
|
47
53
|
if (refreshing || Date.now() - beadState.lastFetch < CACHE_TTL) return;
|
|
48
54
|
const cwd = getCwd();
|
|
49
|
-
if (!isBeadsProject(cwd)) return;
|
|
55
|
+
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
50
56
|
if (!sessionId) return;
|
|
51
57
|
refreshing = true;
|
|
52
58
|
try {
|
|
@@ -84,19 +90,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
84
90
|
const { claimId, shortId, status, openCount } = beadState;
|
|
85
91
|
if (claimId && shortId && status) {
|
|
86
92
|
const icon = STATUS_ICONS[status] ?? "?";
|
|
87
|
-
const bg = STATUS_BG[status] ??
|
|
88
|
-
return
|
|
93
|
+
const bg = STATUS_BG[status] ?? CHIP_BG_NEUTRAL;
|
|
94
|
+
return chip(`bd:${shortId}${icon}`, bg);
|
|
89
95
|
}
|
|
90
96
|
if (openCount > 0) {
|
|
91
|
-
return
|
|
97
|
+
return chip(`bd:${openCount}${STATUS_ICONS.open}`);
|
|
92
98
|
}
|
|
93
99
|
return "";
|
|
94
100
|
};
|
|
95
101
|
|
|
96
102
|
pi.on("session_start", async (_event, ctx) => {
|
|
97
103
|
capturedCtx = ctx;
|
|
98
|
-
// Get session ID from sessionManager (UUID, consistent with hooks)
|
|
99
|
-
sessionId = ctx.sessionManager?.getSessionId?.() ?? process.pid.toString();
|
|
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();
|
|
100
106
|
|
|
101
107
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
102
108
|
requestRender = () => tui.requestRender();
|
|
@@ -108,7 +114,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
108
114
|
render(width: number): string[] {
|
|
109
115
|
refreshBeadState().catch(() => {});
|
|
110
116
|
|
|
111
|
-
const
|
|
117
|
+
const BOLD = "\x1b[1m", BOLD_OFF = "\x1b[22m";
|
|
118
|
+
const brand = `${BOLD}${theme.fg("accent", "XTRM")}${BOLD_OFF}`;
|
|
112
119
|
|
|
113
120
|
const usage = ctx.getContextUsage();
|
|
114
121
|
const pct = usage?.percent ?? 0;
|
|
@@ -123,7 +130,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
123
130
|
const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
|
|
124
131
|
|
|
125
132
|
const modelId = ctx.model?.id || "no-model";
|
|
126
|
-
const modelChip =
|
|
133
|
+
const modelChip = chip(modelId);
|
|
127
134
|
|
|
128
135
|
const sep = theme.fg("dim", " | ");
|
|
129
136
|
|
|
@@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
54
|
if (branch && protectedBranches.includes(branch)) {
|
|
55
55
|
// A. Mutating File Tools on Main
|
|
56
56
|
if (EventAdapter.isMutatingFileTool(event)) {
|
|
57
|
-
const reason = `On protected branch '${branch}'
|
|
57
|
+
const reason = `On protected branch '${branch}' — start on a feature branch and claim an issue.\n git checkout -b feature/<name>\n bd update <id> --claim\n`;
|
|
58
58
|
if (ctx.hasUI) {
|
|
59
59
|
ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
|
|
60
60
|
}
|
|
@@ -97,7 +97,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
97
|
|
|
98
98
|
// Specific blocks
|
|
99
99
|
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
100
|
-
return { block: true, reason: `No commits on '${branch}' — use a feature branch
|
|
100
|
+
return { block: true, reason: `No commits on '${branch}' — use a feature branch.\n git checkout -b feature/<name>\n bd update <id> --claim\n` };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
if (/\bgit\s+push\b/.test(cmd)) {
|
|
@@ -113,7 +113,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// Default deny
|
|
116
|
-
const reason = `Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n Exit: git checkout -b feature/<name
|
|
116
|
+
const reason = `Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n Exit: git checkout -b feature/<name>\n Then: bd update <id> --claim\n Override: MAIN_GUARD_ALLOW_BASH=1 <cmd>\n`;
|
|
117
117
|
if (ctx.hasUI) {
|
|
118
118
|
ctx.ui.notify("Main-Guard: Command blocked", "error");
|
|
119
119
|
}
|
|
@@ -26,38 +26,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
26
26
|
return undefined;
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
// 2. Territory Activation
|
|
30
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
31
|
-
const cwd = getCwd(ctx);
|
|
32
|
-
const activatorPath = path.join(cwd, ".claude", "skills", "using-service-skills", "scripts", "skill_activator.py");
|
|
33
|
-
if (!fs.existsSync(activatorPath)) return undefined;
|
|
34
|
-
|
|
35
|
-
const hookInput = JSON.stringify({
|
|
36
|
-
tool_name: event.toolName === "bash" ? "Bash" : event.toolName,
|
|
37
|
-
tool_input: event.input,
|
|
38
|
-
cwd: cwd
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const result = await SubprocessRunner.run("python3", [activatorPath], {
|
|
42
|
-
cwd,
|
|
43
|
-
input: hookInput,
|
|
44
|
-
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
45
|
-
timeoutMs: 5000
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
49
|
-
try {
|
|
50
|
-
const parsed = JSON.parse(result.stdout.trim());
|
|
51
|
-
const context = parsed.hookSpecificOutput?.additionalContext;
|
|
52
|
-
if (context && ctx.hasUI) {
|
|
53
|
-
ctx.ui.notify(context, "info");
|
|
54
|
-
}
|
|
55
|
-
} catch (e) {
|
|
56
|
-
logger.error("Failed to parse skill_activator output", e);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
});
|
|
61
29
|
|
|
62
30
|
// 3. Drift Detection
|
|
63
31
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -40,7 +40,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
40
40
|
|
|
41
41
|
for (const p of roadmapPaths) {
|
|
42
42
|
if (fs.existsSync(p)) {
|
|
43
|
-
const content = fs.
|
|
43
|
+
const content = await fs.promises.readFile(p, "utf8");
|
|
44
44
|
contextParts.push(`## Project Roadmap & Architecture (${path.relative(cwd, p)})\n\n${content}`);
|
|
45
45
|
break; // Only load the first one found
|
|
46
46
|
}
|
|
@@ -51,10 +51,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
51
|
if (fs.existsSync(rulesDir)) {
|
|
52
52
|
const ruleFiles = findMarkdownFiles(rulesDir);
|
|
53
53
|
if (ruleFiles.length > 0) {
|
|
54
|
-
const rulesContent = ruleFiles.map(f => {
|
|
55
|
-
const content = fs.
|
|
54
|
+
const rulesContent = (await Promise.all(ruleFiles.map(async f => {
|
|
55
|
+
const content = await fs.promises.readFile(path.join(rulesDir, f), "utf8");
|
|
56
56
|
return `### Rule: ${f}\n${content}`;
|
|
57
|
-
}).join("\n\n");
|
|
57
|
+
}))).join("\n\n");
|
|
58
58
|
contextParts.push(`## Project Rules\n\n${rulesContent}`);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// beads-claim-sync — PostToolUse hook
|
|
3
|
-
// Auto-sets
|
|
4
|
-
// Uses session_id from hook input (UUID, matches Pi's sessionManager.getSessionId())
|
|
3
|
+
// Auto-sets kv claim on bd update --claim; auto-clears on bd close.
|
|
5
4
|
|
|
6
5
|
import { spawnSync } from 'node:child_process';
|
|
7
6
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -19,44 +18,79 @@ function isBeadsProject(cwd) {
|
|
|
19
18
|
return existsSync(join(cwd, '.beads'));
|
|
20
19
|
}
|
|
21
20
|
|
|
21
|
+
function isShellTool(toolName) {
|
|
22
|
+
return toolName === 'Bash' || toolName === 'bash' || toolName === 'execute_shell_command';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function commandSucceeded(payload) {
|
|
26
|
+
const tr = payload?.tool_response ?? payload?.tool_result ?? payload?.result;
|
|
27
|
+
if (!tr || typeof tr !== 'object') return true;
|
|
28
|
+
|
|
29
|
+
if (tr.success === false) return false;
|
|
30
|
+
if (tr.error) return false;
|
|
31
|
+
|
|
32
|
+
const numeric = [tr.exit_code, tr.exitCode, tr.status, tr.returncode].find((v) => Number.isInteger(v));
|
|
33
|
+
if (typeof numeric === 'number' && numeric !== 0) return false;
|
|
34
|
+
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
function main() {
|
|
23
39
|
const input = readInput();
|
|
24
40
|
if (!input || input.hook_event_name !== 'PostToolUse') process.exit(0);
|
|
25
|
-
if (input.tool_name
|
|
41
|
+
if (!isShellTool(input.tool_name)) process.exit(0);
|
|
26
42
|
|
|
27
43
|
const cwd = input.cwd || process.cwd();
|
|
28
44
|
if (!isBeadsProject(cwd)) process.exit(0);
|
|
29
45
|
|
|
30
46
|
const command = input.tool_input?.command || '';
|
|
31
|
-
|
|
47
|
+
const sessionId = input.session_id ?? input.sessionId;
|
|
48
|
+
|
|
49
|
+
if (!sessionId) {
|
|
50
|
+
process.stderr.write('Beads claim sync: no session_id in hook input\n');
|
|
32
51
|
process.exit(0);
|
|
33
52
|
}
|
|
34
53
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
// Auto-claim: bd update <id> --claim (fire regardless of exit code — bd returns 1 for "already in_progress")
|
|
55
|
+
if (/\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
|
|
56
|
+
const match = command.match(/\bbd\s+update\s+(\S+)/);
|
|
57
|
+
if (match) {
|
|
58
|
+
const issueId = match[1];
|
|
59
|
+
const result = spawnSync('bd', ['kv', 'set', `claimed:${sessionId}`, issueId], {
|
|
60
|
+
cwd,
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
});
|
|
38
64
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
if (result.status !== 0) {
|
|
66
|
+
const err = (result.stderr || result.stdout || '').toString().trim();
|
|
67
|
+
if (err) process.stderr.write(`Beads claim sync warning: ${err}\n`);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
42
70
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
process.stdout.write(JSON.stringify({
|
|
72
|
+
additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.`,
|
|
73
|
+
}));
|
|
74
|
+
process.stdout.write('\n');
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
46
77
|
}
|
|
47
78
|
|
|
48
|
-
|
|
49
|
-
|
|
79
|
+
// Auto-clear: bd close <id> — remove the kv claim so commit gate unblocks
|
|
80
|
+
if (/\bbd\s+close\b/.test(command) && commandSucceeded(input)) {
|
|
81
|
+
const result = spawnSync('bd', ['kv', 'clear', `claimed:${sessionId}`], {
|
|
50
82
|
cwd,
|
|
51
83
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
84
|
timeout: 5000,
|
|
53
85
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
|
|
87
|
+
if (result.status === 0) {
|
|
88
|
+
process.stdout.write(JSON.stringify({
|
|
89
|
+
additionalContext: `\n🔓 **Beads**: Session claim cleared. Ready to commit.`,
|
|
90
|
+
}));
|
|
91
|
+
process.stdout.write('\n');
|
|
92
|
+
}
|
|
93
|
+
process.exit(0);
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
process.exit(0);
|
|
@@ -32,7 +32,9 @@ withSafeBdContext(() => {
|
|
|
32
32
|
|
|
33
33
|
if (decision.allow) process.exit(0);
|
|
34
34
|
|
|
35
|
-
// Block with
|
|
36
|
-
|
|
37
|
-
process.
|
|
35
|
+
// Block with structured decision
|
|
36
|
+
const reason = commitBlockMessage(decision.summary, decision.claimed);
|
|
37
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
38
|
+
process.stdout.write('\n');
|
|
39
|
+
process.exit(0);
|
|
38
40
|
});
|
|
@@ -29,10 +29,10 @@ withSafeBdContext(() => {
|
|
|
29
29
|
if (decision.allow) process.exit(0);
|
|
30
30
|
|
|
31
31
|
// Block with appropriate message
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
process.exit(
|
|
32
|
+
const reason = decision.reason === 'no_claim_with_work'
|
|
33
|
+
? editBlockMessage(decision.sessionId)
|
|
34
|
+
: editBlockFallbackMessage();
|
|
35
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
36
|
+
process.stdout.write('\n');
|
|
37
|
+
process.exit(0);
|
|
38
38
|
});
|
|
@@ -5,32 +5,36 @@
|
|
|
5
5
|
// All user-facing strings live here. Edit this file to change messaging.
|
|
6
6
|
// Policy logic lives in beads-gate-core.mjs.
|
|
7
7
|
|
|
8
|
-
// ── Shared workflow steps
|
|
8
|
+
// ── Shared workflow steps ────────────────────────────────────────────
|
|
9
9
|
|
|
10
10
|
export const WORKFLOW_STEPS =
|
|
11
11
|
' 1. git checkout -b feature/<name>\n' +
|
|
12
|
-
' 2. bd
|
|
12
|
+
' 2. bd update <id> --claim\n' +
|
|
13
13
|
' 3. Edit / write code\n' +
|
|
14
|
-
' 4. bd close <id
|
|
15
|
-
' 5. git
|
|
16
|
-
' 6.
|
|
17
|
-
' 7.
|
|
14
|
+
' 4. bd close <id>\n' +
|
|
15
|
+
' 5. git add -p && git commit -m "<message>"\n' +
|
|
16
|
+
' 6. git push -u origin feature/<name>\n' +
|
|
17
|
+
' 7. gh pr create --fill && gh pr merge --squash\n' +
|
|
18
|
+
' 8. git checkout master && git reset --hard origin/master\n';
|
|
18
19
|
|
|
19
20
|
export const SESSION_CLOSE_PROTOCOL =
|
|
20
|
-
' bd close <id
|
|
21
|
+
' bd close <id>\n' +
|
|
22
|
+
' git add -p && git commit -m "<message>"\n' +
|
|
23
|
+
' git push -u origin <feature-branch>\n' +
|
|
24
|
+
' gh pr create --fill && gh pr merge --squash\n';
|
|
21
25
|
|
|
22
26
|
export const COMMIT_NEXT_STEPS =
|
|
23
|
-
' bd close <id
|
|
27
|
+
' bd close <id>\n' +
|
|
28
|
+
' git add -p && git commit -m "<message>"\n' +
|
|
24
29
|
' git push -u origin <feature-branch>\n' +
|
|
25
|
-
' gh pr create --fill && gh pr merge --squash\n'
|
|
30
|
+
' gh pr create --fill && gh pr merge --squash\n';
|
|
26
31
|
|
|
27
|
-
// ── Edit gate messages
|
|
32
|
+
// ── Edit gate messages ───────────────────────────────────────────
|
|
28
33
|
|
|
29
34
|
export function editBlockMessage(sessionId) {
|
|
30
35
|
return (
|
|
31
36
|
'🚫 No active claim — claim an issue first.\n' +
|
|
32
|
-
' bd update <id> --
|
|
33
|
-
` bd kv set "claimed:${sessionId}" "<id>"\n`
|
|
37
|
+
' bd update <id> --claim\n'
|
|
34
38
|
);
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -38,11 +42,11 @@ export function editBlockFallbackMessage() {
|
|
|
38
42
|
return (
|
|
39
43
|
'🚫 No active issue — create one before editing.\n' +
|
|
40
44
|
' bd create --title="<task>" --type=task --priority=2\n' +
|
|
41
|
-
' bd update <id> --
|
|
45
|
+
' bd update <id> --claim\n'
|
|
42
46
|
);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
// ── Commit gate messages
|
|
49
|
+
// ── Commit gate messages ─────────────────────────────────────────
|
|
46
50
|
|
|
47
51
|
export function commitBlockMessage(summary, claimed) {
|
|
48
52
|
const issueSummary = summary ?? ` Claimed: ${claimed}`;
|
|
@@ -53,18 +57,18 @@ export function commitBlockMessage(summary, claimed) {
|
|
|
53
57
|
);
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
// ── Stop gate messages
|
|
60
|
+
// ── Stop gate messages ───────────────────────────────────────────
|
|
57
61
|
|
|
58
62
|
export function stopBlockMessage(summary, claimed) {
|
|
59
63
|
const issueSummary = summary ?? ` Claimed: ${claimed}`;
|
|
60
64
|
return (
|
|
61
65
|
'🚫 Unresolved issues — close before stopping.\n\n' +
|
|
62
66
|
`${issueSummary}\n\n` +
|
|
63
|
-
SESSION_CLOSE_PROTOCOL
|
|
67
|
+
'Next steps:\n' + SESSION_CLOSE_PROTOCOL
|
|
64
68
|
);
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
// ── Memory gate messages
|
|
71
|
+
// ── Memory gate messages ─────────────────────────────────────────
|
|
68
72
|
|
|
69
73
|
export function memoryPromptMessage() {
|
|
70
74
|
return (
|