xtrm-tools 2.4.3 → 2.4.6
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/hooks.json +0 -15
- 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/minimal-mode.ts +201 -0
- 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/config/pi/settings.json.template +1 -1
- 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 +16 -28
- 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
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
11
|
import {
|
|
12
12
|
resolveCwd,
|
|
13
|
+
resolveSessionId,
|
|
13
14
|
isBeadsProject,
|
|
14
15
|
getSessionClaim,
|
|
15
16
|
getTotalWork,
|
|
16
17
|
getInProgress,
|
|
18
|
+
isIssueInProgress,
|
|
17
19
|
} from './beads-gate-utils.mjs';
|
|
18
20
|
|
|
19
21
|
// ── Input parsing ────────────────────────────────────────────────────────────
|
|
@@ -41,7 +43,7 @@ export function resolveSessionContext(input) {
|
|
|
41
43
|
if (!cwd) return null;
|
|
42
44
|
return {
|
|
43
45
|
cwd,
|
|
44
|
-
sessionId: input
|
|
46
|
+
sessionId: resolveSessionId(input),
|
|
45
47
|
isBeadsProject: isBeadsProject(cwd),
|
|
46
48
|
};
|
|
47
49
|
}
|
|
@@ -68,6 +70,7 @@ export function resolveClaimAndWorkState(ctx) {
|
|
|
68
70
|
return {
|
|
69
71
|
claimed: !!claimId,
|
|
70
72
|
claimId: claimId || null,
|
|
73
|
+
claimInProgress: claimId ? isIssueInProgress(claimId, ctx.cwd) : false,
|
|
71
74
|
totalWork,
|
|
72
75
|
inProgress,
|
|
73
76
|
};
|
|
@@ -77,6 +80,7 @@ export function resolveClaimAndWorkState(ctx) {
|
|
|
77
80
|
return {
|
|
78
81
|
claimed: false,
|
|
79
82
|
claimId: null,
|
|
83
|
+
claimInProgress: false,
|
|
80
84
|
totalWork,
|
|
81
85
|
inProgress,
|
|
82
86
|
};
|
|
@@ -154,16 +158,16 @@ export function decideCommitGate(ctx, state) {
|
|
|
154
158
|
return { allow: true };
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
//
|
|
158
|
-
if (!state.
|
|
161
|
+
// Claimed issue is no longer in_progress → allow (closed or transferred to another agent)
|
|
162
|
+
if (!state.claimInProgress) {
|
|
159
163
|
return { allow: true };
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
//
|
|
166
|
+
// Session's own claimed issue is still in_progress → block (need to close first)
|
|
163
167
|
return {
|
|
164
168
|
allow: false,
|
|
165
169
|
reason: 'unclosed_claim',
|
|
166
|
-
summary: state.
|
|
170
|
+
summary: ` Claimed: ${state.claimId} (still in_progress)`,
|
|
167
171
|
claimed: state.claimId,
|
|
168
172
|
};
|
|
169
173
|
}
|
|
@@ -196,16 +200,16 @@ export function decideStopGate(ctx, state) {
|
|
|
196
200
|
return { allow: true };
|
|
197
201
|
}
|
|
198
202
|
|
|
199
|
-
//
|
|
200
|
-
if (!state.
|
|
203
|
+
// Claimed issue is no longer in_progress → allow (stale claim)
|
|
204
|
+
if (!state.claimInProgress) {
|
|
201
205
|
return { allow: true };
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
//
|
|
208
|
+
// Session's own claimed issue is still in_progress → block
|
|
205
209
|
return {
|
|
206
210
|
allow: false,
|
|
207
211
|
reason: 'unclosed_claim',
|
|
208
|
-
summary: state.
|
|
212
|
+
summary: ` Claimed: ${state.claimId} (still in_progress)`,
|
|
209
213
|
claimed: state.claimId,
|
|
210
214
|
};
|
|
211
215
|
}
|
|
@@ -68,34 +68,16 @@ export function stopBlockMessage(summary, claimed) {
|
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
const pr = state.prNumber != null ? `#${state.prNumber}` : '(PR pending)';
|
|
73
|
-
const prUrl = state.prUrl ? `\nPR: ${state.prUrl}` : '';
|
|
74
|
-
return (
|
|
75
|
-
`🚫 PR ${pr} not yet merged. Run: xtrm finish\n` +
|
|
76
|
-
`${prUrl}\n` +
|
|
77
|
-
`Worktree: ${state.worktreePath}\n`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function stopBlockConflictingMessage(state) {
|
|
82
|
-
const conflicts = Array.isArray(state.conflictFiles) && state.conflictFiles.length > 0
|
|
83
|
-
? state.conflictFiles.join(', ')
|
|
84
|
-
: 'unknown files';
|
|
85
|
-
return (
|
|
86
|
-
`🚫 Merge conflicts in: ${conflicts}. Resolve, push, then: xtrm finish\n` +
|
|
87
|
-
`Worktree: ${state.worktreePath}\n`
|
|
88
|
-
);
|
|
89
|
-
}
|
|
71
|
+
// ── Memory gate messages ─────────────────────────────────────────
|
|
90
72
|
|
|
91
|
-
export function
|
|
73
|
+
export function memoryGatePendingMessage() {
|
|
92
74
|
return (
|
|
93
|
-
|
|
75
|
+
'🧠 Memory gate pending — evaluate insights before continuing.\n' +
|
|
76
|
+
' YES → bd remember "<insight>" NO → note "nothing to persist"\n' +
|
|
77
|
+
' Then acknowledge: touch .beads/.memory-gate-done\n'
|
|
94
78
|
);
|
|
95
79
|
}
|
|
96
80
|
|
|
97
|
-
// ── Memory gate messages ─────────────────────────────────────────
|
|
98
|
-
|
|
99
81
|
export function memoryPromptMessage() {
|
|
100
82
|
return (
|
|
101
83
|
'🧠 Memory gate: for each closed issue, worth persisting?\n' +
|
|
@@ -13,6 +13,14 @@ export function resolveCwd(input) {
|
|
|
13
13
|
return input.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a stable session key for beads hooks.
|
|
18
|
+
* Priority: explicit hook session id -> cwd fallback.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveSessionId(input) {
|
|
21
|
+
return input?.session_id ?? input?.sessionId ?? resolveCwd(input);
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
/** Return true if the directory contains a .beads project. */
|
|
17
25
|
export function isBeadsProject(cwd) {
|
|
18
26
|
return existsSync(join(cwd, '.beads'));
|
|
@@ -71,6 +79,23 @@ export function getInProgress(cwd) {
|
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Return true if a specific issue ID is currently in in_progress status.
|
|
84
|
+
* Used by commit/stop gates to scope the check to the session's own claimed issue.
|
|
85
|
+
* Returns false (fail open) if bd is unavailable.
|
|
86
|
+
*/
|
|
87
|
+
export function isIssueInProgress(issueId, cwd) {
|
|
88
|
+
if (!issueId) return false;
|
|
89
|
+
try {
|
|
90
|
+
const output = execSync('bd list --status=in_progress', {
|
|
91
|
+
encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'], timeout: 8000,
|
|
92
|
+
});
|
|
93
|
+
return output.includes(issueId);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
/**
|
|
75
100
|
* Count total trackable work (open + in_progress issues) using a single bd list call.
|
|
76
101
|
* Returns the count, or null if bd is unavailable.
|
|
@@ -90,6 +115,42 @@ export function getTotalWork(cwd) {
|
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Get the closed-this-session issue ID for a session from bd kv.
|
|
120
|
+
* Returns: issue ID string if set, '' if not set, null if bd kv unavailable.
|
|
121
|
+
*/
|
|
122
|
+
export function getClosedThisSession(sessionId, cwd) {
|
|
123
|
+
try {
|
|
124
|
+
return execSync(`bd kv get "closed-this-session:${sessionId}"`, {
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
cwd,
|
|
127
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
|
+
timeout: 5000,
|
|
129
|
+
}).trim();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err.status === 1) return ''; // key not found
|
|
132
|
+
return null; // bd kv unavailable
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Return true if the memory gate is pending acknowledgment for this session.
|
|
138
|
+
* Pending = closed-this-session kv is set AND .beads/.memory-gate-done marker is absent.
|
|
139
|
+
*/
|
|
140
|
+
export function isMemoryGatePending(sessionId, cwd) {
|
|
141
|
+
if (existsSync(join(cwd, '.beads', '.memory-gate-done'))) return false;
|
|
142
|
+
const closed = getClosedThisSession(sessionId, cwd);
|
|
143
|
+
return !!closed; // null (unavailable) is falsy → fail open
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Return true if a Bash command is a memory-gate acknowledgment command.
|
|
148
|
+
* These commands are allowed even while the memory gate is pending.
|
|
149
|
+
*/
|
|
150
|
+
export function isMemoryAckCommand(command) {
|
|
151
|
+
return /\bbd\s+remember\b/.test(command) || /\btouch\s+\.beads\/\.memory-gate-done\b/.test(command);
|
|
152
|
+
}
|
|
153
|
+
|
|
93
154
|
/**
|
|
94
155
|
* Clear the session claim key from bd kv. Non-fatal — best-effort cleanup.
|
|
95
156
|
*/
|
|
@@ -115,7 +176,10 @@ export function clearSessionClaim(sessionId, cwd) {
|
|
|
115
176
|
export function withSafeBdContext(fn) {
|
|
116
177
|
try {
|
|
117
178
|
fn();
|
|
118
|
-
} catch {
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err instanceof TypeError || err instanceof ReferenceError || err instanceof SyntaxError) {
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
119
183
|
process.exit(0);
|
|
120
184
|
}
|
|
121
185
|
}
|
|
@@ -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, clearSessionClaim } 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,8 +20,7 @@ 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 = input
|
|
24
|
-
if (!sessionId) process.exit(0);
|
|
23
|
+
const sessionId = resolveSessionId(input);
|
|
25
24
|
|
|
26
25
|
// Agent signals evaluation complete by touching this marker, then stops again
|
|
27
26
|
const marker = join(cwd, '.beads', '.memory-gate-done');
|
|
@@ -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
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
// Output: { hookSpecificOutput: { additionalSystemPrompt } }
|
|
6
6
|
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
|
-
import { readFileSync
|
|
9
|
-
import { join } from 'node:path';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
10
9
|
|
|
11
10
|
function readInput() {
|
|
12
11
|
try { return JSON.parse(readFileSync(0, 'utf-8')); } catch { return null; }
|
|
@@ -16,36 +15,25 @@ function getBranch(cwd) {
|
|
|
16
15
|
try {
|
|
17
16
|
return execSync('git branch --show-current', {
|
|
18
17
|
encoding: 'utf8', cwd,
|
|
19
|
-
stdio: ['pipe', 'pipe', 'pipe'], timeout:
|
|
18
|
+
stdio: ['pipe', 'pipe', 'pipe'], timeout: 2000,
|
|
20
19
|
}).trim() || null;
|
|
21
20
|
} catch { return null; }
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
encoding: 'utf8', cwd,
|
|
28
|
-
stdio: ['pipe', 'pipe', 'pipe'], timeout: 3000,
|
|
29
|
-
}).trim();
|
|
30
|
-
return out || null;
|
|
31
|
-
} catch { return null; }
|
|
32
|
-
}
|
|
23
|
+
try {
|
|
24
|
+
const input = readInput();
|
|
25
|
+
if (!input) process.exit(0);
|
|
33
26
|
|
|
34
|
-
const
|
|
35
|
-
|
|
27
|
+
const cwd = input.cwd || process.cwd();
|
|
28
|
+
const branch = getBranch(cwd);
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
const sessionId = input.session_id ?? input.sessionId;
|
|
39
|
-
const branch = getBranch(cwd);
|
|
40
|
-
const isBeads = existsSync(join(cwd, '.beads'));
|
|
41
|
-
const claim = isBeads && sessionId ? getSessionClaim(sessionId, cwd) : null;
|
|
30
|
+
if (!branch) process.exit(0);
|
|
42
31
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
process.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
process.exit(0);
|
|
32
|
+
process.stdout.write(JSON.stringify({
|
|
33
|
+
hookSpecificOutput: { additionalSystemPrompt: `[Context: branch=${branch}]` },
|
|
34
|
+
}));
|
|
35
|
+
process.stdout.write('\n');
|
|
36
|
+
process.exit(0);
|
|
37
|
+
} catch {
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
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
|
-
}
|