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.
- package/cli/dist/index.cjs +958 -916
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/hooks.json +0 -15
- 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/minimal-mode.ts +201 -0
- 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/config/pi/settings.json.template +1 -1
- package/hooks/beads-claim-sync.mjs +52 -111
- 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 +2 -3
- package/hooks/beads-stop-gate.mjs +1 -52
- package/hooks/branch-state.mjs +16 -28
- package/hooks/guard-rules.mjs +1 -0
- package/hooks/hooks.json +22 -42
- package/hooks/main-guard.mjs +23 -40
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
});
|
|
@@ -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,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}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|