xtrm-tools 0.5.28 → 0.5.30
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -3
- package/README.md +13 -37
- package/cli/dist/index.cjs +311 -161
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/instructions/agents-top.md +1 -1
- package/config/instructions/claude-top.md +1 -1
- package/config/pi/extensions/beads/index.ts +68 -7
- package/config/pi/extensions/core/guard-rules.ts +0 -2
- package/config/pi/extensions/custom-footer/index.ts +5 -6
- package/hooks/beads-claim-sync.mjs +18 -6
- package/hooks/beads-gate-messages.mjs +5 -2
- package/hooks/beads-memory-gate.mjs +20 -7
- package/hooks/statusline.mjs +44 -8
- package/package.json +3 -2
- package/plugins/xtrm-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/xtrm-tools/hooks/beads-claim-sync.mjs +18 -6
- package/plugins/xtrm-tools/hooks/beads-gate-messages.mjs +5 -2
- package/plugins/xtrm-tools/hooks/beads-memory-gate.mjs +20 -7
- package/plugins/xtrm-tools/hooks/statusline.mjs +44 -8
- package/plugins/xtrm-tools/skills/sync-docs/SKILL.md +57 -2
- package/plugins/xtrm-tools/skills/sync-docs/scripts/drift_detector.py +1 -1
- package/plugins/xtrm-tools/skills/sync-docs/scripts/validate_metadata.py +1 -1
- package/plugins/xtrm-tools/skills/xt-end/SKILL.md +4 -4
- package/plugins/xtrm-tools/skills/xt-merge/SKILL.md +190 -0
- package/skills/sync-docs/SKILL.md +57 -2
- package/skills/sync-docs/scripts/drift_detector.py +1 -1
- package/skills/sync-docs/scripts/validate_metadata.py +1 -1
- package/skills/xt-end/SKILL.md +4 -4
- package/skills/xt-merge/SKILL.md +190 -0
package/cli/package.json
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
| **Edit** | Write/Edit without active claim | `bd update <id> --claim` |
|
|
19
19
|
| **Commit** | `git commit` while claim is open | `bd close <id>` first, then commit |
|
|
20
20
|
| **Stop** | Session end with unclosed claim | `bd close <id>` |
|
|
21
|
-
| **Memory** | Auto-fires at session end if issue closed | `bd remember "<insight>"` then `
|
|
21
|
+
| **Memory** | Auto-fires at session end if issue closed | `bd remember "<insight>"` then run the `bd kv set` command shown in the gate message |
|
|
22
22
|
|
|
23
23
|
> `bd close` auto-commits via `git commit -am`. Do not double-commit after closing.
|
|
24
24
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
| **Edit** | Write/Edit without active claim | `bd update <id> --claim` |
|
|
19
19
|
| **Commit** | `git commit` while claim is open | `bd close <id>` first, then commit |
|
|
20
20
|
| **Stop** | Session end with unclosed claim | `bd close <id>` |
|
|
21
|
-
| **Memory** | Auto-fires at Stop if issue closed this session | `bd remember "<insight>"` then `
|
|
21
|
+
| **Memory** | Auto-fires at Stop if issue closed this session | `bd remember "<insight>"` then run the `bd kv set` command shown in the gate message |
|
|
22
22
|
|
|
23
23
|
> `bd close` auto-commits via `git commit -am`. Do not double-commit after closing.
|
|
24
24
|
|
|
@@ -1,9 +1,62 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { existsSync, unlinkSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
3
|
import { SubprocessRunner, EventAdapter } from "../core/lib";
|
|
6
4
|
|
|
5
|
+
// ─── Autocommit helpers (mirrors hooks/beads-claim-sync.mjs) ─────────────────
|
|
6
|
+
|
|
7
|
+
async function hasGitChanges(cwd: string): Promise<boolean> {
|
|
8
|
+
const result = await SubprocessRunner.run("git", ["status", "--porcelain"], { cwd });
|
|
9
|
+
if (result.code !== 0) return false;
|
|
10
|
+
return result.stdout.trim().length > 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function stageUntracked(cwd: string): Promise<void> {
|
|
14
|
+
const result = await SubprocessRunner.run("git", ["ls-files", "--others", "--exclude-standard"], { cwd });
|
|
15
|
+
if (result.code !== 0 || !result.stdout.trim()) return;
|
|
16
|
+
const untracked = result.stdout.trim().split("\n").filter(Boolean);
|
|
17
|
+
if (untracked.length > 0) {
|
|
18
|
+
await SubprocessRunner.run("git", ["add", "--", ...untracked], { cwd });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function getCloseReason(cwd: string, issueId: string, command: string): Promise<string> {
|
|
23
|
+
// 1. Parse --reason "..." from the command itself
|
|
24
|
+
const reasonMatch = command.match(/--reason[=\s]+["']([^"']+)["']/);
|
|
25
|
+
if (reasonMatch) return reasonMatch[1].trim();
|
|
26
|
+
|
|
27
|
+
// 2. Fall back to bd show <id> --json
|
|
28
|
+
const show = await SubprocessRunner.run("bd", ["show", issueId, "--json"], { cwd });
|
|
29
|
+
if (show.code === 0 && show.stdout.trim()) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(show.stdout);
|
|
32
|
+
const issue = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
33
|
+
const reason = issue?.close_reason;
|
|
34
|
+
if (typeof reason === "string" && reason.trim()) return reason.trim();
|
|
35
|
+
} catch { /* fall through */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `Close ${issueId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function autoCommit(cwd: string, issueId: string, command: string): Promise<{ ok: boolean; message: string }> {
|
|
42
|
+
if (!await hasGitChanges(cwd)) {
|
|
43
|
+
return { ok: true, message: "No changes detected — auto-commit skipped." };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await stageUntracked(cwd);
|
|
47
|
+
|
|
48
|
+
const reason = await getCloseReason(cwd, issueId, command);
|
|
49
|
+
const commitMessage = `${reason} (${issueId})`;
|
|
50
|
+
const result = await SubprocessRunner.run("git", ["commit", "--no-verify", "-am", commitMessage], { cwd });
|
|
51
|
+
|
|
52
|
+
if (result.code !== 0) {
|
|
53
|
+
const err = (result.stderr || result.stdout || "").trim();
|
|
54
|
+
return { ok: false, message: `Auto-commit failed: ${err || "unknown error"}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { ok: true, message: `Auto-committed: \`${commitMessage}\`` };
|
|
58
|
+
}
|
|
59
|
+
|
|
7
60
|
export default function (pi: ExtensionAPI) {
|
|
8
61
|
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
9
62
|
|
|
@@ -141,11 +194,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
141
194
|
if (/\bbd\s+close\b/.test(command) && !event.isError) {
|
|
142
195
|
const closeMatch = command.match(/\bbd\s+close\s+(\S+)/);
|
|
143
196
|
const closedIssueId = closeMatch?.[1] ?? null;
|
|
197
|
+
|
|
198
|
+
// Auto-commit staged changes (mirrors hooks/beads-claim-sync.mjs)
|
|
199
|
+
const commit = closedIssueId ? await autoCommit(cwd, closedIssueId, command) : null;
|
|
200
|
+
|
|
144
201
|
if (closedIssueId) {
|
|
145
202
|
await SubprocessRunner.run("bd", ["kv", "set", `closed-this-session:${sessionId}`, closedIssueId], { cwd });
|
|
146
203
|
memoryGateFired = false;
|
|
147
204
|
}
|
|
148
|
-
|
|
205
|
+
|
|
206
|
+
const commitLine = commit
|
|
207
|
+
? `\n${commit.ok ? "✅" : "⚠️"} **Session Flow**: ${commit.message}`
|
|
208
|
+
: "";
|
|
209
|
+
const reminder = `\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via \`bd remember\`.${commitLine}`;
|
|
149
210
|
return { content: [...event.content, { type: "text", text: reminder }] };
|
|
150
211
|
}
|
|
151
212
|
|
|
@@ -159,9 +220,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
220
|
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
160
221
|
const sessionId = getSessionId(ctx);
|
|
161
222
|
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
|
|
223
|
+
const markerCheck = await SubprocessRunner.run("bd", ["kv", "get", `memory-gate-done:${sessionId}`], { cwd });
|
|
224
|
+
if (markerCheck.code === 0) {
|
|
225
|
+
await SubprocessRunner.run("bd", ["kv", "clear", `memory-gate-done:${sessionId}`], { cwd });
|
|
165
226
|
await clearSessionMarkers(sessionId, cwd);
|
|
166
227
|
memoryGateFired = false;
|
|
167
228
|
return;
|
|
@@ -178,7 +239,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
239
|
`For each closed issue, worth persisting?\n` +
|
|
179
240
|
` YES → \`bd remember "<insight>"\`\n` +
|
|
180
241
|
` NO → note "nothing to persist"\n` +
|
|
181
|
-
` Then acknowledge: \`
|
|
242
|
+
` Then acknowledge: \`bd kv set "memory-gate-done:${sessionId}" 1\``,
|
|
182
243
|
);
|
|
183
244
|
};
|
|
184
245
|
|
|
@@ -228,9 +228,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
228
228
|
};
|
|
229
229
|
|
|
230
230
|
const buildIssueLine = (width: number, theme: any): string => {
|
|
231
|
-
const { shortId, claimTitle, openCount } = beadState;
|
|
232
|
-
if (shortId && claimTitle) {
|
|
233
|
-
const
|
|
231
|
+
const { shortId, claimTitle, status, openCount } = beadState;
|
|
232
|
+
if (shortId && claimTitle && status) {
|
|
233
|
+
const icon = STATUS_ICONS[status] ?? "◐";
|
|
234
|
+
const prefix = `${icon} ${shortId} `;
|
|
234
235
|
const title = theme.fg("muted", claimTitle);
|
|
235
236
|
return truncateToWidth(`${prefix}${title}`, width);
|
|
236
237
|
}
|
|
@@ -284,12 +285,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
284
285
|
const hostStr = theme.fg("muted", runtimeState.host);
|
|
285
286
|
const cwdStr = `${BOLD}${runtimeState.displayDir}${BOLD_OFF}`;
|
|
286
287
|
const venvStr = runtimeState.venv ? theme.fg("muted", `(${runtimeState.venv})`) : "";
|
|
287
|
-
const beadChip = buildBeadChip();
|
|
288
288
|
|
|
289
289
|
const line1Parts = [brand, modelStr, hostStr, cwdStr];
|
|
290
290
|
if (branchStr) line1Parts.push(branchStr);
|
|
291
291
|
if (venvStr) line1Parts.push(venvStr);
|
|
292
|
-
if (beadChip) line1Parts.push(beadChip);
|
|
293
292
|
|
|
294
293
|
const line1 = truncateToWidth(line1Parts.join(" "), width);
|
|
295
294
|
const line2 = buildIssueLine(width, theme);
|
|
@@ -309,7 +308,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
308
|
|
|
310
309
|
pi.on("session_start", async (_event, ctx) => {
|
|
311
310
|
capturedCtx = ctx;
|
|
312
|
-
sessionId = ctx.sessionManager?.getSessionId?.()
|
|
311
|
+
sessionId = ctx.sessionManager?.getSessionId?.() || ctx.sessionId || ctx.session_id || process.pid.toString();
|
|
313
312
|
runtimeState.lastFetch = 0;
|
|
314
313
|
beadState.lastFetch = 0;
|
|
315
314
|
applyCustomFooter(ctx);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
8
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
9
9
|
import { resolveSessionId } from './beads-gate-utils.mjs';
|
|
10
10
|
import { logEvent } from './xtrm-logger.mjs';
|
|
11
11
|
|
|
@@ -21,6 +21,18 @@ function isBeadsProject(cwd) {
|
|
|
21
21
|
return existsSync(join(cwd, '.beads'));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// In a git worktree, --git-common-dir returns an absolute path to the main .git dir.
|
|
25
|
+
// In a regular repo it returns '.git' (relative). Use this to find the canonical main root
|
|
26
|
+
// so claim files are always written/deleted from the same location across sessions.
|
|
27
|
+
function resolveMainRoot(cwd) {
|
|
28
|
+
const r = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
29
|
+
cwd, encoding: 'utf8', stdio: 'pipe',
|
|
30
|
+
});
|
|
31
|
+
const commonDir = r.stdout?.trim();
|
|
32
|
+
if (commonDir && isAbsolute(commonDir)) return dirname(commonDir);
|
|
33
|
+
return cwd;
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
function isShellTool(toolName) {
|
|
25
37
|
return toolName === 'Bash' || toolName === 'bash' || toolName === 'execute_shell_command';
|
|
26
38
|
}
|
|
@@ -97,7 +109,7 @@ function autoCommit(cwd, issueId, command) {
|
|
|
97
109
|
|
|
98
110
|
const reason = getCloseReason(cwd, issueId, command);
|
|
99
111
|
const commitMessage = `${reason} (${issueId})`;
|
|
100
|
-
const result = runGit(['commit', '-am', commitMessage], cwd, 15000);
|
|
112
|
+
const result = runGit(['commit', '--no-verify', '-am', commitMessage], cwd, 15000);
|
|
101
113
|
if (result.status !== 0) {
|
|
102
114
|
const err = (result.stderr || result.stdout || '').trim();
|
|
103
115
|
return { ok: false, message: `Auto-commit failed: ${err || 'unknown error'}` };
|
|
@@ -135,9 +147,9 @@ function main() {
|
|
|
135
147
|
process.exit(0);
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
// Write claim state for statusline
|
|
150
|
+
// Write claim state for statusline — always at main repo root so all sessions share it.
|
|
139
151
|
try {
|
|
140
|
-
const xtrmDir = join(cwd, '.xtrm');
|
|
152
|
+
const xtrmDir = join(resolveMainRoot(cwd), '.xtrm');
|
|
141
153
|
mkdirSync(xtrmDir, { recursive: true });
|
|
142
154
|
writeFileSync(join(xtrmDir, 'statusline-claim'), issueId);
|
|
143
155
|
} catch { /* non-fatal */ }
|
|
@@ -168,8 +180,8 @@ function main() {
|
|
|
168
180
|
// Auto-commit before marking the gate (no-op if clean)
|
|
169
181
|
const commit = closedIssueId ? autoCommit(cwd, closedIssueId, command) : null;
|
|
170
182
|
|
|
171
|
-
// Clear claim state for statusline
|
|
172
|
-
try { unlinkSync(join(cwd, '.xtrm', 'statusline-claim')); } catch { /* ok if missing */ }
|
|
183
|
+
// Clear claim state for statusline — use main root so worktree and main sessions agree.
|
|
184
|
+
try { unlinkSync(join(resolveMainRoot(cwd), '.xtrm', 'statusline-claim')); } catch { /* ok if missing */ }
|
|
173
185
|
|
|
174
186
|
// Mark this issue as closed this session (memory gate reads this)
|
|
175
187
|
if (closedIssueId) {
|
|
@@ -56,13 +56,16 @@ export function stopBlockMessage(summary, claimed) {
|
|
|
56
56
|
|
|
57
57
|
// ── Memory gate messages ─────────────────────────────────────────
|
|
58
58
|
|
|
59
|
-
export function memoryPromptMessage(claimId) {
|
|
59
|
+
export function memoryPromptMessage(claimId, sessionId) {
|
|
60
60
|
const claimLine = claimId ? `claim \`${claimId}\` was closed.\n` : '';
|
|
61
|
+
const ackCmd = sessionId
|
|
62
|
+
? `bd kv set "memory-gate-done:${sessionId}" 1`
|
|
63
|
+
: 'touch .beads/.memory-gate-done';
|
|
61
64
|
return (
|
|
62
65
|
`\u25cf Memory gate: ${claimLine}` +
|
|
63
66
|
'Ask: "Would this be useful in 14 days on a fresh session?"\n' +
|
|
64
67
|
' YES → `bd remember "<insight>"`\n' +
|
|
65
68
|
' NO → note "nothing to persist"\n' +
|
|
66
|
-
|
|
69
|
+
` Then: \`${ackCmd}\`\n`
|
|
67
70
|
);
|
|
68
71
|
}
|
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
// Installed by: xtrm install
|
|
9
9
|
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
|
-
import { existsSync, unlinkSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
11
|
import { readHookInput } from './beads-gate-core.mjs';
|
|
14
12
|
import { resolveCwd, resolveSessionId, isBeadsProject, getSessionClaim, clearSessionClaim } from './beads-gate-utils.mjs';
|
|
15
13
|
import { memoryPromptMessage } from './beads-gate-messages.mjs';
|
|
@@ -23,10 +21,25 @@ if (!cwd || !isBeadsProject(cwd)) process.exit(0);
|
|
|
23
21
|
|
|
24
22
|
const sessionId = resolveSessionId(input);
|
|
25
23
|
|
|
26
|
-
// Agent signals evaluation complete
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
// Agent signals evaluation complete via bd kv (works across worktree redirects)
|
|
25
|
+
let memoryGateDone = false;
|
|
26
|
+
try {
|
|
27
|
+
execSync(`bd kv get "memory-gate-done:${sessionId}"`, {
|
|
28
|
+
cwd,
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
});
|
|
32
|
+
memoryGateDone = true;
|
|
33
|
+
} catch { /* key not set → not done */ }
|
|
34
|
+
|
|
35
|
+
if (memoryGateDone) {
|
|
36
|
+
try {
|
|
37
|
+
execSync(`bd kv clear "memory-gate-done:${sessionId}"`, {
|
|
38
|
+
cwd,
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
timeout: 5000,
|
|
41
|
+
});
|
|
42
|
+
} catch { /* ignore */ }
|
|
30
43
|
// Clear the claim and closed-this-session marker
|
|
31
44
|
clearSessionClaim(sessionId, cwd);
|
|
32
45
|
try {
|
|
@@ -66,7 +79,7 @@ try {
|
|
|
66
79
|
|
|
67
80
|
if (!closedIssueId) process.exit(0);
|
|
68
81
|
|
|
69
|
-
const memoryMessage = memoryPromptMessage();
|
|
82
|
+
const memoryMessage = memoryPromptMessage(closedIssueId, sessionId);
|
|
70
83
|
logEvent({
|
|
71
84
|
cwd,
|
|
72
85
|
runtime: 'claude',
|
package/hooks/statusline.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// Cache: /tmp per cwd, 5s TTL
|
|
9
9
|
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
12
|
-
import { join, basename, relative } from 'node:path';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { join, basename, relative, dirname, isAbsolute } from 'node:path';
|
|
13
13
|
import { tmpdir, hostname } from 'node:os';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
|
|
@@ -48,18 +48,44 @@ const D = '\x1b[2m'; // dim on
|
|
|
48
48
|
const I = '\x1b[3m'; // italic on
|
|
49
49
|
const I_ = '\x1b[23m'; // italic off
|
|
50
50
|
|
|
51
|
+
// True-color gradient for XTRM label: light blue (0%) → gold (50%) → orange-red (100%)
|
|
52
|
+
// Two-segment interpolation avoids the gray dead-zone of direct blue↔orange RGB lerp.
|
|
53
|
+
function xtrmColor(pct) {
|
|
54
|
+
if (pct == null) return null;
|
|
55
|
+
const t = Math.min(Math.max(pct, 0), 100) / 100;
|
|
56
|
+
let r, g, b;
|
|
57
|
+
if (t <= 0.5) {
|
|
58
|
+
const s = t * 2;
|
|
59
|
+
r = Math.round(60 + s * (255 - 60));
|
|
60
|
+
g = Math.round(160 + s * (210 - 160));
|
|
61
|
+
b = Math.round(255 * (1 - s));
|
|
62
|
+
} else {
|
|
63
|
+
const s = (t - 0.5) * 2;
|
|
64
|
+
r = 255;
|
|
65
|
+
g = Math.round(210 - s * 150);
|
|
66
|
+
b = 0;
|
|
67
|
+
}
|
|
68
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// pct is always fresh from ctx (not cached) — needed for the color gradient on every render
|
|
72
|
+
const pct = ctx?.context_window?.used_percentage;
|
|
73
|
+
|
|
51
74
|
let data = getCached();
|
|
52
75
|
if (!data) {
|
|
53
76
|
// Model + token %
|
|
54
77
|
const modelId = ctx?.model?.display_name ?? ctx?.model?.id ?? null;
|
|
55
|
-
const
|
|
56
|
-
const modelStr = modelId ? `${modelId}${pct != null ? ` [${Math.round(pct)}%]` : ''}` : null;
|
|
78
|
+
const modelStr = modelId ?? null;
|
|
57
79
|
|
|
58
80
|
// Short hostname
|
|
59
81
|
const host = hostname().split('.')[0];
|
|
60
82
|
|
|
61
83
|
// Directory — repo-relative like the global script
|
|
62
84
|
const repoRoot = run('git rev-parse --show-toplevel');
|
|
85
|
+
// In a git worktree, --git-common-dir returns an absolute path to the main .git dir.
|
|
86
|
+
// In a regular repo it returns '.git' (relative). Use this to find the canonical main root.
|
|
87
|
+
const gitCommonDir = run('git rev-parse --git-common-dir');
|
|
88
|
+
const mainRoot = (gitCommonDir && isAbsolute(gitCommonDir)) ? dirname(gitCommonDir) : (repoRoot || cwd);
|
|
63
89
|
let displayDir;
|
|
64
90
|
if (repoRoot) {
|
|
65
91
|
const rel = relative(repoRoot, cwd) || '.';
|
|
@@ -98,13 +124,21 @@ if (!data) {
|
|
|
98
124
|
let claimId = null;
|
|
99
125
|
let claimTitle = null;
|
|
100
126
|
let openCount = 0;
|
|
101
|
-
if (existsSync(join(cwd, '.beads'))) {
|
|
102
|
-
|
|
127
|
+
if (existsSync(join(cwd, '.beads')) || existsSync(join(mainRoot, '.beads'))) {
|
|
128
|
+
// Use mainRoot so all sessions (main + worktrees) share one canonical claim file.
|
|
129
|
+
const claimFile = join(mainRoot, '.xtrm', 'statusline-claim');
|
|
103
130
|
claimId = existsSync(claimFile) ? (readFileSync(claimFile, 'utf8').trim() || null) : null;
|
|
104
131
|
if (claimId) {
|
|
105
132
|
try {
|
|
106
133
|
const raw = run(`bd show ${claimId} --json`);
|
|
107
|
-
|
|
134
|
+
const issue = raw ? JSON.parse(raw)?.[0] : null;
|
|
135
|
+
if (issue?.status === 'closed') {
|
|
136
|
+
// Self-heal: remove stale claim file left behind when bd close didn't clean up.
|
|
137
|
+
try { unlinkSync(claimFile); } catch {}
|
|
138
|
+
claimId = null;
|
|
139
|
+
} else {
|
|
140
|
+
claimTitle = issue?.title ?? null;
|
|
141
|
+
}
|
|
108
142
|
} catch {}
|
|
109
143
|
}
|
|
110
144
|
if (!claimTitle) {
|
|
@@ -120,7 +154,9 @@ if (!data) {
|
|
|
120
154
|
const { modelStr, host, displayDir, branch, gitStatus, venv, claimId, claimTitle, openCount } = data;
|
|
121
155
|
|
|
122
156
|
// Line 1 — matches global format, XTRM prepended
|
|
123
|
-
const
|
|
157
|
+
const col = xtrmColor(pct);
|
|
158
|
+
const dot = col ? `${col}●${R}` : '●';
|
|
159
|
+
const parts = [`${dot} ${B}XTRM${B_}`];
|
|
124
160
|
if (modelStr) parts.push(`${D}${modelStr}${R}`);
|
|
125
161
|
parts.push(host);
|
|
126
162
|
if (displayDir) parts.push(`${B}${displayDir}${B_}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xtrm-tools",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.30",
|
|
4
4
|
"description": "Claude Code tools installer (skills, hooks, MCP servers)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"version": "npm run sync:cli-version && git add cli/package.json",
|
|
53
53
|
"prepack": "node scripts/prepack-plugin.mjs",
|
|
54
54
|
"postpack": "node scripts/postpack-plugin.mjs",
|
|
55
|
-
"prepublishOnly": "npm run sync:cli-version && npm run build"
|
|
55
|
+
"prepublishOnly": "npm run sync:cli-version && npm run build",
|
|
56
|
+
"release": "npm publish --tag latest"
|
|
56
57
|
},
|
|
57
58
|
"engines": {
|
|
58
59
|
"node": ">=20.0.0"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
8
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
9
9
|
import { resolveSessionId } from './beads-gate-utils.mjs';
|
|
10
10
|
import { logEvent } from './xtrm-logger.mjs';
|
|
11
11
|
|
|
@@ -21,6 +21,18 @@ function isBeadsProject(cwd) {
|
|
|
21
21
|
return existsSync(join(cwd, '.beads'));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// In a git worktree, --git-common-dir returns an absolute path to the main .git dir.
|
|
25
|
+
// In a regular repo it returns '.git' (relative). Use this to find the canonical main root
|
|
26
|
+
// so claim files are always written/deleted from the same location across sessions.
|
|
27
|
+
function resolveMainRoot(cwd) {
|
|
28
|
+
const r = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
29
|
+
cwd, encoding: 'utf8', stdio: 'pipe',
|
|
30
|
+
});
|
|
31
|
+
const commonDir = r.stdout?.trim();
|
|
32
|
+
if (commonDir && isAbsolute(commonDir)) return dirname(commonDir);
|
|
33
|
+
return cwd;
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
function isShellTool(toolName) {
|
|
25
37
|
return toolName === 'Bash' || toolName === 'bash' || toolName === 'execute_shell_command';
|
|
26
38
|
}
|
|
@@ -97,7 +109,7 @@ function autoCommit(cwd, issueId, command) {
|
|
|
97
109
|
|
|
98
110
|
const reason = getCloseReason(cwd, issueId, command);
|
|
99
111
|
const commitMessage = `${reason} (${issueId})`;
|
|
100
|
-
const result = runGit(['commit', '-am', commitMessage], cwd, 15000);
|
|
112
|
+
const result = runGit(['commit', '--no-verify', '-am', commitMessage], cwd, 15000);
|
|
101
113
|
if (result.status !== 0) {
|
|
102
114
|
const err = (result.stderr || result.stdout || '').trim();
|
|
103
115
|
return { ok: false, message: `Auto-commit failed: ${err || 'unknown error'}` };
|
|
@@ -135,9 +147,9 @@ function main() {
|
|
|
135
147
|
process.exit(0);
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
// Write claim state for statusline
|
|
150
|
+
// Write claim state for statusline — always at main repo root so all sessions share it.
|
|
139
151
|
try {
|
|
140
|
-
const xtrmDir = join(cwd, '.xtrm');
|
|
152
|
+
const xtrmDir = join(resolveMainRoot(cwd), '.xtrm');
|
|
141
153
|
mkdirSync(xtrmDir, { recursive: true });
|
|
142
154
|
writeFileSync(join(xtrmDir, 'statusline-claim'), issueId);
|
|
143
155
|
} catch { /* non-fatal */ }
|
|
@@ -168,8 +180,8 @@ function main() {
|
|
|
168
180
|
// Auto-commit before marking the gate (no-op if clean)
|
|
169
181
|
const commit = closedIssueId ? autoCommit(cwd, closedIssueId, command) : null;
|
|
170
182
|
|
|
171
|
-
// Clear claim state for statusline
|
|
172
|
-
try { unlinkSync(join(cwd, '.xtrm', 'statusline-claim')); } catch { /* ok if missing */ }
|
|
183
|
+
// Clear claim state for statusline — use main root so worktree and main sessions agree.
|
|
184
|
+
try { unlinkSync(join(resolveMainRoot(cwd), '.xtrm', 'statusline-claim')); } catch { /* ok if missing */ }
|
|
173
185
|
|
|
174
186
|
// Mark this issue as closed this session (memory gate reads this)
|
|
175
187
|
if (closedIssueId) {
|
|
@@ -56,13 +56,16 @@ export function stopBlockMessage(summary, claimed) {
|
|
|
56
56
|
|
|
57
57
|
// ── Memory gate messages ─────────────────────────────────────────
|
|
58
58
|
|
|
59
|
-
export function memoryPromptMessage(claimId) {
|
|
59
|
+
export function memoryPromptMessage(claimId, sessionId) {
|
|
60
60
|
const claimLine = claimId ? `claim \`${claimId}\` was closed.\n` : '';
|
|
61
|
+
const ackCmd = sessionId
|
|
62
|
+
? `bd kv set "memory-gate-done:${sessionId}" 1`
|
|
63
|
+
: 'touch .beads/.memory-gate-done';
|
|
61
64
|
return (
|
|
62
65
|
`\u25cf Memory gate: ${claimLine}` +
|
|
63
66
|
'Ask: "Would this be useful in 14 days on a fresh session?"\n' +
|
|
64
67
|
' YES → `bd remember "<insight>"`\n' +
|
|
65
68
|
' NO → note "nothing to persist"\n' +
|
|
66
|
-
|
|
69
|
+
` Then: \`${ackCmd}\`\n`
|
|
67
70
|
);
|
|
68
71
|
}
|
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
// Installed by: xtrm install
|
|
9
9
|
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
|
-
import { existsSync, unlinkSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
11
|
import { readHookInput } from './beads-gate-core.mjs';
|
|
14
12
|
import { resolveCwd, resolveSessionId, isBeadsProject, getSessionClaim, clearSessionClaim } from './beads-gate-utils.mjs';
|
|
15
13
|
import { memoryPromptMessage } from './beads-gate-messages.mjs';
|
|
@@ -23,10 +21,25 @@ if (!cwd || !isBeadsProject(cwd)) process.exit(0);
|
|
|
23
21
|
|
|
24
22
|
const sessionId = resolveSessionId(input);
|
|
25
23
|
|
|
26
|
-
// Agent signals evaluation complete
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
// Agent signals evaluation complete via bd kv (works across worktree redirects)
|
|
25
|
+
let memoryGateDone = false;
|
|
26
|
+
try {
|
|
27
|
+
execSync(`bd kv get "memory-gate-done:${sessionId}"`, {
|
|
28
|
+
cwd,
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
});
|
|
32
|
+
memoryGateDone = true;
|
|
33
|
+
} catch { /* key not set → not done */ }
|
|
34
|
+
|
|
35
|
+
if (memoryGateDone) {
|
|
36
|
+
try {
|
|
37
|
+
execSync(`bd kv clear "memory-gate-done:${sessionId}"`, {
|
|
38
|
+
cwd,
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
timeout: 5000,
|
|
41
|
+
});
|
|
42
|
+
} catch { /* ignore */ }
|
|
30
43
|
// Clear the claim and closed-this-session marker
|
|
31
44
|
clearSessionClaim(sessionId, cwd);
|
|
32
45
|
try {
|
|
@@ -66,7 +79,7 @@ try {
|
|
|
66
79
|
|
|
67
80
|
if (!closedIssueId) process.exit(0);
|
|
68
81
|
|
|
69
|
-
const memoryMessage = memoryPromptMessage();
|
|
82
|
+
const memoryMessage = memoryPromptMessage(closedIssueId, sessionId);
|
|
70
83
|
logEvent({
|
|
71
84
|
cwd,
|
|
72
85
|
runtime: 'claude',
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// Cache: /tmp per cwd, 5s TTL
|
|
9
9
|
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
12
|
-
import { join, basename, relative } from 'node:path';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { join, basename, relative, dirname, isAbsolute } from 'node:path';
|
|
13
13
|
import { tmpdir, hostname } from 'node:os';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
|
|
@@ -48,18 +48,44 @@ const D = '\x1b[2m'; // dim on
|
|
|
48
48
|
const I = '\x1b[3m'; // italic on
|
|
49
49
|
const I_ = '\x1b[23m'; // italic off
|
|
50
50
|
|
|
51
|
+
// True-color gradient for XTRM label: light blue (0%) → gold (50%) → orange-red (100%)
|
|
52
|
+
// Two-segment interpolation avoids the gray dead-zone of direct blue↔orange RGB lerp.
|
|
53
|
+
function xtrmColor(pct) {
|
|
54
|
+
if (pct == null) return null;
|
|
55
|
+
const t = Math.min(Math.max(pct, 0), 100) / 100;
|
|
56
|
+
let r, g, b;
|
|
57
|
+
if (t <= 0.5) {
|
|
58
|
+
const s = t * 2;
|
|
59
|
+
r = Math.round(60 + s * (255 - 60));
|
|
60
|
+
g = Math.round(160 + s * (210 - 160));
|
|
61
|
+
b = Math.round(255 * (1 - s));
|
|
62
|
+
} else {
|
|
63
|
+
const s = (t - 0.5) * 2;
|
|
64
|
+
r = 255;
|
|
65
|
+
g = Math.round(210 - s * 150);
|
|
66
|
+
b = 0;
|
|
67
|
+
}
|
|
68
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// pct is always fresh from ctx (not cached) — needed for the color gradient on every render
|
|
72
|
+
const pct = ctx?.context_window?.used_percentage;
|
|
73
|
+
|
|
51
74
|
let data = getCached();
|
|
52
75
|
if (!data) {
|
|
53
76
|
// Model + token %
|
|
54
77
|
const modelId = ctx?.model?.display_name ?? ctx?.model?.id ?? null;
|
|
55
|
-
const
|
|
56
|
-
const modelStr = modelId ? `${modelId}${pct != null ? ` [${Math.round(pct)}%]` : ''}` : null;
|
|
78
|
+
const modelStr = modelId ?? null;
|
|
57
79
|
|
|
58
80
|
// Short hostname
|
|
59
81
|
const host = hostname().split('.')[0];
|
|
60
82
|
|
|
61
83
|
// Directory — repo-relative like the global script
|
|
62
84
|
const repoRoot = run('git rev-parse --show-toplevel');
|
|
85
|
+
// In a git worktree, --git-common-dir returns an absolute path to the main .git dir.
|
|
86
|
+
// In a regular repo it returns '.git' (relative). Use this to find the canonical main root.
|
|
87
|
+
const gitCommonDir = run('git rev-parse --git-common-dir');
|
|
88
|
+
const mainRoot = (gitCommonDir && isAbsolute(gitCommonDir)) ? dirname(gitCommonDir) : (repoRoot || cwd);
|
|
63
89
|
let displayDir;
|
|
64
90
|
if (repoRoot) {
|
|
65
91
|
const rel = relative(repoRoot, cwd) || '.';
|
|
@@ -98,13 +124,21 @@ if (!data) {
|
|
|
98
124
|
let claimId = null;
|
|
99
125
|
let claimTitle = null;
|
|
100
126
|
let openCount = 0;
|
|
101
|
-
if (existsSync(join(cwd, '.beads'))) {
|
|
102
|
-
|
|
127
|
+
if (existsSync(join(cwd, '.beads')) || existsSync(join(mainRoot, '.beads'))) {
|
|
128
|
+
// Use mainRoot so all sessions (main + worktrees) share one canonical claim file.
|
|
129
|
+
const claimFile = join(mainRoot, '.xtrm', 'statusline-claim');
|
|
103
130
|
claimId = existsSync(claimFile) ? (readFileSync(claimFile, 'utf8').trim() || null) : null;
|
|
104
131
|
if (claimId) {
|
|
105
132
|
try {
|
|
106
133
|
const raw = run(`bd show ${claimId} --json`);
|
|
107
|
-
|
|
134
|
+
const issue = raw ? JSON.parse(raw)?.[0] : null;
|
|
135
|
+
if (issue?.status === 'closed') {
|
|
136
|
+
// Self-heal: remove stale claim file left behind when bd close didn't clean up.
|
|
137
|
+
try { unlinkSync(claimFile); } catch {}
|
|
138
|
+
claimId = null;
|
|
139
|
+
} else {
|
|
140
|
+
claimTitle = issue?.title ?? null;
|
|
141
|
+
}
|
|
108
142
|
} catch {}
|
|
109
143
|
}
|
|
110
144
|
if (!claimTitle) {
|
|
@@ -120,7 +154,9 @@ if (!data) {
|
|
|
120
154
|
const { modelStr, host, displayDir, branch, gitStatus, venv, claimId, claimTitle, openCount } = data;
|
|
121
155
|
|
|
122
156
|
// Line 1 — matches global format, XTRM prepended
|
|
123
|
-
const
|
|
157
|
+
const col = xtrmColor(pct);
|
|
158
|
+
const dot = col ? `${col}●${R}` : '●';
|
|
159
|
+
const parts = [`${dot} ${B}XTRM${B_}`];
|
|
124
160
|
if (modelStr) parts.push(`${D}${modelStr}${R}`);
|
|
125
161
|
parts.push(host);
|
|
126
162
|
if (displayDir) parts.push(`${B}${displayDir}${B_}`);
|