xtrm-tools 2.4.2 → 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.
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
11
11
  import { existsSync, unlinkSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { readHookInput } from './beads-gate-core.mjs';
14
- import { resolveCwd, isBeadsProject, getSessionClaim } from './beads-gate-utils.mjs';
14
+ import { resolveCwd, resolveSessionId, isBeadsProject, getSessionClaim, clearSessionClaim } from './beads-gate-utils.mjs';
15
15
  import { memoryPromptMessage } from './beads-gate-messages.mjs';
16
16
 
17
17
  const input = readHookInput();
@@ -20,35 +20,42 @@ if (!input) process.exit(0);
20
20
  const cwd = resolveCwd(input);
21
21
  if (!cwd || !isBeadsProject(cwd)) process.exit(0);
22
22
 
23
+ const sessionId = resolveSessionId(input);
24
+
23
25
  // Agent signals evaluation complete by touching this marker, then stops again
24
26
  const marker = join(cwd, '.beads', '.memory-gate-done');
25
27
  if (existsSync(marker)) {
26
28
  try { unlinkSync(marker); } catch { /* ignore */ }
29
+ // Clear the claim and closed-this-session marker
30
+ clearSessionClaim(sessionId, cwd);
31
+ try {
32
+ execSync(`bd kv clear "closed-this-session:${sessionId}"`, {
33
+ cwd,
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ timeout: 5000,
36
+ });
37
+ } catch { /* ignore */ }
27
38
  process.exit(0);
28
39
  }
29
40
 
30
- // Only fire if this session had an active claim that is now closed
31
- const sessionId = input.session_id ?? null;
32
- if (!sessionId) process.exit(0);
33
-
34
- const claimId = getSessionClaim(sessionId, cwd);
35
- if (!claimId) process.exit(0); // no claim this session → no work to persist
36
-
37
- // Check if the claimed issue was closed this session
38
- let claimClosed = false;
41
+ // Check if an issue was closed this session (set by beads-claim-sync on bd close)
42
+ let closedIssueId = null;
39
43
  try {
40
- const out = execSync('bd list --status=closed', {
44
+ closedIssueId = execSync(`bd kv get "closed-this-session:${sessionId}"`, {
41
45
  encoding: 'utf8',
42
46
  cwd,
43
47
  stdio: ['pipe', 'pipe', 'pipe'],
44
- timeout: 8000,
45
- });
46
- claimClosed = out.includes(claimId);
47
- } catch {
48
+ timeout: 5000,
49
+ }).trim();
50
+ } catch (err) {
51
+ if (err.status === 1) {
52
+ // No closed-this-session marker → nothing to prompt about
53
+ process.exit(0);
54
+ }
48
55
  process.exit(0); // fail open
49
56
  }
50
57
 
51
- if (!claimClosed) process.exit(0);
58
+ if (!closedIssueId) process.exit(0);
52
59
 
53
60
  process.stderr.write(memoryPromptMessage());
54
61
  process.exit(2);
@@ -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
  });
@@ -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.session_id ?? input.sessionId;
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;
@@ -46,6 +46,7 @@ export const DANGEROUS_BASH_PATTERNS = [
46
46
  ];
47
47
 
48
48
  export const SAFE_BASH_PREFIXES = [
49
+ // Git read-only
49
50
  'git status',
50
51
  'git log',
51
52
  'git diff',
@@ -60,10 +61,41 @@ export const SAFE_BASH_PREFIXES = [
60
61
  'git worktree',
61
62
  'git checkout -b',
62
63
  'git switch -c',
64
+ // Tools
63
65
  'gh',
64
66
  'bd',
65
- 'touch .beads/',
66
67
  'npx gitnexus',
68
+ 'xtrm finish',
69
+ // Read-only filesystem
70
+ 'cat',
71
+ 'ls',
72
+ 'head',
73
+ 'tail',
74
+ 'pwd',
75
+ 'which',
76
+ 'type',
77
+ 'env',
78
+ 'printenv',
79
+ 'find',
80
+ 'grep',
81
+ 'rg',
82
+ 'fd',
83
+ 'wc',
84
+ 'sort',
85
+ 'uniq',
86
+ 'cut',
87
+ 'awk',
88
+ 'jq',
89
+ 'yq',
90
+ 'bat',
91
+ 'less',
92
+ 'more',
93
+ 'file',
94
+ 'stat',
95
+ 'du',
96
+ 'tree',
97
+ // Allowed writes (specific paths)
98
+ 'touch .beads/',
67
99
  ];
68
100
 
69
101
  export const NATIVE_TEAM_TOOLS = [
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": [
@@ -5,7 +5,8 @@
5
5
  // Installed by: xtrm install
6
6
 
7
7
  import { execSync } from 'node:child_process';
8
- import { readFileSync } from 'node:fs';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
9
10
  import { WRITE_TOOLS, SAFE_BASH_PREFIXES } from './guard-rules.mjs';
10
11
 
11
12
  let branch = '';
@@ -16,12 +17,10 @@ try {
16
17
  }).trim();
17
18
  } catch {}
18
19
 
