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