xtrm-tools 2.1.20 → 2.1.21
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 +81 -709
- package/cli/package.json +1 -1
- package/config/pi/extensions/beads.ts +185 -0
- package/config/pi/extensions/core/adapter.ts +45 -0
- package/config/pi/extensions/core/index.ts +3 -0
- package/config/pi/extensions/core/logger.ts +45 -0
- package/config/pi/extensions/core/runner.ts +71 -0
- package/config/pi/extensions/custom-footer.ts +19 -53
- package/config/pi/extensions/main-guard-post-push.ts +44 -0
- package/config/pi/extensions/main-guard.ts +126 -0
- package/config/pi/extensions/quality-gates.ts +67 -0
- package/config/pi/extensions/service-skills.ts +88 -0
- package/config/pi/extensions/xtrm-loader.ts +89 -0
- package/hooks/README.md +35 -310
- package/package.json +1 -1
- package/config/pi/extensions/git-guard.ts +0 -45
- package/config/pi/extensions/safe-guard.ts +0 -46
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { SubprocessRunner, EventAdapter, Logger } from "./core";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
const logger = new Logger({ namespace: "quality-gates" });
|
|
7
|
+
|
|
8
|
+
export default function (pi: ExtensionAPI) {
|
|
9
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
10
|
+
if (!EventAdapter.isMutatingFileTool(event)) return undefined;
|
|
11
|
+
|
|
12
|
+
const cwd = ctx.cwd || process.cwd();
|
|
13
|
+
const filePath = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
14
|
+
if (!filePath) return undefined;
|
|
15
|
+
|
|
16
|
+
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
17
|
+
const ext = path.extname(fullPath);
|
|
18
|
+
|
|
19
|
+
let scriptPath: string | null = null;
|
|
20
|
+
let runner: string = "node";
|
|
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;
|
|
31
|
+
|
|
32
|
+
const hookInput = JSON.stringify({
|
|
33
|
+
tool_name: event.toolName,
|
|
34
|
+
tool_input: event.input,
|
|
35
|
+
cwd: cwd,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const result = await SubprocessRunner.run(runner, [scriptPath], {
|
|
39
|
+
cwd,
|
|
40
|
+
input: hookInput,
|
|
41
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
42
|
+
timeoutMs: 30000,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (result.code === 0) {
|
|
46
|
+
if (result.stderr && result.stderr.trim()) {
|
|
47
|
+
const newContent = [...event.content];
|
|
48
|
+
newContent.push({ type: "text", text: `\n\n**Quality Gate**: ${result.stderr.trim()}` });
|
|
49
|
+
return { content: newContent };
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (result.code === 2) {
|
|
55
|
+
const newContent = [...event.content];
|
|
56
|
+
newContent.push({ type: "text", text: `\n\n**Quality Gate FAILED**:\n${result.stderr || result.stdout || "Unknown error"}` });
|
|
57
|
+
|
|
58
|
+
if (ctx.hasUI) {
|
|
59
|
+
ctx.ui.notify(`Quality Gate failed for ${path.basename(fullPath)}`, "error");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { isError: true, content: newContent };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return undefined;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, Logger } from "./core";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
const logger = new Logger({ namespace: "service-skills" });
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
11
|
+
|
|
12
|
+
// 1. Catalog Injection
|
|
13
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
14
|
+
const cwd = getCwd(ctx);
|
|
15
|
+
const catalogerPath = path.join(cwd, ".claude", "skills", "using-service-skills", "scripts", "cataloger.py");
|
|
16
|
+
if (!fs.existsSync(catalogerPath)) return undefined;
|
|
17
|
+
|
|
18
|
+
const result = await SubprocessRunner.run("python3", [catalogerPath], {
|
|
19
|
+
cwd,
|
|
20
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
24
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + result.stdout.trim() };
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
});
|
|
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
|
+
|
|
62
|
+
// 3. Drift Detection
|
|
63
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
64
|
+
const cwd = getCwd(ctx);
|
|
65
|
+
const driftDetectorPath = path.join(cwd, ".claude", "skills", "updating-service-skills", "scripts", "drift_detector.py");
|
|
66
|
+
if (!fs.existsSync(driftDetectorPath)) return undefined;
|
|
67
|
+
|
|
68
|
+
const hookInput = JSON.stringify({
|
|
69
|
+
tool_name: event.toolName === "bash" ? "Bash" : event.toolName,
|
|
70
|
+
tool_input: event.input,
|
|
71
|
+
cwd: cwd
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await SubprocessRunner.run("python3", [driftDetectorPath], {
|
|
75
|
+
cwd,
|
|
76
|
+
input: hookInput,
|
|
77
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
|
|
78
|
+
timeoutMs: 10000
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
82
|
+
const newContent = [...event.content];
|
|
83
|
+
newContent.push({ type: "text", text: "\n\n" + result.stdout.trim() });
|
|
84
|
+
return { content: newContent };
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { Logger } from "./core";
|
|
5
|
+
|
|
6
|
+
const logger = new Logger({ namespace: "xtrm-loader" });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively find markdown files in a directory.
|
|
10
|
+
*/
|
|
11
|
+
function findMarkdownFiles(dir: string, basePath: string = ""): string[] {
|
|
12
|
+
const results: string[] = [];
|
|
13
|
+
if (!fs.existsSync(dir)) return results;
|
|
14
|
+
|
|
15
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath));
|
|
20
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
21
|
+
results.push(relativePath);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function (pi: ExtensionAPI) {
|
|
28
|
+
let projectContext: string = "";
|
|
29
|
+
|
|
30
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
31
|
+
const cwd = ctx.cwd;
|
|
32
|
+
const contextParts: string[] = [];
|
|
33
|
+
|
|
34
|
+
// 1. Architecture & Roadmap
|
|
35
|
+
const roadmapPaths = [
|
|
36
|
+
path.join(cwd, "architecture", "project_roadmap.md"),
|
|
37
|
+
path.join(cwd, "ROADMAP.md"),
|
|
38
|
+
path.join(cwd, "architecture", "index.md")
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
for (const p of roadmapPaths) {
|
|
42
|
+
if (fs.existsSync(p)) {
|
|
43
|
+
const content = fs.readFileSync(p, "utf8");
|
|
44
|
+
contextParts.push(`## Project Roadmap & Architecture (${path.relative(cwd, p)})\n\n${content}`);
|
|
45
|
+
break; // Only load the first one found
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. Project Rules (.claude/rules)
|
|
50
|
+
const rulesDir = path.join(cwd, ".claude", "rules");
|
|
51
|
+
if (fs.existsSync(rulesDir)) {
|
|
52
|
+
const ruleFiles = findMarkdownFiles(rulesDir);
|
|
53
|
+
if (ruleFiles.length > 0) {
|
|
54
|
+
const rulesContent = ruleFiles.map(f => {
|
|
55
|
+
const content = fs.readFileSync(path.join(rulesDir, f), "utf8");
|
|
56
|
+
return `### Rule: ${f}\n${content}`;
|
|
57
|
+
}).join("\n\n");
|
|
58
|
+
contextParts.push(`## Project Rules\n\n${rulesContent}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Project Skills (.claude/skills)
|
|
63
|
+
const skillsDir = path.join(cwd, ".claude", "skills");
|
|
64
|
+
if (fs.existsSync(skillsDir)) {
|
|
65
|
+
const skillFiles = findMarkdownFiles(skillsDir);
|
|
66
|
+
if (skillFiles.length > 0) {
|
|
67
|
+
const skillsContent = skillFiles.map(f => {
|
|
68
|
+
// We only want to list the paths/names so the agent knows what it can read
|
|
69
|
+
return `- ${f} (Path: .claude/skills/${f})`;
|
|
70
|
+
}).join("\n");
|
|
71
|
+
contextParts.push(`## Available Project Skills\n\nExisting service skills and workflows found in .claude/skills/:\n\n${skillsContent}\n\nUse the read tool to load any of these skills if relevant to the current task.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
projectContext = contextParts.join("\n\n---\n\n");
|
|
76
|
+
|
|
77
|
+
if (projectContext && ctx.hasUI) {
|
|
78
|
+
ctx.ui.notify("XTRM-Loader: Project context and skills indexed", "info");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.on("before_agent_start", async (event) => {
|
|
83
|
+
if (!projectContext) return undefined;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
systemPrompt: event.systemPrompt + "\n\n# Project Intelligence Context\n\n" + projectContext
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
package/hooks/README.md
CHANGED
|
@@ -1,350 +1,75 @@
|
|
|
1
1
|
# Hooks
|
|
2
2
|
|
|
3
|
-
Claude Code hooks that extend agent behavior with automated checks,
|
|
3
|
+
Claude Code hooks that extend agent behavior with automated checks, workflow enhancements, and safety guardrails.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
Hooks intercept specific events in the Claude Code lifecycle
|
|
8
|
-
- Proactive skill suggestions
|
|
9
|
-
- Safety guardrails (venv enforcement, type checking)
|
|
10
|
-
- Workflow reminders
|
|
11
|
-
- Status information
|
|
7
|
+
Hooks intercept specific events in the Claude Code lifecycle. Following architecture decisions in v2.0.0+, the hook ecosystem is designed exclusively for Claude Code.
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
*Note: In v2.1.15+, several older hooks (`skill-suggestion.py`, `skill-discovery.py`, `gitnexus-impact-reminder.py`, and `type-safety-enforcement.py`) were removed or superseded by native capabilities, CLI commands, and consolidated quality gates.*
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
## Project Hooks
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
### main-guard.mjs
|
|
18
14
|
|
|
19
|
-
**
|
|
15
|
+
**Purpose**: Enforces PR-only merge workflow with full git protection. Blocks direct commits and dangerous `git checkout`, `git reset`, and file writes via Bash on protected branches (`main`/`master`).
|
|
20
16
|
|
|
21
|
-
**
|
|
22
|
-
- `prompt-improving` - Suggested for short/generic prompts
|
|
23
|
-
- `delegating` - Suggested for simple tasks or explicit delegation requests
|
|
17
|
+
**Trigger**: PreToolUse (Write|Edit|MultiEdit|Serena edit tools|Bash)
|
|
24
18
|
|
|
25
|
-
**Configuration**:
|
|
26
|
-
```json
|
|
27
|
-
{
|
|
28
|
-
"hooks": {
|
|
29
|
-
"UserPromptSubmit": [{
|
|
30
|
-
"hooks": [{
|
|
31
|
-
"type": "command",
|
|
32
|
-
"command": "/home/user/.claude/hooks/skill-suggestion.py",
|
|
33
|
-
"timeout": 5000 // Claude: seconds (5000s), Gemini: milliseconds (5s)
|
|
34
|
-
}]
|
|
35
|
-
}]
|
|
36
|
-
},
|
|
37
|
-
"skillSuggestions": {
|
|
38
|
-
"enabled": true
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
```
|
|
19
|
+
**Configuration**: Installed automatically to protect the main branch from unreviewed changes.
|
|
42
20
|
|
|
43
|
-
###
|
|
21
|
+
### main-guard-post-push.mjs
|
|
44
22
|
|
|
45
|
-
**Purpose**:
|
|
23
|
+
**Purpose**: Workflow enforcement. After pushing a feature branch, reminds to open a PR, merge using `gh pr merge --squash`, and sync local via `git reset --hard origin/main`.
|
|
46
24
|
|
|
47
|
-
**Trigger**:
|
|
25
|
+
**Trigger**: PostToolUse (Bash: git push)
|
|
48
26
|
|
|
49
|
-
|
|
27
|
+
### gitnexus-hook.cjs
|
|
50
28
|
|
|
51
|
-
**
|
|
52
|
-
```json
|
|
53
|
-
{
|
|
54
|
-
"hooks": {
|
|
55
|
-
"SessionStart": [{
|
|
56
|
-
"hooks": [{
|
|
57
|
-
"type": "command",
|
|
58
|
-
"command": "/home/user/.claude/hooks/skill-discovery.py",
|
|
59
|
-
"timeout": 5000
|
|
60
|
-
}]
|
|
61
|
-
}]
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
```
|
|
29
|
+
**Purpose**: Enriches tool calls with knowledge graph context via `gitnexus augment`. Now supports Serena tools and uses a deduplication cache for efficiency.
|
|
65
30
|
|
|
66
|
-
|
|
31
|
+
**Trigger**: PostToolUse (Grep|Glob|Bash|Serena edit tools)
|
|
67
32
|
|
|
68
|
-
|
|
33
|
+
## Beads Issue Tracking Gates
|
|
69
34
|
|
|
70
|
-
|
|
71
|
-
- `SessionStart`: Injects skill context.
|
|
72
|
-
- `PreToolUse` (Read|Edit): Blocks inefficient usage.
|
|
35
|
+
The beads gate hooks integrate the `bd` (beads) issue tracker directly into Claude's workflow, ensuring no code changes happen without an active ticket.
|
|
73
36
|
|
|
74
|
-
**
|
|
37
|
+
**Installation**: Installed with `xtrm install all` or included when `beads`+`dolt` is available.
|
|
75
38
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"hooks": [{ "type": "command", "command": "/home/user/.claude/hooks/serena-workflow-reminder.py" }]
|
|
82
|
-
}],
|
|
83
|
-
"PreToolUse": [{
|
|
84
|
-
"matcher": "Read|Edit",
|
|
85
|
-
"hooks": [{ "type": "command", "command": "/home/user/.claude/hooks/serena-workflow-reminder.py" }]
|
|
86
|
-
}]
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
```
|
|
39
|
+
### Core Gates
|
|
40
|
+
- **`beads-edit-gate.mjs`** (PreToolUse) — Blocks writes/edits without an active issue claim.
|
|
41
|
+
- **`beads-commit-gate.mjs`** (PreToolUse) — Blocks commits with an unresolved session claim.
|
|
42
|
+
- **`beads-stop-gate.mjs`** (Stop) — Blocks session stop while a claim remains open.
|
|
43
|
+
- **`beads-close-memory-prompt.mjs`** (PostToolUse) — Prompts memory handoff after `bd close`.
|
|
90
44
|
|
|
91
|
-
|
|
45
|
+
### Compaction & State Preservation (v2.1.18+)
|
|
46
|
+
- **`beads-pre-compact.mjs`** (PreCompact) — Saves the currently `in_progress` beads state before Claude clears context.
|
|
47
|
+
- **`beads-session-start.mjs`** (SessionStart) — Restores the `in_progress` state when the session restarts after compaction.
|
|
92
48
|
|
|
93
|
-
|
|
49
|
+
*Note: As of v2.1.18+, hook blocking messages are quieted and compacted to save tokens.*
|
|
94
50
|
|
|
95
|
-
|
|
51
|
+
## Hook Timeouts
|
|
96
52
|
|
|
97
|
-
|
|
53
|
+
Adjust hook execution timeouts in `settings.json` if commands take longer than expected:
|
|
98
54
|
|
|
99
|
-
**Configuration**:
|
|
100
55
|
```json
|
|
101
56
|
{
|
|
102
57
|
"hooks": {
|
|
103
|
-
"
|
|
104
|
-
"matcher": "Bash",
|
|
58
|
+
"PostToolUse": [{
|
|
105
59
|
"hooks": [{
|
|
106
|
-
"
|
|
107
|
-
"command": "/home/user/.claude/hooks/pip-venv-guard.py",
|
|
108
|
-
"timeout": 3000 // 3 seconds in milliseconds (both Claude & Gemini)
|
|
60
|
+
"timeout": 5000 // Timeout in milliseconds (5000ms = 5 seconds)
|
|
109
61
|
}]
|
|
110
62
|
}]
|
|
111
63
|
}
|
|
112
64
|
}
|
|
113
65
|
```
|
|
114
66
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
**Purpose**: Enforces type safety checks in Python code before execution.
|
|
118
|
-
|
|
119
|
-
**Trigger**: PreToolUse (Bash, Edit, Write)
|
|
120
|
-
|
|
121
|
-
**Configuration**:
|
|
122
|
-
```json
|
|
123
|
-
{
|
|
124
|
-
"hooks": {
|
|
125
|
-
"PreToolUse": [{
|
|
126
|
-
"matcher": "Bash|Edit|Write",
|
|
127
|
-
"hooks": [{
|
|
128
|
-
"type": "command",
|
|
129
|
-
"command": "/home/user/.claude/hooks/type-safety-enforcement.py",
|
|
130
|
-
"timeout": 10000 // 10 seconds in milliseconds (both Claude & Gemini)
|
|
131
|
-
}]
|
|
132
|
-
}]
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
### statusline.js
|
|
138
|
-
|
|
139
|
-
**Purpose**: Displays custom status line information.
|
|
140
|
-
**Trigger**: StatusLine
|
|
141
|
-
|
|
142
|
-
## Workflow Enforcement Hooks (JavaScript)
|
|
143
|
-
|
|
144
|
-
Installed globally to `~/.claude/hooks/` by `xtrm install`. Require Node.js.
|
|
145
|
-
|
|
146
|
-
### main-guard.mjs
|
|
147
|
-
|
|
148
|
-
**Purpose**: Blocks direct file edits and dangerous git operations on protected branches (`main`/`master`). Enforces the feature-branch → PR workflow.
|
|
149
|
-
|
|
150
|
-
**Trigger**: PreToolUse (`Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|Bash`)
|
|
151
|
-
|
|
152
|
-
**Blocks**:
|
|
153
|
-
- Write/Edit/MultiEdit/NotebookEdit on protected branches
|
|
154
|
-
- `git commit` directly on protected branches
|
|
155
|
-
- `git push` to protected branches
|
|
156
|
-
|
|
157
|
-
**Configuration** (global Claude config):
|
|
158
|
-
```json
|
|
159
|
-
{
|
|
160
|
-
"hooks": {
|
|
161
|
-
"PreToolUse": [{
|
|
162
|
-
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|Bash",
|
|
163
|
-
"hooks": [{ "type": "command", "command": "node \"~/.claude/hooks/main-guard.mjs\"", "timeout": 5000 }]
|
|
164
|
-
}]
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
---
|
|
170
|
-
|
|
171
|
-
### main-guard-post-push.mjs
|
|
172
|
-
|
|
173
|
-
**Purpose**: After a successful `git push` from a non-protected branch, injects a reminder for the next PR workflow steps.
|
|
174
|
-
|
|
175
|
-
**Trigger**: PostToolUse (`Bash`) — only fires when command matches `git push` and appears successful.
|
|
176
|
-
|
|
177
|
-
**Behavior**:
|
|
178
|
-
- No blocking (informational only)
|
|
179
|
-
- Skips protected branches (`main`/`master`)
|
|
180
|
-
- Skips explicit pushes targeting protected branches
|
|
181
|
-
|
|
182
|
-
**Guidance injected**:
|
|
183
|
-
- `gh pr create --fill`
|
|
184
|
-
- `gh pr merge --squash`
|
|
185
|
-
- `git checkout main && git pull --ff-only`
|
|
186
|
-
- Reminder to keep beads issue state updated
|
|
187
|
-
|
|
188
|
-
---
|
|
189
|
-
|
|
190
|
-
### beads-gate-utils.mjs
|
|
191
|
-
|
|
192
|
-
**Purpose**: Shared utility module imported by all beads gate hooks. Not registered as a hook itself.
|
|
193
|
-
|
|
194
|
-
**Exports**: `resolveCwd`, `isBeadsProject`, `getSessionClaim`, `getTotalWork`, `getInProgress`, `clearSessionClaim`, `withSafeBdContext`
|
|
195
|
-
|
|
196
|
-
**Requires**: `bd` (beads CLI), `dolt`
|
|
197
|
-
|
|
198
|
-
---
|
|
199
|
-
|
|
200
|
-
### beads-edit-gate.mjs
|
|
201
|
-
|
|
202
|
-
**Purpose**: Blocks file edits when the current session has not claimed a beads issue via `bd kv`. Prevents free-riding in multi-agent and multi-session scenarios.
|
|
203
|
-
|
|
204
|
-
**Trigger**: PreToolUse (`Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol`)
|
|
205
|
-
|
|
206
|
-
**Behavior**:
|
|
207
|
-
- Session has claim (`bd kv get "claimed:<session_id>"`) → allow
|
|
208
|
-
- No claim + no trackable work → allow (clean-start state)
|
|
209
|
-
- No claim + open/in_progress issues exist → block
|
|
210
|
-
- Falls back to global in_progress check when `session_id` is absent
|
|
211
|
-
|
|
212
|
-
**Requires**: `bd`, `dolt`
|
|
213
|
-
|
|
214
|
-
---
|
|
215
|
-
|
|
216
|
-
### beads-commit-gate.mjs
|
|
217
|
-
|
|
218
|
-
**Purpose**: Blocks `git commit` when the current session still has an unclosed beads claim.
|
|
219
|
-
|
|
220
|
-
**Trigger**: PreToolUse (`Bash`) — only fires when command matches `git commit`
|
|
221
|
-
|
|
222
|
-
**Requires**: `bd`, `dolt`
|
|
223
|
-
|
|
224
|
-
---
|
|
225
|
-
|
|
226
|
-
### beads-stop-gate.mjs
|
|
227
|
-
|
|
228
|
-
**Purpose**: Blocks the agent from stopping when the current session has an unclosed beads claim.
|
|
229
|
-
|
|
230
|
-
**Trigger**: Stop
|
|
231
|
-
|
|
232
|
-
**Requires**: `bd`, `dolt`
|
|
233
|
-
|
|
234
|
-
---
|
|
235
|
-
|
|
236
|
-
### beads-close-memory-prompt.mjs
|
|
237
|
-
|
|
238
|
-
**Purpose**: After `bd close`, clears the session's kv claim and injects a reminder to capture knowledge before moving on.
|
|
239
|
-
|
|
240
|
-
**Trigger**: PostToolUse (`Bash`) — only fires when command matches `bd close`
|
|
241
|
-
|
|
242
|
-
**Requires**: `bd`, `dolt`
|
|
243
|
-
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
## Beads claim workflow
|
|
247
|
-
|
|
248
|
-
```bash
|
|
249
|
-
# Claim an issue before editing
|
|
250
|
-
bd update <id> --status=in_progress
|
|
251
|
-
bd kv set "claimed:<session_id>" "<id>"
|
|
252
|
-
|
|
253
|
-
# Edit files freely
|
|
254
|
-
# ...
|
|
255
|
-
|
|
256
|
-
# Close when done — hook auto-clears the claim
|
|
257
|
-
bd close <id>
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
---
|
|
261
|
-
|
|
262
|
-
## Installation
|
|
263
|
-
|
|
264
|
-
Use `xtrm install` to deploy all hooks automatically. For manual setup:
|
|
265
|
-
|
|
266
|
-
1. Copy hooks to the global Claude Code directory:
|
|
267
|
-
```bash
|
|
268
|
-
cp hooks/*.mjs hooks/*.py ~/.claude/hooks/
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
2. Make scripts executable:
|
|
272
|
-
```bash
|
|
273
|
-
chmod +x ~/.claude/hooks/*.mjs ~/.claude/hooks/*.py
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
3. Merge hook entries into `~/.claude/settings.json`.
|
|
277
|
-
|
|
278
|
-
4. Restart Claude Code.
|
|
279
|
-
|
|
280
|
-
---
|
|
281
|
-
|
|
282
|
-
## Beads Hooks Architecture
|
|
283
|
-
|
|
284
|
-
The beads gate hooks are organized into three layers:
|
|
285
|
-
|
|
286
|
-
```
|
|
287
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
288
|
-
│ HOOK ENTRYPOINTS │
|
|
289
|
-
│ (thin wrappers - just parse, call core, emit, exit) │
|
|
290
|
-
├──────────────────┬──────────────────┬──────────────────────────┤
|
|
291
|
-
│ beads-edit-gate │ beads-commit-gate│ beads-stop-gate │
|
|
292
|
-
│ beads-memory-gate│ │ │
|
|
293
|
-
└────────┬─────────┴────────┬─────────┴─────────────┬────────────┘
|
|
294
|
-
│ │ │
|
|
295
|
-
▼ ▼ ▼
|
|
296
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
297
|
-
│ beads-gate-core.mjs │
|
|
298
|
-
│ Pure decision functions - no I/O, return {allow, reason} │
|
|
299
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
300
|
-
│ • readHookInput() → parsed input or null │
|
|
301
|
-
│ • resolveSessionContext() → {cwd, sessionId, isBeadsProject} │
|
|
302
|
-
│ • resolveClaimAndWorkState() → {claimed, claimId, work} │
|
|
303
|
-
│ • decideEditGate() → {allow: bool, reason?: string} │
|
|
304
|
-
│ • decideCommitGate() → {allow: bool, reason?: string} │
|
|
305
|
-
│ • decideStopGate() → {allow: bool, reason?: string} │
|
|
306
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
307
|
-
│
|
|
308
|
-
▼
|
|
309
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
310
|
-
│ beads-gate-messages.mjs │
|
|
311
|
-
│ Centralized message templates │
|
|
312
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
313
|
-
│ • WORKFLOW_STEPS - full 7-step workflow │
|
|
314
|
-
│ • SESSION_CLOSE_PROTOCOL - stop gate steps │
|
|
315
|
-
│ • editBlockMessage(sessionId) │
|
|
316
|
-
│ • commitBlockMessage(summary, claimed) │
|
|
317
|
-
│ • stopBlockMessage(summary, claimed) │
|
|
318
|
-
│ • memoryPromptMessage() │
|
|
319
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
320
|
-
│
|
|
321
|
-
▼
|
|
322
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
323
|
-
│ beads-gate-utils.mjs │
|
|
324
|
-
│ Low-level adapters - bd CLI, shell, fs operations │
|
|
325
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
326
|
-
│ • resolveCwd(input) │
|
|
327
|
-
│ • isBeadsProject(cwd) │
|
|
328
|
-
│ • getSessionClaim(sessionId, cwd) │
|
|
329
|
-
│ • getTotalWork(cwd), getInProgress(cwd) │
|
|
330
|
-
│ • clearSessionClaim(sessionId, cwd) │
|
|
331
|
-
│ • withSafeBdContext(fn) │
|
|
332
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
### Where to Make Changes
|
|
67
|
+
## Creating Custom Hooks
|
|
336
68
|
|
|
337
|
-
|
|
338
|
-
|-------------|--------------|
|
|
339
|
-
| Policy logic (when to block/allow) | `beads-gate-core.mjs` |
|
|
340
|
-
| User-facing messages | `beads-gate-messages.mjs` |
|
|
341
|
-
| bd CLI integration | `beads-gate-utils.mjs` |
|
|
342
|
-
| Hook registration/wiring | Entrypoints (rarely needed) |
|
|
69
|
+
To create new project-specific hooks, use the `hook-development` global skill. Follow the canonical structure defined in the `xtrm-tools` core libraries.
|
|
343
70
|
|
|
344
|
-
|
|
71
|
+
For debugging orphaned hooks, use `xtrm clean`.
|
|
345
72
|
|
|
346
|
-
|
|
347
|
-
- `exit 0` — Allow the operation
|
|
348
|
-
- `exit 2` — Block with message shown to Claude
|
|
73
|
+
## Pi Extensions Migration
|
|
349
74
|
|
|
350
|
-
|
|
75
|
+
Core workflow hooks have been migrated to native Pi Extensions for better performance and integration. See the [Pi Extensions Migration Guide](../docs/pi-extensions-migration.md) for details.
|
package/package.json
CHANGED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* oh-pi Git Checkpoint Extension
|
|
3
|
-
*
|
|
4
|
-
* Auto-stash before each turn, notify on agent completion.
|
|
5
|
-
* Combines git-checkpoint + notify + dirty-repo-guard.
|
|
6
|
-
*/
|
|
7
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
-
|
|
9
|
-
function terminalNotify(title: string, body: string): void {
|
|
10
|
-
if (process.env.KITTY_WINDOW_ID) {
|
|
11
|
-
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
|
|
12
|
-
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
|
|
13
|
-
} else {
|
|
14
|
-
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export default function (pi: ExtensionAPI) {
|
|
19
|
-
let turnCount = 0;
|
|
20
|
-
|
|
21
|
-
// Warn on dirty repo at session start
|
|
22
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
23
|
-
try {
|
|
24
|
-
const { stdout } = await pi.exec("git", ["status", "--porcelain"]);
|
|
25
|
-
if (stdout.trim() && ctx.hasUI) {
|
|
26
|
-
const lines = stdout.trim().split("\n").length;
|
|
27
|
-
ctx.ui.notify(`⚠️ Dirty repo: ${lines} uncommitted change(s)`, "warning");
|
|
28
|
-
}
|
|
29
|
-
} catch { /* not a git repo, ignore */ }
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// Stash checkpoint before each turn
|
|
33
|
-
pi.on("turn_start", async () => {
|
|
34
|
-
turnCount++;
|
|
35
|
-
try {
|
|
36
|
-
await pi.exec("git", ["stash", "create", "-m", `oh-pi-turn-${turnCount}`]);
|
|
37
|
-
} catch { /* not a git repo */ }
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Notify when agent is done
|
|
41
|
-
pi.on("agent_end", async () => {
|
|
42
|
-
terminalNotify("oh-pi", `Done after ${turnCount} turn(s). Ready for input.`);
|
|
43
|
-
turnCount = 0;
|
|
44
|
-
});
|
|
45
|
-
}
|