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.
Files changed (31) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +19 -3
  3. package/README.md +13 -37
  4. package/cli/dist/index.cjs +311 -161
  5. package/cli/dist/index.cjs.map +1 -1
  6. package/cli/package.json +1 -1
  7. package/config/instructions/agents-top.md +1 -1
  8. package/config/instructions/claude-top.md +1 -1
  9. package/config/pi/extensions/beads/index.ts +68 -7
  10. package/config/pi/extensions/core/guard-rules.ts +0 -2
  11. package/config/pi/extensions/custom-footer/index.ts +5 -6
  12. package/hooks/beads-claim-sync.mjs +18 -6
  13. package/hooks/beads-gate-messages.mjs +5 -2
  14. package/hooks/beads-memory-gate.mjs +20 -7
  15. package/hooks/statusline.mjs +44 -8
  16. package/package.json +3 -2
  17. package/plugins/xtrm-tools/.claude-plugin/plugin.json +1 -1
  18. package/plugins/xtrm-tools/hooks/beads-claim-sync.mjs +18 -6
  19. package/plugins/xtrm-tools/hooks/beads-gate-messages.mjs +5 -2
  20. package/plugins/xtrm-tools/hooks/beads-memory-gate.mjs +20 -7
  21. package/plugins/xtrm-tools/hooks/statusline.mjs +44 -8
  22. package/plugins/xtrm-tools/skills/sync-docs/SKILL.md +57 -2
  23. package/plugins/xtrm-tools/skills/sync-docs/scripts/drift_detector.py +1 -1
  24. package/plugins/xtrm-tools/skills/sync-docs/scripts/validate_metadata.py +1 -1
  25. package/plugins/xtrm-tools/skills/xt-end/SKILL.md +4 -4
  26. package/plugins/xtrm-tools/skills/xt-merge/SKILL.md +190 -0
  27. package/skills/sync-docs/SKILL.md +57 -2
  28. package/skills/sync-docs/scripts/drift_detector.py +1 -1
  29. package/skills/sync-docs/scripts/validate_metadata.py +1 -1
  30. package/skills/xt-end/SKILL.md +4 -4
  31. package/skills/xt-merge/SKILL.md +190 -0
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -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 `touch .beads/.memory-gate-done` |
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 `touch .beads/.memory-gate-done` |
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
- const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
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 markerPath = join(cwd, ".beads", ".memory-gate-done");
163
- if (existsSync(markerPath)) {
164
- try { unlinkSync(markerPath); } catch { /* ignore */ }
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: \`touch .beads/.memory-gate-done\``,
242
+ ` Then acknowledge: \`bd kv set "memory-gate-done:${sessionId}" 1\``,
182
243
  );
183
244
  };
184
245
 
@@ -64,8 +64,6 @@ export const SAFE_BASH_PREFIXES = [
64
64
  "stat",
65
65
  "du",
66
66
  "tree",
67
- // Allowed writes (specific paths)
68
- "touch .beads/",
69
67
  ];
70
68
 
71
69
  export const DANGEROUS_BASH_PATTERNS = [
@@ -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 prefix = `◐ ${shortId} `;
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?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
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
- ' Then: `touch .beads/.memory-gate-done`\n'
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 by touching this marker, then stops again
27
- const marker = join(cwd, '.beads', '.memory-gate-done');
28
- if (existsSync(marker)) {
29
- try { unlinkSync(marker); } catch { /* ignore */ }
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 pct = ctx?.context_window?.used_percentage;
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
- const claimFile = join(cwd, '.xtrm', 'statusline-claim');
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
- claimTitle = raw ? (JSON.parse(raw)?.[0]?.title ?? null) : null;
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 parts = [`${B}XTRM${B_}`];
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.28",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
4
4
  "description": "xtrm-tools: dual-runtime workflow enforcement (Claude Code + Pi) — hooks, extensions, skills, and MCP servers",
5
5
  "author": {
6
6
  "name": "jaggers"
@@ -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
- ' Then: `touch .beads/.memory-gate-done`\n'
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 by touching this marker, then stops again
27
- const marker = join(cwd, '.beads', '.memory-gate-done');
28
- if (existsSync(marker)) {
29
- try { unlinkSync(marker); } catch { /* ignore */ }
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 pct = ctx?.context_window?.used_percentage;
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
- const claimFile = join(cwd, '.xtrm', 'statusline-claim');
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
- claimTitle = raw ? (JSON.parse(raw)?.[0]?.title ?? null) : null;
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 parts = [`${B}XTRM${B_}`];
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_}`);