xtrm-cli 0.5.0 → 0.5.27

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.
Files changed (50) hide show
  1. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
  2. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
  3. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
  4. package/dist/index.cjs +969 -1059
  5. package/dist/index.cjs.map +1 -1
  6. package/package.json +1 -1
  7. package/src/commands/clean.ts +7 -6
  8. package/src/commands/debug.ts +255 -0
  9. package/src/commands/docs.ts +180 -0
  10. package/src/commands/help.ts +92 -171
  11. package/src/commands/init.ts +9 -32
  12. package/src/commands/install-pi.ts +9 -16
  13. package/src/commands/install.ts +150 -2
  14. package/src/commands/pi-install.ts +10 -44
  15. package/src/core/context.ts +4 -52
  16. package/src/core/diff.ts +3 -16
  17. package/src/core/preflight.ts +0 -1
  18. package/src/index.ts +7 -4
  19. package/src/types/config.ts +0 -2
  20. package/src/utils/config-injector.ts +3 -3
  21. package/src/utils/pi-extensions.ts +41 -0
  22. package/src/utils/worktree-session.ts +86 -50
  23. package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
  24. package/test/extensions/beads-parity.test.ts +94 -0
  25. package/test/extensions/extension-harness.ts +5 -5
  26. package/test/extensions/quality-gates-parity.test.ts +89 -0
  27. package/test/extensions/session-flow.test.ts +91 -0
  28. package/test/extensions/xtrm-loader.test.ts +38 -20
  29. package/test/install-pi.test.ts +22 -11
  30. package/test/pi-extensions.test.ts +50 -0
  31. package/test/session-launcher.test.ts +28 -38
  32. package/extensions/beads.ts +0 -109
  33. package/extensions/core/adapter.ts +0 -45
  34. package/extensions/core/lib.ts +0 -3
  35. package/extensions/core/logger.ts +0 -45
  36. package/extensions/core/runner.ts +0 -71
  37. package/extensions/custom-footer.ts +0 -160
  38. package/extensions/main-guard-post-push.ts +0 -44
  39. package/extensions/main-guard.ts +0 -126
  40. package/extensions/minimal-mode.ts +0 -201
  41. package/extensions/quality-gates.ts +0 -67
  42. package/extensions/service-skills.ts +0 -150
  43. package/extensions/xtrm-loader.ts +0 -89
  44. package/hooks/gitnexus-impact-reminder.py +0 -13
  45. package/src/commands/finish.ts +0 -25
  46. package/src/core/session-state.ts +0 -139
  47. package/src/core/xtrm-finish.ts +0 -267
  48. package/src/tests/session-flow-parity.test.ts +0 -118
  49. package/src/tests/session-state.test.ts +0 -124
  50. package/src/tests/xtrm-finish.test.ts +0 -148
