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.
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // beads-claim-sync — PostToolUse hook
3
- // Auto-sets kv claim on bd update --claim; auto-clears on bd close.
4
- // Also bootstraps worktree-first session state for xtrm finish workflow.
3
+ // bd update --claim set kv claim
4
+ // bd close → auto-commit staged changes, set closed-this-session kv for memory gate
5
5
 
6
6
  import { spawnSync } from 'node:child_process';
7
- import { readFileSync, existsSync, mkdirSync } from 'node:fs';
7
+ import { readFileSync, existsSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { writeSessionState } from './session-state.mjs';
9
+ import { resolveSessionId } from './beads-gate-utils.mjs';
10
10
 
11
11
  function readInput() {
12
12
  try {
@@ -46,103 +46,56 @@ function runGit(args, cwd, timeout = 8000) {
46
46
  });
47
47
  }
48
48
 
49
- function getRepoRoot(cwd) {
50
- const result = runGit(['rev-parse', '--show-toplevel'], cwd);
51
- if (result.status !== 0) return null;
52
- return result.stdout.trim();
49
+ function runBd(args, cwd, timeout = 5000) {
50
+ return spawnSync('bd', args, {
51
+ cwd,
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ encoding: 'utf8',
54
+ timeout,
55
+ });
53
56
  }
54
57
 
55
- function inLinkedWorktree(cwd) {
56
- const gitDir = runGit(['rev-parse', '--git-dir'], cwd);
57
- const gitCommonDir = runGit(['rev-parse', '--git-common-dir'], cwd);
58
- if (gitDir.status !== 0 || gitCommonDir.status !== 0) return false;
59
- return gitDir.stdout.trim() !== gitCommonDir.stdout.trim();
58
+ function hasGitChanges(cwd) {
59
+ const result = runGit(['status', '--porcelain'], cwd);
60
+ if (result.status !== 0) return false;
61
+ return result.stdout.trim().length > 0;
60
62
  }
61
63
 
62
- function ensureWorktreeForClaim(cwd, issueId) {
63
- const repoRoot = getRepoRoot(cwd);
64
- if (!repoRoot) return { created: false, reason: 'not-git' };
64
+ function getCloseReason(cwd, issueId, command) {
65
+ // 1. Parse --reason "..." from the command itself (fastest, no extra call)
66
+ const reasonMatch = command.match(/--reason[=\s]+["']([^"']+)["']/);
67
+ if (reasonMatch) return reasonMatch[1].trim();
65
68
 
66
- if (inLinkedWorktree(cwd)) {
67
- return { created: false, reason: 'already-worktree', repoRoot };
69
+ // 2. Fall back to bd show <id> --json
70
+ const show = runBd(['show', issueId, '--json'], cwd);
71
+ if (show.status === 0 && show.stdout) {
72
+ try {
73
+ const parsed = JSON.parse(show.stdout);
74
+ const reason = parsed?.[0]?.close_reason;
75
+ if (typeof reason === 'string' && reason.trim().length > 0) return reason.trim();
76
+ } catch { /* fall through */ }
68
77
  }
69
78
 
70
- const overstoryDir = join(repoRoot, '.overstory');
71
- const worktreesBase = existsSync(overstoryDir)
72
- ? join(overstoryDir, 'worktrees')
73
- : join(repoRoot, '.worktrees');
74
-
75
- mkdirSync(worktreesBase, { recursive: true });
76
-
77
- const branch = `feature/${issueId}`;
78
- const worktreePath = join(worktreesBase, issueId);
79
+ return `Close ${issueId}`;
80
+ }
79
81
 
80
- if (existsSync(worktreePath)) {
81
- // Already created previously — rewrite state file for continuity.
82
- try {
83
- const stateFile = writeSessionState({
84
- issueId,
85
- branch,
86
- worktreePath,
87
- prNumber: null,
88
- prUrl: null,
89
- phase: 'claimed',
90
- conflictFiles: [],
91
- }, { cwd: repoRoot });
92
- return { created: false, reason: 'exists', repoRoot, branch, worktreePath, stateFile };
93
- } catch {
94
- return { created: false, reason: 'exists', repoRoot, branch, worktreePath };
95
- }
82
+ function autoCommit(cwd, issueId, command) {
83
+ if (!hasGitChanges(cwd)) {
84
+ return { ok: true, message: 'No changes detected — auto-commit skipped.' };
96
85
  }
97
86
 
98
- const branchExists = runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot).status === 0;
99
- const addArgs = branchExists
100
- ? ['worktree', 'add', worktreePath, branch]
101
- : ['worktree', 'add', worktreePath, '-b', branch];
102
-
103
- const addResult = runGit(addArgs, repoRoot, 20000);
104
- if (addResult.status !== 0) {
105
- return {
106
- created: false,
107
- reason: 'create-failed',
108
- repoRoot,
109
- branch,
110
- worktreePath,
111
- error: (addResult.stderr || addResult.stdout || '').trim(),
112
- };
87
+ const reason = getCloseReason(cwd, issueId, command);
88
+ const commitMessage = `${reason} (${issueId})`;
89
+ const result = runGit(['commit', '-am', commitMessage], cwd, 15000);
90
+ if (result.status !== 0) {
91
+ const err = (result.stderr || result.stdout || '').trim();
92
+ return { ok: false, message: `Auto-commit failed: ${err || 'unknown error'}` };
113
93
  }
114
94
 
115
- try {
116
- const stateFile = writeSessionState({
117
- issueId,
118
- branch,
119
- worktreePath,
120
- prNumber: null,
121
- prUrl: null,
122
- phase: 'claimed',
123
- conflictFiles: [],
124
- }, { cwd: repoRoot });
125
-
126
- return {
127
- created: true,
128
- reason: 'created',
129
- repoRoot,
130
- branch,
131
- worktreePath,
132
- stateFile,
133
- };
134
- } catch (err) {
135
- return {
136
- created: true,
137
- reason: 'created-state-write-failed',
138
- repoRoot,
139
- branch,
140
- worktreePath,
141
- error: String(err?.message || err),
142
- };
143
- }
95
+ return { ok: true, message: `Auto-committed: \`${commitMessage}\`` };
144
96
  }
145
97
 
98
+
146
99
  function main() {
147
100
  const input = readInput();
148
101
  if (!input || input.hook_event_name !== 'PostToolUse') process.exit(0);
@@ -152,12 +105,7 @@ function main() {
152
105
  if (!isBeadsProject(cwd)) process.exit(0);
153
106
 
154
107
  const command = input.tool_input?.command || '';
155
- const sessionId = input.session_id ?? input.sessionId;
156
-
157
- if (!sessionId) {
158
- process.stderr.write('Beads claim sync: no session_id in hook input\n');
159
- process.exit(0);
160
- }
108
+ const sessionId = resolveSessionId(input);
161
109
 
162
110
  // Auto-claim: bd update <id> --claim (fire regardless of exit code — bd returns 1 for "already in_progress")
163
111
  if (/\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
@@ -176,41 +124,39 @@ function main() {
176
124
  process.exit(0);
177
125
  }
178
126
 
179
- const wt = ensureWorktreeForClaim(cwd, issueId);
180
- const details = [];
181
- if (wt.created) {
182
- details.push(`🧭 **Session Flow**: Worktree created: \`${wt.worktreePath}\` Branch: \`${wt.branch}\``);
183
- } else if (wt.reason === 'exists') {
184
- details.push(`🧭 **Session Flow**: Worktree already exists: \`${wt.worktreePath}\` Branch: \`${wt.branch}\``);
185
- } else if (wt.reason === 'already-worktree') {
186
- details.push('🧭 **Session Flow**: Already in a linked worktree — skipping nested worktree creation.');
187
- } else if (wt.reason === 'create-failed') {
188
- const err = wt.error ? `\nWarning: ${wt.error}` : '';
189
- details.push(`⚠️ **Session Flow**: Worktree creation failed for \`${issueId}\`. Continuing without blocking claim.${err}`);
190
- }
191
-
192
127
  process.stdout.write(JSON.stringify({
193
- additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.${details.length ? `\n${details.join('\n')}` : ''}`,
128
+ additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.`,
194
129
  }));
195
130
  process.stdout.write('\n');
196
131
  process.exit(0);
197
132
  }
198
133
  }
199
134
 
200
- // Auto-clear: bd close <id> remove the kv claim so commit gate unblocks
135
+ // On bd close: auto-commit staged changes, then mark closed-this-session for memory gate
201
136
  if (/\bbd\s+close\b/.test(command) && commandSucceeded(input)) {
202
- const result = spawnSync('bd', ['kv', 'clear', `claimed:${sessionId}`], {
203
- cwd,
204
- stdio: ['pipe', 'pipe', 'pipe'],
205
- timeout: 5000,
206
- });
137
+ const match = command.match(/\bbd\s+close\s+(\S+)/);
138
+ const closedIssueId = match?.[1];
207
139
 
208
- if (result.status === 0) {
209
- process.stdout.write(JSON.stringify({
210
- additionalContext: `\n🔓 **Beads**: Session claim cleared. Ready to commit.`,
211
- }));
212
- process.stdout.write('\n');
140
+ // Auto-commit before marking the gate (no-op if clean)
141
+ const commit = closedIssueId ? autoCommit(cwd, closedIssueId, command) : null;
142
+
143
+ // Mark this issue as closed this session (memory gate reads this)
144
+ if (closedIssueId) {
145
+ spawnSync('bd', ['kv', 'set', `closed-this-session:${sessionId}`, closedIssueId], {
146
+ cwd,
147
+ stdio: ['pipe', 'pipe', 'pipe'],
148
+ timeout: 5000,
149
+ });
213
150
  }
151
+
152
+ const commitLine = commit
153
+ ? `\n${commit.ok ? '✅' : '⚠️'} **Session Flow**: ${commit.message}`
154
+ : '';
155
+
156
+ process.stdout.write(JSON.stringify({
157
+ additionalContext: `\n🔓 **Beads**: Issue closed.${commitLine}\nEvaluate insights, then acknowledge:\n \`bd remember "<insight>"\` (or note "nothing")\n \`touch .beads/.memory-gate-done\``,
158
+ }));
159
+ process.stdout.write('\n');
214
160
  process.exit(0);
215
161
  }
216
162
 
@@ -13,20 +13,35 @@ import {
13
13
  resolveClaimAndWorkState,
14
14
  decideCommitGate,
15
15
  } from './beads-gate-core.mjs';
16
- import { withSafeBdContext } from './beads-gate-utils.mjs';
17
- import { commitBlockMessage } from './beads-gate-messages.mjs';
16
+ import { withSafeBdContext, isMemoryGatePending, isMemoryAckCommand } from './beads-gate-utils.mjs';
17
+ import { commitBlockMessage, memoryGatePendingMessage } from './beads-gate-messages.mjs';
18
18
 
19
19
  const input = readHookInput();
20
20
  if (!input) process.exit(0);
21
21
 
22
- // Only intercept git commit commands
23
22
  if ((input.tool_name ?? '') !== 'Bash') process.exit(0);
24
- if (!/\bgit\s+commit\b/.test(input.tool_input?.command ?? '')) process.exit(0);
23
+
24
+ const command = input.tool_input?.command ?? '';
25
+ // Strip quoted strings to avoid matching patterns inside --reason "..." or similar args
26
+ const commandUnquoted = command.replace(/'[^']*'|"[^"]*"/g, '');
25
27
 
26
28
  withSafeBdContext(() => {
27
29
  const ctx = resolveSessionContext(input);
28
30
  if (!ctx || !ctx.isBeadsProject) process.exit(0);
29
31
 
32
+ // Memory gate: block all Bash except acknowledgment commands while gate pending
33
+ if (ctx.sessionId && isMemoryGatePending(ctx.sessionId, ctx.cwd)) {
34
+ if (!isMemoryAckCommand(commandUnquoted)) {
35
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: memoryGatePendingMessage() }));
36
+ process.stdout.write('\n');
37
+ process.exit(0);
38
+ }
39
+ process.exit(0); // memory-ack command — allow
40
+ }
41
+
42
+ // Only intercept git commit for the claim-gate check
43
+ if (!/\bgit\s+commit\b/.test(commandUnquoted)) process.exit(0);
44
+
30
45
  const state = resolveClaimAndWorkState(ctx);
31
46
  const decision = decideCommitGate(ctx, state);
32
47
 
@@ -7,7 +7,6 @@
7
7
  import { execSync } from 'node:child_process';
8
8
  import { readFileSync, existsSync, unlinkSync } from 'node:fs';
9
9
  import path from 'node:path';
10
- import { writeSessionState } from './session-state.mjs';
11
10
 
12
11
  let input;
13
12
  try {
@@ -22,14 +21,12 @@ const lastActivePath = path.join(cwd, '.beads', '.last_active');
22
21
  if (!existsSync(lastActivePath)) process.exit(0);
23
22
 
24
23
  let ids = [];
25
- let sessionState = null;
26
24
 
27
25
  try {
28
26
  const raw = readFileSync(lastActivePath, 'utf8').trim();
29
27
  if (raw.startsWith('{')) {
30
28
  const parsed = JSON.parse(raw);
31
29
  ids = Array.isArray(parsed.ids) ? parsed.ids.filter(Boolean) : [];
32
- sessionState = parsed.sessionState ?? null;
33
30
  } else {
34
31
  // Backward compatibility: legacy newline format
35
32
  ids = raw.split('\n').filter(Boolean);
@@ -56,27 +53,8 @@ for (const id of ids) {
56
53
  }
57
54
  }
58
55
 
59
- let restoredSession = false;
60
- if (sessionState && typeof sessionState === 'object') {
61
- try {
62
- writeSessionState(sessionState, { cwd });
63
- restoredSession = true;
64
- } catch {
65
- // fail open
66
- }
67
- }
68
-
69
- if (restored > 0 || restoredSession) {
70
- const lines = [];
71
- if (restored > 0) {
72
- lines.push(`Restored ${restored} in_progress issue${restored === 1 ? '' : 's'} from last session before compaction.`);
73
- }
74
-
75
- if (restoredSession && (sessionState.phase === 'waiting-merge' || sessionState.phase === 'pending-cleanup')) {
76
- const pr = sessionState.prNumber != null ? `#${sessionState.prNumber}` : '(pending PR)';
77
- const prUrl = sessionState.prUrl ? ` ${sessionState.prUrl}` : '';
78
- lines.push(`RESUME: Run xtrm finish — PR ${pr}${prUrl} waiting for merge. Worktree: ${sessionState.worktreePath}`);
79
- }
56
+ if (restored > 0) {
57
+ const lines = [`Restored ${restored} in_progress issue${restored === 1 ? '' : 's'} from last session before compaction.`];
80
58
 
81
59
  process.stdout.write(
82
60
  JSON.stringify({
@@ -7,7 +7,6 @@
7
7
  import { execSync } from 'node:child_process';
8
8
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
- import { readSessionState } from './session-state.mjs';
11
10
 
12
11
  let input;
13
12
  try {
@@ -40,38 +39,13 @@ for (const line of output.split('\n')) {
40
39
  if (match) ids.push(match[1]);
41
40
  }
42
41
 
43
- const sessionState = readSessionState(cwd);
44
42
  const bundle = {
45
43
  ids,
46
- sessionState: sessionState ? {
47
- issueId: sessionState.issueId,
48
- branch: sessionState.branch,
49
- worktreePath: sessionState.worktreePath,
50
- prNumber: sessionState.prNumber,
51
- prUrl: sessionState.prUrl,
52
- phase: sessionState.phase,
53
- conflictFiles: Array.isArray(sessionState.conflictFiles) ? sessionState.conflictFiles : [],
54
- startedAt: sessionState.startedAt,
55
- lastChecked: sessionState.lastChecked,
56
- } : null,
57
44
  savedAt: new Date().toISOString(),
58
45
  };
59
46
 
60
- if (bundle.ids.length === 0 && !bundle.sessionState) process.exit(0);
47
+ if (bundle.ids.length === 0) process.exit(0);
61
48
 
62
49
  writeFileSync(path.join(beadsDir, '.last_active'), JSON.stringify(bundle, null, 2) + '\n', 'utf8');
63
50
 
64
- if (bundle.sessionState?.phase === 'waiting-merge') {
65
- const pr = bundle.sessionState.prNumber != null ? `#${bundle.sessionState.prNumber}` : '(pending PR)';
66
- process.stdout.write(
67
- JSON.stringify({
68
- hookSpecificOutput: {
69
- hookEventName: 'PreCompact',
70
- additionalSystemPrompt:
71
- `PENDING: xtrm finish waiting for PR ${pr} to merge. Re-run xtrm finish to resume.`,
72
- },
73
- }) + '\n',
74
- );
75
- }
76
-
77
51
  process.exit(0);
@@ -13,8 +13,8 @@ import {
13
13
  resolveClaimAndWorkState,
14
14
  decideEditGate,
15
15
  } from './beads-gate-core.mjs';
16
- import { withSafeBdContext } from './beads-gate-utils.mjs';
17
- import { editBlockMessage, editBlockFallbackMessage } from './beads-gate-messages.mjs';
16
+ import { withSafeBdContext, isMemoryGatePending } from './beads-gate-utils.mjs';
17
+ import { editBlockMessage, editBlockFallbackMessage, memoryGatePendingMessage } from './beads-gate-messages.mjs';
18
18
 
19
19
  const input = readHookInput();
20
20
  if (!input) process.exit(0);
@@ -23,6 +23,13 @@ withSafeBdContext(() => {
23
23
  const ctx = resolveSessionContext(input);
24
24
  if (!ctx || !ctx.isBeadsProject) process.exit(0);
25
25
 
26
+ // Memory gate takes priority: block edits while pending acknowledgment
27
+ if (ctx.sessionId && isMemoryGatePending(ctx.sessionId, ctx.cwd)) {
28
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: memoryGatePendingMessage() }));
29
+ process.stdout.write('\n');
30
+ process.exit(0);
31
+ }
32
+
26
33
  const state = resolveClaimAndWorkState(ctx);
27
34
  const decision = decideEditGate(ctx, state);
28
35
 
@@ -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.session_id ?? null,
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
- // Has claim but no in_progress issues → allow (stale/already-closed claim)
158
- if (!state.inProgress || state.inProgress.count === 0) {
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
- // Has claim + in_progress issues → block (need to close first)
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.inProgress.summary,
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
- // Has claim but no in_progress issues → allow (stale claim)
200
- if (!state.inProgress || state.inProgress.count === 0) {
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
- // Has claim + in_progress issues → block
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.inProgress.summary,
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
- export function stopBlockWaitingMergeMessage(state) {
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 stopWarnActiveWorktreeMessage(state) {
73
+ export function memoryGatePendingMessage() {
92
74
  return (
93
- `⚠ Session has an active worktree at ${state.worktreePath}. Consider running: xtrm finish\n`
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
  }