xtrm-tools 2.4.3 → 2.4.4
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 +958 -916
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/pi/extensions/beads.ts +1 -1
- package/config/pi/extensions/core/guard-rules.ts +34 -2
- package/config/pi/extensions/custom-footer.ts +5 -5
- package/config/pi/extensions/plan-mode/index.ts +91 -14
- package/config/pi/extensions/plan-mode/package.json +12 -0
- package/config/pi/extensions/plan-mode/utils.ts +158 -2
- package/config/pi/extensions/session-flow.ts +18 -4
- package/hooks/beads-claim-sync.mjs +52 -111
- package/hooks/beads-commit-gate.mjs +19 -4
- package/hooks/beads-compact-restore.mjs +2 -24
- package/hooks/beads-compact-save.mjs +1 -27
- package/hooks/beads-edit-gate.mjs +9 -2
- package/hooks/beads-gate-core.mjs +13 -9
- package/hooks/beads-gate-messages.mjs +5 -23
- package/hooks/beads-gate-utils.mjs +65 -1
- package/hooks/beads-memory-gate.mjs +2 -3
- package/hooks/beads-stop-gate.mjs +1 -52
- package/hooks/branch-state.mjs +2 -1
- package/hooks/guard-rules.mjs +1 -0
- package/hooks/hooks.json +22 -42
- package/hooks/main-guard.mjs +23 -40
- package/package.json +1 -1
- package/config/pi/extensions/main-guard-post-push.ts +0 -44
- package/config/pi/extensions/main-guard.ts +0 -122
- package/hooks/session-state.mjs +0 -138
|
@@ -11,52 +11,11 @@ import {
|
|
|
11
11
|
decideStopGate,
|
|
12
12
|
} from './beads-gate-core.mjs';
|
|
13
13
|
import { withSafeBdContext } from './beads-gate-utils.mjs';
|
|
14
|
-
import {
|
|
15
|
-
stopBlockMessage,
|
|
16
|
-
stopBlockWaitingMergeMessage,
|
|
17
|
-
stopBlockConflictingMessage,
|
|
18
|
-
stopWarnActiveWorktreeMessage,
|
|
19
|
-
} from './beads-gate-messages.mjs';
|
|
20
|
-
import { readSessionState } from './session-state.mjs';
|
|
14
|
+
import { stopBlockMessage } from './beads-gate-messages.mjs';
|
|
21
15
|
|
|
22
16
|
const input = readHookInput();
|
|
23
17
|
if (!input) process.exit(0);
|
|
24
18
|
|
|
25
|
-
function evaluateSessionState(cwd) {
|
|
26
|
-
const state = readSessionState(cwd);
|
|
27
|
-
if (!state) return { allow: true };
|
|
28
|
-
|
|
29
|
-
if (state.phase === 'cleanup-done' || state.phase === 'merged') {
|
|
30
|
-
return { allow: true, state };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (state.phase === 'waiting-merge' || state.phase === 'pending-cleanup') {
|
|
34
|
-
return {
|
|
35
|
-
allow: false,
|
|
36
|
-
state,
|
|
37
|
-
message: stopBlockWaitingMergeMessage(state),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (state.phase === 'conflicting') {
|
|
42
|
-
return {
|
|
43
|
-
allow: false,
|
|
44
|
-
state,
|
|
45
|
-
message: stopBlockConflictingMessage(state),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (state.phase === 'claimed' || state.phase === 'phase1-done') {
|
|
50
|
-
return {
|
|
51
|
-
allow: true,
|
|
52
|
-
state,
|
|
53
|
-
warning: stopWarnActiveWorktreeMessage(state),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { allow: true, state };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
19
|
withSafeBdContext(() => {
|
|
61
20
|
const ctx = resolveSessionContext(input);
|
|
62
21
|
if (!ctx || !ctx.isBeadsProject) process.exit(0);
|
|
@@ -69,15 +28,5 @@ withSafeBdContext(() => {
|
|
|
69
28
|
process.exit(2);
|
|
70
29
|
}
|
|
71
30
|
|
|
72
|
-
const sessionDecision = evaluateSessionState(ctx.cwd);
|
|
73
|
-
if (!sessionDecision.allow) {
|
|
74
|
-
process.stderr.write(sessionDecision.message);
|
|
75
|
-
process.exit(2);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (sessionDecision.warning) {
|
|
79
|
-
process.stderr.write(sessionDecision.warning);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
31
|
process.exit(0);
|
|
83
32
|
});
|
package/hooks/branch-state.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { resolveSessionId } from './beads-gate-utils.mjs';
|
|
10
11
|
|
|
11
12
|
function readInput() {
|
|
12
13
|
try { return JSON.parse(readFileSync(0, 'utf-8')); } catch { return null; }
|
|
@@ -35,7 +36,7 @@ const input = readInput();
|
|
|
35
36
|
if (!input) process.exit(0);
|
|
36
37
|
|
|
37
38
|
const cwd = input.cwd || process.cwd();
|
|
38
|
-
const sessionId = input
|
|
39
|
+
const sessionId = resolveSessionId(input);
|
|
39
40
|
const branch = getBranch(cwd);
|
|
40
41
|
const isBeads = existsSync(join(cwd, '.beads'));
|
|
41
42
|
const claim = isBeads && sessionId ? getSessionClaim(sessionId, cwd) : null;
|
package/hooks/guard-rules.mjs
CHANGED
package/hooks/hooks.json
CHANGED
|
@@ -1,48 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"PreToolUse": [
|
|
4
|
-
{
|
|
5
|
-
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
6
|
-
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/main-guard.mjs",
|
|
10
|
-
"timeout": 5000
|
|
11
|
-
},
|
|
12
|
-
{
|
|
13
|
-
"type": "command",
|
|
14
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-edit-gate.mjs",
|
|
15
|
-
"timeout": 5000
|
|
16
|
-
}
|
|
17
|
-
]
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"matcher": "Bash",
|
|
21
|
-
"hooks": [
|
|
22
|
-
{
|
|
23
|
-
"type": "command",
|
|
24
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/main-guard.mjs",
|
|
25
|
-
"timeout": 5000
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"type": "command",
|
|
29
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-commit-gate.mjs",
|
|
30
|
-
"timeout": 5000
|
|
31
|
-
}
|
|
32
|
-
]
|
|
33
|
-
}
|
|
34
|
-
],
|
|
35
3
|
"PostToolUse": [
|
|
36
|
-
{
|
|
37
|
-
"matcher": "Bash",
|
|
38
|
-
"hooks": [
|
|
39
|
-
{
|
|
40
|
-
"type": "command",
|
|
41
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/main-guard-post-push.mjs",
|
|
42
|
-
"timeout": 5000
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
},
|
|
46
4
|
{
|
|
47
5
|
"matcher": "Bash|execute_shell_command|bash",
|
|
48
6
|
"hooks": [
|
|
@@ -110,6 +68,28 @@
|
|
|
110
68
|
]
|
|
111
69
|
}
|
|
112
70
|
],
|
|
71
|
+
"PreToolUse": [
|
|
72
|
+
{
|
|
73
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
74
|
+
"hooks": [
|
|
75
|
+
{
|
|
76
|
+
"type": "command",
|
|
77
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-edit-gate.mjs",
|
|
78
|
+
"timeout": 5000
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"matcher": "Bash",
|
|
84
|
+
"hooks": [
|
|
85
|
+
{
|
|
86
|
+
"type": "command",
|
|
87
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-commit-gate.mjs",
|
|
88
|
+
"timeout": 5000
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
113
93
|
"PreCompact": [
|
|
114
94
|
{
|
|
115
95
|
"hooks": [
|
package/hooks/main-guard.mjs
CHANGED
|
@@ -17,12 +17,10 @@ try {
|
|
|
17
17
|
}).trim();
|
|
18
18
|
} catch {}
|
|
19
19
|
|
|
20
|
-
// Determine protected branches — env var override for tests and custom setups
|
|
21
20
|
const protectedBranches = process.env.MAIN_GUARD_PROTECTED_BRANCHES
|
|
22
21
|
? process.env.MAIN_GUARD_PROTECTED_BRANCHES.split(',').map(b => b.trim()).filter(Boolean)
|
|
23
22
|
: ['main', 'master'];
|
|
24
23
|
|
|
25
|
-
// Not in a git repo or not on a protected branch — allow
|
|
26
24
|
if (!branch || !protectedBranches.includes(branch)) {
|
|
27
25
|
process.exit(0);
|
|
28
26
|
}
|
|
@@ -35,19 +33,14 @@ try {
|
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
const tool = input.tool_name ?? '';
|
|
38
|
-
const hookEventName = input.hook_event_name ?? 'PreToolUse';
|
|
39
36
|
const cwd = input.cwd || process.cwd();
|
|
40
37
|
|
|
41
38
|
function deny(reason) {
|
|
42
|
-
process.stdout.write(JSON.stringify({
|
|
43
|
-
decision: 'block',
|
|
44
|
-
reason,
|
|
45
|
-
}));
|
|
39
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
46
40
|
process.stdout.write('\n');
|
|
47
41
|
process.exit(0);
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
// Check for existing worktree/session state
|
|
51
44
|
function getSessionState(cwd) {
|
|
52
45
|
const statePath = join(cwd, '.xtrm-session-state.json');
|
|
53
46
|
if (!existsSync(statePath)) return null;
|
|
@@ -58,64 +51,52 @@ function getSessionState(cwd) {
|
|
|
58
51
|
}
|
|
59
52
|
}
|
|
60
53
|
|
|
54
|
+
function normalizeGitCCommand(cmd) {
|
|
55
|
+
const match = cmd.match(/^git\s+-C\s+(?:"[^"]+"|'[^']+'|\S+)\s+(.+)$/);
|
|
56
|
+
if (match?.[1]) return `git ${match[1]}`;
|
|
57
|
+
return cmd;
|
|
58
|
+
}
|
|
59
|
+
|
|
61
60
|
if (WRITE_TOOLS.includes(tool)) {
|
|
62
|
-
const state = getSessionState(cwd);
|
|
63
|
-
if (state?.worktreePath) {
|
|
64
|
-
deny(`⛔ On '${branch}' — worktree already created.\n`
|
|
65
|
-
+ ` cd ${state.worktreePath}\n`);
|
|
66
|
-
}
|
|
67
61
|
deny(`⛔ On '${branch}' — start on a feature branch and claim an issue.\n`
|
|
68
|
-
+ ' git checkout -b feature/<name
|
|
62
|
+
+ ' git checkout -b feature/<name> (or: git switch -c feature/<name>)\n'
|
|
69
63
|
+ ' bd update <id> --claim\n');
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
const WORKFLOW =
|
|
73
|
-
' 1. git checkout -b feature/<name>\n'
|
|
74
|
-
+ ' 2. bd create + bd update in_progress\n'
|
|
75
|
-
+ ' 3. bd close <id> && git add && git commit\n'
|
|
76
|
-
+ ' 4. git push -u origin feature/<name>\n'
|
|
77
|
-
+ ' 5. gh pr create --fill && gh pr merge --squash\n';
|
|
78
|
-
|
|
79
66
|
if (tool === 'Bash') {
|
|
80
67
|
const cmd = (input.tool_input?.command ?? '').trim().replace(/\s+/g, ' ');
|
|
68
|
+
const normalizedCmd = normalizeGitCCommand(cmd);
|
|
69
|
+
const state = getSessionState(cwd);
|
|
81
70
|
|
|
82
|
-
// Emergency override — escape hatch for power users
|
|
83
71
|
if (process.env.MAIN_GUARD_ALLOW_BASH === '1') {
|
|
84
72
|
process.exit(0);
|
|
85
73
|
}
|
|
86
74
|
|
|
87
|
-
// Enforce squash-only PR merges for linear history
|
|
88
|
-
// Must check BEFORE the gh allowlist pattern
|
|
89
75
|
if (/^gh\s+pr\s+merge\b/.test(cmd)) {
|
|
90
76
|
if (!/--squash\b/.test(cmd)) {
|
|
91
77
|
deny('⛔ Squash only: gh pr merge --squash\n'
|
|
92
78
|
+ ' (override: MAIN_GUARD_ALLOW_BASH=1 gh pr merge --merge)\n');
|
|
93
79
|
}
|
|
94
|
-
// --squash present — allow
|
|
95
80
|
process.exit(0);
|
|
96
81
|
}
|
|
97
82
|
|
|
98
|
-
// Safe allowlist — non-mutating commands + explicit branch-exit paths.
|
|
99
|
-
// Important: do not allow generic checkout/switch forms, which include
|
|
100
|
-
// mutating variants such as `git checkout -- <path>`.
|
|
101
83
|
const SAFE_BASH_PATTERNS = [
|
|
102
84
|
...SAFE_BASH_PREFIXES.map(prefix => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)),
|
|
103
|
-
// Allow post-merge sync to protected branch only (not arbitrary origin refs)
|
|
104
85
|
...protectedBranches.map(b => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`)),
|
|
105
86
|
];
|
|
106
87
|
|
|
107
|
-
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
|
|
88
|
+
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd) || p.test(normalizedCmd))) {
|
|
108
89
|
process.exit(0);
|
|
109
90
|
}
|
|
110
91
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
+ '
|
|
92
|
+
if (/\bgit\s+commit\b/.test(normalizedCmd)) {
|
|
93
|
+
deny(`⛔ No commits on '${branch}' — use a feature branch/worktree.\n`
|
|
94
|
+
+ ' git checkout -b feature/<name>\n'
|
|
95
|
+
+ ' bd update <id> --claim\n');
|
|
115
96
|
}
|
|
116
97
|
|
|
117
|
-
if (
|
|
118
|
-
const tokens =
|
|
98
|
+
if (/\bgit\s+push\b/.test(normalizedCmd)) {
|
|
99
|
+
const tokens = normalizedCmd.split(' ');
|
|
119
100
|
const lastToken = tokens[tokens.length - 1];
|
|
120
101
|
const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
|
|
121
102
|
const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
|
|
@@ -123,13 +104,15 @@ if (tool === 'Bash') {
|
|
|
123
104
|
deny(`⛔ No direct push to '${branch}' — push a feature branch and open a PR.\n`
|
|
124
105
|
+ ' git push -u origin <feature-branch> && gh pr create --fill\n');
|
|
125
106
|
}
|
|
126
|
-
// Pushing to a feature branch — allow
|
|
127
107
|
process.exit(0);
|
|
128
108
|
}
|
|
129
109
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
110
|
+
const handoff = state?.worktreePath
|
|
111
|
+
? ` Active worktree session recorded: ${state.worktreePath}\n (Current workaround) use feature branch flow until worktree bug is fixed.\n`
|
|
112
|
+
: ' Exit: git checkout -b feature/<name> (or: git switch -c feature/<name>)\n Then: bd update <id> --claim\n';
|
|
113
|
+
|
|
114
|
+
deny(`⛔ Bash restricted on '${branch}'. Allowed: read-only commands, gh, bd, git checkout -b, git switch -c.\n`
|
|
115
|
+
+ handoff
|
|
133
116
|
+ ' Override: MAIN_GUARD_ALLOW_BASH=1 <cmd>\n');
|
|
134
117
|
}
|
|
135
118
|
|
package/package.json
CHANGED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { SubprocessRunner, Logger } from "./core/lib";
|
|
4
|
-
|
|
5
|
-
const logger = new Logger({ namespace: "main-guard-post-push" });
|
|
6
|
-
|
|
7
|
-
export default function (pi: ExtensionAPI) {
|
|
8
|
-
const getProtectedBranches = (): string[] => {
|
|
9
|
-
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
10
|
-
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
11
|
-
return ["main", "master"];
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
15
|
-
const cwd = ctx.cwd || process.cwd();
|
|
16
|
-
if (!isBashToolResult(event) || event.isError) return undefined;
|
|
17
|
-
|
|
18
|
-
const cmd = event.input.command.trim();
|
|
19
|
-
if (!/\bgit\s+push\b/.test(cmd)) return undefined;
|
|
20
|
-
|
|
21
|
-
// Check if we pushed to a protected branch
|
|
22
|
-
const protectedBranches = getProtectedBranches();
|
|
23
|
-
const tokens = cmd.split(/\s+/);
|
|
24
|
-
const lastToken = tokens[tokens.length - 1];
|
|
25
|
-
if (protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`))) {
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Success! Suggest PR workflow
|
|
30
|
-
const reminder = "\n\n**Main-Guard**: Push successful. Next steps:\n" +
|
|
31
|
-
" 1. `gh pr create --fill` (if not already open)\n" +
|
|
32
|
-
" 2. `gh pr merge --squash` (once approved)\n" +
|
|
33
|
-
" 3. `git checkout main && git reset --hard origin/main` (sync local)";
|
|
34
|
-
|
|
35
|
-
const newContent = [...event.content];
|
|
36
|
-
newContent.push({ type: "text", text: reminder });
|
|
37
|
-
|
|
38
|
-
if (ctx.hasUI) {
|
|
39
|
-
ctx.ui.notify("Main-Guard: Suggesting PR workflow", "info");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { content: newContent };
|
|
43
|
-
});
|
|
44
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
|
|
4
|
-
import { SAFE_BASH_PREFIXES, DANGEROUS_BASH_PATTERNS } from "./core/guard-rules";
|
|
5
|
-
|
|
6
|
-
const logger = new Logger({ namespace: "main-guard" });
|
|
7
|
-
|
|
8
|
-
export default function (pi: ExtensionAPI) {
|
|
9
|
-
const getProtectedBranches = (): string[] => {
|
|
10
|
-
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
11
|
-
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
12
|
-
return ["main", "master"];
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const getCurrentBranch = async (cwd: string): Promise<string | null> => {
|
|
16
|
-
const result = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
|
|
17
|
-
if (result.code === 0) return result.stdout;
|
|
18
|
-
return null;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
22
|
-
|
|
23
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
24
|
-
const cwd = ctx.cwd || process.cwd();
|
|
25
|
-
|
|
26
|
-
// 1. Safety Check: Protected Paths (Global)
|
|
27
|
-
if (EventAdapter.isMutatingFileTool(event)) {
|
|
28
|
-
const path = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
29
|
-
if (path && protectedPaths.some((p) => path.includes(p))) {
|
|
30
|
-
const reason = `Path "${path}" is protected. Edits to sensitive system files are restricted.`;
|
|
31
|
-
if (ctx.hasUI) {
|
|
32
|
-
ctx.ui.notify(`Safety: Blocked edit to protected path`, "error");
|
|
33
|
-
}
|
|
34
|
-
return { block: true, reason };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 2. Safety Check: Dangerous Commands (Global)
|
|
39
|
-
if (isToolCallEventType("bash", event)) {
|
|
40
|
-
const cmd = event.input.command.trim();
|
|
41
|
-
const dangerousRegexes = DANGEROUS_BASH_PATTERNS.map((pattern) => new RegExp(pattern));
|
|
42
|
-
const dangerousMatch = dangerousRegexes.some((rx) => rx.test(cmd));
|
|
43
|
-
if (dangerousMatch && !cmd.includes("--help")) {
|
|
44
|
-
if (ctx.hasUI) {
|
|
45
|
-
const ok = await ctx.ui.confirm("Dangerous Command", `Allow execution of: ${cmd}?`);
|
|
46
|
-
if (!ok) return { block: true, reason: "Blocked by user confirmation" };
|
|
47
|
-
} else {
|
|
48
|
-
return { block: true, reason: "Dangerous command blocked in non-interactive mode" };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// 3. Main-Guard: Branch Protection
|
|
54
|
-
const protectedBranches = getProtectedBranches();
|
|
55
|
-
const branch = await getCurrentBranch(cwd);
|
|
56
|
-
|
|
57
|
-
if (branch && protectedBranches.includes(branch)) {
|
|
58
|
-
// A. Mutating File Tools on Main
|
|
59
|
-
if (EventAdapter.isMutatingFileTool(event)) {
|
|
60
|
-
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`;
|
|
61
|
-
if (ctx.hasUI) {
|
|
62
|
-
ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
|
|
63
|
-
}
|
|
64
|
-
return { block: true, reason };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// B. Bash Commands on Main
|
|
68
|
-
if (isToolCallEventType("bash", event)) {
|
|
69
|
-
const cmd = event.input.command.trim();
|
|
70
|
-
|
|
71
|
-
// Emergency override
|
|
72
|
-
if (process.env.MAIN_GUARD_ALLOW_BASH === "1") return undefined;
|
|
73
|
-
|
|
74
|
-
// Enforce squash-only PR merges
|
|
75
|
-
if (/^gh\s+pr\s+merge\b/.test(cmd)) {
|
|
76
|
-
if (!/--squash\b/.test(cmd)) {
|
|
77
|
-
const reason = "Squash only: use `gh pr merge --squash` (or MAIN_GUARD_ALLOW_BASH=1)";
|
|
78
|
-
return { block: true, reason };
|
|
79
|
-
}
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Safe allowlist
|
|
84
|
-
const safePrefixRegexes = SAFE_BASH_PREFIXES.map((prefix) =>
|
|
85
|
-
new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
|
|
86
|
-
);
|
|
87
|
-
const safeResetRegexes = protectedBranches.map((b) => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`));
|
|
88
|
-
const SAFE_BASH_PATTERNS = [...safePrefixRegexes, ...safeResetRegexes];
|
|
89
|
-
|
|
90
|
-
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Specific blocks
|
|
95
|
-
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
96
|
-
return { block: true, reason: `No commits on '${branch}' — use a feature branch.\n git checkout -b feature/<name>\n bd update <id> --claim\n` };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (/\bgit\s+push\b/.test(cmd)) {
|
|
100
|
-
const tokens = cmd.split(/\s+/);
|
|
101
|
-
const lastToken = tokens[tokens.length - 1];
|
|
102
|
-
const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
|
|
103
|
-
const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
|
|
104
|
-
|
|
105
|
-
if (explicitProtected || impliedProtected) {
|
|
106
|
-
return { block: true, reason: `No direct push to '${branch}' — push a feature branch and open a PR.` };
|
|
107
|
-
}
|
|
108
|
-
return undefined;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Default deny
|
|
112
|
-
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`;
|
|
113
|
-
if (ctx.hasUI) {
|
|
114
|
-
ctx.ui.notify("Main-Guard: Command blocked", "error");
|
|
115
|
-
}
|
|
116
|
-
return { block: true, reason };
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return undefined;
|
|
121
|
-
});
|
|
122
|
-
}
|
package/hooks/session-state.mjs
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { execSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
|
|
7
|
-
export const SESSION_STATE_FILE = '.xtrm-session-state.json';
|
|
8
|
-
|
|
9
|
-
export const SESSION_PHASES = [
|
|
10
|
-
'claimed',
|
|
11
|
-
'phase1-done',
|
|
12
|
-
'waiting-merge',
|
|
13
|
-
'conflicting',
|
|
14
|
-
'pending-cleanup',
|
|
15
|
-
'merged',
|
|
16
|
-
'cleanup-done',
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
const ALLOWED_TRANSITIONS = {
|
|
20
|
-
claimed: ['phase1-done', 'waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
|
|
21
|
-
'phase1-done': ['waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
|
|
22
|
-
'waiting-merge': ['conflicting', 'pending-cleanup', 'merged', 'cleanup-done'],
|
|
23
|
-
conflicting: ['waiting-merge', 'pending-cleanup', 'merged', 'cleanup-done'],
|
|
24
|
-
'pending-cleanup': ['waiting-merge', 'conflicting', 'merged', 'cleanup-done'],
|
|
25
|
-
merged: ['cleanup-done'],
|
|
26
|
-
'cleanup-done': [],
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function nowIso() {
|
|
30
|
-
return new Date().toISOString();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function isValidPhase(phase) {
|
|
34
|
-
return typeof phase === 'string' && SESSION_PHASES.includes(phase);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function normalizeState(state) {
|
|
38
|
-
if (!state || typeof state !== 'object') throw new Error('Invalid session state payload');
|
|
39
|
-
if (!state.issueId || !state.branch || !state.worktreePath) {
|
|
40
|
-
throw new Error('Session state requires issueId, branch, and worktreePath');
|
|
41
|
-
}
|
|
42
|
-
if (!isValidPhase(state.phase)) throw new Error(`Invalid phase: ${String(state.phase)}`);
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
issueId: String(state.issueId),
|
|
46
|
-
branch: String(state.branch),
|
|
47
|
-
worktreePath: String(state.worktreePath),
|
|
48
|
-
prNumber: state.prNumber ?? null,
|
|
49
|
-
prUrl: state.prUrl ?? null,
|
|
50
|
-
phase: state.phase,
|
|
51
|
-
conflictFiles: Array.isArray(state.conflictFiles) ? state.conflictFiles.map(String) : [],
|
|
52
|
-
startedAt: state.startedAt || nowIso(),
|
|
53
|
-
lastChecked: nowIso(),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function canTransition(from, to) {
|
|
58
|
-
if (!isValidPhase(from) || !isValidPhase(to)) return false;
|
|
59
|
-
if (from === to) return true;
|
|
60
|
-
return (ALLOWED_TRANSITIONS[from] || []).includes(to);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function findAncestorStateFile(startCwd) {
|
|
64
|
-
let current = path.resolve(startCwd || process.cwd());
|
|
65
|
-
for (;;) {
|
|
66
|
-
const candidate = path.join(current, SESSION_STATE_FILE);
|
|
67
|
-
if (existsSync(candidate)) return candidate;
|
|
68
|
-
const parent = path.dirname(current);
|
|
69
|
-
if (parent === current) return null;
|
|
70
|
-
current = parent;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function findRepoRoot(cwd) {
|
|
75
|
-
try {
|
|
76
|
-
return execSync('git rev-parse --show-toplevel', {
|
|
77
|
-
encoding: 'utf8',
|
|
78
|
-
cwd,
|
|
79
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
80
|
-
timeout: 5000,
|
|
81
|
-
}).trim();
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function findSessionStateFile(startCwd) {
|
|
88
|
-
return findAncestorStateFile(startCwd);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function readSessionState(startCwd) {
|
|
92
|
-
const filePath = findSessionStateFile(startCwd);
|
|
93
|
-
if (!filePath) return null;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
97
|
-
const state = normalizeState(parsed);
|
|
98
|
-
return { ...state, _filePath: filePath };
|
|
99
|
-
} catch {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function resolveSessionStatePath(cwd) {
|
|
105
|
-
const existing = findSessionStateFile(cwd);
|
|
106
|
-
if (existing) return existing;
|
|
107
|
-
|
|
108
|
-
const repoRoot = findRepoRoot(cwd);
|
|
109
|
-
if (repoRoot) return path.join(repoRoot, SESSION_STATE_FILE);
|
|
110
|
-
return path.join(cwd, SESSION_STATE_FILE);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function writeSessionState(state, opts = {}) {
|
|
114
|
-
const cwd = opts.cwd || process.cwd();
|
|
115
|
-
const filePath = opts.filePath || resolveSessionStatePath(cwd);
|
|
116
|
-
const normalized = normalizeState(state);
|
|
117
|
-
writeFileSync(filePath, JSON.stringify(normalized, null, 2) + '\n', 'utf8');
|
|
118
|
-
return filePath;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function updateSessionPhase(startCwd, nextPhase, patch = {}) {
|
|
122
|
-
if (!isValidPhase(nextPhase)) throw new Error(`Invalid phase: ${String(nextPhase)}`);
|
|
123
|
-
const existing = readSessionState(startCwd);
|
|
124
|
-
if (!existing) throw new Error('Session state file not found');
|
|
125
|
-
if (!canTransition(existing.phase, nextPhase)) {
|
|
126
|
-
throw new Error(`Invalid phase transition: ${existing.phase} -> ${nextPhase}`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const nextState = {
|
|
130
|
-
...existing,
|
|
131
|
-
...patch,
|
|
132
|
-
phase: nextPhase,
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
delete nextState._filePath;
|
|
136
|
-
const filePath = writeSessionState(nextState, { filePath: existing._filePath, cwd: startCwd });
|
|
137
|
-
return { ...nextState, _filePath: filePath };
|
|
138
|
-
}
|