@@ -1,109 +0,0 @@
1
- import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
2
- import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
3
- import * as path from "node:path";
4
- import * as fs from "node:fs";
5
- import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
6
-
7
- const logger = new Logger({ namespace: "beads" });
8
-
9
- export default function (pi: ExtensionAPI) {
10
- const getCwd = (ctx: any) => ctx.cwd || process.cwd();
11
- const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
12
- let cachedSessionId: string | null = null;
13
-
14
- // Resolve a stable session ID across event types.
15
- const getSessionId = (ctx: any): string => {
16
- const fromManager = ctx?.sessionManager?.getSessionId?.();
17
- const fromContext = ctx?.sessionId ?? ctx?.session_id;
18
- const resolved = fromManager || fromContext || cachedSessionId || process.pid.toString();
19
- if (resolved && !cachedSessionId) cachedSessionId = resolved;
20
- return resolved;
21
- };
22
-
23
- const getSessionClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
24
- const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
25
- if (result.code === 0) return result.stdout.trim();
26
- return null;
27
- };
28
-
29
- const hasTrackableWork = async (cwd: string): Promise<boolean> => {
30
- const result = await SubprocessRunner.run("bd", ["list"], { cwd });
31
- if (result.code === 0 && result.stdout.includes("Total:")) {
32
- const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
33
- if (m) {
34
- const open = parseInt(m[1], 10);
35
- const inProgress = parseInt(m[2], 10);
36
- return (open + inProgress) > 0;
37
- }
38
- }
39
- return false;
40
- };
41
-
42
- pi.on("session_start", async (_event, ctx) => {
43
- cachedSessionId = ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? cachedSessionId;
44
- return undefined;
45
- });
46
-
47
- pi.on("tool_call", async (event, ctx) => {
48
- const cwd = getCwd(ctx);
49
- if (!isBeadsProject(cwd)) return undefined;
50
-
51
- const sessionId = getSessionId(ctx);
52
-
53
- if (EventAdapter.isMutatingFileTool(event)) {
54
- const claim = await getSessionClaim(sessionId, cwd);
55
- if (!claim) {
56
- const hasWork = await hasTrackableWork(cwd);
57
- if (hasWork) {
58
- if (ctx.hasUI) {
59
- ctx.ui.notify("Beads: Edit blocked. Claim an issue first.", "warning");
60
- }
61
- return {
62
- block: true,
63
- reason: `No active issue claim for this session (${sessionId}).\n bd update <id> --claim`,
64
- };
65
- }
66
- }
67
- }
68
-
69
- if (isToolCallEventType("bash", event)) {
70
- const command = event.input.command;
71
- if (command && /\bgit\s+commit\b/.test(command)) {
72
- const claim = await getSessionClaim(sessionId, cwd);
73
- if (claim) {
74
- return {
75
- block: true,
76
- reason: `Resolve open claim [${claim}] before committing.`,
77
- };
78
- }
79
- }
80
- }
81
-
82
- return undefined;
83
- });
84
-
85
- pi.on("tool_result", async (event, ctx) => {
86
- if (isBashToolResult(event)) {
87
- const command = event.input.command;
88
- const sessionId = getSessionId(ctx);
89
-
90
- if (command && /\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
91
- const issueMatch = command.match(/\bbd\s+update\s+(\S+)/);
92
- if (issueMatch) {
93
- const issueId = issueMatch[1];
94
- const cwd = getCwd(ctx);
95
- await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
96
- const claimNotice = `\n\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
97
- return { content: [...event.content, { type: "text", text: claimNotice }] };
98
- }
99
- }
100
-
101
- if (command && /\bbd\s+close\b/.test(command) && !event.isError) {
102
- const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
103
- const newContent = [...event.content, { type: "text", text: reminder }];
104
- return { content: newContent };
105
- }
106
- }
107
- return undefined;
108
- });
109
- }
@@ -1,45 +0,0 @@
1
- import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
2
-
3
- export class EventAdapter {
4
- /**
5
- * Checks if the tool event is a mutating file operation (write, edit, etc).
6
- */
7
- static isMutatingFileTool(event: ToolCallEvent<any, any>): boolean {
8
- const tools = [
9
- "write",
10
- "edit",
11
- "replace_content",
12
- "replace_lines",
13
- "delete_lines",
14
- "insert_at_line",
15
- "create_text_file",
16
- "rename_symbol",
17
- "replace_symbol_body",
18
- "insert_after_symbol",
19
- "insert_before_symbol",
20
- ];
21
- return tools.includes(event.toolName);
22
- }
23
-
24
- /**
25
- * Extracts the target path from a tool input, resolving against the current working directory.
26
- */
27
- static extractPathFromToolInput(event: ToolCallEvent<any, any>, cwd: string): string | null {
28
- const input = event.input;
29
- if (!input) return null;
30
-
31
- const pathRaw = input.path || input.file || input.filePath;
32
- if (typeof pathRaw === "string") {
33
- return pathRaw; // Usually Pi passes absolute paths anyway or paths relative to root
34
- }
35
-
36
- return null;
37
- }
38
-
39
- /**
40
- * Safely formats a block reason string to ensure UI readiness.
41
- */
42
- static formatBlockReason(prefix: string, details: string): string {
43
- return `${prefix}: ${details}`;
44
- }
45
- }
@@ -1,3 +0,0 @@
1
- export * from "./logger";
2
- export * from "./runner";
3
- export * from "./adapter";
@@ -1,45 +0,0 @@
1
- export type LogLevel = "debug" | "info" | "warn" | "error";
2
-
3
- export interface LoggerOptions {
4
- namespace: string;
5
- level?: LogLevel;
6
- }
7
-
8
- export class Logger {
9
- private namespace: string;
10
- private level: LogLevel;
11
-
12
- constructor(options: LoggerOptions) {
13
- this.namespace = options.namespace;
14
- this.level = options.level || "info";
15
- }
16
-
17
- private shouldLog(level: LogLevel): boolean {
18
- const levels: LogLevel[] = ["debug", "info", "warn", "error"];
19
- return levels.indexOf(level) >= levels.indexOf(this.level);
20
- }
21
-
22
- debug(message: string, ...args: any[]) {
23
- if (this.shouldLog("debug")) {
24
- console.debug(`[${this.namespace}] DEBUG: ${message}`, ...args);
25
- }
26
- }
27
-
28
- info(message: string, ...args: any[]) {
29
- if (this.shouldLog("info")) {
30
- console.info(`[${this.namespace}] INFO: ${message}`, ...args);
31
- }
32
- }
33
-
34
- warn(message: string, ...args: any[]) {
35
- if (this.shouldLog("warn")) {
36
- console.warn(`[${this.namespace}] WARN: ${message}`, ...args);
37
- }
38
- }
39
-
40
- error(message: string, ...args: any[]) {
41
- if (this.shouldLog("error")) {
42
- console.error(`[${this.namespace}] ERROR: ${message}`, ...args);
43
- }
44
- }
45
- }
@@ -1,71 +0,0 @@
1
- import { execFile, spawnSync } from "node:child_process";
2
- import { promisify } from "node:util";
3
-
4
- const execFileAsync = promisify(execFile);
5
-
6
- export interface RunOptions {
7
- timeoutMs?: number;
8
- cwd?: string;
9
- env?: Record<string, string>;
10
- input?: string; // Standard input
11
- }
12
-
13
- export interface RunResult {
14
- code: number;
15
- stdout: string;
16
- stderr: string;
17
- }
18
-
19
- export class SubprocessRunner {
20
- /**
21
- * Run a command deterministically with a timeout and optional stdin.
22
- */
23
- static async run(
24
- command: string,
25
- args: string[],
26
- options: RunOptions = {}
27
- ): Promise<RunResult> {
28
- const timeout = options.timeoutMs ?? 10000;
29
- const cwd = options.cwd ?? process.cwd();
30
- const env = { ...process.env, ...options.env };
31
-
32
- if (options.input !== undefined) {
33
- // Use spawnSync for stdin support if input is provided
34
- const result = spawnSync(command, args, {
35
- cwd,
36
- env,
37
- input: options.input,
38
- encoding: "utf8",
39
- timeout,
40
- maxBuffer: 1024 * 1024 * 10,
41
- });
42
-
43
- return {
44
- code: result.status ?? 1,
45
- stdout: (result.stdout ?? "").trim(),
46
- stderr: (result.stderr ?? "").trim(),
47
- };
48
- }
49
-
50
- try {
51
- const result = await execFileAsync(command, args, {
52
- timeout,
53
- cwd,
54
- env,
55
- maxBuffer: 1024 * 1024 * 10,
56
- });
57
-
58
- return {
59
- code: 0,
60
- stdout: result.stdout.trim(),
61
- stderr: result.stderr.trim(),
62
- };
63
- } catch (error: any) {
64
- return {
65
- code: error.code ?? 1,
66
- stdout: (error.stdout ?? "").trim(),
67
- stderr: (error.stderr ?? error.message ?? "").trim(),
68
- };
69
- }
70
- }
71
- }
@@ -1,160 +0,0 @@
1
- /**
2
- * XTRM Custom Footer Extension
3
- *
4
- * Displays: XTRM brand, Model, Context%, CWD, Git branch, Beads chip
5
- */
6
-
7
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
- import { truncateToWidth } from "@mariozechner/pi-tui";
9
-
10
- import { SubprocessRunner, EventAdapter } from "./core/lib";
11
-
12
- export default function (pi: ExtensionAPI) {
13
- interface BeadState {
14
- claimId: string | null;
15
- shortId: string | null;
16
- status: string | null;
17
- openCount: number;
18
- lastFetch: number;
19
- }
20
-
21
- const STATUS_ICONS: Record<string, string> = {
22
- open: "○",
23
- in_progress: "◐",
24
- blocked: "●",
25
- closed: "✓",
26
- };
27
- // Chip background colours (raw ANSI — theme has no bg() API)
28
- const CHIP_BG_NEUTRAL = "\x1b[48;5;238m"; // dark gray
29
- const CHIP_BG_ACTIVE = "\x1b[48;5;39m"; // blue
30
- const CHIP_BG_BLOCKED = "\x1b[48;5;88m"; // red
31
- const CHIP_FG = "\x1b[38;5;15m"; // white
32
- const CHIP_RESET = "\x1b[0m";
33
- const chip = (text: string, bg = CHIP_BG_NEUTRAL): string =>
34
- `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
35
-
36
- const STATUS_BG: Record<string, string> = {
37
- open: CHIP_BG_NEUTRAL,
38
- in_progress: CHIP_BG_ACTIVE,
39
- blocked: CHIP_BG_BLOCKED,
40
- };
41
-
42
- let capturedCtx: any = null;
43
- let sessionId: string = "";
44
- let beadState: BeadState = { claimId: null, shortId: null, status: null, openCount: 0, lastFetch: 0 };
45
- let refreshing = false;
46
- let requestRender: (() => void) | null = null;
47
- const CACHE_TTL = 5000;
48
-
49
- const getCwd = () => capturedCtx?.cwd || process.cwd();
50
- const getShortId = (id: string) => id.split("-").pop() ?? id;
51
-
52
- const refreshBeadState = async () => {
53
- if (refreshing || Date.now() - beadState.lastFetch < CACHE_TTL) return;
54
- const cwd = getCwd();
55
- if (!EventAdapter.isBeadsProject(cwd)) return;
56
- if (!sessionId) return;
57
- refreshing = true;
58
- try {
59
- const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
60
- const claimId = claimResult.code === 0 ? claimResult.stdout.trim() || null : null;
61
-
62
- let status: string | null = null;
63
- if (claimId) {
64
- const showResult = await SubprocessRunner.run("bd", ["show", claimId, "--json"], { cwd });
65
- if (showResult.code === 0) {
66
- try { status = JSON.parse(showResult.stdout)[0]?.status ?? null; } catch {}
67
- }
68
- if (status === "closed") {
69
- await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
70
- beadState = { claimId: null, shortId: null, status: null, openCount: beadState.openCount, lastFetch: Date.now() };
71
- requestRender?.();
72
- return;
73
- }
74
- }
75
-
76
- let openCount = 0;
77
- const listResult = await SubprocessRunner.run("bd", ["list"], { cwd });
78
- if (listResult.code === 0) {
79
- const m = listResult.stdout.match(/\((\d+)\s+open/);
80
- if (m) openCount = parseInt(m[1], 10);
81
- }
82
-
83
- beadState = { claimId, shortId: claimId ? getShortId(claimId) : null, status, openCount, lastFetch: Date.now() };
84
- requestRender?.();
85
- } catch {}
86
- finally { refreshing = false; }
87
- };
88
-
89
- const buildBeadChip = (): string => {
90
- const { claimId, shortId, status, openCount } = beadState;
91
- if (claimId && shortId && status) {
92
- const icon = STATUS_ICONS[status] ?? "?";
93
- const bg = STATUS_BG[status] ?? CHIP_BG_NEUTRAL;
94
- return chip(`bd:${shortId}${icon}`, bg);
95
- }
96
- if (openCount > 0) {
97
- return chip(`bd:${openCount}${STATUS_ICONS.open}`);
98
- }
99
- return "";
100
- };
101
-
102
- pi.on("session_start", async (_event, ctx) => {
103
- capturedCtx = ctx;
104
- // Get session ID from sessionManager/context (prefer UUID, consistent with hooks)
105
- sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
106
-
107
- ctx.ui.setFooter((tui, theme, footerData) => {
108
- requestRender = () => tui.requestRender();
109
- const unsub = footerData.onBranchChange(() => tui.requestRender());
110
-
111
- return {
112
- dispose() { unsub(); requestRender = null; },
113
- invalidate() {},
114
- render(width: number): string[] {
115
- refreshBeadState().catch(() => {});
116
-
117
- const BOLD = "\x1b[1m", BOLD_OFF = "\x1b[22m";
118
- const brand = `${BOLD}${theme.fg("accent", "XTRM")}${BOLD_OFF}`;
119
-
120
- const usage = ctx.getContextUsage();
121
- const pct = usage?.percent ?? 0;
122
- const pctColor = pct > 75 ? "error" : pct > 50 ? "warning" : "success";
123
- const usageStr = theme.fg(pctColor, `${pct.toFixed(0)}%`);
124
-
125
- const parts = process.cwd().split("/");
126
- const short = parts.length > 2 ? parts.slice(-2).join("/") : process.cwd();
127
- const cwdStr = theme.fg("muted", `⌂ ${short}`);
128
-
129
- const branch = footerData.getGitBranch();
130
- const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
131
-
132
- const modelId = ctx.model?.id || "no-model";
133
- const modelChip = chip(modelId);
134
-
135
- const sep = " ";
136
-
137
- const brandModel = `${brand} ${modelChip}`;
138
- const beadChip = buildBeadChip();
139
- const leftParts = [brandModel, usageStr];
140
- if (beadChip) leftParts.push(beadChip);
141
- leftParts.push(cwdStr);
142
- if (branchStr) leftParts.push(branchStr);
143
-
144
- const left = leftParts.join(sep);
145
- return [truncateToWidth(left, width)];
146
- },
147
- };
148
- });
149
- });
150
-
151
- // Bust the bead cache immediately after any bd write
152
- pi.on("tool_result", async (event: any) => {
153
- const cmd = event?.input?.command;
154
- if (cmd && /\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
155
- beadState.lastFetch = 0;
156
- setTimeout(() => refreshBeadState().catch(() => {}), 200);
157
- }
158
- return undefined;
159
- });
160
- }
@@ -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,126 +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
-
5
- const logger = new Logger({ namespace: "main-guard" });
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
- const getCurrentBranch = async (cwd: string): Promise<string | null> => {
15
- const result = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
16
- if (result.code === 0) return result.stdout;
17
- return null;
18
- };
19
-
20
- const protectedPaths = [".env", ".git/", "node_modules/"];
21
-
22
- pi.on("tool_call", async (event, ctx) => {
23
- const cwd = ctx.cwd || process.cwd();
24
-
25
- // 1. Safety Check: Protected Paths (Global)
26
- if (EventAdapter.isMutatingFileTool(event)) {
27
- const path = EventAdapter.extractPathFromToolInput(event, cwd);
28
- if (path && protectedPaths.some((p) => path.includes(p))) {
29
- const reason = `Path "${path}" is protected. Edits to sensitive system files are restricted.`;
30
- if (ctx.hasUI) {
31
- ctx.ui.notify(`Safety: Blocked edit to protected path`, "error");
32
- }
33
- return { block: true, reason };
34
- }
35
- }
36
-
37
- // 2. Safety Check: Dangerous Commands (Global)
38
- if (isToolCallEventType("bash", event)) {
39
- const cmd = event.input.command.trim();
40
- if (cmd.includes("rm -rf") && !cmd.includes("--help")) {
41
- if (ctx.hasUI) {
42
- const ok = await ctx.ui.confirm("Dangerous Command", `Allow execution of: ${cmd}?`);
43
- if (!ok) return { block: true, reason: "Blocked by user confirmation" };
44
- } else {
45
- return { block: true, reason: "Dangerous command 'rm -rf' blocked in non-interactive mode" };
46
- }
47
- }
48
- }
49
-
50
- // 3. Main-Guard: Branch Protection
51
- const protectedBranches = getProtectedBranches();
52
- const branch = await getCurrentBranch(cwd);
53
-
54
- if (branch && protectedBranches.includes(branch)) {
55
- // A. Mutating File Tools on Main
56
- if (EventAdapter.isMutatingFileTool(event)) {
57
- const reason = `On protected branch '${branch}'. Checkout a feature branch first: \`git checkout -b feature/<name>\``;
58
- if (ctx.hasUI) {
59
- ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
60
- }
61
- return { block: true, reason };
62
- }
63
-
64
- // B. Bash Commands on Main
65
- if (isToolCallEventType("bash", event)) {
66
- const cmd = event.input.command.trim();
67
-
68
- // Emergency override
69
- if (process.env.MAIN_GUARD_ALLOW_BASH === "1") return undefined;
70
-
71
- // Enforce squash-only PR merges
72
- if (/^gh\s+pr\s+merge\b/.test(cmd)) {
73
- if (!/--squash\b/.test(cmd)) {
74
- const reason = "Squash only: use `gh pr merge --squash` (or MAIN_GUARD_ALLOW_BASH=1)";
75
- return { block: true, reason };
76
- }
77
- return undefined;
78
- }
79
-
80
- // Safe allowlist
81
- const SAFE_BASH_PATTERNS = [
82
- /^git\s+(status|log|diff|branch|show|describe|fetch|remote|config)\b/,
83
- /^git\s+pull\b/,
84
- /^git\s+stash\b/,
85
- /^git\s+worktree\b/,
86
- /^git\s+checkout\s+-b\s+\S+/,
87
- /^git\s+switch\s+-c\s+\S+/,
88
- ...protectedBranches.map(b => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`)),
89
- /^gh\s+/,
90
- /^bd\s+/,
91
- /^touch\s+\.beads\//,
92
- ];
93
-
94
- if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
95
- return undefined;
96
- }
97
-
98
- // Specific blocks
99
- if (/\bgit\s+commit\b/.test(cmd)) {
100
- return { block: true, reason: `No commits on '${branch}' — use a feature branch.` };
101
- }
102
-
103
- if (/\bgit\s+push\b/.test(cmd)) {
104
- const tokens = cmd.split(/\s+/);
105
- const lastToken = tokens[tokens.length - 1];
106
- const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
107
- const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
108
-
109
- if (explicitProtected || impliedProtected) {
110
- return { block: true, reason: `No direct push to '${branch}' — push a feature branch and open a PR.` };
111
- }
112
- return undefined;
113
- }
114
-
115
- // Default deny
116
- const reason = `Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n Exit: git checkout -b feature/<name>`;
117
- if (ctx.hasUI) {
118
- ctx.ui.notify("Main-Guard: Command blocked", "error");
119
- }
120
- return { block: true, reason };
121
- }
122
- }
123
-
124
- return undefined;
125
- });
126
- }