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.
@@ -3,6 +3,10 @@
3
3
  * Extracted for testability.
4
4
  */
5
5
 
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+
6
10
  // Destructive commands blocked in plan mode
7
11
  const DESTRUCTIVE_PATTERNS = [
8
12
  /\brm\b/i,
@@ -134,7 +138,8 @@ export function extractTodoItems(message: string): TodoItem[] {
134
138
  const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
135
139
  const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
136
140
 
137
- for (const match of planSection.matchAll(numberedPattern)) {
141
+ const matches = Array.from(planSection.matchAll(numberedPattern));
142
+ for (const match of matches) {
138
143
  const text = match[2]
139
144
  .trim()
140
145
  .replace(/\*{1,2}$/, "")
@@ -151,7 +156,8 @@ export function extractTodoItems(message: string): TodoItem[] {
151
156
 
152
157
  export function extractDoneSteps(message: string): number[] {
153
158
  const steps: number[] = [];
154
- for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
159
+ const matches = Array.from(message.matchAll(/\[DONE:(\d+)\]/gi));
160
+ for (const match of matches) {
155
161
  const step = Number(match[1]);
156
162
  if (Number.isFinite(step)) steps.push(step);
157
163
  }
@@ -166,3 +172,153 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
166
172
  }
167
173
  return doneSteps.length;
168
174
  }
175
+
176
+ // =============================================================================
177
+ // bd (beads) Integration Functions
178
+ // =============================================================================
179
+
180
+ /**
181
+ * Extract short ID from full bd issue ID.
182
+ * Example: "jaggers-agent-tools-xr9b.1" → "xr9b.1"
183
+ */
184
+ export function getShortId(fullId: string): string {
185
+ const parts = fullId.split("-");
186
+ // Last part is the ID (e.g., "xr9b.1")
187
+ return parts[parts.length - 1];
188
+ }
189
+
190
+ /**
191
+ * Check if a directory is a beads project (has .beads directory).
192
+ */
193
+ export function isBeadsProject(cwd: string): boolean {
194
+ return existsSync(join(cwd, ".beads"));
195
+ }
196
+
197
+ /**
198
+ * Derive epic title from user prompt or conversation messages.
199
+ */
200
+ export function deriveEpicTitle(messages: Array<{ role: string; content?: unknown }>): string {
201
+ // Find the last user message
202
+ for (let i = messages.length - 1; i >= 0; i--) {
203
+ const msg = messages[i];
204
+ if (msg.role === "user") {
205
+ const content = msg.content;
206
+ if (typeof content === "string") {
207
+ // Extract first sentence or first 50 chars
208
+ const firstSentence = content.split(/[.!?\n]/)[0].trim();
209
+ if (firstSentence.length > 10 && firstSentence.length < 80) {
210
+ return firstSentence;
211
+ }
212
+ if (firstSentence.length >= 80) {
213
+ return `${firstSentence.slice(0, 77)}...`;
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return "Plan execution";
219
+ }
220
+
221
+ /**
222
+ * Run a bd command and return the result.
223
+ */
224
+ function runBd(args: string[], cwd: string): { stdout: string; stderr: string; status: number } {
225
+ const result = spawnSync("bd", args, {
226
+ cwd,
227
+ encoding: "utf8",
228
+ timeout: 30000,
229
+ });
230
+ return {
231
+ stdout: result.stdout || "",
232
+ stderr: result.stderr || "",
233
+ status: result.status ?? 1,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Create an epic in bd.
239
+ */
240
+ export function bdCreateEpic(title: string, cwd: string): { id: string; title: string } | null {
241
+ const result = runBd(["create", title, "-t", "epic", "-p", "1", "--json"], cwd);
242
+ if (result.status === 0) {
243
+ try {
244
+ const data = JSON.parse(result.stdout);
245
+ if (Array.isArray(data) && data[0]) {
246
+ return { id: data[0].id, title: data[0].title };
247
+ }
248
+ } catch {
249
+ // Parse the ID from stdout if JSON parse fails
250
+ const match = result.stdout.match(/Created issue:\s*(\S+)/);
251
+ if (match) {
252
+ return { id: match[1], title };
253
+ }
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+
259
+ /**
260
+ * Create a task issue in bd under an epic.
261
+ */
262
+ export function bdCreateIssue(
263
+ title: string,
264
+ description: string,
265
+ parentId: string,
266
+ cwd: string,
267
+ ): { id: string; title: string } | null {
268
+ const result = runBd(
269
+ ["create", title, "-t", "task", "-p", "1", "--parent", parentId, "-d", description, "--json"],
270
+ cwd,
271
+ );
272
+ if (result.status === 0) {
273
+ try {
274
+ const data = JSON.parse(result.stdout);
275
+ if (Array.isArray(data) && data[0]) {
276
+ return { id: data[0].id, title: data[0].title };
277
+ }
278
+ } catch {
279
+ const match = result.stdout.match(/Created issue:\s*(\S+)/);
280
+ if (match) {
281
+ return { id: match[1], title };
282
+ }
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Claim an issue in bd.
290
+ */
291
+ export function bdClaim(issueId: string, cwd: string): boolean {
292
+ const result = runBd(["update", issueId, "--claim"], cwd);
293
+ return result.status === 0;
294
+ }
295
+
296
+ /**
297
+ * Result of creating plan issues.
298
+ */
299
+ export interface PlanIssuesResult {
300
+ epic: { id: string; title: string };
301
+ issues: Array<{ id: string; title: string }>;
302
+ }
303
+
304
+ /**
305
+ * Create an epic and issues from todo items.
306
+ */
307
+ export function createPlanIssues(
308
+ epicTitle: string,
309
+ todos: TodoItem[],
310
+ cwd: string,
311
+ ): PlanIssuesResult | null {
312
+ const epic = bdCreateEpic(epicTitle, cwd);
313
+ if (!epic) return null;
314
+
315
+ const issues: Array<{ id: string; title: string }> = [];
316
+ for (const todo of todos) {
317
+ const issue = bdCreateIssue(todo.text, `Step ${todo.step} of plan: ${epicTitle}`, epic.id, cwd);
318
+ if (issue) {
319
+ issues.push(issue);
320
+ }
321
+ }
322
+
323
+ return { epic, issues };
324
+ }
@@ -84,7 +84,12 @@ export default function (pi: ExtensionAPI) {
84
84
 
85
85
  const ensured = await ensureWorktreeSessionState(cwd, issueId);
86
86
  if (ensured.ok) {
87
- const text = `\n\n🧭 Session Flow: ${ensured.message}`;
87
+ const state = readSessionState(cwd);
88
+ const worktreePath = state?.worktreePath;
89
+ const nextStep = worktreePath
90
+ ? `\nNext: cd ${worktreePath} && pi (sandboxed session)`
91
+ : "";
92
+ const text = `\n\n🧭 Session Flow: ${ensured.message}${nextStep}`;
88
93
  return { content: [...event.content, { type: "text", text }] };
89
94
  }
90
95
  return undefined;
@@ -99,18 +104,27 @@ export default function (pi: ExtensionAPI) {
99
104
  if (state.phase === "waiting-merge" || state.phase === "pending-cleanup") {
100
105
  const pr = state.prNumber != null ? `#${state.prNumber}` : "(pending PR)";
101
106
  const url = state.prUrl ? ` ${state.prUrl}` : "";
102
- pi.sendUserMessage(`🚫 PR ${pr}${url} not yet merged. Run: xtrm finish`);
107
+ pi.sendUserMessage(
108
+ `⚠ PR ${pr}${url} is still pending. xtrm finish is deprecated for Pi workflow. ` +
109
+ "Use xtpi publish (when available) and external merge/cleanup steps.",
110
+ );
103
111
  return undefined;
104
112
  }
105
113
 
106
114
  if (state.phase === "conflicting") {
107
115
  const files = state.conflictFiles?.length ? state.conflictFiles.join(", ") : "unknown files";
108
- pi.sendUserMessage(`🚫 Merge conflicts in: ${files}. Resolve, push, then: xtrm finish`);
116
+ pi.sendUserMessage(
117
+ `⚠ Conflicts in: ${files}. xtrm finish is deprecated for Pi workflow. ` +
118
+ "Resolve conflicts, then continue with publish-only flow.",
119
+ );
109
120
  return undefined;
110
121
  }
111
122
 
112
123
  if (state.phase === "claimed" || state.phase === "phase1-done") {
113
- pi.sendUserMessage(`⚠ Session has an active worktree at ${state.worktreePath}. Consider running: xtrm finish`);
124
+ pi.sendUserMessage(
125
+ `⚠ Session has an active worktree at ${state.worktreePath}. ` +
126
+ "Use publish-only workflow (no automatic push/PR/merge).",
127
+ );
114
128
  }
115
129
  return undefined;
116
130
  });
@@ -12,5 +12,5 @@
12
12
  ],
13
13
  "steeringMode": "all",
14
14
  "followUpMode": "all",
15
- "hideThinkingBlock": false
15
+ "hideThinkingBlock": true
16
16
  }
@@ -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,33 +124,22 @@ 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
- // On bd close: mark as closed-this-session for memory gate (don't clear claim yet)
201
- // Memory gate will clear the claim after user acknowledges memory prompt
135
+ // On bd close: auto-commit staged changes, then mark closed-this-session for memory gate
202
136
  if (/\bbd\s+close\b/.test(command) && commandSucceeded(input)) {
203
137
  const match = command.match(/\bbd\s+close\s+(\S+)/);
204
138
  const closedIssueId = match?.[1];
205
-
139
+
140
+ // Auto-commit before marking the gate (no-op if clean)
141
+ const commit = closedIssueId ? autoCommit(cwd, closedIssueId, command) : null;
142
+
206
143
  // Mark this issue as closed this session (memory gate reads this)
207
144
  if (closedIssueId) {
208
145
  spawnSync('bd', ['kv', 'set', `closed-this-session:${sessionId}`, closedIssueId], {
@@ -212,8 +149,12 @@ function main() {
212
149
  });
213
150
  }
214
151
 
152
+ const commitLine = commit
153
+ ? `\n${commit.ok ? '✅' : '⚠️'} **Session Flow**: ${commit.message}`
154
+ : '';
155
+
215
156
  process.stdout.write(JSON.stringify({
216
- additionalContext: `\n🔓 **Beads**: Issue closed. Memory gate will prompt on session end.`,
157
+ additionalContext: `\n🔓 **Beads**: Issue closed.${commitLine}\nEvaluate insights, then acknowledge:\n \`bd remember "<insight>"\` (or note "nothing")\n \`touch .beads/.memory-gate-done\``,
217
158
  }));
218
159
  process.stdout.write('\n');
219
160
  process.exit(0);
@@ -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