xtrm-tools 2.1.30 → 2.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.30",
3
+ "version": "2.2.0",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
package/config/hooks.json CHANGED
@@ -9,6 +9,12 @@
9
9
  "script": "serena-workflow-reminder.py"
10
10
  }
11
11
  ],
12
+ "UserPromptSubmit": [
13
+ {
14
+ "script": "branch-state.mjs",
15
+ "timeout": 3000
16
+ }
17
+ ],
12
18
  "PreToolUse": [
13
19
  {
14
20
  "matcher": "Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
@@ -20,10 +26,6 @@
20
26
  "script": "main-guard.mjs",
21
27
  "timeout": 5000
22
28
  },
23
- {
24
- "matcher": "Read|Edit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
25
- "script": "serena-workflow-reminder.py"
26
- },
27
29
  {
28
30
  "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
29
31
  "script": "beads-edit-gate.mjs",
@@ -47,7 +49,7 @@
47
49
  "timeout": 5000
48
50
  },
49
51
  {
50
- "matcher": "Read|Grep|Glob|Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview|mcp__serena__search_for_pattern|mcp__serena__find_referencing_symbols|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__rename_symbol",
52
+ "matcher": "Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview|mcp__serena__search_for_pattern|mcp__serena__find_referencing_symbols|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__rename_symbol",
51
53
  "script": "gitnexus/gitnexus-hook.cjs",
52
54
  "timeout": 10000
53
55
  }
@@ -1,14 +1,12 @@
1
1
  import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
2
2
  import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
3
3
  import * as path from "node:path";
4
- import * as fs from "node:fs";
5
4
  import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
6
5
 
7
6
  const logger = new Logger({ namespace: "beads" });
8
7
 
9
8
  export default function (pi: ExtensionAPI) {
10
9
  const getCwd = (ctx: any) => ctx.cwd || process.cwd();
11
- const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
12
10
 
13
11
  let cachedSessionId: string | null = null;
14
12
 
@@ -28,14 +26,19 @@ export default function (pi: ExtensionAPI) {
28
26
  };
29
27
 
30
28
  const hasTrackableWork = async (cwd: string): Promise<boolean> => {
29
+ const result = await SubprocessRunner.run("bd", ["list"], { cwd });
30
+ if (result.code === 0) {
31
+ const counts = EventAdapter.parseBdCounts(result.stdout);
32
+ if (counts) return (counts.open + counts.inProgress) > 0;
33
+ }
34
+ return false;
35
+ };
36
+
37
+ const hasInProgressWork = async (cwd: string): Promise<boolean> => {
31
38
  const result = await SubprocessRunner.run("bd", ["list"], { cwd });
32
39
  if (result.code === 0 && result.stdout.includes("Total:")) {
33
40
  const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
34
- if (m) {
35
- const open = parseInt(m[1], 10);
36
- const inProgress = parseInt(m[2], 10);
37
- return (open + inProgress) > 0;
38
- }
41
+ if (m) return parseInt(m[2], 10) > 0;
39
42
  }
40
43
  return false;
41
44
  };
@@ -47,7 +50,7 @@ export default function (pi: ExtensionAPI) {
47
50
 
48
51
  pi.on("tool_call", async (event, ctx) => {
49
52
  const cwd = getCwd(ctx);
50
- if (!isBeadsProject(cwd)) return undefined;
53
+ if (!EventAdapter.isBeadsProject(cwd)) return undefined;
51
54
  const sessionId = getSessionId(ctx);
52
55
 
53
56
  if (EventAdapter.isMutatingFileTool(event)) {
@@ -60,7 +63,7 @@ export default function (pi: ExtensionAPI) {
60
63
  }
61
64
  return {
62
65
  block: true,
63
- reason: `No active issue claim for this session (${sessionId}).\n bd update <id> --claim\n bd kv set "claimed:${sessionId}" "<id>"`,
66
+ reason: `No active claim for session ${sessionId}.\n bd update <id> --claim\n`,
64
67
  };
65
68
  }
66
69
  }
@@ -69,12 +72,15 @@ export default function (pi: ExtensionAPI) {
69
72
  if (isToolCallEventType("bash", event)) {
70
73
  const command = event.input.command;
71
74
  if (command && /\bgit\s+commit\b/.test(command)) {
72
- const claim = await getSessionClaim(sessionId, cwd);
75
+ const claim = await getSessionClaim(sessionId, cwd);
73
76
  if (claim) {
74
- return {
75
- block: true,
76
- reason: `Resolve open claim [${claim}] before committing.`,
77
- };
77
+ const inProgress = await hasInProgressWork(cwd);
78
+ if (inProgress) {
79
+ return {
80
+ block: true,
81
+ reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n git push -u origin <feature-branch>\n gh pr create --fill && gh pr merge --squash\n`,
82
+ };
83
+ }
78
84
  }
79
85
  }
80
86
  }
@@ -108,4 +114,25 @@ export default function (pi: ExtensionAPI) {
108
114
  }
109
115
  return undefined;
110
116
  });
117
+
118
+ // Dual safety net: notify about unclosed claims when session ends
119
+ const notifySessionEnd = async (ctx: any) => {
120
+ const cwd = getCwd(ctx);
121
+ if (!EventAdapter.isBeadsProject(cwd)) return;
122
+ const sessionId = getSessionId(ctx);
123
+ const claim = await getSessionClaim(sessionId, cwd);
124
+ if (claim && ctx.hasUI) {
125
+ ctx.ui.notify(`Beads: Session ending with active claim [${claim}]`, "warning");
126
+ }
127
+ };
128
+
129
+ pi.on("agent_end", async (_event, ctx) => {
130
+ await notifySessionEnd(ctx);
131
+ return undefined;
132
+ });
133
+
134
+ pi.on("session_shutdown", async (_event, ctx) => {
135
+ await notifySessionEnd(ctx);
136
+ return undefined;
137
+ });
111
138
  }
@@ -1,3 +1,6 @@
1
+ import * as fs from "node:fs";
2
+ import * as nodePath from "node:path";
3
+
1
4
  import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
2
5
 
3
6
  export class EventAdapter {
@@ -42,4 +45,20 @@ export class EventAdapter {
42
45
  static formatBlockReason(prefix: string, details: string): string {
43
46
  return `${prefix}: ${details}`;
44
47
  }
48
+ /**
49
+ * Returns true if the given directory is a beads project (has a .beads directory).
50
+ */
51
+ static isBeadsProject(cwd: string): boolean {
52
+ return fs.existsSync(nodePath.join(cwd, ".beads"));
53
+ }
54
+
55
+ /**
56
+ * Parses the summary line from `bd list` output.
57
+ * Returns { open, inProgress } or null if the line is absent.
58
+ */
59
+ static parseBdCounts(output: string): { open: number; inProgress: number } | null {
60
+ const m = output.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
61
+ if (!m) return null;
62
+ return { open: parseInt(m[1], 10), inProgress: parseInt(m[2], 10) };
63
+ }
45
64
  }
@@ -7,9 +7,7 @@
7
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
8
  import { truncateToWidth } from "@mariozechner/pi-tui";
9
9
 
10
- import * as path from "node:path";
11
- import * as fs from "node:fs";
12
- import { SubprocessRunner } from "./core/lib";
10
+ import { SubprocessRunner, EventAdapter } from "./core/lib";
13
11
 
14
12
  export default function (pi: ExtensionAPI) {
15
13
  interface BeadState {
@@ -26,10 +24,19 @@ export default function (pi: ExtensionAPI) {
26
24
  blocked: "●",
27
25
  closed: "✓",
28
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
+
29
36
  const STATUS_BG: Record<string, string> = {
30
- open: "\x1b[48;5;238m",
31
- in_progress: "\x1b[48;5;39m",
32
- blocked: "\x1b[48;5;88m",
37
+ open: CHIP_BG_NEUTRAL,
38
+ in_progress: CHIP_BG_ACTIVE,
39
+ blocked: CHIP_BG_BLOCKED,
33
40
  };
34
41
 
35
42
  let capturedCtx: any = null;
@@ -40,13 +47,12 @@ export default function (pi: ExtensionAPI) {
40
47
  const CACHE_TTL = 5000;
41
48
 
42
49
  const getCwd = () => capturedCtx?.cwd || process.cwd();
43
- const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
44
50
  const getShortId = (id: string) => id.split("-").pop() ?? id;
45
51
 
46
52
  const refreshBeadState = async () => {
47
53
  if (refreshing || Date.now() - beadState.lastFetch < CACHE_TTL) return;
48
54
  const cwd = getCwd();
49
- if (!isBeadsProject(cwd)) return;
55
+ if (!EventAdapter.isBeadsProject(cwd)) return;
50
56
  if (!sessionId) return;
51
57
  refreshing = true;
52
58
  try {
@@ -84,11 +90,11 @@ export default function (pi: ExtensionAPI) {
84
90
  const { claimId, shortId, status, openCount } = beadState;
85
91
  if (claimId && shortId && status) {
86
92
  const icon = STATUS_ICONS[status] ?? "?";
87
- const bg = STATUS_BG[status] ?? "\x1b[48;5;238m";
88
- return `${bg}\x1b[38;5;15m bd:${shortId}${icon} \x1b[0m`;
93
+ const bg = STATUS_BG[status] ?? CHIP_BG_NEUTRAL;
94
+ return chip(`bd:${shortId}${icon}`, bg);
89
95
  }
90
96
  if (openCount > 0) {
91
- return `\x1b[48;5;238m\x1b[38;5;15m bd:${openCount}${STATUS_ICONS.open} \x1b[0m`;
97
+ return chip(`bd:${openCount}${STATUS_ICONS.open}`);
92
98
  }
93
99
  return "";
94
100
  };
@@ -108,7 +114,8 @@ export default function (pi: ExtensionAPI) {
108
114
  render(width: number): string[] {
109
115
  refreshBeadState().catch(() => {});
110
116
 
111
- const brand = "\x1b[1m" + theme.fg("accent", "XTRM") + "\x1b[22m";
117
+ const BOLD = "\x1b[1m", BOLD_OFF = "\x1b[22m";
118
+ const brand = `${BOLD}${theme.fg("accent", "XTRM")}${BOLD_OFF}`;
112
119
 
113
120
  const usage = ctx.getContextUsage();
114
121
  const pct = usage?.percent ?? 0;
@@ -123,7 +130,7 @@ export default function (pi: ExtensionAPI) {
123
130
  const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
124
131
 
125
132
  const modelId = ctx.model?.id || "no-model";
126
- const modelChip = `\x1b[48;5;238m\x1b[38;5;15m ${modelId} \x1b[0m`;
133
+ const modelChip = chip(modelId);
127
134
 
128
135
  const sep = theme.fg("dim", " | ");
129
136
 
@@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI) {
54
54
  if (branch && protectedBranches.includes(branch)) {
55
55
  // A. Mutating File Tools on Main
56
56
  if (EventAdapter.isMutatingFileTool(event)) {
57
- const reason = `On protected branch '${branch}'. Checkout a feature branch first: \`git checkout -b feature/<name>\``;
57
+ 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`;
58
58
  if (ctx.hasUI) {
59
59
  ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
60
60
  }
@@ -97,7 +97,7 @@ export default function (pi: ExtensionAPI) {
97
97
 
98
98
  // Specific blocks
99
99
  if (/\bgit\s+commit\b/.test(cmd)) {
100
- return { block: true, reason: `No commits on '${branch}' — use a feature branch.` };
100
+ return { block: true, reason: `No commits on '${branch}' — use a feature branch.\n git checkout -b feature/<name>\n bd update <id> --claim\n` };
101
101
  }
102
102
 
103
103
  if (/\bgit\s+push\b/.test(cmd)) {
@@ -113,7 +113,7 @@ export default function (pi: ExtensionAPI) {
113
113
  }
114
114
 
115
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>`;
116
+ 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`;
117
117
  if (ctx.hasUI) {
118
118
  ctx.ui.notify("Main-Guard: Command blocked", "error");
119
119
  }
@@ -26,38 +26,6 @@ export default function (pi: ExtensionAPI) {
26
26
  return undefined;
27
27
  });
28
28
 
29
- // 2. Territory Activation
30
- pi.on("tool_call", async (event, ctx) => {
31
- const cwd = getCwd(ctx);
32
- const activatorPath = path.join(cwd, ".claude", "skills", "using-service-skills", "scripts", "skill_activator.py");
33
- if (!fs.existsSync(activatorPath)) return undefined;
34
-
35
- const hookInput = JSON.stringify({
36
- tool_name: event.toolName === "bash" ? "Bash" : event.toolName,
37
- tool_input: event.input,
38
- cwd: cwd
39
- });
40
-
41
- const result = await SubprocessRunner.run("python3", [activatorPath], {
42
- cwd,
43
- input: hookInput,
44
- env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
45
- timeoutMs: 5000
46
- });
47
-
48
- if (result.code === 0 && result.stdout.trim()) {
49
- try {
50
- const parsed = JSON.parse(result.stdout.trim());
51
- const context = parsed.hookSpecificOutput?.additionalContext;
52
- if (context && ctx.hasUI) {
53
- ctx.ui.notify(context, "info");
54
- }
55
- } catch (e) {
56
- logger.error("Failed to parse skill_activator output", e);
57
- }
58
- }
59
- return undefined;
60
- });
61
29
 
62
30
  // 3. Drift Detection
63
31
  pi.on("tool_result", async (event, ctx) => {
@@ -40,7 +40,7 @@ export default function (pi: ExtensionAPI) {
40
40
 
41
41
  for (const p of roadmapPaths) {
42
42
  if (fs.existsSync(p)) {
43
- const content = fs.readFileSync(p, "utf8");
43
+ const content = await fs.promises.readFile(p, "utf8");
44
44
  contextParts.push(`## Project Roadmap & Architecture (${path.relative(cwd, p)})\n\n${content}`);
45
45
  break; // Only load the first one found
46
46
  }
@@ -51,10 +51,10 @@ export default function (pi: ExtensionAPI) {
51
51
  if (fs.existsSync(rulesDir)) {
52
52
  const ruleFiles = findMarkdownFiles(rulesDir);
53
53
  if (ruleFiles.length > 0) {
54
- const rulesContent = ruleFiles.map(f => {
55
- const content = fs.readFileSync(path.join(rulesDir, f), "utf8");
54
+ const rulesContent = (await Promise.all(ruleFiles.map(async f => {
55
+ const content = await fs.promises.readFile(path.join(rulesDir, f), "utf8");
56
56
  return `### Rule: ${f}\n${content}`;
57
- }).join("\n\n");
57
+ }))).join("\n\n");
58
58
  contextParts.push(`## Project Rules\n\n${rulesContent}`);
59
59
  }
60
60
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // beads-claim-sync — PostToolUse hook
3
- // Auto-sets bd kv claim when bd update --claim is detected.
3
+ // Auto-sets kv claim on bd update --claim; auto-clears on bd close.
4
4
 
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { readFileSync, existsSync } from 'node:fs';
@@ -39,20 +39,11 @@ function main() {
39
39
  const input = readInput();
40
40
  if (!input || input.hook_event_name !== 'PostToolUse') process.exit(0);
41
41
  if (!isShellTool(input.tool_name)) process.exit(0);
42
- if (!commandSucceeded(input)) process.exit(0);
43
42
 
44
43
  const cwd = input.cwd || process.cwd();
45
44
  if (!isBeadsProject(cwd)) process.exit(0);
46
45
 
47
46
  const command = input.tool_input?.command || '';
48
- if (!/\bbd\s+update\b/.test(command) || !/--claim\b/.test(command)) {
49
- process.exit(0);
50
- }
51
-
52
- const match = command.match(/\bbd\s+update\s+(\S+)/);
53
- if (!match) process.exit(0);
54
-
55
- const issueId = match[1];
56
47
  const sessionId = input.session_id ?? input.sessionId;
57
48
 
58
49
  if (!sessionId) {
@@ -60,22 +51,48 @@ function main() {
60
51
  process.exit(0);
61
52
  }
62
53
 
63
- const result = spawnSync('bd', ['kv', 'set', `claimed:${sessionId}`, issueId], {
64
- cwd,
65
- stdio: ['pipe', 'pipe', 'pipe'],
66
- timeout: 5000,
67
- });
54
+ // Auto-claim: bd update <id> --claim (fire regardless of exit code — bd returns 1 for "already in_progress")
55
+ if (/\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
56
+ const match = command.match(/\bbd\s+update\s+(\S+)/);
57
+ if (match) {
58
+ const issueId = match[1];
59
+ const result = spawnSync('bd', ['kv', 'set', `claimed:${sessionId}`, issueId], {
60
+ cwd,
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ timeout: 5000,
63
+ });
64
+
65
+ if (result.status !== 0) {
66
+ const err = (result.stderr || result.stdout || '').toString().trim();
67
+ if (err) process.stderr.write(`Beads claim sync warning: ${err}\n`);
68
+ process.exit(0);
69
+ }
70
+
71
+ process.stdout.write(JSON.stringify({
72
+ additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.`,
73
+ }));
74
+ process.stdout.write('\n');
75
+ process.exit(0);
76
+ }
77
+ }
68
78
 
69
- if (result.status !== 0) {
70
- const err = (result.stderr || result.stdout || '').toString().trim();
71
- if (err) process.stderr.write(`Beads claim sync warning: ${err}\n`);
79
+ // Auto-clear: bd close <id> — remove the kv claim so commit gate unblocks
80
+ if (/\bbd\s+close\b/.test(command) && commandSucceeded(input)) {
81
+ const result = spawnSync('bd', ['kv', 'clear', `claimed:${sessionId}`], {
82
+ cwd,
83
+ stdio: ['pipe', 'pipe', 'pipe'],
84
+ timeout: 5000,
85
+ });
86
+
87
+ if (result.status === 0) {
88
+ process.stdout.write(JSON.stringify({
89
+ additionalContext: `\n🔓 **Beads**: Session claim cleared. Ready to commit.`,
90
+ }));
91
+ process.stdout.write('\n');
92
+ }
72
93
  process.exit(0);
73
94
  }
74
95
 
75
- process.stdout.write(JSON.stringify({
76
- additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.`,
77
- }));
78
- process.stdout.write('\n');
79
96
  process.exit(0);
80
97
  }
81
98
 
@@ -32,7 +32,9 @@ withSafeBdContext(() => {
32
32
 
33
33
  if (decision.allow) process.exit(0);
34
34
 
35
- // Block with message
36
- process.stderr.write(commitBlockMessage(decision.summary, decision.claimed));
37
- process.exit(2);
35
+ // Block with structured decision
36
+ const reason = commitBlockMessage(decision.summary, decision.claimed);
37
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
38
+ process.stdout.write('\n');
39
+ process.exit(0);
38
40
  });
@@ -29,10 +29,10 @@ withSafeBdContext(() => {
29
29
  if (decision.allow) process.exit(0);
30
30
 
31
31
  // Block with appropriate message
32
- if (decision.reason === 'no_claim_with_work') {
33
- process.stderr.write(editBlockMessage(decision.sessionId));
34
- } else {
35
- process.stderr.write(editBlockFallbackMessage());
36
- }
37
- process.exit(2);
32
+ const reason = decision.reason === 'no_claim_with_work'
33
+ ? editBlockMessage(decision.sessionId)
34
+ : editBlockFallbackMessage();
35
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
36
+ process.stdout.write('\n');
37
+ process.exit(0);
38
38
  });
@@ -5,32 +5,36 @@
5
5
  // All user-facing strings live here. Edit this file to change messaging.
6
6
  // Policy logic lives in beads-gate-core.mjs.
7
7
 
8
- // ── Shared workflow steps ────────────────────────────────────────────────────
8
+ // ── Shared workflow steps ────────────────────────────────────────────
9
9
 
10
10
  export const WORKFLOW_STEPS =
11
11
  ' 1. git checkout -b feature/<name>\n' +
12
- ' 2. bd create + bd update in_progress\n' +
12
+ ' 2. bd update <id> --claim\n' +
13
13
  ' 3. Edit / write code\n' +
14
- ' 4. bd close <id> && git add && git commit\n' +
15
- ' 5. git push -u origin feature/<name>\n' +
16
- ' 6. gh pr create --fill && gh pr merge --squash\n' +
17
- ' 7. git checkout master && git reset --hard origin/master\n';;
14
+ ' 4. bd close <id>\n' +
15
+ ' 5. git add -p && git commit -m "<message>"\n' +
16
+ ' 6. git push -u origin feature/<name>\n' +
17
+ ' 7. gh pr create --fill && gh pr merge --squash\n' +
18
+ ' 8. git checkout master && git reset --hard origin/master\n';
18
19
 
19
20
  export const SESSION_CLOSE_PROTOCOL =
20
- ' bd close <id> → commit → push → gh pr create --fill → gh pr merge --squash\n';;
21
+ ' bd close <id>\n' +
22
+ ' git add -p && git commit -m "<message>"\n' +
23
+ ' git push -u origin <feature-branch>\n' +
24
+ ' gh pr create --fill && gh pr merge --squash\n';
21
25
 
22
26
  export const COMMIT_NEXT_STEPS =
23
- ' bd close <id> && git add && git commit\n' +
27
+ ' bd close <id>\n' +
28
+ ' git add -p && git commit -m "<message>"\n' +
24
29
  ' git push -u origin <feature-branch>\n' +
25
- ' gh pr create --fill && gh pr merge --squash\n';;
30
+ ' gh pr create --fill && gh pr merge --squash\n';
26
31
 
27
- // ── Edit gate messages ───────────────────────────────────────────────────────
32
+ // ── Edit gate messages ───────────────────────────────────────────
28
33
 
29
34
  export function editBlockMessage(sessionId) {
30
35
  return (
31
36
  '🚫 No active claim — claim an issue first.\n' +
32
- ' bd update <id> --status=in_progress\n' +
33
- ` bd kv set "claimed:${sessionId}" "<id>"\n`
37
+ ' bd update <id> --claim\n'
34
38
  );
35
39
  }
36
40
 
@@ -38,11 +42,11 @@ export function editBlockFallbackMessage() {
38
42
  return (
39
43
  '🚫 No active issue — create one before editing.\n' +
40
44
  ' bd create --title="<task>" --type=task --priority=2\n' +
41
- ' bd update <id> --status=in_progress\n'
45
+ ' bd update <id> --claim\n'
42
46
  );
43
47
  }
44
48
 
45
- // ── Commit gate messages ─────────────────────────────────────────────────────
49
+ // ── Commit gate messages ─────────────────────────────────────────
46
50
 
47
51
  export function commitBlockMessage(summary, claimed) {
48
52
  const issueSummary = summary ?? ` Claimed: ${claimed}`;
@@ -53,18 +57,18 @@ export function commitBlockMessage(summary, claimed) {
53
57
  );
54
58
  }
55
59
 
56
- // ── Stop gate messages ───────────────────────────────────────────────────────
60
+ // ── Stop gate messages ───────────────────────────────────────────
57
61
 
58
62
  export function stopBlockMessage(summary, claimed) {
59
63
  const issueSummary = summary ?? ` Claimed: ${claimed}`;
60
64
  return (
61
65
  '🚫 Unresolved issues — close before stopping.\n\n' +
62
66
  `${issueSummary}\n\n` +
63
- SESSION_CLOSE_PROTOCOL
67
+ 'Next steps:\n' + SESSION_CLOSE_PROTOCOL
64
68
  );
65
69
  }
66
70
 
67
- // ── Memory gate messages ─────────────────────────────────────────────────────
71
+ // ── Memory gate messages ─────────────────────────────────────────
68
72
 
69
73
  export function memoryPromptMessage() {
70
74
  return (
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // branch-state.mjs — UserPromptSubmit hook
3
+ // Re-injects current git branch and active beads claim at each prompt.
4
+ // Keeps the agent oriented after /compact or long sessions.
5
+ // Output: { hookSpecificOutput: { additionalSystemPrompt } }
6
+
7
+ import { execSync } from 'node:child_process';
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ function readInput() {
12
+ try { return JSON.parse(readFileSync(0, 'utf-8')); } catch { return null; }
13
+ }
14
+
15
+ function getBranch(cwd) {
16
+ try {
17
+ return execSync('git branch --show-current', {
18
+ encoding: 'utf8', cwd,
19
+ stdio: ['pipe', 'pipe', 'pipe'], timeout: 3000,
20
+ }).trim() || null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ function getSessionClaim(sessionId, cwd) {
25
+ try {
26
+ const out = execSync(`bd kv get "claimed:${sessionId}"`, {
27
+ encoding: 'utf8', cwd,
28
+ stdio: ['pipe', 'pipe', 'pipe'], timeout: 3000,
29
+ }).trim();
30
+ return out || null;
31
+ } catch { return null; }
32
+ }
33
+
34
+ const input = readInput();
35
+ if (!input) process.exit(0);
36
+
37
+ const cwd = input.cwd || process.cwd();
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;
42
+
43
+ if (!branch && !claim) process.exit(0);
44
+
45
+ const context = `[Context: branch=${branch ?? 'unknown'}${claim ? ', claim=' + claim : ''}]`;
46
+
47
+ process.stdout.write(JSON.stringify({
48
+ hookSpecificOutput: { additionalSystemPrompt: context },
49
+ }));
50
+ process.stdout.write('\n');
51
+ process.exit(0);
@@ -59,11 +59,13 @@ const explicitlyProtectedTarget = protectedBranches
59
59
  .some((b) => lastToken === b || lastToken.endsWith(`:${b}`));
60
60
  if (explicitlyProtectedTarget) process.exit(0);
61
61
 
62
- process.stdout.write(
63
- `✅ Pushed '${branch}'. Next workflow steps:\n\n` +
64
- ' 1. gh pr create --fill\n' +
65
- ' 2. gh pr merge --squash\n' +
66
- ' 3. git checkout main && git reset --hard origin/main\n\n' +
67
- 'Before/after merge, ensure beads state is updated (e.g. bd close <id>).\n',
68
- );
62
+ process.stdout.write(JSON.stringify({
63
+ additionalContext:
64
+ `✅ Pushed '${branch}'. Next steps:\n` +
65
+ ' 1. gh pr create --fill\n' +
66
+ ' 2. gh pr merge --squash\n' +
67
+ ' 3. git checkout main && git reset --hard origin/main\n' +
68
+ 'Ensure beads state is updated (e.g. bd close <id>).',
69
+ }));
70
+ process.stdout.write('\n');
69
71
  process.exit(0);
@@ -37,10 +37,11 @@ const hookEventName = input.hook_event_name ?? 'PreToolUse';
37
37
 
38
38
  function deny(reason) {
39
39
  process.stdout.write(JSON.stringify({
40
- systemMessage: reason,
40
+ decision: 'block',
41
+ reason,
41
42
  }));
42
43
  process.stdout.write('\n');
43
- process.exit(2);
44
+ process.exit(0);
44
45
  }
45
46
 
46
47
  const WRITE_TOOLS = new Set([
@@ -55,8 +56,9 @@ const WRITE_TOOLS = new Set([
55
56
  ]);
56
57
 
57
58
  if (WRITE_TOOLS.has(tool)) {
58
- deny(`⛔ On '${branch}' — checkout a feature branch first.\n`
59
- + ' git checkout -b feature/<name>\n');
59
+ deny(`⛔ On '${branch}' — start on a feature branch and claim an issue.\n`
60
+ + ' git checkout -b feature/<name>\n'
61
+ + ' bd update <id> --claim\n');
60
62
  }
61
63
 
62
64
  const WORKFLOW =