19
- // Determine protected branches — env var override for tests and custom setups
20
20
  const protectedBranches = process.env.MAIN_GUARD_PROTECTED_BRANCHES
21
21
  ? process.env.MAIN_GUARD_PROTECTED_BRANCHES.split(',').map(b => b.trim()).filter(Boolean)
22
22
  : ['main', 'master'];
23
23
 
24
- // Not in a git repo or not on a protected branch — allow
25
24
  if (!branch || !protectedBranches.includes(branch)) {
26
25
  process.exit(0);
27
26
  }
@@ -34,70 +33,70 @@ try {
34
33
  }
35
34
 
36
35
  const tool = input.tool_name ?? '';
37
- const hookEventName = input.hook_event_name ?? 'PreToolUse';
36
+ const cwd = input.cwd || process.cwd();
38
37
 
39
38
  function deny(reason) {
40
- process.stdout.write(JSON.stringify({
41
- decision: 'block',
42
- reason,
43
- }));
39
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
44
40
  process.stdout.write('\n');
45
41
  process.exit(0);
46
42
  }
47
43
 
44
+ function getSessionState(cwd) {
45
+ const statePath = join(cwd, '.xtrm-session-state.json');
46
+ if (!existsSync(statePath)) return null;
47
+ try {
48
+ return JSON.parse(readFileSync(statePath, 'utf8'));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
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
+
48
60
  if (WRITE_TOOLS.includes(tool)) {
49
61
  deny(`⛔ On '${branch}' — start on a feature branch and claim an issue.\n`
50
- + ' git checkout -b feature/<name>\n'
62
+ + ' git checkout -b feature/<name> (or: git switch -c feature/<name>)\n'
51
63
  + ' bd update <id> --claim\n');
52
64
  }
53
65
 
54
- const WORKFLOW =
55
- ' 1. git checkout -b feature/<name>\n'
56
- + ' 2. bd create + bd update in_progress\n'
57
- + ' 3. bd close <id> && git add && git commit\n'
58
- + ' 4. git push -u origin feature/<name>\n'
59
- + ' 5. gh pr create --fill && gh pr merge --squash\n';
60
-
61
66
  if (tool === 'Bash') {
62
67
  const cmd = (input.tool_input?.command ?? '').trim().replace(/\s+/g, ' ');
68
+ const normalizedCmd = normalizeGitCCommand(cmd);
69
+ const state = getSessionState(cwd);
63
70
 
64
- // Emergency override — escape hatch for power users
65
71
  if (process.env.MAIN_GUARD_ALLOW_BASH === '1') {
66
72
  process.exit(0);
67
73
  }
68
74
 
69
- // Enforce squash-only PR merges for linear history
70
- // Must check BEFORE the gh allowlist pattern
71
75
  if (/^gh\s+pr\s+merge\b/.test(cmd)) {
72
76
  if (!/--squash\b/.test(cmd)) {
73
77
  deny('⛔ Squash only: gh pr merge --squash\n'
74
78
  + ' (override: MAIN_GUARD_ALLOW_BASH=1 gh pr merge --merge)\n');
75
79
  }
76
- // --squash present — allow
77
80
  process.exit(0);
78
81
  }
79
82
 
80
- // Safe allowlist — non-mutating commands + explicit branch-exit paths.
81
- // Important: do not allow generic checkout/switch forms, which include
82
- // mutating variants such as `git checkout -- <path>`.
83
83
  const SAFE_BASH_PATTERNS = [
84
84
  ...SAFE_BASH_PREFIXES.map(prefix => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)),
85
- // Allow post-merge sync to protected branch only (not arbitrary origin refs)
86
85
  ...protectedBranches.map(b => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`)),
87
86
  ];
88
87
 
89
- if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
88
+ if (SAFE_BASH_PATTERNS.some(p => p.test(cmd) || p.test(normalizedCmd))) {
90
89
  process.exit(0);
91
90
  }
92
91
 
93
- // Specific messages for common blocked operations
94
- if (/^git\s+commit\b/.test(cmd)) {
95
- deny(`⛔ No commits on '${branch}' use a feature branch.\n`
96
- + ' git checkout -b feature/<name>\n');
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');
97
96
  }
98
97
 
99
- if (/^git\s+push\b/.test(cmd)) {
100
- const tokens = cmd.split(' ');
98
+ if (/\bgit\s+push\b/.test(normalizedCmd)) {
99
+ const tokens = normalizedCmd.split(' ');
101
100
  const lastToken = tokens[tokens.length - 1];
102
101
  const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
103
102
  const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
@@ -105,13 +104,15 @@ if (tool === 'Bash') {
105
104
  deny(`⛔ No direct push to '${branch}' — push a feature branch and open a PR.\n`
106
105
  + ' git push -u origin <feature-branch> && gh pr create --fill\n');
107
106
  }
108
- // Pushing to a feature branch — allow
109
107
  process.exit(0);
110
108
  }
111
109
 
112
- // Default deny — block everything else on protected branches
113
- deny(`⛔ Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n`
114
- + ' Exit: git checkout -b feature/<name>\n'
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
115
116
  + ' Override: MAIN_GUARD_ALLOW_BASH=1 <cmd>\n');
116
117
  }
117
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- }